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,236 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { mayDrainBufferedInbound } from '../gateway/serialize-drain-gate.js'
3
+ import type { EmissionTurnView, CardDrainGateCtx } from '../gateway/emission-authority.js'
4
+
5
+ /**
6
+ * PR-4d flag-parity proof — the card-drain gate at the façade layer.
7
+ *
8
+ * The emission-authority façade's NEW `mayDrainCardNow` unifies the card-drain
9
+ * single-flight read with the #2137 deliver-before-drain serialization gate,
10
+ * behind the `SWITCHROOM_EMISSION_AUTHORITY` kill-switch. The defining
11
+ * correctness property: flag-OFF ≡ flag-ON ≡ base — the verdict must equal the
12
+ * base single-flight read (`mayDrain(turn)`) on the live foreground card path,
13
+ * for BOTH flag states, across the full input cross-product.
14
+ *
15
+ * §5 modeling decision (the highest-risk call): the gateway threads
16
+ * `endingTurnFinalAnswerDelivered: null` for the CARD path, so
17
+ * `mayDrainBufferedInbound` degenerates to `!turnInFlight`. The card
18
+ * single-flight is governed by `activityInFlight` (via `mayDrain`), NOT by an
19
+ * ending turn's delivery state — so a synthetic represent turn
20
+ * (finalAnswerDelivered=false) can never wedge the live foreground card path.
21
+ *
22
+ * The flag is read ONCE at module top (the asserted read-once invariant). We
23
+ * flip it per flag state by dynamically re-importing the façade module under a
24
+ * unique query string (bun re-evaluates the module, so the read-once const is
25
+ * recomputed against the chosen env). This is a TEST-ONLY seam — it does not
26
+ * touch the production read-once path. Mirrors emission-authority-open-gate.test.ts.
27
+ */
28
+
29
+ afterEach(() => {
30
+ delete process.env.SWITCHROOM_EMISSION_AUTHORITY
31
+ })
32
+
33
+ let reimportSeq = 0
34
+ /**
35
+ * Re-import the façade with the flag set as requested. Bun re-evaluates a module
36
+ * imported under a fresh query string, so the read-once
37
+ * `EMISSION_AUTHORITY_ENABLED` const is recomputed against the env we set here —
38
+ * a test-only seam that leaves the production read-once path untouched.
39
+ */
40
+ async function loadFacade(enabled: boolean): Promise<typeof import('../gateway/emission-authority.js')> {
41
+ if (enabled) process.env.SWITCHROOM_EMISSION_AUTHORITY = '1'
42
+ else delete process.env.SWITCHROOM_EMISSION_AUTHORITY
43
+ return import(`../gateway/emission-authority.js?carddraincase=${reimportSeq++}`)
44
+ }
45
+
46
+ // The full cross-product of card-drain-gate inputs.
47
+ type Case = {
48
+ /** Single-flight: a drain Promise is in flight (non-null) ⇒ may NOT drain. */
49
+ activityInFlight: boolean
50
+ /** turnInFlightForGate() — claude busy. */
51
+ turnInFlight: boolean
52
+ /** The ending turn's finalAnswerDelivered, as the gate would receive it. */
53
+ endingTurnFinalAnswerDelivered: boolean | null | undefined
54
+ /** SERIALIZE_UNTIL_REPLIED_ENABLED. */
55
+ enabled: boolean
56
+ }
57
+
58
+ function* cases(): Generator<Case> {
59
+ for (const activityInFlight of [false, true]) {
60
+ for (const turnInFlight of [false, true]) {
61
+ for (const endingTurnFinalAnswerDelivered of [true, false, null, undefined] as (
62
+ | boolean
63
+ | null
64
+ | undefined
65
+ )[]) {
66
+ for (const enabled of [false, true]) {
67
+ yield { activityInFlight, turnInFlight, endingTurnFinalAnswerDelivered, enabled }
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ function turnFor(c: Case): EmissionTurnView {
75
+ return { activityInFlight: c.activityInFlight ? Promise.resolve() : null }
76
+ }
77
+
78
+ /**
79
+ * The CARD-path ctx the gateway threads. The §5 decision: the card path always
80
+ * passes `endingTurnFinalAnswerDelivered: null` (NOT the case's raw value — the
81
+ * raw value is swept only to prove the card path is insensitive to it). The
82
+ * single-flight is governed by `activityInFlight`, not delivery state.
83
+ */
84
+ function cardCtxFor(c: Case): CardDrainGateCtx {
85
+ return {
86
+ turnInFlight: c.turnInFlight,
87
+ endingTurnFinalAnswerDelivered: null,
88
+ enabled: c.enabled,
89
+ }
90
+ }
91
+
92
+ /** The base oracle: the single-flight read every drain site already performs. */
93
+ function baseMayDrain(c: Case): boolean {
94
+ return turnFor(c).activityInFlight == null
95
+ }
96
+
97
+ function keyOf(c: Case): string {
98
+ return `inflight=${c.activityInFlight} turnInFlight=${c.turnInFlight} delivered=${String(
99
+ c.endingTurnFinalAnswerDelivered,
100
+ )} serialize=${c.enabled}`
101
+ }
102
+
103
+ describe('mayDrainCardNow flag-parity — flag OFF ≡ flag ON ≡ base on the foreground card path', () => {
104
+ it('flag OFF: verdict == base single-flight read (mayDrain) for EVERY input', async () => {
105
+ const { EmissionAuthority } = await loadFacade(false)
106
+ for (const c of cases()) {
107
+ const ea = new EmissionAuthority('k')
108
+ const verdict = ea.mayDrainCardNow(turnFor(c), cardCtxFor(c))
109
+ expect(verdict, keyOf(c)).toBe(baseMayDrain(c))
110
+ // OFF is byte-equivalent to plain mayDrain (no deliver-before-drain term).
111
+ expect(verdict, keyOf(c)).toBe(ea.mayDrain(turnFor(c)))
112
+ }
113
+ })
114
+
115
+ it('flag ON: verdict == base on the foreground card path for EVERY input (endingTurnFinalAnswerDelivered:null degenerates the gate to !turnInFlight, subsumed by activityInFlight)', async () => {
116
+ const { EmissionAuthority } = await loadFacade(true)
117
+ for (const c of cases()) {
118
+ const ea = new EmissionAuthority('k')
119
+ const verdict = ea.mayDrainCardNow(turnFor(c), cardCtxFor(c))
120
+
121
+ // The card path threads endingTurnFinalAnswerDelivered:null, so under the
122
+ // flag the verdict is mayDrain(turn) && mayDrainBufferedInbound({
123
+ // turnInFlight, endingTurnFinalAnswerDelivered:null, enabled }). With
124
+ // delivered=null the predicate degenerates to !turnInFlight.
125
+ const expectedOnVerdict =
126
+ baseMayDrain(c) &&
127
+ mayDrainBufferedInbound({
128
+ turnInFlight: c.turnInFlight,
129
+ endingTurnFinalAnswerDelivered: null,
130
+ enabled: c.enabled,
131
+ })
132
+ expect(verdict, keyOf(c)).toBe(expectedOnVerdict)
133
+
134
+ // The DEFINING parity property: when claude is NOT mid-turn (the live
135
+ // foreground card path — the only path a card drain actually fires on,
136
+ // since a drain runs WITHIN the foreground turn), the flag-ON verdict
137
+ // equals base. turnInFlight just short-circuits to false, which the
138
+ // single-flight already covers when activityInFlight is set.
139
+ if (!c.turnInFlight) {
140
+ expect(verdict, keyOf(c)).toBe(baseMayDrain(c))
141
+ }
142
+ }
143
+ })
144
+
145
+ it('the synthetic-represent-turn row cannot wedge the card path (finalAnswerDelivered=false + activityInFlight=null ⇒ verdict matches base)', async () => {
146
+ const { EmissionAuthority } = await loadFacade(true)
147
+ // A synthetic represent turn starts finalAnswerDelivered=false. On the
148
+ // buffer-drain path that would BLOCK the gate forever. On the CARD path the
149
+ // gateway threads endingTurnFinalAnswerDelivered:null, so the false-delivery
150
+ // state is NEVER consulted — the card drains exactly as base would.
151
+ const represent: Case = {
152
+ activityInFlight: false, // free single-flight slot
153
+ turnInFlight: false, // the live foreground card path
154
+ endingTurnFinalAnswerDelivered: false, // the represent turn's raw state
155
+ enabled: true, // serialize-until-replied ON
156
+ }
157
+ const ea = new EmissionAuthority('k')
158
+ const verdict = ea.mayDrainCardNow(turnFor(represent), cardCtxFor(represent))
159
+ // base = mayDrain = true (slot free). The represent turn does NOT wedge it.
160
+ expect(verdict).toBe(true)
161
+ expect(verdict).toBe(baseMayDrain(represent))
162
+ })
163
+
164
+ it('flag-OFF set == flag-ON set on the foreground card path (cross-product set equality)', async () => {
165
+ const off = await loadFacade(false)
166
+ const on = await loadFacade(true)
167
+ const offVerdicts: string[] = []
168
+ const onVerdicts: string[] = []
169
+ for (const c of cases()) {
170
+ if (c.turnInFlight) continue // not the live foreground card path
171
+ const eaOff = new off.EmissionAuthority('k')
172
+ const eaOn = new on.EmissionAuthority('k')
173
+ offVerdicts.push(`${keyOf(c)} => ${eaOff.mayDrainCardNow(turnFor(c), cardCtxFor(c))}`)
174
+ onVerdicts.push(`${keyOf(c)} => ${eaOn.mayDrainCardNow(turnFor(c), cardCtxFor(c))}`)
175
+ }
176
+ expect(onVerdicts).toEqual(offVerdicts)
177
+ expect(offVerdicts.length).toBeGreaterThan(0)
178
+ })
179
+ })
180
+
181
+ describe('mayDrainCardNow — no-deadlock interleaving (the gateway-acquired lock serializes FIFO; mayDrain stays lock-free)', () => {
182
+ /**
183
+ * The gateway acquires chatLock AROUND the gate; mayDrainCardNow itself is a
184
+ * pure read. This proves two concurrent gate decisions on the same chatKey
185
+ * serialize FIFO under a simple promise-chain lock (the chat-lock contract)
186
+ * and that neither blocks on the pure mayDrain read.
187
+ */
188
+ function makeChainLock() {
189
+ const chains = new Map<string, Promise<unknown>>()
190
+ function run<T>(key: string, fn: () => Promise<T>): Promise<T> {
191
+ const prior = chains.get(key) ?? Promise.resolve()
192
+ const next = prior.then(fn, fn)
193
+ const tracked = next.finally(() => {
194
+ if (chains.get(key) === tracked) chains.delete(key)
195
+ })
196
+ chains.set(key, tracked)
197
+ return next
198
+ }
199
+ return { run }
200
+ }
201
+
202
+ it('two concurrent gate calls on the same chatKey serialize FIFO; neither blocks on the pure mayDrain', async () => {
203
+ const { EmissionAuthority } = await loadFacade(true)
204
+ const ea = new EmissionAuthority('k')
205
+ const turn: EmissionTurnView = { activityInFlight: null }
206
+ const ctx: CardDrainGateCtx = {
207
+ turnInFlight: false,
208
+ endingTurnFinalAnswerDelivered: null,
209
+ enabled: true,
210
+ }
211
+ const lock = makeChainLock()
212
+ const order: string[] = []
213
+
214
+ // The pure read never touches the lock — callable freely, returns instantly.
215
+ expect(ea.mayDrainCardNow(turn, ctx)).toBe(true)
216
+
217
+ const gate = (id: string) =>
218
+ lock.run('k', async () => {
219
+ order.push(`enter-${id}`)
220
+ // The gate decision (pure) runs inside the lock.
221
+ const verdict = ea.mayDrainCardNow(turn, ctx)
222
+ // Yield a microtask to expose any interleaving.
223
+ await Promise.resolve()
224
+ order.push(`exit-${id}`)
225
+ return verdict
226
+ })
227
+
228
+ const [v1, v2] = await Promise.all([gate('A'), gate('B')])
229
+ // FIFO: A fully completes before B enters (no interleave).
230
+ expect(order).toEqual(['enter-A', 'exit-A', 'enter-B', 'exit-B'])
231
+ expect(v1).toBe(true)
232
+ expect(v2).toBe(true)
233
+ // The pure read still works AFTER the serialized section — lock-free.
234
+ expect(ea.mayDrainCardNow(turn, ctx)).toBe(true)
235
+ })
236
+ })