switchroom 0.14.10 → 0.14.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.
@@ -625,17 +625,21 @@ export async function startBootCard(
625
625
  ...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
626
626
  })
627
627
 
628
- // Silence the notification for operator-initiated redeploys. A
629
- // routine `switchroom update` should land in the chat as a record
630
- // but not buzz every user's phone every agent posts a card, so
631
- // a fleet update with N agents produces N notifications otherwise.
632
- // We key on the reason-text prefix `operator:` (today only
633
- // `operator: switchroom update` writes this) so user-initiated
634
- // restarts (`user: /restart from chat`, `cli: switchroom restart`)
635
- // and unplanned events (crash, fresh, planned-marker) keep their
636
- // normal notification behaviour — the user explicitly asked for
637
- // those, or they need to know something went wrong.
638
- const silentBootCard = opts.restartReasonDetail?.startsWith('operator:') === true
628
+ // Boot cards are ALWAYS delivered silently (no Telegram
629
+ // notification). They land in the chat as a record — the operator
630
+ // can scroll up to see "✅ <agent> back up · vX.Y.Z" but they
631
+ // never buzz a phone. Rationale: every agent posts a card on every
632
+ // restart, so a fleet redeploy of N agents produced N notifications;
633
+ // even a single user `/restart` or a crash-recovery is a status
634
+ // record, not something that should pull attention. Operator
635
+ // decision (2026-05-29): silence them all, unconditionally.
636
+ //
637
+ // Previously this was keyed on the `operator:` reason-detail prefix
638
+ // (only routine `switchroom update` was silent); user `/restart`,
639
+ // `cli: switchroom restart` rollouts, crashes, and fresh boots all
640
+ // still notified. That distinction is gone — the card is the record,
641
+ // the chat is where you look, and nothing here warrants a push.
642
+ const silentBootCard = true
639
643
 
640
644
  let messageId: number
641
645
  try {
@@ -3407,11 +3407,13 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
3407
3407
  }
3408
3408
 
3409
3409
  // #1122: framework safety-net for "model is silent to the user for >5min."
3410
- // Starts a single setInterval poll that walks active turns and arms
3411
- // soft/firm poke reminders piggybacked on the next tool result. At 300s
3412
- // the framework itself sends a user-visible "still working… / still
3413
- // thinking…" message. Honours SWITCHROOM_DISABLE_SILENCE_POKE=1 kill
3414
- // switch (no-op if set).
3410
+ // Starts a single setInterval poll that walks active turns; at 300s of
3411
+ // silence the framework itself sends a user-visible "still working… /
3412
+ // still thinking…" message AND unwedges the turn. The model-targeted
3413
+ // nudge ladder (ack/soft/firm) and the 60s awareness ping were retired
3414
+ // once the live-updating reply/draft took over the pacing job — only
3415
+ // this single unwedge fallback remains. Honours
3416
+ // SWITCHROOM_DISABLE_SILENCE_POKE=1 kill switch (no-op if set).
3415
3417
  // Set when this gateway dispatches an `update_apply` to hostd that
3416
3418
  // returns `started`; cleared when the dispatch poll resolves (terminal
3417
3419
  // / not-configured / timeout). While set, the framework silence
