switchroom 0.18.15 → 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 (76) hide show
  1. package/dist/agent-scheduler/index.js +16 -0
  2. package/dist/auth-broker/index.js +445 -10
  3. package/dist/cli/notion-write-pretool.mjs +16 -0
  4. package/dist/cli/switchroom.js +654 -479
  5. package/dist/host-control/main.js +20 -1
  6. package/dist/vault/approvals/kernel-server.js +16 -0
  7. package/dist/vault/broker/server.js +16 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/bridge/bridge.ts +7 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  12. package/telegram-plugin/dist/gateway/gateway.js +1758 -661
  13. package/telegram-plugin/dist/server.js +26 -1
  14. package/telegram-plugin/draft-stream.ts +78 -3
  15. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  16. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  17. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +64 -22
  18. package/telegram-plugin/gateway/effort-command.ts +9 -7
  19. package/telegram-plugin/gateway/gateway.ts +627 -291
  20. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  21. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  22. package/telegram-plugin/gateway/model-command.ts +96 -18
  23. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  24. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  25. package/telegram-plugin/gateway/session-model-file.ts +141 -172
  26. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  27. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  28. package/telegram-plugin/litellm-local-notice.ts +189 -0
  29. package/telegram-plugin/llm-error-present.ts +436 -0
  30. package/telegram-plugin/operator-events.ts +7 -1
  31. package/telegram-plugin/permission-title.ts +172 -10
  32. package/telegram-plugin/premium-recovery.ts +101 -0
  33. package/telegram-plugin/quota-watch.ts +16 -4
  34. package/telegram-plugin/raw-error-scrub.ts +73 -0
  35. package/telegram-plugin/retry-api-call.ts +8 -2
  36. package/telegram-plugin/runtime-metrics.ts +16 -0
  37. package/telegram-plugin/send-gate-degraded.test.ts +161 -8
  38. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  39. package/telegram-plugin/send-gate-observability.ts +65 -20
  40. package/telegram-plugin/send-gate.test.ts +143 -1
  41. package/telegram-plugin/send-gate.ts +246 -23
  42. package/telegram-plugin/session-tail.ts +16 -0
  43. package/telegram-plugin/shared/local-time.ts +69 -0
  44. package/telegram-plugin/stream-controller.ts +143 -20
  45. package/telegram-plugin/stream-reply-handler.ts +12 -2
  46. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  47. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  48. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  49. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  50. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  51. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  52. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  53. package/telegram-plugin/tests/flood-windows-persistence.test.ts +5 -4
  54. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  55. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  56. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  57. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  58. package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
  59. package/telegram-plugin/tests/model-command.test.ts +84 -1
  60. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  61. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  62. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  63. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  64. package/telegram-plugin/tests/reaction-gate-routing.test.ts +8 -3
  65. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  66. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  67. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  68. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  69. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  70. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  71. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +212 -2
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  74. package/telegram-plugin/tier-downgrade.ts +198 -0
  75. package/telegram-plugin/tool-activity-summary.ts +99 -0
  76. package/telegram-plugin/worker-activity-feed.ts +543 -368
