switchroom 0.15.45 → 0.16.4

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 (149) hide show
  1. package/dist/agent-scheduler/index.js +122 -88
  2. package/dist/auth-broker/index.js +463 -177
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +17 -14
  5. package/dist/cli/notion-write-pretool.mjs +117 -86
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/skill-validate-pretool.mjs +72 -72
  9. package/dist/cli/switchroom.js +3158 -1178
  10. package/dist/host-control/main.js +2833 -355
  11. package/dist/vault/approvals/kernel-server.js +7479 -7439
  12. package/dist/vault/broker/server.js +11312 -11272
  13. package/examples/minimal.yaml +1 -0
  14. package/examples/switchroom.yaml +1 -0
  15. package/package.json +3 -3
  16. package/profiles/_base/start.sh.hbs +88 -1
  17. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  18. package/profiles/default/CLAUDE.md.hbs +0 -19
  19. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  20. package/telegram-plugin/answer-stream-flag.ts +12 -49
  21. package/telegram-plugin/answer-stream.ts +5 -150
  22. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  23. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  24. package/telegram-plugin/context-exhaustion.ts +12 -0
  25. package/telegram-plugin/demo-mask.ts +154 -0
  26. package/telegram-plugin/dist/bridge/bridge.js +167 -124
  27. package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
  28. package/telegram-plugin/dist/server.js +215 -172
  29. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  30. package/telegram-plugin/draft-stream.ts +47 -410
  31. package/telegram-plugin/final-answer-detect.ts +17 -12
  32. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  33. package/telegram-plugin/format.ts +56 -19
  34. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  35. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  36. package/telegram-plugin/gateway/auth-command.ts +70 -14
  37. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  38. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  39. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  40. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  41. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  42. package/telegram-plugin/gateway/effort-command.ts +8 -3
  43. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  44. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  45. package/telegram-plugin/gateway/gateway.ts +1837 -291
  46. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  103. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  104. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  105. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  106. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  107. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  108. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  109. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  110. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  111. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  112. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  113. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  114. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  115. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  116. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  117. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  118. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  119. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  120. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  121. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  122. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  123. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  124. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  125. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  126. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  127. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  128. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  129. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  130. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  131. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  132. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  133. package/telegram-plugin/tool-activity-summary.ts +375 -58
  134. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  135. package/telegram-plugin/uat/assertions.ts +115 -0
  136. package/telegram-plugin/uat/driver.ts +68 -0
  137. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  138. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  139. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  145. package/telegram-plugin/welcome-text.ts +13 -1
  146. package/telegram-plugin/worker-activity-feed.ts +157 -82
  147. package/telegram-plugin/draft-transport.ts +0 -122
  148. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  149. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -0,0 +1,188 @@
