Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcfde2822a | ||
|
|
bb568b2a45 |
No files matched your search
+3
-34
@@ -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.
|
||||||
|
|||||||
+1
-1
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)
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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.
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
-7
@@ -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;
|
||||||
|
|||||||
-7
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)")
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user