@@ -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) {
@@ -26,6 +26,40 @@
26
26
 
27
27
  const TELEGRAM_MAX_CHARS = 32768
28
28
 
29
+ /**
30
+ * Error a transport layer (stream-controller.ts) throws from its edit
31
+ * callback when the send gate SHED the edit — it did NOT land, and the
32
+ * stream must not record the snapshot as on-screen (#3110). Marked with a
33
+ * property (not a message pattern) so the classification is exact.
34
+ *
35
+ * `flush()` recognizes this and PRESERVES the snapshot in `shedText` so a
36
+ * later `finalize()` — including the argument-less finalize the gateway's
37
+ * cleanup paths use — re-flushes it as the stream's final state instead of
38
+ * silently losing the content (review F2). It deliberately does NOT restore
39
+ * `pendingText`: `flushLoop` drains while `pendingText != null`, and a
40
+ * restore there would busy-spin the loop against an open flood window.
41
+ */
42
+ export interface DraftEditShedError extends Error {
43
+ draftEditShed: true
44
+ }
45
+
46
+ /** Build the marker error a transport throws for a gate-shed edit. */
47
+ export function makeDraftEditShedError(messageId: number | null): DraftEditShedError {
48
+ return Object.assign(
49
+ new Error(
50
+ `draft edit shed by send gate (id=${messageId ?? 'unknown'}); snapshot preserved for re-flush`,
51
+ ),
52
+ { draftEditShed: true as const },
53
+ )
54
+ }
55
+
56
+ /** True when `err` is the shed marker thrown by a stream transport. */
57
+ export function isDraftEditShedError(err: unknown): err is DraftEditShedError {
58
+ return (
59
+ err instanceof Error && (err as Partial<DraftEditShedError>).draftEditShed === true
60
+ )
61
+ }
62
+
29
63
  // Throttle defaults for the in-place engine.
30
64
  // DM chats: 400 ms — slightly more responsive than groups while staying
31
65
  // well under Telegram's practical ~1 edit/sec/message ceiling. This
@@ -116,8 +150,17 @@ export interface DraftStreamHandle {
116
150
  * Mark the stream as final. Flushes any pending text and rejects all
117
151
  * future update() calls. Returns a promise that resolves once the final
118
152
  * edit has landed (or the initial send if no edits ever fired).
153
+ *
154
+ * When `finalText` is provided, it becomes the pending snapshot for the
155
+ * final flush (superseding any older pending draft — last-write-wins).
156
+ * Callers that know a text is the LAST one (e.g. `stream_reply`
157
+ * `done=true`) MUST pass it here instead of `update(text)` +
158
+ * `finalize()`: the flush then runs with the stream already final, so
159
+ * the transport layer (stream-controller) classifies the edit that
160
+ * renders the completed answer as `critical` for the send gate — never
161
+ * shed as a cosmetic draft under flood pressure (#3110).
119
162
  */
120
- finalize(): Promise<void>
163
+ finalize(finalText?: string): Promise<void>
121
164
 
122
165
  /** Returns the captured Telegram message_id, or null if nothing has sent yet. */
123
166
  getMessageId(): number | null
@@ -163,6 +206,14 @@ export function createDraftStream(
163
206
  let messageId: number | null = config.initialMessageId ?? null
164
207
  let pendingText: string | null = null
165
208
  let lastSentText: string | null = null
209
+ /**
210
+ * Snapshot of the newest text the transport reported as SHED (thrown
211
+ * `DraftEditShedError`) — content that never landed. Cleared on any
212
+ * successful flush (a newer snapshot superseded it) and consumed by
213
+ * `finalize()` so the stream's last state is re-delivered once the
214
+ * pressure clears instead of being lost (review F2).
215
+ */
216
+ let shedText: string | null = null
166
217
  let lastSentAt = 0
167
218
  let inFlight: Promise<void> | null = null
168
219
  // Observability — per-stream fire counters for the stream-end trace.
@@ -216,11 +267,21 @@ export function createDraftStream(
216
267
  await sendViaMessage(textToSend)
217
268
  lastSentText = textToSend
218
269
  lastSentAt = Date.now()
270
+ shedText = null // a newer snapshot landed — drop any older shed one
219
271
  } catch (err) {
220
272
  const msg = (err as Error).message ?? String(err)
221
- if (/\bmessage is not modified\b/i.test(msg)) {
273
+ if (isDraftEditShedError(err)) {
274
+ // #3110 review F2: the send gate shed this edit — it did NOT land.
275
+ // Preserve the snapshot for finalize()'s re-flush (as the stream's
276
+ // final state, sent critical) instead of silently losing it. Do NOT
277
+ // restore pendingText: flushLoop drains while pendingText != null
278
+ // and would busy-spin against an open flood window.
279
+ shedText = textToSend
280
+ log?.(`stream → shed by send gate (id: ${messageId}); snapshot preserved for re-flush`)
281
+ } else if (/\bmessage is not modified\b/i.test(msg)) {
222
282
  lastSentText = textToSend
223
283
  lastSentAt = Date.now()
284
+ shedText = null // on-screen text == this snapshot; nothing to recover
224
285
  log?.(`stream → not modified (id: ${messageId})`)
225
286
  } else if (
226
287
  /\bmessage to edit not found\b/i.test(msg)
@@ -327,9 +388,14 @@ export function createDraftStream(
327
388
  return waitPromise
328
389
  },
329
390
 
330
- async finalize(): Promise<void> {
391
+ async finalize(finalText?: string): Promise<void> {
331
392
  if (final) return
332
393
  final = true
394
+ // A caller-supplied final snapshot supersedes any pending draft
395
+ // (last-write-wins) and is flushed below with `final` already set,
396
+ // so the transport classifies this edit as the answer's final
397
+ // render, not a sheddable draft (#3110).
398
+ if (finalText != null && !stopped) pendingText = finalText
333
399
  // Drain any pending updates
334
400
  if (scheduledTimer != null) {
335
401
  clearTimeout(scheduledTimer)
@@ -338,6 +404,15 @@ export function createDraftStream(
338
404
  if (inFlight) {
339
405
  await inFlight
340
406
  }
407
+ // #3110 review F2: if the newest snapshot was SHED by the send gate
408
+ // (never landed) and nothing newer is pending, re-flush it as the
409
+ // stream's final state. Checked AFTER awaiting inFlight so a flush
410
+ // that sheds mid-finalize is recovered too. A provided finalText and
411
+ // any pending draft both outrank the shed snapshot (they are newer).
412
+ if (pendingText == null && shedText != null && !stopped) {
413
+ pendingText = shedText
414
+ }
415
+ shedText = null
341
416
  if (pendingText != null && !stopped) {
342
417
  await flush()
343
418
  }
@@ -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.
@@ -60,10 +64,9 @@ import { readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
60
64
  import { isCronIdentity } from './cron-session.js'
61
65
  import type { InboundMessage } from './ipc-protocol.js'
62
66
 
63
- /** The distinct triggerSelfRestart reason for this escalation. Classified
64
- * as intent 'keep' by intentForRestartReason (recovery bounce — session
65
- * model stickiness preserved), like every other switchroom-managed
66
- * relaunch. */
67
+ /** The distinct triggerSelfRestart reason for this escalation. Like every
68
+ * other switchroom-managed relaunch it reverts any session `/model` override
69
+ * to the configured default (session-scoped, rev 4). */
67
70
  export const BRIDGE_DEAD_RESTART_REASON = 'bridge-dead-resume'
68
71
 
69
72
  /** Default grace window before a missing bridge is treated as dead.
@@ -353,6 +356,16 @@ export interface BridgeDeadWatchdogOpts {
353
356
  * unref (the watchdog must never keep the gateway process alive). */
354
357
  setTimer?: (fn: () => void, ms: number) => unknown
355
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
356
369
  /** Injectable clock (marker ts + crash-log freshness). */
357
370
  nowMs?: () => number
358
371
  /** Injectable marker writer (tests avoid real fs). */
@@ -366,15 +379,18 @@ export interface BridgeDeadWatchdog {
366
379
  * when the cross-boot streak cap is already reached (stands down with
367
380
  * an audit line instead). */
368
381
  arm: () => void
369
- /** A bridge client registered. Only a REAL bridge (named, non-cron)
370
- * satisfies the watchdog — cron sessions (`<agent>-cron`) and
371
- * anonymous clients (agentName null) are ignored HERE so a gateway
372
- * 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). */
373
388
  noteBridgeRegistered: (agentName: string | null | undefined) => void
374
- /** A bridge client disconnected. Re-arms the grace window only for the
375
- * real bridge (same internal gating), so a bridge that dies AFTER boot
376
- * and never reconnects also escalates. Still capped by the
377
- * 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. */
378
394
  noteBridgeDisconnected: (agentName: string | null | undefined) => void
379
395
  /** Evaluate now (the timer body — exposed for tests). Returns the
380
396
  * decision taken. */
@@ -385,9 +401,34 @@ export interface BridgeDeadWatchdog {
385
401
  hasEscalated: () => boolean
386
402
  }
387
403
 
388
- /** Is this client identity the REAL main-agent bridge (named, non-cron)? */
389
- function isRealBridgeIdentity(agentName: string | null | undefined): boolean {
390
- 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
391
432
  }
392
433
 
393
434
  export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDeadWatchdog {
@@ -405,6 +446,7 @@ export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDe
405
446
  opts.readCrashTail ?? ((p: string, t: number) => readFreshCrashLogTail(p, { nowMs: t }))
406
447
  const priorStreak = opts.priorStreak ?? 0
407
448
  const maxConsecutive = opts.maxConsecutive ?? MAX_CONSECUTIVE_ESCALATIONS
449
+ const selfAgentName = opts.selfAgentName
408
450
 
409
451
  let timer: unknown = null
410
452
  let bridgeRegistered = false
@@ -529,13 +571,13 @@ export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDe
529
571
  return {
530
572
  arm: armInternal,
531
573
  noteBridgeRegistered: (agentName) => {
532
- if (!isRealBridgeIdentity(agentName)) return
574
+ if (!isRealBridgeIdentity(agentName, selfAgentName)) return
533
575
  bridgeRegistered = true
534
576
  bridgeEverRegistered = true
535
577
  cancel()
536
578
  },
537
579
  noteBridgeDisconnected: (agentName) => {
538
- if (!isRealBridgeIdentity(agentName)) return
580
+ if (!isRealBridgeIdentity(agentName, selfAgentName)) return
539
581
  bridgeRegistered = false
540
582
  armInternal()
541
583
  },
@@ -65,8 +65,9 @@ export function parseEffortCommand(text: string): ParsedEffortCommand | null {
65
65
  }
66
66
  const arg = parts[0]
67
67
  if (arg.toLowerCase() === 'help') return { kind: 'help' }
68
- // `/effort default` — explicit user action that clears the durable
69
- // `.session-effort` override and restores the configured default (#3039).
68
+ // `/effort default` — explicit user action that clears the session
69
+ // override (in-memory + any leftover queued-command carrier) and restores
70
+ // the configured default (#3186, session-scoped).
70
71
  if (arg.toLowerCase() === 'default') return { kind: 'default' }
71
72
  if (!isValidEffortArg(arg)) {
72
73
  return { kind: 'help', reason: `not a valid effort level: ${arg}` }
@@ -91,14 +92,15 @@ export interface EffortCommandDeps {
91
92
  */
92
93
  getConfiguredEffort: () => string | null
93
94
  /**
94
- * Delete the durable `.session-effort` override (#3039). Optional so
95
+ * Clear the session effort override (#3186: the in-memory live level plus
96
+ * any leftover queued-command `.session-effort` carrier). Optional so
95
97
  * gateway-agnostic tests can omit it; the gateway always wires it.
96
98
  */
97
99
  clearSessionEffort?: () => void
98
100
  /**
99
- * The active durable `.session-effort` override level, or null when none
100
- * (#3039). Optional; used to mark the LIVE level in the menu and the show
101
- * text honestly after a restart re-applied the override.
101
+ * The active session effort override level, or null when none (#3186:
102
+ * in-memory, session-scoped — reverts on restart). Optional; used to mark
103
+ * the LIVE level in the menu and the show text honestly.
102
104
  */
103
105
  getSessionEffort?: () => string | null
104
106
  escapeHtml: (s: string) => string
@@ -110,7 +112,7 @@ export interface EffortCommandReply {
110
112
  }
111
113
 
112
114
  const PERSIST_NOTE =
113
- '_Sticky — persists across restarts and deploys until \`/effort default\` clears it. To change the configured default, set \`thinking_effort:\` in switchroom.yaml._'
115
+ '_Session-only — this override lasts until the agent’s next restart, then reverts to the configured \`thinking_effort:\`. \`/effort default\` clears it now. To change the default permanently, set \`thinking_effort:\` in switchroom.yaml._'
114
116
 
115
117
  const LEVELS_INLINE = EFFORT_LEVELS.map(l => `\`${l}\``).join(' · ')
116
118