@@ -3425,43 +3427,6 @@ silencePoke.startTimer({
3425
3427
  // Re-emit through the unified runtime-metrics fan-out (PostHog + JSONL).
3426
3428
  emitRuntimeMetric(event)
3427
3429
  },
3428
- onAwarenessPing: async (ctx) => {
3429
- // Early framework-owned awareness signal (~60s) so the user never
3430
- // faces a silent chat while the model is busy / held / thinking.
3431
- // Distinct from the 300s onFrameworkFallback: fires earlier, sends
3432
- // a SILENT message (disable_notification: true — ambient liveness,
3433
- // not a device buzz), and is bounded to ONE per turn by the silence-
3434
- // poke module's `awarenessPingFired` flag. Reuses
3435
- // `formatFrameworkFallbackText` so the wording stays consistent and
3436
- // in-flight tools are named when known. If the model has been
3437
- // silent long enough to cross 300s, the heavier framework_fallback
3438
- // escalates with a notification.
3439
- //
3440
- // Late-fire guard mirrors the framework_fallback handler: skip if
3441
- // the turn ended cleanly between the silence-poke arming and this
3442
- // timer-fired handler so we don't talk over a clean response.
3443
- if (activeTurnStartedAt.get(ctx.key) == null && currentTurn == null) {
3444
- return
3445
- }
3446
- const text = silencePoke.formatFrameworkFallbackText(
3447
- ctx.fallbackKind,
3448
- ctx.silenceMs,
3449
- ctx.inFlightTools,
3450
- )
3451
- try {
3452
- await robustApiCall(
3453
- () => bot.api.sendMessage(ctx.chatId, text, {
3454
- ...(ctx.threadId != null ? { message_thread_id: ctx.threadId } : {}),
3455
- disable_notification: true,
3456
- }),
3457
- { chat_id: ctx.chatId, ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}) },
3458
- )
3459
- } catch (err) {
3460
- process.stderr.write(
3461
- `silence-poke awareness-ping sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
3462
- )
3463
- }
3464
- },
3465
3430
  onFrameworkFallback: async (ctx) => {
3466
3431
  // Late-fire short-circuit (2026-05-23 audit finding). The fallback
3467
3432
  // can race a clean turn-end: the model's actual reply lands inside
@@ -4052,25 +4017,6 @@ const ipcServer: IpcServer = createIpcServer({
4052
4017
  process.stderr.write(`telegram gateway: ipc: tool_call tool=${msg.tool} agent=${client.agentName ?? '-'} clientId=${client.id ?? '-'} callId=${msg.id}\n`)
4053
4018
  try {
4054
4019
  const result = await executeToolCall(msg.tool, msg.args)
4055
- // #1122 silence-poke chokepoint: piggyback any armed poke onto the
4056
- // tool result's content text. The model sees the [silence-poke]
4057
- // system-reminder block as part of the next conversational turn.
4058
- // No-op when nothing is armed (the common case) — cost is one
4059
- // map iteration over <=N active turns (typically 1).
4060
- const reminder = silencePoke.consumeArmedPoke()
4061
- if (reminder != null && result != null && typeof result === 'object') {
4062
- const r = result as { content?: Array<{ type: string; text: string }> }
4063
- if (Array.isArray(r.content) && r.content.length > 0 && r.content[0]!.type === 'text') {
4064
- r.content[0]!.text = `${r.content[0]!.text}\n\n<system-reminder>\n${reminder}\n</system-reminder>`
4065
- } else {
4066
- // Tool result didn't carry a text block to wrap — re-shape so
4067
- // the reminder still reaches the model.
4068
- r.content = [
4069
- ...(Array.isArray(r.content) ? r.content : []),
4070
- { type: 'text', text: `<system-reminder>\n${reminder}\n</system-reminder>` },
4071
- ]
4072
- }
4073
- }
4074
4020
  return { type: 'tool_call_result', id: msg.id, success: true, result }
4075
4021
  } catch (err) {
4076
4022
  return {
@@ -4094,17 +4040,13 @@ const ipcServer: IpcServer = createIpcServer({
4094
4040
  progressDriver?.ingest(ev, chatHint, threadHint)
4095
4041
  handleSessionEvent(ev)
4096
4042
  // #1122 silence-poke: surface activity signals from the session
4097
- // stream so the fallback message wording is honest and so
4098
- // subagent-dispatch waits don't fire spurious soft pokes.
4043
+ // stream so the 300s framework-fallback message wording is honest
4044
+ // (thinking vs working, plus the longest-running in-flight tool).
4099
4045
  if (currentTurn != null) {
4100
4046
  const key = statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId)
4101
4047
  if (ev.kind === 'thinking') {
4102
4048
  silencePoke.noteThinking(key, Date.now())
4103
4049
  } else if (ev.kind === 'tool_use') {
4104
- if (ev.toolName === 'Task' || ev.toolName === 'Agent') {
4105
- // Built-in claude sub-agent dispatch — extends soft threshold to 5min.
4106
- silencePoke.noteSubagentDispatch(key)
4107
- }
4108
4050
  // #1292: track in-flight tool calls so the 300s framework
4109
4051
  // fallback message can name the actual observable (e.g.
4110
4052
  // "running Grep \"foo\" for 4m") instead of the dishonest
@@ -19,7 +19,6 @@
19
19
  import { existsSync, mkdirSync, appendFileSync } from 'node:fs'
20
20
  import { dirname, join } from 'node:path'
21
21
  import { captureEvent } from './analytics-posthog.js'
22
- import type { PokeLevel } from './silence-poke.js'
23
22
 
24
23
  export type RuntimeMetricEvent =
25
24
  /**
@@ -63,40 +62,14 @@ export type RuntimeMetricEvent =
63
62
  ended_via: 'reply' | 'stream_reply_done' | 'silent' | 'forced' | 'framework_fallback'
64
63
  }
65
64
  /**
66
- * Framework safety-net: a silence-poke was armed. `ack` is the early
67
- * (~10s) ack-budget poke the model has sent NOTHING this turn and is
68
- * leaving the user on a silent chat. `soft` (75s) / `firm` (180s) are
69
- * the silence-since-last-outbound ladder. The system-reminder appended
70
- * to the next tool result nudges the model to send an update. Doubles
71
- * as a design-health signal — if these fire frequently, the
72
- * conversational-pacing prompt isn't doing its job.
73
- */
74
- | {
75
- kind: 'silence_poke_fired'
76
- key: string
77
- level: PokeLevel
78
- silence_ms: number
79
- subagent_wait: boolean
80
- }
81
- /**
82
- * The model sent an outbound message within the success window
83
- * (default 15s) after a poke fired. Pair with `silence_poke_fired`
84
- * to compute success rate — the design target is >80%. (`ack`-level
85
- * success is not currently emitted — the ack poke sits outside the
86
- * `pokesFired` ladder noteOutbound measures against; the type admits
87
- * `ack` only so the silence-poke metric union stays assignable.)
88
- */
89
- | {
90
- kind: 'silence_poke_succeeded'
91
- key: string
92
- level: PokeLevel
93
- latency_ms: number
94
- }
95
- /**
96
- * Last-resort: 5 minutes silent, the framework itself sent a
97
- * user-visible "still working… / still thinking…" message. Should
98
- * be rare (target <5 per 1000 turns); a high rate means the model
99
- * is genuinely stuck or the soft/firm pokes aren't being honoured.
65
+ * Last-resort safety net: 5 minutes silent, the framework itself sent
66
+ * a user-visible "still working… / still thinking…" message AND
67
+ * unwedged the turn (cleared activeTurnStartedAt, nulled currentTurn,
68
+ * drained buffered inbound). Should be rare (target <5 per 1000 turns);
69
+ * a high rate means turns are genuinely getting stuck. This is the only
70
+ * remaining framework safety-net signal — the model-targeted nudge
71
+ * ladder (ack/soft/firm) and the 60s awareness ping were retired once
72
+ * the live-updating reply/draft took over the pacing job.
100
73
  */
101
74
  | {
102
75
  kind: 'silence_fallback_sent'
@@ -104,23 +77,6 @@ export type RuntimeMetricEvent =
104
77
  fallback_kind: 'working' | 'thinking'
105
78
  silence_ms: number
106
79
  }
107
- /**
108
- * Awareness ping (~60s, default): framework-owned user-visible
109
- * "still working… / still thinking…" message sent BEFORE the 300s
110
- * fallback so the user never faces a silent chat for the full 5
111
- * minutes. Silent (no device ping); one-shot per turn; suppressed
112
- * by any outbound or sub-agent dispatch. A high rate is the
113
- * diagnostic signal that frequent silences exist (held-inbound,
114
- * extended-thinking, slow startup), and the rate of the heavier
115
- * silence_fallback_sent that still follows tells us how many of
116
- * those escalate all the way to 5 min.
117
- */
118
- | {
119
- kind: 'awareness_ping_sent'
120
- key: string
121
- fallback_kind: 'working' | 'thinking'
122
- silence_ms: number
123
- }
124
80
  /**
125
81
  * #1445 cross-turn pending-async ambient lifecycle. `started` fires
126
82
  * when a turn ends with a captured anchor AND a pending Agent/Task/