switchroom 0.20.10 → 0.20.12

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 +68 -3
  2. package/dist/auth-broker/index.js +211 -46
  3. package/dist/cli/notion-write-pretool.mjs +68 -3
  4. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  5. package/dist/cli/self-improve-stop.mjs +889 -7
  6. package/dist/cli/skill-validate-pretool.mjs +82 -3
  7. package/dist/cli/switchroom.js +5063 -2961
  8. package/dist/host-control/main.js +93 -27
  9. package/dist/vault/approvals/kernel-server.js +92 -26
  10. package/dist/vault/broker/server.js +92 -26
  11. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  14. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  15. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  17. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  18. package/profiles/default/CLAUDE.md.hbs +3 -5
  19. package/skills/switchroom-architecture/telegram.md +0 -1
  20. package/skills/switchroom-cli/SKILL.md +0 -1
  21. package/telegram-plugin/README.md +2 -11
  22. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  23. package/telegram-plugin/bridge/bridge.ts +0 -12
  24. package/telegram-plugin/chat-lock.ts +1 -1
  25. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +1454 -976
  27. package/telegram-plugin/dist/server.js +0 -12
  28. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  29. package/telegram-plugin/gateway/auth-command.ts +11 -1
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  31. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  32. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  33. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  34. package/telegram-plugin/gateway/gateway.ts +109 -149
  35. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  36. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  37. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  38. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  39. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  40. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  41. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  42. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  43. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  44. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  45. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  46. package/telegram-plugin/gateway/stale-pin-sweep.ts +52 -35
  47. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  48. package/telegram-plugin/gateway/stream-render.ts +4 -4
  49. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  50. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  51. package/telegram-plugin/hooks/hooks.json +13 -12
  52. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  53. package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
  54. package/telegram-plugin/slot-banner-driver.ts +42 -5
  55. package/telegram-plugin/status-pin.ts +2 -5
  56. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  57. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  58. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  59. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  60. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  61. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  62. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  63. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  64. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  65. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  66. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  68. package/telegram-plugin/throttle-tier.ts +12 -3
  69. package/telegram-plugin/turn-flush-safety.ts +97 -0
  70. package/telegram-plugin/worker-activity-feed.ts +1 -1
  71. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  72. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  73. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  74. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
@@ -90,7 +90,7 @@ import { formatTurnLifecycle } from './status-surface-log.js'
90
90
  import { removeTurnActiveMarker, touchTurnActiveMarker, writeTurnActiveMarker } from './turn-active-marker.js'
91
91
  import { withTurnEndGateBackstop } from './turn-end-gate-backstop.js'
92
92
  import { decideTurnEndGate } from './turn-end-gate.js'
93
- import { finalizeBackstopSendGated } from './turn-record-status.js'
93
+ import { finalizeBackstopSendGated, computeTurnDurationMs } from './turn-record-status.js'
94
94
  import type { SilentEndDeps } from '../silent-end.js'
95
95
  import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
96
96
  import type { SessionEvent } from '../session-tail.js'
@@ -2174,7 +2174,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2174
2174
  chatId,
2175
2175
  threadId,
2176
2176
  turnId: turn.turnId,
2177
- turnDurationMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
2177
+ turnDurationMs: computeTurnDurationMs(turn.startedAt, Date.now()),
2178
2178
  reactionCount: reactionTransitionCounts.get(tKey) ?? 0,
2179
2179
  })
2180
2180
  }
@@ -2214,7 +2214,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2214
2214
  // still appear in turn-duration graphs.