1
+ /**
2
+ * current-turn-map.ts — per-topic `currentTurn` store behind the
3
+ * emission-authority kill-switch (PR-4e).
4
+ *
5
+ * ## What 4e changes (and, crucially, what it does NOT)
6
+ *
7
+ * The `claude` CLI is genuinely SEQUENTIAL: never two foreground turns
8
+ * executing CPU-simultaneously. So 4e is NOT about concurrent execution.
9
+ * It is about the ISOLATION of per-topic emission STATE.
10
+ *
11
+ * Before 4e the gateway tracked the live foreground turn in ONE ambient
12
+ * module-scope singleton (`let currentTurn`). That is correct for the
13
+ * synchronous-to-the-live-turn reads — but it is WRONG for the LATE async
14
+ * events that fire AFTER the live turn has flipped to a different topic:
15
+ *
16
+ * - a deferred card drain captured for topic A,
17
+ * - the orphaned-reply backstop for A,
18
+ * - an over-ping decision for A,
19
+ * - the answer-stream suppressor for A,
20
+ *
21
+ * any of which can fire AFTER `currentTurn` flipped from A to B. A bare
22
+ * `currentTurn === turnA` liveness check then reads B and FALSIFIES A's own
23
+ * liveness (or, worse, a teardown keyed on the ambient singleton clobbers B's
24
+ * card / ping / single-flight state). 4e keys the store by chat+thread so a
25
+ * late A-captured callback resolves A's authority, never contaminating B.
26
+ *
27
+ * ## Defining property: flag-OFF ≡ flag-ON ≡ base
28
+ *
29
+ * The kill-switch (`SWITCHROOM_EMISSION_AUTHORITY`, default OFF) is read ONCE
30
+ * here, mirroring `emission-authority.ts`'s `EMISSION_AUTHORITY_ENABLED`:
31
+ *
32
+ * - **Flag OFF (default):** the accessors operate on the singleton ONLY.
33
+ * `byKey` is never written (stays empty, zero alloc). Every read returns
34
+ * the singleton. Byte-equivalent to base — there is no map.
35
+ * - **Flag ON:** the per-topic `byKey` map is the source of truth, AND the
36
+ * singleton is retained as a "most-recent-set" MIRROR so every GLOBAL
37
+ * liveness read (`isBusy`, the `if (currentTurn != null) return` poke
38
+ * guards, the orphaned-reply guard) stays byte-identical: under the
39
+ * sequential-CLI invariant the most-recently-set turn IS the live turn,
40
+ * so the mirror answers "is anything live" exactly as the old singleton.
41
+ *
42
+ * No lock is added here. Map ops are synchronous `Map.get/set/delete` — no
43
+ * await, no lock — so the PR-4d no-deadlock invariant is untouched.
44
+ *
45
+ * ## Why a standalone module (not inline in gateway.ts)
46
+ *
47
+ * The gateway IIFE cannot be instantiated in-process, so the multi-topic
48
+ * non-contamination behaviour would be untestable if this lived inline. As a
49
+ * standalone module with a flag-read-once seam (mirroring
50
+ * emission-authority-card-drain-gate.test.ts's `loadFacade` re-import idiom),
51
+ * the per-topic isolation is driven directly in tests/per-topic-current-turn.test.ts.
52
+ */
53
+
54
+ import { chatIdOfChatKey, type ChatKey } from './chat-key.js'
55
+
56
+ /**
57
+ * Kill-switch — read ONCE at module top, `=== '1'` (default OFF), mirroring
58
+ * `emission-authority.ts`'s `EMISSION_AUTHORITY_ENABLED`. The same flag governs
59
+ * both modules: under it OFF the singleton is the only store; under it ON the
60
+ * per-topic map is authoritative with the singleton kept as a most-recent mirror.
61
+ */
62
+ export const EMISSION_AUTHORITY_ENABLED =
63
+ process.env.SWITCHROOM_EMISSION_AUTHORITY === '1'
64
+
65
+ /**
66
+ * Per-topic `currentTurn` store. Generic over the gateway's turn type (which is
67
+ * module-local to gateway.ts and not exported) so this module stays decoupled.
68
+ *
69
+ * - `byKey` is the FLAG-ON store, keyed by `statusKey(chatId, threadId)`.
70
+ * - `singleton` is the FLAG-OFF store AND, under flag-ON, the most-recent-set
71
+ * MIRROR that serves global "is anything live" reads byte-identically.
72
+ *
73
+ * The two thin accessors flag-branch in EXACTLY one place each.
74
+ */
75
+ export class CurrentTurnMap<T> {
76
+ /** Flag-ON store. Never written under flag OFF (stays empty, zero alloc). */
77
+ readonly byKey = new Map<string, T>()
78
+
79
+ /** Flag-OFF store AND flag-ON most-recent-set mirror. */
80
+ private singleton: T | null = null
81
+
82
+ /**
83
+ * Read the live turn.
84
+ *
85
+ * - Flag OFF: the singleton.
86
+ * - Flag ON: `byKey.get(key)` when a key is given (per-topic liveness);
87
+ * otherwise the singleton mirror (global "is anything live" read).
88
+ */
89
+ get(key?: string): T | null {
90
+ if (EMISSION_AUTHORITY_ENABLED) {
91
+ if (key != null) return this.byKey.get(key) ?? null
92
+ return this.singleton
93
+ }
94
+ return this.singleton
95
+ }
96
+
97
+ /**
98
+ * Set the live turn for `key`.
99
+ *
100
+ * - Flag OFF: assign the singleton (key ignored).
101
+ * - Flag ON: set `byKey[key]` AND update the singleton mirror to
102
+ * most-recent-set, so global liveness reads stay byte-identical.
103
+ */
104
+ set(turn: T, key: string): void {
105
+ if (EMISSION_AUTHORITY_ENABLED) {
106
+ this.byKey.set(key, turn)
107
+ this.singleton = turn
108
+ return
109
+ }
110
+ this.singleton = turn
111
+ }
112
+
113
+ /**
114
+ * Is `turn` still the live turn FOR ITS OWN topic `key`?
115
+ *
116
+ * - Flag OFF: `singleton === turn` (the ambient liveness check, verbatim).
117
+ * - Flag ON: `byKey.get(key) === turn` — so a B-flip never falsifies A's
118
+ * own liveness. This is the load-bearing cross-topic isolation read.
119
+ */
120
+ isLiveForKey(turn: T, key: string): boolean {
121
+ if (EMISSION_AUTHORITY_ENABLED) {
122
+ return this.byKey.get(key) === turn
123
+ }
124
+ return this.singleton === turn
125
+ }
126
+
127
+ /**
128
+ * End (delete) `turn` for `key`, iff `key` still maps to `turn` — the
129
+ * leak-close-at-origin used by `endCurrentTurnAtomic` and the silence-poke
130
+ * fallback.
131
+ *
132
+ * - Flag OFF: clear the singleton iff it still points at `turn`.
133
+ * - Flag ON: `byKey.delete(key)` iff `byKey.get(key) === turn`, AND clear
134
+ * the singleton mirror iff it STILL points at `turn` (i.e. no later turn
135
+ * has flipped the mirror). Returns true iff a delete happened.
136
+ */
137
+ endTurnForKey(turn: T, key: string): boolean {
138
+ if (EMISSION_AUTHORITY_ENABLED) {
139
+ if (this.byKey.get(key) !== turn) return false
140
+ this.byKey.delete(key)
141
+ if (this.singleton === turn) this.singleton = null
142
+ return true
143
+ }
144
+ if (this.singleton !== turn) return false
145
+ this.singleton = null
146
+ return true
147
+ }
148
+
149
+ /**
150
+ * Clear EVERYTHING — every entry is a ghost (the disconnect-flush /
151
+ * bridge-died sweep). Drops the whole `byKey` map and the singleton mirror.
152
+ */
153
+ clearAll(): void {
154
+ this.byKey.clear()
155
+ this.singleton = null
156
+ }
157
+
158
+ /**
159
+ * Self-heal backstop: drop any `byKey` entries whose key belongs to `chatId`
160
+ * AND pass the per-sibling `isStale` gate — mirroring `purgeStaleTurnsForChat`
161
+ * (turn-state-purge.ts) exactly, including its `:`-prefix chat match and the
162
+ * one-agent-owns-supergroup safety (a LIVE sibling topic of the same chatId is
163
+ * spared unless it is itself stale). Also clears the singleton mirror iff it
164
+ * pointed at one of the swept entries. No-op under flag OFF (the map is empty).
165
+ *
166
+ * @param isStale per-sibling staleness gate; defaults to always-stale for the
167
+ * DM / single-topic case (every sibling is genuinely dangling), matching the
168
+ * `purgeStaleTurnsForChat` default.
169
+ * @returns the keys swept.
170
+ */
171
+ purgeChatStale(chatId: string, isStale: (key: string) => boolean = () => true): string[] {
172
+ if (!chatId) return []
173
+ const swept: string[] = []
174
+ for (const key of [...this.byKey.keys()]) {
175
+ // A bare chatId (no `:`) cannot belong to a chat+thread keyspace —
176
+ // chatIdOfChatKey returns the whole string in that case, which can never
177
+ // equal `${chatId}:...`-derived keys, so it is correctly skipped.
178
+ if (!key.includes(':')) continue
179
+ if (chatIdOfChatKey(key as ChatKey) !== chatId) continue
180
+ if (!isStale(key)) continue // live sibling topic — leave its turn intact
181
+ const victim = this.byKey.get(key)
182
+ this.byKey.delete(key)
183
+ swept.push(key)
184
+ if (this.singleton === victim) this.singleton = null
185
+ }
186
+ return swept
187
+ }
188
+ }
@@ -157,10 +157,12 @@ export function flushOnAgentDisconnect<
157
157
  // dangling-sweep above for activeTurnStartedAt.
