Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa2c0229d | ||
|
|
485481895e | ||
|
|
f01784369d | ||
|
|
0a853df6c9 | ||
|
|
25ada49e36 | ||
|
|
234059811c | ||
|
|
7f3f73af1c | ||
|
|
bf6aad1965 | ||
|
|
977467066d | ||
|
|
0c37f27a4a | ||
|
|
cffbe79077 | ||
|
|
bb959684fe | ||
|
|
8e8d07cbf4 | ||
|
|
5f4936dce5 | ||
|
|
a9bcf88bfa | ||
|
|
f24fe4e9cd | ||
|
|
7619534bc0 | ||
|
|
ce68d82dfa | ||
|
|
5163886694 | ||
|
|
d9103b387a | ||
|
|
724354b9f0 | ||
|
|
79561d07a0 | ||
|
|
30038f7d37 | ||
|
|
5431a9c692 | ||
|
|
5aebc07369 | ||
|
|
7c51efe8f8 | ||
|
|
1119f2003e | ||
|
|
9be1a14a08 | ||
|
|
17ef7b3b0e | ||
|
|
0d0da2e297 | ||
|
|
82c16a8bed | ||
|
|
2c0f3a2887 | ||
|
|
bc74e7cd9b | ||
|
|
1545ac0003 | ||
|
|
160fd1d8b6 | ||
|
|
4305472787 | ||
|
|
545f52d7d5 | ||
|
|
a40fd5219c | ||
|
|
48322f7174 | ||
|
|
f3cb41511d | ||
|
|
bce62f8c0f | ||
|
|
995f5959af | ||
|
|
a7d33c06f9 | ||
|
|
ce5fd84432 | ||
|
|
a89204362e | ||
|
|
bcdaba1d48 | ||
|
|
95d9160e27 | ||
|
|
8a31a868c0 | ||
|
|
870473be85 | ||
|
|
b593ccb122 | ||
|
|
35a32b31bd | ||
|
|
5dbbad0452 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+182
-95
@@ -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: {}
|
||||
@@ -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=}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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") };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
[
|
||||
|
||||
@@ -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
@@ -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", () => ({
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
68f18193053997f3dee16de6b0be0bcd97dc70ff8200c77f687479e8b19b78e1
|
||||
||||||| Stash base
|
||||
7daf1cbf58ef395b74c2690c439ac7b3cb536e8eb124baf72ad41da4f542204d
|
||||
+32
-37
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
export type OnboardMode = "local" | "remote";
|
||||
export type AuthChoice =
|
||||
| "oauth"
|
||||
| "openai-codex"
|
||||
| "antigravity"
|
||||
| "apiKey"
|
||||
| "minimax"
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
export type AgentEventStream =
|
||||
| "job"
|
||||
| "lifecycle"
|
||||
| "tool"
|
||||
| "assistant"
|
||||
| "error"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user