2215
2215
  {
2216
2216
  const sKey = streamKey(chatId, threadId)
2217
- const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
2217
+ const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
2218
2218
  logStreamingEvent({
2219
2219
  kind: 'turn_end',
2220
2220
  chatId,
@@ -2799,7 +2799,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2799
2799
  finalizeStatusReaction(chatId, threadId, terminalReason)
2800
2800
  {
2801
2801
  const sKey = streamKey(chatId, threadId)
2802
- const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
2802
+ const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
2803
2803
  logStreamingEvent({
2804
2804
  kind: 'turn_end',
2805
2805
  chatId,
@@ -73,6 +73,10 @@ export interface ThrottleBrokerClient {
73
73
  throttled_until: number
74
74
  escalated: boolean
75
75
  rolledTo?: string | null
76
+ /** True when the caller has a strict pin (`auth.strict`) — its null
77
+ * `rolledTo` means "riding out the wall", not fleet all-blocked.
78
+ * Absent on pre-flag brokers. */
79
+ caller_pinned_strict?: boolean
76
80
  }>
77
81
  claimNotification(key: string, windowMs: number): Promise<{ granted: boolean }>
78
82
  }
@@ -214,19 +218,22 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
214
218
  client: ThrottleBrokerClient | null,
215
219
  account: string | null,
216
220
  rolledTo: string | null,
221
+ callerPinnedStrict: boolean,
217
222
  triggerAgent: string,
218
223
  armedAtMs: number,
219
224
  ): Promise<void> {
220
225
  deps.log(
221
226
  `[throttle-tier] escalated to wall account=${account ?? '?'} ` +
222
- `rolledTo=${rolledTo ?? 'none (all blocked)'}`,
227
+ `rolledTo=${rolledTo ?? (callerPinnedStrict ? 'none (strict pin, riding it out)' : 'none (all blocked)')}`,
223
228
  )
224
229
  await broadcastDeduped(
225
230
  client,
226
231
  'throttle-escalation',
227
232
  account,
228
- renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo }),
233
+ renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo, callerPinnedStrict }),
229
234
  )
235
+ // No nudge on a null rolledTo — for a strict pin a resume would replay
236
+ // the turn straight back into the wall it just hit.
230
237
  if (rolledTo) nudgeResume('throttle-escalation-resume', armedAtMs)
231
238
  }
232
239
 
@@ -240,6 +247,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
240
247
  let account: string | null = null
241
248
  let escalated = false
242
249
  let rolledTo: string | null = null
