Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be37b39782 | ||
|
|
49c35c752c | ||
|
|
25d8043b9d | ||
|
|
f9f4a953fc | ||
|
|
34c3fbc66c | ||
|
|
a9f21b3d3a | ||
|
|
ed5c5629f6 | ||
|
|
868952f958 | ||
|
|
9b9836be71 | ||
|
|
22cd839cb2 | ||
|
|
dc3ac9fa28 | ||
|
|
c874fa9712 | ||
|
|
6b784a9771 | ||
|
|
f8e673cdbc | ||
|
|
ad360b4d18 | ||
|
|
69ba2765de | ||
|
|
31e8ecca10 | ||
|
|
4ca38286d8 | ||
|
|
fbf1c3ca3c | ||
|
|
1a4313c2aa | ||
|
|
a6deb0d9d5 | ||
|
|
b6ea5895b6 | ||
|
|
d66bc65ca6 | ||
|
|
89f85ddeab | ||
|
|
bbb71c9198 | ||
|
|
ae6792522d | ||
|
|
e637bbdfb5 | ||
|
|
869ef0c5ba | ||
|
|
1002c74d9c | ||
|
|
61e60f3b84 | ||
|
|
13b931c006 | ||
|
|
ab49fe0e92 | ||
|
|
d0bc08a934 | ||
|
|
64d21f5ea8 | ||
|
|
0a95d8a840 | ||
|
|
56f3a2de25 | ||
|
|
d8b463d0b3 | ||
|
|
eef3df9fa5 | ||
|
|
837eea4ebd | ||
|
|
55622bac06 | ||
|
|
f172ccfcf6 | ||
|
|
a2a6893566 | ||
|
|
616ee3075c | ||
|
|
c5239f6a8e | ||
|
|
cccd7c7b8e | ||
|
|
8f1132e8ec | ||
|
|
e6477363e9 | ||
|
|
1a4fc8dea6 | ||
|
|
a3daf3d115 | ||
|
|
f3f80509e3 | ||
|
|
7cebe7a506 | ||
|
|
5986175268 | ||
|
|
7630c6dccb | ||
|
|
d63cc1e8a7 | ||
|
|
80bb6b712c | ||
|
|
410b8f223e | ||
|
|
693f152895 | ||
|
|
78a4441ac2 | ||
|
|
c92265a51b | ||
|
|
d5fdda8e28 | ||
|
|
cddf198321 | ||
|
|
e7953d8164 |
No files matched your search
@@ -117,6 +117,7 @@
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
|
||||
+46
-13
@@ -1,40 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.16 (unreleased)
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
||||
|
||||
## 2026.1.16-1
|
||||
|
||||
### Highlights
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos.
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
|
||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
|
||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
|
||||
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** drop legacy target normalization helpers; use outbound target normalization and resolver flows.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
|
||||
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
|
||||
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Tools: add Firecrawl fallback for `web_fetch` when configured.
|
||||
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
|
||||
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
|
||||
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
|
||||
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
|
||||
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
|
||||
- Tools: add `process submit` helper to send CR for PTY sessions.
|
||||
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
|
||||
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
|
||||
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
|
||||
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
|
||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||
- UI: allow deleting sessions from the Control UI.
|
||||
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||
- iMessage: add remote attachment support for VM/SSH deployments.
|
||||
- Messages: refresh live directory cache results when resolving targets.
|
||||
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
|
||||
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
|
||||
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
|
||||
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
||||
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
||||
@@ -43,29 +59,46 @@
|
||||
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
||||
|
||||
### Fixes
|
||||
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
||||
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
|
||||
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||
- Telegram: split long captions into follow-up messages.
|
||||
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
|
||||
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
|
||||
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
|
||||
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
|
||||
- Sessions: preserve overrides on `/new` reset.
|
||||
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
|
||||
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
|
||||
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
|
||||
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
|
||||
- Skills: narrow skills watcher ignores to `.git` so hidden workspaces still refresh. (#1074) — thanks @roshanasingh4.
|
||||
- Build: allow `@lydell/node-pty` builds on supported platforms.
|
||||
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
|
||||
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
|
||||
- Messages: honor message tool channel when deduping sends.
|
||||
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
|
||||
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
|
||||
- Sessions: repair orphaned user turns before embedded prompts.
|
||||
- Sessions: hard-stop `sessions.delete` cleanup.
|
||||
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
||||
- Security: lock down slash/control commands to sender allowlists across Discord/Slack/Telegram/Signal/iMessage/WhatsApp (+ plugin channels like Matrix/Teams) and add stable `clawdbot security audit` checkIds for Slack/Discord command allowlists.
|
||||
- Channels: normalize object-format capabilities in channel capability parsing.
|
||||
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
|
||||
- Security: redact sensitive text in gateway WS logs.
|
||||
- Tools: cap pending `exec` process output to avoid unbounded buffers.
|
||||
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
|
||||
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
|
||||
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
|
||||
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
|
||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
||||
- WhatsApp: include `linked` field in `describeAccount`.
|
||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
||||
- Agents: hide the image tool when the primary model already supports images.
|
||||
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
|
||||
- Auth: inherit/merge sub-agent auth profiles from the main agent.
|
||||
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- OpenAI image-gen: remove deprecated `response_format` and use URL downloads.
|
||||
- iMessage: avoid RPC restart loops.
|
||||
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
|
||||
- CLI: auto-update global installs when installed via a package manager.
|
||||
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
|
||||
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
|
||||
@@ -73,6 +106,7 @@
|
||||
- Models: align ZAI thinking toggles.
|
||||
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
|
||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
||||
- Skills: fix skills watcher ignored list typing (tsc).
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
@@ -113,7 +147,6 @@
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||
- Media: add optional inbound media understanding for image/audio/video with provider + CLI fallbacks. (#1005) — thanks @tristanmanchester.
|
||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
|
||||
@@ -278,8 +278,7 @@ enum GatewayEnvironment {
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning(
|
||||
@@ -294,7 +293,6 @@ enum GatewayEnvironment {
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let raw = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Semver.parse(raw)
|
||||
|
||||
@@ -72,11 +72,11 @@ enum LaunchAgentManager {
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
_ = try process.runAndReadToEnd(from: pipe)
|
||||
return process.terminationStatus
|
||||
} catch {
|
||||
return -1
|
||||
|
||||
@@ -16,9 +16,7 @@ enum Launchctl {
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return Result(status: process.terminationStatus, output: output)
|
||||
} catch {
|
||||
|
||||
@@ -580,11 +580,10 @@ final class NodePairingApprovalPrompter {
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
_ = try process.runAndReadToEnd(from: pipe)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
}.value
|
||||
}
|
||||
|
||||
@@ -203,15 +203,13 @@ actor PortGuardian {
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
let data = try proc.runAndReadToEnd(from: pipe)
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func parseListeners(from text: String) -> [Listener] {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
extension Process {
|
||||
/// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers.
|
||||
func runAndReadToEnd(from pipe: Pipe) throws -> Data {
|
||||
try self.run()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
self.waitUntilExit()
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -133,8 +133,7 @@ enum RuntimeLocator {
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning(
|
||||
@@ -149,7 +148,6 @@ enum RuntimeLocator {
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch {
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
|
||||
@@ -365,6 +365,7 @@ Allowlist matching notes:
|
||||
Native command notes:
|
||||
- The registered commands mirror Clawdbot’s chat commands.
|
||||
- Native commands honor the same allowlists as DMs/guild messages (`channels.discord.dm.allowFrom`, `channels.discord.guilds`, per-channel rules).
|
||||
- Slash commands may still be visible in Discord UI to users who aren’t allowlisted; Clawdbot enforces allowlists on execution and replies “not authorized”.
|
||||
|
||||
## Tool actions
|
||||
The agent can call `discord` with actions like:
|
||||
|
||||
+2
-2
@@ -292,7 +292,7 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@@ -527,7 +527,7 @@ Surfaces:
|
||||
|
||||
Notes:
|
||||
- Data comes directly from provider usage endpoints (no estimates).
|
||||
- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
|
||||
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled.
|
||||
- If no matching credentials exist, usage is hidden.
|
||||
- Details: see [Usage tracking](/concepts/usage-tracking).
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ clawdbot plugins update <id>
|
||||
clawdbot plugins update --all
|
||||
```
|
||||
|
||||
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
|
||||
activate them.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,6 +15,8 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
|
||||
|
||||
```bash
|
||||
clawdbot update
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --tag beta
|
||||
clawdbot update --restart
|
||||
clawdbot update --json
|
||||
clawdbot --update
|
||||
@@ -23,9 +25,13 @@ clawdbot --update
|
||||
## Options
|
||||
|
||||
- `--restart`: restart the Gateway daemon after a successful update.
|
||||
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
Note: downgrades require confirmation because older versions can break configuration.
|
||||
|
||||
## What it does (git checkout)
|
||||
|
||||
High-level:
|
||||
|
||||
@@ -83,7 +83,12 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
|
||||
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
|
||||
- CLI: `clawdbot onboard --auth-choice antigravity` (others via interactive wizard)
|
||||
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-antigravity-auth`
|
||||
- Login: `clawdbot models auth login --provider google-antigravity --set-default`
|
||||
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
||||
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
### Z.AI (GLM)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ When spawning long-running child processes outside the exec/process tools (for e
|
||||
Environment overrides:
|
||||
- `PI_BASH_YIELD_MS`: default yield (ms)
|
||||
- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars)
|
||||
- `CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars)
|
||||
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
|
||||
|
||||
Config (preferred):
|
||||
|
||||
@@ -50,6 +50,22 @@ pnpm add -g clawdbot@latest
|
||||
```
|
||||
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
|
||||
|
||||
To stay on the beta channel for CLI updates:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel beta
|
||||
```
|
||||
|
||||
Switch back to stable later:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel stable
|
||||
```
|
||||
|
||||
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
||||
|
||||
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
@@ -75,7 +91,7 @@ It runs a safe-ish update flow:
|
||||
- Fetches + rebases against the configured upstream.
|
||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||
|
||||
## Update (Control UI / RPC)
|
||||
|
||||
|
||||
@@ -62,8 +62,25 @@ read_when:
|
||||
}
|
||||
```
|
||||
|
||||
### Provider-only (Deepgram)
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes & limits
|
||||
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
|
||||
- Deepgram picks up `DEEPGRAM_API_KEY` when `provider: "deepgram"` is used.
|
||||
- Deepgram setup details: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
||||
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
||||
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
||||
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||
|
||||
@@ -32,6 +32,8 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
|
||||
- `tools.media.models`: shared model list (use `capabilities` to gate).
|
||||
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
|
||||
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
|
||||
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
|
||||
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
|
||||
- optional **per‑capability `models` list** (preferred before shared models)
|
||||
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
|
||||
- `scope` (optional gating by channel/chatType/session key)
|
||||
@@ -108,6 +110,7 @@ lists, Clawdbot can infer defaults:
|
||||
- `openai`, `anthropic`, `minimax`: **image**
|
||||
- `google` (Gemini API): **image + audio + video**
|
||||
- `groq`: **audio**
|
||||
- `deepgram`: **audio**
|
||||
|
||||
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
|
||||
If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
@@ -116,7 +119,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
| Capability | Provider integration | Notes |
|
||||
|------------|----------------------|-------|
|
||||
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
||||
| Audio | OpenAI, Groq | Provider transcription (Whisper). |
|
||||
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
|
||||
| Video | Google (Gemini API) | Provider video understanding. |
|
||||
|
||||
## Recommended providers
|
||||
@@ -125,8 +128,9 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
||||
|
||||
**Audio**
|
||||
- `openai/whisper-1` or `groq/whisper-large-v3-turbo`.
|
||||
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper` binary.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
|
||||
|
||||
+18
-5
@@ -41,6 +41,9 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
|
||||
|
||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||
register:
|
||||
@@ -58,16 +61,26 @@ Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
|
||||
Clawdbot scans, in order:
|
||||
|
||||
1) Global extensions
|
||||
- `~/.clawdbot/extensions/*.ts`
|
||||
- `~/.clawdbot/extensions/*/index.ts`
|
||||
1) Config paths
|
||||
- `plugins.load.paths` (file or directory)
|
||||
|
||||
2) Workspace extensions
|
||||
- `<workspace>/.clawdbot/extensions/*.ts`
|
||||
- `<workspace>/.clawdbot/extensions/*/index.ts`
|
||||
|
||||
3) Config paths
|
||||
- `plugins.load.paths` (file or directory)
|
||||
3) Global extensions
|
||||
- `~/.clawdbot/extensions/*.ts`
|
||||
- `~/.clawdbot/extensions/*/index.ts`
|
||||
|
||||
4) Bundled extensions (shipped with Clawdbot, **disabled by default**)
|
||||
- `<clawdbot>/extensions/*`
|
||||
|
||||
Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
|
||||
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
|
||||
but can be disabled the same way.
|
||||
|
||||
If multiple plugins resolve to the same id, the first match in the order above
|
||||
wins and lower-precedence copies are ignored.
|
||||
|
||||
### Package packs
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
summary: "Deepgram transcription for inbound voice notes"
|
||||
read_when:
|
||||
- You want Deepgram speech-to-text for audio attachments
|
||||
- You need a quick Deepgram config example
|
||||
---
|
||||
# Deepgram (Audio Transcription)
|
||||
|
||||
Deepgram is a speech-to-text API. In Clawdbot it is used for **inbound audio/voice note
|
||||
transcription** via `tools.media.audio`.
|
||||
|
||||
When enabled, Clawdbot uploads the audio file to Deepgram and injects the transcript
|
||||
into the reply pipeline (`{{Transcript}}` + `[Audio]` block). This is **not streaming**;
|
||||
it uses the pre-recorded transcription endpoint.
|
||||
|
||||
Website: https://deepgram.com
|
||||
Docs: https://developers.deepgram.com
|
||||
|
||||
## Quick start
|
||||
|
||||
1) Set your API key:
|
||||
```
|
||||
DEEPGRAM_API_KEY=dg_...
|
||||
```
|
||||
|
||||
2) Enable the provider:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `model`: Deepgram model id (default: `nova-3`)
|
||||
- `language`: language hint (optional)
|
||||
- `tools.media.audio.providerOptions.deepgram.detect_language`: enable language detection (optional)
|
||||
- `tools.media.audio.providerOptions.deepgram.punctuate`: enable punctuation (optional)
|
||||
- `tools.media.audio.providerOptions.deepgram.smart_format`: enable smart formatting (optional)
|
||||
|
||||
Example with language:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "deepgram", model: "nova-3", language: "en" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example with Deepgram options:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
providerOptions: {
|
||||
deepgram: {
|
||||
detect_language: true,
|
||||
punctuate: true,
|
||||
smart_format: true
|
||||
}
|
||||
},
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Authentication follows the standard provider auth order; `DEEPGRAM_API_KEY` is the simplest path.
|
||||
- Override endpoints or headers with `tools.media.audio.baseUrl` and `tools.media.audio.headers` when using a proxy.
|
||||
- Output follows the same audio rules as other providers (size caps, timeouts, transcript injection).
|
||||
@@ -34,5 +34,9 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
|
||||
## Transcription providers
|
||||
|
||||
- [Deepgram (audio transcription)](/providers/deepgram)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
@@ -20,6 +20,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
2) **Build & artifacts**
|
||||
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
||||
- [ ] `pnpm run build` (regenerates `dist/`).
|
||||
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
|
||||
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
||||
|
||||
3) **Changelog & docs**
|
||||
@@ -51,7 +52,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
- [ ] Confirm git status is clean; commit and push as needed.
|
||||
- [ ] `npm login` (verify 2FA) if needed.
|
||||
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
|
||||
- [ ] Verify the registry: `npm view clawdbot version` and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
|
||||
- [ ] Verify the registry: `npm view clawdbot version`, `npm view clawdbot dist-tags`, and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
|
||||
|
||||
### Troubleshooting (notes from 2.0.0-beta2 release)
|
||||
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/Clawdbot.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/Clawdbot.app` is not listed.
|
||||
|
||||
@@ -290,6 +290,11 @@ Live tests discover credentials the same way the CLI does. Practical implication
|
||||
|
||||
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
|
||||
|
||||
## Deepgram live (audio transcription)
|
||||
|
||||
- Test: `src/media-understanding/providers/deepgram/audio.live.test.ts`
|
||||
- Enable: `DEEPGRAM_API_KEY=... DEEPGRAM_LIVE_TEST=1 pnpm test:live src/media-understanding/providers/deepgram/audio.live.test.ts`
|
||||
|
||||
## Docker runners (optional “works in Linux” checks)
|
||||
|
||||
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted):
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copilot Proxy (Clawdbot plugin)
|
||||
|
||||
Provider plugin for the **Copilot Proxy** VS Code extension.
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable copilot-proxy
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider copilot-proxy --set-default
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Copilot Proxy must be running in VS Code.
|
||||
- Base URL must include `/v1`.
|
||||
@@ -0,0 +1,139 @@
|
||||
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
|
||||
const DEFAULT_API_KEY = "n/a";
|
||||
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
||||
const DEFAULT_MAX_TOKENS = 8192;
|
||||
const DEFAULT_MODEL_IDS = [
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5-mini",
|
||||
"claude-opus-4.5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-haiku-4.5",
|
||||
"gemini-3-pro",
|
||||
"gemini-3-flash",
|
||||
"grok-code-fast-1",
|
||||
] as const;
|
||||
|
||||
function normalizeBaseUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return DEFAULT_BASE_URL;
|
||||
let normalized = trimmed;
|
||||
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
||||
if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateBaseUrl(value: string): string | undefined {
|
||||
const normalized = normalizeBaseUrl(value);
|
||||
try {
|
||||
new URL(normalized);
|
||||
} catch {
|
||||
return "Enter a valid URL";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseModelIds(input: string): string[] {
|
||||
const parsed = input
|
||||
.split(/[\n,]/)
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(parsed));
|
||||
}
|
||||
|
||||
function buildModelDefinition(modelId: string) {
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-completions",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
const copilotProxyPlugin = {
|
||||
id: "copilot-proxy",
|
||||
name: "Copilot Proxy",
|
||||
description: "Local Copilot Proxy (VS Code LM) provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "copilot-proxy",
|
||||
label: "Copilot Proxy",
|
||||
docsPath: "/providers/models",
|
||||
auth: [
|
||||
{
|
||||
id: "local",
|
||||
label: "Local proxy",
|
||||
hint: "Configure base URL + models for the Copilot Proxy server",
|
||||
kind: "custom",
|
||||
run: async (ctx) => {
|
||||
const baseUrlInput = await ctx.prompter.text({
|
||||
message: "Copilot Proxy base URL",
|
||||
initialValue: DEFAULT_BASE_URL,
|
||||
validate: validateBaseUrl,
|
||||
});
|
||||
|
||||
const modelInput = await ctx.prompter.text({
|
||||
message: "Model IDs (comma-separated)",
|
||||
initialValue: DEFAULT_MODEL_IDS.join(", "),
|
||||
validate: (value) =>
|
||||
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
|
||||
});
|
||||
|
||||
const baseUrl = normalizeBaseUrl(baseUrlInput);
|
||||
const modelIds = parseModelIds(modelInput);
|
||||
const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0];
|
||||
const defaultModelRef = `copilot-proxy/${defaultModelId}`;
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: "copilot-proxy:local",
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "copilot-proxy",
|
||||
token: DEFAULT_API_KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
models: {
|
||||
providers: {
|
||||
"copilot-proxy": {
|
||||
baseUrl,
|
||||
apiKey: DEFAULT_API_KEY,
|
||||
api: "openai-completions",
|
||||
authHeader: false,
|
||||
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
models: Object.fromEntries(
|
||||
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: defaultModelRef,
|
||||
notes: [
|
||||
"Start the Copilot Proxy VS Code extension before using these models.",
|
||||
"Copilot Proxy serves /v1/chat/completions; base URL must include /v1.",
|
||||
"Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.",
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default copilotProxyPlugin;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/copilot-proxy",
|
||||
"version": "2026.1.16-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Google Antigravity Auth (Clawdbot plugin)
|
||||
|
||||
OAuth provider plugin for **Google Antigravity** (Cloud Code Assist).
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable google-antigravity-auth
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider google-antigravity --set-default
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Antigravity uses Google Cloud project quotas.
|
||||
- If requests fail, ensure Gemini for Google Cloud is enabled.
|
||||
@@ -0,0 +1,428 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const CLIENT_ID = decode(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||
);
|
||||
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
|
||||
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
const DEFAULT_MODEL = "google-antigravity/claude-opus-4-5-thinking";
|
||||
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
];
|
||||
|
||||
const CODE_ASSIST_ENDPOINTS = [
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
];
|
||||
|
||||
const RESPONSE_PAGE = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Clawdbot Antigravity OAuth</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Authentication complete</h1>
|
||||
<p>You can return to the terminal.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function isWSL(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWSL2(): boolean {
|
||||
if (!isWSL()) return false;
|
||||
try {
|
||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote || isWSL2();
|
||||
}
|
||||
|
||||
function buildAuthUrl(params: { challenge: string; state: string }): string {
|
||||
const url = new URL(AUTH_URL);
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", SCOPES.join(" "));
|
||||
url.searchParams.set("code_challenge", params.challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", params.state);
|
||||
url.searchParams.set("access_type", "offline");
|
||||
url.searchParams.set("prompt", "consent");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseCallbackInput(
|
||||
input: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { error: "No input provided" };
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
||||
if (!state) return { error: "Missing 'state' parameter in URL" };
|
||||
return { code, state };
|
||||
} catch {
|
||||
return { error: "Paste the full redirect URL (not just the code)." };
|
||||
}
|
||||
}
|
||||
|
||||
async function startCallbackServer(params: { timeoutMs: number }) {
|
||||
const redirect = new URL(REDIRECT_URI);
|
||||
const port = redirect.port ? Number(redirect.port) : 51121;
|
||||
|
||||
let settled = false;
|
||||
let resolveCallback: (url: URL) => void;
|
||||
let rejectCallback: (err: Error) => void;
|
||||
|
||||
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
||||
resolveCallback = (url) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(url);
|
||||
};
|
||||
rejectCallback = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
rejectCallback(new Error("Timed out waiting for OAuth callback"));
|
||||
}, params.timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
if (!request.url) {
|
||||
response.writeHead(400, { "Content-Type": "text/plain" });
|
||||
response.end("Missing URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
|
||||
if (url.pathname !== redirect.pathname) {
|
||||
response.writeHead(404, { "Content-Type": "text/plain" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(RESPONSE_PAGE);
|
||||
resolveCallback(url);
|
||||
|
||||
setImmediate(() => {
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
server.off("error", onError);
|
||||
reject(err);
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeCode(params: {
|
||||
code: string;
|
||||
verifier: string;
|
||||
}): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: params.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: params.verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Token exchange failed: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
const access = data.access_token?.trim();
|
||||
const refresh = data.refresh_token?.trim();
|
||||
const expiresIn = data.expires_in ?? 0;
|
||||
|
||||
if (!access) throw new Error("Token exchange returned no access_token");
|
||||
if (!refresh) throw new Error("Token exchange returned no refresh_token");
|
||||
|
||||
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
||||
return { access, refresh, expires };
|
||||
}
|
||||
|
||||
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectId(accessToken: string): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
};
|
||||
|
||||
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) continue;
|
||||
const data = (await response.json()) as {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
};
|
||||
|
||||
if (typeof data.cloudaicompanionProject === "string") {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (
|
||||
data.cloudaicompanionProject &&
|
||||
typeof data.cloudaicompanionProject === "object" &&
|
||||
data.cloudaicompanionProject.id
|
||||
) {
|
||||
return data.cloudaicompanionProject.id;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_PROJECT_ID;
|
||||
}
|
||||
|
||||
async function loginAntigravity(params: {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
}): Promise<{
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
projectId: string;
|
||||
}> {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const state = randomBytes(16).toString("hex");
|
||||
const authUrl = buildAuthUrl({ challenge, state });
|
||||
|
||||
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
|
||||
const needsManual = shouldUseManualOAuthFlow(params.isRemote);
|
||||
if (!needsManual) {
|
||||
try {
|
||||
callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
|
||||
} catch {
|
||||
callbackServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!callbackServer) {
|
||||
await params.note(
|
||||
[
|
||||
"Open the URL in your local browser.",
|
||||
"After signing in, copy the full redirect URL and paste it back here.",
|
||||
"",
|
||||
`Auth URL: ${authUrl}`,
|
||||
`Redirect URI: ${REDIRECT_URI}`,
|
||||
].join("\n"),
|
||||
"Google Antigravity OAuth",
|
||||
);
|
||||
}
|
||||
|
||||
if (!needsManual) {
|
||||
params.progress.update("Opening Google sign-in…");
|
||||
try {
|
||||
await params.openUrl(authUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let code = "";
|
||||
let returnedState = "";
|
||||
|
||||
if (callbackServer) {
|
||||
params.progress.update("Waiting for OAuth callback…");
|
||||
const callback = await callbackServer.waitForCallback();
|
||||
code = callback.searchParams.get("code") ?? "";
|
||||
returnedState = callback.searchParams.get("state") ?? "";
|
||||
await callbackServer.close();
|
||||
} else {
|
||||
params.progress.update("Waiting for redirect URL…");
|
||||
const input = await params.prompt("Paste the redirect URL: ");
|
||||
const parsed = parseCallbackInput(input);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
code = parsed.code;
|
||||
returnedState = parsed.state;
|
||||
}
|
||||
|
||||
if (!code) throw new Error("Missing OAuth code");
|
||||
if (returnedState !== state) {
|
||||
throw new Error("OAuth state mismatch. Please try again.");
|
||||
}
|
||||
|
||||
params.progress.update("Exchanging code for tokens…");
|
||||
const tokens = await exchangeCode({ code, verifier });
|
||||
const email = await fetchUserEmail(tokens.access);
|
||||
const projectId = await fetchProjectId(tokens.access);
|
||||
|
||||
params.progress.stop("Antigravity OAuth complete");
|
||||
return { ...tokens, email, projectId };
|
||||
}
|
||||
|
||||
const antigravityPlugin = {
|
||||
id: "google-antigravity-auth",
|
||||
name: "Google Antigravity Auth",
|
||||
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "google-antigravity",
|
||||
label: "Google Antigravity",
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["antigravity"],
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "Google OAuth",
|
||||
hint: "PKCE + localhost callback",
|
||||
kind: "oauth",
|
||||
run: async (ctx) => {
|
||||
const spin = ctx.prompter.progress("Starting Antigravity OAuth…");
|
||||
try {
|
||||
const result = await loginAntigravity({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
||||
note: ctx.prompter.note,
|
||||
progress: spin,
|
||||
});
|
||||
|
||||
const profileId = `google-antigravity:${result.email ?? "default"}`;
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "google-antigravity",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
projectId: result.projectId,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEFAULT_MODEL]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
notes: [
|
||||
"Antigravity uses Google Cloud project quotas.",
|
||||
"Enable Gemini for Google Cloud on your project if requests fail.",
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
spin.stop("Antigravity OAuth failed");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default antigravityPlugin;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/google-antigravity-auth",
|
||||
"version": "2026.1.16-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Google Gemini CLI Auth (Clawdbot plugin)
|
||||
|
||||
OAuth provider plugin for **Gemini CLI** (Google Code Assist).
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable google-gemini-cli-auth
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider google-gemini-cli --set-default
|
||||
```
|
||||
|
||||
## Env vars
|
||||
|
||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
||||
@@ -0,0 +1,88 @@
|
||||
import { loginGeminiCliOAuth } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "google-gemini-cli";
|
||||
const PROVIDER_LABEL = "Gemini CLI OAuth";
|
||||
const DEFAULT_MODEL = "google-gemini-cli/gemini-3-pro-preview";
|
||||
const ENV_VARS = [
|
||||
"CLAWDBOT_GEMINI_OAUTH_CLIENT_ID",
|
||||
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_ID",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
];
|
||||
|
||||
const geminiCliPlugin = {
|
||||
id: "google-gemini-cli-auth",
|
||||
name: "Google Gemini CLI Auth",
|
||||
description: "OAuth flow for Gemini CLI (Google Code Assist)",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["gemini-cli"],
|
||||
envVars: ENV_VARS,
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "Google OAuth",
|
||||
hint: "PKCE + localhost callback",
|
||||
kind: "oauth",
|
||||
run: async (ctx) => {
|
||||
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
|
||||
try {
|
||||
const result = await loginGeminiCliOAuth({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
log: (msg) => ctx.runtime.log(msg),
|
||||
note: ctx.prompter.note,
|
||||
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
||||
progress: spin,
|
||||
});
|
||||
|
||||
spin.stop("Gemini CLI OAuth complete");
|
||||
const profileId = `google-gemini-cli:${result.email ?? "default"}`;
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: PROVIDER_ID,
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
projectId: result.projectId,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEFAULT_MODEL]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
notes: [
|
||||
"If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
spin.stop("Gemini CLI OAuth failed");
|
||||
await ctx.prompter.note(
|
||||
"Trouble with OAuth? Ensure your Google account has Gemini CLI access.",
|
||||
"OAuth help",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default geminiCliPlugin;
|
||||
@@ -0,0 +1,496 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
||||
const CLIENT_SECRET_KEYS = [
|
||||
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
];
|
||||
const REDIRECT_URI = "http://localhost:8085/oauth2callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
|
||||
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
];
|
||||
|
||||
const TIER_FREE = "free-tier";
|
||||
const TIER_LEGACY = "legacy-tier";
|
||||
const TIER_STANDARD = "standard-tier";
|
||||
|
||||
export type GeminiCliOAuthCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type GeminiCliOAuthContext = {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
log: (msg: string) => void;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
};
|
||||
|
||||
function resolveEnv(keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
||||
const clientId = resolveEnv(CLIENT_ID_KEYS);
|
||||
if (!clientId) {
|
||||
throw new Error(
|
||||
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
|
||||
);
|
||||
}
|
||||
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
function isWSL(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWSL2(): boolean {
|
||||
if (!isWSL()) return false;
|
||||
try {
|
||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote || isWSL2();
|
||||
}
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function buildAuthUrl(challenge: string, verifier: string): string {
|
||||
const { clientId } = resolveOAuthClientConfig();
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES.join(" "),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: verifier,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseCallbackInput(
|
||||
input: string,
|
||||
expectedState: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { error: "No input provided" };
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state") ?? expectedState;
|
||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
||||
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." };
|
||||
return { code, state };
|
||||
} catch {
|
||||
if (!expectedState) return { error: "Paste the full redirect URL, not just the code." };
|
||||
return { code: trimmed, state: expectedState };
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLocalCallback(params: {
|
||||
expectedState: string;
|
||||
timeoutMs: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ code: string; state: string }> {
|
||||
const port = 8085;
|
||||
const hostname = "localhost";
|
||||
const expectedPath = "/oauth2callback";
|
||||
|
||||
return new Promise<{ code: string; state: string }>((resolve, reject) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
|
||||
if (requestUrl.pathname !== expectedPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const error = requestUrl.searchParams.get("error");
|
||||
const code = requestUrl.searchParams.get("code")?.trim();
|
||||
const state = requestUrl.searchParams.get("state")?.trim();
|
||||
|
||||
if (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`Authentication failed: ${error}`);
|
||||
finish(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Missing code or state");
|
||||
finish(new Error("Missing OAuth code or state"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== params.expectedState) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid state");
|
||||
finish(new Error("OAuth state mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(
|
||||
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
|
||||
"<body><h2>Gemini CLI OAuth complete</h2>" +
|
||||
"<p>You can close this window and return to Clawdbot.</p></body></html>",
|
||||
);
|
||||
|
||||
finish(undefined, { code, state });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
server.once("error", (err) => {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(new Error("OAuth callback timeout"));
|
||||
}, params.timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, verifier: string): Promise<GeminiCliOAuthCredentials> {
|
||||
const { clientId, clientSecret } = resolveOAuthClientConfig();
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
if (clientSecret) {
|
||||
body.set("client_secret", clientSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
if (!data.refresh_token) {
|
||||
throw new Error("No refresh token received. Please try again.");
|
||||
}
|
||||
|
||||
const email = await getUserEmail(data.access_token);
|
||||
const projectId = await discoverProject(data.access_token);
|
||||
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
return {
|
||||
refresh: data.refresh_token,
|
||||
access: data.access_token,
|
||||
expires: expiresAt,
|
||||
projectId,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(USERINFO_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function discoverProject(accessToken: string): Promise<string> {
|
||||
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "gl-node/clawdbot",
|
||||
};
|
||||
|
||||
const loadBody = {
|
||||
cloudaicompanionProject: envProject,
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
duetProject: envProject,
|
||||
},
|
||||
};
|
||||
|
||||
let data: {
|
||||
currentTier?: { id?: string };
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(loadBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => null);
|
||||
if (isVpcScAffected(errorPayload)) {
|
||||
data = { currentTier: { id: TIER_STANDARD } };
|
||||
} else {
|
||||
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} else {
|
||||
data = (await response.json()) as typeof data;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error("loadCodeAssist failed");
|
||||
}
|
||||
|
||||
if (data.currentTier) {
|
||||
const project = data.cloudaicompanionProject;
|
||||
if (typeof project === "string" && project) return project;
|
||||
if (typeof project === "object" && project?.id) return project.id;
|
||||
if (envProject) return envProject;
|
||||
throw new Error(
|
||||
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
||||
);
|
||||
}
|
||||
|
||||
const tier = getDefaultTier(data.allowedTiers);
|
||||
const tierId = tier?.id || TIER_FREE;
|
||||
if (tierId !== TIER_FREE && !envProject) {
|
||||
throw new Error(
|
||||
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
||||
);
|
||||
}
|
||||
|
||||
const onboardBody: Record<string, unknown> = {
|
||||
tierId,
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
};
|
||||
if (tierId !== TIER_FREE && envProject) {
|
||||
onboardBody.cloudaicompanionProject = envProject;
|
||||
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
|
||||
}
|
||||
|
||||
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(onboardBody),
|
||||
});
|
||||
|
||||
if (!onboardResponse.ok) {
|
||||
throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`);
|
||||
}
|
||||
|
||||
let lro = (await onboardResponse.json()) as {
|
||||
done?: boolean;
|
||||
name?: string;
|
||||
response?: { cloudaicompanionProject?: { id?: string } };
|
||||
};
|
||||
|
||||
if (!lro.done && lro.name) {
|
||||
lro = await pollOperation(lro.name, headers);
|
||||
}
|
||||
|
||||
const projectId = lro.response?.cloudaicompanionProject?.id;
|
||||
if (projectId) return projectId;
|
||||
if (envProject) return envProject;
|
||||
|
||||
throw new Error(
|
||||
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
|
||||
);
|
||||
}
|
||||
|
||||
function isVpcScAffected(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
const error = (payload as { error?: unknown }).error;
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const details = (error as { details?: unknown[] }).details;
|
||||
if (!Array.isArray(details)) return false;
|
||||
return details.some(
|
||||
(item) =>
|
||||
typeof item === "object" && item && (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED",
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultTier(
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
|
||||
): { id?: string } | undefined {
|
||||
if (!allowedTiers?.length) return { id: TIER_LEGACY };
|
||||
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
|
||||
}
|
||||
|
||||
async function pollOperation(
|
||||
operationName: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
|
||||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) continue;
|
||||
const data = (await response.json()) as {
|
||||
done?: boolean;
|
||||
response?: { cloudaicompanionProject?: { id?: string } };
|
||||
};
|
||||
if (data.done) return data;
|
||||
}
|
||||
throw new Error("Operation polling timeout");
|
||||
}
|
||||
|
||||
export async function loginGeminiCliOAuth(ctx: GeminiCliOAuthContext): Promise<GeminiCliOAuthCredentials> {
|
||||
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
|
||||
await ctx.note(
|
||||
needsManual
|
||||
? [
|
||||
"You are running in a remote/VPS environment.",
|
||||
"A URL will be shown for you to open in your LOCAL browser.",
|
||||
"After signing in, copy the redirect URL and paste it back here.",
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for Google authentication.",
|
||||
"Sign in with your Google account for Gemini CLI access.",
|
||||
"The callback will be captured automatically on localhost:8085.",
|
||||
].join("\n"),
|
||||
"Gemini CLI OAuth",
|
||||
);
|
||||
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const authUrl = buildAuthUrl(challenge, verifier);
|
||||
|
||||
if (needsManual) {
|
||||
ctx.progress.update("OAuth URL ready");
|
||||
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
||||
ctx.progress.update("Waiting for you to paste the callback URL...");
|
||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return exchangeCodeForTokens(parsed.code, verifier);
|
||||
}
|
||||
|
||||
ctx.progress.update("Complete sign-in in browser...");
|
||||
try {
|
||||
await ctx.openUrl(authUrl);
|
||||
} catch {
|
||||
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { code } = await waitForLocalCallback({
|
||||
expectedState: verifier,
|
||||
timeoutMs: 5 * 60 * 1000,
|
||||
onProgress: (msg) => ctx.progress.update(msg),
|
||||
});
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return await exchangeCodeForTokens(code, verifier);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes("EADDRINUSE") ||
|
||||
err.message.includes("port") ||
|
||||
err.message.includes("listen"))
|
||||
) {
|
||||
ctx.progress.update("Local callback server failed. Switching to manual mode...");
|
||||
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return exchangeCodeForTokens(parsed.code, verifier);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/google-gemini-cli-auth",
|
||||
"version": "2026.1.16-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/matrix",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.16",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Matrix channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-it": "14.1.0",
|
||||
|
||||
@@ -16,14 +16,14 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupRoom = params.groupRoom?.trim() ?? "";
|
||||
const aliases = groupRoom ? [groupRoom] : [];
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupRoom || undefined,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
if (resolved) {
|
||||
if (resolved.autoReply === true) return false;
|
||||
|
||||
@@ -379,7 +379,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
SenderId: senderId,
|
||||
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
|
||||
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
||||
GroupRoom: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
|
||||
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
|
||||
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
||||
Provider: "matrix" as const,
|
||||
Surface: "matrix" as const,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/msteams",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.16",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/agents-hosting": "^1.1.1",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/voice-call",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.16",
|
||||
"type": "module",
|
||||
"description": "Clawdbot voice-call plugin",
|
||||
"dependencies": {
|
||||
@@ -9,6 +9,8 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/zalo",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.16",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"undici": "7.18.2"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
isControlCommandMessage,
|
||||
shouldComputeCommandAuthorized,
|
||||
} from "../../../src/auto-reply/command-detection.js";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
|
||||
import {
|
||||
ZaloApiError,
|
||||
deleteWebhook,
|
||||
@@ -437,6 +442,21 @@ async function processMessageWithPipeline(params: {
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await deps.readChannelAllowFromStore("zalo").catch(() => [])
|
||||
: [];
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
@@ -445,9 +465,7 @@ async function processMessageWithPipeline(params: {
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await deps.readChannelAllowFromStore("zalo").catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const allowed = senderAllowedForCommands;
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
@@ -496,7 +514,11 @@ async function processMessageWithPipeline(params: {
|
||||
},
|
||||
});
|
||||
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
||||
logVerbose(deps, `zalo: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
@@ -511,7 +533,7 @@ async function processMessageWithPipeline(params: {
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `group:${chatId}` : `zalo:${senderId}`,
|
||||
From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
|
||||
To: `zalo:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
@@ -519,6 +541,7 @@ async function processMessageWithPipeline(params: {
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "zalo",
|
||||
Surface: "zalo",
|
||||
MessageSid: message_id,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "@clawdbot/zalouser",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.16",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import {
|
||||
isControlCommandMessage,
|
||||
shouldComputeCommandAuthorized,
|
||||
} from "../../../src/auto-reply/command-detection.js";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
|
||||
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
|
||||
@@ -105,6 +110,21 @@ async function processMessage(
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
const rawBody = content.trim();
|
||||
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await deps.readChannelAllowFromStore("zalouser").catch(() => [])
|
||||
: [];
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
@@ -113,9 +133,7 @@ async function processMessage(
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await deps.readChannelAllowFromStore("zalouser").catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const allowed = senderAllowedForCommands;
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
@@ -158,6 +176,11 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
||||
logVerbose(deps, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
|
||||
|
||||
const route = deps.resolveAgentRoute({
|
||||
@@ -172,9 +195,7 @@ async function processMessage(
|
||||
});
|
||||
|
||||
const rawBody = content.trim();
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
@@ -186,7 +207,7 @@ async function processMessage(
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`,
|
||||
From: isGroup ? `zalouser:group:${chatId}` : `zalouser:${senderId}`,
|
||||
To: `zalouser:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
@@ -194,6 +215,7 @@ async function processMessage(
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "zalouser",
|
||||
Surface: "zalouser",
|
||||
MessageSid: message.msgId ?? `${timestamp}`,
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.16-2",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -26,6 +26,7 @@
|
||||
"dist/infra/**",
|
||||
"dist/macos/**",
|
||||
"dist/media/**",
|
||||
"dist/media-understanding/**",
|
||||
"dist/process/**",
|
||||
"dist/plugins/**",
|
||||
"dist/security/**",
|
||||
@@ -65,7 +66,7 @@
|
||||
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
|
||||
"docs:dev": "cd docs && mint dev",
|
||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts",
|
||||
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts && tsx scripts/write-build-info.ts",
|
||||
"plugins:sync": "tsx scripts/sync-plugin-versions.ts",
|
||||
"release:check": "tsx scripts/release-check.ts",
|
||||
"ui:install": "node scripts/ui.js install",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
const pkgPath = path.join(rootDir, "package.json");
|
||||
|
||||
const readPackageVersion = () => {
|
||||
try {
|
||||
const raw = fs.readFileSync(pkgPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return parsed.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveCommit = () => {
|
||||
const envCommit = process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim();
|
||||
if (envCommit) return envCommit;
|
||||
try {
|
||||
return execSync("git rev-parse HEAD", {
|
||||
cwd: rootDir,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const version = readPackageVersion();
|
||||
const commit = resolveCommit();
|
||||
|
||||
const buildInfo = {
|
||||
version,
|
||||
commit,
|
||||
builtAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(distDir, "build-info.json"),
|
||||
`${JSON.stringify(buildInfo, null, 2)}\n`,
|
||||
);
|
||||
+131
-167
@@ -4,20 +4,73 @@ description: Run Codex CLI, Claude Code, OpenCode, or Pi Coding Agent via backgr
|
||||
metadata: {"clawdbot":{"emoji":"🧩","requires":{"anyBins":["claude","codex","opencode","pi"]}}}
|
||||
---
|
||||
|
||||
# Coding Agent (background-first)
|
||||
# Coding Agent (bash-first)
|
||||
|
||||
Use **bash background mode** for non-interactive coding work. For interactive coding sessions, use the **tmux** skill (always, except very simple one-shot prompts).
|
||||
Use **bash** (with optional background mode) for all coding agent work. Simple and effective.
|
||||
|
||||
## The Pattern: workdir + background
|
||||
## ⚠️ PTY Mode Required!
|
||||
|
||||
Coding agents (Codex, Claude Code, Pi) are **interactive terminal applications** that need a pseudo-terminal (PTY) to work correctly. Without PTY, you'll get broken output, missing colors, or the agent may hang.
|
||||
|
||||
**Always use `pty:true`** when running coding agents:
|
||||
|
||||
```bash
|
||||
# Create temp space for chats/scratch work
|
||||
SCRATCH=$(mktemp -d)
|
||||
# ✅ Correct - with PTY
|
||||
bash pty:true command:"codex exec 'Your prompt'"
|
||||
|
||||
# Start agent in target directory ("little box" - only sees relevant files)
|
||||
bash workdir:$SCRATCH background:true command:"<agent command>"
|
||||
# Or for project work:
|
||||
bash workdir:~/project/folder background:true command:"<agent command>"
|
||||
# ❌ Wrong - no PTY, agent may break
|
||||
bash command:"codex exec 'Your prompt'"
|
||||
```
|
||||
|
||||
### Bash Tool Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `command` | string | The shell command to run |
|
||||
| `pty` | boolean | **Use for coding agents!** Allocates a pseudo-terminal for interactive CLIs |
|
||||
| `workdir` | string | Working directory (agent sees only this folder's context) |
|
||||
| `background` | boolean | Run in background, returns sessionId for monitoring |
|
||||
| `timeout` | number | Timeout in seconds (kills process on expiry) |
|
||||
| `elevated` | boolean | Run on host instead of sandbox (if allowed) |
|
||||
|
||||
### Process Tool Actions (for background sessions)
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `list` | List all running/recent sessions |
|
||||
| `poll` | Check if session is still running |
|
||||
| `log` | Get session output (with optional offset/limit) |
|
||||
| `write` | Send raw data to stdin |
|
||||
| `submit` | Send data + newline (like typing and pressing Enter) |
|
||||
| `send-keys` | Send key tokens or hex bytes |
|
||||
| `paste` | Paste text (with optional bracketed mode) |
|
||||
| `kill` | Terminate the session |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start: One-Shot Tasks
|
||||
|
||||
For quick prompts/chats, create a temp git repo and run:
|
||||
|
||||
```bash
|
||||
# Quick chat (Codex needs a git repo!)
|
||||
SCRATCH=$(mktemp -d) && cd $SCRATCH && git init && codex exec "Your prompt here"
|
||||
|
||||
# Or in a real project - with PTY!
|
||||
bash pty:true workdir:~/Projects/myproject command:"codex exec 'Add error handling to the API calls'"
|
||||
```
|
||||
|
||||
**Why git init?** Codex refuses to run outside a trusted git directory. Creating a temp repo solves this for scratch work.
|
||||
|
||||
---
|
||||
|
||||
## The Pattern: workdir + background + pty
|
||||
|
||||
For longer tasks, use background mode with PTY:
|
||||
|
||||
```bash
|
||||
# Start agent in target directory (with PTY!)
|
||||
bash pty:true workdir:~/project background:true command:"codex exec --full-auto 'Build a snake game'"
|
||||
# Returns sessionId for tracking
|
||||
|
||||
# Monitor progress
|
||||
@@ -29,6 +82,9 @@ process action:poll sessionId:XXX
|
||||
# Send input (if agent asks a question)
|
||||
process action:write sessionId:XXX data:"y"
|
||||
|
||||
# Submit with Enter (like typing "yes" and pressing Enter)
|
||||
process action:submit sessionId:XXX data:"yes"
|
||||
|
||||
# Kill if needed
|
||||
process action:kill sessionId:XXX
|
||||
```
|
||||
@@ -41,72 +97,67 @@ process action:kill sessionId:XXX
|
||||
|
||||
**Model:** `gpt-5.2-codex` is the default (set in ~/.codex/config.toml)
|
||||
|
||||
### Building/Creating (use --full-auto or --yolo)
|
||||
### Flags
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `exec "prompt"` | One-shot execution, exits when done |
|
||||
| `--full-auto` | Sandboxed but auto-approves in workspace |
|
||||
| `--yolo` | NO sandbox, NO approvals (fastest, most dangerous) |
|
||||
|
||||
### Building/Creating
|
||||
```bash
|
||||
# --full-auto: sandboxed but auto-approves in workspace
|
||||
bash workdir:~/project background:true command:"codex exec --full-auto \"Build a snake game with dark theme\""
|
||||
# Quick one-shot (auto-approves) - remember PTY!
|
||||
bash pty:true workdir:~/project command:"codex exec --full-auto 'Build a dark mode toggle'"
|
||||
|
||||
# --yolo: NO sandbox, NO approvals (fastest, most dangerous)
|
||||
bash workdir:~/project background:true command:"codex --yolo \"Build a snake game with dark theme\""
|
||||
|
||||
# Note: --yolo is a shortcut for --dangerously-bypass-approvals-and-sandbox
|
||||
# Background for longer work
|
||||
bash pty:true workdir:~/project background:true command:"codex --yolo 'Refactor the auth module'"
|
||||
```
|
||||
|
||||
### Reviewing PRs (vanilla, no flags)
|
||||
### Reviewing PRs
|
||||
|
||||
**⚠️ CRITICAL: Never review PRs in Clawdbot's own project folder!**
|
||||
- Either use the project where the PR is submitted (if it's NOT ~/Projects/clawdbot)
|
||||
- Or clone to a temp folder first
|
||||
Clone to temp folder or use git worktree.
|
||||
|
||||
```bash
|
||||
# Option 1: Review in the actual project (if NOT clawdbot)
|
||||
bash workdir:~/Projects/some-other-repo background:true command:"codex review --base main"
|
||||
|
||||
# Option 2: Clone to temp folder for safe review (REQUIRED for clawdbot PRs!)
|
||||
# Clone to temp for safe review
|
||||
REVIEW_DIR=$(mktemp -d)
|
||||
git clone https://github.com/clawdbot/clawdbot.git $REVIEW_DIR
|
||||
git clone https://github.com/user/repo.git $REVIEW_DIR
|
||||
cd $REVIEW_DIR && gh pr checkout 130
|
||||
bash workdir:$REVIEW_DIR background:true command:"codex review --base origin/main"
|
||||
# Clean up after: rm -rf $REVIEW_DIR
|
||||
bash pty:true workdir:$REVIEW_DIR command:"codex review --base origin/main"
|
||||
# Clean up after: trash $REVIEW_DIR
|
||||
|
||||
# Option 3: Use git worktree (keeps main intact)
|
||||
# Or use git worktree (keeps main intact)
|
||||
git worktree add /tmp/pr-130-review pr-130-branch
|
||||
bash workdir:/tmp/pr-130-review background:true command:"codex review --base main"
|
||||
bash pty:true workdir:/tmp/pr-130-review command:"codex review --base main"
|
||||
```
|
||||
|
||||
**Why?** Checking out branches in the running Clawdbot repo can break the live instance!
|
||||
|
||||
### Batch PR Reviews (parallel army!)
|
||||
```bash
|
||||
# Fetch all PR refs first
|
||||
git fetch origin '+refs/pull/*/head:refs/remotes/origin/pr/*'
|
||||
|
||||
# Deploy the army - one Codex per PR!
|
||||
bash workdir:~/project background:true command:"codex exec \"Review PR #86. git diff origin/main...origin/pr/86\""
|
||||
bash workdir:~/project background:true command:"codex exec \"Review PR #87. git diff origin/main...origin/pr/87\""
|
||||
bash workdir:~/project background:true command:"codex exec \"Review PR #95. git diff origin/main...origin/pr/95\""
|
||||
# ... repeat for all PRs
|
||||
# Deploy the army - one Codex per PR (all with PTY!)
|
||||
bash pty:true workdir:~/project background:true command:"codex exec 'Review PR #86. git diff origin/main...origin/pr/86'"
|
||||
bash pty:true workdir:~/project background:true command:"codex exec 'Review PR #87. git diff origin/main...origin/pr/87'"
|
||||
|
||||
# Monitor all
|
||||
process action:list
|
||||
|
||||
# Get results and post to GitHub
|
||||
process action:log sessionId:XXX
|
||||
# Post results to GitHub
|
||||
gh pr comment <PR#> --body "<review content>"
|
||||
```
|
||||
|
||||
### Tips for PR Reviews
|
||||
- **Fetch refs first:** `git fetch origin '+refs/pull/*/head:refs/remotes/origin/pr/*'`
|
||||
- **Use git diff:** Tell Codex to use `git diff origin/main...origin/pr/XX`
|
||||
- **Don't checkout:** Multiple parallel reviews = don't let them change branches
|
||||
- **Post results:** Use `gh pr comment` to post reviews to GitHub
|
||||
|
||||
---
|
||||
|
||||
## Claude Code
|
||||
|
||||
```bash
|
||||
bash workdir:~/project background:true command:"claude \"Your task\""
|
||||
# With PTY for proper terminal output
|
||||
bash pty:true workdir:~/project command:"claude 'Your task'"
|
||||
|
||||
# Background
|
||||
bash pty:true workdir:~/project background:true command:"claude 'Your task'"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -114,7 +165,7 @@ bash workdir:~/project background:true command:"claude \"Your task\""
|
||||
## OpenCode
|
||||
|
||||
```bash
|
||||
bash workdir:~/project background:true command:"opencode run \"Your task\""
|
||||
bash pty:true workdir:~/project command:"opencode run 'Your task'"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -123,152 +174,65 @@ bash workdir:~/project background:true command:"opencode run \"Your task\""
|
||||
|
||||
```bash
|
||||
# Install: npm install -g @mariozechner/pi-coding-agent
|
||||
bash workdir:~/project background:true command:"pi \"Your task\""
|
||||
bash pty:true workdir:~/project command:"pi 'Your task'"
|
||||
|
||||
# Non-interactive mode (PTY still recommended)
|
||||
bash pty:true command:"pi -p 'Summarize src/'"
|
||||
|
||||
# Different provider/model
|
||||
bash pty:true command:"pi --provider openai --model gpt-4o-mini -p 'Your task'"
|
||||
```
|
||||
|
||||
**Note:** Pi now has Anthropic prompt caching enabled (PR #584, merged Jan 2026)!
|
||||
|
||||
---
|
||||
|
||||
## Pi flags (common)
|
||||
## Parallel Issue Fixing with git worktrees
|
||||
|
||||
- `--print` / `-p`: non-interactive; runs prompt and exits.
|
||||
- `--provider <name>`: pick provider (default: google).
|
||||
- `--model <id>`: pick model (default: gemini-2.5-flash).
|
||||
- `--api-key <key>`: override API key (defaults to env vars).
|
||||
|
||||
Examples:
|
||||
For fixing multiple issues in parallel, use git worktrees:
|
||||
|
||||
```bash
|
||||
# Set provider + model, non-interactive
|
||||
bash workdir:~/project background:true command:"pi --provider openai --model gpt-4o-mini -p \"Summarize src/\""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tmux (interactive sessions)
|
||||
|
||||
Use the tmux skill for interactive coding sessions (always, except very simple one-shot prompts). Prefer bash background mode for non-interactive runs.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Issue Fixing with git worktrees + tmux
|
||||
|
||||
For fixing multiple issues in parallel, use git worktrees (isolated branches) + tmux sessions:
|
||||
|
||||
```bash
|
||||
# 1. Clone repo to temp location
|
||||
cd /tmp && git clone git@github.com:user/repo.git repo-worktrees
|
||||
cd repo-worktrees
|
||||
|
||||
# 2. Create worktrees for each issue (isolated branches!)
|
||||
# 1. Create worktrees for each issue
|
||||
git worktree add -b fix/issue-78 /tmp/issue-78 main
|
||||
git worktree add -b fix/issue-99 /tmp/issue-99 main
|
||||
|
||||
# 3. Set up tmux sessions
|
||||
SOCKET="${TMPDIR:-/tmp}/codex-fixes.sock"
|
||||
tmux -S "$SOCKET" new-session -d -s fix-78
|
||||
tmux -S "$SOCKET" new-session -d -s fix-99
|
||||
# 2. Launch Codex in each (background + PTY!)
|
||||
bash pty:true workdir:/tmp/issue-78 background:true command:"pnpm install && codex --yolo 'Fix issue #78: <description>. Commit and push.'"
|
||||
bash pty:true workdir:/tmp/issue-99 background:true command:"pnpm install && codex --yolo 'Fix issue #99: <description>. Commit and push.'"
|
||||
|
||||
# 4. Launch Codex in each (after pnpm install!)
|
||||
tmux -S "$SOCKET" send-keys -t fix-78 "cd /tmp/issue-78 && pnpm install && codex --yolo 'Fix issue #78: <description>. Commit and push.'" Enter
|
||||
tmux -S "$SOCKET" send-keys -t fix-99 "cd /tmp/issue-99 && pnpm install && codex --yolo 'Fix issue #99: <description>. Commit and push.'" Enter
|
||||
# 3. Monitor progress
|
||||
process action:list
|
||||
process action:log sessionId:XXX
|
||||
|
||||
# 5. Monitor progress
|
||||
tmux -S "$SOCKET" capture-pane -p -t fix-78 -S -30
|
||||
tmux -S "$SOCKET" capture-pane -p -t fix-99 -S -30
|
||||
|
||||
# 6. Check if done (prompt returned)
|
||||
tmux -S "$SOCKET" capture-pane -p -t fix-78 -S -3 | grep -q "❯" && echo "Done!"
|
||||
|
||||
# 7. Create PRs after fixes
|
||||
# 4. Create PRs after fixes
|
||||
cd /tmp/issue-78 && git push -u origin fix/issue-78
|
||||
gh pr create --repo user/repo --head fix/issue-78 --title "fix: ..." --body "..."
|
||||
|
||||
# 8. Cleanup
|
||||
tmux -S "$SOCKET" kill-server
|
||||
# 5. Cleanup
|
||||
git worktree remove /tmp/issue-78
|
||||
git worktree remove /tmp/issue-99
|
||||
```
|
||||
|
||||
**Why worktrees?** Each Codex works in isolated branch, no conflicts. Can run 5+ parallel fixes!
|
||||
|
||||
**Why tmux over bash background?** Codex is interactive — needs TTY for proper output. tmux provides persistent sessions with full history capture.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Rules
|
||||
|
||||
1. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
|
||||
2. **Be patient** — don't kill sessions because they're "slow"
|
||||
3. **Monitor with process:log** — check progress without interfering
|
||||
4. **--full-auto for building** — auto-approves changes
|
||||
5. **vanilla for reviewing** — no special flags needed
|
||||
6. **Parallel is OK** — run many Codex processes at once for batch work
|
||||
7. **NEVER start Codex in ~/clawd/** — it'll read your soul docs and get weird ideas about the org chart! Use the target project dir or /tmp for blank slate chats
|
||||
8. **NEVER checkout branches in ~/Projects/clawdbot/** — that's the LIVE Clawdbot instance! Clone to /tmp or use git worktree for PR reviews
|
||||
1. **Always use pty:true** — coding agents need a terminal!
|
||||
2. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
|
||||
3. **Be patient** — don't kill sessions because they're "slow"
|
||||
4. **Monitor with process:log** — check progress without interfering
|
||||
5. **--full-auto for building** — auto-approves changes
|
||||
6. **vanilla for reviewing** — no special flags needed
|
||||
7. **Parallel is OK** — run many Codex processes at once for batch work
|
||||
8. **NEVER start Codex in ~/clawd/** — it'll read your soul docs and get weird ideas about the org chart!
|
||||
9. **NEVER checkout branches in ~/Projects/clawdbot/** — that's the LIVE Clawdbot instance!
|
||||
|
||||
---
|
||||
|
||||
## PR Template (The Razor Standard)
|
||||
## Learnings (Jan 2026)
|
||||
|
||||
When submitting PRs to external repos, use this format for quality & maintainer-friendliness:
|
||||
|
||||
````markdown
|
||||
## Original Prompt
|
||||
[Exact request/problem statement]
|
||||
|
||||
## What this does
|
||||
[High-level description]
|
||||
|
||||
**Features:**
|
||||
- [Key feature 1]
|
||||
- [Key feature 2]
|
||||
|
||||
**Example usage:**
|
||||
```bash
|
||||
# Example
|
||||
command example
|
||||
```
|
||||
|
||||
## Feature intent (maintainer-friendly)
|
||||
[Why useful, how it fits, workflows it enables]
|
||||
|
||||
## Prompt history (timestamped)
|
||||
- YYYY-MM-DD HH:MM UTC: [Step 1]
|
||||
- YYYY-MM-DD HH:MM UTC: [Step 2]
|
||||
|
||||
## How I tested
|
||||
**Manual verification:**
|
||||
1. [Test step] - Output: `[result]`
|
||||
2. [Test step] - Result: [result]
|
||||
|
||||
**Files tested:**
|
||||
- [Detail]
|
||||
- [Edge cases]
|
||||
|
||||
## Session logs (implementation)
|
||||
- [What was researched]
|
||||
- [What was discovered]
|
||||
- [Time spent]
|
||||
|
||||
## Implementation details
|
||||
**New files:**
|
||||
- `path/file.ts` - [description]
|
||||
|
||||
**Modified files:**
|
||||
- `path/file.ts` - [change]
|
||||
|
||||
**Technical notes:**
|
||||
- [Detail 1]
|
||||
- [Detail 2]
|
||||
|
||||
---
|
||||
*Submitted by Razor 🥷 - Mariano's AI agent*
|
||||
````
|
||||
|
||||
**Key principles:**
|
||||
1. Human-written description (no AI slop)
|
||||
2. Feature intent for maintainers
|
||||
3. Timestamped prompt history
|
||||
4. Session logs if using Codex/agent
|
||||
|
||||
**Example:** https://github.com/steipete/bird/pull/22
|
||||
- **PTY is essential:** Coding agents are interactive terminal apps. Without `pty:true`, output breaks or agent hangs.
|
||||
- **Git repo required:** Codex won't run outside a git directory. Use `mktemp -d && git init` for scratch work.
|
||||
- **exec is your friend:** `codex exec "prompt"` runs and exits cleanly - perfect for one-shots.
|
||||
- **submit vs write:** Use `submit` to send input + Enter, `write` for raw data without newline.
|
||||
- **Sass works:** Codex responds well to playful prompts. Asked it to write a haiku about being second fiddle to a space lobster, got: *"Second chair, I code / Space lobster sets the tempo / Keys glow, I follow"* 🦞
|
||||
@@ -4,6 +4,7 @@ import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import {
|
||||
addSession,
|
||||
appendOutput,
|
||||
drainSession,
|
||||
listFinishedSessions,
|
||||
markBackgrounded,
|
||||
markExited,
|
||||
@@ -23,9 +24,12 @@ describe("bash process registry", () => {
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 10,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
@@ -43,6 +47,105 @@ describe("bash process registry", () => {
|
||||
expect(session.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("caps pending output to avoid runaway polls", () => {
|
||||
const session: ProcessSession = {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 100_000,
|
||||
pendingMaxOutputChars: 20_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
|
||||
addSession(session);
|
||||
const payload = `${"a".repeat(70_000)}${"b".repeat(20_000)}`;
|
||||
appendOutput(session, "stdout", payload);
|
||||
|
||||
const drained = drainSession(session);
|
||||
expect(drained.stdout).toBe("b".repeat(20_000));
|
||||
expect(session.pendingStdout).toHaveLength(0);
|
||||
expect(session.pendingStdoutChars).toBe(0);
|
||||
expect(session.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("respects max output cap when pending cap is larger", () => {
|
||||
const session: ProcessSession = {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 5_000,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
|
||||
addSession(session);
|
||||
appendOutput(session, "stdout", "x".repeat(10_000));
|
||||
|
||||
const drained = drainSession(session);
|
||||
expect(drained.stdout.length).toBe(5_000);
|
||||
expect(session.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("caps stdout and stderr independently", () => {
|
||||
const session: ProcessSession = {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 100,
|
||||
pendingMaxOutputChars: 10,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
|
||||
addSession(session);
|
||||
appendOutput(session, "stdout", "a".repeat(6));
|
||||
appendOutput(session, "stdout", "b".repeat(6));
|
||||
appendOutput(session, "stderr", "c".repeat(12));
|
||||
|
||||
const drained = drainSession(session);
|
||||
expect(drained.stdout).toBe("a".repeat(4) + "b".repeat(6));
|
||||
expect(drained.stderr).toBe("c".repeat(10));
|
||||
expect(session.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("only persists finished sessions when backgrounded", () => {
|
||||
const session: ProcessSession = {
|
||||
id: "sess",
|
||||
@@ -51,9 +154,12 @@ describe("bash process registry", () => {
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 100,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSessionSlug as createSessionSlugId } from "./session-slug.js";
|
||||
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
|
||||
const MAX_JOB_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
const DEFAULT_PENDING_OUTPUT_CHARS = 30_000;
|
||||
|
||||
function clampTtl(value: number | undefined) {
|
||||
if (!value || Number.isNaN(value)) return DEFAULT_JOB_TTL_MS;
|
||||
@@ -33,9 +34,12 @@ export interface ProcessSession {
|
||||
startedAt: number;
|
||||
cwd?: string;
|
||||
maxOutputChars: number;
|
||||
pendingMaxOutputChars?: number;
|
||||
totalOutputChars: number;
|
||||
pendingStdout: string[];
|
||||
pendingStderr: string[];
|
||||
pendingStdoutChars: number;
|
||||
pendingStderrChars: number;
|
||||
aggregated: string;
|
||||
tail: string;
|
||||
exitCode?: number | null;
|
||||
@@ -95,8 +99,25 @@ export function deleteSession(id: string) {
|
||||
export function appendOutput(session: ProcessSession, stream: "stdout" | "stderr", chunk: string) {
|
||||
session.pendingStdout ??= [];
|
||||
session.pendingStderr ??= [];
|
||||
session.pendingStdoutChars ??= sumPendingChars(session.pendingStdout);
|
||||
session.pendingStderrChars ??= sumPendingChars(session.pendingStderr);
|
||||
const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
|
||||
const bufferChars = stream === "stdout" ? session.pendingStdoutChars : session.pendingStderrChars;
|
||||
const pendingCap = Math.min(
|
||||
session.pendingMaxOutputChars ?? DEFAULT_PENDING_OUTPUT_CHARS,
|
||||
session.maxOutputChars,
|
||||
);
|
||||
buffer.push(chunk);
|
||||
let pendingChars = bufferChars + chunk.length;
|
||||
if (pendingChars > pendingCap) {
|
||||
session.truncated = true;
|
||||
pendingChars = capPendingBuffer(buffer, pendingChars, pendingCap);
|
||||
}
|
||||
if (stream === "stdout") {
|
||||
session.pendingStdoutChars = pendingChars;
|
||||
} else {
|
||||
session.pendingStderrChars = pendingChars;
|
||||
}
|
||||
session.totalOutputChars += chunk.length;
|
||||
const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars);
|
||||
session.truncated =
|
||||
@@ -110,6 +131,8 @@ export function drainSession(session: ProcessSession) {
|
||||
const stderr = session.pendingStderr.join("");
|
||||
session.pendingStdout = [];
|
||||
session.pendingStderr = [];
|
||||
session.pendingStdoutChars = 0;
|
||||
session.pendingStderrChars = 0;
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
@@ -155,6 +178,32 @@ export function tail(text: string, max = 2000) {
|
||||
return text.slice(text.length - max);
|
||||
}
|
||||
|
||||
function sumPendingChars(buffer: string[]) {
|
||||
let total = 0;
|
||||
for (const chunk of buffer) total += chunk.length;
|
||||
return total;
|
||||
}
|
||||
|
||||
function capPendingBuffer(buffer: string[], pendingChars: number, cap: number) {
|
||||
if (pendingChars <= cap) return pendingChars;
|
||||
const last = buffer.at(-1);
|
||||
if (last && last.length >= cap) {
|
||||
buffer.length = 0;
|
||||
buffer.push(last.slice(last.length - cap));
|
||||
return cap;
|
||||
}
|
||||
while (buffer.length && pendingChars - buffer[0].length >= cap) {
|
||||
pendingChars -= buffer[0].length;
|
||||
buffer.shift();
|
||||
}
|
||||
if (buffer.length && pendingChars > cap) {
|
||||
const overflow = pendingChars - cap;
|
||||
buffer[0] = buffer[0].slice(overflow);
|
||||
pendingChars = cap;
|
||||
}
|
||||
return pendingChars;
|
||||
}
|
||||
|
||||
export function trimWithCap(text: string, max: number) {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(text.length - max);
|
||||
|
||||
@@ -10,7 +10,7 @@ afterEach(() => {
|
||||
test("exec supports pty output", async () => {
|
||||
const tool = createExecTool({ allowBackground: false });
|
||||
const result = await tool.execute("toolcall", {
|
||||
command: "node -e 'process.stdout.write(\"ok\")'",
|
||||
command: 'node -e "process.stdout.write(String.fromCharCode(111,107))"',
|
||||
pty: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||
1_000,
|
||||
150_000,
|
||||
);
|
||||
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
|
||||
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
|
||||
30_000,
|
||||
1_000,
|
||||
150_000,
|
||||
);
|
||||
const DEFAULT_PATH =
|
||||
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
const DEFAULT_NOTIFY_TAIL_CHARS = 400;
|
||||
@@ -189,6 +195,7 @@ export function createExecTool(
|
||||
}
|
||||
|
||||
const maxOutput = DEFAULT_MAX_OUTPUT;
|
||||
const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT;
|
||||
const startedAt = Date.now();
|
||||
const sessionId = createSessionSlug();
|
||||
const warnings: string[] = [];
|
||||
@@ -350,9 +357,12 @@ export function createExecTool(
|
||||
startedAt,
|
||||
cwd: workdir,
|
||||
maxOutputChars: maxOutput,
|
||||
pendingMaxOutputChars: pendingMaxOutput,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
|
||||
@@ -15,7 +15,7 @@ test("process send-keys encodes Enter for pty sessions", async () => {
|
||||
const processTool = createProcessTool();
|
||||
const result = await execTool.execute("toolcall", {
|
||||
command:
|
||||
"node -e \"process.stdin.on('data', d => { process.stdout.write(d); if (d.includes(10) || d.includes(13)) process.exit(0); });\"",
|
||||
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});"',
|
||||
pty: true,
|
||||
background: true,
|
||||
});
|
||||
@@ -45,12 +45,12 @@ test("process send-keys encodes Enter for pty sessions", async () => {
|
||||
throw new Error("PTY session did not exit after send-keys");
|
||||
});
|
||||
|
||||
test("process submit sends CR for pty sessions", async () => {
|
||||
test("process submit sends Enter for pty sessions", async () => {
|
||||
const execTool = createExecTool();
|
||||
const processTool = createProcessTool();
|
||||
const result = await execTool.execute("toolcall", {
|
||||
command:
|
||||
"node -e \"process.stdin.on('data', d => { if (d.includes(10) || d.includes(13)) { process.stdout.write('submitted'); process.exit(0); } });\"",
|
||||
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});"',
|
||||
pty: true,
|
||||
background: true,
|
||||
});
|
||||
@@ -64,7 +64,8 @@ test("process submit sends CR for pty sessions", async () => {
|
||||
sessionId,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
|
||||
while (Date.now() < deadline) {
|
||||
await wait(50);
|
||||
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
|
||||
@@ -33,9 +33,7 @@ const processSchema = Type.Object({
|
||||
keys: Type.Optional(
|
||||
Type.Array(Type.String(), { description: "Key tokens to send for send-keys" }),
|
||||
),
|
||||
hex: Type.Optional(
|
||||
Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" }),
|
||||
),
|
||||
hex: Type.Optional(Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" })),
|
||||
literal: Type.Optional(Type.String({ description: "Literal string for send-keys" })),
|
||||
text: Type.Optional(Type.String({ description: "Text to paste for paste" })),
|
||||
bracketed: Type.Optional(Type.Boolean({ description: "Wrap paste in bracketed mode" })),
|
||||
@@ -121,10 +119,7 @@ export function createProcessTool(
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.map((s) => {
|
||||
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
|
||||
return `${s.sessionId} ${pad(
|
||||
s.status,
|
||||
9,
|
||||
)} ${formatDuration(s.runtimeMs)} :: ${label}`;
|
||||
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
|
||||
@@ -151,6 +151,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
openai: "OPENAI_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
deepgram: "DEEPGRAM_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import { enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||
import {
|
||||
markAuthProfileFailure,
|
||||
@@ -58,6 +59,14 @@ export async function runEmbeddedPiAgent(
|
||||
const globalLane = resolveGlobalLane(params.lane);
|
||||
const enqueueGlobal =
|
||||
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
||||
const channelHint = params.messageChannel ?? params.messageProvider;
|
||||
const resolvedToolResultFormat =
|
||||
params.toolResultFormat ??
|
||||
(channelHint
|
||||
? isMarkdownCapableMessageChannel(channelHint)
|
||||
? "markdown"
|
||||
: "plain"
|
||||
: "markdown");
|
||||
|
||||
return enqueueCommandInLane(sessionLane, () =>
|
||||
enqueueGlobal(async () => {
|
||||
@@ -208,6 +217,7 @@ export async function runEmbeddedPiAgent(
|
||||
thinkLevel,
|
||||
verboseLevel: params.verboseLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
toolResultFormat: resolvedToolResultFormat,
|
||||
bashElevated: params.bashElevated,
|
||||
timeoutMs: params.timeoutMs,
|
||||
runId: params.runId,
|
||||
@@ -408,6 +418,7 @@ export async function runEmbeddedPiAgent(
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
verboseLevel: params.verboseLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
toolResultFormat: resolvedToolResultFormat,
|
||||
inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult,
|
||||
});
|
||||
|
||||
|
||||
@@ -365,6 +365,7 @@ export async function runEmbeddedAttempt(
|
||||
runId: params.runId,
|
||||
verboseLevel: params.verboseLevel,
|
||||
reasoningMode: params.reasoningLevel ?? "off",
|
||||
toolResultFormat: params.toolResultFormat,
|
||||
shouldEmitToolResult: params.shouldEmitToolResult,
|
||||
shouldEmitToolOutput: params.shouldEmitToolOutput,
|
||||
onToolResult: params.onToolResult,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-rep
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { enqueueCommand } from "../../../process/command-queue.js";
|
||||
import type { ExecElevatedDefaults } from "../../bash-tools.js";
|
||||
import type { BlockReplyChunking } from "../../pi-embedded-subscribe.js";
|
||||
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
||||
import type { SkillSnapshot } from "../../skills.js";
|
||||
|
||||
export type RunEmbeddedPiAgentParams = {
|
||||
@@ -33,6 +33,7 @@ export type RunEmbeddedPiAgentParams = {
|
||||
thinkLevel?: ThinkLevel;
|
||||
verboseLevel?: VerboseLevel;
|
||||
reasoningLevel?: ReasoningLevel;
|
||||
toolResultFormat?: ToolResultFormat;
|
||||
bashElevated?: ExecElevatedDefaults;
|
||||
timeoutMs: number;
|
||||
runId: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
extractAssistantThinking,
|
||||
formatReasoningMessage,
|
||||
} from "../../pi-embedded-utils.js";
|
||||
import type { ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
||||
|
||||
type ToolMetaEntry = { toolName: string; meta?: string };
|
||||
|
||||
@@ -26,6 +27,7 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
sessionKey: string;
|
||||
verboseLevel?: VerboseLevel;
|
||||
reasoningLevel?: ReasoningLevel;
|
||||
toolResultFormat?: ToolResultFormat;
|
||||
inlineToolResultsAllowed: boolean;
|
||||
}): Array<{
|
||||
text?: string;
|
||||
@@ -47,6 +49,7 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
replyToCurrent?: boolean;
|
||||
}> = [];
|
||||
|
||||
const useMarkdown = params.toolResultFormat === "markdown";
|
||||
const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
|
||||
const errorText = params.lastAssistant
|
||||
? formatAssistantErrorText(params.lastAssistant, {
|
||||
@@ -71,7 +74,9 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
params.inlineToolResultsAllowed && params.verboseLevel !== "off" && params.toolMetas.length > 0;
|
||||
if (inlineToolResults) {
|
||||
for (const { toolName, meta } of params.toolMetas) {
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : []);
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : [], {
|
||||
markdown: useMarkdown,
|
||||
});
|
||||
const {
|
||||
text: cleanedText,
|
||||
mediaUrls,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-rep
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { ExecElevatedDefaults } from "../../bash-tools.js";
|
||||
import type { MessagingToolSend } from "../../pi-embedded-messaging.js";
|
||||
import type { BlockReplyChunking } from "../../pi-embedded-subscribe.js";
|
||||
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
||||
import type { SkillSnapshot } from "../../skills.js";
|
||||
import type { SessionSystemPromptReport } from "../../../config/sessions/types.js";
|
||||
|
||||
@@ -38,6 +38,7 @@ export type EmbeddedRunAttemptParams = {
|
||||
thinkLevel: ThinkLevel;
|
||||
verboseLevel?: VerboseLevel;
|
||||
reasoningLevel?: ReasoningLevel;
|
||||
toolResultFormat?: ToolResultFormat;
|
||||
bashElevated?: ExecElevatedDefaults;
|
||||
timeoutMs: number;
|
||||
runId: string;
|
||||
|
||||
@@ -23,10 +23,13 @@ const log = createSubsystemLogger("agent/embedded");
|
||||
export type {
|
||||
BlockReplyChunking,
|
||||
SubscribeEmbeddedPiSessionParams,
|
||||
ToolResultFormat,
|
||||
} from "./pi-embedded-subscribe.types.js";
|
||||
|
||||
export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionParams) {
|
||||
const reasoningMode = params.reasoningMode ?? "off";
|
||||
const toolResultFormat = params.toolResultFormat ?? "markdown";
|
||||
const useMarkdown = toolResultFormat === "markdown";
|
||||
const state: EmbeddedPiSubscribeState = {
|
||||
assistantTexts: [],
|
||||
toolMetas: [],
|
||||
@@ -180,11 +183,14 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
const formatToolOutputBlock = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return "(no output)";
|
||||
if (!useMarkdown) return trimmed;
|
||||
return `\`\`\`txt\n${trimmed}\n\`\`\``;
|
||||
};
|
||||
const emitToolSummary = (toolName?: string, meta?: string) => {
|
||||
if (!params.onToolResult) return;
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined, {
|
||||
markdown: useMarkdown,
|
||||
});
|
||||
const { text: cleanedText, mediaUrls } = parseReplyDirectives(agg);
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
|
||||
try {
|
||||
@@ -198,7 +204,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
};
|
||||
const emitToolOutput = (toolName?: string, meta?: string, output?: string) => {
|
||||
if (!params.onToolResult || !output) return;
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined);
|
||||
const agg = formatToolAggregate(toolName, meta ? [meta] : undefined, {
|
||||
markdown: useMarkdown,
|
||||
});
|
||||
const message = `${agg}\n${formatToolOutputBlock(output)}`;
|
||||
const { text: cleanedText, mediaUrls } = parseReplyDirectives(message);
|
||||
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return;
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
||||
|
||||
export type ToolResultFormat = "markdown" | "plain";
|
||||
|
||||
export type SubscribeEmbeddedPiSessionParams = {
|
||||
session: AgentSession;
|
||||
runId: string;
|
||||
verboseLevel?: VerboseLevel;
|
||||
reasoningMode?: ReasoningLevel;
|
||||
toolResultFormat?: ToolResultFormat;
|
||||
shouldEmitToolResult?: () => boolean;
|
||||
shouldEmitToolOutput?: () => boolean;
|
||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const ESC = "\u001b";
|
||||
const ESC = String.fromCharCode(0x1b);
|
||||
const DSR_PATTERN = new RegExp(`${ESC}\\[\\??6n`, "g");
|
||||
|
||||
export function stripDsrRequests(input: string): { cleaned: string; requests: number } {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, encodeKeySequence, encodePaste } from "./pty-keys.js";
|
||||
import {
|
||||
BRACKETED_PASTE_END,
|
||||
BRACKETED_PASTE_START,
|
||||
encodeKeySequence,
|
||||
encodePaste,
|
||||
} from "./pty-keys.js";
|
||||
|
||||
test("encodeKeySequence maps common keys and modifiers", () => {
|
||||
const enter = encodeKeySequence({ keys: ["Enter"] });
|
||||
|
||||
@@ -19,8 +19,8 @@ describe("ensureSkillsWatcher", () => {
|
||||
expect(watchMock).toHaveBeenCalledTimes(1);
|
||||
const opts = watchMock.mock.calls[0]?.[1] as { ignored?: unknown };
|
||||
|
||||
expect(Array.isArray(opts.ignored)).toBe(true);
|
||||
const ignored = opts.ignored as RegExp[];
|
||||
expect(opts.ignored).toBe(mod.DEFAULT_SKILLS_WATCH_IGNORED);
|
||||
const ignored = mod.DEFAULT_SKILLS_WATCH_IGNORED;
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,12 @@ const workspaceVersions = new Map<string, number>();
|
||||
const watchers = new Map<string, SkillsWatchState>();
|
||||
let globalVersion = 0;
|
||||
|
||||
export const DEFAULT_SKILLS_WATCH_IGNORED: RegExp[] = [
|
||||
/(^|[\\/])\.git([\\/]|$)/,
|
||||
/(^|[\\/])node_modules([\\/]|$)/,
|
||||
/(^|[\\/])dist([\\/]|$)/,
|
||||
];
|
||||
|
||||
function bumpVersion(current: number): number {
|
||||
const now = Date.now();
|
||||
return now <= current ? current + 1 : now;
|
||||
@@ -127,11 +133,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
|
||||
},
|
||||
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
|
||||
// This watcher only needs to react to skill changes.
|
||||
ignored: [
|
||||
/(^|[\\/])\.git([\\/]|$)/,
|
||||
/(^|[\\/])node_modules([\\/]|$)/,
|
||||
/(^|[\\/])dist([\\/]|$)/,
|
||||
],
|
||||
ignored: DEFAULT_SKILLS_WATCH_IGNORED,
|
||||
});
|
||||
|
||||
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
|
||||
|
||||
@@ -102,6 +102,27 @@ describe("image tool implicit imageModel config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disables image tool when primary model already supports images", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-"));
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "acme/vision-1" },
|
||||
imageModel: { primary: "openai/gpt-5-mini" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
acme: {
|
||||
models: [{ id: "vision-1", input: ["text", "image"] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toBeNull();
|
||||
expect(createImageTool({ config: cfg, agentDir })).toBeNull();
|
||||
});
|
||||
|
||||
it("sandboxes image paths like the read tool", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-image-sandbox-"));
|
||||
const agentDir = path.join(stateDir, "agent");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -19,7 +20,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||
import { minimaxUnderstandImage } from "../minimax-vlm.js";
|
||||
import { getApiKeyForModel, resolveEnvApiKey } from "../model-auth.js";
|
||||
import { runWithImageModelFallback } from "../model-fallback.js";
|
||||
import { parseModelRef } from "../model-selection.js";
|
||||
import { normalizeProviderId, resolveConfiguredModelRef } from "../model-selection.js";
|
||||
import { ensureClawdbotModelsJson } from "../models-config.js";
|
||||
import { assertSandboxPath } from "../sandbox-paths.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
@@ -42,12 +43,15 @@ function resolveDefaultModelRef(cfg?: ClawdbotConfig): {
|
||||
provider: string;
|
||||
model: string;
|
||||
} {
|
||||
const modelConfig = cfg?.agents?.defaults?.model as { primary?: string } | string | undefined;
|
||||
const raw = typeof modelConfig === "string" ? modelConfig.trim() : modelConfig?.primary?.trim();
|
||||
const parsed =
|
||||
parseModelRef(raw ?? "", DEFAULT_PROVIDER) ??
|
||||
({ provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL } as const);
|
||||
return { provider: parsed.provider, model: parsed.model };
|
||||
if (cfg) {
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
return { provider: resolved.provider, model: resolved.model };
|
||||
}
|
||||
return { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL };
|
||||
}
|
||||
|
||||
function hasAuthForProvider(params: { provider: string; agentDir: string }): boolean {
|
||||
@@ -58,6 +62,77 @@ function hasAuthForProvider(params: { provider: string; agentDir: string }): boo
|
||||
return listProfilesForProvider(store, params.provider).length > 0;
|
||||
}
|
||||
|
||||
type ProviderModelEntry = {
|
||||
id?: string;
|
||||
input?: string[];
|
||||
};
|
||||
|
||||
type ProviderConfigLike = {
|
||||
models?: ProviderModelEntry[];
|
||||
};
|
||||
|
||||
function resolveProviderConfig(
|
||||
providers: Record<string, ProviderConfigLike> | undefined,
|
||||
provider: string,
|
||||
): ProviderConfigLike | null {
|
||||
if (!providers) return null;
|
||||
const normalized = normalizeProviderId(provider);
|
||||
for (const [key, value] of Object.entries(providers)) {
|
||||
if (normalizeProviderId(key) === normalized) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveModelSupportsImages(params: {
|
||||
providerConfig: ProviderConfigLike | null;
|
||||
modelId: string;
|
||||
}): boolean | null {
|
||||
const models = params.providerConfig?.models;
|
||||
if (!Array.isArray(models) || models.length === 0) return null;
|
||||
const trimmedId = params.modelId.trim();
|
||||
if (!trimmedId) return null;
|
||||
const match =
|
||||
models.find((model) => String(model?.id ?? "").trim() === trimmedId) ??
|
||||
models.find(
|
||||
(model) =>
|
||||
String(model?.id ?? "")
|
||||
.trim()
|
||||
.toLowerCase() === trimmedId.toLowerCase(),
|
||||
);
|
||||
if (!match) return null;
|
||||
const input = Array.isArray(match.input) ? match.input : [];
|
||||
return input.includes("image");
|
||||
}
|
||||
|
||||
function resolvePrimaryModelSupportsImages(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
agentDir: string;
|
||||
}): boolean | null {
|
||||
if (!params.cfg) return null;
|
||||
const primary = resolveDefaultModelRef(params.cfg);
|
||||
const providerConfig = resolveProviderConfig(
|
||||
params.cfg.models?.providers as Record<string, ProviderConfigLike> | undefined,
|
||||
primary.provider,
|
||||
);
|
||||
const fromConfig = resolveModelSupportsImages({
|
||||
providerConfig,
|
||||
modelId: primary.model,
|
||||
});
|
||||
if (fromConfig !== null) return fromConfig;
|
||||
try {
|
||||
const modelsPath = path.join(params.agentDir, "models.json");
|
||||
const raw = fsSync.readFileSync(modelsPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { providers?: Record<string, ProviderConfigLike> };
|
||||
const provider = resolveProviderConfig(parsed.providers, primary.provider);
|
||||
return resolveModelSupportsImages({
|
||||
providerConfig: provider,
|
||||
modelId: primary.model,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective image model config for the `image` tool.
|
||||
*
|
||||
@@ -70,6 +145,11 @@ export function resolveImageModelConfigForTool(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
agentDir: string;
|
||||
}): ImageModelConfig | null {
|
||||
const primarySupportsImages = resolvePrimaryModelSupportsImages({
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (primarySupportsImages === true) return null;
|
||||
const explicit = coerceImageModelConfig(params.cfg);
|
||||
if (explicit.primary?.trim() || (explicit.fallbacks?.length ?? 0) > 0) {
|
||||
return explicit;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../memory/index.js", () => {
|
||||
return {
|
||||
getMemorySearchManager: async () => {
|
||||
return {
|
||||
manager: {
|
||||
search: async () => {
|
||||
throw new Error("openai embeddings failed: 429 insufficient_quota");
|
||||
},
|
||||
readFile: async () => {
|
||||
throw new Error("path required");
|
||||
},
|
||||
status: () => ({
|
||||
files: 0,
|
||||
chunks: 0,
|
||||
dirty: true,
|
||||
workspaceDir: "/tmp",
|
||||
dbPath: "/tmp/index.sqlite",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
requestedProvider: "openai",
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js";
|
||||
|
||||
describe("memory tools", () => {
|
||||
it("does not throw when memory_search fails (e.g. embeddings 429)", async () => {
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
||||
const tool = createMemorySearchTool({ config: cfg });
|
||||
expect(tool).not.toBeNull();
|
||||
if (!tool) throw new Error("tool missing");
|
||||
|
||||
const result = await tool.execute("call_1", { query: "hello" });
|
||||
expect(result.details).toEqual({
|
||||
results: [],
|
||||
disabled: true,
|
||||
error: "openai embeddings failed: 429 insufficient_quota",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not throw when memory_get fails", async () => {
|
||||
const cfg = { agents: { list: [{ id: "main", default: true }] } };
|
||||
const tool = createMemoryGetTool({ config: cfg });
|
||||
expect(tool).not.toBeNull();
|
||||
if (!tool) throw new Error("tool missing");
|
||||
|
||||
const result = await tool.execute("call_2", { path: "memory/NOPE.md" });
|
||||
expect(result.details).toEqual({
|
||||
path: "memory/NOPE.md",
|
||||
text: "",
|
||||
disabled: true,
|
||||
error: "path required",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,18 +47,23 @@ export function createMemorySearchTool(options: {
|
||||
if (!manager) {
|
||||
return jsonResult({ results: [], disabled: true, error });
|
||||
}
|
||||
const results = await manager.search(query, {
|
||||
maxResults,
|
||||
minScore,
|
||||
sessionKey: options.agentSessionKey,
|
||||
});
|
||||
const status = manager.status();
|
||||
return jsonResult({
|
||||
results,
|
||||
provider: status.provider,
|
||||
model: status.model,
|
||||
fallback: status.fallback,
|
||||
});
|
||||
try {
|
||||
const results = await manager.search(query, {
|
||||
maxResults,
|
||||
minScore,
|
||||
sessionKey: options.agentSessionKey,
|
||||
});
|
||||
const status = manager.status();
|
||||
return jsonResult({
|
||||
results,
|
||||
provider: status.provider,
|
||||
model: status.model,
|
||||
fallback: status.fallback,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return jsonResult({ results: [], disabled: true, error: message });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -91,12 +96,17 @@ export function createMemoryGetTool(options: {
|
||||
if (!manager) {
|
||||
return jsonResult({ path: relPath, text: "", disabled: true, error });
|
||||
}
|
||||
const result = await manager.readFile({
|
||||
relPath,
|
||||
from: from ?? undefined,
|
||||
lines: lines ?? undefined,
|
||||
});
|
||||
return jsonResult(result);
|
||||
try {
|
||||
const result = await manager.readFile({
|
||||
relPath,
|
||||
from: from ?? undefined,
|
||||
lines: lines ?? undefined,
|
||||
});
|
||||
return jsonResult(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return jsonResult({ path: relPath, text: "", disabled: true, error: message });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -309,7 +309,6 @@ export function createSessionStatusTool(opts?: {
|
||||
const isGroup =
|
||||
resolved.entry.chatType === "group" ||
|
||||
resolved.entry.chatType === "channel" ||
|
||||
resolved.key.startsWith("group:") ||
|
||||
resolved.key.includes(":group:") ||
|
||||
resolved.key.includes(":channel:");
|
||||
const groupActivation = isGroup
|
||||
|
||||
@@ -68,7 +68,7 @@ export function classifySessionKind(params: {
|
||||
if (key.startsWith("hook:")) return "hook";
|
||||
if (key.startsWith("node-") || key.startsWith("node:")) return "node";
|
||||
if (params.gatewayKind === "group") return "group";
|
||||
if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) {
|
||||
if (key.includes(":group:") || key.includes(":channel:")) {
|
||||
return "group";
|
||||
}
|
||||
return "other";
|
||||
|
||||
@@ -150,12 +150,12 @@ export function createSessionsListTool(opts?: {
|
||||
: undefined;
|
||||
const deliveryChannel =
|
||||
typeof deliveryContext?.channel === "string" ? deliveryContext.channel : undefined;
|
||||
const deliveryTo =
|
||||
typeof deliveryContext?.to === "string" ? deliveryContext.to : undefined;
|
||||
const deliveryTo = typeof deliveryContext?.to === "string" ? deliveryContext.to : undefined;
|
||||
const deliveryAccountId =
|
||||
typeof deliveryContext?.accountId === "string" ? deliveryContext.accountId : undefined;
|
||||
const lastChannel =
|
||||
deliveryChannel ?? (typeof entry.lastChannel === "string" ? entry.lastChannel : undefined);
|
||||
deliveryChannel ??
|
||||
(typeof entry.lastChannel === "string" ? entry.lastChannel : undefined);
|
||||
const lastAccountId =
|
||||
deliveryAccountId ??
|
||||
(typeof entry.lastAccountId === "string" ? entry.lastAccountId : undefined);
|
||||
|
||||
@@ -23,11 +23,13 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
|
||||
if (!channelRaw) return null;
|
||||
const normalizedChannel = normalizeChannelId(channelRaw);
|
||||
const channel = normalizedChannel ?? channelRaw.toLowerCase();
|
||||
const kindTarget = normalizedChannel
|
||||
? kind === "channel"
|
||||
? `channel:${id}`
|
||||
: `group:${id}`
|
||||
: id;
|
||||
const kindTarget = (() => {
|
||||
if (!normalizedChannel) return id;
|
||||
if (normalizedChannel === "discord" || normalizedChannel === "slack") {
|
||||
return `channel:${id}`;
|
||||
}
|
||||
return kind === "channel" ? `channel:${id}` : `group:${id}`;
|
||||
})();
|
||||
const normalized = normalizedChannel
|
||||
? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget)
|
||||
: undefined;
|
||||
|
||||
@@ -382,6 +382,42 @@ describe("handleTelegramAction", () => {
|
||||
).rejects.toThrow(/inline buttons are limited to DMs/i);
|
||||
});
|
||||
|
||||
it("allows inline buttons in DMs with tg: prefixed targets", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { botToken: "tok", capabilities: { inlineButtons: "dm" } },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "tg:5232990709",
|
||||
content: "Choose",
|
||||
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows inline buttons in groups with topic targets", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { botToken: "tok", capabilities: { inlineButtons: "group" } },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "telegram:group:-1001234567890:topic:456",
|
||||
content: "Choose",
|
||||
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends messages with inline keyboard buttons when enabled", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasControlCommand } from "./command-detection.js";
|
||||
import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js";
|
||||
import { listChatCommands } from "./commands-registry.js";
|
||||
import { parseActivationCommand } from "./group-activation.js";
|
||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||
@@ -72,6 +72,14 @@ describe("control command parsing", () => {
|
||||
expect(hasControlCommand("/send on")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects inline command tokens", () => {
|
||||
expect(hasInlineCommandTokens("hello /status")).toBe(true);
|
||||
expect(hasInlineCommandTokens("hey /think high")).toBe(true);
|
||||
expect(hasInlineCommandTokens("plain text")).toBe(false);
|
||||
expect(hasInlineCommandTokens("http://example.com/path")).toBe(false);
|
||||
expect(hasInlineCommandTokens("stop")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores telegram commands addressed to other bots", () => {
|
||||
expect(
|
||||
hasControlCommand("/help@otherbot", undefined, {
|
||||
|
||||
@@ -45,3 +45,24 @@ export function isControlCommandMessage(
|
||||
const normalized = normalizeCommandBody(trimmed, options).trim().toLowerCase();
|
||||
return isAbortTrigger(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse detection for inline directives/shortcuts (e.g. "hey /status") so channel monitors
|
||||
* can decide whether to compute CommandAuthorized for a message.
|
||||
*
|
||||
* This intentionally errs on the side of false positives; CommandAuthorized only gates
|
||||
* command/directive execution, not normal chat replies.
|
||||
*/
|
||||
export function hasInlineCommandTokens(text?: string): boolean {
|
||||
const body = text ?? "";
|
||||
if (!body.trim()) return false;
|
||||
return /(?:^|\s)[/!][a-z]/i.test(body);
|
||||
}
|
||||
|
||||
export function shouldComputeCommandAuthorized(
|
||||
text?: string,
|
||||
cfg?: ClawdbotConfig,
|
||||
options?: CommandNormalizeOptions,
|
||||
): boolean {
|
||||
return isControlCommandMessage(text, cfg, options) || hasInlineCommandTokens(text);
|
||||
}
|
||||
@@ -59,6 +59,27 @@ export function formatInboundEnvelope(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function formatInboundFromLabel(params: {
|
||||
isGroup: boolean;
|
||||
groupLabel?: string;
|
||||
groupId?: string;
|
||||
directLabel: string;
|
||||
directId?: string;
|
||||
groupFallback?: string;
|
||||
}): string {
|
||||
// Keep envelope headers compact: group labels include id, DMs only add id when it differs.
|
||||
if (params.isGroup) {
|
||||
const label = params.groupLabel?.trim() || params.groupFallback || "Group";
|
||||
const id = params.groupId?.trim();
|
||||
return id ? `${label} id:${id}` : label;
|
||||
}
|
||||
|
||||
const directLabel = params.directLabel.trim();
|
||||
const directId = params.directId?.trim();
|
||||
if (!directId || directId === directLabel) return directLabel;
|
||||
return `${directLabel} id:${directId}`;
|
||||
}
|
||||
|
||||
export function formatThreadStarterEnvelope(params: {
|
||||
channel: string;
|
||||
author?: string;
|
||||
|
||||
+11
-4
@@ -81,6 +81,7 @@ describe("directive behavior", () => {
|
||||
Body: "/thinking xhigh",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -90,7 +91,7 @@ describe("directive behavior", () => {
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
@@ -108,6 +109,7 @@ describe("directive behavior", () => {
|
||||
Body: "/thinking xhigh",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -117,7 +119,7 @@ describe("directive behavior", () => {
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
@@ -135,6 +137,7 @@ describe("directive behavior", () => {
|
||||
Body: "/thinking xhigh",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -144,7 +147,7 @@ describe("directive behavior", () => {
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
@@ -164,6 +167,7 @@ describe("directive behavior", () => {
|
||||
Body: "/help",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -201,6 +205,7 @@ describe("directive behavior", () => {
|
||||
Body: "/demo_skill",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -232,6 +237,7 @@ describe("directive behavior", () => {
|
||||
Body: "/queue collect debounce:bogus cap:zero drop:maybe",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -263,6 +269,7 @@ describe("directive behavior", () => {
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -300,7 +307,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/think", From: "+1222", To: "+1222" },
|
||||
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
|
||||
+4
-4
@@ -173,7 +173,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/verbose on", From: "+1222", To: "+1222" },
|
||||
{ Body: "/verbose on", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -197,7 +197,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/verbose off", From: "+1222", To: "+1222" },
|
||||
{ Body: "/verbose off", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -223,7 +223,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/think", From: "+1222", To: "+1222" },
|
||||
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -248,7 +248,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/think", From: "+1222", To: "+1222" },
|
||||
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
|
||||
+2
-2
@@ -73,7 +73,7 @@ describe("directive behavior", () => {
|
||||
]);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/think", From: "+1222", To: "+1222" },
|
||||
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -105,7 +105,7 @@ describe("directive behavior", () => {
|
||||
]);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/think", From: "+1222", To: "+1222" },
|
||||
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
|
||||
+6
-6
@@ -66,7 +66,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model list", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -97,7 +97,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -137,7 +137,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model list", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -178,7 +178,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model list", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -205,7 +205,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -235,7 +235,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model Opus", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model Opus", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
|
||||
+5
-3
@@ -68,7 +68,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model ki", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -135,7 +135,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -167,7 +167,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model Opus", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model Opus", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -200,6 +200,7 @@ describe("directive behavior", () => {
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -230,6 +231,7 @@ describe("directive behavior", () => {
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
|
||||
+5
@@ -72,6 +72,7 @@ describe("directive behavior", () => {
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
SessionKey: "agent:work:main",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -118,6 +119,7 @@ describe("directive behavior", () => {
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1333",
|
||||
SessionKey: "agent:work:main",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -163,6 +165,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -200,6 +203,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -235,6 +239,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
|
||||
+6
-3
@@ -72,6 +72,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -115,6 +116,7 @@ describe("directive behavior", () => {
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
SessionKey: "agent:restricted:main",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -153,7 +155,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
|
||||
{ Body: "/queue interrupt", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -185,6 +187,7 @@ describe("directive behavior", () => {
|
||||
Body: "/queue collect debounce:2s cap:5 drop:old",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -219,7 +222,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
|
||||
{ Body: "/queue interrupt", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -234,7 +237,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/queue reset", From: "+1222", To: "+1222" },
|
||||
{ Body: "/queue reset", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
|
||||
+6
@@ -72,6 +72,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -99,6 +100,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -153,6 +155,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
@@ -164,6 +167,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
@@ -176,6 +180,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
@@ -203,6 +208,7 @@ describe("directive behavior", () => {
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
SessionKey: "agent:restricted:main",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
|
||||
+4
-2
@@ -65,7 +65,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/verbose", From: "+1222", To: "+1222" },
|
||||
{ Body: "/verbose", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -90,7 +90,7 @@ describe("directive behavior", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/reasoning", From: "+1222", To: "+1222" },
|
||||
{ Body: "/reasoning", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -120,6 +120,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
@@ -158,6 +159,7 @@ describe("directive behavior", () => {
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
|
||||
+10
-5
@@ -66,7 +66,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model kimi", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -107,7 +107,12 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model kimi-k2-0905-preview", From: "+1222", To: "+1222" },
|
||||
{
|
||||
Body: "/model kimi-k2-0905-preview",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -148,7 +153,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -189,7 +194,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model minimax", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -234,7 +239,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model minimax/m2.1", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model minimax/m2.1", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
|
||||
+3
-3
@@ -153,7 +153,7 @@ describe("directive behavior", () => {
|
||||
});
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/verbose on", From: ctx.From, To: ctx.To },
|
||||
{ Body: "/verbose on", From: ctx.From, To: ctx.To, CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -193,7 +193,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -224,7 +224,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model status", From: "+1222", To: "+1222" },
|
||||
{ Body: "/model status", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
|
||||
@@ -54,6 +54,7 @@ describe("RawBody directive parsing", () => {
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
ChatType: "group",
|
||||
CommandAuthorized: true,
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
@@ -87,6 +88,7 @@ describe("RawBody directive parsing", () => {
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
ChatType: "group",
|
||||
CommandAuthorized: true,
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
@@ -123,6 +125,7 @@ describe("RawBody directive parsing", () => {
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
ChatType: "group",
|
||||
CommandAuthorized: true,
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
@@ -160,6 +163,7 @@ describe("RawBody directive parsing", () => {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
CommandAuthorized: true,
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
@@ -207,6 +211,7 @@ describe("RawBody directive parsing", () => {
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
ChatType: "group",
|
||||
CommandAuthorized: true,
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("group intro prompts", () => {
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
Body: "status update",
|
||||
From: "group:dev",
|
||||
From: "discord:group:dev",
|
||||
To: "+1888",
|
||||
ChatType: "group",
|
||||
GroupSubject: "Release Squad",
|
||||
@@ -172,7 +172,7 @@ describe("group intro prompts", () => {
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
Body: "ping",
|
||||
From: "group:tg",
|
||||
From: "telegram:group:tg",
|
||||
To: "+1777",
|
||||
ChatType: "group",
|
||||
GroupSubject: "Dev Chat",
|
||||
|
||||
+2
@@ -105,6 +105,7 @@ describe("trigger handling", () => {
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+999",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
@@ -179,6 +180,7 @@ describe("trigger handling", () => {
|
||||
Body: "/new",
|
||||
From: "+1003",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
|
||||
+2
-1
@@ -123,6 +123,7 @@ describe("trigger handling", () => {
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
@@ -210,7 +211,7 @@ describe("trigger handling", () => {
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated on",
|
||||
From: "group:123@g.us",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
To: "whatsapp:+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
|
||||
+5
-2
@@ -128,10 +128,11 @@ describe("trigger handling", () => {
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated off",
|
||||
From: "group:123@g.us",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
To: "whatsapp:+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
ChatType: "group",
|
||||
WasMentioned: false,
|
||||
},
|
||||
@@ -171,10 +172,11 @@ describe("trigger handling", () => {
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated on",
|
||||
From: "group:123@g.us",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
To: "whatsapp:+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
ChatType: "group",
|
||||
WasMentioned: true,
|
||||
},
|
||||
@@ -218,6 +220,7 @@ describe("trigger handling", () => {
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
|
||||
+6
@@ -116,6 +116,7 @@ describe("trigger handling", () => {
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
@@ -138,6 +139,7 @@ describe("trigger handling", () => {
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{
|
||||
onBlockReply: async (payload) => {
|
||||
@@ -162,6 +164,7 @@ describe("trigger handling", () => {
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{
|
||||
onBlockReply: async (payload) => {
|
||||
@@ -193,6 +196,7 @@ describe("trigger handling", () => {
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1002",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{
|
||||
onBlockReply: async (payload) => {
|
||||
@@ -217,6 +221,7 @@ describe("trigger handling", () => {
|
||||
Body: "[Dec 5 10:00] stop",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
@@ -233,6 +238,7 @@ describe("trigger handling", () => {
|
||||
Body: "/stop",
|
||||
From: "+1003",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
|
||||
+2
@@ -108,6 +108,7 @@ describe("trigger handling", () => {
|
||||
Body: "please /commands now",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{
|
||||
onBlockReply: async (payload) => {
|
||||
@@ -141,6 +142,7 @@ describe("trigger handling", () => {
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
SenderId: "12345",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{
|
||||
onBlockReply: async (payload) => {
|
||||
|
||||
+1
@@ -161,6 +161,7 @@ describe("trigger handling", () => {
|
||||
SenderName: "Peter Steinberger",
|
||||
SenderUsername: "steipete",
|
||||
SenderTag: "steipete",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
|
||||
+1
@@ -209,6 +209,7 @@ describe("trigger handling", () => {
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
|
||||
+2
@@ -184,6 +184,7 @@ describe("trigger handling", () => {
|
||||
Body: "/help",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
@@ -218,6 +219,7 @@ describe("trigger handling", () => {
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
|
||||
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user