158
158
  if (claudeBusyKeys.size > 0) {
159
159
  const orphanCount = claudeBusyKeys.size
160
+ const orphanKeys = [...claudeBusyKeys]
160
161
  claudeBusyKeys.clear()
161
162
  log(
162
163
  `telegram gateway: disconnect-flush cleared ${orphanCount} orphan claudeBusyKeys ` +
163
- `entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)`,
164
+ `entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)` +
165
+ ` keys=${orphanKeys.join(',')}`,
164
166
  )
165
167
  }
166
168
 
@@ -6,9 +6,14 @@
6
6
  * inline keyboard of the five levels the CLI offers
7
7
  * (`low · medium · high · xhigh · max`, faster→smarter), the live level
8
8
  * marked ✅. A tap types claude's own `/effort <level>` into the agent's
9
- * tmux pane via the allowlisted inject primitive (`/effort` is on the
10
- * inject allowlist) — the Claude-native mechanism: the unmodified CLI's
11
- * REPL command, no API, no SDK, no config mutation.
9
+ * tmux pane via the dedicated `applyEffort` driver
10
+ * (src/agents/effort-picker.ts) — the Claude-native mechanism: the
11
+ * unmodified CLI's REPL command, no API, no SDK, no config mutation.
12
+ * `applyEffort` (NOT the bare inject primitive) is used deliberately: it
13
+ * answers the "Change effort level?" confirmation modal so the pane never
14
+ * wedges. `/effort` is therefore on the inject BLOCKLIST (#2471) — raw
15
+ * `/inject /effort` would leave that modal open — and this command is the
16
+ * only sanctioned path.
12
17
  *
13
18
  * `/effort <level>` does the same non-interactively.
14
19
  *
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Per-foreground-turn EMISSION-AUTHORITY façade (kill-switched no-op seam).
3
+ *
4
+ * This is the SEAM the message-emission-determinism refactor (PR-4 series,
5
+ * `docs/message-emission-determinism.md` §9 "deeper architectural move") routes
6
+ * the foreground-lane card/ping emission call sites through. It mirrors the
7
+ * one-concern-per-file precedent of `feed-open-gate.ts`: a single place that
8
+ * will eventually OWN the WHEN of foreground emission. The gateway constructs
9
+ * exactly ONE instance per foreground turn, alongside the `CurrentTurn` object,
10
+ * and passes the chat/thread key in EXPLICITLY (even though today it is sourced
11
+ * from the `currentTurn` singleton) — that explicit key is the seam PR-4e uses
12
+ * to swap the singleton for a per-topic map. The façade must NOT persist across
13
+ * turns: it is per-turn only, because cross-turn persistence would pre-empt the
14
+ * per-chat scoping decision PR-4e owns.
15
+ *
16
+ * ## PR-4a is a behaviourally-IDENTICAL no-op
17
+ *
18
+ * In PR-4a NO decision logic moves in. Every method is a THIN DELEGATE: it
19
+ * invokes the same statements the call site ran before, via an `apply` thunk
20
+ * the caller hands in (so the load-bearing literals — `drainActivitySummary`,
21
+ * `clearActivitySummary`, `decideOverPing`, the `finalAnswerEverDelivered`
22
+ * latch-set — stay AT THE CALL SITE and remain visible to the source-read
23
+ * wiring oracles). Framing (A) "construct-but-bypass": each method body branches
24
+ * on the kill-switch, and in PR-4a BOTH branches execute the SAME delegate, so
25
+ * the seam is a provable no-op even when the flag is ON. The flag's real teeth
26
+ * (moving `mayOpenActivityCard` / `decideOverPing` decisions INTO the façade)
27
+ * start in PR-4b — NOT here.
28
+ *
29
+ * ## PR-4d deadlock invariant (ship as a standing doc-comment)
30
+ *
31
+ * `mayDrain` is a PURE READ; it must NOT acquire `chatLock`. PR-4d acquires
32
+ * `chatLock` strictly INSIDE the deliver-before-drain gate decision (gateway.ts
33
+ * component-1, ref ~`:2730`), never the reverse, because a synthetic represent
34
+ * turn starts `finalAnswerDelivered=false` and would wedge the gate (ref
35
+ * ~`:1646`) if a card OPEN held the lock across the gate's release. Keep
36
+ * `mayDrain` lock-free so PR-4d has a clean read to build on.
37
+ *
38
+ * ## PR-4d ships: `mayDrainCardNow` unifies the card-drain path with the #2137
39
+ * deliver-before-drain gate
40
+ *
41
+ * PR-4d adds `mayDrainCardNow` — the card-path counterpart of the deliver-before-
42
+ * drain decision in `serialize-drain-gate.ts`. It is ALSO a pure, lock-free read:
43
+ *
44
+ * - Flag OFF (default): returns exactly the `mayDrain` single-flight read —
45
+ * byte-equivalent to base. The card single-flight gate is unchanged.
46
+ * - Flag ON: returns the `mayDrain` read AND-ed with `mayDrainBufferedInbound`,
47
+ * importing the PURE `mayDrainBufferedInbound` from `./serialize-drain-gate.js`
48
+ * (the façade imports the pure helper, NEVER the gateway). The card path
49
+ * threads `endingTurnFinalAnswerDelivered: null` so the deliver-before-drain
50
+ * predicate degenerates to `!turnInFlight`: the card single-flight is governed
51
+ * by `activityInFlight` (via `mayDrain`), NOT by an ending turn's delivery
52
+ * state. A synthetic represent turn (finalAnswerDelivered=false) therefore can
53
+ * never wedge the live foreground card path.
54
+ *
55
+ * The LOCK is acquired by the GATEWAY around the gate decision (a centralized
56
+ * `chatLock.run(statusKey(...), …)` helper that consults this method then kicks
57
+ * off `openOrEditCard`). `mayDrainCardNow` itself, like `mayDrain`, acquires NO
58
+ * lock — it is a pure read the gateway calls from inside the lock. The lock spans
59
+ * only the gate decision + the synchronous `openOrEditCard` kick-off (which only
60
+ * ASSIGNS `turn.activityInFlight = drainActivitySummary(...)`; the async send is
61
+ * NOT awaited inside the lock), so a card OPEN never holds `chatLock` across the
62
+ * gate's release.
63
+ */
64
+
65
+ import {
66
+ computeFeedOpenVerdict,
67
+ type FeedOpenGateView,
68
+ type FeedOpenGateDeps,
69
+ } from './feed-open-gate.js'
70
+ import {
71
+ decideOverPing,
72
+ type OverPingDecision,
73
+ } from '../over-ping-safety-net.js'
74
+ import { mayDrainBufferedInbound } from './serialize-drain-gate.js'
75
+
76
+ /**
77
+ * Kill-switch. Read ONCE at module top, `=== '1'` (default OFF), following the
78
+ * existing flag convention (`SWITCHROOM_FEED_HEARTBEAT` etc. in gateway.ts's
79
+ * flag region). In PR-4a this gates nothing behaviourally — both branches of
80
+ * every façade method run the same delegate — so unset and set are identical.
81
+ * It exists now so the seam is in place; PR-4b is where ON starts to differ.
82
+ */
83
+ export const EMISSION_AUTHORITY_ENABLED =
84
+ process.env.SWITCHROOM_EMISSION_AUTHORITY === '1'
85
+
86
+ /** Which producer triggered a card OPEN/EDIT — carried for the PR-4b seam. */
87
+ export type EmissionProducer = 'narrative' | 'tool' | 'liveness'
88
+
89
+ /**
90
+ * The minimal per-turn surface the façade needs. A structural subset of
91
+ * `CurrentTurn` — keeps this module decoupled from gateway.ts's giant turn type
92
+ * (and out of an import cycle) while still typing the reads it performs.
93
+ */
94
+ export interface EmissionTurnView {
95
+ /** Single-flight slot: a drain Promise is in flight when non-null. */
96
+ activityInFlight: Promise<void> | null
97
+ }
98
+
99
+ /** Inputs to the over-ping claim/downgrade decision (carried for PR-4b). */
100
+ export interface PingClaimInput {
101
+ /** The model asked for a device ping (`disable_notification: false`). */
102
+ modelRequestedPing: boolean
103
+ /** This reply is a substantive final answer (`isSubstantiveFinalReply`). */
104
+ substantive: boolean
105
+ }
106
+
107
+ /**
108
+ * The live per-turn ping-slot state the over-ping decision reads (PR-4c seam).
109
+ *
110
+ * A structural subset of `CurrentTurn` threaded in EXPLICITLY so the façade can
111
+ * call `decideOverPing` in its enabled branch without importing gateway.ts's
112
+ * turn type. These are READS only — the façade decides; the call-site
113
+ * `applyDecision` thunk performs the atomic `firstPingAt`/`firstPingWasSubstantive`
114
+ * pair-set (the #2562 atomicity invariant: two adjacent assignments, no await
115
+ * between, so a racing second reply reads a consistent pair).
116
+ */
117
+ export interface PingClaimCtx {
118
+ /** Wall-clock ms of the FIRST ping this turn, or null if none has landed. */
119
+ firstPingAt: number | null
120
+ /** True iff the send that CLAIMED the slot was itself a substantive answer. */
121
+ firstPingWasSubstantive: boolean
122
+ /** Deterministic clock for the decision (the call site's `Date.now()`). */
123
+ nowMs: number
124
+ }
125
+
126
+ /**
127
+ * The deliver-before-drain inputs the PR-4d card-drain gate threads into the
128
+ * pure `mayDrainBufferedInbound` predicate. A small structural subset (mirrors
129
+ * the `PingClaimCtx` / `FeedOpenGateDeps` shape) so the façade never imports the
130
+ * gateway's turn type — the gateway sources these and passes them in.
131
+ *
132
+ * `endingTurnFinalAnswerDelivered` is fixed to `null` on the card path (§5
133
+ * modeling decision): the predicate degenerates to `!turnInFlight`, so the card
134
+ * single-flight is governed by `activityInFlight` (via `mayDrain`), not by an
135
+ * ending turn's delivery state — a synthetic represent turn cannot wedge it.
136
+ */
137
+ export interface CardDrainGateCtx {
138
+ /** A turn is in flight RIGHT NOW (`turnInFlightForGate()`), evaluated at the
139
+ * gate site. When true, never drain the card — claude is busy. */
140
+ turnInFlight: boolean
141
+ /** The ending turn's `finalAnswerDelivered`. Threaded `null` for the CARD
142
+ * path so the deliver-before-drain predicate degenerates to `!turnInFlight`
143
+ * (the card single-flight is governed by `activityInFlight`, not delivery). */
144
+ endingTurnFinalAnswerDelivered: boolean | null
145
+ /** Serialize-until-replied kill-switch state (`SERIALIZE_UNTIL_REPLIED_ENABLED`). */
146
+ enabled: boolean
147
+ }
148
+
149
+ /**
150
+ * Per-foreground-turn emission-authority façade.
151
+ *
152
+ * Constructed once per turn with the chat/thread key passed in explicitly (the
153
+ * PR-4e seam). In PR-4a every method just runs the caller's delegate — no
154
+ * decision logic lives here yet.
155
+ */
156
+ export class EmissionAuthority {
157
+ /**
158
+ * @param chatKey The chat/thread key this façade governs, passed in
159
+ * EXPLICITLY by the gateway (today sourced from the `currentTurn`
160
+ * singleton). PR-4e swaps the singleton for a per-topic map keyed on this.
161
+ * Unused in PR-4a beyond being held — it is the seam, not yet a decision
162
+ * input.
163
+ */
164
+ constructor(private readonly chatKey: string) {}
165
+
166
+ /**
167
+ * PR-4b OPEN-gate wiring. The gateway centralizes it in `emissionAuthorityFor`
168
+ * (one place) so the 6 `openOrEditCard` call sites stay byte-identical
169
+ * `(producer, apply)` — the façade reads the live turn view + injected history
170
+ * deps from HERE, not per-call. `viewProvider` is a thunk so the verdict reads
171
+ * the CURRENT card/latch/tool-count at gate time (they mutate during the turn).
172
+ * Lazily set on every `emissionAuthorityFor`; the disabled branch never reads
173
+ * it, so a turn that never wires it is harmless (only `openOrEditCard`'s
174
+ * EMISSION_AUTHORITY_ENABLED branch consults it).
175
+ */
176
+ private feedOpenView?: () => FeedOpenGateView
177
+ private feedOpenDeps?: FeedOpenGateDeps
178
+
179
+ /**
180
+ * Wire the PR-4b OPEN-gate inputs (idempotent). Called by the gateway's
181
+ * `emissionAuthorityFor` accessor so every routed turn's façade can compute
182
+ * the open verdict in its enabled branch. No-op-equivalent under the flag OFF.
183
+ */
184
+ wireFeedOpenGate(
185
+ viewProvider: () => FeedOpenGateView,
186
+ deps: FeedOpenGateDeps,
187
+ ): void {
188
+ this.feedOpenView = viewProvider
189
+ this.feedOpenDeps = deps
190
+ }
191
+
192
+ /** The chat/thread key this façade was constructed for (PR-4e seam read). */
193
+ get key(): string {
194
+ return this.chatKey
195
+ }
196
+
197
+ /**
198
+ * Card OPEN-or-EDIT for the foreground turn (delegates to
199
+ * `drainActivitySummary` at the call site via `apply`).
200
+ *
201
+ * PR-4b — the OPEN-gate decision RELOCATES here, behind the kill-switch:
202
+ *
203
+ * - **Disabled branch (default):** unchanged from PR-4a — `apply()` runs
204
+ * unconditionally. The drain's own inline OPEN gate is what refuses (and
205
+ * `break`s) a disallowed OPEN, exactly as on main. Byte-identical to main.
206
+ * - **Enabled branch:** compute the OPEN verdict from the live turn view +
207
+ * injected history deps (`computeFeedOpenVerdict`) and run `apply()` IFF
208
+ * `!(isOpen && !mayOpen)` is FALSE-y in the refuse sense — concretely
209
+ * `apply()` runs iff `isOpen || mayOpen`. That is EXACTLY the set of cases
210
+ * main did NOT `break` (main refuses iff `activityMessageId == null &&
211
+ * !mayOpenActivityCard(...)`, i.e. `!isOpen && !mayOpen`). Same pure inputs
212
+ * ⇒ same verdict, so flag-ON ≡ flag-OFF ≡ main: no emitted message differs.
213
+ *
214
+ * On flag-ON the drain still re-evaluates the SAME gate (now redundant) — an
215
+ * intentional, safe double-check: both are pure over identical inputs, and it
216
+ * also covers the documented in-flight-send race (a send already PAST the
217
+ * drain's check). A refused OPEN never calls `apply()`, so `activityInFlight`
218
+ * stays null and the pending render stays unadvanced — matching main's `break`
219
+ * + `finally` end-state. The delegate keeps the `turn.activityInFlight =
220
+ * drainActivitySummary(turn, <producer>)` assignment AT THE CALL SITE so the
221
+ * source-read wiring oracles still see it.
222
+ */
223
+ openOrEditCard(
224
+ producer: EmissionProducer,
225
+ apply: () => void,
226
+ ): void {
227
+ if (EMISSION_AUTHORITY_ENABLED) {
228
+ // The OPEN-gate inputs are wired by `emissionAuthorityFor` (centralized).
229
+ // If a turn somehow reaches here unwired, fall back to delegating (no
230
+ // behaviour change — the drain's own gate still governs the OPEN).
231
+ if (this.feedOpenView != null && this.feedOpenDeps != null) {
232
+ const { isOpen, mayOpen } = computeFeedOpenVerdict(
233
+ this.feedOpenView(),
234
+ producer,
235
+ this.feedOpenDeps,
236
+ )
237
+ // Refuse (skip apply) iff main would have `break`-refused the OPEN:
238
+ // `!isOpen && !mayOpen`. Apply otherwise (an EDIT — isOpen — is never
239
+ // gated; an OPEN-eligible producer — mayOpen — opens).
240
+ if (!isOpen && !mayOpen) return
241
+ }
242
+ apply()
243
+ return
244
+ }
245
+ apply()
246
+ }
247
+
248
+ /**
249
+ * Finalize the card before a substantive reply send (delegates to
250
+ * `clearActivitySummary` at the call site via `apply`).
251
+ *
252
+ * PR-4a: a pure pass-through. Both branches run `apply`.
253
+ */
254
+ finalizeCard(apply: () => void): void {
255
+ if (EMISSION_AUTHORITY_ENABLED) {
256
+ apply()
257
+ return
258
+ }
259
+ apply()
260
+ }
261
+
262
+ /**
263
+ * Mark that a substantive final answer was delivered this turn — the sticky
264
+ * lever-1 latch (delegates to the `finalAnswerEverDelivered = true` set at the
265
+ * call site via `apply`).
266
+ *
267
+ * PR-4a: a pure pass-through. Both branches run `apply`.
268
+ */
269
+ markSubstantiveFinalDelivered(apply: () => void): void {
270
+ if (EMISSION_AUTHORITY_ENABLED) {
271
+ apply()
272
+ return
273
+ }
274
+ apply()
275
+ }
276
+
277
+ /**
278
+ * Claim-or-downgrade the turn's one notification ping.
279
+ *
280
+ * PR-4c — the over-ping DECISION relocates here, behind the kill-switch, the
281
+ * same structural way PR-4b moved the OPEN gate. Nothing new is extracted:
282
+ * `decideOverPing` is already a pure predicate in `over-ping-safety-net.ts`;
283
+ * PR-4c only relocates the *call* into the enabled branch and keeps the
284
+ * *effects* (stderr, metric, the atomic `firstPingAt`/`firstPingWasSubstantive`
285
+ * pair-set, the `disableNotification`/`wasOverPingSuppressed` closure writes)
286
+ * at the call site, parameterized by the decision the façade hands back.
287
+ *
288
+ * - **Disabled branch (default):** unchanged from PR-4a — `disabled()` runs,
289
+ * and that thunk holds its OWN literal `decideOverPing(...)` call + the full
290
+ * effects block, VERBATIM from PR-4b-base. The disabled path is therefore
291
+ * provably byte-identical to base: nothing about the decision moves out of
292
+ * the call site when the flag is OFF.
293
+ * - **Enabled branch:** compute the decision HERE via `decideOverPing` (the
294
+ * SAME pure predicate, the SAME inputs threaded through `ctx` + `input`) and
295
+ * hand it to `applyDecision(decision)`, which performs the identical effects
296
+ * at the call site. Same pure inputs ⇒ same decision ⇒ flag-ON ≡ flag-OFF ≡
297
+ * base: not one device ping differs.
298
+ *
299
+ * SYNCHRONOUS by contract — no `async`, no `await`. The
300
+ * `decideOverPing → applyDecision → pair-set` chain runs in one synchronous
301
+ * block so the #2562 atomicity invariant holds across the façade boundary: the
302
+ * façade decides whether to claim/upgrade; the call-site `applyDecision` thunk
303
+ * performs the two-adjacent-line `firstPingAt`/`firstPingWasSubstantive`
304
+ * pair-set with no await between.
305
+ */
306
+ claimOrDowngradePing(
307
+ input: PingClaimInput,
308
+ ctx: PingClaimCtx,
309
+ applyDecision: (decision: OverPingDecision) => void,
310
+ disabled: () => void,
311
+ ): void {
312
+ if (EMISSION_AUTHORITY_ENABLED) {
313
+ const decision = decideOverPing({
314
+ modelRequestedPing: input.modelRequestedPing,
315
+ firstPingAt: ctx.firstPingAt,
316
+ substantive: input.substantive,
317
+ firstPingWasSubstantive: ctx.firstPingWasSubstantive,
318
+ nowMs: ctx.nowMs,
319
+ })
320
+ applyDecision(decision)
321
+ return
322
+ }
323
+ disabled()
324
+ }
325
+
326
+ /**
327
+ * Single-flight read: may a drain start? Returns `turn.activityInFlight ==
328
+ * null` — the EXACT guard every drain call site already performs.
329
+ *
330
+ * PURE READ, NO lock acquisition (see the PR-4d deadlock invariant in this
331
+ * module's header). PR-4d needs this read clean so it can acquire `chatLock`
332
+ * elsewhere without the lattice inverting.
333
+ */
334
+ mayDrain(turn: EmissionTurnView): boolean {
335
+ return turn.activityInFlight == null
336
+ }
337
+
338
+ /**
339
+ * PR-4d card-drain gate: may the card drain start RIGHT NOW, unifying the
340
+ * single-flight read with the #2137 deliver-before-drain serialization gate.
341
+ *
342
+ * - **Flag OFF (default):** returns exactly `this.mayDrain(turn)` — the card
343
+ * single-flight read, byte-equivalent to base. Nothing else is consulted.
344
+ * - **Flag ON:** returns `this.mayDrain(turn) && mayDrainBufferedInbound(ctx)`
345
+ * — the single-flight read AND the pure deliver-before-drain predicate
346
+ * (imported from `./serialize-drain-gate.js`). The gateway threads
347
+ * `endingTurnFinalAnswerDelivered: null` for the card path (§5 modeling
348
+ * decision), so `mayDrainBufferedInbound` degenerates to `!turnInFlight`:
349
+ * the card single-flight stays governed by `activityInFlight` (via
350
+ * `mayDrain`), NOT by an ending turn's delivery state.
351
+ *
352
+ * PURE READ — see the PR-4d deadlock invariant in this module's header. The
353
+ * GATEWAY serializes this decision via the chat mutex AROUND the call; this
354
+ * method only reads. Routes the single-flight via the pure `this.mayDrain`.
355
+ */
356
+ mayDrainCardNow(turn: EmissionTurnView, ctx: CardDrainGateCtx): boolean {
357
+ if (EMISSION_AUTHORITY_ENABLED) {
358
+ return (
359
+ this.mayDrain(turn) &&
360
+ mayDrainBufferedInbound({
361
+ turnInFlight: ctx.turnInFlight,
362
+ endingTurnFinalAnswerDelivered: ctx.endingTurnFinalAnswerDelivered,
363
+ enabled: ctx.enabled,
364
+ })
365
+ )
366
+ }
367
+ return this.mayDrain(turn)
368
+ }
369
+ }