switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -9,12 +9,26 @@
9
9
  * Run with:
10
10
  * bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
11
11
  */
12
- import { describe, it, expect, beforeEach } from "vitest";
12
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
13
+ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { fileURLToPath } from "node:url";
13
17
  import {
14
18
  ownedWorktreeCwds,
19
+ refreshOwnedWorktreeHeartbeats,
20
+ makeWorktreeWatchProvider,
15
21
  __resetIdentityEscalationForTests,
16
22
  type WorktreeOwnershipRecord,
23
+ type WorktreeHeartbeatRecord,
17
24
  } from "../worktree-watch-cwds.js";
25
+ import {
26
+ writeRecord,
27
+ readRecord,
28
+ listRecords as registryListRecords,
29
+ touchHeartbeat as registryTouchHeartbeat,
30
+ } from "../../src/worktree/registry.js";
31
+ import type { WorktreeRecord } from "../../src/worktree/types.js";
18
32
 
19
33
  const idPath = (p: string) => p; // identity realpath for deterministic tests
20
34
 
@@ -196,3 +210,203 @@ describe("ownedWorktreeCwds", () => {
196
210
  expect(provider()).toEqual(["/wt/b"]);
197
211
  });
198
212
  });
213
+
214
+ describe("refreshOwnedWorktreeHeartbeats", () => {
215
+ const records: WorktreeHeartbeatRecord[] = [
216
+ { id: "mine-1", ownerAgent: "klanker" },
217
+ { id: "mine-2", ownerAgent: "klanker" },
218
+ { id: "theirs", ownerAgent: "reggie" },
219
+ { id: "ownerless" }, // ownerAgent undefined
220
+ ];
221
+
222
+ it("touches ONLY this agent's records, never ownerless or foreign ones", () => {
223
+ const touched: string[] = [];
224
+ const n = refreshOwnedWorktreeHeartbeats({
225
+ self: "klanker",
226
+ listRecords: () => records,
227
+ touchHeartbeat: (id) => touched.push(id),
228
+ minRefreshIntervalMs: 0, // always touch
229
+ });
230
+ expect(touched.sort()).toEqual(["mine-1", "mine-2"]);
231
+ expect(touched).not.toContain("theirs");
232
+ expect(touched).not.toContain("ownerless");
233
+ expect(n).toBe(2);
234
+ });
235
+
236
+ it("fail-closed: unresolved identity touches NOTHING (no #1116 leak)", () => {
237
+ const touched: string[] = [];
238
+ const n = refreshOwnedWorktreeHeartbeats({
239
+ self: undefined,
240
+ listRecords: () => records,
241
+ touchHeartbeat: (id) => touched.push(id),
242
+ minRefreshIntervalMs: 0,
243
+ });
244
+ expect(touched).toEqual([]);
245
+ expect(n).toBe(0);
246
+ });
247
+
248
+ it("resolves identity from agentDir when env is unset (durable fallback)", () => {
249
+ const touched: string[] = [];
250
+ refreshOwnedWorktreeHeartbeats({
251
+ self: undefined,
252
+ agentDir: "/home/x/.switchroom/agents/klanker",
253
+ listRecords: () => records,
254
+ touchHeartbeat: (id) => touched.push(id),
255
+ minRefreshIntervalMs: 0,
256
+ });
257
+ expect(touched.sort()).toEqual(["mine-1", "mine-2"]);
258
+ });
259
+
260
+ it("throttles: skips a record whose heartbeat is younger than the interval", () => {
261
+ const now = 1_000_000;
262
+ const fresh = new Date(now - 30_000).toISOString(); // 30s ago
263
+ const aged = new Date(now - 5 * 60_000).toISOString(); // 5min ago
264
+ const touched: string[] = [];
265
+ refreshOwnedWorktreeHeartbeats({
266
+ self: "klanker",
267
+ listRecords: () => [
268
+ { id: "fresh", ownerAgent: "klanker", heartbeatAt: fresh },
269
+ { id: "aged", ownerAgent: "klanker", heartbeatAt: aged },
270
+ ],
271
+ touchHeartbeat: (id) => touched.push(id),
272
+ minRefreshIntervalMs: 2 * 60_000, // 2 min
273
+ now: () => now,
274
+ });
275
+ // Only the aged one crosses the throttle window.
276
+ expect(touched).toEqual(["aged"]);
277
+ });
278
+
279
+ it("a registry read failure is swallowed (never throws on the hot loop)", () => {
280
+ expect(() =>
281
+ refreshOwnedWorktreeHeartbeats({
282
+ self: "klanker",
283
+ listRecords: () => {
284
+ throw new Error("registry gone");
285
+ },
286
+ touchHeartbeat: () => {},
287
+ }),
288
+ ).not.toThrow();
289
+ });
290
+
291
+ it("a per-record touch failure does not abort the remaining records", () => {
292
+ const touched: string[] = [];
293
+ const n = refreshOwnedWorktreeHeartbeats({
294
+ self: "klanker",
295
+ listRecords: () => records,
296
+ touchHeartbeat: (id) => {
297
+ if (id === "mine-1") throw new Error("write failed");
298
+ touched.push(id);
299
+ },
300
+ minRefreshIntervalMs: 0,
301
+ });
302
+ // mine-2 still touched despite mine-1 throwing.
303
+ expect(touched).toEqual(["mine-2"]);
304
+ expect(n).toBe(1);
305
+ });
306
+ });
307
+
308
+ // ─── M1: the gateway's extraWatchCwds provider ──────────────────────────────
309
+ //
310
+ // The gateway wires a single closure (`makeWorktreeWatchProvider`) as the
311
+ // subagent-watcher's extraWatchCwdsProvider. The whole point of this PR is
312
+ // that that closure ALSO advances heartbeats on every tick — deleting the
313
+ // refresh call would leave the cwd behaviour (and every ownership test above)
314
+ // green while silently reintroducing the zero-caller `touchHeartbeat` bug.
315
+ // These tests are the deterministic guard: they assert the provider returns
316
+ // owned cwds AND advances a real registry record's heartbeatAt on the same
317
+ // call, and that the gateway actually installs it.
318
+ describe("makeWorktreeWatchProvider (gateway wiring)", () => {
319
+ const idPathLocal = (p: string) => p; // deterministic realpath
320
+
321
+ it("both returns owned cwds AND advances the claim's heartbeat (in-memory)", () => {
322
+ const now = 10_000_000;
323
+ const before = new Date(now - 60 * 60_000).toISOString(); // 1h ago (stale)
324
+ const store: (WorktreeOwnershipRecord & WorktreeHeartbeatRecord)[] = [
325
+ { id: "mine", path: "/wt/mine", ownerAgent: "klanker", heartbeatAt: before },
326
+ { id: "theirs", path: "/wt/theirs", ownerAgent: "reggie", heartbeatAt: before },
327
+ ];
328
+ const touched: string[] = [];
329
+ const provider = makeWorktreeWatchProvider({
330
+ self: "klanker",
331
+ listRecords: () => store,
332
+ touchHeartbeat: (id) => touched.push(id),
333
+ realpath: idPathLocal,
334
+ minRefreshIntervalMs: 0,
335
+ now: () => now,
336
+ });
337
+
338
+ const cwds = provider();
339
+
340
+ // (1) returns exactly this agent's owned cwds
341
+ expect(cwds).toEqual(["/wt/mine"]);
342
+ // (2) AND advanced the heartbeat of the owned record only — never foreign
343
+ expect(touched).toEqual(["mine"]);
344
+ });
345
+
346
+ describe("against a real temp registry", () => {
347
+ let tmpDir: string;
348
+ const origEnv = process.env.SWITCHROOM_WORKTREE_DIR;
349
+
350
+ beforeEach(() => {
351
+ tmpDir = mkdtempSync(join(tmpdir(), "sw-provider-test-"));
352
+ process.env.SWITCHROOM_WORKTREE_DIR = tmpDir;
353
+ });
354
+
355
+ afterEach(() => {
356
+ rmSync(tmpDir, { recursive: true, force: true });
357
+ if (origEnv === undefined) delete process.env.SWITCHROOM_WORKTREE_DIR;
358
+ else process.env.SWITCHROOM_WORKTREE_DIR = origEnv;
359
+ });
360
+
361
+ function makeRecord(overrides: Partial<WorktreeRecord> = {}): WorktreeRecord {
362
+ const iso = new Date().toISOString();
363
+ return {
364
+ id: "prov001",
365
+ repo: "/fake/repo",
366
+ repoName: "fake",
367
+ branch: "task/prov001",
368
+ path: "/wt/prov001",
369
+ createdAt: iso,
370
+ heartbeatAt: iso,
371
+ ownerAgent: "klanker",
372
+ ...overrides,
373
+ };
374
+ }
375
+
376
+ it("provider drives the REAL registry: returns cwd AND advances heartbeatAt on disk", async () => {
377
+ const past = new Date(Date.now() - 5 * 60_000).toISOString(); // 5min ago
378
+ writeRecord(makeRecord({ heartbeatAt: past }));
379
+ const before = new Date(readRecord("prov001")!.heartbeatAt).getTime();
380
+
381
+ const provider = makeWorktreeWatchProvider({
382
+ self: "klanker",
383
+ listRecords: registryListRecords,
384
+ touchHeartbeat: registryTouchHeartbeat,
385
+ realpath: idPathLocal,
386
+ minRefreshIntervalMs: 0, // always touch
387
+ });
388
+
389
+ await new Promise((r) => setTimeout(r, 5)); // ensure a strictly newer ts
390
+ const cwds = provider();
391
+
392
+ // (1) returns the owned cwd from the real registry
393
+ expect(cwds).toEqual(["/wt/prov001"]);
394
+ // (2) AND the on-disk heartbeat actually advanced (the zero-caller guard)
395
+ const after = new Date(readRecord("prov001")!.heartbeatAt).getTime();
396
+ expect(after).toBeGreaterThan(before);
397
+ });
398
+ });
399
+
400
+ it("gateway.ts installs makeWorktreeWatchProvider as extraWatchCwdsProvider", () => {
401
+ // Grep-pin (anchor MUST resolve so it can never pass vacuously — repo test
402
+ // convention, PR #3126): proves the gateway actually wires the extracted
403
+ // provider, so a behaviour test on the provider is not testing dead code.
404
+ const here = dirname(fileURLToPath(import.meta.url));
405
+ const gatewaySrc = readFileSync(
406
+ join(here, "..", "gateway", "gateway.ts"),
407
+ "utf8",
408
+ );
409
+ const anchor = "extraWatchCwdsProvider: makeWorktreeWatchProvider(";
410
+ expect(gatewaySrc.indexOf(anchor)).toBeGreaterThan(-1);
411
+ });
412
+ });
@@ -0,0 +1,224 @@
1
+ /**
2
+ * The single gate every `sendChatAction` in the gateway passes through (#3084).
3
+ *
4
+ * WHY THIS EXISTS — the 2026-07-11 flood ban
5
+ * ------------------------------------------
6
+ * The typing indicator used to be "a 4 s interval", which sounds rate-limited
7
+ * and is not. Both typing loops (`startTypingLoop` in gateway.ts and the
8
+ * turn-level `createTurnTypingLoop`) are restart-safe by design: a re-start
9
+ * clears the old interval and fires ONE action immediately so "typing…" lands
10
+ * instantly. The tool-use wrapper restarts the loop on every tool call, so the
11
+ * ping rate tracked the AGENT'S TOOL-CALL RATE, not the 4 s cadence — the
12
+ * interval was decorative. On 2026-07-11 `overlord` emitted 8,729
13
+ * sendChatAction calls (55% of all outbound volume, bursting at 200-300/min
14
+ * into ONE DM) to deliver 203 messages, and earned a per-bot-token flood ban:
15
+ * `429 retry_after=16739s` — 4.6 hours with every outbound reply rejected.
16
+ *
17
+ * The old code comment said the redundant pings were "harmless — same action,
18
+ * and sendChatAction is cheap." They are not cheap. They spend the per-bot
19
+ * flood budget the REPLIES need.
20
+ *
21
+ * WHAT THIS ENFORCES
22
+ * ------------------
23
+ * 1. A per-chat-key emission FLOOR. At most one chat action per key per
24
+ * `floorMs` (~4 s refresh window), no matter how many loops restart, from
25
+ * which caller, on which surface. Both loops share ONE emitter, so the
26
+ * floor holds ACROSS them — they target the same chat key and neither can
27
+ * out-shout the other. This is what structurally decouples ping rate from
28
+ * tool-call rate.
29
+ * 2. The UX intent survives: a COLD start (no ping for this key inside the
30
+ * floor) still fires immediately, so "typing…" lands the moment a turn
31
+ * begins. Only the REDUNDANT restarts are dropped — and a dropped tick is
32
+ * never silently lost: it arms a COALESCED catch-up at the instant the
33
+ * window opens, so the worst-case gap between two emissions is `floorMs`
34
+ * (3.5 s), comfortably inside Telegram's ~5 s action expiry. Without it,
35
+ * an eaten tick on a fixed-cadence loop pushes the next emission out to
36
+ * `floorMs + refreshMs` = 7.5 s and the chat goes DARK mid-turn — trading
37
+ * a flood ban for a dead indicator, which is not a trade we make.
38
+ * 3. NON-ESSENTIAL by definition: while a flood-wait window is open
39
+ * (`isSuppressed`, wired to the #2923 circuit breaker) no typing is
40
+ * emitted at all. It cannot succeed, and sending into an open window can
41
+ * extend the ban.
42
+ *
43
+ * The window is claimed on every attempt that PASSES the floor, including one
44
+ * suppressed by a flood window. That is deliberate: it bounds the flood-state
45
+ * read to once per floor per chat instead of once per tool call.
46
+ *
47
+ * Pure + injectable (clock, send, chat-key, suppression) so the whole gate is
48
+ * unit-testable on a fake clock without a bot — `tests/typing-emitter.test.ts`.
49
+ */
50
+
51
+ /** Refresh cadence of the typing loops. Telegram's `typing` expires at ~5 s. */
52
+ export const TYPING_REFRESH_MS = 4000
53
+
54
+ /**
55
+ * Minimum gap between two chat actions on one chat key. Deliberately a hair
56
+ * UNDER `TYPING_REFRESH_MS` so a loop's own on-time refresh is never eaten by
57
+ * timer jitter (a 3999 ms tick would be dropped by a 4000 ms floor, and the
58
+ * indicator would go dark for a whole window). The gap between successive
59
+ * emissions therefore stays under Telegram's ~5 s action expiry, while the
60
+ * worst-case rate falls from the observed ~300/min to ~17/min per chat.
61
+ */
62
+ export const TYPING_FLOOR_MS = 3500
63
+
64
+ export interface TypingEmitterDeps {
65
+ /** Perform the actual chat action. Errors are the caller's concern — the
66
+ * emitter never throws and never awaits. */
67
+ send: (chatId: string, threadId: number | null, action: string) => void
68
+ /** Canonical chat:thread key (the gateway's `chatKey`) — a supergroup topic
69
+ * is its own lane and gets its own floor. */
70
+ chatKey: (chatId: string, threadId: number | null) => string
71
+ /** True while a flood-wait window is open. Typing is non-essential: while
72
+ * this is true nothing is emitted. Defaults to "never suppressed". */
73
+ isSuppressed?: () => boolean
74
+ /** Injected clock (tests pass a fake). */
75
+ now?: () => number
76
+ /** Per-key emission floor in ms. */
77
+ floorMs?: number
78
+ /** Injected timer for the catch-up (tests pass fake timers). */
79
+ schedule?: (fn: () => void, ms: number) => unknown
80
+ /** Cancel a handle returned by `schedule`. */
81
+ cancel?: (handle: unknown) => void
82
+ /** Observability hook — fires for each dropped emission. */
83
+ onDrop?: (info: { key: string; reason: 'floor' | 'flood' }) => void
84
+ }
85
+
86
+ export interface TypingEmitter {
87
+ /**
88
+ * Emit one chat action for (chatId, threadId), subject to the floor and the
89
+ * flood gate. Returns true iff the action was actually handed to `send`.
90
+ * A floor-dropped emission arms a coalesced catch-up (see below), so the
91
+ * caller's cadence is never silently lost.
92
+ */
93
+ emit: (chatId: string, threadId?: number | null, action?: string) => boolean
94
+ /**
95
+ * Cancel any pending catch-up for a chat key. The canonical turn-end calls
96
+ * this so a dropped tick can't resurrect "typing…" after the turn is over.
97
+ * Does NOT clear the floor — the floor must survive loop stops, because the
98
+ * tool loop stops on every tool result and that is precisely the churn the
99
+ * floor exists to absorb.
100
+ */
101
+ cancelPending: (chatId: string, threadId?: number | null) => void
102
+ /** Test/observability: number of chat keys currently tracked. */
103
+ trackedKeys: () => number
104
+ /** Test/observability: number of catch-ups currently armed (0 ⇒ none leaked). */
105
+ pendingCatchUps: () => number
106
+ /** Drop all floor state and cancel every catch-up (shutdown / tests). */
107
+ reset: () => void
108
+ }
109
+
110
+ /** Keep the floor map from growing without bound in a long-lived gateway. */
111
+ const PRUNE_AFTER_FACTOR = 10
112
+ const PRUNE_SIZE_THRESHOLD = 64
113
+
114
+ export function createTypingEmitter(deps: TypingEmitterDeps): TypingEmitter {
115
+ const floorMs = deps.floorMs ?? TYPING_FLOOR_MS
116
+ const now = deps.now ?? Date.now
117
+ const isSuppressed = deps.isSuppressed ?? (() => false)
118
+ const schedule =
119
+ deps.schedule ??
120
+ ((fn: () => void, ms: number) => {
121
+ const h = setTimeout(fn, ms)
122
+ ;(h as { unref?: () => void }).unref?.()
123
+ return h
124
+ })
125
+ const cancel =
126
+ deps.cancel ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>))
127
+
128
+ /** chat key → epoch ms at which the current emission window was claimed. */
129
+ const windowClaimedAt = new Map<string, number>()
130
+ /** chat key → the single armed catch-up timer (coalesced; never two). */
131
+ const catchUps = new Map<string, unknown>()
132
+
133
+ function prune(t: number): void {
134
+ if (windowClaimedAt.size <= PRUNE_SIZE_THRESHOLD) return
135
+ const cutoff = t - floorMs * PRUNE_AFTER_FACTOR
136
+ for (const [k, ts] of [...windowClaimedAt.entries()]) {
137
+ if (ts < cutoff && !catchUps.has(k)) windowClaimedAt.delete(k)
138
+ }
139
+ }
140
+
141
+ function clearCatchUp(key: string): void {
142
+ const h = catchUps.get(key)
143
+ if (h !== undefined) {
144
+ cancel(h)
145
+ catchUps.delete(key)
146
+ }
147
+ }
148
+
149
+ /**
150
+ * A dropped tick MUST NOT become silence. Both loops re-arm a FIXED-cadence
151
+ * interval, so a tick eaten by the floor is simply lost: the next emission
152
+ * for that key could land as late as `floorMs + refreshMs` (7.5 s) after the
153
+ * last one — past Telegram's ~5 s chat-action expiry, and the chat goes dark
154
+ * mid-turn. That would trade a flood ban for a dead "typing…" indicator,
155
+ * which breaches `know-what-my-agent-is-doing` ("stays present from receipt
156
+ * to turn end").
157
+ *
158
+ * So a floor drop arms ONE coalesced catch-up at the exact moment the window
159
+ * opens (`claimedAt + floorMs`). Effect: the worst-case gap between two
160
+ * emissions is bounded by `floorMs`, while the emission CAP is untouched (the
161
+ * catch-up fires only once the floor has expired, so it can never emit early).
162
+ * A second drop inside the same window must not arm a second timer — hence
163
+ * the coalesce.
164
+ */
165
+ function armCatchUp(
166
+ key: string,
167
+ chatId: string,
168
+ threadId: number | null,
169
+ action: string,
170
+ delayMs: number,
171
+ ): void {
172
+ if (catchUps.has(key)) return // coalesced — one timer per key, always
173
+ const h = schedule(() => {
174
+ catchUps.delete(key)
175
+ // Re-enters `emit`, which re-checks the floor (now expired) and the flood
176
+ // gate. A send here cancels nothing — there is nothing left to cancel.
177
+ emit(chatId, threadId, action)
178
+ }, Math.max(0, delayMs))
179
+ catchUps.set(key, h)
180
+ }
181
+
182
+ function emit(chatId: string, threadId: number | null = null, action = 'typing'): boolean {
183
+ const key = deps.chatKey(chatId, threadId ?? null)
184
+ const t = now()
185
+ const claimed = windowClaimedAt.get(key)
186
+ // `t >= claimed` guards a backwards clock step (NTP): a stale future
187
+ // timestamp must not wedge the indicator off, so treat it as expired.
188
+ if (claimed != null && t >= claimed && t - claimed < floorMs) {
189
+ armCatchUp(key, chatId, threadId ?? null, action, claimed + floorMs - t)
190
+ deps.onDrop?.({ key, reason: 'floor' })
191
+ return false
192
+ }
193
+ // Claim the window BEFORE the flood check so a burst of restarts costs
194
+ // one flood-state read per floor, not one per restart.
195
+ windowClaimedAt.set(key, t)
196
+ prune(t)
197
+ if (isSuppressed()) {
198
+ // A flood window is open. Don't arm a catch-up — the goal is silence, not
199
+ // a deferred ping into the ban.
200
+ clearCatchUp(key)
201
+ deps.onDrop?.({ key, reason: 'flood' })
202
+ return false
203
+ }
204
+ // This send satisfies whatever a pending catch-up was going to deliver.
205
+ // Cancelling it here is what keeps a catch-up from re-arming itself into a
206
+ // self-sustaining heartbeat after the loops have stopped.
207
+ clearCatchUp(key)
208
+ deps.send(chatId, threadId ?? null, action)
209
+ return true
210
+ }
211
+
212
+ return {
213
+ emit,
214
+ cancelPending(chatId, threadId = null) {
215
+ clearCatchUp(deps.chatKey(chatId, threadId ?? null))
216
+ },
217
+ trackedKeys: () => windowClaimedAt.size,
218
+ pendingCatchUps: () => catchUps.size,
219
+ reset: () => {
220
+ for (const key of [...catchUps.keys()]) clearCatchUp(key)
221
+ windowClaimedAt.clear()
222
+ },
223
+ }
224
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * JTBD scenario — feel like a colleague, not a chatbot (DM).
3
+ *
4
+ * Serves: `reference/jobs/feel-like-a-colleague.md`. The colleague posture
5
+ * is shipped fleet-wide as lane-2 model-visible text (`~/.switchroom/fleet/
6
+ * CLAUDE.md`, seeded by `renderFleetDefaultsClaudeMd()` in
7
+ * `src/agents/fleet-defaults.ts`, epic #1850 / issue #1855). One bullet of
8
+ * "Good looks like" reads:
9
+ *
10
+ * "The agent asks at most one good clarifying question, and skips it when
11
+ * intent is clear, stating its assumption inline as it acts."
12
+ *
13
+ * This scenario sends a genuinely ambiguous request and asserts the agent
14
+ * asks EXACTLY ONE clarifying question rather than a question avalanche
15
+ * (the "Bad looks like" failure) or a confident guess that ignores the
16
+ * ambiguity.
17
+ *
18
+ * Gating: like every file under `telegram-plugin/uat/scenarios/`, this hits
19
+ * real Telegram + real Claude and only runs under `vitest.uat.config.ts`
20
+ * (`bun run test:uat`). The default vitest config excludes this directory,
21
+ * so CI stays green without live credentials.
22
+ */
23
+
24
+ import { describe, it, expect } from "vitest";
25
+ import { spinUp } from "../harness.js";
26
+
27
+ const AGENT = "test-harness";
28
+
29
+ // Ambiguous on purpose: "the report" has no referent, no format, no
30
+ // destination. A colleague asks which report / what for; it does not
31
+ // silently invent one, and it does not fire three questions.
32
+ const AMBIGUOUS_PROMPT = "can you send over the report when you get a sec?";
33
+
34
+ /**
35
+ * Count clarifying questions the agent itself asks. We count sentences
36
+ * ending in a question mark AFTER stripping spans where a `?` is not a
37
+ * question the agent is asking: URLs (query strings), inline/fenced code,
38
+ * and quoted echoes of the user's own words. Rhetorical framing
39
+ * ("sure, which one?") counts as one question; the invariant is at most one.
40
+ */
41
+ function countQuestions(text: string): number {
42
+ const stripped = text
43
+ // Fenced code blocks, then inline code spans.
44
+ .replace(/```[\s\S]*?```/g, " ")
45
+ .replace(/`[^`\n]*`/g, " ")
46
+ // URLs — `?` here is a query string, not a question.
47
+ .replace(/\bhttps?:\/\/\S+/gi, " ")
48
+ .replace(/\bwww\.\S+/gi, " ")
49
+ // Quoted spans — the agent echoing the user ("you said 'which report?'")
50
+ // is not the agent asking a question.
51
+ .replace(/"[^"\n]*"/g, " ")
52
+ // Single-quoted spans must OPEN at a word boundary so apostrophes in
53
+ // contractions ("don't", "user's") are not misread as quote delimiters.
54
+ .replace(/(^|[\s([{])'[^'\n]*'/g, "$1 ")
55
+ .replace(/[“”][^“”\n]*[“”]/g, " ")
56
+ // Markdown blockquote lines are quoted material, not the agent's voice.
57
+ .replace(/^\s*>.*$/gm, " ");
58
+ const matches = stripped.match(/[^.!?\n]*\?/g);
59
+ if (matches == null) return 0;
60
+ // Filter out trivial fragments (a lone "?" or whitespace) that are not
61
+ // real questions.
62
+ return matches.filter((m) => m.replace(/[^a-z0-9]/gi, "").length >= 3).length;
63
+ }
64
+
65
+ /**
66
+ * `true` for bot messages that are infrastructure cards rather than a
67
+ * conversational reply: the boot/greeting card (always delivered with the
68
+ * Telegram `silent` flag — see boot-card.ts "Boot cards are ALWAYS
69
+ * delivered silently"), edits of earlier messages, and anything matching
70
+ * the known boot-card header shape (`✅ <agent> back up · <version>`).
71
+ */
72
+ function isInfrastructureCard(m: {
73
+ text: string;
74
+ silent: boolean;
75
+ edited: boolean;
76
+ }): boolean {
77
+ if (m.silent || m.edited) return true;
78
+ return /back up ·|^✅ /u.test(m.text.trim());
79
+ }
80
+
81
+ describe("uat: feel like a colleague — one clarifying question when ambiguous", () => {
82
+ it(
83
+ "ambiguous ask → agent asks exactly one clarifying question",
84
+ async () => {
85
+ const sc = await spinUp({ agent: AGENT });
86
+ try {
87
+ await sc.sendDM(AMBIGUOUS_PROMPT);
88
+
89
+ // Substantive replies only: skip the boot/greeting card and any
90
+ // silent interim edits so the assertion runs against the agent's
91
+ // actual conversational answer to the prompt.
92
+ const reply = await sc.expectMessage(
93
+ (m) => /\S/.test(m.text) && !isInfrastructureCard(m),
94
+ {
95
+ from: "bot",
96
+ timeout: 90_000,
97
+ },
98
+ );
99
+
100
+ expect(reply.text.length).toBeGreaterThan(0);
101
+
102
+ const questionCount = countQuestions(reply.text);
103
+
104
+ // Invariant: at least one clarifying question (it did not guess
105
+ // blindly) and at most one (no question avalanche).
106
+ if (questionCount === 0) {
107
+ throw new Error(
108
+ `[colleague] ambiguous ask got no clarifying question — the ` +
109
+ `agent either guessed a referent or ignored the ambiguity. ` +
110
+ `Reply: ${JSON.stringify(reply.text.slice(0, 300))}`,
111
+ );
112
+ }
113
+ if (questionCount > 1) {
114
+ throw new Error(
115
+ `[colleague] question avalanche: ${questionCount} questions ` +
116
+ `where one would do. Reply: ${JSON.stringify(reply.text.slice(0, 300))}`,
117
+ );
118
+ }
119
+ expect(questionCount).toBe(1);
120
+
121
+ // Posture bonus: no sycophantic preamble. A soft forensic warn,
122
+ // not a hard fail (voice specifics are audited in
123
+ // fuzz-voice-scrub-dm).
124
+ if (/^(great question|i'?d be happy to|absolutely!|of course!)/i.test(reply.text.trim())) {
125
+ console.warn(
126
+ `[colleague] reply opens with sycophantic preamble: ` +
127
+ `${JSON.stringify(reply.text.slice(0, 120))}`,
128
+ );
129
+ }
130
+ } finally {
131
+ await sc.tearDown();
132
+ }
133
+ },
134
+ 120_000,
135
+ );
136
+ });
@@ -93,6 +93,27 @@ export type AgentMetadata = {
93
93
  * asked for current state, so terseness loses to completeness here.
94
94
  */
95
95
  live?: StatusProbeRow[];
96
+ /**
97
+ * Send-gate state (#3084 PR 3, part3-design §6). Present only when the gate
98
+ * feature flag is ON; omitted entirely when off so `/status` looks exactly
99
+ * as it did before the gate existed.
100
+ */
101
+ sendGate?: SendGateStatus;
102
+ };
103
+
104
+ /**
105
+ * `/status` view of the deterministic send gate (#3084). Queued / shed totals
106
+ * plus any open flood windows with their expiry. Only populated when the gate
107
+ * is enabled.
108
+ */
109
+ export type SendGateStatus = {
110
+ queued: number;
111
+ shed: number;
112
+ expired: number;
113
+ failedFast: number;
114
+ dropped: number;
115
+ /** Currently-open flood windows (expired already pruned). */
116
+ openWindows: { scopeKey: string; untilTs: number }[];
96
117
  };
97
118
 
98
119
  // Markdown escaper for dynamic values interpolated into bold/plain card
@@ -231,6 +252,27 @@ export function statusPairedText(params: {
231
252
  }
232
253
  }
233
254
 
255
+ // Send-gate block (#3084 PR 3) — only when the gate flag is on, so a fleet
256
+ // running with the gate OFF sees the identical pre-gate /status.
257
+ if (meta.sendGate) {
258
+ const sg = meta.sendGate;
259
+ lines.push("");
260
+ lines.push("**Send gate**");
261
+ lines.push(
262
+ `queued ${sg.queued} · shed ${sg.shed} · expired ${sg.expired} · ` +
263
+ `fail-fast ${sg.failedFast} · dropped ${sg.dropped}`,
264
+ );
265
+ if (sg.openWindows.length > 0) {
266
+ const now = Date.now();
267
+ for (const w of sg.openWindows) {
268
+ const secs = Math.max(0, Math.round((w.untilTs - now) / 1000));
269
+ lines.push(`⏳ flood window \`${escapeHtml(w.scopeKey)}\` — clears in ${secs}s`);
270
+ }
271
+ } else {
272
+ lines.push("no open flood windows");
273
+ }
274
+ }
275
+
234
276
  const audit = meta.audit;
235
277
  if (audit) {
236
278
  // Blank separator before the audit block so the reply reads as two