From 91c9859000e88344fe7e386b3980dafc1b8ff426 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 27 Dec 2025 20:02:27 +0000 Subject: [PATCH] fix: harden heartbeat acks + gateway reconnect --- CHANGELOG.md | 2 ++ .../Clawdis/GatewayProcessManager.swift | 13 +++++++++ .../Clawdis/MenuSessionsInjector.swift | 27 +++++++++++++++++-- docs/heartbeat.md | 1 + src/agents/system-prompt.ts | 1 + src/auto-reply/heartbeat.test.ts | 24 ++++++++++------- src/auto-reply/heartbeat.ts | 14 +++------- 7 files changed, 59 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e01eb8fac..3b506b84e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixes - Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors. +- Heartbeat replies now drop any output containing `HEARTBEAT_OK`, preventing stray emoji/text from being delivered. +- macOS menu now refreshes the control channel after the gateway starts and shows “Connecting to gateway…” while the gateway is coming up. ## 2.0.0-beta3 — 2025-12-27 diff --git a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift index 04a0d1a37..7c904d2cd 100644 --- a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift @@ -182,6 +182,7 @@ final class GatewayProcessManager { self.existingGatewayDetails = details self.status = .attachedExisting(details: details) self.appendLog("[gateway] using existing instance: \(details)\n") + self.refreshControlChannelIfNeeded(reason: "attach existing") self.refreshLog() return true } catch { @@ -289,6 +290,7 @@ final class GatewayProcessManager { let instance = await PortGuardian.shared.describe(port: port) let details = instance.map { "pid \($0.pid)" } self.status = .running(details: details) + self.refreshControlChannelIfNeeded(reason: "gateway started") self.refreshLog() return } catch { @@ -307,6 +309,17 @@ final class GatewayProcessManager { } } + private func refreshControlChannelIfNeeded(reason: String) { + switch ControlChannel.shared.state { + case .connected, .connecting: + return + case .disconnected, .degraded: + break + } + self.appendLog("[gateway] refreshing control channel (\(reason))\n") + Task { await ControlChannel.shared.configure() } + } + func clearLog() { self.log = "" try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath) diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index 1b32501ba..a66b1a38d 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -110,7 +110,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { guard self.isControlChannelConnected else { menu.insertItem(self.makeMessageItem( - text: "No connection to gateway", + text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width), at: insertIndex) return @@ -199,7 +199,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { guard self.isControlChannelConnected else { menu.insertItem( - self.makeMessageItem(text: "No connection to gateway", symbolName: "wifi.slash", width: width), + self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width), at: cursor) cursor += 1 let separator = NSMenuItem.separator() @@ -259,6 +259,29 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return false } + private var controlChannelStatusText: String { + switch ControlChannel.shared.state { + case .connected: + return "Connected" + case .connecting: + return "Connecting to gateway…" + case let .degraded(reason): + if self.shouldShowConnecting { return "Connecting to gateway…" } + return reason.nonEmpty ?? "No connection to gateway" + case .disconnected: + return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway" + } + } + + private var shouldShowConnecting: Bool { + switch GatewayProcessManager.shared.status { + case .starting, .running, .attachedExisting: + return true + case .stopped, .failed: + return false + } + } + private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem { let view = AnyView( Label(text, systemImage: symbolName) diff --git a/docs/heartbeat.md b/docs/heartbeat.md index a166365fa..33b762084 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -11,6 +11,7 @@ surface anything that needs attention without spamming the user. ## Prompt contract - Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`). - If nothing needs attention, the model must reply **exactly** `HEARTBEAT_OK`. +- Any response containing `HEARTBEAT_OK` is treated as an ack and discarded. - For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text. ## Config diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index cf5fb487f..00bfb165c 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -95,6 +95,7 @@ export function buildAgentSystemPromptAppend(params: { "## Heartbeats", 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:', "HEARTBEAT_OK", + 'Any response containing "HEARTBEAT_OK" is treated as a heartbeat ack and will not be delivered.', 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', "", "## Runtime", diff --git a/src/auto-reply/heartbeat.test.ts b/src/auto-reply/heartbeat.test.ts index b95a98fff..500c17e39 100644 --- a/src/auto-reply/heartbeat.test.ts +++ b/src/auto-reply/heartbeat.test.ts @@ -19,18 +19,15 @@ describe("stripHeartbeatToken", () => { }); }); - it("keeps content and removes token when mixed", () => { + it("skips any reply that includes the heartbeat token", () => { expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({ - shouldSkip: false, - text: "ALERT", + shouldSkip: true, + text: "", }); - expect(stripHeartbeatToken("hello")).toEqual({ - shouldSkip: false, - text: "hello", + expect(stripHeartbeatToken("HEARTBEAT_OK 🦞")).toEqual({ + shouldSkip: true, + text: "", }); - }); - - it("strips repeated OK tails after heartbeat token", () => { expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({ shouldSkip: true, text: "", @@ -48,8 +45,15 @@ describe("stripHeartbeatToken", () => { text: "", }); expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({ + shouldSkip: true, + text: "", + }); + }); + + it("keeps non-heartbeat content", () => { + expect(stripHeartbeatToken("hello")).toEqual({ shouldSkip: false, - text: "ALERT", + text: "hello", }); }); }); diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 262275be8..8c64f2507 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -6,16 +6,8 @@ export function stripHeartbeatToken(raw?: string) { if (!raw) return { shouldSkip: true, text: "" }; const trimmed = raw.trim(); if (!trimmed) return { shouldSkip: true, text: "" }; - if (trimmed === HEARTBEAT_TOKEN) return { shouldSkip: true, text: "" }; - const hadToken = trimmed.includes(HEARTBEAT_TOKEN); - let withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim(); - if (hadToken && withoutToken) { - // LLMs sometimes echo malformed HEARTBEAT_OK_OK... tails; strip trailing OK runs to avoid spam. - withoutToken = withoutToken.replace(/[\s_]*OK(?:[\s_]*OK)*$/gi, "").trim(); + if (trimmed.includes(HEARTBEAT_TOKEN)) { + return { shouldSkip: true, text: "" }; } - const shouldSkip = withoutToken.length === 0; - return { - shouldSkip, - text: shouldSkip ? "" : withoutToken || trimmed, - }; + return { shouldSkip: false, text: trimmed }; }