switchroom 0.18.17 → 0.18.18

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 (54) hide show
  1. package/dist/agent-scheduler/index.js +13 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/notion-write-pretool.mjs +13 -0
  4. package/dist/cli/switchroom.js +605 -479
  5. package/dist/host-control/main.js +17 -1
  6. package/dist/vault/approvals/kernel-server.js +13 -0
  7. package/dist/vault/broker/server.js +13 -0
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +7 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +1401 -431
  12. package/telegram-plugin/dist/server.js +26 -1
  13. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  14. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  15. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
  16. package/telegram-plugin/gateway/gateway.ts +362 -71
  17. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  18. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  19. package/telegram-plugin/gateway/session-model-file.ts +103 -0
  20. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  21. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  22. package/telegram-plugin/llm-error-present.ts +436 -0
  23. package/telegram-plugin/operator-events.ts +7 -1
  24. package/telegram-plugin/permission-title.ts +172 -10
  25. package/telegram-plugin/premium-recovery.ts +101 -0
  26. package/telegram-plugin/raw-error-scrub.ts +73 -0
  27. package/telegram-plugin/retry-api-call.ts +8 -2
  28. package/telegram-plugin/send-gate-degraded.test.ts +152 -1
  29. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  30. package/telegram-plugin/send-gate-observability.ts +65 -20
  31. package/telegram-plugin/send-gate.test.ts +143 -1
  32. package/telegram-plugin/send-gate.ts +212 -19
  33. package/telegram-plugin/session-tail.ts +16 -0
  34. package/telegram-plugin/shared/local-time.ts +69 -0
  35. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  36. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  37. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  38. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  39. package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
  40. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  41. package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
  42. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  43. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  44. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  45. package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
  46. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  47. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  48. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  49. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  50. package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
  51. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  52. package/telegram-plugin/tier-downgrade.ts +198 -0
  53. package/telegram-plugin/tool-activity-summary.ts +99 -0
  54. package/telegram-plugin/worker-activity-feed.ts +509 -409
@@ -16994,6 +16994,27 @@ var init_plugin_logger = __esm(() => {
16994
16994
  DEFAULT_LOG_PATH2 = join2(homedir2(), ".switchroom", "logs", "telegram-plugin.log");
16995
16995
  ROTATE_AT_BYTES2 = 50 * 1024 * 1024;
16996
16996
  });
