Compare commits

..
Author SHA1 Message Date
Peter Steinberger 8aa2c0229d docs: update changelog for rate-limit fallback 2026-01-05 22:33:55 +01:00
Peter Steinberger 485481895e style: format PR 223 changes 2026-01-05 22:33:51 +01:00
CI f01784369d fix(model): treat quota errors as rate limits 2026-01-05 19:16:38 +01:00
CI 0a853df6c9 fix(model): retry with supported thinking level 2026-01-05 18:54:23 +01:00
CI 25ada49e36 fix: retry model fallback on rate limits 2026-01-05 18:04:36 +01:00
Peter Steinberger 234059811c feat(ui): add chat reading indicator 2026-01-05 16:16:34 +00:00
Peter Steinberger 7f3f73af1c fix: show model auth in status 2026-01-05 15:50:18 +01:00
Peter Steinberger bf6aad1965 fix(ci): format directive-handling 2026-01-05 14:34:55 +00:00
Django Navarro 977467066d fix(coding-agent): close PR template code block correctly
The outer fence (4 backticks) was closing prematurely after the bash
example, leaving the rest of the template (Feature intent through
Submitted by Razor) rendered as prose instead of inside the code block.

Fixed by moving the closing fence to the end of the full template.
2026-01-05 14:33:21 +00:00
Peter Steinberger 0c37f27a4a fix: show /model auth source 2026-01-05 14:14:26 +00:00
Peter Steinberger cffbe79077 fix: add /model list alias 2026-01-05 14:11:33 +00:00
Peter Steinberger bb959684fe fix(tui): support pi-tui 0.36 key exports 2026-01-05 13:59:50 +00:00
Peter Steinberger 8e8d07cbf4 fix(ci): satisfy formatter checks 2026-01-05 13:55:53 +00:00
Peter Steinberger 5f4936dce5 fix(wizard): type OAuth provider login 2026-01-05 13:55:46 +00:00
Peter Steinberger a9bcf88bfa refactor(tui): use key helper predicates 2026-01-05 13:55:43 +00:00
Peter Steinberger f24fe4e9cd fix(whatsapp): reconnect on crypto unhandled rejection 2026-01-05 13:55:37 +00:00
Peter Steinberger 7619534bc0 feat(groups): resolve requireMention for discord/slack 2026-01-05 13:55:32 +00:00
Peter Steinberger ce68d82dfa fix: widen /model key masking 2026-01-05 13:50:45 +00:00
Peter Steinberger 5163886694 fix: show auth in /model list 2026-01-05 13:49:25 +00:00
Peter Steinberger d9103b387a fix: sync gateway mode via gateway config 2026-01-05 06:39:37 +00:00
Peter Steinberger 724354b9f0 fix: make tool list dynamic in system prompt 2026-01-05 06:36:24 +00:00
Peter Steinberger 79561d07a0 fix: allow openai-codex in onboarding types 2026-01-05 07:33:33 +01:00
Peter Steinberger 30038f7d37 fix: custom connections sidebar 2026-01-05 07:25:13 +01:00
Peter Steinberger 5431a9c692 fix: clean status + help + mid alias 2026-01-05 07:24:51 +01:00
Peter Steinberger 5aebc07369 chore: remove stale a2ui bundle hash 2026-01-05 06:17:06 +00:00
Peter Steinberger 7c51efe8f8 fix: prefer gateway config in local mode 2026-01-05 06:16:48 +00:00
Peter Steinberger 1119f2003e fix: preserve JSON5 config parsing 2026-01-05 06:16:48 +00:00
Peter Steinberger 9be1a14a08 fix: resolve agent dir in onboarding 2026-01-05 07:12:13 +01:00
Peter Steinberger 17ef7b3b0e fix: status runtime + help 2026-01-05 07:07:17 +01:00
Peter Steinberger 0d0da2e297 fix: remove sidebar toggle toolbar item safely 2026-01-05 06:49:57 +01:00
Peter Steinberger 82c16a8bed fix: remove settings sidebar toggle 2026-01-05 06:48:49 +01:00
Peter Steinberger 2c0f3a2887 docs: update auth docs 2026-01-05 06:46:20 +01:00
Peter Steinberger bc74e7cd9b docs: default mac build arch to host 2026-01-05 06:45:23 +01:00
Peter Steinberger 1545ac0003 chore: update a2ui bundle hash 2026-01-05 06:39:08 +01:00
Peter Steinberger 160fd1d8b6 docs: clarify a2ui bundle hash handling 2026-01-05 06:39:03 +01:00
Peter Steinberger 4305472787 docs: document sandbox media staging 2026-01-05 06:37:12 +01:00
Peter Steinberger 545f52d7d5 fix: hide settings toolbar row 2026-01-05 06:36:34 +01:00
Peter Steinberger a40fd5219c docs: clarify unrecognized file handling 2026-01-05 06:36:30 +01:00
Peter Steinberger 48322f7174 docs: highlight oauth and any-os support 2026-01-05 06:35:43 +01:00
Peter Steinberger f3cb41511d feat: add openai codex oauth 2026-01-05 06:31:45 +01:00
Peter Steinberger bce62f8c0f chore: update pi dependencies 2026-01-05 06:19:35 +01:00
Peter Steinberger 995f5959af fix: stage sandbox media for inbound attachments 2026-01-05 06:18:11 +01:00
Peter Steinberger a7d33c06f9 refactor: align agent lifecycle 2026-01-05 05:55:02 +01:00
Peter Steinberger ce5fd84432 docs: note settings sidebar layout 2026-01-05 05:54:37 +01:00
Peter Steinberger a89204362e fix: use sidebar settings layout 2026-01-05 05:54:21 +01:00
Peter Steinberger bcdaba1d48 chore: format custom editor 2026-01-05 05:32:30 +01:00
Peter Steinberger 95d9160e27 fix: avoid settings toolbar overflow 2026-01-05 05:32:14 +01:00
Peter Steinberger 8a31a868c0 fix: honor tailnet bind for macOS gateway endpoint 2026-01-05 05:30:40 +01:00
Peter Steinberger 870473be85 chore: update deps 2026-01-05 05:27:58 +01:00
Peter Steinberger b593ccb122 chore: update appcast for 2026.1.5-3 2026-01-05 05:14:07 +01:00
Peter Steinberger 35a32b31bd docs: note notarize env vars 2026-01-05 04:26:05 +01:00
Peter Steinberger 5dbbad0452 chore: default mac packaging to notarize 2026-01-05 04:22:58 +01:00
89 changed files with 2541 additions and 1029 deletions

No files matched your search