250
+ let callerPinnedStrict = false
243
251
  try {
244
252
  client = await deps.getBrokerClient()
245
253
  if (client) {
@@ -247,6 +255,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
247
255
  account = r.account
248
256
  escalated = r.escalated
249
257
  rolledTo = r.rolledTo ?? null
258
+ callerPinnedStrict = r.caller_pinned_strict ?? false
250
259
  } else {
251
260
  deps.log(
252
261
  `[throttle-tier] broker unreachable — notice only, no ledger record agent=${triggerAgent}`,
@@ -259,7 +268,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
259
268
  }
260
269
 
261
270
  if (escalated) {
262
- await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
271
+ await announceEscalation(client, account, rolledTo, callerPinnedStrict, triggerAgent, armedAtMs)
263
272
  return
264
273
  }
265
274
 
@@ -303,6 +312,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
303
312
  let account: string | null = null
304
313
  let escalated = false
305
314
  let rolledTo: string | null = null
315
+ let callerPinnedStrict = false
306
316
  try {
307
317
  client = await deps.getBrokerClient()
308
318
  if (client) {
@@ -312,6 +322,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
312
322
  account = r.account
313
323
  escalated = r.escalated
314
324
  rolledTo = r.rolledTo ?? null
325
+ callerPinnedStrict = r.caller_pinned_strict ?? false
315
326
  } else {
316
327
  deps.log(
317
328
  `[throttle-tier] broker unreachable — probe-only skipped agent=${triggerAgent}`,
@@ -324,7 +335,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
324
335
  }
325
336
 
326
337
  if (escalated) {
327
- await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
338
+ await announceEscalation(client, account, rolledTo, callerPinnedStrict, triggerAgent, armedAtMs)
328
339
  return
329
340
  }
330
341
 
@@ -24,6 +24,37 @@
24
24
  */
25
25
  export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
26
26
 
27
+ /**
28
+ * The ONE way a turn's `duration_ms` is computed, shared by every emitter
29
+ * (`buildTurnRecord` → turns.jsonl, and all three `turn_ended` runtime-metric
30
+ * sites: the two stream-render turn-end paths + the liveness framework_fallback).
31
+ *
32
+ * Why a single helper: the three `turn_ended` emitters each hand-rolled the
33
+ * subtraction, and one drifted. `stream-render.ts` guarded with
34
+ * `startedAt > 0 ? now - startedAt : 0`; the framework_fallback path in
35
+ * `liveness-wiring.ts` did a bare `Date.now() - turnStartedAt` with only a
36
+ * `!= null` presence check. When `activeTurnStartedAt` held `0` (or any
37
+ * non-positive / non-finite value), that path emitted `duration_ms = now - 0`,
38
+ * i.e. the absolute Unix-epoch-ms — a ~56,000-year "duration". This poisoned the
39
+ * analysed turn_ended dataset (observed: 110 rows where `duration_ms === ts`),
40
+ * making every latency aggregate unusable.
41
+ *
42
+ * The invariant this helper enforces: a `duration_ms` is ALWAYS a non-negative
43
+ * elapsed-milliseconds value derived from a positive, finite start stamp — never
44
+ * an absolute epoch value, never negative (clock step back), never NaN. A start
45
+ * that is missing / zero / bogus yields `0` (a bounded, filterable sentinel)
46
+ * instead of a value that destroys aggregates.
47
+ */
48
+ export function computeTurnDurationMs(
49
+ startedAt: number | null | undefined,
50
+ now: number,
51
+ ): number {
52
+ if (typeof startedAt !== 'number' || !Number.isFinite(startedAt) || startedAt <= 0) {
53
+ return 0
54
+ }
55
+ return Math.max(0, now - startedAt)
56
+ }
57
+
27
58
  /** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
28
59
  export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
29
60
 
@@ -261,7 +292,7 @@ export function buildTurnRecord(
261
292
  return {
262
293
  ts: Math.floor(endedAt / 1000),
263
294
  agent: turn.agent,
264
- duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
295
+ duration_ms: computeTurnDurationMs(turn.startedAt, endedAt),
265
296
  tools: turn.toolCallCount ?? 0,
266
297
  status: computeTurnStatus(turn),
267
298
  turn_id: turn.turnId,
@@ -5,16 +5,17 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
8
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
9
9
  "timeout": 10
10
10
  }
11
11
  ]
12
12
  },
13
13
  {
14
+ "matcher": "^mcp__switchroom-telegram__(reply|stream_reply)$",
14
15
  "hooks": [
15
16
  {
16
17
  "type": "command",
17
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
18
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
18
19
  "timeout": 5
19
20
  }
20
21
  ]
@@ -24,7 +25,7 @@
24
25
  "hooks": [
25
26
  {
26
27
  "type": "command",
27
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
28
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
28
29
  "timeout": 10
29
30
  }
30
31
  ]
@@ -33,7 +34,7 @@
33
34
  "hooks": [
34
35
  {
35
36
  "type": "command",
36
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
37
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
37
38
  "timeout": 5
38
39
  }
39
40
  ]
@@ -43,7 +44,7 @@
43
44
  "hooks": [
44
45
  {
45
46
  "type": "command",
46
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
47
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
47
48
  "timeout": 5
48
49
  }
49
50
  ]
@@ -55,7 +56,7 @@
55
56
  "hooks": [
56
57
  {
57
58
  "type": "command",
58
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
59
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
59
60
  "timeout": 10
60
61
  }
61
62
  ]
@@ -65,7 +66,7 @@
65
66
  "hooks": [
66
67
  {
67
68
  "type": "command",
68
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
69
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
69
70
  "timeout": 3
70
71
  }
71
72
  ]
@@ -76,7 +77,7 @@
76
77
  "hooks": [
77
78
  {
78
79
  "type": "command",
79
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/compaction-marker-precompact.mjs\"",
80
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/compaction-marker-precompact.mjs\"",
80
81
  "timeout": 5
81
82
  }
82
83
  ]
@@ -87,7 +88,7 @@
87
88
  "hooks": [
88
89
  {
89
90
  "type": "command",
90
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
91
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
91
92
  "timeout": 15,
92
93
  "async": true
93
94
  }
@@ -97,7 +98,7 @@
97
98
  "hooks": [
98
99
  {
99
100
  "type": "command",
100
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
101
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
101
102
  "timeout": 5
102
103
  }
103
104
  ]
@@ -106,7 +107,7 @@
106
107
  "hooks": [
107
108
  {
108
109
  "type": "command",
109
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-claim-stop.mjs\"",
110
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-claim-stop.mjs\"",
110
111
  "timeout": 5
111
112
  }
112
113
  ]
@@ -115,7 +116,7 @@
115
116
  "hooks": [
116
117
  {
117
118
  "type": "command",
118
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
119
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
119
120
  "timeout": 5,
120
121
  "async": true
121
122
  }
@@ -108,7 +108,7 @@ export function isStructuralNarration(text, followedByToolUse) {
108
108
  * follow-up, MF4). A text block followed ONLY by tools in this set is still the
109
109
  * TERMINAL answer for backstop-coalescing purposes — these tools carry no
110
110
  * model-authored answer text and are the only ones plausibly fired AFTER a
111
- * terminal answer (answer, then react / pin / typing / edit). Everything NOT in
111
+ * terminal answer (answer, then react / typing / edit). Everything NOT in
112
112
  * this set — work / deliverable tools (download_attachment, get_recent_messages,
113
113
  * send_checklist, ask_user, …) and every non-telegram tool (retain / read /
114
114
  * bash / web / …) — is turn-CONTINUING: a text block a real tool followed is
@@ -123,7 +123,6 @@ export function isStructuralNarration(text, followedByToolUse) {
123
123
  export const EPHEMERAL_TOOLS = new Set([
124
124
  'react',
125
125
  'send_typing',
126
- 'pin_message',
127
126
  'delete_message',
128
127
  'edit_message',
129
128
  ])
@@ -41,7 +41,7 @@
41
41
  // Cross-checked the full tool surface in `telegram-plugin/bridge/bridge.ts`
42
42
  // (`TOOL_SCHEMAS`, kept in sync with `gateway/gateway.ts`): `edit_message`
43
43
  // explicitly does NOT ping/deliver a fresh answer (its own description says
44
- // "send a new reply when a long task completes"); `react`, `pin_message`,
44
+ // "send a new reply when a long task completes"); `react`,
45
45
  // `delete_message`, `forward_message`, `send_typing`, `download_attachment`,
46
46
  // `get_recent_messages` carry no model-authored answer text at all;
47
47
  // `send_checklist` / `send_sticker` / `send_gif` / `ask_user` /
@@ -22,6 +22,11 @@
22
22
 
23
23
  import type { BannerState } from './slot-banner.js';
24
24
  import { decideBannerAction } from './slot-banner.js';
25
+ // Shared rights model (D5): the banner adopts the status-pin path's rights
26
+ // detector, terminal-unpin classifier and per-process negative cache, so the two
27
+ // pin owners agree on which chats are rights-less instead of the banner's old
28
+ // drop-on-ANY-unpin. status-pin.ts is dependency-free.
29
+ import { isPinRightsError, isUnpinTerminalError, type PinRightsCache } from './status-pin.js';
25
30
 
26
31
  /** Minimal subset of grammy's `bot.api` we depend on. Letting tests
27
32
  * swap in `fake-bot-api.ts` without dragging in the full Bot type. */
@@ -72,6 +77,15 @@ export interface RefreshBannerArgs {
72
77
  /** Optional API-failure observer. Phase identifies which Bot API
73
78
  * call failed so the caller can log meaningfully. Default: silent. */
74
79
  onError?: (phase: 'pin' | 'edit' | 'unpin', err: unknown) => void;
80
+ /** Shared per-process pin-rights negative cache (status-pin.ts
81
+ * `PinRightsCache`), unifying the banner with the status-pin path on
82
+ * rights-less chats (D5). When the chat is already known rights-less the pin
83
+ * action is skipped so we never emit an un-pinnable notice; a real pin-rights
84
+ * 400 from the pin/unpin verb records the block and a confirmed pin clears
85
+ * it. Fed ONLY from the pin/unpin verbs — a `sendMessage` failure is never
86
+ * cached, because a send failure is not a pin-rights signal. Optional;
87
+ * omitted in unit tests. */
88
+ rightsCache?: Pick<PinRightsCache, 'isBlocked' | 'block' | 'clear'>;
75
89
  /** Optional durable-persistence hooks so an orphaned banner pin is
76
90
  * recoverable across a gateway crash. The gateway wires these to the
77
91
  * shared status-pin store (a distinct `banner:` pinKey), mirroring the
@@ -127,20 +141,34 @@ export async function refreshBanner(
127
141
 
128
142
  if (action.kind === 'unpin') {
129
143
  try {
144
+ // allow-raw-pin: the slot banner is a sanctioned separate pin owner (see
145
+ // scripts/check-status-pin-single-path-allowlist.txt); its unpin is not
146
+ // routed through reconcileStatusPin.
130
147
  await args.bot.api.unpinChatMessage(args.ownerChatId, action.messageId);
131
148
  } catch (err) {
132
149
  args.onError?.('unpin', err);
150
+ // Feed the shared negative cache ONLY from a real pin-rights 400 on the
151
+ // unpin verb — never from any other failure class (D5).
152
+ if (isPinRightsError(err)) args.rightsCache?.block(String(args.ownerChatId));
153
+ // Adopt the status-pin terminal-vs-never-confirmed contract (#3664 Defect
154
+ // B) instead of drop-on-ANY-unpin: retain the claim when the unpin did
155
+ // NOT terminally land (transient flood/5xx/network), so the next refresh
156
+ // retries rather than orphaning a still-pinned banner with no record. A
157
+ // terminal 4xx (rights, message gone, bot kicked) — and a success — fall
158
+ // through to the drop below, because re-issuing cannot help.
159
+ if (!isUnpinTerminalError(err)) return args.prevState;
133
160
  }
134
- // Even if unpin failed, drop our claim — the message may have been
135
- // unpinned out-of-band (operator did it manually) and re-pinning
136
- // would be more confusing than surfacing it again later. Drop the
137
- // persisted record too (unpin-then-clear mirrors the status-pin store:
138
- // a crash between unpin and clear just re-unpins next boot, idempotent).
161
+ // Terminal outcome: drop our claim and the persisted record. A crash
162
+ // between unpin and clear just re-unpins next boot (idempotent).
139
163
  safePersist(args.persist?.clear);
140
164
  return null;
141
165
  }
142
166
 
143
167
  if (action.kind === 'pin') {
168
+ // The banner is a PINNED notice: if this chat is already known rights-less
169
+ // (proved by the status-pin path or an earlier banner pin), skip the whole
170
+ // send+pin — an un-pinnable send is pure noise (D5).
171
+ if (args.rightsCache?.isBlocked(String(args.ownerChatId))) return args.prevState;
144
172
  let sent: { message_id: number };
145
173
  try {
146
174
  // sendRichMessage doesn't accept link_preview_options — omit it.
@@ -150,6 +178,8 @@ export async function refreshBanner(
150
178
  disable_notification: true,
151
179
  });
152
180
  } catch (err) {
181
+ // SEND failure — deliberately NOT a pin-rights signal, so the cache is
182
+ // NEVER written here even though the phase tag below is 'pin' (D5).
153
183
  args.onError?.('pin', err);
154
184
  return args.prevState;
155
185
  }
@@ -158,17 +188,24 @@ export async function refreshBanner(
158
188
  // unpins next boot — closing the persist-after-pin leak.
159
189
  safePersist(() => args.persist?.pending?.(String(args.ownerChatId), sent.message_id));
160
190
  try {
191
+ // allow-raw-pin: sanctioned banner pin owner (see the allowlist).
161
192
  await args.bot.api.pinChatMessage(args.ownerChatId, sent.message_id, {
162
193
  disable_notification: true,
163
194
  });
164
195
  } catch (err) {
165
196
  args.onError?.('pin', err);
197
+ // The PIN verb failing with a real pin-rights 400 IS the rights signal —
198
+ // record it so later refreshes skip the doomed send+pin (D5).
199
+ if (isPinRightsError(err)) args.rightsCache?.block(String(args.ownerChatId));
166
200
  // sendMessage succeeded but pin failed — don't claim the message, and
167
201
  // drop the pending record so we don't leave a phantom claim for a pin
168
202
  // that never landed.
169
203
  safePersist(args.persist?.clear);
170
204
  return args.prevState;
171
205
  }
206
+ // Pin confirmed — rights are present, so clear any stale block for this chat
207
+ // (mirrors the status-pin path clearing on a successful pin).
208
+ args.rightsCache?.clear(String(args.ownerChatId));
172
209
  // Pin confirmed — rewrite the record without the pending flag.
173
210
  safePersist(() => args.persist?.confirm?.(String(args.ownerChatId), sent.message_id));
174
211
  return { messageId: sent.message_id, slot: action.slot };
@@ -210,12 +210,9 @@ export function isUnpinTerminalError(err: unknown): boolean {
210
210
  * Deliberately IN-MEMORY / per-boot only: pin rights may be granted later, and
211
211
  * Telegram surfaces the new permission to the bot only on a fresh chat-member
212
212
  * fetch — a gateway restart. Clearing the cache on restart is therefore the
213
- * correct re-enable trigger. An explicit `pin_message` tool success in a chat
214
- * also clears its entry (rights were granted mid-session).
213
+ * correct re-enable trigger.
215
214
  *
216
- * Scope: the AUTO status-pin path only. The explicit `pin_message` MCP tool
217
- * never consults this cache — it always attempts and surfaces the error to the
218
- * agent as a normal tool error.
215
+ * Scope: the AUTO status-pin path only (`pin_status_while_working`).
219
216
  */
220
217
  export class PinRightsCache {
221
218
  private readonly blocked = new Set<string>()
@@ -190,6 +190,30 @@ describe('runFleetAutoFallback', () => {
190
190
  expect(failover).toHaveBeenCalledTimes(1);
191
191
  });
192
192
 
193
+ it('strict-pinned caller: null rolledTo yields the strict-pinned outcome, NOT all-blocked', async () => {
194
+ // agents.<name>.auth.strict — the broker marked the account but
195
+ // deliberately did not roll the caller. The all-blocked card here would
196
+ // claim fleet-wide exhaustion while its own snapshots show healthy
197
+ // accounts.
198
+ const failover = vi.fn(async () => ({
199
+ rolledTo: null, rolled: [], callerPinnedStrict: true,
200
+ }));
201
+ const out = await runFleetAutoFallback({
202
+ state: state('work@x', ['work@x']),
203
+ quotas: [qOk({ fiveHourUtilizationPct: 12, sevenDayUtilizationPct: 30 })],
204
+ failover,
205
+ triggerAgent: 'workbot',
206
+ now: NOW,
207
+ tz: 'UTC',
208
+ rateLimitTrigger: true,
209
+ });
210
+ expect(out.kind).toBe('strict-pinned');
211
+ expect(failover).toHaveBeenCalledTimes(1);
212
+ expect(out.announcement).toContain('strictly pinned');
213
+ expect(out.announcement).toContain('fleet is unaffected');
214
+ expect(out.announcement).not.toContain('blocked');
215
+ });
216
+
193
217
  it('idempotency: skips the swap WITHOUT calling failover when active probes healthy', async () => {
194
218
  const failover = vi.fn();
195
219
  const out = await runFleetAutoFallback({
@@ -121,9 +121,9 @@ describe('selectBackstopDelivery — deterministic backstop coalescer (#3513 fol
121
121
  // ── isEphemeralTool — MF4 exact set ─────────────────────────────────────────
122
122
 
123
123
  describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
124
- it('recognises exactly {react, send_typing, pin_message, delete_message, edit_message}, MCP-prefixed or bare', () => {
124
+ it('recognises exactly {react, send_typing, delete_message, edit_message}, MCP-prefixed or bare', () => {
125
125
  expect([...EPHEMERAL_TOOLS].sort()).toEqual(
126
- ['delete_message', 'edit_message', 'pin_message', 'react', 'send_typing'].sort(),
126
+ ['delete_message', 'edit_message', 'react', 'send_typing'].sort(),
127
127
  )
128
128
  for (const t of EPHEMERAL_TOOLS) {
129
129
  expect(isEphemeralTool(t)).toBe(true)
@@ -132,6 +132,12 @@ describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
132
132
  }
133
133
  })
134
134
 
135
+ it('pin_message is NOT ephemeral — the pin_message MCP tool was retired (#4452)', () => {
136
+ expect(EPHEMERAL_TOOLS.has('pin_message')).toBe(false)
137
+ expect(isEphemeralTool('pin_message')).toBe(false)
138
+ expect(isEphemeralTool('mcp__switchroom-telegram__pin_message')).toBe(false)
139
+ })
140
+
135
141
  it('progress_update is NOT ephemeral (MF4 — dropped from the set)', () => {
136
142
  expect(isEphemeralTool('progress_update')).toBe(false)
137
143
  expect(isEphemeralTool('mcp__switchroom-telegram__progress_update')).toBe(false)
@@ -0,0 +1,125 @@
1
+ /**
2
+ * M0 metric-corruption regression — the framework_fallback `turn_ended` emitter
3
+ * must never write an absolute Unix-epoch value as `duration_ms`.
4
+ *
5
+ * ROOT CAUSE (fixed by this change): the 300 s framework-fallback unwedge in
6
+ * `liveness-wiring.ts` read the turn's start from `activeTurnStartedAt.get(key)`
7
+ * and, guarded only by a `!= null` presence check, computed
8
+ * `Date.now() - turnStartedAt` inline. When the map held `0` (a bogus / not-yet
9
+ * stamped start — reproduced here exactly as the parked-turn fixture does with
10
+ * `activeTurnStartedAt.set(KEY, 0)`), it emitted `duration_ms = Date.now() - 0`,
11
+ * i.e. the current epoch-ms — a ~56,000-year "duration". The analysed dataset
12
+ * carried 110 such rows (`duration_ms === ts`, `ended_via: "framework_fallback"`),
13
+ * making every latency aggregate unusable.
14
+ *
15
+ * The two `stream-render.ts` turn_ended paths already guarded with
16
+ * `startedAt > 0 ? … : 0`; this path had drifted. The fix routes all emitters
17
+ * through the shared `computeTurnDurationMs`, which returns 0 for a non-positive
18
+ * start.
19
+ *
20
+ * This test drives the REAL `onFrameworkFallback` (via `buildSilencePokeOptions`,
21
+ * same standard as the other liveness-wiring tests) with a 0 start and asserts on
22
+ * the ACTUAL emitted `turn_ended` row (captured through the real runtime-metrics
23
+ * JSONL sink, pinned to a temp file). It asserts the OUTCOME — the emitted
24
+ * `duration_ms` — not a code path.
25
+ *
26
+ * Pre-fix: `duration_ms` ≈ `Date.now()` (~1.78e12). Post-fix: `duration_ms === 0`.
27
+ */
28
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
29
+ import { mkdtempSync, readFileSync, existsSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { join } from 'node:path'
32
+ import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
33
+ import {
34
+ __setRuntimeMetricsPathForTests,
35
+ } from '../runtime-metrics.js'
36
+ import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
37
+
38
+ const CHAT = '-100999'
39
+
40
+ function readTurnEndedRows(path: string): Array<Record<string, unknown>> {
41
+ if (!existsSync(path)) return []
42
+ return readFileSync(path, 'utf-8')
43
+ .split('\n')
44
+ .filter((l) => l.trim() !== '')
45
+ .map((l) => JSON.parse(l) as Record<string, unknown>)
46
+ .filter((r) => r.kind === 'turn_ended')
47
+ }
48
+
49
+ describe('framework_fallback turn_ended never emits an epoch value as duration_ms', () => {
50
+ let metricsPath: string
51
+
52
+ beforeEach(() => {
53
+ const dir = mkdtempSync(join(tmpdir(), 'fallback-duration-'))
54
+ metricsPath = join(dir, 'runtime-metrics.jsonl')
55
+ __setRuntimeMetricsPathForTests(metricsPath)
56
+ })
57
+
58
+ afterEach(() => {
59
+ __setRuntimeMetricsPathForTests(null)
60
+ })
61
+
62
+ it('emits duration_ms === 0 for a zero/bogus start instead of Date.now()', async () => {
63
+ const KEY = statusKeyForTests(CHAT, null)
64
+ const fx = makeLivenessFixture()
65
+
66
+ // The wedged turn's start is 0 — the exact corruption trigger. Before the
67
+ // fix this made the fallback emit `Date.now() - 0`.
68
+ fx.activeTurnStartedAt.set(KEY, 0)
69
+ fx.setCurrentTurn(
70
+ makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#42` }),
71
+ )
72
+
73
+ const opts = buildSilencePokeOptions(fx.deps)
74
+ await opts.onFrameworkFallback({
75
+ key: KEY,
76
+ chatId: CHAT,
77
+ threadId: null,
78
+ fallbackKind: 'working',
79
+ silenceMs: 302_000,
80
+ inFlightTools: [],
81
+ })
82
+
83
+ const rows = readTurnEndedRows(metricsPath)
84
+ const fallbackRow = rows.find((r) => r.ended_via === 'framework_fallback')
85
+ expect(fallbackRow, 'framework_fallback turn_ended row was emitted').toBeDefined()
86
+
87
+ const duration = fallbackRow!.duration_ms as number
88
+ // The falsifying assertion: pre-fix this is ~Date.now() (an epoch-ms value,
89
+ // ~1.78e12), and in particular equals the row's own `ts`. Post-fix it is 0.
90
+ expect(duration).toBe(0)
91
+ // Belt-and-braces: a duration can never be an absolute epoch stamp, and can
92
+ // never equal the emission timestamp.
93
+ expect(duration).not.toBe(fallbackRow!.ts as number)
94
+ expect(duration).toBeLessThan(365 * 24 * 60 * 60 * 1000) // < 1 year, sane bound
95
+ })
96
+
97
+ it('emits a real elapsed duration for a valid positive start', async () => {
98
+ const KEY = statusKeyForTests(CHAT, null)
99
+ const fx = makeLivenessFixture()
100
+
101
+ const startedAt = Date.now() - 302_000 // ~5 min ago
102
+ fx.activeTurnStartedAt.set(KEY, startedAt)
103
+ fx.setCurrentTurn(
104
+ makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#43` }),
105
+ )
106
+
107
+ const opts = buildSilencePokeOptions(fx.deps)
108
+ await opts.onFrameworkFallback({
109
+ key: KEY,
110
+ chatId: CHAT,
111
+ threadId: null,
112
+ fallbackKind: 'working',
113
+ silenceMs: 302_000,
114
+ inFlightTools: [],
115
+ })
116
+
117
+ const fallbackRow = readTurnEndedRows(metricsPath).find(
118
+ (r) => r.ended_via === 'framework_fallback',
119
+ )
120
+ const duration = fallbackRow!.duration_ms as number
121
+ // Around 302 s, with generous slack for test-runtime jitter.
122
+ expect(duration).toBeGreaterThanOrEqual(302_000)
123
+ expect(duration).toBeLessThan(360_000)
124
+ })
125
+ })
@@ -57,6 +57,8 @@ const GOLDEN_REGISTRATIONS: readonly string[] = [
57
57
  'command:inject',
58
58
  'command:compact',
59
59
  'command:clear',
60
+ 'command:private',
61
+ 'command:public',
60
62
  'helper:registerModelEffortCommands',
61
63
  'command:agentstart',
62
64
  'command:agentstop',