Compare commits

..

40 Commits

Author SHA1 Message Date
Peter Steinberger 9f703a44dc chore(release): 2026.2.6-3
Docker Release / build-amd64 (push) Failing after 33s
Docker Release / build-arm64 (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
2026-02-07 00:44:32 -08:00
Peter Steinberger 85ed6c7fa4 chore(onboard): reorder xAI + Qianfan providers 2026-02-07 00:43:13 -08:00
Peter Steinberger ad4dd0422e chore(release): 2026.2.6-2
Docker Release / build-amd64 (push) Failing after 39s
Docker Release / build-arm64 (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
2026-02-07 00:30:43 -08:00
Peter Steinberger 4ba9809f18 test(hooks): stabilize session-memory hook tests 2026-02-07 00:22:34 -08:00
Peter Steinberger 80d42eb0ba fix(docker): support .mjs entrypoints in images and e2e 2026-02-07 00:22:34 -08:00
Peter Steinberger 2b6cf03b47 fix(build): support daemon-cli .mjs bundles in compat shim 2026-02-07 00:22:34 -08:00
Peter Steinberger 88ffad1c4f Merge PR #8868: add Baidu Qianfan support (thanks @ide-rea) 2026-02-07 00:19:04 -08:00
Peter Steinberger 875324e7c7 docs(changelog): note CI pipeline optimization (#10784) (thanks @mcaxtr) 2026-02-06 23:31:48 -08:00
Marcus Castro 2d7428a7f2 ci: re-enable parallel vitest on Windows CI 2026-02-06 23:31:48 -08:00
Marcus Castro 47596257ea ci: add concurrency controls, consolidate macOS jobs, optimize Windows CI 2026-02-06 23:31:48 -08:00
Peter Steinberger ab3045cb48 chore(onboard): move xAI below Google 2026-02-06 23:06:55 -08:00
Peter Steinberger aaddbdae52 chore(release): 2026.2.6-1
Docker Release / build-amd64 (push) Failing after 45s
Workflow Sanity / no-tabs (push) Failing after 34s
CI / install-check (push) Has been cancelled
CI / checks (pnpm build && pnpm lint, node, lint) (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && bunx vitest run, bun, test) (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks (pnpm format, node, format) (push) Has been cancelled
CI / checks (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks (pnpm tsgo, node, tsgo) (push) Has been cancelled
CI / secrets (push) Has been cancelled
CI / checks-windows (pnpm build && pnpm lint, node, build & lint) (push) Has been cancelled
CI / checks-windows (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks-macos (pnpm test, test) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
2026-02-06 22:48:19 -08:00
Peter Steinberger 8d0e7997c8 chore(onboard): move xAI up in auth list 2026-02-06 22:41:19 -08:00
Peter Steinberger 31a7e4f937 chore(skills): remove bird skill 2026-02-06 22:28:44 -08:00
Peter Steinberger c5194d8148 fix(dashboard): restore tokenized control ui links 2026-02-06 22:17:09 -08:00
ide-rea 43c0a7fe1c Merge branch 'openclaw:main' into qianfan 2026-02-07 14:07:52 +08:00
Jake e78ae48e69 fix(memory): add input_type to Voyage AI embeddings for improved retrieval (#10818)
* fix(memory): add input_type to Voyage AI embeddings for improved retrieval

Voyage AI recommends passing input_type='document' when indexing and
input_type='query' when searching. This improves retrieval quality by
optimising the embedding space for each direction.

Changes:
- embedQuery now passes input_type: 'query'
- embedBatch now passes input_type: 'document'
- Batch API request_params includes input_type: 'document'
- Tests updated to verify input_type is passed correctly

* Changelog: note Voyage embeddings input_type fix (#10818) (thanks @mcinteerj)

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-06 21:55:09 -06:00
Markus Buhatem Koch 4c1da23a71 Revert previous change from 'Clawdbot' to 'OpenClaw' in lore (#9119) 2026-02-06 21:53:02 -05:00
Val Alexander 3d2fe9284e Fix repository links in formal-verification.md (#10200)
Updated repository links for formal verification models.
2026-02-06 21:47:55 -05:00
Peter Steinberger a3b5f1b15c fix(build): unblock pnpm build dts 2026-02-06 18:43:11 -08:00
DEOKLYONG MOON d1dc60774b Docs: fix broken /plugins links (#9308)
* Docs: fix broken /plugins links to /plugin

The documentation linked to /plugins which doesn't exist.
The correct path is /plugin (singular) which contains the
plugins overview documentation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: drop manual zh-CN doc edits from plugins link fix

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-06 21:08:26 -05:00
Tyler Yust d90cac990c fix: cron scheduler reliability, store hardening, and UX improvements (#10776)
* refactor: update cron job wake mode and run mode handling

- Changed default wake mode from 'next-heartbeat' to 'now' in CronJobEditor and related CLI commands.
- Updated cron-tool tests to reflect changes in run mode, introducing 'due' and 'force' options.
- Enhanced cron-tool logic to handle new run modes and ensure compatibility with existing job structures.
- Added new tests for delivery plan consistency and job execution behavior under various conditions.
- Improved normalization functions to handle wake mode and session target casing.

This refactor aims to streamline cron job configurations and enhance the overall user experience with clearer defaults and improved functionality.

* test: enhance cron job functionality and UI

- Added tests to ensure the isolated agent correctly announces the final payload text when delivering messages via Telegram.
- Implemented a new function to pick the last deliverable payload from a list of delivery payloads.
- Enhanced the cron service to maintain legacy "every" jobs while minute cron jobs recompute schedules.
- Updated the cron store migration tests to verify the addition of anchorMs to legacy every schedules.
- Improved the UI for displaying cron job details, including job state and delivery information, with new styles and layout adjustments.

These changes aim to improve the reliability and user experience of the cron job system.

* test: enhance sessions thinking level handling

- Added tests to verify that the correct thinking levels are applied during session spawning.
- Updated the sessions-spawn-tool to include a new parameter for overriding thinking levels.
- Enhanced the UI to support additional thinking levels, including "xhigh" and "full", and improved the handling of current options in dropdowns.

These changes aim to improve the flexibility and accuracy of thinking level configurations in session management.

* feat: enhance session management and cron job functionality

- Introduced passthrough arguments in the test-parallel script to allow for flexible command-line options.
- Updated session handling to hide cron run alias session keys from the sessions list, improving clarity.
- Enhanced the cron service to accurately record job start times and durations, ensuring better tracking of job execution.
- Added tests to verify the correct behavior of the cron service under various conditions, including zero-delay timers.

These changes aim to improve the usability and reliability of session and cron job management.

* feat: implement job running state checks in cron service

- Added functionality to prevent manual job runs if a job is already in progress, enhancing job management.
- Updated the `isJobDue` function to include checks for running jobs, ensuring accurate scheduling.
- Enhanced the `run` function to return a specific reason when a job is already running.
- Introduced a new test case to verify the behavior of forced manual runs during active job execution.

These changes aim to improve the reliability and clarity of cron job execution and management.

* feat: add session ID and key to CronRunLogEntry model

- Introduced `sessionid` and `sessionkey` properties to the `CronRunLogEntry` struct for enhanced tracking of session-related information.
- Updated the initializer and Codable conformance to accommodate the new properties, ensuring proper serialization and deserialization.

These changes aim to improve the granularity of logging and session management within the cron job system.

* fix: improve session display name resolution

- Updated the `resolveSessionDisplayName` function to ensure that both label and displayName are trimmed and default to an empty string if not present.
- Enhanced the logic to prevent returning the key if it matches the label or displayName, improving clarity in session naming.

These changes aim to enhance the accuracy and usability of session display names in the UI.

* perf: skip cron store persist when idle timer tick produces no changes

recomputeNextRuns now returns a boolean indicating whether any job
state was mutated. The idle path in onTimer only persists when the
return value is true, eliminating unnecessary file writes every 60s
for far-future or idle schedules.

* fix: prep for merge - explicit delivery mode migration, docs + changelog (#10776) (thanks @tyler6204)
2026-02-06 18:03:03 -08:00
ideoutrea 0b51f0d762 Fix conflicts 2026-02-06 18:16:20 +08:00
ideoutrea 7a9deb2400 Resolve conflicts 2026-02-06 18:13:42 +08:00
ide-rea 3997316fb0 Merge branch 'main' into qianfan 2026-02-06 17:58:28 +08:00
ideoutrea 360851366f Support ERNIE-5.0-Thinking-Preview 2026-02-06 17:49:54 +08:00
ideoutrea 7af00f040a Optimize import 2026-02-05 14:53:38 +08:00
ideoutrea 4d30f97407 Fix key resolve 2026-02-05 14:40:56 +08:00
ideoutrea ff948a6dd7 Optimize doc 2026-02-05 14:04:23 +08:00
ideoutrea ad759c9446 Optimize format 2026-02-05 13:50:09 +08:00
ide-rea 9ccbd57016 Merge branch 'openclaw:main' into qianfan 2026-02-05 13:36:44 +08:00
ideoutrea 52c9d3480f Add auth choice 2026-02-05 13:35:35 +08:00
ide-rea 517a8eafe5 Merge branch 'openclaw:main' into qianfan 2026-02-05 12:43:21 +08:00
ideoutrea c8e67ad5d5 Fix import error 2026-02-05 12:39:38 +08:00
ideoutrea fb5280e1b5 optimize doc 2026-02-04 22:57:34 +08:00
ide-rea 009abd306a Merge branch 'main' into qianfan 2026-02-04 22:39:13 +08:00
ideoutrea 8c53dfb74f Optimize doc 2026-02-04 22:30:42 +08:00
ideoutrea 7bf4080608 Fix format 2026-02-04 22:27:51 +08:00
ideoutrea 1de05ad068 Add baidu qianfan model provider 2026-02-04 22:27:49 +08:00
ideoutrea 30ac80b96b Add baidu qianfan model provider 2026-02-04 16:36:37 +08:00
153 changed files with 3828 additions and 704 deletions
+67 -66
View File
@@ -2,8 +2,13 @@ name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
install-check:
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -185,7 +190,9 @@ jobs:
runs-on: blacksmith-4vcpu-windows-2025
env:
NODE_OPTIONS: --max-old-space-size=4096
CLAWDBOT_TEST_WORKERS: 1
# Keep total concurrency predictable on the 4 vCPU runner:
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
OPENCLAW_TEST_WORKERS: 2
defaults:
run:
shell: bash
@@ -208,6 +215,25 @@ jobs:
with:
submodules: false
- name: Try to exclude workspace from Windows Defender (best-effort)
shell: pwsh
run: |
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
if (-not $cmd) {
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
exit 0
}
try {
# Defender sometimes intercepts process spawning (vitest workers). If this fails
# (eg hardened images), keep going and rely on worker limiting above.
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
Write-Host "Defender exclusions applied."
} catch {
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
}
- name: Checkout submodules (retry)
run: |
set -euo pipefail
@@ -269,15 +295,13 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
checks-macos:
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
# running 4 separate jobs per PR (as before) starved the queue. One job
# per PR allows 5 PRs to run macOS checks simultaneously.
macos:
if: github.event_name == 'pull_request'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: test
command: pnpm test
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -297,6 +321,7 @@ jobs:
done
exit 1
# --- Node/pnpm setup (for TS tests) ---
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -336,71 +361,20 @@ jobs:
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
# --- Run all checks sequentially (fast gates first) ---
- name: TS tests (macOS)
env:
NODE_OPTIONS: --max-old-space-size=4096
run: ${{ matrix.command }}
macos-app:
if: github.event_name == 'pull_request'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: lint
command: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- task: build
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- task: test
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
run: pnpm test
# --- Xcode/Swift setup ---
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install XcodeGen / SwiftLint / SwiftFormat
run: |
brew install xcodegen swiftlint swiftformat
run: brew install xcodegen swiftlint swiftformat
- name: Show toolchain
run: |
@@ -408,8 +382,35 @@ jobs:
xcodebuild -version
swift --version
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
- name: Swift lint
run: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- name: Swift build (release)
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- name: Swift test
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
ios:
if: false # ignore iOS in CI for now
runs-on: macos-latest
+4
View File
@@ -3,6 +3,10 @@ name: Formal models (informational conformance)
on:
pull_request:
concurrency:
group: formal-conformance-${{ github.event.pull_request.number || github.ref_name }}
cancel-in-progress: true
jobs:
formal_conformance:
runs-on: ubuntu-latest
+4
View File
@@ -6,6 +6,10 @@ on:
pull_request:
workflow_dispatch:
concurrency:
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
install-smoke:
runs-on: ubuntu-latest
+5
View File
@@ -3,6 +3,11 @@ name: Workflow Sanity
on:
pull_request:
push:
branches: [main]
concurrency:
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
no-tabs:
+13
View File
@@ -6,16 +6,29 @@ Docs: https://docs.openclaw.ai
### Changes
- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204.
- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204.
- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204.
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
### Added
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
### Fixes
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.
+2 -2
View File
@@ -44,5 +44,5 @@ USER node
#
# For container platforms requiring external health checks:
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
# 2. Override CMD: ["node","dist/index.js","gateway","--allow-unconfigured","--bind","lan"]
CMD ["node", "dist/index.js", "gateway", "--allow-unconfigured"]
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
@@ -29,7 +29,7 @@ struct CronJobEditor: View {
@State var agentId: String = ""
@State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat
@State var wakeMode: CronWakeMode = .now
@State var deleteAfterRun: Bool = false
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@@ -119,8 +119,8 @@ struct CronJobEditor: View {
GridRow {
self.gridLabel("Wake mode")
Picker("", selection: self.$wakeMode) {
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
Text("now").tag(CronWakeMode.now)
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
}
.labelsHidden()
.pickerStyle(.segmented)
@@ -2025,6 +2025,8 @@ public struct CronRunLogEntry: Codable, Sendable {
public let status: AnyCodable?
public let error: String?
public let summary: String?
public let sessionid: String?
public let sessionkey: String?
public let runatms: Int?
public let durationms: Int?
public let nextrunatms: Int?
@@ -2036,6 +2038,8 @@ public struct CronRunLogEntry: Codable, Sendable {
status: AnyCodable?,
error: String?,
summary: String?,
sessionid: String?,
sessionkey: String?,
runatms: Int?,
durationms: Int?,
nextrunatms: Int?
@@ -2046,6 +2050,8 @@ public struct CronRunLogEntry: Codable, Sendable {
self.status = status
self.error = error
self.summary = summary
self.sessionid = sessionid
self.sessionkey = sessionkey
self.runatms = runatms
self.durationms = durationms
self.nextrunatms = nextrunatms
@@ -2057,6 +2063,8 @@ public struct CronRunLogEntry: Codable, Sendable {
case status
case error
case summary
case sessionid = "sessionId"
case sessionkey = "sessionKey"
case runatms = "runAtMs"
case durationms = "durationMs"
case nextrunatms = "nextRunAtMs"
@@ -2025,6 +2025,8 @@ public struct CronRunLogEntry: Codable, Sendable {
public let status: AnyCodable?
public let error: String?
public let summary: String?
public let sessionid: String?
public let sessionkey: String?
public let runatms: Int?
public let durationms: Int?
public let nextrunatms: Int?
@@ -2036,6 +2038,8 @@ public struct CronRunLogEntry: Codable, Sendable {
status: AnyCodable?,
error: String?,
summary: String?,
sessionid: String?,
sessionkey: String?,
runatms: Int?,
durationms: Int?,
nextrunatms: Int?
@@ -2046,6 +2050,8 @@ public struct CronRunLogEntry: Codable, Sendable {
self.status = status
self.error = error
self.summary = summary
self.sessionid = sessionid
self.sessionkey = sessionkey
self.runatms = runatms
self.durationms = durationms
self.nextrunatms = nextrunatms
@@ -2057,6 +2063,8 @@ public struct CronRunLogEntry: Codable, Sendable {
case status
case error
case summary
case sessionid = "sessionId"
case sessionkey = "sessionKey"
case runatms = "runAtMs"
case durationms = "durationMs"
case nextrunatms = "nextRunAtMs"
+7 -6
View File
@@ -40,7 +40,7 @@ openclaw cron add \
--delete-after-run
openclaw cron list
openclaw cron run <job-id> --force
openclaw cron run <job-id>
openclaw cron runs --id <job-id>
```
@@ -123,8 +123,8 @@ local timezone is used.
Main jobs enqueue a system event and optionally wake the heartbeat runner.
They must use `payload.kind = "systemEvent"`.
- `wakeMode: "next-heartbeat"` (default): event waits for the next scheduled heartbeat.
- `wakeMode: "now"`: event triggers an immediate heartbeat run.
- `wakeMode: "now"` (default): event triggers an immediate heartbeat run.
- `wakeMode: "next-heartbeat"`: event waits for the next scheduled heartbeat.
This is the best fit when you want the normal heartbeat prompt + main-session context.
See [Heartbeat](/gateway/heartbeat).
@@ -288,7 +288,7 @@ Notes:
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
`delivery`.
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
- `wakeMode` defaults to `"now"` when omitted.
### cron.update params
@@ -420,10 +420,11 @@ openclaw cron edit <jobId> --agent ops
openclaw cron edit <jobId> --clear-agent
```
Manual run (debug):
Manual run (force is the default, use `--due` to only run when due):
```bash
openclaw cron run <jobId> --force
openclaw cron run <jobId>
openclaw cron run <jobId> --due
```
Edit an existing job (patch fields):
+1 -1
View File
@@ -337,4 +337,4 @@ Prefer `chat_guid` for stable routing:
- OpenClaw auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`.
- For status/health info: `openclaw status --all` or `openclaw status --deep`.
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugins) guide.
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugin) guide.
+1 -1
View File
@@ -14,7 +14,7 @@ Provided by the active memory plugin (default: `memory-core`; set `plugins.slots
Related:
- Memory concept: [Memory](/concepts/memory)
- Plugins: [Plugins](/plugins)
- Plugins: [Plugins](/plugin)
## Examples
+6 -1
View File
@@ -98,6 +98,10 @@
"source": "/opencode",
"destination": "/providers/opencode"
},
{
"source": "/qianfan",
"destination": "/providers/qianfan"
},
{
"source": "/mattermost",
"destination": "/channels/mattermost"
@@ -1006,7 +1010,8 @@
"providers/opencode",
"providers/glm",
"providers/zai",
"providers/synthetic"
"providers/synthetic",
"providers/qianfan"
]
}
]
+3 -3
View File
@@ -23,7 +23,7 @@ misconfiguration safety), under explicit assumptions.
## Where the models live
Models are maintained in a separate repo: [vignesh07/openclaw-formal-models](https://github.com/vignesh07/openclaw-formal-models).
Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
## Important caveats
@@ -41,8 +41,8 @@ Today, results are reproduced by cloning the models repo locally and running TLC
Getting started:
```bash
git clone https://github.com/vignesh07/openclaw-formal-models
cd openclaw-formal-models
git clone https://github.com/vignesh07/clawdbot-formal-models
cd clawdbot-formal-models
# Java 11+ required (TLC runs on the JVM).
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
+1
View File
@@ -50,6 +50,7 @@ See [Venice AI](/providers/venice).
- [MiniMax](/providers/minimax)
- [Venice (Venice AI, privacy-focused)](/providers/venice)
- [Ollama (local models)](/providers/ollama)
- [Qianfan](/providers/qianfan)
## Transcription providers
+1
View File
@@ -46,6 +46,7 @@ See [Venice AI](/providers/venice).
- [MiniMax](/providers/minimax)
- [Venice (Venice AI)](/providers/venice)
- [Amazon Bedrock](/bedrock)
- [Qianfan](/providers/qianfan)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).
+38
View File
@@ -0,0 +1,38 @@
---
summary: "Use Qianfan's unified API to access many models in OpenClaw"
read_when:
- You want a single API key for many LLMs
- You need Baidu Qianfan setup guidance
title: "Qianfan"
---
# Qianfan Provider Guide
Qianfan is Baidu's MaaS platform, provides a **unified API** that routes requests to many models behind a single
endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switching the base URL.
## Prerequisites
1. A Baidu Cloud account with Qianfan API access
2. An API key from the Qianfan console
3. OpenClaw installed on your system
## Getting Your API Key
1. Visit the [Qianfan Console](https://console.bce.baidu.com/qianfan/ais/console/apiKey)
2. Create a new application or select an existing one
3. Generate an API key (format: `bce-v3/ALTAK-...`)
4. Copy the API key for use with OpenClaw
## CLI setup
```bash
openclaw onboard --auth-choice qianfan-api-key
```
## Related Documentation
- [OpenClaw Configuration](/configuration)
- [Model Providers](/concepts/model-providers)
- [Agent Setup](/agents)
- [Qianfan API Documentation](https://cloud.baidu.com/doc/qianfan-api/s/3m7of64lb)
-1
View File
@@ -110,7 +110,6 @@ git commit -m "Add Clawd workspace"
- **OpenHue CLI** — Philips Hue lighting control for scenes and automations.
- **OpenAI Whisper** — Local speech-to-text for quick dictation and voicemail transcripts.
- **Gemini CLI** — Google Gemini models from the terminal for fast Q&A.
- **bird** — X/Twitter CLI to tweet, reply, read threads, and search without a browser.
- **agent-tools** — Utility toolkit for automations and helper scripts.
## Usage Notes
+1
View File
@@ -35,6 +35,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.openclaw/.env` so launchd can read it.
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth).
- **API key**: stores the key for you.
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
+1 -1
View File
@@ -15,7 +15,7 @@ In the beginning, there was **Warelay** — a sensible name for a WhatsApp gatew
But then came a space lobster.
For a while, the lobster was called **Clawd**, living in an **OpenClaw**. But in January 2026, Anthropic sent a polite email asking for a name change (trademark stuff). And so the lobster did what lobsters do best:
For a while, the lobster was called **Clawd**, living in a **Clawdbot**. But in January 2026, Anthropic sent a polite email asking for a name change (trademark stuff). And so the lobster did what lobsters do best:
**It molted.**
+3
View File
@@ -145,6 +145,9 @@ What you set:
Sets `agents.defaults.model` to `openai/gpt-5.1-codex` when model is unset, `openai/*`, or `openai-codex/*`.
</Accordion>
<Accordion title="xAI (Grok) API key">
Prompts for `XAI_API_KEY` and configures xAI as a model provider.
</Accordion>
<Accordion title="OpenCode Zen">
Prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`).
Setup URL: [opencode.ai/auth](https://opencode.ai/auth).
+1 -2
View File
@@ -34,8 +34,7 @@ If you have multiple profiles, pass `--browser-profile <name>` (the default is `
## X/Twitter: recommended flow
- **Read/search/threads:** use the **bird** CLI skill (no browser, stable).
- Repo: [https://github.com/steipete/bird](https://github.com/steipete/bird)
- **Read/search/threads:** use the **host** browser (manual login).
- **Post updates:** use the **host** browser (manual login).
## Sandboxing + host browser access
+1 -1
View File
@@ -102,7 +102,7 @@ Legacy `agents.default` entries are migrated to `agents.main` on load.
Examples:
- `~/Projects/**/bin/bird`
- `~/Projects/**/bin/peekaboo`
- `~/.local/bin/*`
- `/opt/homebrew/bin/rg`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-antigravity-auth",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Google Antigravity OAuth provider plugin",
"type": "module",
"devDependencies": {
@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Google Chat channel plugin",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw iMessage channel plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw LINE channel plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"devDependencies": {
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-core",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw core memory search plugin",
"type": "module",
"devDependencies": {
+17 -3
View File
@@ -6,8 +6,8 @@
* Provides seamless auto-recall and auto-capture via lifecycle hooks.
*/
import type * as LanceDB from "@lancedb/lancedb";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import * as lancedb from "@lancedb/lancedb";
import { Type } from "@sinclair/typebox";
import { randomUUID } from "node:crypto";
import OpenAI from "openai";
@@ -23,6 +23,19 @@ import {
// Types
// ============================================================================
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
if (!lancedbImportPromise) {
lancedbImportPromise = import("@lancedb/lancedb");
}
try {
return await lancedbImportPromise;
} catch (err) {
// Common on macOS today: upstream package may not ship darwin native bindings.
throw new Error(`memory-lancedb: failed to load LanceDB. ${String(err)}`);
}
};
type MemoryEntry = {
id: string;
text: string;
@@ -44,8 +57,8 @@ type MemorySearchResult = {
const TABLE_NAME = "memories";
class MemoryDB {
private db: lancedb.Connection | null = null;
private table: lancedb.Table | null = null;
private db: LanceDB.Connection | null = null;
private table: LanceDB.Table | null = null;
private initPromise: Promise<void> | null = null;
constructor(
@@ -66,6 +79,7 @@ class MemoryDB {
}
private async doInitialize(): Promise<void> {
const lancedb = await loadLanceDB();
this.db = await lancedb.connect(this.dbPath);
const tables = await this.db.tableNames();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",
"devDependencies": {
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"devDependencies": {
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/nostr",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Signal channel plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Slack channel plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Telegram channel plugin",
"type": "module",
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Twitch channel plugin",
"type": "module",
"dependencies": {
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",
"devDependencies": {
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalo",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalouser",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Zalo Personal Account plugin via zca-cli",
"type": "module",
"dependencies": {
+20 -1
View File
@@ -11,4 +11,23 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
}
}
await import("./dist/entry.js");
const tryImport = async (specifier) => {
try {
await import(specifier);
return true;
} catch (err) {
// Only swallow missing-module errors; rethrow real runtime errors.
if (err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND") {
return false;
}
throw err;
}
};
if (await tryImport("./dist/entry.js")) {
// OK
} else if (await tryImport("./dist/entry.mjs")) {
// OK
} else {
throw new Error("openclaw: missing dist/entry.(m)js (build output).");
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"keywords": [],
"license": "MIT",
@@ -32,7 +32,8 @@
"android:install": "cd apps/android && ./gradlew :app:installDebug",
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.android/.MainActivity",
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
"build": "pnpm canvas:a2ui:bundle && tsdown && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
"build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm tsgo && pnpm lint && pnpm format",
"check:docs": "pnpm format:docs && pnpm lint:docs && pnpm docs:build",
+1 -1
View File
@@ -6,7 +6,7 @@ WORKDIR /app
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
COPY src ./src
COPY test ./test
COPY scripts ./scripts
+14 -4
View File
@@ -80,10 +80,20 @@ LOGINCTL
fi
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
npm_bin="/tmp/npm-prefix/bin/openclaw"
npm_entry="/tmp/npm-prefix/lib/node_modules/openclaw/dist/index.js"
git_entry="/app/dist/index.js"
git_cli="/app/openclaw.mjs"
npm_bin="/tmp/npm-prefix/bin/openclaw"
npm_root="/tmp/npm-prefix/lib/node_modules/openclaw"
if [ -f "$npm_root/dist/index.mjs" ]; then
npm_entry="$npm_root/dist/index.mjs"
else
npm_entry="$npm_root/dist/index.js"
fi
if [ -f "/app/dist/index.mjs" ]; then
git_entry="/app/dist/index.mjs"
else
git_entry="/app/dist/index.js"
fi
git_cli="/app/openclaw.mjs"
assert_entrypoint() {
local unit_path="$1"
+3 -3
View File
@@ -31,7 +31,7 @@ echo "Starting gateway container..."
-e "OPENCLAW_SKIP_CRON=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
"$IMAGE_NAME" \
bash -lc "node dist/index.js gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1"
bash -lc "entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1"
echo "Waiting for gateway to come up..."
ready=0
@@ -77,9 +77,9 @@ docker run --rm \
-e "GW_URL=ws://$GW_NAME:$PORT" \
-e "GW_TOKEN=$TOKEN" \
"$IMAGE_NAME" \
bash -lc "node - <<'NODE'
bash -lc "node --import tsx - <<'NODE'
import { WebSocket } from \"ws\";
import { PROTOCOL_VERSION } from \"./dist/gateway/protocol/index.js\";
import { PROTOCOL_VERSION } from \"./src/gateway/protocol/index.ts\";
const url = process.env.GW_URL;
const token = process.env.GW_TOKEN;
+44 -33
View File
@@ -10,9 +10,20 @@ docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
echo "Running onboarding E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
trap "" PIPE
export TERM=xterm-256color
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
# tsdown may emit dist/index.js or dist/index.mjs depending on runtime/bundler.
if [ -f dist/index.mjs ]; then
OPENCLAW_ENTRY="dist/index.mjs"
elif [ -f dist/index.js ]; then
OPENCLAW_ENTRY="dist/index.js"
else
echo "Missing dist/index.(m)js (build output):"
ls -la dist || true
exit 1
fi
export OPENCLAW_ENTRY
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
export PATH="/tmp/openclaw-bin:$PATH"
@@ -82,10 +93,10 @@ TRASH
done
}
start_gateway() {
node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
GATEWAY_PID="$!"
}
start_gateway() {
node "$OPENCLAW_ENTRY" gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
GATEWAY_PID="$!"
}
wait_for_gateway() {
for _ in $(seq 1 20); do
@@ -184,9 +195,9 @@ TRASH
local send_fn="$3"
local validate_fn="${4:-}"
# Default onboarding command wrapper.
run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
}
# Default onboarding command wrapper.
run_wizard_cmd "$case_name" "$home_dir" "node \"$OPENCLAW_ENTRY\" onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
}
make_home() {
mktemp -d "/tmp/openclaw-e2e-$1.XXXXXX"
@@ -263,14 +274,14 @@ TRASH
send "" 1.0
}
run_case_local_basic() {
local home_dir
home_dir="$(make_home local-basic)"
export HOME="$home_dir"
mkdir -p "$HOME"
node dist/index.js onboard \
--non-interactive \
--accept-risk \
run_case_local_basic() {
local home_dir
home_dir="$(make_home local-basic)"
export HOME="$home_dir"
mkdir -p "$HOME"
node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--skip-channels \
@@ -343,11 +354,11 @@ NODE
local home_dir
home_dir="$(make_home remote-non-interactive)"
export HOME="$home_dir"
mkdir -p "$HOME"
# Smoke test non-interactive remote config write.
node dist/index.js onboard --non-interactive --accept-risk \
--mode remote \
--remote-url ws://gateway.local:18789 \
mkdir -p "$HOME"
# Smoke test non-interactive remote config write.
node "$OPENCLAW_ENTRY" onboard --non-interactive --accept-risk \
--mode remote \
--remote-url ws://gateway.local:18789 \
--remote-token remote-token \
--skip-skills \
--skip-health
@@ -388,7 +399,7 @@ NODE
export HOME="$home_dir"
mkdir -p "$HOME/.openclaw"
# Seed a remote config to exercise reset path.
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
{
"agents": { "defaults": { "workspace": "/root/old" } },
"gateway": {
@@ -398,9 +409,9 @@ NODE
}
JSON
node dist/index.js onboard \
--non-interactive \
--accept-risk \
node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--reset \
@@ -438,10 +449,10 @@ NODE
}
run_case_channels() {
local home_dir
home_dir="$(make_home channels)"
# Channels-only configure flow.
run_wizard_cmd channels "$home_dir" "node dist/index.js configure --section channels" send_channels_flow
local home_dir
home_dir="$(make_home channels)"
# Channels-only configure flow.
run_wizard_cmd channels "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow
config_path="$HOME/.openclaw/openclaw.json"
assert_file "$config_path"
@@ -483,7 +494,7 @@ NODE
export HOME="$home_dir"
mkdir -p "$HOME/.openclaw"
# Seed skills config to ensure it survives the wizard.
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
{
"skills": {
"allowBundled": ["__none__"],
@@ -492,7 +503,7 @@ NODE
}
JSON
run_wizard_cmd skills "$home_dir" "node dist/index.js configure --section skills" send_skills_flow
run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow
config_path="$HOME/.openclaw/openclaw.json"
assert_file "$config_path"
+21 -11
View File
@@ -8,11 +8,21 @@ echo "Building Docker image..."
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
echo "Running plugins Docker E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
if [ -f dist/index.mjs ]; then
OPENCLAW_ENTRY="dist/index.mjs"
elif [ -f dist/index.js ]; then
OPENCLAW_ENTRY="dist/index.js"
else
echo "Missing dist/index.(m)js (build output):"
ls -la dist || true
exit 1
fi
export OPENCLAW_ENTRY
home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX")
export HOME="$home_dir"
home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX")
export HOME="$home_dir"
mkdir -p "$HOME/.openclaw/extensions/demo-plugin"
cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'"'"'JS'"'"'
@@ -38,7 +48,7 @@ JS
}
JSON
node dist/index.js plugins list --json > /tmp/plugins.json
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json
node - <<'"'"'NODE'"'"'
const fs = require("node:fs");
@@ -99,8 +109,8 @@ JS
JSON
tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package
node dist/index.js plugins install /tmp/demo-plugin-tgz.tgz
node dist/index.js plugins list --json > /tmp/plugins2.json
node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json
node - <<'"'"'NODE'"'"'
const fs = require("node:fs");
@@ -145,8 +155,8 @@ JS
}
JSON
node dist/index.js plugins install "$dir_plugin"
node dist/index.js plugins list --json > /tmp/plugins3.json
node "$OPENCLAW_ENTRY" plugins install "$dir_plugin"
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json
node - <<'"'"'NODE'"'"'
const fs = require("node:fs");
@@ -192,8 +202,8 @@ JS
}
JSON
node dist/index.js plugins install "file:$file_pack_dir/package"
node dist/index.js plugins list --json > /tmp/plugins4.json
node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package"
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json
node - <<'"'"'NODE'"'"'
const fs = require("node:fs");
+11 -4
View File
@@ -7,9 +7,9 @@ import { join, resolve } from "node:path";
type PackFile = { path: string };
type PackResult = { files?: PackFile[] };
const requiredPaths = [
"dist/index.js",
"dist/entry.js",
const requiredPathGroups = [
["dist/index.js", "dist/index.mjs"],
["dist/entry.js", "dist/entry.mjs"],
"dist/plugin-sdk/index.js",
"dist/plugin-sdk/index.d.ts",
"dist/build-info.json",
@@ -82,7 +82,14 @@ function main() {
const files = results.flatMap((entry) => entry.files ?? []);
const paths = new Set(files.map((file) => file.path));
const missing = requiredPaths.filter((path) => !paths.has(path));
const missing = requiredPathGroups
.flatMap((group) => {
if (Array.isArray(group)) {
return group.some((path) => paths.has(path)) ? [] : [group.join(" or ")];
}
return paths.has(group) ? [] : [group];
})
.toSorted();
const forbidden = [...paths].filter((path) =>
forbiddenPrefixes.some((prefix) => path.startsWith(prefix)),
);
+28 -5
View File
@@ -29,14 +29,13 @@ const shardCount = isWindowsCi
? shardOverride
: 2
: 1;
const windowsCiArgs = isWindowsCi
? ["--no-file-parallelism", "--dangerouslyIgnoreUnhandledErrors"]
: [];
const windowsCiArgs = isWindowsCi ? ["--dangerouslyIgnoreUnhandledErrors"] : [];
const passthroughArgs = process.argv.slice(2);
const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
const resolvedOverride =
Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const parallelRuns = isWindowsCi ? [] : runs.filter((entry) => entry.name !== "gateway");
const serialRuns = isWindowsCi ? runs : runs.filter((entry) => entry.name === "gateway");
const parallelRuns = runs.filter((entry) => entry.name !== "gateway");
const serialRuns = runs.filter((entry) => entry.name === "gateway");
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
const parallelCount = Math.max(1, parallelRuns.length);
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelCount));
@@ -96,6 +95,30 @@ const shutdown = (signal) => {
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
if (passthroughArgs.length > 0) {
const args = maxWorkers
? ["vitest", "run", "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...passthroughArgs]
: ["vitest", "run", ...windowsCiArgs, ...passthroughArgs];
const nodeOptions = process.env.NODE_OPTIONS ?? "";
const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce(
(acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()),
nodeOptions,
);
const code = await new Promise((resolve) => {
const child = spawn(pnpm, args, {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: nextNodeOptions },
shell: process.platform === "win32",
});
children.add(child);
child.on("exit", (exitCode, signal) => {
children.delete(child);
resolve(exitCode ?? (signal ? 1 : 0));
});
});
process.exit(Number(code) || 0);
}
const parallelCodes = await Promise.all(parallelRuns.map(run));
const failedParallel = parallelCodes.find((code) => code !== 0);
if (failedParallel !== undefined) {
+7 -3
View File
@@ -7,9 +7,13 @@ const distDir = path.join(rootDir, "dist");
const cliDir = path.join(distDir, "cli");
const findCandidates = () =>
fs
.readdirSync(distDir)
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
fs.readdirSync(distDir).filter((entry) => {
if (!entry.startsWith("daemon-cli-")) {
return false;
}
// tsdown can emit either .js or .mjs depending on bundler settings/runtime.
return entry.endsWith(".js") || entry.endsWith(".mjs");
});
// In rare cases, build output can land slightly after this script starts (depending on FS timing).
// Retry briefly to avoid flaky builds.
+9
View File
@@ -0,0 +1,9 @@
import fs from "node:fs";
import path from "node:path";
// `tsc` emits the entry d.ts at `dist/plugin-sdk/plugin-sdk/index.d.ts` because
// the source lives at `src/plugin-sdk/index.ts` and `rootDir` is `src/`.
// Keep a stable `dist/plugin-sdk/index.d.ts` alongside `index.js` for TS users.
const out = path.join(process.cwd(), "dist/plugin-sdk/index.d.ts");
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, 'export * from "./plugin-sdk/index";\n', "utf8");
-224
View File
@@ -1,224 +0,0 @@
---
name: bird
description: X/Twitter CLI for reading, searching, posting, and engagement via cookies.
homepage: https://bird.fast
metadata:
{
"openclaw":
{
"emoji": "🐦",
"requires": { "bins": ["bird"] },
"install":
[
{
"id": "brew",
"kind": "brew",
"formula": "steipete/tap/bird",
"bins": ["bird"],
"label": "Install bird (brew)",
"os": ["darwin"],
},
{
"id": "npm",
"kind": "node",
"package": "@steipete/bird",
"bins": ["bird"],
"label": "Install bird (npm)",
},
],
},
}
---
# bird 🐦
Fast X/Twitter CLI using GraphQL + cookie auth.
## Install
```bash
# npm/pnpm/bun
npm install -g @steipete/bird
# Homebrew (macOS, prebuilt binary)
brew install steipete/tap/bird
# One-shot (no install)
bunx @steipete/bird whoami
```
## Authentication
`bird` uses cookie-based auth.
Use `--auth-token` / `--ct0` to pass cookies directly, or `--cookie-source` for browser cookies.
Run `bird check` to see which source is active. For Arc/Brave, use `--chrome-profile-dir <path>`.
## Commands
### Account & Auth
```bash
bird whoami # Show logged-in account
bird check # Show credential sources
bird query-ids --fresh # Refresh GraphQL query ID cache
```
### Reading Tweets
```bash
bird read <url-or-id> # Read a single tweet
bird <url-or-id> # Shorthand for read
bird thread <url-or-id> # Full conversation thread
bird replies <url-or-id> # List replies to a tweet
```
### Timelines
```bash
bird home # Home timeline (For You)
bird home --following # Following timeline
bird user-tweets @handle -n 20 # User's profile timeline
bird mentions # Tweets mentioning you
bird mentions --user @handle # Mentions of another user
```
### Search
```bash
bird search "query" -n 10
bird search "from:steipete" --all --max-pages 3
```
### News & Trending
```bash
bird news -n 10 # AI-curated from Explore tabs
bird news --ai-only # Filter to AI-curated only
bird news --sports # Sports tab
bird news --with-tweets # Include related tweets
bird trending # Alias for news
```
### Lists
```bash
bird lists # Your lists
bird lists --member-of # Lists you're a member of
bird list-timeline <id> -n 20 # Tweets from a list
```
### Bookmarks & Likes
```bash
bird bookmarks -n 10
bird bookmarks --folder-id <id> # Specific folder
bird bookmarks --include-parent # Include parent tweet
bird bookmarks --author-chain # Author's self-reply chain
bird bookmarks --full-chain-only # Full reply chain
bird unbookmark <url-or-id>
bird likes -n 10
```
### Social Graph
```bash
bird following -n 20 # Users you follow
bird followers -n 20 # Users following you
bird following --user <id> # Another user's following
bird about @handle # Account origin/location info
```
### Engagement Actions
```bash
bird follow @handle # Follow a user
bird unfollow @handle # Unfollow a user
```
### Posting
```bash
bird tweet "hello world"
bird reply <url-or-id> "nice thread!"
bird tweet "check this out" --media image.png --alt "description"
```
**⚠️ Posting risks**: Posting is more likely to be rate limited; if blocked, use the browser tool instead.
## Media Uploads
```bash
bird tweet "hi" --media img.png --alt "description"
bird tweet "pics" --media a.jpg --media b.jpg # Up to 4 images
bird tweet "video" --media clip.mp4 # Or 1 video
```
## Pagination
Commands supporting pagination: `replies`, `thread`, `search`, `bookmarks`, `likes`, `list-timeline`, `following`, `followers`, `user-tweets`
```bash
bird bookmarks --all # Fetch all pages
bird bookmarks --max-pages 3 # Limit pages
bird bookmarks --cursor <cursor> # Resume from cursor
bird replies <id> --all --delay 1000 # Delay between pages (ms)
```
## Output Options
```bash
--json # JSON output
--json-full # JSON with raw API response
--plain # No emoji, no color (script-friendly)
--no-emoji # Disable emoji
--no-color # Disable ANSI colors (or set NO_COLOR=1)
--quote-depth n # Max quoted tweet depth in JSON (default: 1)
```
## Global Options
```bash
--auth-token <token> # Set auth_token cookie
--ct0 <token> # Set ct0 cookie
--cookie-source <source> # Cookie source for browser cookies (repeatable)
--chrome-profile <name> # Chrome profile name
--chrome-profile-dir <path> # Chrome/Chromium profile dir or cookie DB path
--firefox-profile <name> # Firefox profile
--timeout <ms> # Request timeout
--cookie-timeout <ms> # Cookie extraction timeout
```
## Config File
`~/.config/bird/config.json5` (global) or `./.birdrc.json5` (project):
```json5
{
cookieSource: ["chrome"],
chromeProfileDir: "/path/to/Arc/Profile",
timeoutMs: 20000,
quoteDepth: 1,
}
```
Environment variables: `BIRD_TIMEOUT_MS`, `BIRD_COOKIE_TIMEOUT_MS`, `BIRD_QUOTE_DEPTH`
## Troubleshooting
### Query IDs stale (404 errors)
```bash
bird query-ids --fresh
```
### Cookie extraction fails
- Check browser is logged into X
- Try different `--cookie-source`
- For Arc/Brave: use `--chrome-profile-dir`
---
**TL;DR**: Read/search/engage with CLI. Post carefully or use browser. 🐦
+24
View File
@@ -257,6 +257,30 @@ describe("getApiKeyForModel", () => {
}
});
it("resolves Qianfan API key from env", async () => {
const previous = process.env.QIANFAN_API_KEY;
try {
process.env.QIANFAN_API_KEY = "qianfan-test-key";
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
const resolved = await resolveApiKeyForProvider({
provider: "qianfan",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("qianfan-test-key");
expect(resolved.source).toContain("QIANFAN_API_KEY");
} finally {
if (previous === undefined) {
delete process.env.QIANFAN_API_KEY;
} else {
process.env.QIANFAN_API_KEY = previous;
}
}
});
it("resolves Vercel AI Gateway API key from env", async () => {
const previousGatewayKey = process.env.AI_GATEWAY_API_KEY;
+1
View File
@@ -302,6 +302,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
venice: "VENICE_API_KEY",
mistral: "MISTRAL_API_KEY",
opencode: "OPENCODE_API_KEY",
qianfan: "QIANFAN_API_KEY",
ollama: "OLLAMA_API_KEY",
};
const envVar = envMap[normalized];
@@ -0,0 +1,25 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveImplicitProviders } from "./models-config.providers.js";
describe("Qianfan provider", () => {
it("should include qianfan when QIANFAN_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const previous = process.env.QIANFAN_API_KEY;
process.env.QIANFAN_API_KEY = "test-key";
try {
const providers = await resolveImplicitProviders({ agentDir });
expect(providers?.qianfan).toBeDefined();
expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY");
} finally {
if (previous === undefined) {
delete process.env.QIANFAN_API_KEY;
} else {
process.env.QIANFAN_API_KEY = previous;
}
}
});
});
+45
View File
@@ -80,6 +80,17 @@ const OLLAMA_DEFAULT_COST = {
cacheWrite: 0,
};
export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2";
export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2";
const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304;
const QIANFAN_DEFAULT_MAX_TOKENS = 32768;
const QIANFAN_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
interface OllamaModel {
name: string;
modified_at: string;
@@ -403,6 +414,33 @@ async function buildOllamaProvider(): Promise<ProviderConfig> {
};
}
export function buildQianfanProvider(): ProviderConfig {
return {
baseUrl: QIANFAN_BASE_URL,
api: "openai-completions",
models: [
{
id: QIANFAN_DEFAULT_MODEL_ID,
name: "DEEPSEEK V3.2",
reasoning: true,
input: ["text"],
cost: QIANFAN_DEFAULT_COST,
contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW,
maxTokens: QIANFAN_DEFAULT_MAX_TOKENS,
},
{
id: "ernie-5.0-thinking-preview",
name: "ERNIE-5.0-Thinking-Preview",
reasoning: true,
input: ["text", "image"],
cost: QIANFAN_DEFAULT_COST,
contextWindow: 119000,
maxTokens: 64000,
},
],
};
}
export async function resolveImplicitProviders(params: {
agentDir: string;
}): Promise<ModelsConfig["providers"]> {
@@ -498,6 +536,13 @@ export async function resolveImplicitProviders(params: {
providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey };
}
const qianfanKey =
resolveEnvApiKeyVarName("qianfan") ??
resolveApiKeyFromProfiles({ provider: "qianfan", store: authStore });
if (qianfanKey) {
providers.qianfan = { ...buildQianfanProvider(), apiKey: qianfanKey };
}
return providers;
}
@@ -45,8 +45,12 @@ describe("sessions_spawn thinking defaults", () => {
const agentCall = calls
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
.findLast((call) => call.method === "agent");
const thinkingPatch = calls
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
.findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel);
expect(agentCall?.params?.thinking).toBe("high");
expect(thinkingPatch?.params?.thinkingLevel).toBe("high");
});
it("prefers explicit sessions_spawn.thinking over config default", async () => {
@@ -60,7 +64,11 @@ describe("sessions_spawn thinking defaults", () => {
const agentCall = calls
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
.findLast((call) => call.method === "agent");
const thinkingPatch = calls
.map((call) => call[0] as { method: string; params?: Record<string, unknown> })
.findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel);
expect(agentCall?.params?.thinking).toBe("low");
expect(thinkingPatch?.params?.thinkingLevel).toBe("low");
});
});
+18 -4
View File
@@ -30,8 +30,8 @@ describe("cron tool", () => {
],
["remove", { action: "remove", jobId: "job-1" }, { id: "job-1" }],
["remove", { action: "remove", id: "job-2" }, { id: "job-2" }],
["run", { action: "run", jobId: "job-1" }, { id: "job-1" }],
["run", { action: "run", id: "job-2" }, { id: "job-2" }],
["run", { action: "run", jobId: "job-1" }, { id: "job-1", mode: "force" }],
["run", { action: "run", id: "job-2" }, { id: "job-2", mode: "force" }],
["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }],
["runs", { action: "runs", id: "job-2" }, { id: "job-2" }],
])("%s sends id to gateway", async (action, args, expectedParams) => {
@@ -58,7 +58,21 @@ describe("cron tool", () => {
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: unknown;
};
expect(call?.params).toEqual({ id: "job-primary" });
expect(call?.params).toEqual({ id: "job-primary", mode: "force" });
});
it("supports due-only run mode", async () => {
const tool = createCronTool();
await tool.execute("call-due", {
action: "run",
jobId: "job-due",
runMode: "due",
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: unknown;
};
expect(call?.params).toEqual({ id: "job-due", mode: "due" });
});
it("normalizes cron.add job payloads", async () => {
@@ -86,7 +100,7 @@ describe("cron tool", () => {
deleteAfterRun: true,
schedule: { kind: "at", at: new Date(123).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
wakeMode: "now",
payload: { kind: "systemEvent", text: "hello" },
});
});
+5 -2
View File
@@ -18,6 +18,7 @@ import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-h
const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs", "wake"] as const;
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
const CRON_RUN_MODES = ["due", "force"] as const;
const REMINDER_CONTEXT_MESSAGES_MAX = 10;
const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220;
@@ -37,6 +38,7 @@ const CronToolSchema = Type.Object({
patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
text: Type.Optional(Type.String()),
mode: optionalStringEnum(CRON_WAKE_MODES),
runMode: optionalStringEnum(CRON_RUN_MODES),
contextMessages: Type.Optional(
Type.Number({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }),
),
@@ -312,7 +314,6 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
}
}
// [Fix Issue 3] Infer delivery target from session key for isolated jobs if not provided
if (
opts?.agentSessionKey &&
job &&
@@ -393,7 +394,9 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
if (!id) {
throw new Error("jobId required (id accepted for backward compatibility)");
}
return jsonResult(await callGatewayTool("cron.run", gatewayOpts, { id }));
const runMode =
params.runMode === "due" || params.runMode === "force" ? params.runMode : "force";
return jsonResult(await callGatewayTool("cron.run", gatewayOpts, { id, mode: runMode }));
}
case "runs": {
const id = readStringParam(params, "jobId") ?? readStringParam(params, "id");
+20
View File
@@ -214,6 +214,26 @@ export function createSessionsSpawnTool(opts?: {
modelWarning = messageText;
}
}
if (thinkingOverride !== undefined) {
try {
await callGateway({
method: "sessions.patch",
params: {
key: childSessionKey,
thinkingLevel: thinkingOverride === "off" ? null : thinkingOverride,
},
timeoutMs: 10_000,
});
} catch (err) {
const messageText =
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return jsonResult({
status: "error",
error: messageText,
childSessionKey,
});
}
}
const childSystemPrompt = buildSubagentSystemPrompt({
requesterSessionKey,
requesterOrigin,
+3 -3
View File
@@ -71,7 +71,7 @@ export function registerCronAddCommand(cron: Command) {
.option("--keep-after-run", "Keep one-shot job after it succeeds", false)
.option("--agent <id>", "Agent id for this job")
.option("--session <target>", "Session target (main|isolated)")
.option("--wake <mode>", "Wake mode (now|next-heartbeat)", "next-heartbeat")
.option("--wake <mode>", "Wake mode (now|next-heartbeat)", "now")
.option("--at <when>", "Run once at time (ISO) or +duration (e.g. 20m)")
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
.option("--cron <expr>", "Cron expression (5-field)")
@@ -122,8 +122,8 @@ export function registerCronAddCommand(cron: Command) {
};
})();
const wakeModeRaw = typeof opts.wake === "string" ? opts.wake : "next-heartbeat";
const wakeMode = wakeModeRaw.trim() || "next-heartbeat";
const wakeModeRaw = typeof opts.wake === "string" ? opts.wake : "now";
const wakeMode = wakeModeRaw.trim() || "now";
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
throw new Error("--wake must be now or next-heartbeat");
}
+2 -2
View File
@@ -92,12 +92,12 @@ export function registerCronSimpleCommands(cron: Command) {
.command("run")
.description("Run a cron job now (debug)")
.argument("<id>", "Job id")
.option("--force", "Run even if not due", false)
.option("--due", "Run only when due (default behavior in older versions)", false)
.action(async (id, opts) => {
try {
const res = await callGatewayFromCli("cron.run", opts, {
id,
mode: opts.force ? "force" : "due",
mode: opts.due ? "due" : "force",
});
defaultRuntime.log(JSON.stringify(res, null, 2));
} catch (err) {
+3 -1
View File
@@ -58,7 +58,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|cloudflare-ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|xai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
"Auth: setup-token|token|chutes|openai-codex|openai-api-key|xai-api-key|qianfan-api-key|openrouter-api-key|ai-gateway-api-key|cloudflare-ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
)
.option(
"--token-provider <id>",
@@ -87,6 +87,7 @@ export function registerOnboardCommand(program: Command) {
.option("--venice-api-key <key>", "Venice API key")
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
.option("--xai-api-key <key>", "xAI API key")
.option("--qianfan-api-key <key>", "QIANFAN API key")
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
.option("--gateway-auth <mode>", "Gateway auth: token|password")
@@ -137,6 +138,7 @@ export function registerOnboardCommand(program: Command) {
geminiApiKey: opts.geminiApiKey as string | undefined,
zaiApiKey: opts.zaiApiKey as string | undefined,
xiaomiApiKey: opts.xiaomiApiKey as string | undefined,
qianfanApiKey: opts.qianfanApiKey as string | undefined,
minimaxApiKey: opts.minimaxApiKey as string | undefined,
syntheticApiKey: opts.syntheticApiKey as string | undefined,
veniceApiKey: opts.veniceApiKey as string | undefined,
+18 -7
View File
@@ -23,6 +23,7 @@ export type AuthChoiceGroupId =
| "synthetic"
| "venice"
| "qwen"
| "qianfan"
| "xai";
export type AuthChoiceGroup = {
@@ -38,12 +39,6 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint?: string;
choices: AuthChoice[];
}[] = [
{
value: "xai",
label: "xAI (Grok)",
hint: "API key",
choices: ["xai-api-key"],
},
{
value: "openai",
label: "OpenAI",
@@ -74,6 +69,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "Gemini API key + OAuth",
choices: ["gemini-api-key", "google-antigravity", "google-gemini-cli"],
},
{
value: "xai",
label: "xAI (Grok)",
hint: "API key",
choices: ["xai-api-key"],
},
{
value: "openrouter",
label: "OpenRouter",
@@ -92,6 +93,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "API key",
choices: ["zai-api-key"],
},
{
value: "qianfan",
label: "Qianfan",
hint: "API key",
choices: ["qianfan-api-key"],
},
{
value: "copilot",
label: "Copilot",
@@ -155,8 +162,12 @@ export function buildAuthChoiceOptions(params: {
});
options.push({ value: "chutes", label: "Chutes (OAuth)" });
options.push({ value: "openai-api-key", label: "OpenAI API key" });
options.push({ value: "openrouter-api-key", label: "OpenRouter API key" });
options.push({ value: "xai-api-key", label: "xAI (Grok) API key" });
options.push({
value: "qianfan-api-key",
label: "Qianfan API key",
});
options.push({ value: "openrouter-api-key", label: "OpenRouter API key" });
options.push({
value: "ai-gateway-api-key",
label: "Vercel AI Gateway API key",
@@ -15,6 +15,8 @@ import {
applyAuthProfileConfig,
applyCloudflareAiGatewayConfig,
applyCloudflareAiGatewayProviderConfig,
applyQianfanConfig,
applyQianfanProviderConfig,
applyKimiCodeConfig,
applyKimiCodeProviderConfig,
applyMoonshotConfig,
@@ -35,6 +37,7 @@ import {
applyXiaomiProviderConfig,
applyZaiConfig,
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
QIANFAN_DEFAULT_MODEL_REF,
KIMI_CODING_MODEL_REF,
MOONSHOT_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
@@ -43,6 +46,7 @@ import {
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
XIAOMI_DEFAULT_MODEL_REF,
setCloudflareAiGatewayConfig,
setQianfanApiKey,
setGeminiApiKey,
setKimiCodingApiKey,
setMoonshotApiKey,
@@ -104,6 +108,8 @@ export async function applyAuthChoiceApiProviders(
authChoice = "venice-api-key";
} else if (params.opts.tokenProvider === "opencode") {
authChoice = "opencode-zen";
} else if (params.opts.tokenProvider === "qianfan") {
authChoice = "qianfan-api-key";
}
}
@@ -797,5 +803,61 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (authChoice === "qianfan-api-key") {
let hasCredential = false;
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "qianfan") {
setQianfanApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
await params.prompter.note(
[
"Get your API key at: https://console.bce.baidu.com/qianfan/ais/console/apiKey",
"API key format: bce-v3/ALTAK-...",
].join("\n"),
"QIANFAN",
);
}
const envKey = resolveEnvApiKey("qianfan");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing QIANFAN_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
setQianfanApiKey(envKey.apiKey, params.agentDir);
hasCredential = true;
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: "Enter QIANFAN API key",
validate: validateApiKeyInput,
});
setQianfanApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "qianfan:default",
provider: "qianfan",
mode: "api_key",
});
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: QIANFAN_DEFAULT_MODEL_REF,
applyDefaultConfig: applyQianfanConfig,
applyProviderConfig: applyQianfanProviderConfig,
noteDefault: QIANFAN_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
return { config: nextConfig, agentModelOverride };
}
return null;
}
@@ -33,6 +33,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"xai-api-key": "xai",
"qwen-portal": "qwen-portal",
"minimax-portal": "minimax-portal",
"qianfan-api-key": "qianfan",
};
export function resolvePreferredProviderForAuthChoice(choice: AuthChoice): string | undefined {
+2 -2
View File
@@ -83,8 +83,8 @@ describe("dashboardCommand", () => {
customBindHost: undefined,
basePath: undefined,
});
expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");
expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");
expect(runtime.log).toHaveBeenCalledWith(
"Opened in your browser. Keep that tab to control OpenClaw.",
);
+6 -1
View File
@@ -23,6 +23,7 @@ export async function dashboardCommand(
const bind = cfg.gateway?.bind ?? "loopback";
const basePath = cfg.gateway?.controlUi?.basePath;
const customBindHost = cfg.gateway?.customBindHost;
const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
const links = resolveControlUiLinks({
port,
@@ -30,7 +31,10 @@ export async function dashboardCommand(
customBindHost,
basePath,
});
const dashboardUrl = links.httpUrl;
// Prefer URL fragment to avoid leaking auth tokens via query params.
const dashboardUrl = token
? `${links.httpUrl}#token=${encodeURIComponent(token)}`
: links.httpUrl;
runtime.log(`Dashboard URL: ${dashboardUrl}`);
@@ -48,6 +52,7 @@ export async function dashboardCommand(
hint = formatControlUiSshHint({
port,
basePath,
token: token || undefined,
});
}
} else {
+86 -1
View File
@@ -1,9 +1,15 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ModelApi } from "../config/types.models.js";
import {
buildCloudflareAiGatewayModelDefinition,
resolveCloudflareAiGatewayBaseUrl,
} from "../agents/cloudflare-ai-gateway.js";
import { buildXiaomiProvider, XIAOMI_DEFAULT_MODEL_ID } from "../agents/models-config.providers.js";
import {
buildQianfanProvider,
buildXiaomiProvider,
QIANFAN_DEFAULT_MODEL_ID,
XIAOMI_DEFAULT_MODEL_ID,
} from "../agents/models-config.providers.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
@@ -27,6 +33,8 @@ import {
import {
buildMoonshotModelDefinition,
buildXaiModelDefinition,
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_REF,
KIMI_CODING_MODEL_REF,
MOONSHOT_BASE_URL,
MOONSHOT_CN_BASE_URL,
@@ -705,3 +713,80 @@ export function applyAuthProfileConfig(
},
};
}
export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[QIANFAN_DEFAULT_MODEL_REF] = {
...models[QIANFAN_DEFAULT_MODEL_REF],
alias: models[QIANFAN_DEFAULT_MODEL_REF]?.alias ?? "QIANFAN",
};
const providers = { ...cfg.models?.providers };
const existingProvider = providers.qianfan;
const defaultProvider = buildQianfanProvider();
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const defaultModels = defaultProvider.models ?? [];
const hasDefaultModel = existingModels.some((model) => model.id === QIANFAN_DEFAULT_MODEL_ID);
const mergedModels =
existingModels.length > 0
? hasDefaultModel
? existingModels
: [...existingModels, ...defaultModels]
: defaultModels;
const {
apiKey: existingApiKey,
baseUrl: existingBaseUrl,
api: existingApi,
...existingProviderRest
} = (existingProvider ?? {}) as Record<string, unknown> as {
apiKey?: string;
baseUrl?: string;
api?: ModelApi;
};
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim();
providers.qianfan = {
...existingProviderRest,
baseUrl: existingBaseUrl ?? QIANFAN_BASE_URL,
api: existingApi ?? "openai-completions",
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : defaultProvider.models,
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
providers,
},
};
}
export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyQianfanProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: QIANFAN_DEFAULT_MODEL_REF,
},
},
},
};
}
+12
View File
@@ -205,6 +205,18 @@ export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
});
}
export function setQianfanApiKey(key: string, agentDir?: string) {
upsertAuthProfile({
profileId: "qianfan:default",
credential: {
type: "api_key",
provider: "qianfan",
key,
},
agentDir: resolveAuthAgentDir(agentDir),
});
}
export function setXaiApiKey(key: string, agentDir?: string) {
upsertAuthProfile({
profileId: "xai:default",
+4
View File
@@ -1,4 +1,5 @@
import type { ModelDefinitionConfig } from "../config/types.js";
import { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID } from "../agents/models-config.providers.js";
export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
@@ -16,6 +17,9 @@ export const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
export const KIMI_CODING_MODEL_ID = "k2p5";
export const KIMI_CODING_MODEL_REF = `kimi-coding/${KIMI_CODING_MODEL_ID}`;
export { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID };
export const QIANFAN_DEFAULT_MODEL_REF = `qianfan/${QIANFAN_DEFAULT_MODEL_ID}`;
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
export const MINIMAX_API_COST = {
input: 15,
+6
View File
@@ -7,6 +7,8 @@ export {
applyAuthProfileConfig,
applyCloudflareAiGatewayConfig,
applyCloudflareAiGatewayProviderConfig,
applyQianfanConfig,
applyQianfanProviderConfig,
applyKimiCodeConfig,
applyKimiCodeProviderConfig,
applyMoonshotConfig,
@@ -45,6 +47,7 @@ export {
OPENROUTER_DEFAULT_MODEL_REF,
setAnthropicApiKey,
setCloudflareAiGatewayConfig,
setQianfanApiKey,
setGeminiApiKey,
setKimiCodingApiKey,
setMinimaxApiKey,
@@ -69,6 +72,9 @@ export {
buildMoonshotModelDefinition,
DEFAULT_MINIMAX_BASE_URL,
MOONSHOT_CN_BASE_URL,
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_ID,
QIANFAN_DEFAULT_MODEL_REF,
KIMI_CODING_MODEL_ID,
KIMI_CODING_MODEL_REF,
MINIMAX_API_BASE_URL,
+9 -1
View File
@@ -179,16 +179,24 @@ export async function detectBrowserOpenSupport(): Promise<BrowserOpenSupport> {
return { ok: true, command: resolved.command };
}
export function formatControlUiSshHint(params: { port: number; basePath?: string }): string {
export function formatControlUiSshHint(params: {
port: number;
basePath?: string;
token?: string;
}): string {
const basePath = normalizeControlUiBasePath(params.basePath);
const uiPath = basePath ? `${basePath}/` : "/";
const localUrl = `http://localhost:${params.port}${uiPath}`;
const authedUrl = params.token
? `${localUrl}#token=${encodeURIComponent(params.token)}`
: undefined;
const sshTarget = resolveSshTargetHint();
return [
"No GUI detected. Open from your computer:",
`ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`,
"Then open:",
localUrl,
authedUrl,
"Docs:",
"https://docs.openclaw.ai/gateway/remote",
"https://docs.openclaw.ai/web/control-ui",
@@ -11,6 +11,7 @@ import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default
import {
applyAuthProfileConfig,
applyCloudflareAiGatewayConfig,
applyQianfanConfig,
applyKimiCodeConfig,
applyMinimaxApiConfig,
applyMinimaxConfig,
@@ -26,6 +27,7 @@ import {
applyZaiConfig,
setAnthropicApiKey,
setCloudflareAiGatewayConfig,
setQianfanApiKey,
setGeminiApiKey,
setKimiCodingApiKey,
setMinimaxApiKey,
@@ -243,6 +245,29 @@ export async function applyNonInteractiveAuthChoice(params: {
return applyXaiConfig(nextConfig);
}
if (authChoice === "qianfan-api-key") {
const resolved = await resolveNonInteractiveApiKey({
provider: "qianfan",
cfg: baseConfig,
flagValue: opts.qianfanApiKey,
flagName: "--qianfan-api-key",
envVar: "QIANFAN_API_KEY",
runtime,
});
if (!resolved) {
return null;
}
if (resolved.source !== "profile") {
setQianfanApiKey(resolved.key);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "qianfan:default",
provider: "qianfan",
mode: "api_key",
});
return applyQianfanConfig(nextConfig);
}
if (authChoice === "openai-api-key") {
const resolved = await resolveNonInteractiveApiKey({
provider: "openai",
+2
View File
@@ -36,6 +36,7 @@ export type AuthChoice =
| "copilot-proxy"
| "qwen-portal"
| "xai-api-key"
| "qianfan-api-key"
| "skip";
export type GatewayAuthChoice = "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
@@ -81,6 +82,7 @@ export type OnboardOptions = {
veniceApiKey?: string;
opencodeZenApiKey?: string;
xaiApiKey?: string;
qianfanApiKey?: string;
gatewayPort?: number;
gatewayBind?: GatewayBind;
gatewayAuth?: GatewayAuthChoice;
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { CronJob } from "./types.js";
import { resolveCronDeliveryPlan } from "./delivery.js";
function makeJob(overrides: Partial<CronJob>): CronJob {
const now = Date.now();
return {
id: "job-1",
name: "test",
enabled: true,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
state: {},
...overrides,
};
}
describe("resolveCronDeliveryPlan", () => {
it("defaults to announce when delivery object has no mode", () => {
const plan = resolveCronDeliveryPlan(
makeJob({
delivery: { channel: "telegram", to: "123", mode: undefined as never },
}),
);
expect(plan.mode).toBe("announce");
expect(plan.requested).toBe(true);
expect(plan.channel).toBe("telegram");
expect(plan.to).toBe("123");
});
it("respects legacy payload deliver=false", () => {
const plan = resolveCronDeliveryPlan(
makeJob({
delivery: undefined,
payload: { kind: "agentTurn", message: "hello", deliver: false },
}),
);
expect(plan.mode).toBe("none");
expect(plan.requested).toBe(false);
});
});
+5 -4
View File
@@ -32,12 +32,13 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
const delivery = job.delivery;
const hasDelivery = delivery && typeof delivery === "object";
const rawMode = hasDelivery ? (delivery as { mode?: unknown }).mode : undefined;
const normalizedMode = typeof rawMode === "string" ? rawMode.trim().toLowerCase() : rawMode;
const mode =
rawMode === "announce"
normalizedMode === "announce"
? "announce"
: rawMode === "none"
: normalizedMode === "none"
? "none"
: rawMode === "deliver"
: normalizedMode === "deliver"
? "announce"
: undefined;
@@ -51,7 +52,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
const channel = deliveryChannel ?? payloadChannel ?? "last";
const to = deliveryTo ?? payloadTo;
if (hasDelivery) {
const resolvedMode = mode ?? "none";
const resolvedMode = mode ?? "announce";
return {
mode: resolvedMode,
channel,
@@ -134,6 +134,48 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("announces only the final payload text", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "Working on it..." }, { text: "Final weather summary" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123",
"Final weather summary",
expect.any(Object),
);
});
});
it("skips announce when messaging tool already sent to target", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
@@ -48,7 +48,7 @@ async function writeSessionStore(home: string) {
async function readSessionEntry(storePath: string, key: string) {
const raw = await fs.readFile(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, { sessionId?: string }>;
const store = JSON.parse(raw) as Record<string, { sessionId?: string; label?: string }>;
return store[key];
}
@@ -90,6 +90,38 @@ describe("runCronIsolatedAgentTurn", () => {
vi.mocked(loadModelCatalog).mockResolvedValue([]);
});
it("treats blank model overrides as unset", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({ kind: "agentTurn", message: "do it", model: " " }),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(vi.mocked(runEmbeddedPiAgent)).toHaveBeenCalledTimes(1);
});
});
it("uses last non-empty agent text as summary", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
@@ -585,6 +617,49 @@ describe("runCronIsolatedAgentTurn", () => {
expect(first?.sessionId).toBeDefined();
expect(second?.sessionId).toBeDefined();
expect(second?.sessionId).not.toBe(first?.sessionId);
expect(first?.label).toBe("Cron: job-1");
expect(second?.label).toBe("Cron: job-1");
});
});
it("preserves an existing cron session label", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const raw = await fs.readFile(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, Record<string, unknown>>;
store["agent:main:cron:job-1"] = {
sessionId: "old",
updatedAt: Date.now(),
label: "Nightly digest",
};
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({ kind: "agentTurn", message: "ping", deliver: false }),
message: "ping",
sessionKey: "cron:job-1",
lane: "cron",
});
const entry = await readSessionEntry(storePath, "agent:main:cron:job-1");
expect(entry?.label).toBe("Nightly digest");
});
});
});

Some files were not shown because too many files have changed in this diff Show More