Compare commits

..
Author SHA1 Message Date
Peter Steinberger dcfde2822a fix: refine Slack user token routing (#981) (thanks @jalehman) 2026-01-16 00:11:12 +00:00
Josh Lehman bb568b2a45 feat(slack): add userToken for read-only access to DMs and private channels
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes

User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.

Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 23:57:39 +00:00
137 changed files with 964 additions and 3117 deletions

No files matched your search

+3 -34
View File
@@ -2,14 +2,8 @@
## 2026.1.15 (unreleased) ## 2026.1.15 (unreleased)
- CLI: set process titles to `clawdbot-<command>` for clearer process listings. - Slack: add optional user token routing for read access with safe write fallback. (#981) — thanks @jalehman.
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
- TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. - Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. - Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter. - Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
@@ -17,17 +11,14 @@
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. - Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- Docs: add Date & Time guide and update prompt/timezone configuration docs. - Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc. - Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. - Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. - Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`. - Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors. - Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback). - Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page). - Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi. - Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow. - Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
## 2026.1.14-1 ## 2026.1.14-1
@@ -42,9 +33,8 @@
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. - Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. - Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. - Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths. - Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
- Security: add `SECURITY.md` reporting policy. - Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba. - Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist. - Docs: expand gateway security hardening guidance and incident response checklist.
@@ -86,28 +76,7 @@
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor. - UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. - TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. - TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). - Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
#### Agents / Auth / Tools / Sandbox
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
#### macOS / Apps
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. - macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. - Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman. - Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
Submodule Peekaboo updated: 5c195f5e46...95ad7532c1.
@@ -307,8 +307,8 @@ enum AgentWorkspace {
} }
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs") urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname) .appendingPathComponent(self.templateDirname)
.appendingPathComponent(named)) .appendingPathComponent(named))
return urls return urls
} }
@@ -109,8 +109,8 @@ struct AnthropicAuthControls: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(self.busy || self.connectionMode != .local || self.code .disabled(self.busy || self.connectionMode != .local || self.code
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty) .isEmpty)
} }
} }
@@ -228,7 +228,7 @@ enum ClawdbotOAuthStore {
static func oauthDir() -> URL { static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]? if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty !override.isEmpty
{ {
let expanded = NSString(string: override).expandingTildeInPath let expanded = NSString(string: override).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true) return URL(fileURLWithPath: expanded, isDirectory: true)
+4 -4
View File
@@ -264,8 +264,8 @@ final class AppState {
} }
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl? let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
let resolvedConnectionMode: ConnectionMode = if let configMode { let resolvedConnectionMode: ConnectionMode = if let configMode {
@@ -356,8 +356,8 @@ final class AppState {
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
let hasRemoteUrl = !(remoteUrl? let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let desiredMode: ConnectionMode? = switch modeRaw { let desiredMode: ConnectionMode? = switch modeRaw {
case "local": case "local":
@@ -182,12 +182,12 @@ actor BridgeServer {
?? "main" ?? "main"
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text, message: text,
sessionKey: sessionKey, sessionKey: sessionKey,
thinking: "low", thinking: "low",
deliver: false, deliver: false,
to: nil, to: nil,
channel: .last)) channel: .last))
case "agent.request": case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
@@ -208,12 +208,12 @@ actor BridgeServer {
let channel = GatewayAgentChannel(raw: link.channel) let channel = GatewayAgentChannel(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( _ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message, message: message,
sessionKey: sessionKey, sessionKey: sessionKey,
thinking: thinking, thinking: thinking,
deliver: link.deliver, deliver: link.deliver,
to: to, to: to,
channel: channel)) channel: channel))
default: default:
break break
@@ -55,7 +55,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return } guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId = let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString ?? UUID().uuidString
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)") canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
@@ -39,13 +39,13 @@ final class CanvasFileWatcher: @unchecked Sendable {
kFSEventStreamCreateFlagNoDefer) kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate( guard let stream = FSEventStreamCreate(
kCFAllocatorDefault, kCFAllocatorDefault,
Self.callback, Self.callback,
&context, &context,
paths, paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow), FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05, 0.05,
flags) flags)
else { else {
retainedSelf.release() retainedSelf.release()
return return
@@ -242,8 +242,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
} }
guard let url = CanvasScheme.makeURL( guard let url = CanvasScheme.makeURL(
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
path: trimmed) path: trimmed)
else { else {
canvasWindowLogger canvasWindowLogger
.error( .error(
@@ -125,13 +125,13 @@ enum CommandResolver {
// fnm // fnm
bins.append(contentsOf: self.versionedNodeBinPaths( bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".local/share/fnm/node-versions"), base: home.appendingPathComponent(".local/share/fnm/node-versions"),
suffix: "installation/bin")) suffix: "installation/bin"))
// nvm // nvm
bins.append(contentsOf: self.versionedNodeBinPaths( bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".nvm/versions/node"), base: home.appendingPathComponent(".nvm/versions/node"),
suffix: "bin")) suffix: "bin"))
return bins return bins
} }
@@ -45,13 +45,13 @@ final class ConfigFileWatcher: @unchecked Sendable {
kFSEventStreamCreateFlagNoDefer) kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate( guard let stream = FSEventStreamCreate(
kCFAllocatorDefault, kCFAllocatorDefault,
Self.callback, Self.callback,
&context, &context,
paths, paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow), FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05, 0.05,
flags) flags)
else { else {
retainedSelf.release() retainedSelf.release()
return return
@@ -10,7 +10,7 @@ struct ConfigSettings: View {
"When enabled, the browser server will only connect if the clawd browser is already running." "When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote = private static let browserProfileNote =
"Clawd uses a separate Chrome profile and ports (default 18791/18792) " "Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it won’t interfere with your daily browser." + "so it won’t interfere with your daily browser."
@State private var configModel: String = "" @State private var configModel: String = ""
@State private var configSaving = false @State private var configSaving = false
@State private var hasLoaded = false @State private var hasLoaded = false
@@ -97,8 +97,8 @@ extension ConfigSettings {
Text("Clawdbot CLI config") Text("Clawdbot CLI config")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text(self.isNixMode Text(self.isNixMode
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild." ? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).") : "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -753,9 +753,9 @@ extension ConfigSettings {
do { do {
let res: ModelsListResult = let res: ModelsListResult =
try await GatewayConnection.shared try await GatewayConnection.shared
.requestDecoded( .requestDecoded(
method: .modelsList, method: .modelsList,
timeoutMs: 15000) timeoutMs: 15000)
self.models = res.models self.models = res.models
self.modelsSourceLabel = "gateway" self.modelsSourceLabel = "gateway"
} catch { } catch {
@@ -792,8 +792,8 @@ extension ConfigSettings {
choice.provider, choice.provider,
self.modelRef(for: choice), self.modelRef(for: choice),
] ]
.joined(separator: " ") .joined(separator: " ")
.lowercased() .lowercased()
return tokens.allSatisfy { haystack.contains($0) } return tokens.allSatisfy { haystack.contains($0) }
} }
} }
@@ -53,8 +53,8 @@ final class ConnectionModeCoordinator {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
} catch { } catch {
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -377,18 +377,18 @@ extension ConnectionsSettings {
case .telegram: case .telegram:
return self return self
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)? .date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt) .lastProbeAt)
case .discord: case .discord:
return self return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)? .date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt) .lastProbeAt)
case .signal: case .signal:
return self return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt) .date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case .imessage: case .imessage:
return self return self
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)? .date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.lastProbeAt) .lastProbeAt)
} }
} }
@@ -38,10 +38,10 @@ extension ConnectionsStore {
private func applyUIConfig(_ snap: ConfigSnapshot) { private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?[ let ui = snap.config?[
"ui", "ui",
]?.dictionaryValue ]?.dictionaryValue
let rawSeam = ui?[ let rawSeam = ui?[
"seamColor", "seamColor",
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" ]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
} }
@@ -189,20 +189,20 @@ enum CritterIconRenderer {
canvas.context.setFillColor(NSColor.labelColor.cgColor) canvas.context.setFillColor(NSColor.labelColor.cgColor)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: geometry.bodyRect, roundedRect: geometry.bodyRect,
cornerWidth: geometry.bodyCorner, cornerWidth: geometry.bodyCorner,
cornerHeight: geometry.bodyCorner, cornerHeight: geometry.bodyCorner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: geometry.leftEarRect, roundedRect: geometry.leftEarRect,
cornerWidth: geometry.earCorner, cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner, cornerHeight: geometry.earCorner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: geometry.rightEarRect, roundedRect: geometry.rightEarRect,
cornerWidth: geometry.earCorner, cornerWidth: geometry.earCorner,
cornerHeight: geometry.earCorner, cornerHeight: geometry.earCorner,
transform: nil)) transform: nil))
for i in 0..<4 { for i in 0..<4 {
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing) let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
@@ -213,10 +213,10 @@ enum CritterIconRenderer {
width: geometry.legW, width: geometry.legW,
height: geometry.legH * geometry.legHeightScale) height: geometry.legH * geometry.legHeightScale)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: rect, roundedRect: rect,
cornerWidth: geometry.legW * 0.34, cornerWidth: geometry.legW * 0.34,
cornerHeight: geometry.legW * 0.34, cornerHeight: geometry.legW * 0.34,
transform: nil)) transform: nil))
} }
canvas.context.fillPath() canvas.context.fillPath()
} }
@@ -252,15 +252,15 @@ enum CritterIconRenderer {
height: holeH) height: holeH)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: leftHoleRect, roundedRect: leftHoleRect,
cornerWidth: holeCorner, cornerWidth: holeCorner,
cornerHeight: holeCorner, cornerHeight: holeCorner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: rightHoleRect, roundedRect: rightHoleRect,
cornerWidth: holeCorner, cornerWidth: holeCorner,
cornerHeight: holeCorner, cornerHeight: holeCorner,
transform: nil)) transform: nil))
} }
if options.eyesClosedLines { if options.eyesClosedLines {
@@ -278,41 +278,41 @@ enum CritterIconRenderer {
width: lineW, width: lineW,
height: lineH) height: lineH)
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: leftRect, roundedRect: leftRect,
cornerWidth: corner, cornerWidth: corner,
cornerHeight: corner, cornerHeight: corner,
transform: nil)) transform: nil))
canvas.context.addPath(CGPath( canvas.context.addPath(CGPath(
roundedRect: rightRect, roundedRect: rightRect,
cornerWidth: corner, cornerWidth: corner,
cornerHeight: corner, cornerHeight: corner,
transform: nil)) transform: nil))
} else { } else {
let eyeOpen = max(0.05, 1 - options.blink) let eyeOpen = max(0.05, 1 - options.blink)
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen) let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
let left = CGMutablePath() let left = CGMutablePath()
left.move(to: CGPoint( left.move(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y - eyeH))) y: canvas.snapY(leftCenter.y - eyeH)))
left.addLine(to: CGPoint( left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2), x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y))) y: canvas.snapY(leftCenter.y)))
left.addLine(to: CGPoint( left.addLine(to: CGPoint(
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
y: canvas.snapY(leftCenter.y + eyeH))) y: canvas.snapY(leftCenter.y + eyeH)))
left.closeSubpath() left.closeSubpath()
let right = CGMutablePath() let right = CGMutablePath()
right.move(to: CGPoint( right.move(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y - eyeH))) y: canvas.snapY(rightCenter.y - eyeH)))
right.addLine(to: CGPoint( right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2), x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y))) y: canvas.snapY(rightCenter.y)))
right.addLine(to: CGPoint( right.addLine(to: CGPoint(
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
y: canvas.snapY(rightCenter.y + eyeH))) y: canvas.snapY(rightCenter.y + eyeH)))
right.closeSubpath() right.closeSubpath()
canvas.context.addPath(left) canvas.context.addPath(left)
@@ -121,12 +121,12 @@ extension CritterStatusLabel {
} }
return Image(nsImage: CritterIconRenderer.makeIcon( return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount, blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle, earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0, earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive, earHoles: self.earBoostActive,
badge: badge)) badge: badge))
} }
private func resetMotion() { private func resetMotion() {
@@ -11,15 +11,15 @@ struct CronJobEditor: View {
let labelColumnWidth: CGFloat = 160 let labelColumnWidth: CGFloat = 160
static let introText = static let introText =
"Create a schedule that wakes clawd via the Gateway. " "Create a schedule that wakes clawd via the Gateway. "
+ "Use an isolated session for agent turns so your main chat stays clean." + "Use an isolated session for agent turns so your main chat stays clean."
static let sessionTargetNote = static let sessionTargetNote =
"Main jobs post a system event into the current main session. " "Main jobs post a system event into the current main session. "
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)." + "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
static let scheduleKindNote = static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote = static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a channel, " "Isolated jobs always run an agent turn. The result can be delivered to a channel, "
+ "and a short summary is posted back to your main chat." + "and a short summary is posted back to your main chat."
static let mainPayloadNote = static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target." "System events are injected into the current main session. Agent turns require an isolated session target."
static let mainSummaryNote = static let mainSummaryNote =
+7 -7
View File
@@ -70,13 +70,13 @@ enum CronSchedule: Codable, Equatable {
enum CronPayload: Codable, Equatable { enum CronPayload: Codable, Equatable {
case systemEvent(text: String) case systemEvent(text: String)
case agentTurn( case agentTurn(
message: String, message: String,
thinking: String?, thinking: String?,
timeoutSeconds: Int?, timeoutSeconds: Int?,
deliver: Bool?, deliver: Bool?,
channel: String?, channel: String?,
to: String?, to: String?,
bestEffortDeliver: Bool?) bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
@@ -26,8 +26,8 @@ extension CronSettings {
}) })
} }
.alert("Delete cron job?", isPresented: Binding( .alert("Delete cron job?", isPresented: Binding(
get: { self.confirmDelete != nil }, get: { self.confirmDelete != nil },
set: { if !$0 { self.confirmDelete = nil } })) set: { if !$0 { self.confirmDelete = nil } }))
{ {
Button("Cancel", role: .cancel) { self.confirmDelete = nil } Button("Cancel", role: .cancel) { self.confirmDelete = nil }
Button("Delete", role: .destructive) { Button("Delete", role: .destructive) {
@@ -42,9 +42,9 @@ extension CronSettings {
} }
} }
.onChange(of: self.store.selectedJobId) { _, newValue in .onChange(of: self.store.selectedJobId) { _, newValue in
guard let newValue else { return } guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) } Task { await self.store.refreshRuns(jobId: newValue) }
} }
} }
var schedulerBanner: some View { var schedulerBanner: some View {
@@ -69,8 +69,8 @@ extension CronSettings {
Spacer() Spacer()
HStack(spacing: 8) { HStack(spacing: 8) {
Toggle("Enabled", isOn: Binding( Toggle("Enabled", isOn: Binding(
get: { job.enabled }, get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } })) set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
.toggleStyle(.switch) .toggleStyle(.switch)
.labelsHidden() .labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
@@ -102,8 +102,8 @@ enum DebugActions {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel() _ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
} catch { } catch {
// ControlChannel will surface a degraded state; also refresh health to update the menu text. // ControlChannel will surface a degraded state; also refresh health to update the menu text.
Task { await HealthStore.shared.refresh(onDemand: true) } Task { await HealthStore.shared.refresh(onDemand: true) }
@@ -127,8 +127,8 @@ enum DebugActions {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel() _ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
await HealthStore.shared.refresh(onDemand: true) await HealthStore.shared.refresh(onDemand: true)
return .success("SSH tunnel reset.") return .success("SSH tunnel reset.")
} catch { } catch {
@@ -107,9 +107,9 @@ enum DeviceModelCatalog {
private static func loadMapping(resourceName: String) -> [String: String] { private static func loadMapping(resourceName: String) -> [String: String] {
guard let url = self.resourceBundle?.url( guard let url = self.resourceBundle?.url(
forResource: resourceName, forResource: resourceName,
withExtension: "json", withExtension: "json",
subdirectory: self.resourceSubdirectory) subdirectory: self.resourceSubdirectory)
else { return [:] } else { return [:] }
do { do {
@@ -113,17 +113,17 @@ actor GatewayChannelActor {
self.task = nil self.task = nil
await self.failPending(NSError( await self.failPending(NSError(
domain: "Gateway", domain: "Gateway",
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
let waiters = self.connectWaiters let waiters = self.connectWaiters
self.connectWaiters.removeAll() self.connectWaiters.removeAll()
for waiter in waiters { for waiter in waiters {
waiter.resume(throwing: NSError( waiter.resume(throwing: NSError(
domain: "Gateway", domain: "Gateway",
code: 0, code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
} }
} }
@@ -433,14 +433,14 @@ extension GatewayConnection {
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{ {
await self.sendAgent(GatewayAgentInvocation( await self.sendAgent(GatewayAgentInvocation(
message: message, message: message,
sessionKey: sessionKey, sessionKey: sessionKey,
thinking: thinking, thinking: thinking,
deliver: deliver, deliver: deliver,
to: to, to: to,
channel: channel, channel: channel,
timeoutSeconds: timeoutSeconds, timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey)) idempotencyKey: idempotencyKey))
} }
func sendSystemEvent(_ params: [String: AnyCodable]) async { func sendSystemEvent(_ params: [String: AnyCodable]) async {
@@ -27,7 +27,7 @@ struct GatewayDiscoveryInlineList: View {
ForEach(self.discovery.gateways.prefix(6)) { gateway in ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway) let target = self.suggestedSSHTarget(gateway)
let selected = (target != nil && self.currentTarget? let selected = (target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target) .trimmingCharacters(in: .whitespacesAndNewlines) == target)
Button { Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -61,8 +61,8 @@ struct GatewayDiscoveryInlineList: View {
.background( .background(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowBackground( .fill(self.rowBackground(
selected: selected, selected: selected,
hovered: self.hoveredGatewayID == gateway.id))) hovered: self.hoveredGatewayID == gateway.id)))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder( .strokeBorder(
@@ -206,10 +206,10 @@ actor GatewayEndpointStore {
let port = self.deps.localPort() let port = self.deps.localPort()
let host = await self.deps.localHost() let host = await self.deps.localHost()
self.setState(.ready( self.setState(.ready(
mode: .local, mode: .local,
url: URL(string: "ws://\(host):\(port)")!, url: URL(string: "ws://\(host):\(port)")!,
token: token, token: token,
password: password)) password: password))
case .remote: case .remote:
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
@@ -219,10 +219,10 @@ actor GatewayEndpointStore {
} }
self.cancelRemoteEnsure() self.cancelRemoteEnsure()
self.setState(.ready( self.setState(.ready(
mode: .remote, mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!, url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token, token: token,
password: password)) password: password))
case .unconfigured: case .unconfigured:
self.cancelRemoteEnsure() self.cancelRemoteEnsure()
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
@@ -8,7 +8,7 @@ enum GatewayPayloadDecoding {
} }
static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
-> T? -> T?
{ {
guard let payload else { return nil } guard let payload else { return nil }
return try self.decode(payload, as: T.self) return try self.decode(payload, as: T.self)
@@ -238,7 +238,7 @@ struct GeneralSettings: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget .disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
GatewayDiscoveryInlineList( GatewayDiscoveryInlineList(
@@ -627,8 +627,8 @@ extension GeneralSettings {
let originalMode = AppStateStore.shared.connectionMode let originalMode = AppStateStore.shared.connectionMode
do { do {
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
target: settings.target, target: settings.target,
identity: settings.identity)) identity: settings.identity))
let data = try await ControlChannel.shared.health(timeout: 10) let data = try await ControlChannel.shared.health(timeout: 10)
if decodeHealthSnapshot(from: data) != nil { if decodeHealthSnapshot(from: data) != nil {
self.remoteStatus = .ok self.remoteStatus = .ok
@@ -13,7 +13,7 @@ enum InstanceIdentity {
let defaults = Self.defaults let defaults = Self.defaults
if let existing = defaults.string(forKey: instanceIdKey)? if let existing = defaults.string(forKey: instanceIdKey)?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty !existing.isEmpty
{ {
return existing return existing
} }
+3 -3
View File
@@ -30,9 +30,9 @@ enum LogLocator {
self.ensureLogDirExists() self.ensureLogDirExists()
let fm = FileManager.default let fm = FileManager.default
let files = (try? fm.contentsOfDirectory( let files = (try? fm.contentsOfDirectory(
at: self.logDir, at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey], includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles])) ?? [] options: [.skipsHiddenFiles])) ?? []
return files return files
.filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" } .filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" }
@@ -51,9 +51,9 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
let initialWidth = self.initialCardWidth(for: menu) let initialWidth = self.initialCardWidth(for: menu)
let initial = AnyView(ContextMenuCardView( let initial = AnyView(ContextMenuCardView(
rows: initialRows, rows: initialRows,
statusText: initialStatusText, statusText: initialStatusText,
isLoading: initialIsLoading)) isLoading: initialIsLoading))
let hosting = NSHostingView(rootView: initial) let hosting = NSHostingView(rootView: initial)
hosting.frame.size.width = max(1, initialWidth) hosting.frame.size.width = max(1, initialWidth)
@@ -103,7 +103,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
extension MenuSessionsInjector { extension MenuSessionsInjector {
// MARK: - Injection // MARK: - Injection
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey } private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) { private func inject(into menu: NSMenu) {
@@ -139,8 +138,8 @@ extension MenuSessionsInjector {
headerItem.isEnabled = false headerItem.isEnabled = false
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: rows.count, count: rows.count,
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))), statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@@ -176,8 +175,8 @@ extension MenuSessionsInjector {
: self.controlChannelStatusText(for: channelState) : self.controlChannelStatusText(for: channelState)
let hosted = self.makeHostedView( let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView( rootView: AnyView(MenuSessionsHeaderView(
count: 0, count: 0,
statusText: statusText)), statusText: statusText)),
width: width, width: width,
highlighted: false) highlighted: false)
headerItem.view = hosted headerItem.view = hosted
@@ -300,7 +299,7 @@ extension MenuSessionsInjector {
headerItem.isEnabled = false headerItem.isEnabled = false
headerItem.view = self.makeHostedView( headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView( rootView: AnyView(MenuUsageHeaderView(
count: rows.count)), count: rows.count)),
width: width, width: width,
highlighted: false) highlighted: false)
menu.insertItem(headerItem, at: cursor) menu.insertItem(headerItem, at: cursor)
@@ -473,11 +472,11 @@ extension MenuSessionsInjector {
item.tag = self.tag item.tag = self.tag
item.isEnabled = false item.isEnabled = false
let view = AnyView(SessionMenuPreviewView( let view = AnyView(SessionMenuPreviewView(
sessionKey: sessionKey, sessionKey: sessionKey,
width: width, width: width,
maxItems: 10, maxItems: 10,
maxLines: maxLines, maxLines: maxLines,
title: title)) title: title))
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
return item return item
} }
@@ -597,10 +596,10 @@ extension MenuSessionsInjector {
let width = self.submenuWidth() let width = self.submenuWidth()
menu.addItem(self.makeSessionPreviewItem( menu.addItem(self.makeSessionPreviewItem(
sessionKey: row.key, sessionKey: row.key,
title: "Recent messages (last 10)", title: "Recent messages (last 10)",
width: width, width: width,
maxLines: 3)) maxLines: 3))
let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "") let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "")
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width) morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width)
@@ -704,10 +703,10 @@ extension MenuSessionsInjector {
private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu { private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu {
let menu = NSMenu() let menu = NSMenu()
menu.addItem(self.makeSessionPreviewItem( menu.addItem(self.makeSessionPreviewItem(
sessionKey: sessionKey, sessionKey: sessionKey,
title: "Recent messages (expanded)", title: "Recent messages (expanded)",
width: width, width: width,
maxLines: 8)) maxLines: 8))
return menu return menu
} }
@@ -764,9 +763,9 @@ extension MenuSessionsInjector {
!commands.isEmpty !commands.isEmpty
{ {
menu.addItem(self.makeNodeMultilineItem( menu.addItem(self.makeNodeMultilineItem(
label: "Commands", label: "Commands",
value: commands.joined(separator: ", "), value: commands.joined(separator: ", "),
width: width)) width: width))
} }
return menu return menu
@@ -856,9 +855,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return } guard let key = sender.representedObject as? String else { return }
Task { @MainActor in Task { @MainActor in
guard SessionActions.confirmDestructiveAction( guard SessionActions.confirmDestructiveAction(
title: "Reset session?", title: "Reset session?",
message: "Starts a new session id for “\(key)”.", message: "Starts a new session id for “\(key)”.",
action: "Reset") action: "Reset")
else { return } else { return }
do { do {
@@ -875,9 +874,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return } guard let key = sender.representedObject as? String else { return }
Task { @MainActor in Task { @MainActor in
guard SessionActions.confirmDestructiveAction( guard SessionActions.confirmDestructiveAction(
title: "Compact session log?", title: "Compact session log?",
message: "Keeps the last 400 lines; archives the old file.", message: "Keeps the last 400 lines; archives the old file.",
action: "Compact") action: "Compact")
else { return } else { return }
do { do {
@@ -894,9 +893,9 @@ extension MenuSessionsInjector {
guard let key = sender.representedObject as? String else { return } guard let key = sender.representedObject as? String else { return }
Task { @MainActor in Task { @MainActor in
guard SessionActions.confirmDestructiveAction( guard SessionActions.confirmDestructiveAction(
title: "Delete session?", title: "Delete session?",
message: "Deletes the “\(key)” entry and archives its transcript.", message: "Deletes the “\(key)” entry and archives its transcript.",
action: "Delete") action: "Delete")
else { return } else { return }
do { do {
@@ -39,7 +39,7 @@ actor MacNodeBridgeSession {
onConnected: (@Sendable (String, String?) async -> Void)? = nil, onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil, onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws async throws
{ {
await self.disconnect() await self.disconnect()
self.disconnectHandler = onDisconnected self.disconnectHandler = onDisconnected
@@ -77,15 +77,15 @@ actor MacNodeBridgeSession {
}) })
guard let line = try await AsyncTimeout.withTimeout( guard let line = try await AsyncTimeout.withTimeout(
seconds: 6, seconds: 6,
onTimeout: { onTimeout: {
TimeoutError(message: "operation timed out") TimeoutError(message: "operation timed out")
}, },
operation: { operation: {
try await self.receiveLine() try await self.receiveLine()
}), }),
let data = line.data(using: .utf8), let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data) let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else { else {
self.logger.error("node bridge hello failed (unexpected response)") self.logger.error("node bridge hello failed (unexpected response)")
await self.disconnect() await self.disconnect()
@@ -312,23 +312,9 @@ final class MacNodeModeCoordinator {
} }
let remotePort = Self.remoteBridgePort() let remotePort = Self.remoteBridgePort()
let preferredLocalPort = Self.loopbackBridgePort()
if let preferredLocalPort {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
} else {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=none " +
"remotePort=\(remotePort, privacy: .public)")
}
self.tunnel = try await RemotePortTunnel.create( self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort, remotePort: remotePort,
preferredLocalPort: preferredLocalPort, allowRemoteUrlOverride: false)
allowRemoteUrlOverride: false,
allowRandomLocalPort: true)
if let localPort = self.tunnel?.localPort, if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort) let port = NWEndpoint.Port(rawValue: localPort)
{ {
@@ -403,10 +389,10 @@ final class MacNodeModeCoordinator {
let preferred = BridgeDiscoveryPreferences.preferredStableID() let preferred = BridgeDiscoveryPreferences.preferredStableID()
if let preferred, if let preferred,
let match = results.first(where: { let match = results.first(where: {
if case .service = $0.endpoint { if case .service = $0.endpoint {
return BridgeEndpointID.stableID($0.endpoint) == preferred return BridgeEndpointID.stableID($0.endpoint) == preferred
} }
return false return false
}) })
{ {
state.finish(match.endpoint) state.finish(match.endpoint)
@@ -181,10 +181,10 @@ actor MacNodeRuntime {
var height: Int var height: Int
} }
let payload = try Self.encodePayload(SnapPayload( let payload = try Self.encodePayload(SnapPayload(
format: (params.format ?? .jpg).rawValue, format: (params.format ?? .jpg).rawValue,
base64: res.data.base64EncodedString(), base64: res.data.base64EncodedString(),
width: Int(res.size.width), width: Int(res.size.width),
height: Int(res.size.height))) height: Int(res.size.height)))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.clip.rawValue: case ClawdbotCameraCommand.clip.rawValue:
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ?? let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
@@ -204,10 +204,10 @@ actor MacNodeRuntime {
var hasAudio: Bool var hasAudio: Bool
} }
let payload = try Self.encodePayload(ClipPayload( let payload = try Self.encodePayload(ClipPayload(
format: (params.format ?? .mp4).rawValue, format: (params.format ?? .mp4).rawValue,
base64: data.base64EncodedString(), base64: data.base64EncodedString(),
durationMs: res.durationMs, durationMs: res.durationMs,
hasAudio: res.hasAudio)) hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.list.rawValue: case ClawdbotCameraCommand.list.rawValue:
let devices = await self.cameraCapture.listDevices() let devices = await self.cameraCapture.listDevices()
@@ -312,12 +312,12 @@ actor MacNodeRuntime {
var hasAudio: Bool var hasAudio: Bool
} }
let payload = try Self.encodePayload(ScreenPayload( let payload = try Self.encodePayload(ScreenPayload(
format: "mp4", format: "mp4",
base64: data.base64EncodedString(), base64: data.base64EncodedString(),
durationMs: params.durationMs, durationMs: params.durationMs,
fps: params.fps, fps: params.fps,
screenIndex: params.screenIndex, screenIndex: params.screenIndex,
hasAudio: res.hasAudio)) hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
@@ -454,12 +454,12 @@ actor MacNodeRuntime {
} }
let payload = try Self.encodePayload(RunPayload( let payload = try Self.encodePayload(RunPayload(
exitCode: result.exitCode, exitCode: result.exitCode,
timedOut: result.timedOut, timedOut: result.timedOut,
success: result.success, success: result.success,
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
error: result.errorMessage)) error: result.errorMessage))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
@@ -576,8 +576,8 @@ actor MacNodeRuntime {
case .jpeg: case .jpeg:
let clamped = min(1.0, max(0.05, quality)) let clamped = min(1.0, max(0.05, quality))
guard let data = rep.representation( guard let data = rep.representation(
using: .jpeg, using: .jpeg,
properties: [.compressionFactor: clamped]) properties: [.compressionFactor: clamped])
else { else {
throw NSError(domain: "Canvas", code: 24, userInfo: [ throw NSError(domain: "Canvas", code: 24, userInfo: [
NSLocalizedDescriptionKey: "jpeg encode failed", NSLocalizedDescriptionKey: "jpeg encode failed",
@@ -454,7 +454,7 @@ final class NodePairingApprovalPrompter {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings() let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized || guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional settings.authorizationStatus == .provisional
else { else {
return return
} }
@@ -547,7 +547,7 @@ final class NodePairingApprovalPrompter {
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
guard let gateway else { return nil } guard let gateway else { return nil }
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
guard let host, !host.isEmpty else { return nil } guard let host, !host.isEmpty else { return nil }
let port = gateway.sshPort > 0 ? gateway.sshPort : 22 let port = gateway.sshPort > 0 ? gateway.sshPort : 22
return SSHTarget(host: host, port: port) return SSHTarget(host: host, port: port)
@@ -77,10 +77,10 @@ final class PeekabooBridgeHostCoordinator {
var infoCF: CFDictionary? var infoCF: CFDictionary?
guard SecCodeCopySigningInformation( guard SecCodeCopySigningInformation(
staticCode, staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation), SecCSFlags(rawValue: kSecCSSigningInformation),
&infoCF) == errSecSuccess, &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any] let info = infoCF as? [String: Any]
else { else {
return nil return nil
} }
@@ -106,9 +106,9 @@ private final class ClawdbotPeekabooBridgeServices: PeekabooBridgeServiceProvidi
let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient() let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient()
let snapshots = InMemorySnapshotManager(options: .init( let snapshots = InMemorySnapshotManager(options: .init(
snapshotValidityWindow: 600, snapshotValidityWindow: 600,
maxSnapshots: 50, maxSnapshots: 50,
deleteArtifactsOnCleanup: false)) deleteArtifactsOnCleanup: false))
let applications = ApplicationService(feedbackClient: feedbackClient) let applications = ApplicationService(feedbackClient: feedbackClient)
let screenCapture = ScreenCaptureService(loggingService: logging) let screenCapture = ScreenCaptureService(loggingService: logging)
@@ -158,10 +158,10 @@ actor PortGuardian {
mode: mode, mode: mode,
listeners: listeners) listeners: listeners)
reports.append(Self.buildReport( reports.append(Self.buildReport(
port: port, port: port,
listeners: listeners, listeners: listeners,
mode: mode, mode: mode,
tunnelHealthy: tunnelHealthy)) tunnelHealthy: tunnelHealthy))
} }
return reports return reports
@@ -105,8 +105,8 @@ final class RemotePortTunnel {
return return
} }
guard let line = String(data: data, encoding: .utf8)? guard let line = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines), .trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty !line.isEmpty
else { return } else { return }
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)") Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
} }
@@ -42,11 +42,11 @@ struct RuntimeResolution {
enum RuntimeResolutionError: Error { enum RuntimeResolutionError: Error {
case notFound(searchPaths: [String]) case notFound(searchPaths: [String])
case unsupported( case unsupported(
kind: RuntimeKind, kind: RuntimeKind,
found: RuntimeVersion, found: RuntimeVersion,
required: RuntimeVersion, required: RuntimeVersion,
path: String, path: String,
searchPaths: [String]) searchPaths: [String])
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
} }
@@ -65,21 +65,21 @@ enum RuntimeLocator {
} }
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else { guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
return .failure(.versionParse( return .failure(.versionParse(
kind: runtime, kind: runtime,
raw: "(unreadable)", raw: "(unreadable)",
path: binary, path: binary,
searchPaths: searchPaths)) searchPaths: searchPaths))
} }
guard let parsed = RuntimeVersion.from(string: rawVersion) else { guard let parsed = RuntimeVersion.from(string: rawVersion) else {
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths)) return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
} }
guard parsed >= self.minNode else { guard parsed >= self.minNode else {
return .failure(.unsupported( return .failure(.unsupported(
kind: runtime, kind: runtime,
found: parsed, found: parsed,
required: self.minNode, required: self.minNode,
path: binary, path: binary,
searchPaths: searchPaths)) searchPaths: searchPaths))
} }
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed)) return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
@@ -251,11 +251,11 @@ private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate,
if let err = self.writer.error { if let err = self.writer.error {
cont cont
.resume(throwing: ScreenRecordService.ScreenRecordError .resume(throwing: ScreenRecordService.ScreenRecordError
.writeFailed(err.localizedDescription)) .writeFailed(err.localizedDescription))
} else if self.writer.status != .completed { } else if self.writer.status != .completed {
cont cont
.resume(throwing: ScreenRecordService.ScreenRecordError .resume(throwing: ScreenRecordService.ScreenRecordError
.writeFailed("Failed to finalize video")) .writeFailed("Failed to finalize video"))
} else { } else {
cont.resume() cont.resume()
} }
@@ -54,9 +54,9 @@ enum SoundEffectCatalog {
var map: [String: URL] = [:] var map: [String: URL] = [:]
for root in Self.searchRoots { for root in Self.searchRoots {
guard let contents = try? FileManager.default.contentsOfDirectory( guard let contents = try? FileManager.default.contentsOfDirectory(
at: root, at: root,
includingPropertiesForKeys: nil, includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]) options: [.skipsHiddenFiles])
else { continue } else { continue }
for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) { for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) {
@@ -88,9 +88,9 @@ enum SoundEffectPlayer {
static func sound(from bookmark: Data) -> NSSound? { static func sound(from bookmark: Data) -> NSSound? {
var stale = false var stale = false
guard let url = try? URL( guard let url = try? URL(
resolvingBookmarkData: bookmark, resolvingBookmarkData: bookmark,
options: [.withoutUI, .withSecurityScope], options: [.withoutUI, .withSecurityScope],
bookmarkDataIsStale: &stale) bookmarkDataIsStale: &stale)
else { return nil } else { return nil }
let scoped = url.startAccessingSecurityScopedResource() let scoped = url.startAccessingSecurityScopedResource()
@@ -354,9 +354,9 @@ actor TalkModeRuntime {
"session=\(sessionKey, privacy: .public)") "session=\(sessionKey, privacy: .public)")
guard let assistantText = await self.waitForAssistantText( guard let assistantText = await self.waitForAssistantText(
sessionKey: sessionKey, sessionKey: sessionKey,
since: startedAt, since: startedAt,
timeoutSeconds: 45) timeoutSeconds: 45)
else { else {
self.logger.warning("talk assistant text missing after timeout") self.logger.warning("talk assistant text missing after timeout")
await self.startListening() await self.startListening()
@@ -48,12 +48,12 @@ enum VoiceWakeForwarder {
let payload = Self.prefixedTranscript(transcript) let payload = Self.prefixedTranscript(transcript)
let deliver = options.channel.shouldDeliver(options.deliver) let deliver = options.channel.shouldDeliver(options.deliver)
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: payload, message: payload,
sessionKey: options.sessionKey, sessionKey: options.sessionKey,
thinking: options.thinking, thinking: options.thinking,
deliver: deliver, deliver: deliver,
to: options.to, to: options.to,
channel: options.channel)) channel: options.channel))
if result.ok { if result.ok {
self.logger.info("voice wake forward ok") self.logger.info("voice wake forward ok")
@@ -493,10 +493,10 @@ actor VoiceWakeRuntime {
config: WakeWordGateConfig) -> WakeWordGateMatch? config: WakeWordGateConfig) -> WakeWordGateMatch?
{ {
guard let command = VoiceWakeTextUtils.textOnlyCommand( guard let command = VoiceWakeTextUtils.textOnlyCommand(
transcript: transcript, transcript: transcript,
triggers: triggers, triggers: triggers,
minCommandLength: config.minCommandLength, minCommandLength: config.minCommandLength,
trimWake: Self.trimmedAfterTrigger) trimWake: Self.trimmedAfterTrigger)
else { return nil } else { return nil }
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
} }
@@ -519,9 +519,9 @@ actor VoiceWakeRuntime {
guard let lastSeenAt, let lastText else { return } guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
guard let match = self.textOnlyFallbackMatch( guard let match = self.textOnlyFallbackMatch(
transcript: lastText, transcript: lastText,
triggers: triggers, triggers: triggers,
config: gateConfig) config: gateConfig)
else { return } else { return }
if let cooldown = self.cooldownUntil, Date() < cooldown { if let cooldown = self.cooldownUntil, Date() < cooldown {
return return
@@ -155,7 +155,7 @@ struct VoiceWakeSettings: View {
Label("Add word", systemImage: "plus") Label("Add word", systemImage: "plus")
} }
.disabled(self.state.swabbleTriggerWords .disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
} }
@@ -440,21 +440,21 @@ struct VoiceWakeSettings: View {
{ idx, localeID in { idx, localeID in
HStack(spacing: 8) { HStack(spacing: 8) {
Picker("Extra \(idx + 1)", selection: Binding( Picker("Extra \(idx + 1)", selection: Binding(
get: { localeID }, get: { localeID },
set: { newValue in set: { newValue in
guard self.state guard self.state
.voiceWakeAdditionalLocaleIDs.indices .voiceWakeAdditionalLocaleIDs.indices
.contains(idx) else { return } .contains(idx) else { return }
self.state self.state
.voiceWakeAdditionalLocaleIDs[idx] = .voiceWakeAdditionalLocaleIDs[idx] =
newValue newValue
})) { })) {
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
Text(self.friendlyName(for: Locale(identifier: id))).tag(id) Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
}
} }
.labelsHidden() }
.frame(width: 220) .labelsHidden()
.frame(width: 220)
Button { Button {
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return } guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
@@ -360,10 +360,10 @@ final class VoiceWakeTester {
config: WakeWordGateConfig) -> WakeWordGateMatch? config: WakeWordGateConfig) -> WakeWordGateMatch?
{ {
guard let command = VoiceWakeTextUtils.textOnlyCommand( guard let command = VoiceWakeTextUtils.textOnlyCommand(
transcript: transcript, transcript: transcript,
triggers: triggers, triggers: triggers,
minCommandLength: config.minCommandLength, minCommandLength: config.minCommandLength,
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
else { return nil } else { return nil }
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
} }
@@ -408,9 +408,9 @@ final class VoiceWakeTester {
guard let lastSeenAt, let lastText else { return } guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
guard let match = self.textOnlyFallbackMatch( guard let match = self.textOnlyFallbackMatch(
transcript: lastText, transcript: lastText,
triggers: triggers, triggers: triggers,
config: WakeWordGateConfig(triggers: triggers)) else { return } config: WakeWordGateConfig(triggers: triggers)) else { return }
self.holdingAfterDetect = true self.holdingAfterDetect = true
self.detectedText = match.command self.detectedText = match.command
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))") self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
@@ -92,8 +92,8 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
switch push { switch push {
case let .snapshot(hello): case let .snapshot(hello):
let ok = (try? JSONDecoder().decode( let ok = (try? JSONDecoder().decode(
ClawdbotGatewayHealthOK.self, ClawdbotGatewayHealthOK.self,
from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true
return .health(ok: ok) return .health(ok: ok)
case let .event(evt): case let .event(evt):
@@ -101,16 +101,16 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
case "health": case "health":
guard let payload = evt.payload else { return nil } guard let payload = evt.payload else { return nil }
let ok = (try? JSONDecoder().decode( let ok = (try? JSONDecoder().decode(
ClawdbotGatewayHealthOK.self, ClawdbotGatewayHealthOK.self,
from: JSONEncoder().encode(payload)))?.ok ?? true from: JSONEncoder().encode(payload)))?.ok ?? true
return .health(ok: ok) return .health(ok: ok)
case "tick": case "tick":
return .tick return .tick
case "chat": case "chat":
guard let payload = evt.payload else { return nil } guard let payload = evt.payload else { return nil }
guard let chat = try? JSONDecoder().decode( guard let chat = try? JSONDecoder().decode(
ClawdbotChatEventPayload.self, ClawdbotChatEventPayload.self,
from: JSONEncoder().encode(payload)) from: JSONEncoder().encode(payload))
else { else {
return nil return nil
} }
@@ -118,8 +118,8 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
case "agent": case "agent":
guard let payload = evt.payload else { return nil } guard let payload = evt.payload else { return nil }
guard let agent = try? JSONDecoder().decode( guard let agent = try? JSONDecoder().decode(
ClawdbotAgentEventPayload.self, ClawdbotAgentEventPayload.self,
from: JSONEncoder().encode(payload)) from: JSONEncoder().encode(payload))
else { else {
return nil return nil
} }
@@ -157,9 +157,9 @@ final class WebChatSwiftUIWindowController {
let vm = ClawdbotChatViewModel(sessionKey: sessionKey, transport: transport) let vm = ClawdbotChatViewModel(sessionKey: sessionKey, transport: transport)
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
self.hosting = NSHostingController(rootView: ClawdbotChatView( self.hosting = NSHostingController(rootView: ClawdbotChatView(
viewModel: vm, viewModel: vm,
showsSessionSwitcher: true, showsSessionSwitcher: true,
userAccent: accent)) userAccent: accent))
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting) self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
} }
@@ -41,12 +41,12 @@ enum WideAreaGatewayDiscovery {
} }
guard let ips = collectTailnetIPv4s( guard let ips = collectTailnetIPv4s(
statusJson: context.tailscaleStatus()).nonEmpty else { return [] } statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
var candidates = Array(ips.prefix(self.maxCandidates)) var candidates = Array(ips.prefix(self.maxCandidates))
guard let nameserver = findNameserver( guard let nameserver = findNameserver(
candidates: &candidates, candidates: &candidates,
remaining: remaining, remaining: remaining,
dig: context.dig) dig: context.dig)
else { else {
return [] return []
} }
@@ -55,9 +55,9 @@ enum WideAreaGatewayDiscovery {
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)" let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig( guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"], ["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline), min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
!ptrLines.isEmpty !ptrLines.isEmpty
else { else {
return [] return []
} }
@@ -74,8 +74,8 @@ enum WideAreaGatewayDiscovery {
let instanceName = self.decodeDnsSdEscapes(rawInstanceName) let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
guard let srv = context.dig( guard let srv = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"], ["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
min(defaultTimeoutSeconds, remaining())) min(defaultTimeoutSeconds, remaining()))
else { continue } else { continue }
guard let (host, port) = parseSrv(srv) else { continue } guard let (host, port) = parseSrv(srv) else { continue }
@@ -198,7 +198,7 @@ enum WideAreaGatewayDiscovery {
if let stdout = dig( if let stdout = dig(
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"], ["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
min(defaultTimeoutSeconds, budget)), min(defaultTimeoutSeconds, budget)),
stdout.split(whereSeparator: \.isNewline).isEmpty == false stdout.split(whereSeparator: \.isNewline).isEmpty == false
{ {
state.lock.lock() state.lock.lock()
if state.found == nil { if state.found == nil {
+13 -13
View File
@@ -107,18 +107,18 @@ public enum CanvasA2UICommand: String, Codable, Sendable {
public enum Request: Sendable { public enum Request: Sendable {
case notify( case notify(
title: String, title: String,
body: String, body: String,
sound: String?, sound: String?,
priority: NotificationPriority?, priority: NotificationPriority?,
delivery: NotificationDelivery?) delivery: NotificationDelivery?)
case ensurePermissions([Capability], interactive: Bool) case ensurePermissions([Capability], interactive: Bool)
case runShell( case runShell(
command: [String], command: [String],
cwd: String?, cwd: String?,
env: [String: String]?, env: [String: String]?,
timeoutSec: Double?, timeoutSec: Double?,
needsScreenRecording: Bool) needsScreenRecording: Bool)
case status case status
case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?) case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?)
case rpcStatus case rpcStatus
@@ -410,6 +410,6 @@ extension Request: Codable {
// Shared transport settings // Shared transport settings
public let controlSocketPath = public let controlSocketPath =
FileManager.default FileManager.default
.homeDirectoryForCurrentUser .homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/clawdbot/control.sock") .appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path .path
+23
View File
@@ -33,6 +33,7 @@ Minimal config:
- `app_mention` - `app_mention`
- `reaction_added`, `reaction_removed` - `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel` - `member_joined_channel`, `member_left_channel`
- `channel_id_changed`
- `channel_rename` - `channel_rename`
- `pin_added`, `pin_removed` - `pin_added`, `pin_removed`
6) Invite the bot to channels you want it to read. 6) Invite the bot to channels you want it to read.
@@ -112,6 +113,21 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
## History context ## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. - `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
## Config writes
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
This happens when:
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
Disable with:
```json5
{
channels: { slack: { configWrites: false } }
}
```
## Manifest (optional) ## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
@@ -196,6 +212,7 @@ user scopes if you plan to configure a user token.
"reaction_removed", "reaction_removed",
"member_joined_channel", "member_joined_channel",
"member_left_channel", "member_left_channel",
"channel_id_changed",
"channel_rename", "channel_rename",
"pin_added", "pin_added",
"pin_removed" "pin_removed"
@@ -326,6 +343,11 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Thread session isolation
Slack thread sessions are isolated by default. Configure with:
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
### Manual threading tags ### Manual threading tags
For fine-grained control, use these tags in agent responses: For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread). - `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
@@ -390,4 +412,5 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`. - Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. - Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions). - For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
- Read/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Slack `ts`.
- Attachments are downloaded to the media store when permitted and under the size limit. - Attachments are downloaded to the media store when permitted and under the size limit.
-1
View File
@@ -188,7 +188,6 @@ Disable with:
Telegram forum topics include a `message_thread_id` per message. Clawdbot: Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated. - Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
- Sends typing indicators and replies with `message_thread_id` so responses stay in the topic. - Sends typing indicators and replies with `message_thread_id` so responses stay in the topic.
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating. - Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable). - Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
+2 -4
View File
@@ -296,9 +296,8 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
## Heartbeats ## Heartbeats
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
- **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally - **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session.
via `agents.defaults.heartbeat` (fallback when no per-agent entries are set). - Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior.
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`) + `HEARTBEAT_OK` skip behavior.
- Delivery defaults to the last used channel (or configured target). - Delivery defaults to the last used channel (or configured target).
## Reconnect behavior ## Reconnect behavior
@@ -331,7 +330,6 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- `agents.defaults.heartbeat.model` (optional override) - `agents.defaults.heartbeat.model` (optional override)
- `agents.defaults.heartbeat.target` - `agents.defaults.heartbeat.target`
- `agents.defaults.heartbeat.to` - `agents.defaults.heartbeat.to`
- `agents.list[].heartbeat.*` (per-agent overrides)
- `session.*` (scope, idle, store, mainKey) - `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable channel startup when false) - `web.enabled` (disable channel startup when false)
- `web.heartbeatSeconds` - `web.heartbeatSeconds`
+1 -3
View File
@@ -11,12 +11,10 @@ Interactive prompt to set up credentials, devices, and agent defaults.
Related: Related:
- Gateway configuration reference: [Configuration](/gateway/configuration) - Gateway configuration reference: [Configuration](/gateway/configuration)
Notes:
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
## Examples ## Examples
```bash ```bash
clawdbot configure clawdbot configure
clawdbot configure --section models --section channels clawdbot configure --section models --section channels
``` ```
+1 -7
View File
@@ -22,10 +22,6 @@ clawdbot models set <model-or-alias>
clawdbot models scan clawdbot models scan
``` ```
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
## Aliases + fallbacks ## Aliases + fallbacks
```bash ```bash
@@ -37,9 +33,7 @@ clawdbot models fallbacks list
```bash ```bash
clawdbot models auth add clawdbot models auth add
clawdbot models auth login --provider <id>
clawdbot models auth setup-token clawdbot models auth setup-token
clawdbot models auth paste-token clawdbot models auth paste-token
``` ```
`models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use
`clawdbot plugins list` to see which providers are installed.
-11
View File
@@ -48,17 +48,6 @@ If no explicit order is configured, Clawdbot uses a round‑robin order:
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type). - **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry. - **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
### Session stickiness (cache-friendly)
Clawdbot **pins the chosen auth profile per session** to keep provider caches warm.
It does **not** rotate on every request. The pinned profile is reused until:
- the session is reset (`/new` / `/reset`)
- a compaction completes (compaction count increments)
- the profile is in cooldown/disabled
Manual selection via `/model …@<profileId>` sets a **user override** for that session
and is not auto‑rotated until a new session starts.
### Why OAuth can “look lost” ### Why OAuth can “look lost”
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile: If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
-7
View File
@@ -15,13 +15,6 @@ Clawdbot supports “subscription auth” via OAuth for providers that offer it
- how we **reuse external CLI tokens** (Claude Code / Codex CLI) - how we **reuse external CLI tokens** (Claude Code / Codex CLI)
- how to handle **multiple accounts** (profiles + per-session overrides) - how to handle **multiple accounts** (profiles + per-session overrides)
Clawdbot also supports **provider plugins** that ship their own OAuth or API‑key
flows. Run them via:
```bash
clawdbot models auth login --provider <id>
```
## The token sink (why it exists) ## The token sink (why it exists)
OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app. OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app.
-16
View File
@@ -26,22 +26,6 @@ The prompt is intentionally compact and uses fixed sections:
- **Runtime**: host, OS, node, model, thinking level (one line). - **Runtime**: host, OS, node, model, thinking level (one line).
- **Reasoning**: current visibility level + /reasoning toggle hint. - **Reasoning**: current visibility level + /reasoning toggle hint.
## Prompt modes
Clawdbot can render smaller system prompts for sub-agents. The runtime sets a
`promptMode` for each run (not a user-facing config):
- `full` (default): includes all sections above.
- `minimal`: used for sub-agents; omits **Skills**, **Memory Recall**, **Clawdbot
Self-Update**, **Model Aliases**, **User Identity**, **Reply Tags**,
**Messaging**, **Silent Replies**, and **Heartbeats**. Tooling, Workspace,
Sandbox, Current Date & Time (when known), Runtime, and injected context stay
available.
- `none`: returns only the base identity line.
When `promptMode=minimal`, extra injected prompts are labeled **Subagent
Context** instead of **Group Chat Context**.
## Workspace bootstrap injection ## Workspace bootstrap injection
Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads: Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads:
+1 -1
View File
@@ -44,7 +44,7 @@ Common methods + events:
| --- | --- | --- | | --- | --- | --- |
| Core | `connect`, `health`, `status` | `connect` must be first | | Core | `connect`, `health`, `status` | `connect` must be first |
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` | | Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
| Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these | | Chat | `chat.history`, `chat.send`, `chat.abort` | WebChat uses these |
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin | | Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions | | Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push | | Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
- No estimated costs; only the provider-reported windows. - No estimated costs; only the provider-reported windows.
## Where it shows up ## Where it shows up
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available). - `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only) and provider quota windows when available.
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only). - `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
-38
View File
@@ -455,44 +455,6 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
} }
``` ```
### Anthropic subscription + API key, MiniMax fallback
```json5
{
auth: {
profiles: {
"anthropic:subscription": {
provider: "anthropic",
mode: "oauth",
email: "user@example.com"
},
"anthropic:api": {
provider: "anthropic",
mode: "api_key"
}
},
order: {
anthropic: ["anthropic:subscription", "anthropic:api"]
}
},
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
apiKey: "${MINIMAX_API_KEY}"
}
}
},
agent: {
workspace: "~/clawd",
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["minimax/MiniMax-M2.1"]
}
}
}
```
### Work bot (restricted access) ### Work bot (restricted access)
```json5 ```json5
{ {
+4 -6
View File
@@ -1052,6 +1052,8 @@ Slack runs in Socket Mode and requires both a bot token and app token:
enabled: true, enabled: true,
botToken: "xoxb-...", botToken: "xoxb-...",
appToken: "xapp-...", appToken: "xapp-...",
userToken: "xoxp-...", // optional (read-only by default)
userTokenReadOnly: true, // default: true
dm: { dm: {
enabled: true, enabled: true,
policy: "pairing", // pairing | allowlist | open | disabled policy: "pairing", // pairing | allowlist | open | disabled
@@ -1102,6 +1104,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account. Multi-account support lives under `channels.slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands. Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
Optional user tokens (`xoxp-...`) are configured in the config file (no env var support). Reads prefer the user token when present; writes use the bot token unless you set `channels.slack.userTokenReadOnly: false` and no bot token is available.
Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`). Set `channels.slack.configWrites: false` to block Slack-initiated config writes (including channel ID migrations and `/config set|unset`).
Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`. Bot-authored messages are ignored by default. Enable with `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
@@ -1675,14 +1678,9 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`. - `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`. - `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read. - `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
Per-agent heartbeats:
- Set `agents.list[].heartbeat` to enable or override heartbeat settings for a specific agent.
- If any agent entry defines `heartbeat`, **only those agents** run heartbeats; defaults
become the shared baseline for those agents.
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`. of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
+6 -43
View File
@@ -33,9 +33,9 @@ Example config:
## Defaults ## Defaults
- Interval: `30m` (set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable). - Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable).
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): - Prompt body (configurable via `agents.defaults.heartbeat.prompt`):
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.` `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
- The heartbeat prompt is sent **verbatim** as the user message. The system - The heartbeat prompt is sent **verbatim** as the user message. The system
prompt includes a “Heartbeat” section and the run is flagged internally. prompt includes a “Heartbeat” section and the run is flagged internally.
@@ -49,8 +49,8 @@ The default prompt is intentionally broad:
by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)). by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)).
If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub
stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` (or stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` to a
`agents.list[].heartbeat.prompt`) to a custom body (sent verbatim). custom body (sent verbatim).
## Response contract ## Response contract
@@ -77,7 +77,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
includeReasoning: false, // default: false (deliver separate Reasoning: message when available) includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
to: "+15551234567", // optional channel-specific override to: "+15551234567", // optional channel-specific override
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.", prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.",
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
} }
} }
@@ -85,39 +85,6 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
} }
``` ```
### Per-agent heartbeats
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat`
(so you can set shared defaults once and override per agent).
Example: two agents, only the second agent runs heartbeats.
```json5
{
agents: {
defaults: {
heartbeat: {
every: "30m",
target: "last"
}
},
list: [
{ id: "main", default: true },
{
id: "ops",
heartbeat: {
every: "1h",
target: "whatsapp",
to: "+15551234567",
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."
}
}
]
}
}
```
### Field notes ### Field notes
- `every`: heartbeat interval (duration string; default unit = minutes). - `every`: heartbeat interval (duration string; default unit = minutes).
@@ -133,8 +100,7 @@ Example: two agents, only the second agent runs heartbeats.
## Delivery behavior ## Delivery behavior
- Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global` - Heartbeats run in the **main session** (`main`, or `global` when scope is global).
when `session.scope = "global"`.
- If the main queue is busy, the heartbeat is skipped and retried later. - If the main queue is busy, the heartbeat is skipped and retried later.
- If `target` resolves to no external destination, the run still happens but no - If `target` resolves to no external destination, the run still happens but no
outbound message is sent. outbound message is sent.
@@ -183,9 +149,6 @@ You can enqueue a system event and trigger an immediate heartbeat with:
clawdbot wake --text "Check for urgent follow-ups" --mode now clawdbot wake --text "Check for urgent follow-ups" --mode now
``` ```
If multiple agents have `heartbeat` configured, a manual wake runs each of those
agent heartbeats immediately.
Use `--mode next-heartbeat` to wait for the next scheduled tick. Use `--mode next-heartbeat` to wait for the next scheduled tick.
## Reasoning delivery (optional) ## Reasoning delivery (optional)
+2 -2
View File
@@ -24,8 +24,8 @@ agent (with a session switcher for other sessions).
## How it’s wired ## How it’s wired
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`, - Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort` and
`chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`. events `chat`, `agent`, `presence`, `tick`, `health`.
- Session: defaults to the primary session (`main`, or `global` when scope is - Session: defaults to the primary session (`main`, or `global` when scope is
global). The UI can switch between sessions. global). The UI can switch between sessions.
- Onboarding uses a dedicated session to keep first‑run setup separate. - Onboarding uses a dedicated session to keep first‑run setup separate.
-26
View File
@@ -110,32 +110,6 @@ Tip: compare against `pnpm clawdbot gateway discover --json` to see whether the
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
the Node CLI’s `dns-sd` based discovery. the Node CLI’s `dns-sd` based discovery.
## Remote connection plumbing (SSH tunnels)
When the macOS app runs in **Remote** mode, it opens SSH tunnels so local UI
components can talk to a remote Gateway as if it were on localhost. There are
two independent tunnels:
### Control tunnel (Gateway control/WebSocket port)
- **Purpose:** health checks, status, Web Chat, config, and other control-plane calls.
- **Local port:** the Gateway port (default `18789`), always stable.
- **Remote port:** the same Gateway port on the remote host.
- **Behavior:** no random local port; the app reuses an existing healthy tunnel
or restarts it if needed.
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
ExitOnForwardFailure + keepalive options.
### Node bridge tunnel (macOS node mode)
- **Purpose:** connect the macOS node to the Gateway **Bridge** protocol (TCP JSONL).
- **Remote port:** `gatewayPort + 1` (default `18790`), derived from the Gateway port.
- **Local port preference:** `CLAWDBOT_BRIDGE_PORT` or the default `18790`.
- **Behavior:** prefer the default bridge port for consistency; fall back to a
random local port if the preferred one is busy. The node then connects to the
resolved local port.
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
details, see [Bridge protocol](/gateway/bridge-protocol).
## Related docs ## Related docs
- [Gateway runbook](/gateway) - [Gateway runbook](/gateway)
-50
View File
@@ -172,56 +172,6 @@ Plugins export either:
- A function: `(api) => { ... }` - A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }` - An object: `{ id, name, configSchema, register(api) { ... } }`
## Provider plugins (model auth)
Plugins can register **model provider auth** flows so users can run OAuth or
API-key setup inside Clawdbot (no external scripts needed).
Register a provider via `api.registerProvider(...)`. Each provider exposes one
or more auth methods (OAuth, API key, device code, etc.). These methods power:
- `clawdbot models auth login --provider <id> [--method <id>]`
Example:
```ts
api.registerProvider({
id: "acme",
label: "AcmeAI",
auth: [
{
id: "oauth",
label: "OAuth",
kind: "oauth",
run: async (ctx) => {
// Run OAuth flow and return auth profiles.
return {
profiles: [
{
profileId: "acme:default",
credential: {
type: "oauth",
provider: "acme",
access: "...",
refresh: "...",
expires: Date.now() + 3600 * 1000,
},
},
],
defaultModel: "acme/opus-1",
};
},
},
],
});
```
Notes:
- `run` receives a `ProviderAuthContext` with `prompter`, `runtime`,
`openUrl`, and `oauth.createVpsAwareHandlers` helpers.
- Return `configPatch` when you need to add default models or provider config.
- Return `defaultModel` so `--set-default` can update agent defaults.
### Register a messaging channel ### Register a messaging channel
Plugins can register **channel plugins** that behave like built‑in channels Plugins can register **channel plugins** that behave like built‑in channels
+1 -1
View File
@@ -63,7 +63,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
7) **GitHub release + appcast** 7) **GitHub release + appcast**
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`). - [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**. - [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `clawdbot X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**.
- [ ] Attach artifacts: `npm pack` tarball (optional), `Clawdbot-X.Y.Z.zip`, and `Clawdbot-X.Y.Z.dSYM.zip` (if generated). - [ ] Attach artifacts: `npm pack` tarball (optional), `Clawdbot-X.Y.Z.zip`, and `Clawdbot-X.Y.Z.dSYM.zip` (if generated).
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main). - [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work. - [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
+1 -1
View File
@@ -108,7 +108,7 @@ Skills provide your tools. When you need one, check its `SKILL.md`. Keep local n
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively! When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
Default heartbeat prompt: Default heartbeat prompt:
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.` `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn. You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
+1 -1
View File
@@ -175,7 +175,7 @@ Example:
## Heartbeats (proactive mode) ## Heartbeats (proactive mode)
By default, Clawdbot runs a heartbeat every 30 minutes with the prompt: By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.` `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
Set `agents.defaults.heartbeat.every: "0m"` to disable. Set `agents.defaults.heartbeat.every: "0m"` to disable.
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
+1 -1
View File
@@ -54,7 +54,7 @@ They run immediately, are stripped before the model sees the message, and the re
Text + native (when enabled): Text + native (when enabled):
- `/help` - `/help`
- `/commands` - `/commands`
- `/status` (show current status; includes provider usage/quota when available, plus OAuth/token status block when OAuth profiles exist) - `/status` (show current status; includes a short provider usage/quota line when available)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/usage` (alias: `/status`) - `/usage` (alias: `/status`)
- `/whoami` (show your sender id; alias: `/id`) - `/whoami` (show your sender id; alias: `/id`)
+2 -2
View File
@@ -50,8 +50,8 @@ read_when:
- Elevated mode docs live in [Elevated mode](/tools/elevated). - Elevated mode docs live in [Elevated mode](/tools/elevated).
## Heartbeats ## Heartbeats
- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats). - Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.list[].heartbeat.includeReasoning: true`. - Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true`.
## Web chat UI ## Web chat UI
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads. - The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
+1 -2
View File
@@ -28,7 +28,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
The onboarding wizard generates a gateway token by default, so paste it here on first connect. The onboarding wizard generates a gateway token by default, so paste it here on first connect.
## What it can do (today) ## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`) - Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
- Stream tool calls + live tool output cards in Chat (agent events) - Stream tool calls + live tool output cards in Chat (agent events)
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`) - Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`) - Instances: presence list + refresh (`system-presence`)
@@ -60,7 +60,6 @@ Notes:
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events. - `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion. - Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
- Stop: - Stop:
- Click **Stop** (calls `chat.abort`) - Click **Stop** (calls `chat.abort`)
- Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band
+1 -2
View File
@@ -19,8 +19,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
3) Ensure gateway auth is configured if you are not on loopback. 3) Ensure gateway auth is configured if you are not on loopback.
## How it works (behavior) ## How it works (behavior)
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`. - The UI connects to the Gateway WebSocket and uses `chat.history` + `chat.send`.
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
- History is always fetched from the gateway (no local file watching). - History is always fetched from the gateway (no local file watching).
- If the gateway is unreachable, WebChat is read-only. - If the gateway is unreachable, WebChat is read-only.
-2
View File
@@ -22,7 +22,6 @@ type ResolvedAgentConfig = {
model?: AgentEntry["model"]; model?: AgentEntry["model"];
memorySearch?: AgentEntry["memorySearch"]; memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"]; humanDelay?: AgentEntry["humanDelay"];
heartbeat?: AgentEntry["heartbeat"];
identity?: AgentEntry["identity"]; identity?: AgentEntry["identity"];
groupChat?: AgentEntry["groupChat"]; groupChat?: AgentEntry["groupChat"];
subagents?: AgentEntry["subagents"]; subagents?: AgentEntry["subagents"];
@@ -90,7 +89,6 @@ export function resolveAgentConfig(
: undefined, : undefined,
memorySearch: entry.memorySearch, memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay, humanDelay: entry.humanDelay,
heartbeat: entry.heartbeat,
identity: entry.identity, identity: entry.identity,
groupChat: entry.groupChat, groupChat: entry.groupChat,
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined, subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
+5 -102
View File
@@ -1,8 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CliBackendConfig } from "../config/types.js";
import { runCliAgent } from "./cli-runner.js"; import { runCliAgent } from "./cli-runner.js";
import { cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js";
const runCommandWithTimeoutMock = vi.fn(); const runCommandWithTimeoutMock = vi.fn();
const runExecMock = vi.fn(); const runExecMock = vi.fn();
@@ -19,12 +17,7 @@ describe("runCliAgent resume cleanup", () => {
}); });
it("kills stale resume processes for codex sessions", async () => { it("kills stale resume processes for codex sessions", async () => {
runExecMock runExecMock.mockResolvedValue({ stdout: "", stderr: "" });
.mockResolvedValueOnce({
stdout: " 1 S /bin/launchd\n",
stderr: "",
}) // cleanupSuspendedCliProcesses (ps)
.mockResolvedValueOnce({ stdout: "", stderr: "" }); // cleanupResumeProcesses (pkill)
runCommandWithTimeoutMock.mockResolvedValueOnce({ runCommandWithTimeoutMock.mockResolvedValueOnce({
stdout: "ok", stdout: "ok",
stderr: "", stderr: "",
@@ -50,103 +43,13 @@ describe("runCliAgent resume cleanup", () => {
return; return;
} }
expect(runExecMock).toHaveBeenCalledTimes(2); expect(runExecMock).toHaveBeenCalledTimes(1);
const pkillCall = runExecMock.mock.calls[1] ?? []; const args = runExecMock.mock.calls[0] ?? [];
expect(pkillCall[0]).toBe("pkill"); expect(args[0]).toBe("pkill");
const pkillArgs = pkillCall[1] as string[]; const pkillArgs = args[1] as string[];
expect(pkillArgs[0]).toBe("-f"); expect(pkillArgs[0]).toBe("-f");
expect(pkillArgs[1]).toContain("codex"); expect(pkillArgs[1]).toContain("codex");
expect(pkillArgs[1]).toContain("resume"); expect(pkillArgs[1]).toContain("resume");
expect(pkillArgs[1]).toContain("thread-123"); expect(pkillArgs[1]).toContain("thread-123");
}); });
}); });
describe("cleanupSuspendedCliProcesses", () => {
beforeEach(() => {
runExecMock.mockReset();
});
it("skips when no session tokens are configured", async () => {
await cleanupSuspendedCliProcesses(
{
command: "tool",
} as CliBackendConfig,
0,
);
if (process.platform === "win32") {
expect(runExecMock).not.toHaveBeenCalled();
return;
}
expect(runExecMock).not.toHaveBeenCalled();
});
it("matches sessionArg-based commands", async () => {
runExecMock
.mockResolvedValueOnce({
stdout: [
" 40 T+ claude --session-id thread-1 -p",
" 41 S claude --session-id thread-2 -p",
].join("\n"),
stderr: "",
})
.mockResolvedValueOnce({ stdout: "", stderr: "" });
await cleanupSuspendedCliProcesses(
{
command: "claude",
sessionArg: "--session-id",
} as CliBackendConfig,
0,
);
if (process.platform === "win32") {
expect(runExecMock).not.toHaveBeenCalled();
return;
}
expect(runExecMock).toHaveBeenCalledTimes(2);
const killCall = runExecMock.mock.calls[1] ?? [];
expect(killCall[0]).toBe("kill");
expect(killCall[1]).toEqual(["-9", "40"]);
});
it("matches resumeArgs with positional session id", async () => {
runExecMock
.mockResolvedValueOnce({
stdout: [
" 50 T codex exec resume thread-99 --color never --sandbox read-only",
" 51 T codex exec resume other --color never --sandbox read-only",
].join("\n"),
stderr: "",
})
.mockResolvedValueOnce({ stdout: "", stderr: "" });
await cleanupSuspendedCliProcesses(
{
command: "codex",
resumeArgs: [
"exec",
"resume",
"{sessionId}",
"--color",
"never",
"--sandbox",
"read-only",
],
} as CliBackendConfig,
1,
);
if (process.platform === "win32") {
expect(runExecMock).not.toHaveBeenCalled();
return;
}
expect(runExecMock).toHaveBeenCalledTimes(2);
const killCall = runExecMock.mock.calls[1] ?? [];
expect(killCall[0]).toBe("kill");
expect(killCall[1]).toEqual(["-9", "50", "51"]);
});
});
-3
View File
@@ -13,7 +13,6 @@ import {
buildCliArgs, buildCliArgs,
buildSystemPrompt, buildSystemPrompt,
cleanupResumeProcesses, cleanupResumeProcesses,
cleanupSuspendedCliProcesses,
enqueueCliRun, enqueueCliRun,
normalizeCliModel, normalizeCliModel,
parseCliJson, parseCliJson,
@@ -207,8 +206,6 @@ export async function runCliAgent(params: {
return next; return next;
})(); })();
// Cleanup suspended processes that have accumulated (regardless of sessionId)
await cleanupSuspendedCliProcesses(backend);
if (useResume && cliSessionIdToSend) { if (useResume && cliSessionIdToSend) {
await cleanupResumeProcesses(backend, cliSessionIdToSend); await cleanupResumeProcesses(backend, cliSessionIdToSend);
} }
-76
View File
@@ -44,82 +44,6 @@ export async function cleanupResumeProcesses(
} }
} }
function buildSessionMatchers(backend: CliBackendConfig): RegExp[] {
const commandToken = path.basename(backend.command ?? "").trim();
if (!commandToken) return [];
const matchers: RegExp[] = [];
const sessionArg = backend.sessionArg?.trim();
const sessionArgs = backend.sessionArgs ?? [];
const resumeArgs = backend.resumeArgs ?? [];
const addMatcher = (args: string[]) => {
if (args.length === 0) return;
const tokens = [commandToken, ...args];
const pattern = tokens
.map((token, index) => {
const tokenPattern = tokenToRegex(token);
return index === 0 ? `(?:^|\\s)${tokenPattern}` : `\\s+${tokenPattern}`;
})
.join("");
matchers.push(new RegExp(pattern));
};
if (sessionArgs.some((arg) => arg.includes("{sessionId}"))) {
addMatcher(sessionArgs);
} else if (sessionArg) {
addMatcher([sessionArg, "{sessionId}"]);
}
if (resumeArgs.some((arg) => arg.includes("{sessionId}"))) {
addMatcher(resumeArgs);
}
return matchers;
}
function tokenToRegex(token: string): string {
if (!token.includes("{sessionId}")) return escapeRegex(token);
const parts = token.split("{sessionId}").map((part) => escapeRegex(part));
return parts.join("\\S+");
}
/**
* Cleanup suspended Clawdbot CLI processes that have accumulated.
* Only cleans up if there are more than the threshold (default: 10).
*/
export async function cleanupSuspendedCliProcesses(
backend: CliBackendConfig,
threshold = 10,
): Promise<void> {
if (process.platform === "win32") return;
const matchers = buildSessionMatchers(backend);
if (matchers.length === 0) return;
try {
const { stdout } = await runExec("ps", ["-ax", "-o", "pid=,stat=,command="]);
const suspended: number[] = [];
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = /^(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed);
if (!match) continue;
const pid = Number(match[1]);
const stat = match[2] ?? "";
const command = match[3] ?? "";
if (!Number.isFinite(pid)) continue;
if (!stat.includes("T")) continue;
if (!matchers.some((matcher) => matcher.test(command))) continue;
suspended.push(pid);
}
if (suspended.length > threshold) {
// Verified locally: stopped (T) processes ignore SIGTERM, so use SIGKILL.
await runExec("kill", ["-9", ...suspended.map((pid) => String(pid))]);
}
} catch {
// ignore errors - best effort cleanup
}
}
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> { export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve(); const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task); const chained = prior.catch(() => undefined).then(task);
@@ -45,13 +45,6 @@ vi.mock("node:child_process", async (importOriginal) => {
}; };
}); });
vi.mock("../skills.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../skills.js")>();
return {
...actual,
syncSkillsToWorkspace: vi.fn(async () => undefined),
};
});
describe("Agent-specific sandbox config", () => { describe("Agent-specific sandbox config", () => {
beforeEach(() => { beforeEach(() => {
spawnCalls.length = 0; spawnCalls.length = 0;
@@ -46,13 +46,6 @@ vi.mock("node:child_process", async (importOriginal) => {
}; };
}); });
vi.mock("../skills.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../skills.js")>();
return {
...actual,
syncSkillsToWorkspace: vi.fn(async () => undefined),
};
});
describe("Agent-specific sandbox config", () => { describe("Agent-specific sandbox config", () => {
beforeEach(() => { beforeEach(() => {
spawnCalls.length = 0; spawnCalls.length = 0;
@@ -123,54 +123,4 @@ describe("subagent registry persistence", () => {
); );
expect(match).toBeFalsy(); expect(match).toBeFalsy();
}); });
it("retries cleanup announce after a failed announce", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 1,
runs: {
"run-3": {
runId: "run-3",
childSessionKey: "agent:main:subagent:three",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "retry announce",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
},
},
};
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
announceSpy.mockResolvedValueOnce(false);
vi.resetModules();
const mod1 = await import("./subagent-registry.js");
mod1.initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
expect(announceSpy).toHaveBeenCalledTimes(1);
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs: Record<string, { cleanupHandled?: boolean; cleanupCompletedAt?: number }>;
};
expect(afterFirst.runs["run-3"].cleanupHandled).toBe(false);
expect(afterFirst.runs["run-3"].cleanupCompletedAt).toBeUndefined();
announceSpy.mockResolvedValueOnce(true);
vi.resetModules();
const mod2 = await import("./subagent-registry.js");
mod2.initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
expect(announceSpy).toHaveBeenCalledTimes(2);
const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs: Record<string, { cleanupCompletedAt?: number }>;
};
expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeDefined();
});
}); });
-24
View File
@@ -23,30 +23,6 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).not.toContain("Owner numbers:"); expect(prompt).not.toContain("Owner numbers:");
}); });
it("omits extended sections in minimal prompt mode", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
promptMode: "minimal",
ownerNumbers: ["+123"],
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping",
toolNames: ["message", "memory_search"],
extraSystemPrompt: "Subagent details",
});
expect(prompt).not.toContain("## User Identity");
expect(prompt).not.toContain("## Skills");
expect(prompt).not.toContain("## Memory Recall");
expect(prompt).not.toContain("## Reply Tags");
expect(prompt).not.toContain("## Messaging");
expect(prompt).not.toContain("## Silent Replies");
expect(prompt).not.toContain("## Heartbeats");
expect(prompt).toContain("## Subagent Context");
expect(prompt).not.toContain("## Group Chat Context");
expect(prompt).toContain("Subagent details");
});
it("adds reasoning tag hint when enabled", () => { it("adds reasoning tag hint when enabled", () => {
const prompt = buildAgentSystemPrompt({ const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd", workspaceDir: "/tmp/clawd",
+74 -120
View File
@@ -12,105 +12,6 @@ import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
*/ */
export type PromptMode = "full" | "minimal" | "none"; export type PromptMode = "full" | "minimal" | "none";
function buildSkillsSection(params: {
skillsPrompt?: string;
isMinimal: boolean;
readToolName: string;
}) {
const trimmed = params.skillsPrompt?.trim();
if (!trimmed || params.isMinimal) return [];
return [
"## Skills",
`Skills provide task-specific instructions. Use \`${params.readToolName}\` to load the SKILL.md at the location listed for that skill.`,
trimmed,
"",
];
}
function buildMemorySection(params: {
isMinimal: boolean;
availableTools: Set<string>;
}) {
if (params.isMinimal) return [];
if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) {
return [];
}
return [
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
"",
];
}
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
if (!ownerLine || isMinimal) return [];
return ["## User Identity", ownerLine, ""];
}
function buildTimeSection(params: {
userTimezone?: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
}) {
if (!params.userTimezone && !params.userTime) return [];
return [
"## Current Date & Time",
params.userTime
? `${params.userTime} (${params.userTimezone ?? "unknown"})`
: `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
];
}
function buildReplyTagsSection(isMinimal: boolean) {
if (isMinimal) return [];
return [
"## Reply Tags",
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
"- [[reply_to_current]] replies to the triggering message.",
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"Tags are stripped before sending; support depends on the current channel config.",
"",
];
}
function buildMessagingSection(params: {
isMinimal: boolean;
availableTools: Set<string>;
messageChannelOptions: string;
inlineButtonsEnabled: boolean;
runtimeChannel?: string;
}) {
if (params.isMinimal) return [];
return [
"## Messaging",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
params.availableTools.has("message")
? [
"",
"### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
params.inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: params.runtimeChannel
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to add "inlineButtons" to ${params.runtimeChannel}.capabilities or ${params.runtimeChannel}.accounts.<id>.capabilities.`
: "",
]
.filter(Boolean)
.join("\n")
: "",
"",
];
}
export function buildAgentSystemPrompt(params: { export function buildAgentSystemPrompt(params: {
workspaceDir: string; workspaceDir: string;
defaultThinkLevel?: ThinkLevel; defaultThinkLevel?: ThinkLevel;
@@ -217,7 +118,6 @@ export function buildAgentSystemPrompt(params: {
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim()); const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
const canonicalToolNames = rawToolNames.filter(Boolean); const canonicalToolNames = rawToolNames.filter(Boolean);
// Preserve caller casing while deduping tool names by lowercase.
const canonicalByNormalized = new Map<string, string>(); const canonicalByNormalized = new Map<string, string>();
for (const name of canonicalToolNames) { for (const name of canonicalToolNames) {
const normalized = name.toLowerCase(); const normalized = name.toLowerCase();
@@ -291,12 +191,26 @@ export function buildAgentSystemPrompt(params: {
const messageChannelOptions = listDeliverableMessageChannels().join("|"); const messageChannelOptions = listDeliverableMessageChannels().join("|");
const promptMode = params.promptMode ?? "full"; const promptMode = params.promptMode ?? "full";
const isMinimal = promptMode === "minimal" || promptMode === "none"; const isMinimal = promptMode === "minimal" || promptMode === "none";
const skillsSection = buildSkillsSection({ const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
skillsPrompt, // Skip skills section for subagent/none modes
isMinimal, const skillsSection =
readToolName, skillsPrompt && !isMinimal
}); ? [
const memorySection = buildMemorySection({ isMinimal, availableTools }); "## Skills",
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
...skillsLines,
"",
]
: [];
// Skip memory section for subagent/none modes
const memorySection =
!isMinimal && (availableTools.has("memory_search") || availableTools.has("memory_get"))
? [
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
"",
]
: [];
// For "none" mode, return just the basic identity line // For "none" mode, return just the basic identity line
if (promptMode === "none") { if (promptMode === "none") {
@@ -421,23 +335,63 @@ export function buildAgentSystemPrompt(params: {
.join("\n") .join("\n")
: "", : "",
params.sandboxInfo?.enabled ? "" : "", params.sandboxInfo?.enabled ? "" : "",
...buildUserIdentitySection(ownerLine, isMinimal), // Skip user identity for subagent/none modes
...buildTimeSection({ ownerLine && !isMinimal ? "## User Identity" : "",
userTimezone, ownerLine && !isMinimal ? ownerLine : "",
userTime, ownerLine && !isMinimal ? "" : "",
userTimeFormat: params.userTimeFormat, ...(userTimezone || userTime
}), ? [
"## Current Date & Time",
userTime
? `${userTime} (${userTimezone ?? "unknown"})`
: `Time zone: ${userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
]
: []),
"## Workspace Files (injected)", "## Workspace Files (injected)",
"These user-editable files are loaded by Clawdbot and included below in Project Context.", "These user-editable files are loaded by Clawdbot and included below in Project Context.",
"", "",
...buildReplyTagsSection(isMinimal), // Skip reply tags for subagent/none modes
...buildMessagingSection({ ...(isMinimal
isMinimal, ? []
availableTools, : [
messageChannelOptions, "## Reply Tags",
inlineButtonsEnabled, "To request a native reply/quote on supported surfaces, include one tag in your reply:",
runtimeChannel, "- [[reply_to_current]] replies to the triggering message.",
}), "- [[reply_to:<id>]] replies to a specific message id when you have it.",
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"Tags are stripped before sending; support depends on the current channel config.",
"",
]),
// Skip messaging section for subagent/none modes
...(isMinimal
? []
: [
"## Messaging",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
availableTools.has("message")
? [
"",
"### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: runtimeChannel
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
: "",
]
.filter(Boolean)
.join("\n")
: "",
"",
]),
]; ];
if (extraSystemPrompt) { if (extraSystemPrompt) {
+11 -81
View File
@@ -1,6 +1,5 @@
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { resolveAgentDir } from "../../agents/agent-scope.js"; import { resolveAgentDir } from "../../agents/agent-scope.js";
import { buildAuthHealthSummary, formatRemainingShort } from "../../agents/auth-health.js";
import { import {
ensureAuthProfileStore, ensureAuthProfileStore,
resolveAuthProfileDisplayLabel, resolveAuthProfileDisplayLabel,
@@ -29,10 +28,9 @@ import {
updateSessionStore, updateSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { import {
formatUsageWindowSummary, formatUsageSummaryLine,
loadProviderUsageSummary, loadProviderUsageSummary,
resolveUsageProviderId, resolveUsageProviderId,
type UsageProviderId,
} from "../../infra/provider-usage.js"; } from "../../infra/provider-usage.js";
import { import {
buildAgentMainSessionKey, buildAgentMainSessionKey,
@@ -259,14 +257,10 @@ export function createSessionStatusTool(opts?: {
delete nextEntry.providerOverride; delete nextEntry.providerOverride;
delete nextEntry.modelOverride; delete nextEntry.modelOverride;
delete nextEntry.authProfileOverride; delete nextEntry.authProfileOverride;
delete nextEntry.authProfileOverrideSource;
delete nextEntry.authProfileOverrideCompactionCount;
} else { } else {
nextEntry.providerOverride = selection.provider; nextEntry.providerOverride = selection.provider;
nextEntry.modelOverride = selection.model; nextEntry.modelOverride = selection.model;
delete nextEntry.authProfileOverride; delete nextEntry.authProfileOverride;
delete nextEntry.authProfileOverrideSource;
delete nextEntry.authProfileOverrideCompactionCount;
} }
store[resolved.key] = nextEntry; store[resolved.key] = nextEntry;
await updateSessionStore(storePath, (nextStore) => { await updateSessionStore(storePath, (nextStore) => {
@@ -283,48 +277,24 @@ export function createSessionStatusTool(opts?: {
defaultModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL,
}); });
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider; const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); const usageProvider = resolveUsageProviderId(providerForCard);
const authHealth = buildAuthHealthSummary({ let usageLine: string | undefined;
store: authStore, if (usageProvider) {
cfg,
});
const oauthProfiles = authHealth.profiles.filter(
(profile) => profile.type === "oauth" || profile.type === "token",
);
const usageProviders = Array.from(
new Set(
oauthProfiles
.map((profile) => resolveUsageProviderId(profile.provider))
.filter((provider): provider is UsageProviderId => Boolean(provider)),
),
);
const usageByProvider = new Map<string, string>();
if (usageProviders.length > 0) {
try { try {
const usageSummary = await loadProviderUsageSummary({ const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500, timeoutMs: 3500,
providers: usageProviders, providers: [usageProvider],
agentDir, agentDir,
}); });
for (const snapshot of usageSummary.providers) { const formatted = formatUsageSummaryLine(usageSummary, {
const formatted = formatUsageWindowSummary(snapshot, { now: Date.now(),
now: Date.now(), });
maxWindows: 2, if (formatted) usageLine = formatted;
});
if (formatted) usageByProvider.set(snapshot.provider, formatted);
}
} catch { } catch {
// ignore // ignore
} }
} }
const usageProvider = resolveUsageProviderId(providerForCard);
const usageLine =
oauthProfiles.length === 0 && usageProvider && usageByProvider.has(usageProvider)
? `📊 Usage: ${usageByProvider.get(usageProvider)}`
: undefined;
const isGroup = const isGroup =
resolved.entry.chatType === "group" || resolved.entry.chatType === "group" ||
resolved.entry.chatType === "room" || resolved.entry.chatType === "room" ||
@@ -370,53 +340,13 @@ export function createSessionStatusTool(opts?: {
includeTranscriptUsage: false, includeTranscriptUsage: false,
}); });
const authStatusLines = (() => {
if (oauthProfiles.length === 0) return [];
const formatStatus = (status: string) => {
if (status === "ok") return "ok";
if (status === "static") return "static";
if (status === "expiring") return "expiring";
if (status === "missing") return "unknown";
return "expired";
};
const profilesByProvider = new Map<string, typeof oauthProfiles>();
for (const profile of oauthProfiles) {
const current = profilesByProvider.get(profile.provider);
if (current) current.push(profile);
else profilesByProvider.set(profile.provider, [profile]);
}
const lines: string[] = ["OAuth/token status"];
for (const [provider, profiles] of profilesByProvider) {
const usageKey = resolveUsageProviderId(provider);
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
const usageSuffix = usage ? ` — usage: ${usage}` : "";
lines.push(`- ${provider}${usageSuffix}`);
for (const profile of profiles) {
const labelText = profile.label || profile.profileId;
const status = formatStatus(profile.status);
const expiry =
profile.status === "static"
? ""
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source = profile.source !== "store" ? ` (${profile.source})` : "";
lines.push(` - ${labelText} ${status}${expiry}${source}`);
}
}
return lines;
})();
const fullStatusText =
authStatusLines.length > 0 ? `${statusText}\n\n${authStatusLines.join("\n")}` : statusText;
return { return {
content: [{ type: "text", text: fullStatusText }], content: [{ type: "text", text: statusText }],
details: { details: {
ok: true, ok: true,
sessionKey: resolved.key, sessionKey: resolved.key,
changedModel, changedModel,
statusText: fullStatusText, statusText,
}, },
}; };
}, },
+34
View File
@@ -373,6 +373,28 @@ describe("handleSlackAction", () => {
expect(opts?.token).toBe("xoxp-1"); expect(opts?.token).toBe("xoxp-1");
}); });
it("uses user token for pin reads when available", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as ClawdbotConfig;
listSlackPins.mockClear();
listSlackPins.mockResolvedValueOnce([]);
await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
const [, opts] = listSlackPins.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1");
});
it("uses user token for member info reads when available", async () => {
const cfg = {
channels: { slack: { botToken: "xoxb-1", userToken: "xoxp-1" } },
} as ClawdbotConfig;
getSlackMemberInfo.mockClear();
getSlackMemberInfo.mockResolvedValueOnce({});
await handleSlackAction({ action: "memberInfo", userId: "U1" }, cfg);
const [, opts] = getSlackMemberInfo.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1");
});
it("falls back to bot token for reads when user token missing", async () => { it("falls back to bot token for reads when user token missing", async () => {
const cfg = { const cfg = {
channels: { slack: { botToken: "xoxb-1" } }, channels: { slack: { botToken: "xoxb-1" } },
@@ -405,4 +427,16 @@ describe("handleSlackAction", () => {
const [, , opts] = sendSlackMessage.mock.calls[0] ?? []; const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
expect(opts?.token).toBe("xoxp-1"); expect(opts?.token).toBe("xoxp-1");
}); });
it("prefers bot token for writes when both tokens are available", async () => {
const cfg = {
channels: {
slack: { botToken: "xoxb-1", userToken: "xoxp-1", userTokenReadOnly: false },
},
} as ClawdbotConfig;
sendSlackMessage.mockClear();
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
const [, , opts] = sendSlackMessage.mock.calls[0] ?? [];
expect(opts?.token).toBeUndefined();
});
}); });
+9 -14
View File
@@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { resolveSlackAccount } from "../../slack/accounts.js"; import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveSlackTokenOverride } from "../../slack/token.js";
import { import {
deleteSlackMessage, deleteSlackMessage,
editSlackMessage, editSlackMessage,
@@ -81,20 +82,14 @@ export async function handleSlackAction(
const account = resolveSlackAccount({ cfg, accountId }); const account = resolveSlackAccount({ cfg, accountId });
const actionConfig = account.actions ?? cfg.channels?.slack?.actions; const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
const isActionEnabled = createActionGate(actionConfig); const isActionEnabled = createActionGate(actionConfig);
const userToken = account.config.userToken?.trim() || undefined;
const botToken = account.botToken?.trim();
const allowUserWrites = account.config.userTokenReadOnly === false;
// Choose the most appropriate token for Slack read/write operations.
const getTokenForOperation = (operation: "read" | "write") => {
if (operation === "read") return userToken ?? botToken;
if (!allowUserWrites) return botToken;
return botToken ?? userToken;
};
const buildActionOpts = (operation: "read" | "write") => { const buildActionOpts = (operation: "read" | "write") => {
const token = getTokenForOperation(operation); const tokenOverride = resolveSlackTokenOverride({
const tokenOverride = token && token !== botToken ? token : undefined; botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation,
});
if (!accountId && !tokenOverride) return undefined; if (!accountId && !tokenOverride) return undefined;
return { return {
...(accountId ? { accountId } : {}), ...(accountId ? { accountId } : {}),
@@ -260,7 +255,7 @@ export async function handleSlackAction(
} }
return jsonResult({ ok: true }); return jsonResult({ ok: true });
} }
const pins = writeOpts const pins = readOpts
? await listSlackPins(channelId, readOpts) ? await listSlackPins(channelId, readOpts)
: await listSlackPins(channelId); : await listSlackPins(channelId);
const normalizedPins = pins.map((pin) => { const normalizedPins = pins.map((pin) => {
@@ -280,7 +275,7 @@ export async function handleSlackAction(
throw new Error("Slack member info is disabled."); throw new Error("Slack member info is disabled.");
} }
const userId = readStringParam(params, "userId", { required: true }); const userId = readStringParam(params, "userId", { required: true });
const info = writeOpts const info = readOpts
? await getSlackMemberInfo(userId, readOpts) ? await getSlackMemberInfo(userId, readOpts)
: await getSlackMemberInfo(userId); : await getSlackMemberInfo(userId);
return jsonResult({ ok: true, info }); return jsonResult({ ok: true, info });
+1 -6
View File
@@ -90,12 +90,7 @@ Add whatever else you want the assistant to know about your local toolchain.
const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md
Optional: keep a tiny checklist for heartbeat runs. Keep this file empty unless you want a tiny checklist. Keep it small.
Guidance (to avoid nagging):
- Only report items that are truly new or changed.
- Do not invent tasks from old chat context.
- If nothing needs attention, reply HEARTBEAT_OK.
`; `;
const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after) const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after)
+1 -3
View File
@@ -1,9 +1,7 @@
import { HEARTBEAT_TOKEN } from "./tokens.js"; import { HEARTBEAT_TOKEN } from "./tokens.js";
// Default heartbeat prompt (used when config.agents.defaults.heartbeat.prompt is unset).
// Keep it tight and avoid encouraging the model to invent/rehash "open loops" from prior chat context.
export const HEARTBEAT_PROMPT = export const HEARTBEAT_PROMPT =
"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."; "Consider outstanding tasks and HEARTBEAT.md guidance from the workspace context (if present). Checkup sometimes on your human during (user local) day time.";
export const DEFAULT_HEARTBEAT_EVERY = "30m"; export const DEFAULT_HEARTBEAT_EVERY = "30m";
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300; export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
@@ -264,12 +264,8 @@ export async function handleDirectiveOnly(params: {
} }
if (profileOverride) { if (profileOverride) {
sessionEntry.authProfileOverride = profileOverride; sessionEntry.authProfileOverride = profileOverride;
sessionEntry.authProfileOverrideSource = "user";
delete sessionEntry.authProfileOverrideCompactionCount;
} else if (directives.hasModelDirective) { } else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride; delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
} }
} }
if (directives.hasQueueDirective && directives.queueReset) { if (directives.hasQueueDirective && directives.queueReset) {
@@ -156,12 +156,8 @@ export async function persistInlineDirectives(params: {
} }
if (profileOverride) { if (profileOverride) {
sessionEntry.authProfileOverride = profileOverride; sessionEntry.authProfileOverride = profileOverride;
sessionEntry.authProfileOverrideSource = "user";
delete sessionEntry.authProfileOverrideCompactionCount;
} else if (directives.hasModelDirective) { } else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride; delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
} }
provider = resolved.ref.provider; provider = resolved.ref.provider;
model = resolved.ref.model; model = resolved.ref.model;
+1 -96
View File
@@ -5,15 +5,9 @@ import {
isEmbeddedPiRunStreaming, isEmbeddedPiRunStreaming,
resolveEmbeddedSessionLane, resolveEmbeddedSessionLane,
} from "../../agents/pi-embedded.js"; } from "../../agents/pi-embedded.js";
import {
ensureAuthProfileStore,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { import {
resolveSessionFilePath, resolveSessionFilePath,
saveSessionStore,
type SessionEntry, type SessionEntry,
updateSessionStore, updateSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
@@ -103,86 +97,6 @@ type RunPreparedReplyParams = {
abortedLastRun: boolean; abortedLastRun: boolean;
}; };
async function resolveSessionAuthProfileOverride(params: {
cfg: ClawdbotConfig;
provider: string;
agentDir: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
isNewSession: boolean;
}): Promise<string | undefined> {
const {
cfg,
provider,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
storePath,
isNewSession,
} = params;
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const order = resolveAuthProfileOrder({ cfg, store, provider });
if (order.length === 0) return sessionEntry.authProfileOverride;
const pickFirstAvailable = () =>
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
const pickNextAvailable = (current: string) => {
const startIndex = order.indexOf(current);
if (startIndex < 0) return pickFirstAvailable();
for (let offset = 1; offset <= order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (!isProfileInCooldown(store, candidate)) return candidate;
}
return order[startIndex] ?? order[0];
};
const compactionCount = sessionEntry.compactionCount ?? 0;
const storedCompaction =
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? sessionEntry.authProfileOverrideCompactionCount
: compactionCount;
let current = sessionEntry.authProfileOverride?.trim();
if (current && !order.includes(current)) current = undefined;
const source = sessionEntry.authProfileOverrideSource ?? (current ? "user" : undefined);
if (source === "user" && current && !isNewSession) {
return current;
}
let next = current;
if (isNewSession) {
next = current ? pickNextAvailable(current) : pickFirstAvailable();
} else if (current && compactionCount > storedCompaction) {
next = pickNextAvailable(current);
} else if (!current || isProfileInCooldown(store, current)) {
next = pickFirstAvailable();
}
if (!next) return current;
const shouldPersist =
next !== sessionEntry.authProfileOverride ||
sessionEntry.authProfileOverrideSource !== "auto" ||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
if (shouldPersist) {
sessionEntry.authProfileOverride = next;
sessionEntry.authProfileOverrideSource = "auto";
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
return next;
}
export async function runPreparedReply( export async function runPreparedReply(
params: RunPreparedReplyParams, params: RunPreparedReplyParams,
): Promise<ReplyPayload | ReplyPayload[] | undefined> { ): Promise<ReplyPayload | ReplyPayload[] | undefined> {
@@ -400,16 +314,7 @@ export async function runPreparedReply(
resolvedQueue.mode === "followup" || resolvedQueue.mode === "followup" ||
resolvedQueue.mode === "collect" || resolvedQueue.mode === "collect" ||
resolvedQueue.mode === "steer-backlog"; resolvedQueue.mode === "steer-backlog";
const authProfileId = await resolveSessionAuthProfileOverride({ const authProfileId = sessionEntry?.authProfileOverride;
cfg,
provider,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
storePath,
isNewSession,
});
const followupRun = { const followupRun = {
prompt: queuedBody, prompt: queuedBody,
messageId: sessionCtx.MessageSid, messageId: sessionCtx.MessageSid,
-2
View File
@@ -217,8 +217,6 @@ export async function createModelSelectionState(params: {
const profile = store.profiles[sessionEntry.authProfileOverride]; const profile = store.profiles[sessionEntry.authProfileOverride];
if (!profile || profile.provider !== provider) { if (!profile || profile.provider !== provider) {
delete sessionEntry.authProfileOverride; delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
sessionEntry.updatedAt = Date.now(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
if (storePath) { if (storePath) {
-1
View File
@@ -15,7 +15,6 @@ export type SetupChannelsOptions = {
promptWhatsAppAccountId?: boolean; promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void; onWhatsAppAccountId?: (accountId: string) => void;
forceAllowFromChannels?: ChannelId[]; forceAllowFromChannels?: ChannelId[];
skipStatusNote?: boolean;
skipDmPolicyPrompt?: boolean; skipDmPolicyPrompt?: boolean;
skipConfirm?: boolean; skipConfirm?: boolean;
quickstartDefaults?: boolean; quickstartDefaults?: boolean;
+20 -2
View File
@@ -1,4 +1,6 @@
import { resolveSlackAccount } from "../../../slack/accounts.js";
import { sendMessageSlack } from "../../../slack/send.js"; import { sendMessageSlack } from "../../../slack/send.js";
import { resolveSlackTokenOverride } from "../../../slack/token.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
export const slackOutbound: ChannelOutboundAdapter = { export const slackOutbound: ChannelOutboundAdapter = {
@@ -15,20 +17,36 @@ export const slackOutbound: ChannelOutboundAdapter = {
} }
return { ok: true, to: trimmed }; return { ok: true, to: trimmed };
}, },
sendText: async ({ to, text, accountId, deps, replyToId }) => { sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, { const result = await send(to, text, {
threadTs: replyToId ?? undefined, threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
}); });
return { channel: "slack", ...result }; return { channel: "slack", ...result };
}, },
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const tokenOverride = resolveSlackTokenOverride({
botToken: account.botToken,
userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, { const result = await send(to, text, {
mediaUrl, mediaUrl,
threadTs: replyToId ?? undefined, threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
}); });
return { channel: "slack", ...result }; return { channel: "slack", ...result };
}, },
+28 -230
View File
@@ -1,9 +1,5 @@
import { createActionGate, readNumberParam, readStringParam } from "../../agents/tools/common.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import { loadConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { import {
listEnabledSlackAccounts,
listSlackAccountIds, listSlackAccountIds,
type ResolvedSlackAccount, type ResolvedSlackAccount,
resolveDefaultSlackAccountId, resolveDefaultSlackAccountId,
@@ -11,6 +7,7 @@ import {
} from "../../slack/accounts.js"; } from "../../slack/accounts.js";
import { probeSlack } from "../../slack/probe.js"; import { probeSlack } from "../../slack/probe.js";
import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageSlack } from "../../slack/send.js";
import { resolveSlackTokenOverride } from "../../slack/token.js";
import { getChatChannelMeta } from "../registry.js"; import { getChatChannelMeta } from "../registry.js";
import { import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
@@ -25,23 +22,11 @@ import {
applyAccountNameToChannelSection, applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount, migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js"; } from "./setup-helpers.js";
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js"; import { createSlackActions } from "./slack.actions.js";
import type { ChannelPlugin } from "./types.js";
const meta = getChatChannelMeta("slack"); const meta = getChatChannelMeta("slack");
// Select the appropriate Slack token for read/write operations.
function getTokenForOperation(
account: ResolvedSlackAccount,
operation: "read" | "write",
): string | undefined {
const userToken = account.config.userToken?.trim() || undefined;
const botToken = account.botToken?.trim();
const allowUserWrites = account.config.userTokenReadOnly === false;
if (operation === "read") return userToken ?? botToken;
if (!allowUserWrites) return botToken;
return botToken ?? userToken;
}
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = { export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
id: "slack", id: "slack",
meta: { meta: {
@@ -51,22 +36,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
pairing: { pairing: {
idLabel: "slackUserId", idLabel: "slackUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""),
notifyApproval: async ({ id }) => { notifyApproval: async ({ cfg, id }) => {
const cfg = loadConfig(); const account = resolveSlackAccount({ cfg, accountId: DEFAULT_ACCOUNT_ID });
const account = resolveSlackAccount({ const tokenOverride = resolveSlackTokenOverride({
cfg, botToken: account.botToken,
accountId: DEFAULT_ACCOUNT_ID, userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
}); });
const token = getTokenForOperation(account, "write"); await sendMessageSlack(
const botToken = account.botToken?.trim(); `user:${id}`,
const tokenOverride = token && token !== botToken ? token : undefined; PAIRING_APPROVED_MESSAGE,
if (tokenOverride) { tokenOverride ? { token: tokenOverride } : undefined,
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE, { );
token: tokenOverride,
});
} else {
await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE);
}
}, },
}, },
capabilities: { capabilities: {
@@ -169,197 +151,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
messaging: { messaging: {
normalizeTarget: normalizeSlackMessagingTarget, normalizeTarget: normalizeSlackMessagingTarget,
}, },
actions: { actions: createSlackActions(meta.id),
listActions: ({ cfg }) => {
const accounts = listEnabledSlackAccounts(cfg).filter(
(account) => account.botTokenSource !== "none",
);
if (accounts.length === 0) return [];
const isActionEnabled = (key: string, defaultValue = true) => {
for (const account of accounts) {
const gate = createActionGate(
(account.actions ?? cfg.channels?.slack?.actions) as Record<
string,
boolean | undefined
>,
);
if (gate(key, defaultValue)) return true;
}
return false;
};
const actions = new Set<ChannelMessageActionName>(["send"]);
if (isActionEnabled("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (isActionEnabled("messages")) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (isActionEnabled("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (isActionEnabled("memberInfo")) actions.add("member-info");
if (isActionEnabled("emojiList")) actions.add("emoji-list");
return Array.from(actions);
},
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null;
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null;
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const resolveChannelId = () =>
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const threadId = readStringParam(params, "threadId");
const replyTo = readStringParam(params, "replyTo");
return await handleSlackAction(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
accountId: accountId ?? undefined,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
toolContext,
);
}
if (action === "react") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await handleSlackAction(
{
action: "react",
channelId: resolveChannelId(),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
return await handleSlackAction(
{
action: "reactions",
channelId: resolveChannelId(),
messageId,
limit,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await handleSlackAction(
{
action: "readMessages",
channelId: resolveChannelId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "message", { required: true });
return await handleSlackAction(
{
action: "editMessage",
channelId: resolveChannelId(),
messageId,
content,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
return await handleSlackAction(
{
action: "deleteMessage",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await handleSlackAction(
{
action:
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg,
);
}
if (action === "emoji-list") {
return await handleSlackAction(
{ action: "emojiList", accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(`Action ${action} is not supported for provider ${meta.id}.`);
},
},
setup: { setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => applyAccountName: ({ cfg, accountId, name }) =>
@@ -448,9 +240,12 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => { sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId }); const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write"); const tokenOverride = resolveSlackTokenOverride({
const botToken = account.botToken?.trim(); botToken: account.botToken,
const tokenOverride = token && token !== botToken ? token : undefined; userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, { const result = await send(to, text, {
threadTs: replyToId ?? undefined, threadTs: replyToId ?? undefined,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
@@ -461,9 +256,12 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => { sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, cfg }) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId }); const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write"); const tokenOverride = resolveSlackTokenOverride({
const botToken = account.botToken?.trim(); botToken: account.botToken,
const tokenOverride = token && token !== botToken ? token : undefined; userToken: account.userToken,
userTokenReadOnly: account.config.userTokenReadOnly,
operation: "write",
});
const result = await send(to, text, { const result = await send(to, text, {
mediaUrl, mediaUrl,
threadTs: replyToId ?? undefined, threadTs: replyToId ?? undefined,
+7 -30
View File
@@ -4,14 +4,13 @@ import {
githubCopilotLoginCommand, githubCopilotLoginCommand,
modelsAliasesAddCommand, modelsAliasesAddCommand,
modelsAliasesListCommand, modelsAliasesListCommand,
modelsAliasesRemoveCommand, modelsAliasesRemoveCommand,
modelsAuthAddCommand, modelsAuthAddCommand,
modelsAuthLoginCommand, modelsAuthOrderClearCommand,
modelsAuthOrderClearCommand, modelsAuthOrderGetCommand,
modelsAuthOrderGetCommand, modelsAuthOrderSetCommand,
modelsAuthOrderSetCommand, modelsAuthPasteTokenCommand,
modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand,
modelsAuthSetupTokenCommand,
modelsFallbacksAddCommand, modelsFallbacksAddCommand,
modelsFallbacksClearCommand, modelsFallbacksClearCommand,
modelsFallbacksListCommand, modelsFallbacksListCommand,
@@ -310,28 +309,6 @@ export function registerModelsCli(program: Command) {
} }
}); });
auth
.command("login")
.description("Run a provider plugin auth flow (OAuth/API key)")
.option("--provider <id>", "Provider id registered by a plugin")
.option("--method <id>", "Provider auth method id")
.option("--set-default", "Apply the provider's default model recommendation", false)
.action(async (opts) => {
try {
await modelsAuthLoginCommand(
{
provider: opts.provider as string | undefined,
method: opts.method as string | undefined,
setDefault: Boolean(opts.setDefault),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
auth auth
.command("setup-token") .command("setup-token")
.description("Run a provider CLI to create/sync a token (TTY required)") .description("Run a provider CLI to create/sync a token (TTY required)")
-6
View File
@@ -49,9 +49,6 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
` origin: ${plugin.origin}`, ` origin: ${plugin.origin}`,
]; ];
if (plugin.version) parts.push(` version: ${plugin.version}`); if (plugin.version) parts.push(` version: ${plugin.version}`);
if (plugin.providerIds.length > 0) {
parts.push(` providers: ${plugin.providerIds.join(", ")}`);
}
if (plugin.error) parts.push(chalk.red(` error: ${plugin.error}`)); if (plugin.error) parts.push(chalk.red(` error: ${plugin.error}`));
return parts.join("\n"); return parts.join("\n");
} }
@@ -141,9 +138,6 @@ export function registerPluginsCli(program: Command) {
if (plugin.gatewayMethods.length > 0) { if (plugin.gatewayMethods.length > 0) {
lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`); lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`);
} }
if (plugin.providerIds.length > 0) {
lines.push(`Providers: ${plugin.providerIds.join(", ")}`);
}
if (plugin.cliCommands.length > 0) { if (plugin.cliCommands.length > 0) {
lines.push(`CLI commands: ${plugin.cliCommands.join(", ")}`); lines.push(`CLI commands: ${plugin.cliCommands.join(", ")}`);
} }
-11
View File
@@ -11,19 +11,8 @@ import { autoMigrateLegacyState } from "../../infra/state-migrations.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js"; import { emitCliBanner } from "../banner.js";
function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand;
while (current.parent && current.parent.parent) {
current = current.parent;
}
const name = current.name();
if (!name || name === "clawdbot") return;
process.title = `clawdbot-${name}`;
}
export function registerPreActionHooks(program: Command, programVersion: string) { export function registerPreActionHooks(program: Command, programVersion: string) {
program.hook("preAction", async (_thisCommand, actionCommand) => { program.hook("preAction", async (_thisCommand, actionCommand) => {
setProcessTitleForCommand(actionCommand);
emitCliBanner(programVersion); emitCliBanner(programVersion);
if (actionCommand.name() === "doctor") return; if (actionCommand.name() === "doctor") return;
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
-2
View File
@@ -286,8 +286,6 @@ export async function agentCommand(
const profile = store.profiles[authProfileId]; const profile = store.profiles[authProfileId];
if (!profile || profile.provider !== provider) { if (!profile || profile.provider !== provider) {
delete entry.authProfileOverride; delete entry.authProfileOverride;
delete entry.authProfileOverrideSource;
delete entry.authProfileOverrideCompactionCount;
entry.updatedAt = Date.now(); entry.updatedAt = Date.now();
if (sessionStore && sessionKey) { if (sessionStore && sessionKey) {
sessionStore[sessionKey] = entry; sessionStore[sessionKey] = entry;
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more