+3 -1
View File
@@ -45,13 +45,15 @@
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) instead of manual conflict resolution.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are in `~/.profile`.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
+19
View File
@@ -5,7 +5,25 @@
## Unreleased
### Fixes
- Model: retry fallback on rate-limit/quota errors and unsupported thinking levels. (#223) — thanks @augard
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
- TUI: migrate key handling to the updated pi-tui Key matcher API.
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json.
- Model: `/model` list shows auth source (masked key or OAuth email) per provider.
- Model: `/model list` is an alias for `/model`.
- Model: `/model` output now includes auth source location (env/auth.json/models.json).
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
- Control UI: show a reading indicator bubble while the assistant is responding.
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
- Status: show model auth source (api-key/oauth).
### Maintenance
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
## 2026.1.5-3
@@ -41,6 +59,7 @@
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan
- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files.
- macOS: parse JSON5 config safely to avoid wiping user settings when comments are present.
- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj
- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj
- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT).
+1 -1
View File
@@ -24,7 +24,7 @@ Website: https://clawd.me · Docs: https://docs.clawdbot.com/ · FAQ: [`docs/faq
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
Subscriptions: **Anthropic (Claude Pro/Max)** and **OpenAI (ChatGPT/Codex)** are supported via OAuth. See `docs/onboarding.md`.
## Highlights
+16 -17
View File
@@ -2,6 +2,22 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdis</title>
<item>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3095</sparkle:version>
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
</item>
<item>
<title>2026.1.5-3</title>
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
@@ -35,22 +51,5 @@
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
</item>
<item>
<title>2026.1.5-1</title>
<pubDate>Mon, 05 Jan 2026 03:32:13 +0100</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>3087</sparkle:version>
<sparkle:shortVersionString>2026.1.5-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.5-1</h2>
<h3>Fixes</h3>
<ul>
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/index.js</code>.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-1/Clawdbot-2026.1.5-1.zip" length="150253493" type="application/octet-stream" sparkle:edSignature="gmb6ubX9AobT8OjraFY2zrS7qch050lSWA0Wuwzr68/kcCySG+GXzxSqvFp2DHTAMH3OsvkiCqQtET8qFuv9DQ=="/>
</item>
</channel>
</rss>
+35 -30
View File
@@ -401,11 +401,9 @@ final class AppState {
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
var root = ClawdbotConfigFile.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let desiredMode: String? = switch self.connectionMode {
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let desiredMode: String? = switch connectionMode {
case .local:
"local"
case .remote:
@@ -413,37 +411,44 @@ final class AppState {
case .unconfigured:
nil
}
let remoteHost = connectionMode == .remote
? CommandResolver.parseSSHTarget(remoteTarget)?.host
: nil
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
Task { @MainActor in
var root = await ConfigStore.load()
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if self.connectionMode == .remote,
let host = CommandResolver.parseSSHTarget(self.remoteTarget)?.host
{
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
gateway["remote"] = remote
changed = true
if connectionMode == .remote, let host = remoteHost {
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
gateway["remote"] = remote
changed = true
}
}
}
guard changed else { return }
root["gateway"] = gateway
ClawdbotConfigFile.saveDict(root)
guard changed else { return }
root["gateway"] = gateway
try? await ConfigStore.save(root)
}
}
func triggerVoiceEars(ttl: TimeInterval? = 5) {
@@ -20,7 +20,7 @@ enum ClawdbotConfigFile {
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
guard let root = self.parseConfigData(data) else {
self.logger.warning("config JSON root invalid")
return [:]
}
@@ -122,4 +122,19 @@ enum ClawdbotConfigFile {
}
return nil
}
private static func parseConfigData(_ data: Data) -> [String: Any]? {
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return root
}
let decoder = JSONDecoder()
if #available(macOS 12.0, *) {
decoder.allowsJSON5 = true
}
if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) {
self.logger.notice("config parsed with JSON5 decoder")
return decoded.mapValues { $0.foundationValue }
}
return nil
}
}
+11 -4
View File
@@ -34,11 +34,14 @@ enum ConfigStore {
if let override = overrides.loadRemote {
return await override()
}
return await self.loadFromGateway()
return await self.loadFromGateway() ?? [:]
}
if let override = overrides.loadLocal {
return override()
}
if let gateway = await self.loadFromGateway() {
return gateway
}
return ClawdbotConfigFile.loadDict()
}
@@ -55,13 +58,17 @@ enum ConfigStore {
if let override = overrides.saveLocal {
override(root)
} else {
ClawdbotConfigFile.saveDict(root)
do {
try await self.saveToGateway(root)
} catch {
ClawdbotConfigFile.saveDict(root)
}
}
}
}
@MainActor
private static func loadFromGateway() async -> [String: Any] {
private static func loadFromGateway() async -> [String: Any]? {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
@@ -69,7 +76,7 @@ enum ConfigStore {
timeoutMs: 8000)
return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch {
return [:]
return nil
}
}
@@ -2,11 +2,11 @@ import SwiftUI
extension ConnectionsSettings {
var body: some View {
NavigationSplitView {
HStack(spacing: 0) {
self.sidebar
} detail: {
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
self.store.start()
self.ensureSelection()
@@ -18,27 +18,30 @@ extension ConnectionsSettings {
}
private var sidebar: some View {
List(selection: self.$selectedProvider) {
if !self.enabledProviders.isEmpty {
Section("Configured") {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if !self.enabledProviders.isEmpty {
self.sidebarSectionHeader("Configured")
ForEach(self.enabledProviders) { provider in
self.sidebarRow(provider)
.tag(provider)
}
}
}
if !self.availableProviders.isEmpty {
Section("Available") {
if !self.availableProviders.isEmpty {
self.sidebarSectionHeader("Available")
ForEach(self.availableProviders) { provider in
self.sidebarRow(provider)
.tag(provider)
}
}
}
.padding(.vertical, 10)
.padding(.horizontal, 10)
}
.listStyle(.sidebar)
.frame(minWidth: 210, idealWidth: 230, maxWidth: 260)
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
@@ -49,7 +52,7 @@ extension ConnectionsSettings {
self.emptyDetail
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var emptyDetail: some View {
@@ -79,18 +82,41 @@ extension ConnectionsSettings {
}
private func sidebarRow(_ provider: ConnectionProvider) -> some View {
HStack(spacing: 8) {
Circle()
.fill(self.providerTint(provider))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(provider.title)
Text(self.providerSummary(provider))
.font(.caption)
.foregroundStyle(.secondary)
let isSelected = self.selectedProvider == provider
return Button {
self.selectedProvider = provider
} label: {
HStack(spacing: 8) {
Circle()
.fill(self.providerTint(provider))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(provider.title)
Text(self.providerSummary(provider))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear) // ensure full-width hit test area
.contentShape(Rectangle())
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
private func sidebarSectionHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
.padding(.horizontal, 4)
.padding(.top, 2)
}
private func detailHeader(for provider: ConnectionProvider) -> some View {
@@ -13,12 +13,14 @@ enum GatewayEndpointState: Sendable, Equatable {
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
actor GatewayEndpointStore {
static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String?
let password: @Sendable () -> String?
let localPort: @Sendable () -> Int
let localHost: @Sendable () async -> String
let remotePortIfRunning: @Sendable () async -> UInt16?
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
@@ -33,6 +35,16 @@ actor GatewayEndpointStore {
env: ProcessInfo.processInfo.environment)
},
localPort: { GatewayEnvironment.gatewayPort() },
localHost: {
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
tailscaleIP: tailscaleIP)
},
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
}
@@ -89,13 +101,17 @@ actor GatewayEndpointStore {
}
let port = deps.localPort()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil)
let token = deps.token()
let password = deps.password()
switch initialMode {
case .local:
self.state = .ready(
mode: .local,
url: URL(string: "ws://127.0.0.1:\(port)")!,
url: URL(string: "ws://\(host):\(port)")!,
token: token,
password: password)
case .remote:
@@ -129,9 +145,10 @@ actor GatewayEndpointStore {
switch mode {
case .local:
let port = self.deps.localPort()
let host = await self.deps.localHost()
self.setState(.ready(
mode: .local,
url: URL(string: "ws://127.0.0.1:\(port)")!,
url: URL(string: "ws://\(host):\(port)")!,
token: token,
password: password))
case .remote:
@@ -218,6 +235,39 @@ actor GatewayEndpointStore {
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
}
}
private static func resolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
{
if let envBind = env["CLAWDBOT_GATEWAY_BIND"] {
let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
if let gateway = root["gateway"] as? [String: Any],
let bind = gateway["bind"] as? String
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
return nil
}
private static func resolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
{
switch bindMode {
case "tailnet", "auto":
tailscaleIP ?? "127.0.0.1"
default:
"127.0.0.1"
}
}
}
#if DEBUG
@@ -229,5 +279,19 @@ extension GatewayEndpointStore {
{
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
}
static func _testResolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
{
self.resolveGatewayBindMode(root: root, env: env)
}
static func _testResolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
{
self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
}
}
#endif
@@ -194,6 +194,22 @@ enum SettingsTab: CaseIterable {
case .about: "About"
}
}
var systemImage: String {
switch self {
case .general: "gearshape"
case .connections: "link"
case .skills: "sparkles"
case .sessions: "clock.arrow.circlepath"
case .cron: "calendar"
case .config: "slider.horizontal.3"
case .instances: "network"
case .voiceWake: "waveform.circle"
case .permissions: "lock.shield"
case .debug: "ant"
case .about: "info.circle"
}
}
}
@MainActor
@@ -424,21 +424,17 @@ public struct AgentParams: Codable, Sendable {
public struct AgentWaitParams: Codable, Sendable {
public let runid: String
public let afterms: Int?
public let timeoutms: Int?
public init(
runid: String,
afterms: Int?,
timeoutms: Int?
) {
self.runid = runid
self.afterms = afterms
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case runid = "runId"
case afterms = "afterMs"
case timeoutms = "timeoutMs"
}
}
@@ -1,156 +1,36 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite struct GatewayEndpointStoreTests {
private final class ModeBox: @unchecked Sendable {
private let lock = NSLock()
private var value: AppState.ConnectionMode
init(_ initial: AppState.ConnectionMode) {
self.value = initial
}
func get() -> AppState.ConnectionMode {
self.lock.lock()
defer { self.lock.unlock() }
return self.value
}
func set(_ next: AppState.ConnectionMode) {
self.lock.lock()
defer { self.lock.unlock() }
self.value = next
}
@Suite(.serialized)
struct GatewayEndpointStoreTests {
@Test func resolvesLocalHostFromBindModes() {
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "loopback",
tailscaleIP: "100.64.0.10") == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "lan",
tailscaleIP: "100.64.0.10") == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.0.10") == "100.64.0.10")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil) == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.0.10") == "100.64.0.10")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil) == "127.0.0.1")
}
@Test func localRefreshResolvesToLocalhostPort() async throws {
let mode = ModeBox(.local)
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { "t" },
password: { nil },
localPort: { 1234 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))
await store.refresh()
let cfg = try await store.requireConfig()
#expect(cfg.url.absoluteString == "ws://127.0.0.1:1234")
#expect(cfg.token == "t")
}
@Test func remoteWithoutTunnelRecoversByEnsuringTunnel() async throws {
let mode = ModeBox(.remote)
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { nil },
password: { nil },
localPort: { 18789 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))
let cfg = try await store.requireConfig()
#expect(cfg.url.absoluteString == "ws://127.0.0.1:18789")
#expect(cfg.token == nil)
}
@Test func ensureRemoteTunnelPublishesReadyState() async throws {
let mode = ModeBox(.remote)
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { "tok" },
password: { "pw" },
localPort: { 1 },
remotePortIfRunning: { 5555 },
ensureRemoteTunnel: { 5555 }))
let stream = await store.subscribe(bufferingNewest: 10)
var iterator = stream.makeAsyncIterator()
_ = await iterator.next() // initial
_ = try await store.ensureRemoteControlTunnel()
let next = await iterator.next()
guard case let .ready(mode, url, token, password) = next else {
Issue.record("expected .ready after ensure, got \(String(describing: next))")
return
}
#expect(mode == .remote)
#expect(url.absoluteString == "ws://127.0.0.1:5555")
#expect(token == "tok")
#expect(password == "pw")
}
@Test func resolvesGatewayPasswordByMode() {
let root: [String: Any] = [
"gateway": [
"auth": ["password": " local "],
"remote": ["password": " remote "],
],
]
let env: [String: String] = [:]
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
@Test func resolvesBindModeFromEnvOrConfig() {
let root: [String: Any] = ["gateway": ["bind": "tailnet"]]
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
root: root,
env: env) == "local")
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: true,
env: [:]) == "tailnet")
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
root: root,
env: env) == "remote")
}
@Test func gatewayPasswordEnvOverridesConfig() {
let root: [String: Any] = [
"gateway": [
"auth": ["password": "local"],
"remote": ["password": "remote"],
],
]
let env = ["CLAWDBOT_GATEWAY_PASSWORD": " env "]
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: root,
env: env) == "env")
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: true,
root: root,
env: env) == "env")
}
@Test func gatewayPasswordIgnoresWhitespaceValues() {
let root: [String: Any] = [
"gateway": [
"auth": ["password": " "],
"remote": ["password": "\n\t"],
],
]
let env = ["CLAWDBOT_GATEWAY_PASSWORD": " "]
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: root,
env: env) == nil)
#expect(GatewayEndpointStore._testResolveGatewayPassword(
isRemote: true,
root: root,
env: env) == nil)
}
@Test func unconfiguredModeRejectsConfig() async {
let mode = ModeBox(.unconfigured)
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { nil },
password: { nil },
localPort: { 18789 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))
await #expect(throws: Error.self) {
_ = try await store.requireConfig()
}
env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan")
}
}
+61
View File
@@ -0,0 +1,61 @@
---
summary: "Agent loop lifecycle, streams, and wait semantics"
read_when:
- You need an exact walkthrough of the agent loop or lifecycle events
---
# Agent Loop (Clawdis)
Short, exact flow of one agent run. Source of truth: current code in `src/`.
## Entry points
- Gateway RPC: `agent` and `agent.wait` in `src/gateway/server-methods/agent.ts`.
- CLI: `agentCommand` in `src/commands/agent.ts`.
## High-level flow
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
2) `agentCommand` runs the agent:
- resolves model + thinking/verbose defaults
- loads skills snapshot
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
- emits **lifecycle end/error** if the embedded loop does not emit one
3) `runEmbeddedPiAgent`:
- builds `AgentSession` and subscribes to pi events
- streams assistant deltas + tool events
- enforces timeout -> aborts run if exceeded
- returns payloads + usage metadata
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdis `agent` stream:
- tool events => `stream: "tool"`
- assistant deltas => `stream: "assistant"`
- lifecycle events => `stream: "lifecycle"` (`phase: "start" | "end" | "error"`)
5) `agent.wait` uses `waitForAgentJob`:
- waits for **lifecycle end/error** for `runId`
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
## Event streams (today)
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
- `assistant`: streamed deltas from pi-agent-core
- `tool`: streamed tool events from pi-agent-core
## Chat surface handling
- `createAgentEventHandler` in `src/gateway/server-chat.ts`:
- buffers assistant deltas
- emits chat `delta` messages
- emits chat `final` when **lifecycle end/error** arrives
## Timeouts
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
- Agent runtime: `agent.timeoutSeconds` default 600s; enforced in `runEmbeddedPiAgent` abort timer.
## Where things can end early
- Agent timeout (abort)
- AbortSignal (cancel)
- Gateway disconnect or RPC timeout
- `agent.wait` timeout (wait-only, does not stop agent)
## Files
- `src/gateway/server-methods/agent.ts`
- `src/gateway/server-methods/agent-job.ts`
- `src/commands/agent.ts`
- `src/agents/pi-embedded-runner.ts`
- `src/agents/pi-embedded-subscribe.ts`
- `src/gateway/server-chat.ts`
+15
View File
@@ -89,6 +89,21 @@ Env var equivalent:
- `CLAWDBOT_LOAD_SHELL_ENV=1`
- `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`
### Auth storage (OAuth + API keys)
Clawdbot keeps subscription OAuth tokens + API keys in the **agent auth store**:
- `~/.clawdbot/agent/auth.json`
The agent directory can be overridden with:
- `CLAWDBOT_AGENT_DIR` (preferred)
- `PI_CODING_AGENT_DIR` (legacy)
Legacy OAuth storage is still supported for migration:
- Default: `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)
- Override: `CLAWDBOT_OAUTH_DIR`
On first use, Clawdbot auto‑migrates legacy `oauth.json` entries into `auth.json`.
### `identity`
Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
+1
View File
@@ -73,6 +73,7 @@ container. The gateway stays on your host, but the tool execution is isolated:
- one container per session (hard wall)
- per-session workspace folder mounted at `/workspace`
- allow/deny tool policy (deny wins)
- inbound media is copied into the sandbox workspace (`media/inbound/*`) so tools can read it
### Default behavior
+9 -5
View File
@@ -14,7 +14,9 @@ Everything lives under `~/.clawdbot/`:
| Path | Purpose |
|------|---------|
| `~/.clawdbot/clawdbot.json` | Main config (JSON5) |
| `~/.clawdbot/agent/auth.json` | OAuth + API key store (Anthropic/OpenAI, etc.) |
| `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens |
| `~/.clawdbot/credentials/oauth.json` | Legacy OAuth store (auto‑migrated) |
| `~/.clawdbot/sessions/` | Conversation history & state |
| `~/.clawdbot/sessions/sessions.json` | Session metadata |
@@ -105,18 +107,18 @@ The macOS app onboarding is still being polished and can have quirks (e.g., What
### OAuth vs API key — what's the difference?
- **OAuth** — Uses your Claude Pro/Max subscription ($20-100/mo flat). No per-token charges. ✅ Recommended!
- **API key** — Pay-per-token via console.anthropic.com. Can get expensive fast.
- **OAuth** — Uses your **subscription** (Anthropic Claude Pro/Max or OpenAI ChatGPT/Codex). No per‑token charges. ✅ Recommended!
- **API key** — Pay‑per‑token via the provider’s API billing. Can get expensive fast.
They're **separate billing**! An API key does NOT use your subscription.
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back. Or just run:
**For OAuth:** During onboarding, pick **Anthropic OAuth** or **OpenAI Codex OAuth**, log in, paste the code/URL when prompted. Or just run:
```bash
pnpm clawdbot login
```
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/` to your server. The auth is just a JSON file.
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/agent/auth.json` to your server. The auth is just a JSON file.
### How are env vars loaded?
@@ -146,7 +148,7 @@ Or set `CLAWDBOT_LOAD_SHELL_ENV=1` (timeout: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=1500
OAuth needs the callback to reach the machine running the CLI. Options:
1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/credentials/` to the container.
1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/agent/auth.json` to the container.
2. **SSH tunnel** — `ssh -L 18789:localhost:18789 user@server`
3. **Tailscale** — Put both machines on your tailnet.
@@ -554,6 +556,8 @@ Use `/model` to switch without restarting:
/model gemini-flash
```
List available models with `/model`, `/model list`, or `/model status`.
Clawdbot ships a few default model shorthands (you can override them in config):
`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`.
+1
View File
@@ -37,6 +37,7 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media
- When inbound web messages include media, CLAWDBOT downloads to a temp file and exposes templating variables:
- `{{MediaUrl}}` pseudo-URL for the inbound media.
- `{{MediaPath}}` local temp path written before running the command.
- When a per-session Docker sandbox is enabled, inbound media is copied into the sandbox workspace and `MediaPath`/`MediaUrl` are rewritten to a relative path like `media/inbound/<filename>`.
- Audio transcription (if configured) runs before templating and can replace `Body` with the transcript.
## Limits & Errors
+2 -1
View File
@@ -12,7 +12,7 @@ read_when:
</p>
<p align="center">
<strong>WhatsApp + Telegram + Discord + iMessage gateway for AI agents (Pi).</strong><br />
<strong>Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).</strong><br />
Send a message, get an agent response — from your pocket.
</p>
@@ -63,6 +63,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
- 🎮 **Discord Bot** — DMs + guild channels via discord.js
- 💬 **iMessage** — Local imsg CLI integration (macOS)
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
- 🔐 **Subscription auth** — Anthropic (Claude Pro/Max) + OpenAI (ChatGPT/Codex) via OAuth
- 💬 **Sessions** — Direct chats collapse into shared `main` (default); groups are isolated
- 👥 **Group Chat Support** — Mention-based by default; owner can toggle `/activation always|mention`
- 📎 **Media Support** — Send and receive images, audio, documents
+1 -1
View File
@@ -23,7 +23,7 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
## Build & package
Notes:
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
- Universal by default (`arm64 x86_64`). Override with `BUILD_ARCHS="arm64"` if you need arm-only.
- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`).
```bash
# From repo root; set release IDs so Sparkle feed is enabled.
+28 -23
View File
@@ -1,17 +1,17 @@
---
summary: "Planned first-run onboarding flow for Clawdbot (local vs remote, Anthropic OAuth, workspace bootstrap ritual)"
summary: "Planned first-run onboarding flow for Clawdbot (local vs remote, OAuth auth, workspace bootstrap ritual)"
read_when:
- Designing the macOS onboarding assistant
- Implementing Anthropic auth or identity setup
- Implementing Anthropic/OpenAI auth or identity setup
---
# Onboarding (macOS app)
This doc describes the intended **first-run onboarding** for Clawdbot. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for the embedded agent runtime, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
This doc describes the intended **first-run onboarding** for Clawdbot. The goal is a good “day 0” experience: pick where the Gateway runs, bind subscription auth (Anthropic or OpenAI) for the embedded agent runtime, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
## Page order (high level)
1) **Local vs Remote**
2) **(Local only)** Connect Claude (Anthropic OAuth) — optional, but recommended
2) **(Local only)** Connect subscription auth (Anthropic / OpenAI OAuth) — optional, but recommended
3) **Connect Gmail (optional)** — run `clawdbot hooks gmail setup` to configure Pub/Sub hooks
4) **Onboarding chat** — dedicated session where the agent introduces itself and guides setup
@@ -19,7 +19,7 @@ This doc describes the intended **first-run onboarding** for Clawdbot. The goal
First question: where does the **Gateway** run?
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdbot token store locally.
- **Local (this Mac):** onboarding can run OAuth flows and write the Clawdbot auth store locally.
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
Gateway auth tip:
@@ -28,20 +28,28 @@ Gateway auth tip:
Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user).
## 2) Local-only: Connect Claude (Anthropic OAuth)
## 2) Local-only: Connect subscription auth (Anthropic / OpenAI OAuth)
This is the “bind Clawdbot to Anthropic” step. It is explicitly the **Anthropic (Claude Pro/Max) OAuth flow**, not a generic “login”.
This is the “bind Clawdbot to subscription auth” step. It is explicitly the **Anthropic (Claude Pro/Max)** or **OpenAI (ChatGPT/Codex)** OAuth flow, not a generic “login”.
### Recommended: OAuth
### Recommended: OAuth (Anthropic)
The macOS app should:
- Start the Anthropic OAuth (PKCE) flow in the user’s browser.
- Ask the user to paste the `code#state` value.
- Exchange it for tokens and write credentials to:
- `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
- `~/.clawdbot/agent/auth.json` (file mode `0600`, directory mode `0700`)
Why this location matters: it’s the Clawdbot-owned OAuth store.
On first run, Clawdbot can import existing OAuth tokens from legacy p/Claude locations if present.
Why this location matters: it’s the Clawdbot-owned auth store (OAuth + API keys).
Clawdbot auto-migrates legacy OAuth tokens from `~/.clawdbot/credentials/oauth.json` (and older pi/Claude locations) into `auth.json` on first use.
### Recommended: OAuth (OpenAI Codex)
The macOS app should:
- Start the OpenAI Codex OAuth (PKCE) flow in the user’s browser.
- Auto-capture the callback on `http://127.0.0.1:1455/auth/callback` when possible.
- If the callback fails, prompt the user to paste the redirect URL or code.
- Store credentials in `~/.clawdbot/agent/auth.json` (same auth store as Anthropic).
### Alternative: API key (instructions only)
@@ -136,30 +144,27 @@ Daily memory lives under `memory/` in the workspace:
## Remote mode note (why OAuth is hidden)
If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where the agent runtime runs).
If the Gateway runs on another machine, OAuth credentials must be created/stored on that host (where the agent runtime runs).
For now, remote onboarding should:
- explain why OAuth isn't shown
- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the workspace location on the gateway host
- point the user at the credential location (`~/.clawdbot/agent/auth.json`) and the workspace location on the gateway host
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
### Manual credential setup
On the gateway host, create `~/.clawdbot/credentials/oauth.json` with this exact format:
On the gateway host, create `~/.clawdbot/agent/auth.json` with this exact format:
```json
{
"anthropic": {
"access": "sk-ant-oat01-...",
"refresh": "sk-ant-ort01-...",
"expires": 1767304352803
}
"anthropic": { "type": "oauth", "access": "sk-ant-oat01-...", "refresh": "sk-ant-ort01-...", "expires": 1767304352803 },
"openai-codex": { "type": "oauth", "access": "eyJhbGciOi...", "refresh": "oai-refresh-...", "expires": 1767304352803, "accountId": "acct_..." }
}
```
Set permissions: `chmod 600 ~/.clawdbot/credentials/oauth.json`
Set permissions: `chmod 600 ~/.clawdbot/agent/auth.json`
**Note:** Clawdbot can auto-import from legacy pi-coding-agent paths (`~/.pi/agent/oauth.json`, etc.) but this does NOT work with Claude Code credentials — different file and format.
**Note:** Clawdbot auto-imports from legacy pi-coding-agent paths (`~/.pi/agent/oauth.json`, etc.) but this does NOT work with Claude Code credentials — different file and format.
### Using Claude Code credentials
@@ -172,8 +177,8 @@ cat ~/.claude/.credentials.json | jq '{
refresh: .claudeAiOauth.refreshToken,
expires: .claudeAiOauth.expiresAt
}
}' > ~/.clawdbot/credentials/oauth.json
chmod 600 ~/.clawdbot/credentials/oauth.json
}' > ~/.clawdbot/agent/auth.json
chmod 600 ~/.clawdbot/agent/auth.json
```
| Claude Code field | Clawdbot field |
+65
View File
@@ -0,0 +1,65 @@
---
summary: "Refactor plan: unify agent lifecycle events and wait semantics"
read_when:
- Refactoring agent lifecycle events or wait behavior
---
# Refactor: Agent Loop
Goal: align Clawdis run lifecycle with pi/mom semantics, remove ambiguity between "job" and "agent_end".
## Problem
- Two lifecycles today:
- `job` (gateway wrapper) => used by `agent.wait` + chat final
- pi-agent `agent_end` (inner loop) => only logged
- This can finalize early (job done) while late assistant deltas still arrive.
- `afterMs` and timeouts can cause false timeouts in `agent.wait`.
## Reference (mom)
- Single lifecycle: `agent_start`/`agent_end` from pi-agent-core event stream.
- `waitForIdle()` resolves on `agent_end`.
- No separate job state exposed to clients.
## Proposed refactor (breaking allowed)
1) Replace public `job` stream with `lifecycle` stream
- `stream: "lifecycle"`
- `data: { phase: "start" | "end" | "error", startedAt, endedAt, error? }`
2) `agent.wait` waits on lifecycle end/error only
- remove `afterMs`
- return `{ runId, status, startedAt, endedAt, error? }`
3) Chat final emitted on lifecycle end only
- deltas still from `assistant` stream
4) Centralize run registry
- one map keyed by runId: sessionKey, startedAt, lastSeq, bufferedText
- clear on lifecycle end
## Implementation outline
- `src/agents/pi-embedded-subscribe.ts`
- emit lifecycle start/end events (translate pi `agent_start`/`agent_end`)
- `src/infra/agent-events.ts`
- add `"lifecycle"` to stream type
- `src/gateway/protocol/schema.ts`
- update AgentEvent schema; update AgentWait params (remove afterMs, add status)
- `src/gateway/server-methods/agent-job.ts`
- rename to `agent-wait.ts` or similar; wait on lifecycle end/error
- `src/gateway/server-chat.ts`
- finalize on lifecycle end (not job)
- `src/commands/agent.ts`
- stop emitting `job` externally (keep internal log if needed)
## Migration notes (breaking)
- Update all callers of `agent.wait` to new response shape.
- Update tests that expect `timeout` based on job events.
- If any UI relies on job state, map lifecycle instead.
## Risks
- If lifecycle events are dropped, wait/chat could hang; add timeout in `agent.wait` to fail fast.
- Late deltas after lifecycle end should be ignored; keep seq tracking + drop.
## Acceptance
- One lifecycle visible to clients.
- `agent.wait` resolves when agent loop ends, not wrapper completion.
- Chat final never emits before last assistant delta.
## Rollout (if we wanted safety)
- Gate with config flag `agent.lifecycleMode = "legacy"|"refactor"`.
- Remove legacy after one release.
+3 -1
View File
@@ -26,7 +26,7 @@ clawdbot configure
## What the wizard does
**Local mode (default)** walks you through:
- Model/auth (Anthropic OAuth recommended, API key optional, Minimax M2.1 via LM Studio)
- Model/auth (Anthropic or OpenAI Codex OAuth recommended, API key optional, Minimax M2.1 via LM Studio)
- Workspace location + bootstrap files
- Gateway settings (port/bind/auth/tailscale)
- Providers (WhatsApp, Telegram, Discord, Signal)
@@ -48,9 +48,11 @@ It does **not** install or change anything on the remote host.
2) **Model/Auth**
- **Anthropic OAuth (recommended)**: browser flow; paste the `code#state`.
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
- **API key**: stores the key for you.
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
- **Skip**: no auth configured yet.
- OAuth + API keys are stored in `~/.clawdbot/agent/auth.json`.
3) **Workspace**
- Default `~/clawd` (configurable).
+10 -10
View File
@@ -83,13 +83,13 @@
"@clack/prompts": "^0.11.0",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.32.3",
"@mariozechner/pi-ai": "^0.32.3",
"@mariozechner/pi-coding-agent": "^0.32.3",
"@mariozechner/pi-tui": "^0.32.3",
"@slack/bolt": "^4.5.0",
"@slack/web-api": "^7.11.1",
"@mariozechner/pi-agent-core": "^0.36.0",
"@mariozechner/pi-ai": "^0.36.0",
"@mariozechner/pi-coding-agent": "^0.36.0",
"@mariozechner/pi-tui": "^0.36.0",
"@sinclair/typebox": "0.34.46",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.13.0",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ajv": "^8.17.1",
"body-parser": "^2.2.1",
@@ -99,11 +99,11 @@
"commander": "^14.0.2",
"croner": "^9.1.0",
"detect-libc": "^2.1.2",
"discord-api-types": "^0.38.33",
"discord-api-types": "^0.38.37",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"file-type": "^21.2.0",
"file-type": "^21.3.0",
"grammy": "^1.39.2",
"json5": "^2.2.3",
"long": "5.3.2",
@@ -113,10 +113,10 @@
"tslog": "^4.10.2",
"undici": "^7.16.0",
"ws": "^8.18.3",
"zod": "^4.3.4"
"zod": "^4.3.5"
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@biomejs/biome": "^2.3.11",
"@lit-labs/signals": "^0.2.0",
"@lit/context": "^1.1.6",
"@mariozechner/mini-lit": "0.2.1",
+182 -95
View File
@@ -26,25 +26,25 @@ importers:
specifier: ^1.3.4
version: 1.3.4
'@mariozechner/pi-agent-core':
specifier: ^0.32.3
version: 0.32.3(ws@8.18.3)(zod@4.3.4)
specifier: ^0.36.0
version: 0.36.0(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: ^0.32.3
version: 0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)
specifier: ^0.36.0
version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
specifier: ^0.32.3
version: 0.32.3(ws@8.18.3)(zod@4.3.4)
specifier: ^0.36.0
version: 0.36.0(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-tui':
specifier: ^0.32.3
version: 0.32.3
specifier: ^0.36.0
version: 0.36.0
'@sinclair/typebox':
specifier: 0.34.46
version: 0.34.46
'@slack/bolt':
specifier: ^4.5.0
specifier: ^4.6.0
version: 4.6.0(@types/express@5.0.6)
'@slack/web-api':
specifier: ^7.11.1
specifier: ^7.13.0
version: 7.13.0
'@whiskeysockets/baileys':
specifier: 7.0.0-rc.9
@@ -74,7 +74,7 @@ importers:
specifier: ^2.1.2
version: 2.1.2
discord-api-types:
specifier: ^0.38.33
specifier: ^0.38.37
version: 0.38.37
discord.js:
specifier: ^14.25.1
@@ -86,8 +86,8 @@ importers:
specifier: ^5.2.1
version: 5.2.1
file-type:
specifier: ^21.2.0
version: 21.2.0
specifier: ^21.3.0
version: 21.3.0
grammy:
specifier: ^1.39.2
version: 1.39.2
@@ -116,12 +116,12 @@ importers:
specifier: ^8.18.3
version: 8.18.3
zod:
specifier: ^4.3.4
version: 4.3.4
specifier: ^4.3.5
version: 4.3.5
devDependencies:
'@biomejs/biome':
specifier: ^2.3.10
version: 2.3.10
specifier: ^2.3.11
version: 2.3.11
'@lit-labs/signals':
specifier: ^0.2.0
version: 0.2.0
@@ -266,55 +266,55 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@biomejs/biome@2.3.10':
resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==}
'@biomejs/biome@2.3.11':
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.3.10':
resolution: {integrity: sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==}
'@biomejs/cli-darwin-arm64@2.3.11':
resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.3.10':
resolution: {integrity: sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==}
'@biomejs/cli-darwin-x64@2.3.11':
resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.3.10':
resolution: {integrity: sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==}
'@biomejs/cli-linux-arm64-musl@2.3.11':
resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.3.10':
resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==}
'@biomejs/cli-linux-arm64@2.3.11':
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.3.10':
resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==}
'@biomejs/cli-linux-x64-musl@2.3.11':
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.3.10':
resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==}
'@biomejs/cli-linux-x64@2.3.11':
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.3.10':
resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==}
'@biomejs/cli-win32-arm64@2.3.11':
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.3.10':
resolution: {integrity: sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==}
'@biomejs/cli-win32-x64@2.3.11':
resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -338,6 +338,57 @@ packages:
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@crosscopy/clipboard-darwin-arm64@0.2.8':
resolution: {integrity: sha512-Y36ST9k5JZgtDE6SBT45bDNkPKBHd4UEIZgWnC0iC4kAWwdjPmsZ8Mn8e5W0YUKowJ/BDcO+EGm2tVTPQOQKXg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@crosscopy/clipboard-darwin-universal@0.2.8':
resolution: {integrity: sha512-btGV1tLpJWZ4iKa66niahvpZpVRJzgQnYUE+PUX3YYZzaWD0ESuHuVtKVC8sR+b4dsXIiWW5skXbcRmLsF4rtA==}
engines: {node: '>= 10'}
os: [darwin]
'@crosscopy/clipboard-darwin-x64@0.2.8':
resolution: {integrity: sha512-0QMKf0XrLZrprYYXU4lgaTuzbnYPh9wH6PvsfDB1FZvWf6rOi0syTaBZYnoghbQe700qwLPEfBRjgljJ3Tn6oA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@crosscopy/clipboard-linux-arm64-gnu@0.2.8':
resolution: {integrity: sha512-8YrU03MRsygymqEcHkNgqCqSCQbYRmJCnMXeS4i8FYeOkAxBEeRvPbHoNmI10uppXJZNZgfIKM7Qqk9tEHiwqQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@crosscopy/clipboard-linux-riscv64-gnu@0.2.8':
resolution: {integrity: sha512-/QWLhnb0QYVjEv5GOAC1q+1DaezYU8Th+IoDKUCsR5i43Cqm+g+N/I2K35yo3J+HHkK9XNbtIDZDXlFgK6tRUg==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@crosscopy/clipboard-linux-x64-gnu@0.2.8':
resolution: {integrity: sha512-j17eaF/onP/6VAGGKtxA1KmmkErmdjta9gMdMV/yUmgeBYzJ9fMpWUzbk2vmaOyXfhaSzR/sk1P6VLBmvCpqHg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@crosscopy/clipboard-win32-arm64-msvc@0.2.8':
resolution: {integrity: sha512-MVkMyuYN3y5v0s4HrijM0iA8hZVmpUhHd8X4zKG30t4nE6MbOjOt/8EabMrVmGZlsLeOL2sa0o8Wo9bvhWU+vA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@crosscopy/clipboard-win32-x64-msvc@0.2.8':
resolution: {integrity: sha512-/GpiB4B3lSgg7eCLDQw9NfFjtQFjo0S88IL+EK54Hx7ZgAP4Ad/ezP/8dw0cA+N/M6iPYy0reCIjW9st82/uxw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@crosscopy/clipboard@0.2.8':
resolution: {integrity: sha512-0qRWscafAHzQ+DdfXX+YgPN2KDTIzWBNfN5Q6z1CgCWsRxtkwK8HfQUc00xIejfRWSGWPIxcCTg82hvg06bodg==}
engines: {node: '>= 10'}
'@discordjs/builders@1.13.1':
resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==}
engines: {node: '>=16.11.0'}
@@ -366,11 +417,11 @@ packages:
resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==}
engines: {node: '>=16.11.0'}
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
'@emnapi/runtime@1.7.1':
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
'@emnapi/runtime@1.8.1':
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@@ -750,22 +801,22 @@ packages:
peerDependencies:
lit: ^3.3.1
'@mariozechner/pi-agent-core@0.32.3':
resolution: {integrity: sha512-NN/fd8eBISnRbQYblLjiaRbcFjz+SEFOGhZDZCXYgHJikTV8VyEon0nZFh7agBZJ7sXZaumMTjw/pGFrBFKcmA==}
'@mariozechner/pi-agent-core@0.36.0':
resolution: {integrity: sha512-86BI1/j/MLxQHSWRXVLz8+NuSmDvLQebNb40+lFDI9XI9YBh8+r5fkYgU43u4j2TvANZ7iW6SFFnhWhzy8y6dg==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.32.3':
resolution: {integrity: sha512-njaa4/pN7U1TbjI1PKqII3M/W1CQ/obPAu5A4KHsX74e8sKjDEiEELUWzLTa5xOj+l1OXp/FtzI0hfJDq682rQ==}
'@mariozechner/pi-ai@0.36.0':
resolution: {integrity: sha512-xkzTgvdMzAZ/L/TgMH8z9Zi+aH0EWc54l5ygiafwvCgDk7xvfbylQG6pa9yn5zEn9T4NF9byJNk+nMHnycZvMQ==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.32.3':
resolution: {integrity: sha512-kOuzflbWc8GWHGOu/7KJQ1P3IodDk4AdOvibkJDcAn2aw5kG+LZAjLGt72tIMQRQna51i9XzL4nSvbxqV+mgWw==}
'@mariozechner/pi-coding-agent@0.36.0':
resolution: {integrity: sha512-lKdpuGE0yVs/96GnDhrPLEEFhRteHRtnkfX04KIBpcsEXXg2vyAlpxtjtZ9nlhYqLLIY7qJRkeyjbhcFFfbAAA==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-tui@0.32.3':
resolution: {integrity: sha512-aq7e6qXcwSHvaTUKj5Ut9BY+ZezQ2rL+V4Sd2R5SnBW6JMCt5GnR/8uYBC2PcdxBQzGL9TnzjrC4av5jR+ilow==}
'@mariozechner/pi-tui@0.36.0':
resolution: {integrity: sha512-4n+nmTd36q0AVCbqWmjtTHTjIEwlGayKKhc+4QbpN9U3Z9jyQQa8Za1P2OHRmi6Jeu+ISuf4VBDvgmgCaxPZYg==}
engines: {node: '>=20.0.0'}
'@mistralai/mistralai@1.10.0':
@@ -1756,8 +1807,8 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
file-type@21.2.0:
resolution: {integrity: sha512-vCYBgFOrJQLoTzDyAXAL/RFfKnXXpUYt4+tipVy26nJJhT7ftgGETf2tAQF59EEL61i3MrorV/PG6tf7LJK7eg==}
file-type@21.3.0:
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
engines: {node: '>=20'}
fill-range@7.1.1:
@@ -3059,16 +3110,16 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.4:
resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==}
zod@4.3.5:
resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
snapshots:
'@anthropic-ai/sdk@0.71.2(zod@4.3.4)':
'@anthropic-ai/sdk@0.71.2(zod@4.3.5)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.3.4
zod: 4.3.5
'@babel/code-frame@7.27.1':
dependencies:
@@ -3094,39 +3145,39 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.3.10':
'@biomejs/biome@2.3.11':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.10
'@biomejs/cli-darwin-x64': 2.3.10
'@biomejs/cli-linux-arm64': 2.3.10
'@biomejs/cli-linux-arm64-musl': 2.3.10
'@biomejs/cli-linux-x64': 2.3.10
'@biomejs/cli-linux-x64-musl': 2.3.10
'@biomejs/cli-win32-arm64': 2.3.10
'@biomejs/cli-win32-x64': 2.3.10
'@biomejs/cli-darwin-arm64': 2.3.11
'@biomejs/cli-darwin-x64': 2.3.11
'@biomejs/cli-linux-arm64': 2.3.11
'@biomejs/cli-linux-arm64-musl': 2.3.11
'@biomejs/cli-linux-x64': 2.3.11
'@biomejs/cli-linux-x64-musl': 2.3.11
'@biomejs/cli-win32-arm64': 2.3.11
'@biomejs/cli-win32-x64': 2.3.11
'@biomejs/cli-darwin-arm64@2.3.10':
'@biomejs/cli-darwin-arm64@2.3.11':
optional: true
'@biomejs/cli-darwin-x64@2.3.10':
'@biomejs/cli-darwin-x64@2.3.11':
optional: true
'@biomejs/cli-linux-arm64-musl@2.3.10':
'@biomejs/cli-linux-arm64-musl@2.3.11':
optional: true
'@biomejs/cli-linux-arm64@2.3.10':
'@biomejs/cli-linux-arm64@2.3.11':
optional: true
'@biomejs/cli-linux-x64-musl@2.3.10':
'@biomejs/cli-linux-x64-musl@2.3.11':
optional: true
'@biomejs/cli-linux-x64@2.3.10':
'@biomejs/cli-linux-x64@2.3.11':
optional: true
'@biomejs/cli-win32-arm64@2.3.10':
'@biomejs/cli-win32-arm64@2.3.11':
optional: true
'@biomejs/cli-win32-x64@2.3.10':
'@biomejs/cli-win32-x64@2.3.11':
optional: true
'@borewit/text-codec@0.2.1': {}
@@ -3160,6 +3211,41 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
'@crosscopy/clipboard-darwin-arm64@0.2.8':
optional: true
'@crosscopy/clipboard-darwin-universal@0.2.8':
optional: true
'@crosscopy/clipboard-darwin-x64@0.2.8':
optional: true
'@crosscopy/clipboard-linux-arm64-gnu@0.2.8':
optional: true
'@crosscopy/clipboard-linux-riscv64-gnu@0.2.8':
optional: true
'@crosscopy/clipboard-linux-x64-gnu@0.2.8':
optional: true
'@crosscopy/clipboard-win32-arm64-msvc@0.2.8':
optional: true
'@crosscopy/clipboard-win32-x64-msvc@0.2.8':
optional: true
'@crosscopy/clipboard@0.2.8':
optionalDependencies:
'@crosscopy/clipboard-darwin-arm64': 0.2.8
'@crosscopy/clipboard-darwin-universal': 0.2.8
'@crosscopy/clipboard-darwin-x64': 0.2.8
'@crosscopy/clipboard-linux-arm64-gnu': 0.2.8
'@crosscopy/clipboard-linux-riscv64-gnu': 0.2.8
'@crosscopy/clipboard-linux-x64-gnu': 0.2.8
'@crosscopy/clipboard-win32-arm64-msvc': 0.2.8
'@crosscopy/clipboard-win32-x64-msvc': 0.2.8
'@discordjs/builders@1.13.1':
dependencies:
'@discordjs/formatters': 0.6.2
@@ -3209,13 +3295,13 @@ snapshots:
- bufferutil
- utf-8-validate
'@emnapi/core@1.7.1':
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.7.1':
'@emnapi/runtime@1.8.1':
dependencies:
tslib: 2.8.1
optional: true
@@ -3423,7 +3509,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.7.1
'@emnapi/runtime': 1.8.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -3499,10 +3585,10 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@mariozechner/pi-agent-core@0.32.3(ws@8.18.3)(zod@4.3.4)':
'@mariozechner/pi-agent-core@0.36.0(ws@8.18.3)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-tui': 0.32.3
'@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-tui': 0.36.0
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3511,18 +3597,18 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)':
'@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.4)
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@google/genai': 1.34.0
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.46
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
chalk: 5.6.2
openai: 6.10.0(ws@8.18.3)(zod@4.3.4)
openai: 6.10.0(ws@8.18.3)(zod@4.3.5)
partial-json: 0.1.7
zod-to-json-schema: 3.25.1(zod@4.3.4)
zod-to-json-schema: 3.25.1(zod@4.3.5)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3531,15 +3617,16 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.32.3(ws@8.18.3)(zod@4.3.4)':
'@mariozechner/pi-coding-agent@0.36.0(ws@8.18.3)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-agent-core': 0.32.3(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-ai': 0.32.3(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.4)
'@mariozechner/pi-tui': 0.32.3
'@crosscopy/clipboard': 0.2.8
'@mariozechner/pi-agent-core': 0.36.0(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)
'@mariozechner/pi-tui': 0.36.0
chalk: 5.6.2
cli-highlight: 2.1.11
diff: 8.0.2
file-type: 21.2.0
file-type: 21.3.0
glob: 11.1.0
jiti: 2.6.1
marked: 15.0.12
@@ -3552,7 +3639,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-tui@0.32.3':
'@mariozechner/pi-tui@0.36.0':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -3567,8 +3654,8 @@ snapshots:
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.7.1
'@emnapi/runtime': 1.7.1
'@emnapi/core': 1.8.1
'@emnapi/runtime': 1.8.1
'@tybys/wasm-util': 0.10.1
optional: true
@@ -4586,7 +4673,7 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
file-type@21.2.0:
file-type@21.3.0:
dependencies:
'@tokenizer/inflate': 0.4.1
strtok3: 10.3.4
@@ -5106,7 +5193,7 @@ snapshots:
'@tokenizer/token': 0.3.0
content-type: 1.0.5
debug: 4.4.3
file-type: 21.2.0
file-type: 21.3.0
media-typer: 1.1.0
strtok3: 10.3.4
token-types: 6.1.2
@@ -5169,10 +5256,10 @@ snapshots:
dependencies:
wrappy: 1.0.2
openai@6.10.0(ws@8.18.3)(zod@4.3.4):
openai@6.10.0(ws@8.18.3)(zod@4.3.5):
optionalDependencies:
ws: 8.18.3
zod: 4.3.4
zod: 4.3.5
opus-decoder@0.7.11:
dependencies:
@@ -5930,10 +6017,10 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1(zod@4.3.4):
zod-to-json-schema@3.25.1(zod@4.3.5):
dependencies:
zod: 4.3.4
zod: 4.3.5
zod@3.25.76: {}
zod@4.3.4: {}
zod@4.3.5: {}
+4 -1
View File
@@ -16,7 +16,10 @@ GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || ec
APP_VERSION="${APP_VERSION:-$PKG_VERSION}"
APP_BUILD="${APP_BUILD:-$GIT_BUILD_NUMBER}"
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-arm64 x86_64}"
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-$(uname -m)}"
if [[ "${BUILD_ARCHS_VALUE}" == "all" ]]; then
BUILD_ARCHS_VALUE="arm64 x86_64"
fi
IFS=' ' read -r -a BUILD_ARCHS <<< "$BUILD_ARCHS_VALUE"
PRIMARY_ARCH="${BUILD_ARCHS[0]}"
SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}"
+6 -1
View File
@@ -22,7 +22,12 @@ VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Co
ZIP="$ROOT_DIR/dist/Clawdbot-$VERSION.zip"
DMG="$ROOT_DIR/dist/Clawdbot-$VERSION.dmg"
NOTARY_ZIP="$ROOT_DIR/dist/Clawdbot-$VERSION.notary.zip"
NOTARIZE="${NOTARIZE:-0}"
SKIP_NOTARIZE="${SKIP_NOTARIZE:-0}"
NOTARIZE=1
if [[ "$SKIP_NOTARIZE" == "1" ]]; then
NOTARIZE=0
fi
if [[ "$NOTARIZE" == "1" ]]; then
echo "📦 Notary zip: $NOTARY_ZIP"
+1 -2
View File
@@ -228,7 +228,6 @@ When submitting PRs to external repos, use this format for quality & maintainer-
# Example
command example
```
````
## Feature intent (maintainer-friendly)
[Why useful, how it fits, workflows it enables]
@@ -264,7 +263,7 @@ command example
---
*Submitted by Razor 🥷 - Mariano's AI agent*
```
````
**Key principles:**
1. Human-written description (no AI slop)
+14 -16
View File
@@ -5,16 +5,20 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
session: {
mainKey: "main",
scope: "per-sender",
agentToAgent: { maxPingPongTurns: 2 },
},
}),
resolveGatewayPort: () => 18789,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
session: {
mainKey: "main",
scope: "per-sender",
agentToAgent: { maxPingPongTurns: 2 },
},
}),
resolveGatewayPort: () => 18789,
};
});
import { createClawdbotTools } from "./clawdbot-tools.js";
@@ -261,12 +265,6 @@ describe("sessions tools", () => {
).toBe(true);
expect(waitCalls).toHaveLength(8);
expect(historyOnlyCalls).toHaveLength(8);
expect(
waitCalls.some(
(call) =>
typeof (call.params as { afterMs?: number })?.afterMs === "number",
),
).toBe(true);
expect(sendCallCount).toBe(0);
});
+66
View File
@@ -0,0 +1,66 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
const oauthFixture = {
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
accountId: "acct_123",
};
describe("getApiKeyForModel", () => {
it("migrates legacy oauth.json into auth.json", async () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-"));
try {
process.env.CLAWDBOT_STATE_DIR = tempDir;
const oauthDir = path.join(tempDir, "credentials");
await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 });
await fs.writeFile(
path.join(oauthDir, "oauth.json"),
`${JSON.stringify({ "openai-codex": oauthFixture }, null, 2)}\n`,
"utf8",
);
const agentDir = path.join(tempDir, "agent");
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
const authStorage = discoverAuthStorage(agentDir);
vi.resetModules();
const { getApiKeyForModel } = await import("./model-auth.js");
const model = {
id: "codex-mini-latest",
provider: "openai-codex",
api: "openai-codex-responses",
} as Model<Api>;
const apiKey = await getApiKeyForModel(model, authStorage);
expect(apiKey).toBe(oauthFixture.access);
const authJson = await fs.readFile(
path.join(agentDir, "auth.json"),
"utf8",
);
const authData = JSON.parse(authJson) as Record<string, unknown>;
expect(authData["openai-codex"]).toMatchObject({
type: "oauth",
access: oauthFixture.access,
refresh: oauthFixture.refresh,
});
} finally {
if (previousStateDir === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
+37 -1
View File
@@ -17,6 +17,7 @@ import { CONFIG_DIR, resolveUserPath } from "../utils.js";
const OAUTH_FILENAME = "oauth.json";
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
let oauthStorageConfigured = false;
let oauthStorageMigrated = false;
type OAuthStorage = Record<string, OAuthCredentials>;
@@ -97,6 +98,39 @@ export function ensureOAuthStorage(): void {
importLegacyOAuthIfNeeded(oauthPath);
}
function isValidOAuthCredential(
entry: OAuthCredentials | undefined,
): entry is OAuthCredentials {
if (!entry) return false;
return Boolean(
entry.access?.trim() &&
entry.refresh?.trim() &&
Number.isFinite(entry.expires),
);
}
function migrateOAuthStorageToAuthStorage(
authStorage: ReturnType<typeof discoverAuthStorage>,
): void {
if (oauthStorageMigrated) return;
oauthStorageMigrated = true;
const oauthPath = resolveClawdbotOAuthPath();
const storage = loadOAuthStorageAt(oauthPath);
if (!storage) return;
for (const [provider, creds] of Object.entries(storage)) {
if (!isValidOAuthCredential(creds)) continue;
if (authStorage.get(provider)) continue;
authStorage.set(provider, { type: "oauth", ...creds });
}
}
export function hydrateAuthStorage(
authStorage: ReturnType<typeof discoverAuthStorage>,
): void {
ensureOAuthStorage();
migrateOAuthStorageToAuthStorage(authStorage);
}
function isOAuthProvider(provider: string): provider is OAuthProvider {
return (
provider === "anthropic" ||
@@ -104,6 +138,7 @@ function isOAuthProvider(provider: string): provider is OAuthProvider {
provider === "google" ||
provider === "openai" ||
provider === "openai-compatible" ||
provider === "openai-codex" ||
provider === "github-copilot" ||
provider === "google-gemini-cli" ||
provider === "google-antigravity"
@@ -114,9 +149,10 @@ export async function getApiKeyForModel(
model: Model<Api>,
authStorage: ReturnType<typeof discoverAuthStorage>,
): Promise<string> {
ensureOAuthStorage();
migrateOAuthStorageToAuthStorage(authStorage);
const storedKey = await authStorage.getApiKey(model.provider);
if (storedKey) return storedKey;
ensureOAuthStorage();
if (model.provider === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return oauthEnv.trim();
+77
View File
@@ -0,0 +1,77 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import {
isRateLimitAssistantError,
pickFallbackThinkingLevel,
} from "./pi-embedded-helpers.js";
const asAssistant = (overrides: Partial<AssistantMessage>) =>
({
role: "assistant",
stopReason: "error",
...overrides,
}) as AssistantMessage;
describe("isRateLimitAssistantError", () => {
it("detects 429 rate limit payloads", () => {
const msg = asAssistant({
errorMessage:
'429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account\'s rate limit. Please try again later."}}',
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("detects human-readable rate limit messages", () => {
const msg = asAssistant({
errorMessage: "Too many requests. Rate limit exceeded.",
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("detects quota exceeded messages", () => {
const msg = asAssistant({
errorMessage:
"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
});
expect(isRateLimitAssistantError(msg)).toBe(true);
});
it("returns false for non-error messages", () => {
const msg = asAssistant({
stopReason: "end_turn",
errorMessage: "rate limit",
});
expect(isRateLimitAssistantError(msg)).toBe(false);
});
});
describe("pickFallbackThinkingLevel", () => {
it("selects the first supported thinking level", () => {
const attempted = new Set<ThinkLevel>(["low"]);
const next = pickFallbackThinkingLevel({
message:
"Unsupported value: 'low' is not supported with the 'gpt-5.2-pro' model. Supported values are: 'medium', 'high', and 'xhigh'.",
attempted,
});
expect(next).toBe("medium");
});
it("skips already attempted levels", () => {
const attempted = new Set<ThinkLevel>(["low", "medium"]);
const next = pickFallbackThinkingLevel({
message: "Supported values are: 'medium', 'high', and 'xhigh'.",
attempted,
});
expect(next).toBe("high");
});
it("returns undefined when no supported values are found", () => {
const attempted = new Set<ThinkLevel>(["low"]);
const next = pickFallbackThinkingLevel({
message: "Request failed.",
attempted,
});
expect(next).toBeUndefined();
});
});
+51
View File
@@ -6,6 +6,10 @@ import type {
AgentToolResult,
} from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import {
normalizeThinkLevel,
type ThinkLevel,
} from "../auto-reply/thinking.js";
import { sanitizeContentBlocksImages } from "./tool-images.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
@@ -109,3 +113,50 @@ export function formatAssistantErrorText(
// Keep it short for WhatsApp.
return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw;
}
export function isRateLimitAssistantError(
msg: AssistantMessage | undefined,
): boolean {
if (!msg || msg.stopReason !== "error") return false;
const raw = (msg.errorMessage ?? "").toLowerCase();
if (!raw) return false;
return (
/rate[_ ]limit|too many requests|429/.test(raw) ||
raw.includes("exceeded your current quota")
);
}
function extractSupportedValues(raw: string): string[] {
const match =
raw.match(/supported values are:\s*([^\n.]+)/i) ??
raw.match(/supported values:\s*([^\n.]+)/i);
if (!match?.[1]) return [];
const fragment = match[1];
const quoted = Array.from(fragment.matchAll(/['"]([^'"]+)['"]/g)).map(
(entry) => entry[1]?.trim(),
);
if (quoted.length > 0) {
return quoted.filter((entry): entry is string => Boolean(entry));
}
return fragment
.split(/,|\band\b/gi)
.map((entry) => entry.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, "").trim())
.filter(Boolean);
}
export function pickFallbackThinkingLevel(params: {
message?: string;
attempted: Set<ThinkLevel>;
}): ThinkLevel | undefined {
const raw = params.message?.trim();
if (!raw) return undefined;
const supported = extractSupportedValues(raw);
if (supported.length === 0) return undefined;
for (const entry of supported) {
const normalized = normalizeThinkLevel(entry);
if (!normalized) continue;
if (params.attempted.has(normalized)) continue;
return normalized;
}
return undefined;
}
+342 -291
View File
@@ -32,6 +32,8 @@ import {
buildBootstrapContextFiles,
ensureSessionHeader,
formatAssistantErrorText,
isRateLimitAssistantError,
pickFallbackThinkingLevel,
sanitizeSessionMessagesImages,
} from "./pi-embedded-helpers.js";
import {
@@ -317,301 +319,350 @@ export async function runEmbeddedPiAgent(params: {
const apiKey = await getApiKeyForModel(model, authStorage);
authStorage.setRuntimeApiKey(model.provider, apiKey);
const thinkingLevel = mapThinkingLevel(params.thinkLevel);
let thinkLevel = params.thinkLevel ?? "off";
const attemptedThinking = new Set<ThinkLevel>();
log.debug(
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} surface=${params.surface ?? "unknown"}`,
);
await fs.mkdir(resolvedWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: resolvedWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace);
try {
const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(resolvedWorkspace)
: [];
const skillsSnapshot =
params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
config: params.config,
entries: skillEntries,
});
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
config: params.config,
})
: applySkillEnvOverrides({
skills: skillEntries ?? [],
config: params.config,
});
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
elevated: params.bashElevated,
},
sandbox,
surface: params.surface,
sessionKey: params.sessionKey ?? params.sessionId,
config: params.config,
});
const machineName = await getMachineDisplayName();
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${provider}/${modelId}`,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
runtimeInfo,
sandboxInfo,
}),
contextFiles,
skills: promptSkills,
cwd: resolvedWorkspace,
tools,
});
const sessionManager = SessionManager.open(params.sessionFile);
const settingsManager = SettingsManager.create(
resolvedWorkspace,
agentDir,
);
const { session } = await createAgentSession({
cwd: resolvedWorkspace,
agentDir,
authStorage,
modelRegistry,
model,
thinkingLevel,
systemPrompt,
// Custom tool set: extra bash/process + read image sanitization.
tools,
sessionManager,
settingsManager,
skills: promptSkills,
contextFiles,
});
const prior = await sanitizeSessionMessagesImages(
session.messages,
"session:history",
);
if (prior.length > 0) {
session.agent.replaceMessages(prior);
}
let aborted = Boolean(params.abortSignal?.aborted);
const abortRun = () => {
aborted = true;
void session.abort();
};
const queueHandle: EmbeddedPiQueueHandle = {
queueMessage: async (text: string) => {
await session.steer(text);
},
isStreaming: () => session.isStreaming,
abort: abortRun,
};
ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
const {
assistantTexts,
toolMetas,
unsubscribe,
waitForCompactionRetry,
} = subscribeEmbeddedPiSession({
session,
runId: params.runId,
verboseLevel: params.verboseLevel,
shouldEmitToolResult: params.shouldEmitToolResult,
onToolResult: params.onToolResult,
onBlockReply: params.onBlockReply,
blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: params.onPartialReply,
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
});
let abortWarnTimer: NodeJS.Timeout | undefined;
const abortTimer = setTimeout(
() => {
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
abortRun();
if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => {
if (!session.isStreaming) return;
log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
}, 10_000);
}
},
Math.max(1, params.timeoutMs),
);
let messagesSnapshot: AgentMessage[] = [];
let sessionIdUsed = session.sessionId;
const onAbort = () => {
abortRun();
};
if (params.abortSignal) {
if (params.abortSignal.aborted) {
onAbort();
} else {
params.abortSignal.addEventListener("abort", onAbort, {
once: true,
});
}
}
let promptError: unknown = null;
try {
const promptStartedAt = Date.now();
log.debug(
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`,
);
try {
await session.prompt(params.prompt);
} catch (err) {
promptError = err;
} finally {
log.debug(
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,
);
}
await waitForCompactionRetry();
messagesSnapshot = session.messages.slice();
sessionIdUsed = session.sessionId;
} finally {
clearTimeout(abortTimer);
if (abortWarnTimer) {
clearTimeout(abortWarnTimer);
abortWarnTimer = undefined;
}
unsubscribe();
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
notifyEmbeddedRunEnded(params.sessionId);
}
session.dispose();
params.abortSignal?.removeEventListener?.("abort", onAbort);
}
if (promptError && !aborted) {
throw promptError;
}
const lastAssistant = messagesSnapshot
.slice()
.reverse()
.find((m) => (m as AgentMessage)?.role === "assistant") as
| AssistantMessage
| undefined;
const usage = lastAssistant?.usage;
const agentMeta: EmbeddedPiAgentMeta = {
sessionId: sessionIdUsed,
provider: lastAssistant?.provider ?? provider,
model: lastAssistant?.model ?? model.id,
usage: usage
? {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
total: usage.totalTokens,
}
: undefined,
};
const replyItems: Array<{ text: string; media?: string[] }> = [];
const errorText = lastAssistant
? formatAssistantErrorText(lastAssistant)
: undefined;
if (errorText) replyItems.push({ text: errorText });
const inlineToolResults =
params.verboseLevel === "on" &&
!params.onPartialReply &&
!params.onToolResult &&
toolMetas.length > 0;
if (inlineToolResults) {
for (const { toolName, meta } of toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg);
if (cleanedText)
replyItems.push({ text: cleanedText, media: mediaUrls });
}
}
for (const text of assistantTexts.length
? assistantTexts
: lastAssistant
? [extractAssistantText(lastAssistant)]
: []) {
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) continue;
replyItems.push({ text: cleanedText, media: mediaUrls });
}
const payloads = replyItems
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
}))
.filter(
(p) =>
p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0),
);
while (true) {
const thinkingLevel = mapThinkingLevel(thinkLevel);
attemptedThinking.add(thinkLevel);
log.debug(
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} surface=${params.surface ?? "unknown"}`,
);
return {
payloads: payloads.length ? payloads : undefined,
meta: {
durationMs: Date.now() - started,
agentMeta,
aborted,
},
};
} finally {
restoreSkillEnv?.();
process.chdir(prevCwd);
await fs.mkdir(resolvedWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: resolvedWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace);
try {
const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(resolvedWorkspace)
: [];
const skillsSnapshot =
params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
config: params.config,
entries: skillEntries,
});
const sandboxSessionKey =
params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
config: params.config,
})
: applySkillEnvOverrides({
skills: skillEntries ?? [],
config: params.config,
});
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(
skillsSnapshot,
skillEntries,
);
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
elevated: params.bashElevated,
},
sandbox,
surface: params.surface,
sessionKey: params.sessionKey ?? params.sessionId,
config: params.config,
});
const machineName = await getMachineDisplayName();
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${provider}/${modelId}`,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace,
defaultThinkLevel: thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
runtimeInfo,
sandboxInfo,
toolNames: tools.map((tool) => tool.name),
}),
contextFiles,
skills: promptSkills,
cwd: resolvedWorkspace,
tools,
});
const sessionManager = SessionManager.open(params.sessionFile);
const settingsManager = SettingsManager.create(
resolvedWorkspace,
agentDir,
);
const { session } = await createAgentSession({
cwd: resolvedWorkspace,
agentDir,
authStorage,
modelRegistry,
model,
thinkingLevel,
systemPrompt,
// Custom tool set: extra bash/process + read image sanitization.
tools,
sessionManager,
settingsManager,
skills: promptSkills,
contextFiles,
});
const prior = await sanitizeSessionMessagesImages(
session.messages,
"session:history",
);
if (prior.length > 0) {
session.agent.replaceMessages(prior);
}
let aborted = Boolean(params.abortSignal?.aborted);
const abortRun = () => {
aborted = true;
void session.abort();
};
const queueHandle: EmbeddedPiQueueHandle = {
queueMessage: async (text: string) => {
await session.steer(text);
},
isStreaming: () => session.isStreaming,
abort: abortRun,
};
ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle);
const {
assistantTexts,
toolMetas,
unsubscribe,
waitForCompactionRetry,
} = subscribeEmbeddedPiSession({
session,
runId: params.runId,
verboseLevel: params.verboseLevel,
shouldEmitToolResult: params.shouldEmitToolResult,
onToolResult: params.onToolResult,
onBlockReply: params.onBlockReply,
blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: params.onPartialReply,
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
});
let abortWarnTimer: NodeJS.Timeout | undefined;
const abortTimer = setTimeout(
() => {
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
abortRun();
if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => {
if (!session.isStreaming) return;
log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
}, 10_000);
}
},
Math.max(1, params.timeoutMs),
);
let messagesSnapshot: AgentMessage[] = [];
let sessionIdUsed = session.sessionId;
const onAbort = () => {
abortRun();
};
if (params.abortSignal) {
if (params.abortSignal.aborted) {
onAbort();
} else {
params.abortSignal.addEventListener("abort", onAbort, {
once: true,
});
}
}
let promptError: unknown = null;
try {
const promptStartedAt = Date.now();
log.debug(
`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`,
);
try {
await session.prompt(params.prompt);
} catch (err) {
promptError = err;
} finally {
log.debug(
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,
);
}
await waitForCompactionRetry();
messagesSnapshot = session.messages.slice();
sessionIdUsed = session.sessionId;
} finally {
clearTimeout(abortTimer);
if (abortWarnTimer) {
clearTimeout(abortWarnTimer);
abortWarnTimer = undefined;
}
unsubscribe();
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
notifyEmbeddedRunEnded(params.sessionId);
}
session.dispose();
params.abortSignal?.removeEventListener?.("abort", onAbort);
}
if (promptError && !aborted) {
const fallbackThinking = pickFallbackThinkingLevel({
message:
promptError instanceof Error
? promptError.message
: String(promptError),
attempted: attemptedThinking,
});
if (fallbackThinking) {
log.warn(
`unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`,
);
thinkLevel = fallbackThinking;
continue;
}
throw promptError;
}
const lastAssistant = messagesSnapshot
.slice()
.reverse()
.find((m) => (m as AgentMessage)?.role === "assistant") as
| AssistantMessage
| undefined;
const fallbackThinking = pickFallbackThinkingLevel({
message: lastAssistant?.errorMessage,
attempted: attemptedThinking,
});
if (fallbackThinking && !aborted) {
log.warn(
`unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`,
);
thinkLevel = fallbackThinking;
continue;
}
const fallbackConfigured =
(params.config?.agent?.modelFallbacks?.length ?? 0) > 0;
if (fallbackConfigured && isRateLimitAssistantError(lastAssistant)) {
const message =
lastAssistant?.errorMessage?.trim() ||
(lastAssistant ? formatAssistantErrorText(lastAssistant) : "") ||
"LLM request rate limited.";
throw new Error(message);
}
const usage = lastAssistant?.usage;
const agentMeta: EmbeddedPiAgentMeta = {
sessionId: sessionIdUsed,
provider: lastAssistant?.provider ?? provider,
model: lastAssistant?.model ?? model.id,
usage: usage
? {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
total: usage.totalTokens,
}
: undefined,
};
const replyItems: Array<{ text: string; media?: string[] }> = [];
const errorText = lastAssistant
? formatAssistantErrorText(lastAssistant)
: undefined;
if (errorText) replyItems.push({ text: errorText });
const inlineToolResults =
params.verboseLevel === "on" &&
!params.onPartialReply &&
!params.onToolResult &&
toolMetas.length > 0;
if (inlineToolResults) {
for (const { toolName, meta } of toolMetas) {
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
const { text: cleanedText, mediaUrls } =
splitMediaFromOutput(agg);
if (cleanedText)
replyItems.push({ text: cleanedText, media: mediaUrls });
}
}
for (const text of assistantTexts.length
? assistantTexts
: lastAssistant
? [extractAssistantText(lastAssistant)]
: []) {
const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text);
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0))
continue;
replyItems.push({ text: cleanedText, media: mediaUrls });
}
const payloads = replyItems
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
}))
.filter(
(p) =>
p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0),
);
log.debug(
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
);
return {
payloads: payloads.length ? payloads : undefined,
meta: {
durationMs: Date.now() - started,
agentMeta,
aborted,
},
};
} finally {
restoreSkillEnv?.();
process.chdir(prevCwd);
}
}
}),
);
+24
View File
@@ -616,6 +616,18 @@ export function subscribeEmbeddedPiSession(params: {
if (evt.type === "agent_start") {
log.debug(`embedded run agent start: runId=${params.runId}`);
emitAgentEvent({
runId: params.runId,
stream: "lifecycle",
data: {
phase: "start",
startedAt: Date.now(),
},
});
params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "start" },
});
}
if (evt.type === "auto_compaction_start") {
@@ -638,6 +650,18 @@ export function subscribeEmbeddedPiSession(params: {
if (evt.type === "agent_end") {
log.debug(`embedded run agent end: runId=${params.runId}`);
emitAgentEvent({
runId: params.runId,
stream: "lifecycle",
data: {
phase: "end",
endedAt: Date.now(),
},
});
params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "end" },
});
if (pendingCompactionRetry > 0) {
resolveCompactionRetry();
} else {
+30
View File
@@ -99,6 +99,11 @@ export type SandboxContext = {
browser?: SandboxBrowserContext;
};
export type SandboxWorkspaceInfo = {
workspaceDir: string;
containerWorkdir: string;
};
const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(
os.homedir(),
".clawdbot",
@@ -866,3 +871,28 @@ export async function resolveSandboxContext(params: {
browser: browser ?? undefined,
};
}
export async function ensureSandboxWorkspaceForSession(params: {
config?: ClawdbotConfig;
sessionKey?: string;
workspaceDir?: string;
}): Promise<SandboxWorkspaceInfo | null> {
const rawSessionKey = params.sessionKey?.trim();
if (!rawSessionKey) return null;
const cfg = defaultSandboxConfig(params.config);
const mainKey = params.config?.session?.mainKey?.trim() || "main";
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const workspaceDir = cfg.perSession
? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey)
: workspaceRoot;
const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
await ensureSandboxWorkspace(workspaceDir, seedWorkspace);
return {
workspaceDir,
containerWorkdir: cfg.docker.workdir,
};
}
+13
View File
@@ -33,4 +33,17 @@ describe("buildAgentSystemPromptAppend", () => {
expect(prompt).toContain("<think>...</think>");
expect(prompt).toContain("<final>...</final>");
});
it("lists available and unavailable tools when provided", () => {
const prompt = buildAgentSystemPromptAppend({
workspaceDir: "/tmp/clawd",
toolNames: ["bash", "sessions_list", "sessions_history", "sessions_send"],
});
expect(prompt).toContain("Tool availability (filtered by policy):");
expect(prompt).toContain("sessions_list");
expect(prompt).toContain("sessions_history");
expect(prompt).toContain("sessions_send");
expect(prompt).toContain("Unavailable tools (do not call):");
});
});
+86 -11
View File
@@ -6,6 +6,7 @@ export function buildAgentSystemPromptAppend(params: {
extraSystemPrompt?: string;
ownerNumbers?: string[];
reasoningTagHint?: boolean;
toolNames?: string[];
runtimeInfo?: {
host?: string;
os?: string;
@@ -20,6 +21,69 @@ export function buildAgentSystemPromptAppend(params: {
browserNoVncUrl?: string;
};
}) {
const toolSummaries: Record<string, string> = {
read: "Read file contents",
write: "Create or overwrite files",
edit: "Make precise edits to files",
grep: "Search file contents for patterns",
find: "Find files by glob pattern",
ls: "List directory contents",
bash: "Run shell commands",
process: "Manage background bash sessions",
whatsapp_login: "Generate and wait for WhatsApp QR login",
browser: "Control the dedicated clawd browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events",
gateway: "Restart the running Gateway process",
sessions_list: "List sessions with filters and last messages",
sessions_history: "Fetch message history for a session",
sessions_send: "Send a message into another session",
image: "Analyze an image with the configured image model",
discord: "Send Discord reactions/messages and manage threads",
slack: "Send Slack messages and manage channels",
};
const toolOrder = [
"read",
"write",
"edit",
"grep",
"find",
"ls",
"bash",
"process",
"whatsapp_login",
"browser",
"canvas",
"nodes",
"cron",
"gateway",
"sessions_list",
"sessions_history",
"sessions_send",
"image",
"discord",
"slack",
];
const normalizedTools = (params.toolNames ?? [])
.map((tool) => tool.trim().toLowerCase())
.filter(Boolean);
const availableTools = new Set(normalizedTools);
const extraTools = Array.from(
new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))),
);
const enabledTools = toolOrder.filter((tool) => availableTools.has(tool));
const disabledTools = toolOrder.filter((tool) => !availableTools.has(tool));
const toolLines = enabledTools.map((tool) => {
const summary = toolSummaries[tool];
return summary ? `- ${tool}: ${summary}` : `- ${tool}`;
});
for (const tool of extraTools.sort()) {
toolLines.push(`- ${tool}`);
}
const thinkHint =
params.defaultThinkLevel && params.defaultThinkLevel !== "off"
? `Default thinking level: ${params.defaultThinkLevel}.`
@@ -61,17 +125,28 @@ export function buildAgentSystemPromptAppend(params: {
"You are Clawd, a personal assistant running inside Clawdbot.",
"",
"## Tooling",
"Pi lists the standard tools above. This runtime enables:",
"- grep: search file contents for patterns",
"- find: find files by glob pattern",
"- ls: list directory contents",
"- bash: run shell commands (supports background via yieldMs/background)",
"- process: manage background bash sessions",
"- whatsapp_login: generate a WhatsApp QR code and wait for linking",
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events",
"Tool availability (filtered by policy):",
toolLines.length > 0
? toolLines.join("\n")
: [
"Pi lists the standard tools above. This runtime enables:",
"- grep: search file contents for patterns",
"- find: find files by glob pattern",
"- ls: list directory contents",
"- bash: run shell commands (supports background via yieldMs/background)",
"- process: manage background bash sessions",
"- whatsapp_login: generate a WhatsApp QR code and wait for linking",
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
].join("\n"),
disabledTools.length > 0
? `Unavailable tools (do not call): ${disabledTools.join(", ")}`
: "",
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
"",
"## Workspace",
+2 -17
View File
@@ -151,16 +151,11 @@ export function createSessionsSendTool(opts?: {
typeof response?.runId === "string" && response.runId
? response.runId
: stepIdem;
const stepAcceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
const stepWaitMs = Math.min(step.timeoutMs, 60_000);
const wait = (await callGateway({
method: "agent.wait",
params: {
runId: stepRunId,
afterMs: stepAcceptedAt,
timeoutMs: stepWaitMs,
},
timeoutMs: stepWaitMs + 2000,
@@ -171,7 +166,7 @@ export function createSessionsSendTool(opts?: {
const runAgentToAgentFlow = async (
roundOneReply?: string,
runInfo?: { runId: string; acceptedAt?: number },
runInfo?: { runId: string },
) => {
try {
let primaryReply = roundOneReply;
@@ -182,7 +177,6 @@ export function createSessionsSendTool(opts?: {
method: "agent.wait",
params: {
runId: runInfo.runId,
afterMs: runInfo.acceptedAt,
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
@@ -277,14 +271,10 @@ export function createSessionsSendTool(opts?: {
params: sendParams,
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const acceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
void runAgentToAgentFlow(undefined, { runId, acceptedAt });
void runAgentToAgentFlow(undefined, { runId });
return jsonResult({
runId,
status: "accepted",
@@ -306,7 +296,6 @@ export function createSessionsSendTool(opts?: {
}
}
let acceptedAt: number | undefined;
try {
const response = (await callGateway({
method: "agent",
@@ -316,9 +305,6 @@ export function createSessionsSendTool(opts?: {
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
if (typeof response?.acceptedAt === "number") {
acceptedAt = response.acceptedAt;
}
} catch (err) {
const messageText =
err instanceof Error
@@ -341,7 +327,6 @@ export function createSessionsSendTool(opts?: {
method: "agent.wait",
params: {
runId,
afterMs: acceptedAt,
timeoutMs,
},
timeoutMs: timeoutMs + 2000,
+3 -1
View File
@@ -1,7 +1,9 @@
const CONTROL_COMMAND_RE =
/(?:^|\s)\/(?:status|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i;
/(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i;
const CONTROL_COMMAND_EXACT = new Set([
"help",
"/help",
"status",
"/status",
"restart",
+28
View File
@@ -578,6 +578,7 @@ describe("directive parsing", () => {
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");
expect(text).toContain("auth:");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -604,6 +605,33 @@ describe("directive parsing", () => {
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");
expect(text).toContain("auth:");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("lists allowlisted models on /model list", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("auth:");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
+99 -1
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../agents/pi-embedded.js", () => ({
@@ -14,6 +14,8 @@ vi.mock("../agents/pi-embedded.js", () => ({
}));
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { resolveSessionKey } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
@@ -124,6 +126,24 @@ describe("trigger handling", () => {
});
});
it("returns help without invoking the agent", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/help",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Help");
expect(text).toContain("Shortcuts");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("allows owner to set send policy", async () => {
await withTempHome(async (home) => {
const cfg = {
@@ -712,6 +732,84 @@ describe("trigger handling", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("stages inbound media into the sandbox workspace", async () => {
await withTempHome(async (home) => {
const inboundDir = join(home, ".clawdbot", "media", "inbound");
await fs.mkdir(inboundDir, { recursive: true });
const mediaPath = join(inboundDir, "photo.jpg");
await fs.writeFile(mediaPath, "test");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
},
whatsapp: {
allowFrom: ["*"],
},
session: {
store: join(home, "sessions.json"),
},
};
const ctx = {
Body: "hi",
From: "group:whatsapp:demo",
To: "+2000",
ChatType: "group" as const,
Surface: "whatsapp" as const,
MediaPath: mediaPath,
MediaType: "image/jpeg",
MediaUrl: mediaPath,
};
const res = await getReplyFromConfig(ctx, {}, cfg);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt =
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const stagedPath = `media/inbound/${basename(mediaPath)}`;
expect(prompt).toContain(stagedPath);
expect(prompt).not.toContain(mediaPath);
const sessionKey = resolveSessionKey(
cfg.session?.scope ?? "per-sender",
ctx,
cfg.session?.mainKey,
);
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir: cfg.agent.workspace,
});
expect(sandbox).not.toBeNull();
if (!sandbox) {
throw new Error("Expected sandbox to be set");
}
const stagedFullPath = join(
sandbox.workspaceDir,
"media",
"inbound",
basename(mediaPath),
);
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
});
});
});
describe("group intro prompts", () => {
+77 -1
View File
@@ -1,4 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveModelRefFromString } from "../agents/model-selection.js";
import {
@@ -7,6 +10,7 @@ import {
isEmbeddedPiRunStreaming,
resolveEmbeddedSessionLane,
} from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
@@ -49,7 +53,7 @@ import {
prependSystemEvents,
} from "./reply/session-updates.js";
import { createTypingController } from "./reply/typing.js";
import type { MsgContext } from "./templating.js";
import type { MsgContext, TemplateContext } from "./templating.js";
import {
type ElevatedLevel,
normalizeThinkLevel,
@@ -468,6 +472,7 @@ export async function getReplyFromConfig(
defaultGroupActivation: () => defaultActivation,
resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedElevatedLevel,
resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel,
provider,
model,
@@ -478,6 +483,15 @@ export async function getReplyFromConfig(
typing.cleanup();
return commandResult.reply;
}
await stageSandboxMedia({
ctx,
sessionCtx,
cfg,
sessionKey,
workspaceDir,
});
const isFirstTurnInSession = isNewSession || !systemSent;
const isGroupChat = sessionCtx.ChatType === "group";
const wasMentioned = ctx.WasMentioned === true;
@@ -681,3 +695,65 @@ export async function getReplyFromConfig(
shouldInjectGroupIntro,
});
}
async function stageSandboxMedia(params: {
ctx: MsgContext;
sessionCtx: TemplateContext;
cfg: ClawdbotConfig;
sessionKey?: string;
workspaceDir: string;
}) {
const { ctx, sessionCtx, cfg, sessionKey, workspaceDir } = params;
const rawPath = ctx.MediaPath?.trim();
if (!rawPath || !sessionKey) return;
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir,
});
if (!sandbox) return;
let source = rawPath;
if (source.startsWith("file://")) {
try {
source = fileURLToPath(source);
} catch {
return;
}
}
if (!path.isAbsolute(source)) return;
const originalMediaPath = ctx.MediaPath;
const originalMediaUrl = ctx.MediaUrl;
try {
const fileName = path.basename(source);
if (!fileName) return;
const destDir = path.join(sandbox.workspaceDir, "media", "inbound");
await fs.mkdir(destDir, { recursive: true });
const dest = path.join(destDir, fileName);
await fs.copyFile(source, dest);
const relative = path.posix.join("media", "inbound", fileName);
ctx.MediaPath = relative;
sessionCtx.MediaPath = relative;
if (originalMediaUrl) {
let normalizedUrl = originalMediaUrl;
if (normalizedUrl.startsWith("file://")) {
try {
normalizedUrl = fileURLToPath(normalizedUrl);
} catch {
normalizedUrl = originalMediaUrl;
}
}
if (normalizedUrl === originalMediaPath || normalizedUrl === source) {
ctx.MediaUrl = relative;
sessionCtx.MediaUrl = relative;
}
}
} catch (err) {
logVerbose(`Failed to stage inbound media for sandbox: ${String(err)}`);
}
}
+81 -2
View File
@@ -1,4 +1,9 @@
import fs from "node:fs";
import { getEnvApiKey } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveOAuthPath } from "../../config/paths.js";
import {
type SessionEntry,
type SessionScope,
@@ -15,9 +20,9 @@ import {
parseActivationCommand,
} from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { buildStatusMessage } from "../status.js";
import { buildHelpMessage, buildStatusMessage } from "../status.js";
import type { MsgContext } from "../templating.js";
import type { ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js";
import type { InlineDirectives } from "./directive-handling.js";
@@ -36,6 +41,59 @@ export type CommandContext = {
to?: string;
};
function hasOAuthCredentials(provider: string): boolean {
try {
const oauthPath = resolveOAuthPath();
if (!fs.existsSync(oauthPath)) return false;
const raw = fs.readFileSync(oauthPath, "utf8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const entry = parsed?.[provider] as
| {
refresh?: string;
refresh_token?: string;
refreshToken?: string;
access?: string;
access_token?: string;
accessToken?: string;
}
| undefined;
if (!entry) return false;
const refresh =
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
const access =
entry.access ?? entry.access_token ?? entry.accessToken ?? "";
return Boolean(refresh.trim() && access.trim());
} catch {
return false;
}
}
function resolveModelAuthLabel(provider?: string): string | undefined {
const resolved = provider?.trim();
if (!resolved) return undefined;
try {
const authStorage = discoverAuthStorage(resolveClawdbotAgentDir());
const stored = authStorage.get(resolved);
if (stored?.type === "oauth") return "oauth";
if (stored?.type === "api_key") return "api-key";
} catch {
// ignore auth storage errors
}
if (resolved === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return "oauth";
}
if (hasOAuthCredentials(resolved)) return "oauth";
const envKey = getEnvApiKey(resolved);
if (envKey?.trim()) return "api-key";
return "unknown";
}
export function buildCommandContext(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
@@ -121,6 +179,7 @@ export async function handleCommands(params: {
defaultGroupActivation: () => "always" | "mention";
resolvedThinkLevel?: ThinkLevel;
resolvedVerboseLevel: VerboseLevel;
resolvedElevatedLevel?: ElevatedLevel;
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
provider: string;
model: string;
@@ -143,7 +202,9 @@ export async function handleCommands(params: {
defaultGroupActivation,
resolvedThinkLevel,
resolvedVerboseLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup,
@@ -260,6 +321,20 @@ export async function handleCommands(params: {
};
}
const helpRequested =
command.commandBodyNormalized === "/help" ||
command.commandBodyNormalized === "help" ||
/(?:^|\s)\/help(?=$|\s|:)\b/i.test(command.commandBodyNormalized);
if (helpRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /help from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildHelpMessage() } };
}
const statusRequested =
directives.hasStatusDirective ||
command.commandBodyNormalized === "/status" ||
@@ -281,10 +356,12 @@ export async function handleCommands(params: {
: undefined;
const statusText = buildStatusMessage({
agent: {
...cfg.agent,
model,
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
},
workspaceDir,
sessionEntry,
@@ -295,6 +372,8 @@ export async function handleCommands(params: {
resolvedThink:
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
resolvedVerbose: resolvedVerboseLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider),
webLinked,
webAuthAgeMs,
heartbeatSeconds,
+77 -3
View File
@@ -1,9 +1,13 @@
import { getEnvApiKey } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import { lookupContextTokens } from "../../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../../agents/defaults.js";
import { hydrateAuthStorage } from "../../agents/model-auth.js";
import {
buildModelAliasIndex,
type ModelAliasIndex,
@@ -14,6 +18,7 @@ import {
import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { shortenHomePath } from "../../utils.js";
import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js";
import type { ReplyPayload } from "../types.js";
@@ -39,6 +44,55 @@ import {
const SYSTEM_MARK = "⚙️";
const maskApiKey = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return "missing";
if (trimmed.length <= 16) return trimmed;
return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`;
};
const resolveAuthLabel = async (
provider: string,
authStorage: ReturnType<typeof discoverAuthStorage>,
authPaths: { authPath: string; modelsPath: string },
): Promise<{ label: string; source: string }> => {
const formatPath = (value: string) => shortenHomePath(value);
const stored = authStorage.get(provider);
if (stored?.type === "oauth") {
const email = stored.email?.trim();
return {
label: email ? `OAuth ${email}` : "OAuth (unknown)",
source: `auth.json: ${formatPath(authPaths.authPath)}`,
};
}
if (stored?.type === "api_key") {
return {
label: maskApiKey(stored.key),
source: `auth.json: ${formatPath(authPaths.authPath)}`,
};
}
const envKey = getEnvApiKey(provider);
if (envKey) return { label: maskApiKey(envKey), source: "env" };
if (provider === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
if (oauthEnv) {
return { label: "OAuth (env)", source: "env: ANTHROPIC_OAUTH_TOKEN" };
}
}
try {
const key = await authStorage.getApiKey(provider);
if (key) {
return {
label: maskApiKey(key),
source: `models.json: ${formatPath(authPaths.modelsPath)}`,
};
}
} catch {
// ignore missing auth
}
return { label: "missing", source: "missing" };
};
export type InlineDirectives = {
cleaned: string;
hasThinkDirective: boolean;
@@ -196,12 +250,30 @@ export async function handleDirectiveOnly(params: {
} = params;
if (directives.hasModelDirective) {
const modelDirective = directives.rawModelDirective?.trim().toLowerCase();
const isModelListAlias =
directives.rawModelDirective?.trim().toLowerCase() === "status";
modelDirective === "status" || modelDirective === "list";
if (!directives.rawModelDirective || isModelListAlias) {
if (allowedModelCatalog.length === 0) {
return { text: "No models available." };
}
const agentDir = resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const authPaths = {
authPath: `${agentDir}/auth.json`,
modelsPath: `${agentDir}/models.json`,
};
hydrateAuthStorage(authStorage);
const authByProvider = new Map<string, string>();
for (const entry of allowedModelCatalog) {
if (authByProvider.has(entry.provider)) continue;
const auth = await resolveAuthLabel(
entry.provider,
authStorage,
authPaths,
);
authByProvider.set(entry.provider, `${auth.label} (${auth.source})`);
}
const current = `${params.provider}/${params.model}`;
const defaultLabel = `${defaultProvider}/${defaultModel}`;
const header =
@@ -219,9 +291,11 @@ export async function handleDirectiveOnly(params: {
aliases && aliases.length > 0
? ` (alias: ${aliases.join(", ")})`
: "";
const suffix =
const nameSuffix =
entry.name && entry.name !== entry.id ? ` — ${entry.name}` : "";
lines.push(`- ${label}${aliasSuffix}${suffix}`);
const authLabel = authByProvider.get(entry.provider) ?? "missing";
const authSuffix = ` — auth: ${authLabel}`;
lines.push(`- ${label}${aliasSuffix}${nameSuffix}${authSuffix}`);
}
return { text: lines.join("\n") };
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { GroupKeyResolution } from "../../config/sessions.js";
import type { TemplateContext } from "../templating.js";
import { resolveGroupRequireMention } from "./groups.js";
describe("resolveGroupRequireMention", () => {
it("respects Discord guild/channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
discord: {
guilds: {
"145": {
requireMention: false,
channels: {
general: { allow: true },
},
},
},
},
};
const ctx: TemplateContext = {
Surface: "discord",
From: "group:123",
GroupRoom: "#general",
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
surface: "discord",
id: "123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
false,
);
});
it("respects Slack channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
slack: {
channels: {
C123: { requireMention: false },
},
},
};
const ctx: TemplateContext = {
Surface: "slack",
From: "slack:channel:C123",
GroupSubject: "#general",
};
const groupResolution: GroupKeyResolution = {
surface: "slack",
id: "C123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
false,
);
});
});
+91
View File
@@ -6,6 +6,43 @@ import type {
import { normalizeGroupActivation } from "../group-activation.js";
import type { TemplateContext } from "../templating.js";
function normalizeDiscordSlug(value?: string | null) {
if (!value) return "";
let text = value.trim().toLowerCase();
if (!text) return "";
text = text.replace(/^[@#]+/, "");
text = text.replace(/[\s_]+/g, "-");
text = text.replace(/[^a-z0-9-]+/g, "-");
text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
return text;
}
function normalizeSlackSlug(raw?: string | null) {
const trimmed = raw?.trim().toLowerCase() ?? "";
if (!trimmed) return "";
const dashed = trimmed.replace(/\s+/g, "-");
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
}
function resolveDiscordGuildEntry(
guilds: NonNullable<ClawdbotConfig["discord"]>["guilds"],
groupSpace?: string,
) {
if (!guilds || Object.keys(guilds).length === 0) return null;
const space = groupSpace?.trim();
if (space && guilds[space]) return guilds[space];
const normalized = normalizeDiscordSlug(space);
if (normalized && guilds[normalized]) return guilds[normalized];
if (normalized) {
const match = Object.values(guilds).find(
(entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized,
);
if (match) return match;
}
return guilds["*"] ?? null;
}
export function resolveGroupRequireMention(params: {
cfg: ClawdbotConfig;
ctx: TemplateContext;
@@ -14,6 +51,8 @@ export function resolveGroupRequireMention(params: {
const { cfg, ctx, groupResolution } = params;
const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase();
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
if (surface === "telegram") {
if (groupId) {
const groupConfig = cfg.telegram?.groups?.[groupId];
@@ -47,6 +86,58 @@ export function resolveGroupRequireMention(params: {
if (typeof groupDefault === "boolean") return groupDefault;
return true;
}
if (surface === "discord") {
const guildEntry = resolveDiscordGuildEntry(
cfg.discord?.guilds,
groupSpace,
);
const channelEntries = guildEntry?.channels;
if (channelEntries && Object.keys(channelEntries).length > 0) {
const channelSlug = normalizeDiscordSlug(groupRoom);
const entry =
(groupId ? channelEntries[groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(groupRoom
? channelEntries[normalizeDiscordSlug(groupRoom)]
: undefined);
if (entry && typeof entry.requireMention === "boolean") {
return entry.requireMention;
}
}
if (typeof guildEntry?.requireMention === "boolean") {
return guildEntry.requireMention;
}
return true;
}
if (surface === "slack") {
const channels = cfg.slack?.channels ?? {};
const keys = Object.keys(channels);
if (keys.length === 0) return true;
const channelId = groupId?.trim();
const channelName = groupRoom?.replace(/^#/, "");
const normalizedName = normalizeSlackSlug(channelName);
const candidates = [
channelId ?? "",
channelName ? `#${channelName}` : "",
channelName ?? "",
normalizedName,
].filter(Boolean);
let matched: { requireMention?: boolean } | undefined;
for (const candidate of candidates) {
if (candidate && channels[candidate]) {
matched = channels[candidate];
break;
}
}
const fallback = channels["*"];
const resolved = matched ?? fallback;
if (typeof resolved?.requireMention === "boolean") {
return resolved.requireMention;
}
return true;
}
return true;
}
+3
View File
@@ -36,12 +36,15 @@ describe("buildStatusMessage", () => {
expect(text).toContain("⚙️ Status");
expect(text).toContain("Agent: embedded pi");
expect(text).toContain("Runtime: direct");
expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("Session: main");
expect(text).toContain("Web: linked");
expect(text).toContain("heartbeat 45s");
expect(text).toContain("thinking=medium");
expect(text).toContain("verbose=off");
expect(text).not.toContain("Shortcuts:");
expect(text).not.toContain("set with");
});
it("handles missing agent config gracefully", () => {
+44 -6
View File
@@ -14,12 +14,13 @@ import {
} from "../agents/usage.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
resolveMainSessionKey,
resolveSessionTranscriptPath,
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import { shortenHomePath } from "../utils.js";
import type { ThinkLevel, VerboseLevel } from "./thinking.js";
import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
@@ -33,6 +34,8 @@ type StatusArgs = {
groupActivation?: "mention" | "always";
resolvedThink?: ThinkLevel;
resolvedVerbose?: VerboseLevel;
resolvedElevated?: ElevatedLevel;
modelAuth?: string;
now?: number;
webLinked?: boolean;
webAuthAgeMs?: number | null;
@@ -164,7 +167,30 @@ export function buildStatusMessage(args: StatusArgs): string {
const verboseLevel =
args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off";
const elevatedLevel =
args.sessionEntry?.elevatedLevel ?? args.agent?.elevatedDefault ?? "on";
args.resolvedElevated ??
args.sessionEntry?.elevatedLevel ??
args.agent?.elevatedDefault ??
"on";
const runtime = (() => {
const sandboxMode = args.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off")
return { line: "Runtime: direct", sandboxed: false };
const sessionScope = args.sessionScope ?? "per-sender";
const mainKey = resolveMainSessionKey({
session: { scope: sessionScope },
});
const sessionKey = args.sessionKey?.trim();
const sandboxed = sessionKey
? sandboxMode === "all" || sessionKey !== mainKey.trim()
: false;
const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
const suffix = sandboxed ? ` • elevated ${elevatedLevel}` : "";
return {
line: `Runtime: ${runtime} (sandbox ${sandboxMode})${suffix}`,
sandboxed,
};
})();
const webLine = (() => {
if (args.webLinked === false) {
@@ -204,27 +230,39 @@ export function buildStatusMessage(args: StatusArgs): string {
contextTokens ?? null,
)}${entry?.abortedLastRun ? " • last run aborted" : ""}`;
const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | elevated=${elevatedLevel} (set with /think <level>, /verbose on|off, /elevated on|off, /model <id>)`;
const optionsLine = runtime.sandboxed
? `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | elevated=${elevatedLevel}`
: `Options: thinking=${thinkLevel} | verbose=${verboseLevel}`;
const modelLabel = model ? `${provider}/${model}` : "unknown";
const agentLine = `Agent: embedded pi • ${modelLabel}`;
const authLine = args.modelAuth ? `Model auth: ${args.modelAuth}` : undefined;
const workspaceLine = args.workspaceDir
? `Workspace: ${shortenHomePath(args.workspaceDir)}`
: undefined;
const helpersLine = "Shortcuts: /new reset | /restart relink";
return [
"⚙️ Status",
webLine,
agentLine,
authLine,
runtime.line,
workspaceLine,
contextLine,
sessionLine,
groupActivationLine,
optionsLine,
helpersLine,
]
.filter(Boolean)
.join("\n");
}
export function buildHelpMessage(): string {
return [
"ℹ️ Help",
"Shortcuts: /new reset | /restart relink",
"Options: /think <level> | /verbose on|off | /elevated on|off | /model <id>",
].join("\n");
}
+8
View File
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { normalizeThinkLevel } from "./thinking.js";
describe("normalizeThinkLevel", () => {
it("accepts mid as medium", () => {
expect(normalizeThinkLevel("mid")).toBe("medium");
});
});
+5 -1
View File
@@ -12,7 +12,11 @@ export function normalizeThinkLevel(
if (["min", "minimal"].includes(key)) return "minimal";
if (["low", "thinkhard", "think-hard", "think_hard"].includes(key))
return "low";
if (["med", "medium", "thinkharder", "think-harder", "harder"].includes(key))
if (
["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(
key,
)
)
return "medium";
if (
[
+8 -4
View File
@@ -10,10 +10,14 @@ import type {
BrowserServerState,
} from "./server-context.js";
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(),
writeConfigFile: vi.fn(async () => {}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: vi.fn(),
writeConfigFile: vi.fn(async () => {}),
};
});
vi.mock("./trash.js", () => ({
movePathToTrash: vi.fn(async (targetPath: string) => targetPath),
+19 -15
View File
@@ -64,22 +64,26 @@ function makeProc(pid = 123) {
const proc = makeProc();
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
browser: {
enabled: true,
controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
controlUrl: `http://127.0.0.1:${testPort}`,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
},
}),
writeConfigFile: vi.fn(async () => {}),
}));
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
-3
View File
@@ -1,3 +0,0 @@
68f18193053997f3dee16de6b0be0bcd97dc70ff8200c77f687479e8b19b78e1
||||||| Stash base
7daf1cbf58ef395b74c2690c439ac7b3cb536e8eb124baf72ad41da4f542204d
+32 -37
View File
@@ -352,17 +352,7 @@ export async function agentCommand(
const sessionFile = resolveSessionTranscriptPath(sessionId);
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "job",
data: {
state: "started",
startedAt,
to: opts.to ?? null,
sessionId,
isNewSession,
},
});
let lifecycleEnded = false;
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = provider;
@@ -399,6 +389,13 @@ export async function agentCommand(
abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt,
onAgentEvent: (evt) => {
if (
evt.stream === "lifecycle" &&
typeof evt.data?.phase === "string" &&
(evt.data.phase === "end" || evt.data.phase === "error")
) {
lifecycleEnded = true;
}
emitAgentEvent({
runId,
stream: evt.stream,
@@ -410,33 +407,31 @@ export async function agentCommand(
result = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
emitAgentEvent({
runId,
stream: "job",
data: {
state: "done",
startedAt,
endedAt: Date.now(),
to: opts.to ?? null,
sessionId,
durationMs: Date.now() - startedAt,
aborted: result.meta.aborted ?? false,
},
});
if (!lifecycleEnded) {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt,
endedAt: Date.now(),
aborted: result.meta.aborted ?? false,
},
});
}
} catch (err) {
emitAgentEvent({
runId,
stream: "job",
data: {
state: "error",
startedAt,
endedAt: Date.now(),
to: opts.to ?? null,
sessionId,
durationMs: Date.now() - startedAt,
error: String(err),
},
});
if (!lifecycleEnded) {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: String(err),
},
});
}
throw err;
}
+7 -3
View File
@@ -10,9 +10,13 @@ import { getHealthSnapshot } from "./health.js";
let testConfig: Record<string, unknown> = {};
let testStore: Record<string, { updatedAt?: number }> = {};
vi.mock("../config/config.js", () => ({
loadConfig: () => testConfig,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
vi.mock("../config/sessions.js", () => ({
resolveStorePath: () => "/tmp/sessions.json",
+10 -2
View File
@@ -99,9 +99,17 @@ export async function runNonInteractiveOnboarding(
await setAnthropicApiKey(key);
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
} else if (authChoice === "oauth" || authChoice === "antigravity") {
} else if (
authChoice === "oauth" ||
authChoice === "openai-codex" ||
authChoice === "antigravity"
) {
runtime.error(
`${authChoice === "oauth" ? "OAuth" : "Antigravity"} requires interactive mode.`,
`${
authChoice === "oauth" || authChoice === "openai-codex"
? "OAuth"
: "Antigravity"
} requires interactive mode.`,
);
runtime.exit(1);
return;
+1
View File
@@ -1,6 +1,7 @@
export type OnboardMode = "local" | "remote";
export type AuthChoice =
| "oauth"
| "openai-codex"
| "antigravity"
| "apiKey"
| "minimax"
+7 -3
View File
@@ -5,9 +5,13 @@ import type { RuntimeEnv } from "../runtime.js";
import { sendCommand } from "./send.js";
let testConfig: Record<string, unknown> = {};
vi.mock("../config/config.js", () => ({
loadConfig: () => testConfig,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => testConfig,
};
});
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
+9 -5
View File
@@ -7,11 +7,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Disable colors for deterministic snapshots.
process.env.FORCE_COLOR = "0";
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
agent: { model: "pi:opus", contextTokens: 32000 },
}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
agent: { model: "pi:opus", contextTokens: 32000 },
}),
};
});
import { sessionsCommand } from "./sessions.js";
+7 -3
View File
@@ -31,9 +31,13 @@ vi.mock("../web/session.js", () => ({
readWebSelfId: mocks.readWebSelfId,
logWebSelfId: mocks.logWebSelfId,
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({ session: {} }),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({ session: {} }),
};
});
import { statusCommand } from "./status.js";
+8 -4
View File
@@ -9,10 +9,14 @@ let lastClientOptions: {
onHelloOk?: () => void | Promise<void>;
} | null = null;
vi.mock("../config/config.js", () => ({
loadConfig,
resolveGatewayPort,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig,
resolveGatewayPort,
};
});
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4,
-1
View File
@@ -218,7 +218,6 @@ export const AgentParamsSchema = Type.Object(
export const AgentWaitParamsSchema = Type.Object(
{
runId: NonEmptyString,
afterMs: Type.Optional(Type.Integer({ minimum: 0 })),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
+7 -7
View File
@@ -208,9 +208,9 @@ export function createAgentEventHandler({
agentRunSeq.set(evt.runId, evt.seq);
broadcast("agent", agentPayload);
const jobState =
evt.stream === "job" && typeof evt.data?.state === "string"
? evt.data.state
const lifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data?.phase === "string"
? evt.data.phase
: null;
if (sessionKey) {
@@ -218,7 +218,7 @@ export function createAgentEventHandler({
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
const clientRunId = chatLink?.clientRunId ?? evt.runId;
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (jobState === "done" || jobState === "error") {
} else if (lifecyclePhase === "end" || lifecyclePhase === "error") {
if (chatLink) {
const finished = chatRunState.registry.shift(evt.runId);
if (!finished) {
@@ -229,7 +229,7 @@ export function createAgentEventHandler({
finished.sessionKey,
finished.clientRunId,
evt.seq,
jobState,
lifecyclePhase === "error" ? "error" : "done",
evt.data?.error,
);
} else {
@@ -237,14 +237,14 @@ export function createAgentEventHandler({
sessionKey,
evt.runId,
evt.seq,
jobState,
lifecyclePhase === "error" ? "error" : "done",
evt.data?.error,
);
}
}
}
if (jobState === "done" || jobState === "error") {
if (lifecyclePhase === "end" || lifecyclePhase === "error") {
clearAgentRunContext(evt.runId);
}
};
+42 -50
View File
@@ -1,50 +1,48 @@
import { onAgentEvent } from "../../infra/agent-events.js";
const AGENT_JOB_CACHE_TTL_MS = 10 * 60_000;
const agentJobCache = new Map<string, AgentJobSnapshot>();
const AGENT_RUN_CACHE_TTL_MS = 10 * 60_000;
const agentRunCache = new Map<string, AgentRunSnapshot>();
const agentRunStarts = new Map<string, number>();
let agentJobListenerStarted = false;
let agentRunListenerStarted = false;
type AgentJobSnapshot = {
type AgentRunSnapshot = {
runId: string;
state: "done" | "error";
status: "ok" | "error";
startedAt?: number;
endedAt?: number;
error?: string;
ts: number;
};
function pruneAgentJobCache(now = Date.now()) {
for (const [runId, entry] of agentJobCache) {
if (now - entry.ts > AGENT_JOB_CACHE_TTL_MS) {
agentJobCache.delete(runId);
function pruneAgentRunCache(now = Date.now()) {
for (const [runId, entry] of agentRunCache) {
if (now - entry.ts > AGENT_RUN_CACHE_TTL_MS) {
agentRunCache.delete(runId);
}
}
}
function recordAgentJobSnapshot(entry: AgentJobSnapshot) {
pruneAgentJobCache(entry.ts);
agentJobCache.set(entry.runId, entry);
function recordAgentRunSnapshot(entry: AgentRunSnapshot) {
pruneAgentRunCache(entry.ts);
agentRunCache.set(entry.runId, entry);
}
function ensureAgentJobListener() {
if (agentJobListenerStarted) return;
agentJobListenerStarted = true;
function ensureAgentRunListener() {
if (agentRunListenerStarted) return;
agentRunListenerStarted = true;
onAgentEvent((evt) => {
if (!evt) return;
if (evt.stream !== "job") return;
const state = evt.data?.state;
if (state === "started") {
if (evt.stream !== "lifecycle") return;
const phase = evt.data?.phase;
if (phase === "start") {
const startedAt =
typeof evt.data?.startedAt === "number"
? (evt.data.startedAt as number)
: undefined;
if (startedAt !== undefined) {
agentRunStarts.set(evt.runId, startedAt);
}
agentRunStarts.set(evt.runId, startedAt ?? Date.now());
return;
}
if (state !== "done" && state !== "error") return;
if (phase !== "end" && phase !== "error") return;
const startedAt =
typeof evt.data?.startedAt === "number"
? (evt.data.startedAt as number)
@@ -58,9 +56,9 @@ function ensureAgentJobListener() {
? (evt.data.error as string)
: undefined;
agentRunStarts.delete(evt.runId);
recordAgentJobSnapshot({
recordAgentRunSnapshot({
runId: evt.runId,
state: state === "error" ? "error" : "done",
status: phase === "error" ? "error" : "ok",
startedAt,
endedAt,
error,
@@ -69,34 +67,24 @@ function ensureAgentJobListener() {
});
}
function matchesAfterMs(entry: AgentJobSnapshot, afterMs?: number) {
if (afterMs === undefined) return true;
if (typeof entry.startedAt === "number") return entry.startedAt >= afterMs;
if (typeof entry.endedAt === "number") return entry.endedAt >= afterMs;
return false;
}
function getCachedAgentJob(runId: string, afterMs?: number) {
pruneAgentJobCache();
const cached = agentJobCache.get(runId);
if (!cached) return undefined;
return matchesAfterMs(cached, afterMs) ? cached : undefined;
function getCachedAgentRun(runId: string) {
pruneAgentRunCache();
return agentRunCache.get(runId);
}
export async function waitForAgentJob(params: {
runId: string;
afterMs?: number;
timeoutMs: number;
}): Promise<AgentJobSnapshot | null> {
const { runId, afterMs, timeoutMs } = params;
ensureAgentJobListener();
const cached = getCachedAgentJob(runId, afterMs);
}): Promise<AgentRunSnapshot | null> {
const { runId, timeoutMs } = params;
ensureAgentRunListener();
const cached = getCachedAgentRun(runId);
if (cached) return cached;
if (timeoutMs <= 0) return null;
return await new Promise((resolve) => {
let settled = false;
const finish = (entry: AgentJobSnapshot | null) => {
const finish = (entry: AgentRunSnapshot | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
@@ -104,10 +92,15 @@ export async function waitForAgentJob(params: {
resolve(entry);
};
const unsubscribe = onAgentEvent((evt) => {
if (!evt || evt.stream !== "job") return;
if (!evt || evt.stream !== "lifecycle") return;
if (evt.runId !== runId) return;
const state = evt.data?.state;
if (state !== "done" && state !== "error") return;
const phase = evt.data?.phase;
if (phase !== "end" && phase !== "error") return;
const cached = getCachedAgentRun(runId);
if (cached) {
finish(cached);
return;
}
const startedAt =
typeof evt.data?.startedAt === "number"
? (evt.data.startedAt as number)
@@ -120,20 +113,19 @@ export async function waitForAgentJob(params: {
typeof evt.data?.error === "string"
? (evt.data.error as string)
: undefined;
const snapshot: AgentJobSnapshot = {
const snapshot: AgentRunSnapshot = {
runId: evt.runId,
state: state === "error" ? "error" : "done",
status: phase === "error" ? "error" : "ok",
startedAt,
endedAt,
error,
ts: Date.now(),
};
recordAgentJobSnapshot(snapshot);
if (!matchesAfterMs(snapshot, afterMs)) return;
recordAgentRunSnapshot(snapshot);
finish(snapshot);
});
const timer = setTimeout(() => finish(null), Math.max(1, timeoutMs));
});
}
ensureAgentJobListener();
ensureAgentRunListener();
+1 -6
View File
@@ -288,10 +288,6 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const p = params as AgentWaitParams;
const runId = p.runId.trim();
const afterMs =
typeof p.afterMs === "number" && Number.isFinite(p.afterMs)
? Math.max(0, Math.floor(p.afterMs))
: undefined;
const timeoutMs =
typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs)
? Math.max(0, Math.floor(p.timeoutMs))
@@ -299,7 +295,6 @@ export const agentHandlers: GatewayRequestHandlers = {
const snapshot = await waitForAgentJob({
runId,
afterMs,
timeoutMs,
});
if (!snapshot) {
@@ -311,7 +306,7 @@ export const agentHandlers: GatewayRequestHandlers = {
}
respond(true, {
runId,
status: snapshot.state === "done" ? "ok" : "error",
status: snapshot.status,
startedAt: snapshot.startedAt,
endedAt: snapshot.endedAt,
error: snapshot.error,
+68 -44
View File
@@ -463,8 +463,8 @@ describe("gateway server agent", () => {
});
emitAgentEvent({
runId: "run-auto-1",
stream: "job",
data: { state: "done" },
stream: "lifecycle",
data: { phase: "end" },
});
const evt = await finalChatP;
@@ -518,21 +518,20 @@ describe("gateway server agent", () => {
await server.close();
});
test("agent.wait resolves after job completes", async () => {
test("agent.wait resolves after lifecycle end", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-1",
afterMs: 100,
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-1",
stream: "job",
data: { state: "done", startedAt: 200, endedAt: 210 },
stream: "lifecycle",
data: { phase: "end", startedAt: 200, endedAt: 210 },
});
}, 10);
@@ -545,14 +544,14 @@ describe("gateway server agent", () => {
await server.close();
});
test("agent.wait resolves when job completed before wait call", async () => {
test("agent.wait resolves when lifecycle ended before wait call", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
emitAgentEvent({
runId: "run-wait-early",
stream: "job",
data: { state: "done", startedAt: 50, endedAt: 55 },
stream: "lifecycle",
data: { phase: "end", startedAt: 50, endedAt: 55 },
});
const res = await rpcReq(ws, "agent.wait", {
@@ -567,41 +566,7 @@ describe("gateway server agent", () => {
await server.close();
});
test("agent.wait ignores jobs before afterMs", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-2",
afterMs: 500,
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-2",
stream: "job",
data: { state: "done", startedAt: 200, endedAt: 220 },
});
}, 10);
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-2",
stream: "job",
data: { state: "done", startedAt: 700, endedAt: 710 },
});
}, 20);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(700);
ws.close();
await server.close();
});
test("agent.wait times out when no job completes", async () => {
test("agent.wait times out when no lifecycle ends", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -615,4 +580,63 @@ describe("gateway server agent", () => {
ws.close();
await server.close();
});
test("agent.wait returns error on lifecycle error", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-err",
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-err",
stream: "lifecycle",
data: { phase: "error", error: "boom" },
});
}, 10);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("error");
expect(res.payload.error).toBe("boom");
ws.close();
await server.close();
});
test("agent.wait uses lifecycle start timestamp when end omits it", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-start",
timeoutMs: 1000,
});
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "start", startedAt: 123 },
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "end", endedAt: 456 },
});
}, 10);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(123);
expect(res.payload.endedAt).toBe(456);
ws.close();
await server.close();
});
});
+4 -4
View File
@@ -831,8 +831,8 @@ describe("gateway server chat", () => {
emitAgentEvent({
runId: "sess-main",
stream: "job",
data: { state: "done" },
stream: "lifecycle",
data: { phase: "end" },
});
const final1 = await final1P;
@@ -853,8 +853,8 @@ describe("gateway server chat", () => {
emitAgentEvent({
runId: "sess-main",
stream: "job",
data: { state: "done" },
stream: "lifecycle",
data: { phase: "end" },
});
const final2 = await final2P;
+2 -2
View File
@@ -173,9 +173,9 @@ describe("gateway server health/presence", () => {
o.type === "event" &&
o.event === "agent" &&
o.payload?.runId === runId &&
o.payload?.stream === "job",
o.payload?.stream === "lifecycle",
);
emitAgentEvent({ runId, stream: "job", data: { msg: "hi" } });
emitAgentEvent({ runId, stream: "lifecycle", data: { msg: "hi" } });
const evt = await evtPromise;
expect(evt.payload.runId).toBe(runId);
expect(typeof evt.seq).toBe("number");
+4 -4
View File
@@ -699,8 +699,8 @@ describe("gateway server node/bridge", () => {
runId: "sess-main",
seq: 2,
ts: Date.now(),
stream: "job",
data: { state: "done" },
stream: "lifecycle",
data: { phase: "end" },
});
await new Promise((r) => setTimeout(r, 25));
@@ -841,8 +841,8 @@ describe("gateway server node/bridge", () => {
runId: "sess-main",
seq: 2,
ts: Date.now(),
stream: "job",
data: { state: "done" },
stream: "lifecycle",
data: { phase: "end" },
});
const evt = await finalChatP;
+5 -6
View File
@@ -14,7 +14,7 @@ import {
installGatewayTestHooks();
describe("sessions_send gateway loopback", () => {
it("returns reply when job finishes before agent.wait", async () => {
it("returns reply when lifecycle ends before agent.wait", async () => {
const port = await getFreePort();
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
@@ -35,8 +35,8 @@ describe("sessions_send gateway loopback", () => {
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "job",
data: { state: "started", startedAt, sessionId },
stream: "lifecycle",
data: { phase: "start", startedAt },
});
let text = "pong";
@@ -60,12 +60,11 @@ describe("sessions_send gateway loopback", () => {
emitAgentEvent({
runId,
stream: "job",
stream: "lifecycle",
data: {
state: "done",
phase: "end",
startedAt,
endedAt: Date.now(),
sessionId,
},
});
});
+3 -7
View File
@@ -121,13 +121,9 @@ export function summarizeAgentEventForWsLog(
return extra;
}
if (stream === "job") {
const state = typeof data.state === "string" ? data.state : undefined;
if (state) extra.state = state;
if (data.to === null) extra.to = null;
else if (typeof data.to === "string") extra.to = data.to;
if (typeof data.durationMs === "number")
extra.ms = Math.round(data.durationMs);
if (stream === "lifecycle") {
const phase = typeof data.phase === "string" ? data.phase : undefined;
if (phase) extra.phase = phase;
if (typeof data.aborted === "boolean") extra.aborted = data.aborted;
const error = typeof data.error === "string" ? data.error : undefined;
if (error?.trim()) extra.error = compactPreview(error, 120);
+7 -3
View File
@@ -14,9 +14,13 @@ let notificationHandler:
| undefined;
let closeResolve: (() => void) | undefined;
vi.mock("../config/config.js", () => ({
loadConfig: () => config,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => config,
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
+7 -3
View File
@@ -5,9 +5,13 @@ import { sendMessageIMessage } from "./send.js";
const requestMock = vi.fn();
const stopMock = vi.fn();
vi.mock("../config/config.js", () => ({
loadConfig: () => ({}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({}),
};
});
vi.mock("./client.js", () => ({
createIMessageRpcClient: vi.fn().mockResolvedValue({
+2
View File
@@ -27,6 +27,7 @@ import {
PortInUseError,
} from "./infra/ports.js";
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
import { isUnhandledRejectionHandled } from "./infra/unhandled-rejections.js";
import { enableConsoleCapture } from "./logging.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js";
import { monitorWebProvider } from "./provider-web.js";
@@ -79,6 +80,7 @@ if (isMain) {
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
// These log the error and exit gracefully instead of crashing without trace.
process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) return;
console.error(
"[clawdbot] Unhandled promise rejection:",
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
+4 -4
View File
@@ -25,10 +25,10 @@ describe("agent-events sequencing", () => {
list.push(evt.seq);
});
emitAgentEvent({ runId: "run-1", stream: "job", data: {} });
emitAgentEvent({ runId: "run-1", stream: "job", data: {} });
emitAgentEvent({ runId: "run-2", stream: "job", data: {} });
emitAgentEvent({ runId: "run-1", stream: "job", data: {} });
emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: {} });
emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: {} });
emitAgentEvent({ runId: "run-2", stream: "lifecycle", data: {} });
emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: {} });
stop();
+1 -1
View File
@@ -1,5 +1,5 @@
export type AgentEventStream =
| "job"
| "lifecycle"
| "tool"
| "assistant"
| "error"
+26
View File
@@ -0,0 +1,26 @@
type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set<UnhandledRejectionHandler>();
export function registerUnhandledRejectionHandler(
handler: UnhandledRejectionHandler,
): () => void {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
}
export function isUnhandledRejectionHandled(reason: unknown): boolean {
for (const handler of handlers) {
try {
if (handler(reason)) return true;
} catch (err) {
console.error(
"[clawdbot] Unhandled rejection handler failed:",
err instanceof Error ? (err.stack ?? err.message) : err,
);
}
}
return false;
}
+4 -7
View File
@@ -32,13 +32,6 @@ async function main() {
process.exit(0);
}
if (process.env.CLAWDBOT_SMOKE_QR === "1") {
const { renderQrPngBase64 } = await import("../web/qr-image.js");
await renderQrPngBase64("clawdbot-smoke");
console.log("smoke: qr ok");
return;
}
await patchBunLongForProtobuf();
const { loadDotEnv } = await import("../infra/dotenv.js");
@@ -52,11 +45,15 @@ async function main() {
const { assertSupportedRuntime } = await import("../infra/runtime-guard.js");
assertSupportedRuntime();
const { isUnhandledRejectionHandled } = await import(
"../infra/unhandled-rejections.js"
);
const { buildProgram } = await import("../cli/program.js");
const program = buildProgram();
process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) return;
console.error(
"[clawdbot] Unhandled promise rejection:",
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
+7 -3
View File
@@ -31,9 +31,13 @@ vi.mock("@grammyjs/transformer-throttler", () => ({
apiThrottler: () => throttlerSpy(),
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({}),
};
});
vi.mock("../auto-reply/reply.js", () => {
const replySpy = vi.fn(async (_ctx, opts) => {
+7 -3
View File
@@ -5,9 +5,13 @@ import { createTelegramBot } from "./bot.js";
const { loadConfig } = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
}));
vi.mock("../config/config.js", () => ({
loadConfig,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig,
};
});
const useSpy = vi.fn();
const onSpy = vi.fn();
+14 -21
View File
@@ -1,15 +1,4 @@
import {
Editor,
isAltEnter,
isCtrlC,
isCtrlD,
isCtrlL,
isCtrlO,
isCtrlP,
isCtrlT,
isEscape,
isShiftTab,
} from "@mariozechner/pi-tui";
import { Editor, Key, matchesKey } from "@mariozechner/pi-tui";
export class CustomEditor extends Editor {
onEscape?: () => void;
@@ -23,39 +12,43 @@ export class CustomEditor extends Editor {
onAltEnter?: () => void;
handleInput(data: string): void {
if (isAltEnter(data) && this.onAltEnter) {
if (matchesKey(data, Key.alt("enter")) && this.onAltEnter) {
this.onAltEnter();
return;
}
if (isCtrlL(data) && this.onCtrlL) {
if (matchesKey(data, Key.ctrl("l")) && this.onCtrlL) {
this.onCtrlL();
return;
}
if (isCtrlO(data) && this.onCtrlO) {
if (matchesKey(data, Key.ctrl("o")) && this.onCtrlO) {
this.onCtrlO();
return;
}
if (isCtrlP(data) && this.onCtrlP) {
if (matchesKey(data, Key.ctrl("p")) && this.onCtrlP) {
this.onCtrlP();
return;
}
if (isCtrlT(data) && this.onCtrlT) {
if (matchesKey(data, Key.ctrl("t")) && this.onCtrlT) {
this.onCtrlT();
return;
}
if (isShiftTab(data) && this.onShiftTab) {
if (matchesKey(data, Key.shift("tab")) && this.onShiftTab) {
this.onShiftTab();
return;
}
if (isEscape(data) && this.onEscape && !this.isShowingAutocomplete()) {
if (
matchesKey(data, Key.escape) &&
this.onEscape &&
!this.isShowingAutocomplete()
) {
this.onEscape();
return;
}
if (isCtrlC(data) && this.onCtrlC) {
if (matchesKey(data, Key.ctrl("c")) && this.onCtrlC) {
this.onCtrlC();
return;
}
if (isCtrlD(data)) {
if (matchesKey(data, Key.ctrl("d"))) {
if (this.getText().length === 0 && this.onCtrlD) {
this.onCtrlD();
}
+5 -5
View File
@@ -375,11 +375,11 @@ export async function runTui(opts: TuiOptions) {
tui.requestRender();
return;
}
if (evt.stream === "job") {
const state = typeof evt.data?.state === "string" ? evt.data.state : "";
if (state === "started") setStatus("running");
if (state === "done") setStatus("idle");
if (state === "error") setStatus("error");
if (evt.stream === "lifecycle") {
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
if (phase === "start") setStatus("running");
if (phase === "end") setStatus("idle");
if (phase === "error") setStatus("error");
tui.requestRender();
}
};
+82 -16
View File
@@ -1,5 +1,4 @@
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import {
normalizeGroupActivation,
@@ -26,6 +25,7 @@ import {
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { createSubsystemLogger, getChildLogger } from "../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
@@ -49,6 +49,45 @@ const whatsappInboundLog = whatsappLog.child("inbound");
const whatsappOutboundLog = whatsappLog.child("outbound");
const whatsappHeartbeatLog = whatsappLog.child("heartbeat");
const isLikelyWhatsAppCryptoError = (reason: unknown) => {
const formatReason = (value: unknown): string => {
if (value == null) return "";
if (typeof value === "string") return value;
if (value instanceof Error) {
return `${value.message}\n${value.stack ?? ""}`;
}
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return Object.prototype.toString.call(value);
}
}
if (typeof value === "number") return String(value);
if (typeof value === "boolean") return String(value);
if (typeof value === "bigint") return String(value);
if (typeof value === "symbol") return value.description ?? value.toString();
if (typeof value === "function")
return value.name ? `[function ${value.name}]` : "[function]";
return Object.prototype.toString.call(value);
};
const raw =
reason instanceof Error
? `${reason.message}\n${reason.stack ?? ""}`
: formatReason(reason);
const haystack = raw.toLowerCase();
const hasAuthError =
haystack.includes("unsupported state or unable to authenticate data") ||
haystack.includes("bad mac");
if (!hasAuthError) return false;
return (
haystack.includes("@whiskeysockets/baileys") ||
haystack.includes("baileys") ||
haystack.includes("noise-handler") ||
haystack.includes("aesdecryptgcm")
);
};
// Send via the active gateway-backed listener. The monitor already owns the single
// Baileys session, so use its send API directly.
async function sendWithIpcFallback(
@@ -849,23 +888,35 @@ export async function monitorWebProvider(
);
};
const resolveCommandAllowFrom = () => {
const resolveOwnerList = (selfE164?: string | null) => {
const allowFrom = mentionConfig.allowFrom;
const raw =
Array.isArray(allowFrom) && allowFrom.length > 0 ? allowFrom : [];
Array.isArray(allowFrom) && allowFrom.length > 0
? allowFrom
: selfE164
? [selfE164]
: [];
return raw
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry));
};
const isCommandAuthorized = (msg: WebInboundMsg) => {
const allowFrom = resolveCommandAllowFrom();
if (allowFrom.length === 0) return true;
if (mentionConfig.allowFrom?.includes("*")) return true;
const isOwnerSender = (msg: WebInboundMsg) => {
const sender = normalizeE164(msg.senderE164 ?? "");
if (!sender) return false;
return allowFrom.includes(sender);
const owners = resolveOwnerList(msg.selfE164 ?? undefined);
return owners.includes(sender);
};
const isStatusCommand = (body: string) => {
const trimmed = body.trim().toLowerCase();
if (!trimmed) return false;
return (
trimmed === "/status" ||
trimmed === "status" ||
trimmed.startsWith("/status ")
);
};
const stripMentionsForCommand = (text: string, selfE164?: string | null) => {
@@ -912,6 +963,7 @@ export async function monitorWebProvider(
let lastMessageAt: number | null = null;
let handledMessages = 0;
let _lastInboundMsg: WebInboundMsg | null = null;
let unregisterUnhandled: (() => void) | null = null;
// Watchdog to detect stuck message processing (e.g., event emitter died)
// Should be significantly longer than the reply heartbeat interval to avoid false positives
@@ -1182,7 +1234,6 @@ export async function monitorWebProvider(
SenderName: msg.senderName,
SenderE164: msg.senderE164,
WasMentioned: msg.wasMentioned,
CommandAuthorized: isCommandAuthorized(msg),
Surface: "whatsapp",
},
{
@@ -1323,15 +1374,12 @@ export async function monitorWebProvider(
noteGroupMember(conversationId, msg.senderE164, msg.senderName);
const commandBody = stripMentionsForCommand(msg.body, msg.selfE164);
const activationCommand = parseActivationCommand(commandBody);
const commandAuthorized = isCommandAuthorized(msg);
const statusCommand = hasControlCommand(commandBody);
const hasAnyMention = (msg.mentionedJids?.length ?? 0) > 0;
const isOwner = isOwnerSender(msg);
const statusCommand = isStatusCommand(commandBody);
const shouldBypassMention =
commandAuthorized &&
(activationCommand.hasCommand || statusCommand) &&
!hasAnyMention;
isOwner && (activationCommand.hasCommand || statusCommand);
if (activationCommand.hasCommand && !commandAuthorized) {
if (activationCommand.hasCommand && !isOwner) {
logVerbose(
`Ignoring /activation from non-owner in group ${conversationId}`,
);
@@ -1393,9 +1441,27 @@ export async function monitorWebProvider(
);
setActiveWebListener(listener);
unregisterUnhandled = registerUnhandledRejectionHandler((reason) => {
if (!isLikelyWhatsAppCryptoError(reason)) return false;
const errorStr = formatError(reason);
reconnectLogger.warn(
{ connectionId, error: errorStr },
"web reconnect: unhandled rejection from WhatsApp socket; forcing reconnect",
);
listener.signalClose?.({
status: 499,
isLoggedOut: false,
error: reason,
});
return true;
});
const closeListener = async () => {
setActiveWebListener(null);
if (unregisterUnhandled) {
unregisterUnhandled();
unregisterUnhandled = null;
}
if (heartbeat) clearInterval(heartbeat);
if (watchdogTimer) clearInterval(watchdogTimer);
if (backgroundTasks.size > 0) {
+16 -12
View File
@@ -5,18 +5,22 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn().mockReturnValue({
whatsapp: {
allowFrom: ["*"], // Allow all in tests
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
},
}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: vi.fn().mockReturnValue({
whatsapp: {
allowFrom: ["*"], // Allow all in tests
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
},
}),
};
});
const HOME = path.join(
os.tmpdir(),
+7 -3
View File
@@ -20,9 +20,13 @@ const mockLoadConfig = vi.fn().mockReturnValue({
},
});
vi.mock("../config/config.js", () => ({
loadConfig: () => mockLoadConfig(),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => mockLoadConfig(),
};
});
vi.mock("./session.js", () => {
const { EventEmitter } = require("node:events");
+11 -7
View File
@@ -31,13 +31,17 @@ export function resetLoadConfigMock() {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
}
vi.mock("../config/config.js", () => ({
loadConfig: () => {
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
if (typeof getter === "function") return getter();
return DEFAULT_CONFIG;
},
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => {
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
if (typeof getter === "function") return getter();
return DEFAULT_CONFIG;
},
};
});
vi.mock("../media/store.js", () => ({
saveMediaBuffer: vi
+55 -1
View File
@@ -1,6 +1,12 @@
import path from "node:path";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
import {
loginAnthropic,
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import {
isRemoteEnvironment,
loginAntigravityVpsAware,
@@ -185,6 +191,7 @@ export async function runOnboardingWizard(
message: "Model/auth choice",
options: [
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
{ value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" },
{
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
@@ -224,6 +231,53 @@ export async function runOnboardingWizard(
spin.stop("OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
await prompter.note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
].join("\n")
: [
"Browser will open for OpenAI authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"OpenAI OAuth uses localhost:1455 for the callback.",
].join("\n"),
"OpenAI Codex OAuth",
);
const spin = prompter.progress("Starting OAuth flow…");
try {
const agentDir = resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const provider = "openai-codex" as unknown as OAuthProvider;
await authStorage.login(provider, {
onAuth: async ({ url }) => {
if (isRemote) {
spin.stop("OAuth URL ready");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else {
spin.update("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
onPrompt: async (prompt) => {
const code = await prompter.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return String(code);
},
onProgress: (msg) => spin.update(msg),
});
spin.stop("OpenAI OAuth complete");
} catch (err) {
spin.stop("OpenAI OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "antigravity") {
const isRemote = isRemoteEnvironment();
await prompter.note(
+50
View File
@@ -595,6 +595,56 @@
}
}
.chat-bubble.chat-reading-indicator {
width: fit-content;
padding: 10px 14px;
}
.chat-reading-indicator__dots {
display: inline-flex;
align-items: center;
gap: 6px;
height: 10px;
}
.chat-reading-indicator__dots > span {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--chat-text);
opacity: 0.55;
transform: translateY(0);
animation: chatReadingDot 1.1s ease-in-out infinite;
}
.chat-reading-indicator__dots > span:nth-child(2) {
animation-delay: 0.12s;
}
.chat-reading-indicator__dots > span:nth-child(3) {
animation-delay: 0.24s;
}
@keyframes chatReadingDot {
0%,
80%,
100% {
opacity: 0.45;
transform: translateY(0);
}
40% {
opacity: 0.95;
transform: translateY(-2px);
}
}
@media (prefers-reduced-motion: reduce) {
.chat-reading-indicator__dots > span {
animation: none;
opacity: 0.75;
}
}
.chat-text {
overflow-wrap: anywhere;
word-break: break-word;
+25 -9
View File
@@ -78,15 +78,17 @@ export function renderChat(props: ChatProps) {
<div class="chat-thread" role="log" aria-live="polite">
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${props.messages.map((m) => renderMessage(m))}
${props.stream
? renderMessage(
{
role: "assistant",
content: [{ type: "text", text: props.stream }],
timestamp: Date.now(),
},
{ streaming: true },
)
${props.stream !== null
? props.stream.trim().length > 0
? renderMessage(
{
role: "assistant",
content: [{ type: "text", text: props.stream }],
timestamp: Date.now(),
},
{ streaming: true },
)
: renderReadingIndicator()
: nothing}
</div>
@@ -171,6 +173,20 @@ function resolveSessionOptions(
return result;
}
function renderReadingIndicator() {
return html`
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
<span></span><span></span><span></span>
</span>
</div>
</div>
</div>
`;
}
function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";