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.
@@ -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
+ }
@@ -65,6 +65,8 @@ export type ParsedAuthCommand =
65
65
  replace: boolean
66
66
  /** Google `--write` (Drive write scope). */
67
67
  write: boolean
68
+ /** Google `--calendar` (read-only Calendar scope). */
69
+ calendar: boolean
68
70
  /** Microsoft `--org-mode`. */
69
71
  orgMode: boolean
70
72
  }
@@ -251,7 +253,7 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
251
253
  * wired; anything else is a help-with-reason so the operator sees the shape.
252
254
  *
253
255
  * Flags mirror the CLI:
254
- * google: `add <email> [--replace] [--write]`
256
+ * google: `add <email> [--replace] [--write] [--calendar]`
255
257
  * microsoft: `add <email> [--replace] [--org-mode]`
256
258
  */
257
259
  export function parseProviderVerb(
@@ -265,7 +267,7 @@ export function parseProviderVerb(
265
267
  if (sub !== 'add') {
266
268
  const usage =
267
269
  provider === 'google'
268
- ? 'Usage: /auth google add <email> [--replace] [--write]'
270
+ ? 'Usage: /auth google add <email> [--replace] [--write] [--calendar]'
269
271
  : 'Usage: /auth microsoft add <email> [--replace] [--org-mode]'
270
272
  return {
271
273
  kind: 'help',
@@ -280,7 +282,7 @@ export function parseProviderVerb(
280
282
  if (!email) {
281
283
  const usage =
282
284
  provider === 'google'
283
- ? 'Usage: /auth google add <email> [--replace] [--write]'
285
+ ? 'Usage: /auth google add <email> [--replace] [--write] [--calendar]'
284
286
  : 'Usage: /auth microsoft add <email> [--replace] [--org-mode]'
285
287
  return { kind: 'help', reason: usage }
286
288
  }
@@ -288,7 +290,7 @@ export function parseProviderVerb(
288
290
  if (emailErr) return { kind: 'help', reason: emailErr }
289
291
  // Reject unknown flags so a typo (`--replaced`) isn't silently dropped.
290
292
  const allowed = provider === 'google'
291
- ? new Set(['--replace', '--write'])
293
+ ? new Set(['--replace', '--write', '--calendar'])
292
294
  : new Set(['--replace', '--org-mode'])
293
295
  for (const f of flags) {
294
296
  if (!allowed.has(f)) {
@@ -304,6 +306,7 @@ export function parseProviderVerb(
304
306
  email,
305
307
  replace: flags.has('--replace'),
306
308
  write: provider === 'google' && flags.has('--write'),
309
+ calendar: provider === 'google' && flags.has('--calendar'),
307
310
  orgMode: provider === 'microsoft' && flags.has('--org-mode'),
308
311
  }
309
312
  }
@@ -84,6 +84,8 @@ export interface SpawnRelayOpts {
84
84
  replace?: boolean
85
85
  /** Google `--write` (Drive write scope). */
86
86
  write?: boolean
87
+ /** Google `--calendar` (read-only Calendar scope). */
88
+ calendar?: boolean
87
89
  /** Microsoft `--org-mode`. */
88
90
  orgMode?: boolean
89
91
  /** Override the CLI binary (tests / non-default install). */
@@ -96,6 +98,42 @@ export type SpawnRelay = (
96
98
  opts: SpawnRelayOpts,
97
99
  ) => RelayChild
98
100
 
101
+ /**
102
+ * Build the `switchroom auth <provider> account add` argv the relay spawns.
103
+ *
104
+ * Pure + exported so the flag plumbing is unit-pinned: every opt-in flag
105
+ * the chat command accepts has to actually reach the CLI, and a flag that
106
+ * was NOT requested must never appear (the relay is the only path most
107
+ * operators use, so a dropped `--calendar` here is indistinguishable from
108
+ * the feature not existing, and a spurious one silently widens a grant).
109
+ */
110
+ export function buildRelayArgs(
111
+ provider: LoopbackProvider,
112
+ email: string,
113
+ opts: SpawnRelayOpts,
114
+ ): string[] {
115
+ return provider === 'google'
116
+ ? [
117
+ 'auth',
118
+ 'google',
119
+ 'account',
120
+ 'add',
121
+ email,
122
+ ...(opts.replace ? ['--replace'] : []),
123
+ ...(opts.write ? ['--write'] : []),
124
+ ...(opts.calendar ? ['--calendar'] : []),
125
+ ]
126
+ : [
127
+ 'auth',
128
+ 'microsoft',
129
+ 'account',
130
+ 'add',
131
+ email,
132
+ ...(opts.replace ? ['--replace'] : []),
133
+ ...(opts.orgMode ? ['--org-mode'] : []),
134
+ ]
135
+ }
136
+
99
137
  /**
100
138
  * Default spawn: the real `switchroom` CLI, stdout+stderr piped, stdin
101
139
  * ignored. `BROWSER=/bin/true` suppresses any browser auto-open; the Drive
@@ -109,26 +147,7 @@ export function defaultSpawnRelay(
109
147
  opts: SpawnRelayOpts,
110
148
  ): RelayChild {
111
149
  const binary = opts.binary ?? 'switchroom'
112
- const args =
113
- provider === 'google'
114
- ? [
115
- 'auth',
116
- 'google',
117
- 'account',
118
- 'add',
119
- email,
120
- ...(opts.replace ? ['--replace'] : []),
121
- ...(opts.write ? ['--write'] : []),
122
- ]
123
- : [
124
- 'auth',
125
- 'microsoft',
126
- 'account',
127
- 'add',
128
- email,
129
- ...(opts.replace ? ['--replace'] : []),
130
- ...(opts.orgMode ? ['--org-mode'] : []),
131
- ]
150
+ const args = buildRelayArgs(provider, email, opts)
132
151
  const child: ChildProcess = spawn(binary, args, {
133
152
  stdio: ['ignore', 'pipe', 'pipe'],
134
153
  env: {
@@ -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