16997
+ // raw-error-scrub.ts
16998
+ function extractRequestId(raw) {
16999
+ if (typeof raw !== "string" || raw.length === 0)
17000
+ return;
17001
+ const m = raw.match(/["']request[_-]?id["']\s*:\s*["']([A-Za-z0-9._-]+)["']/i) ?? raw.match(/\brequest[_-]?id[=:]\s*([A-Za-z0-9._-]+)/i);
17002
+ return m ? m[1] : undefined;
17003
+ }
17004
+ function truncateDetailPreservingRequestId(detail, max) {
17005
+ if (typeof detail !== "string")
17006
+ return "";
17007
+ if (detail.length <= max)
17008
+ return detail;
17009
+ const rid = extractRequestId(detail);
17010
+ const head = detail.slice(0, max);
17011
+ if (rid == null || head.includes(rid))
17012
+ return head;
17013
+ const suffix = ` request_id=${rid}`;
17014
+ const headBudget = Math.max(0, max - suffix.length);
17015
+ return `${detail.slice(0, headBudget)}${suffix}`;
17016
+ }
17017
+
16997
17018
  // operator-events.ts
16998
17019
  function classifyClaudeError(raw) {
16999
17020
  try {
@@ -17376,6 +17397,10 @@ function projectTranscriptLine(line) {
17376
17397
  const content = message?.content;
17377
17398
  if (!Array.isArray(content))
17378
17399
  return [];
17400
+ if (obj.isApiErrorMessage === true) {
17401
+ const mainModel2 = message?.model;
17402
+ return typeof mainModel2 === "string" && !isModelSentinel(mainModel2) ? [{ kind: "model", model: mainModel2 }] : [];
17403
+ }
17379
17404
  const events = [];
17380
17405
  const mainModel = message?.model;
17381
17406
  if (typeof mainModel === "string" && !isModelSentinel(mainModel)) {
@@ -25140,7 +25165,7 @@ var init_bridge = __esm(async () => {
25140
25165
  type: "operator_event",
25141
25166
  kind: ev.kind,
25142
25167
  agent: AGENT_NAME,
25143
- detail: ev.detail.slice(0, 1000),
25168
+ detail: truncateDetailPreservingRequestId(ev.detail, 1000),
25144
25169
  chatId: ""
25145
25170
  });
25146
25171
  } catch (err) {
@@ -81,12 +81,24 @@ export interface FleetFallbackResumeGate {
81
81
  /**
82
82
  * Decide whether to resume the dead turn after a successful swap. Call ONLY
83
83
  * when the swap outcome was `'switched'`. Records the arm time on a 'resume'
84
- * verdict so a follow-on swap within `singleFlightMs` is suppressed.
84
+ * verdict so a follow-on swap within `singleFlightMs` is suppressed. Equivalent
85
+ * to `peek()` followed by `arm()` on a 'resume' verdict.
85
86
  *
86
87
  * @param failedTurnStartedAtMs epoch-ms the failed turn began, or null when
87
88
  * unknown (then the staleness guard is deferred to the boot path).
88
89
  */
89
90
  decide(failedTurnStartedAtMs: number | null): ResumeDecision;
91
+ /**
92
+ * Evaluate the resume verdict WITHOUT arming the single-flight latch. Use when
93
+ * the caller must perform fallible work (e.g. write a consume-once carrier)
94
+ * BEFORE committing to a restart: peek → do the writes → `arm()` only on
95
+ * success, so a throwing write can never leave an armed latch with no pending
96
+ * restart (which would suppress a legitimate later resume for `singleFlightMs`).
97
+ */
98
+ peek(failedTurnStartedAtMs: number | null): ResumeDecision;
99
+ /** Commit the single-flight arm (records "now" as the arm time). Pair with a
100
+ * prior `peek()` that returned 'resume'. */
101
+ arm(): void;
90
102
  /** Test seam — reset to fresh state. Production code should not call this. */
91
103
  reset(): void;
92
104
  /** Test/debug — current internal state. */
@@ -105,7 +117,7 @@ export function createFleetFallbackResumeGate(
105
117
  // -Infinity = never resumed. A concrete value arms the single-flight window.
106
118
  let lastResumedAtMs = Number.NEGATIVE_INFINITY;
107
119
 
108
- function decide(failedTurnStartedAtMs: number | null): ResumeDecision {
120
+ function peek(failedTurnStartedAtMs: number | null): ResumeDecision {
109
121
  const now = nowFn();
110
122
  // Guard 2 — single-flight. A second swap within the window does not
111
123
  // re-arm; the in-flight restart owns the resume.
@@ -115,12 +127,23 @@ export function createFleetFallbackResumeGate(
115
127
  if (failedTurnStartedAtMs != null && now - failedTurnStartedAtMs > maxAgeMs) {
116
128
  return 'skip-stale';
117
129
  }
118
- lastResumedAtMs = now;
119
130
  return 'resume';
120
131
  }
121
132
 
133
+ function arm(): void {
134
+ lastResumedAtMs = nowFn();
135
+ }
136
+
137
+ function decide(failedTurnStartedAtMs: number | null): ResumeDecision {
138
+ const verdict = peek(failedTurnStartedAtMs);
139
+ if (verdict === 'resume') arm();
140
+ return verdict;
141
+ }
142
+
122
143
  return {
123
144
  decide,
145
+ peek,
146
+ arm,
124
147
  reset() {
125
148
  lastResumedAtMs = Number.NEGATIVE_INFINITY;
126
149
  },
@@ -516,6 +516,55 @@ export function isHeldUndeliverable(
516
516
  return pend.undeliverable != null
517
517
  }
518
518
 
519
+ /** The fields the on-delivery reset reads and mutates. */
520
+ export interface DeliveredPermissionEntry {
521
+ undeliverable?: UndeliverableMark | null
522
+ redeliveryFailures?: number
523
+ /** When the operator's decision window began — the TTL is measured from here. */
524
+ startedAt: number
525
+ }
526
+
527
+ /**
528
+ * A HELD card just LANDED — the operator can finally see and tap it. Clear the
529
+ * hold marks and RESTART the TTL clock, as ONE shared decision used by BOTH
530
+ * gateway.ts and the outcome test's harness.
531
+ *
532
+ * WHY THIS IS SHARED CODE AND NOT AN INLINE BLOCK (#3128):
533
+ *
534
+ * The harness used to carry a PRIVATE copy of this reset (its own
535
+ * `live.startedAt = clock.now()`), so the behavioural test that proves the
536
+ * operator gets a FULL window after re-delivery drove the double, not the real
537
+ * gateway. Deleting `startedAt = Date.now()` from gateway.ts would have left that
538
+ * test GREEN — only a source-text grep noticed, and greps drift. Same placebo-pin
539
+ * class as the leash (#3123) and the never-auto-approve pin (#3126). Now the reset
540
+ * lives here, in one importable place, and both callers drive it: delete
541
+ * `entry.startedAt = now` below and the `(d2)` outcome test goes RED, because there
542
+ * is only one implementation to delete.
543
+ *
544
+ * RESET THE TTL CLOCK is load-bearing, not cosmetic. `startedAt` is when the
545
+ * agent asked; the TTL measures how long the operator had to answer. Until the
546
+ * card lands they have NOTHING to answer — it did not exist in any chat. Without
547
+ * the reset, a card held through a 4.6h ban lands already-expired against a 60-min
548
+ * TTL and the very next reaper tick auto-denies it: the silent denial would be
549
+ * MOVED, not removed.
550
+ *
551
+ * Returns true when this was a HELD-card recovery — the caller should reconcile
552
+ * the off-Telegram surface and log the re-delivery. Returns false for a normal
553
+ * first delivery (the entry was never held), where there is nothing to reset.
554
+ *
555
+ * @see reference/invariants.md § no-self-escalation, § on-leash
556
+ */
557
+ export function applyDeliveredHoldReset(
558
+ entry: DeliveredPermissionEntry,
559
+ now: number,
560
+ ): boolean {
561
+ if (entry.undeliverable == null) return false
562
+ entry.undeliverable = null
563
+ entry.redeliveryFailures = 0
564
+ entry.startedAt = now
565
+ return true
566
+ }
567
+
519
568
  /**
520
569
  * Per-tick re-delivery cap (PR 2).
521
570
  *
@@ -32,11 +32,15 @@
32
32
  * claude itself down — where a bounce is either premature or
33
33
  * futile); the timer re-arms so a slow-booting claude gets the full
34
34
  * grace window again once it appears;
35
- * - cron-session bridges (`<agent>-cron`) and anonymous IPC clients
36
- * (recall.py one-shots, pre-handshake connects) neither satisfy nor
37
- * re-arm the watchdog the gating lives INSIDE noteBridge* so a
38
- * gateway handler refactor can't silently reintroduce the
39
- * bounce-after-cron-fire false positive;
35
+ * - cron-session bridges (`<agent>-cron`), anonymous IPC clients
36
+ * (recall.py one-shots, pre-handshake connects), AND secondary/relay
37
+ * clients that register a DIFFERENT agent name than this gateway serves
38
+ * (e.g. an `overlord-relay` connecting into another agent's gateway
39
+ * socket — #3086) neither satisfy nor re-arm the watchdog: only the
40
+ * gateway's OWN primary bridge (agentName === $SWITCHROOM_AGENT_NAME)
41
+ * counts. The gating lives INSIDE noteBridge* so a gateway handler
42
+ * refactor can't silently reintroduce the bounce-after-cron-fire or the
43
+ * bounce-after-relay-disconnect false positive;
40
44
  * - loud, structured escalation log line including a fresh
41
45
  * bridge-crash.log tail (PR #3037 breadcrumbs) so the operator sees
42
46
  * WHY from the supervisor log alone.
@@ -352,6 +356,16 @@ export interface BridgeDeadWatchdogOpts {
352
356
  * unref (the watchdog must never keep the gateway process alive). */
353
357
  setTimer?: (fn: () => void, ms: number) => unknown
354
358
  clearTimer?: (handle: unknown) => void
359
+ /** The agent identity THIS gateway serves ($SWITCHROOM_AGENT_NAME).
360
+ * Only a bridge client registering under this exact name is the
361
+ * primary bridge whose presence/absence drives the watchdog. Secondary
362
+ * or relay clients (a different named identity connecting into this
363
+ * gateway's socket — e.g. `overlord-relay`, #3086) are ignored on both
364
+ * the register and disconnect paths. When unset/empty (a misconfigured
365
+ * gateway with no agent name), the watchdog falls back to the pre-#3086
366
+ * test — any named non-cron client counts — so a genuine bridge death is
367
+ * still caught rather than silently un-guarded. */
368
+ selfAgentName?: string
355
369
  /** Injectable clock (marker ts + crash-log freshness). */
356
370
  nowMs?: () => number
357
371
  /** Injectable marker writer (tests avoid real fs). */
@@ -365,15 +379,18 @@ export interface BridgeDeadWatchdog {
365
379
  * when the cross-boot streak cap is already reached (stands down with
366
380
  * an audit line instead). */
367
381
  arm: () => void
368
- /** A bridge client registered. Only a REAL bridge (named, non-cron)
369
- * satisfies the watchdog cron sessions (`<agent>-cron`) and
370
- * anonymous clients (agentName null) are ignored HERE so a gateway
371
- * handler refactor can't reorder the gating away (review finding 5). */
382
+ /** A bridge client registered. Only THIS gateway's OWN primary bridge
383
+ * (agentName === selfAgentName, named, non-cron) satisfies the watchdog
384
+ * — cron sessions (`<agent>-cron`), anonymous clients (agentName null),
385
+ * and secondary/relay clients registering a different name (#3086) are
386
+ * ignored HERE so a gateway handler refactor can't reorder the gating
387
+ * away (review finding 5). */
372
388
  noteBridgeRegistered: (agentName: string | null | undefined) => void
373
- /** A bridge client disconnected. Re-arms the grace window only for the
374
- * real bridge (same internal gating), so a bridge that dies AFTER boot
375
- * and never reconnects also escalates. Still capped by the
376
- * once-per-boot fuse. */
389
+ /** A bridge client disconnected. Re-arms the grace window only for THIS
390
+ * gateway's own primary bridge (same internal gating), so a bridge that
391
+ * dies AFTER boot and never reconnects also escalates while a
392
+ * transient relay/secondary client's disconnect (#3086) is a no-op.
393
+ * Still capped by the once-per-boot fuse. */
377
394
  noteBridgeDisconnected: (agentName: string | null | undefined) => void
378
395
  /** Evaluate now (the timer body — exposed for tests). Returns the
379
396
  * decision taken. */
@@ -384,9 +401,34 @@ export interface BridgeDeadWatchdog {
384
401
  hasEscalated: () => boolean
385
402
  }
386
403
 
387
- /** Is this client identity the REAL main-agent bridge (named, non-cron)? */
388
- function isRealBridgeIdentity(agentName: string | null | undefined): boolean {
389
- return agentName != null && agentName.length > 0 && !isCronIdentity(agentName)
404
+ /**
405
+ * Is this client identity THIS gateway's OWN primary main-agent bridge?
406
+ *
407
+ * A named, non-cron client whose name matches the gateway's own agent
408
+ * identity (`selfAgentName` = $SWITCHROOM_AGENT_NAME). A switchroom gateway
409
+ * serves exactly one agent, and its real bridge registers under exactly that
410
+ * name (see bridge/bridge.ts — `agentName: AGENT_NAME`). Secondary or relay
411
+ * clients (e.g. an `overlord-relay` that connects into another agent's
412
+ * gateway socket, #3086) register a DIFFERENT name; they are named and
413
+ * non-cron but are NOT this gateway's primary bridge, so their register /
414
+ * disconnect must neither satisfy nor re-arm the watchdog — otherwise a
415
+ * transient relay disconnect marks the (still-alive) primary bridge dead and
416
+ * bounces a healthy container.
417
+ *
418
+ * When `selfAgentName` is unset/empty (misconfigured gateway) we cannot
419
+ * identity-match, so fall back to the pre-#3086 test — any named non-cron
420
+ * client counts — keeping the watchdog protective rather than un-guarded.
421
+ */
422
+ function isRealBridgeIdentity(
423
+ agentName: string | null | undefined,
424
+ selfAgentName: string | null | undefined,
425
+ ): boolean {
426
+ if (agentName == null || agentName.length === 0) return false
427
+ if (isCronIdentity(agentName)) return false
428
+ if (selfAgentName != null && selfAgentName.length > 0) {
429
+ return agentName === selfAgentName
430
+ }
431
+ return true
390
432
  }
391
433
 
392
434
  export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDeadWatchdog {
@@ -404,6 +446,7 @@ export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDe
404
446
  opts.readCrashTail ?? ((p: string, t: number) => readFreshCrashLogTail(p, { nowMs: t }))
405
447
  const priorStreak = opts.priorStreak ?? 0
406
448
  const maxConsecutive = opts.maxConsecutive ?? MAX_CONSECUTIVE_ESCALATIONS
449
+ const selfAgentName = opts.selfAgentName
407
450
 
408
451
  let timer: unknown = null
409
452
  let bridgeRegistered = false
@@ -528,13 +571,13 @@ export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDe
528
571
  return {
529
572
  arm: armInternal,
530
573
  noteBridgeRegistered: (agentName) => {
531
- if (!isRealBridgeIdentity(agentName)) return
574
+ if (!isRealBridgeIdentity(agentName, selfAgentName)) return
532
575
  bridgeRegistered = true
533
576
  bridgeEverRegistered = true
534
577
  cancel()
535
578
  },
536
579
  noteBridgeDisconnected: (agentName) => {
537
- if (!isRealBridgeIdentity(agentName)) return
580
+ if (!isRealBridgeIdentity(agentName, selfAgentName)) return
538
581
  bridgeRegistered = false
539
582
  armInternal()
540
583
  },