mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 00:34:16 +08:00
Compare commits
167 Commits
codex/loca
...
codex/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d46cd809b | ||
|
|
43658872d9 | ||
|
|
bd04d2db0d | ||
|
|
c8a733eae5 | ||
|
|
3e35f599bc | ||
|
|
914f313740 | ||
|
|
4efc48a80d | ||
|
|
ecc5601b2a | ||
|
|
14795dc0cc | ||
|
|
05dee6760d | ||
|
|
582aa1ceb2 | ||
|
|
f6e1bc393b | ||
|
|
c91d1048e4 | ||
|
|
90994a38a0 | ||
|
|
c01a0f5588 | ||
|
|
8ff61be8d6 | ||
|
|
90d569e896 | ||
|
|
d8bc71f222 | ||
|
|
f3ea2982f5 | ||
|
|
8f389de88f | ||
|
|
2bcba64906 | ||
|
|
cbd492d680 | ||
|
|
fadd275e7b | ||
|
|
35a3c064a7 | ||
|
|
91adfa1582 | ||
|
|
f3f85ae5f7 | ||
|
|
69550a9d3d | ||
|
|
5b8472b0b9 | ||
|
|
73dd36626c | ||
|
|
83905c9169 | ||
|
|
d92a0292a9 | ||
|
|
0e6937cc1b | ||
|
|
b1e5c9d7fa | ||
|
|
ba3eae5518 | ||
|
|
60673b03bc | ||
|
|
d5e8da8499 | ||
|
|
5f89fbe669 | ||
|
|
bc848b367f | ||
|
|
a6a99b923e | ||
|
|
ccad5d7b63 | ||
|
|
42b4715124 | ||
|
|
465c4cb580 | ||
|
|
37ccec0dc7 | ||
|
|
cb4d2e7bb9 | ||
|
|
41a92ae445 | ||
|
|
d7354d61b2 | ||
|
|
c57671176e | ||
|
|
44e31f7c6a | ||
|
|
63a06e312d | ||
|
|
ed9e9aab3d | ||
|
|
dfe99e9cd7 | ||
|
|
9331ac2cb0 | ||
|
|
7f28c8bd07 | ||
|
|
bafa6de76d | ||
|
|
6037a74660 | ||
|
|
f1235477de | ||
|
|
526925c509 | ||
|
|
3204efc195 | ||
|
|
2860da8cd5 | ||
|
|
8f2e520abb | ||
|
|
fe3f2bee3f | ||
|
|
a51e8a21b6 | ||
|
|
260e8e26fd | ||
|
|
196ea61ec4 | ||
|
|
49cc613021 | ||
|
|
347486a4c4 | ||
|
|
1517fe2c32 | ||
|
|
fe69df6b3a | ||
|
|
dac67b3978 | ||
|
|
a6c694da7e | ||
|
|
259d6aada8 | ||
|
|
de6aaf8e23 | ||
|
|
496e1e071f | ||
|
|
112939df60 | ||
|
|
e8cece82ef | ||
|
|
93c68c4432 | ||
|
|
2009bec87a | ||
|
|
f382a36458 | ||
|
|
45b12c0085 | ||
|
|
0b86591d9d | ||
|
|
1221414709 | ||
|
|
1c8de09ba9 | ||
|
|
7cd93f8e5c | ||
|
|
1dbde826f2 | ||
|
|
1d84255581 | ||
|
|
e1c88d4425 | ||
|
|
e69fedc8cf | ||
|
|
a841778b7b | ||
|
|
522d0f7ef5 | ||
|
|
50378c01e4 | ||
|
|
3416edf740 | ||
|
|
040f14b641 | ||
|
|
8c53d100ca | ||
|
|
5230a23202 | ||
|
|
6443d06764 | ||
|
|
6fd8cfd5bb | ||
|
|
95f9231136 | ||
|
|
e6b011823e | ||
|
|
31169ff3b4 | ||
|
|
7f09d6ae48 | ||
|
|
a7820b2f54 | ||
|
|
150673a734 | ||
|
|
b7e9272dbe | ||
|
|
0b86decf94 | ||
|
|
61e7b042b6 | ||
|
|
d10fd6b8f4 | ||
|
|
a509c48f0e | ||
|
|
057be10e5b | ||
|
|
b832975f3e | ||
|
|
26ea53cc68 | ||
|
|
57aec8c565 | ||
|
|
be6cac375a | ||
|
|
6e125adf3a | ||
|
|
0983e763fe | ||
|
|
69c3b56bde | ||
|
|
f66d14def5 | ||
|
|
eb7e237151 | ||
|
|
beb665212c | ||
|
|
689e8ec893 | ||
|
|
f8ad20b87e | ||
|
|
6897711d19 | ||
|
|
8ed5ea499d | ||
|
|
960117259d | ||
|
|
4b9a80d895 | ||
|
|
3b91d18c37 | ||
|
|
4f2dc09431 | ||
|
|
b3dc7a4a80 | ||
|
|
e2966faea7 | ||
|
|
b245cb2b6d | ||
|
|
2b15850b47 | ||
|
|
f10bad944f | ||
|
|
fb8b9e9138 | ||
|
|
e848671e9d | ||
|
|
b1719474d5 | ||
|
|
c8f5a2e0e2 | ||
|
|
c4e1bb30da | ||
|
|
1e2fda9e68 | ||
|
|
7d0347b6de | ||
|
|
a0c1f5962d | ||
|
|
33b81686ad | ||
|
|
07870dff45 | ||
|
|
99b24a80fb | ||
|
|
a39c2d784e | ||
|
|
0167f0a6df | ||
|
|
11e82bdef2 | ||
|
|
7aca070723 | ||
|
|
e5845dd452 | ||
|
|
ba55b3e360 | ||
|
|
467b068fdc | ||
|
|
18bfd44439 | ||
|
|
fb18f95348 | ||
|
|
7f4338d435 | ||
|
|
16cd7f9d3f | ||
|
|
4e2d9b0b76 | ||
|
|
040eba1cdc | ||
|
|
18d2bc441c | ||
|
|
75ef73d4f7 | ||
|
|
f440121a49 | ||
|
|
1ca7f5c0a0 | ||
|
|
61031d1b1c | ||
|
|
afa6b81120 | ||
|
|
4eeb7bfa57 | ||
|
|
aae13f4dd2 | ||
|
|
4305fb7cdf | ||
|
|
e8217cbb7a | ||
|
|
e3be541a6c | ||
|
|
b9d7dd4a84 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: discrawl
|
||||
description: "Discord archive: search, sync freshness, DMs, channel slices, SQL counts, and Discrawl repo work."
|
||||
description: "Discord archive: search, sync freshness, DMs, summaries, TUI, repo/release work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/discrawl
|
||||
@@ -16,29 +16,154 @@ metadata:
|
||||
|
||||
# Discrawl
|
||||
|
||||
Use local Discord archive data before live Discord APIs. Check freshness for recent/current questions:
|
||||
Use local Discord archive data first for Discord questions. Hit Discord APIs
|
||||
only when the archive is stale, missing the requested scope, or the user asks
|
||||
for current external context.
|
||||
|
||||
## Sources
|
||||
|
||||
- DB: platform-native XDG data dir, usually
|
||||
`${XDG_DATA_HOME:-~/.local/share}/discrawl/discrawl.db` on Linux or
|
||||
`~/Library/Application Support/discrawl/discrawl.db` on macOS
|
||||
- Config: platform-native XDG config dir, with legacy fallback to
|
||||
`~/.discrawl/config.toml`
|
||||
- Cache: platform-native XDG cache dir
|
||||
- Logs: platform-native XDG state dir
|
||||
- Git share repo: platform-native XDG data dir
|
||||
- Repo: `openclaw/discrawl`; use `~/GIT/_Perso/discrawl` only after verifying
|
||||
its remote targets `openclaw/discrawl`, otherwise use a fresh checkout
|
||||
- Preferred CLI: `discrawl`; fallback to `go run ./cmd/discrawl` from the repo
|
||||
if the installed binary is stale
|
||||
|
||||
## Freshness
|
||||
|
||||
For recent/current questions, check freshness before analysis:
|
||||
|
||||
```bash
|
||||
discrawl status --json
|
||||
```
|
||||
|
||||
For precise freshness from the default database:
|
||||
|
||||
```bash
|
||||
# Discrawl uses macOS ~/Library defaults unless XDG_DATA_HOME is explicitly set.
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
db="$HOME/Library/Application Support/discrawl/discrawl.db"
|
||||
;;
|
||||
*)
|
||||
db="${XDG_DATA_HOME:-$HOME/.local/share}/discrawl/discrawl.db"
|
||||
;;
|
||||
esac
|
||||
sqlite3 "$db" \
|
||||
"select coalesce(max(updated_at),'') from sync_state where scope like 'channel:%';"
|
||||
```
|
||||
|
||||
Routine diagnostics:
|
||||
|
||||
```bash
|
||||
discrawl doctor
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
Desktop-local refresh:
|
||||
|
||||
```bash
|
||||
discrawl sync --source wiretap
|
||||
```
|
||||
|
||||
Bot API latest refresh, when credentials are available:
|
||||
|
||||
```bash
|
||||
discrawl sync
|
||||
```
|
||||
|
||||
Query with bounded slices:
|
||||
Use `--full` only for deliberate historical backfills:
|
||||
|
||||
```bash
|
||||
discrawl sync --full
|
||||
```
|
||||
|
||||
If SQLite reports busy/locked, check for stray `discrawl` processes before retrying.
|
||||
|
||||
## Query Workflow
|
||||
|
||||
1. Resolve scope: guild, channel, DM, author, keyword, date range.
|
||||
2. Check freshness for recent/current requests.
|
||||
3. Prefer CLI search/messages for slices; use read-only SQL for exact counts.
|
||||
4. Report absolute date spans, counts, channel/DM names, and known gaps.
|
||||
|
||||
Use root or subcommand help for syntax: `discrawl --help`,
|
||||
`discrawl help search`, `discrawl search --help`. Use
|
||||
`DISCRAWL_NO_AUTO_UPDATE=1` for read smokes when you do not want git-share
|
||||
updates.
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl search --limit 20 "query"
|
||||
discrawl messages --channel '#maintainers' --days 7 --all
|
||||
discrawl dms --last 20
|
||||
discrawl tui --dm
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) from messages;"
|
||||
```
|
||||
|
||||
Report absolute date spans, channel/DM names, counts, and known gaps. Use read-only SQL for exact counts/rankings. Never use `--unsafe --confirm` unless the user explicitly requests a reviewed DB mutation.
|
||||
## SQL
|
||||
|
||||
Boundaries: bot sync needs configured Discord bot credentials. Wiretap reads local Discord Desktop artifacts only; do not extract user tokens, call Discord as the user, or write to Discord storage. Git-share snapshots must not include secrets or `@me` DM rows.
|
||||
Use `discrawl sql` for exact counts, joins, and ranking queries when normal
|
||||
CLI reads are too coarse. The command is read-only by default, accepts SQL as
|
||||
args or stdin, and supports `--json` for agent parsing.
|
||||
|
||||
Useful examples:
|
||||
|
||||
```bash
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) as messages from messages;"
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select coalesce(nullif(c.name, ''), m.channel_id) as channel, count(*) as messages from messages m left join channels c on c.id = m.channel_id group by m.channel_id order by messages desc limit 20;"
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select coalesce(nullif(mm.display_name, ''), nullif(mm.global_name, ''), nullif(mm.username, ''), m.author_id) as author, count(*) as messages from messages m left join members mm on mm.guild_id = m.guild_id and mm.user_id = m.author_id group by m.guild_id, m.author_id order by messages desc limit 20;"
|
||||
```
|
||||
|
||||
Never use `--unsafe --confirm` unless the user explicitly asks for a database
|
||||
mutation and the write has been reviewed.
|
||||
|
||||
When the installed CLI lacks a new feature, build or run from a verified
|
||||
`openclaw/discrawl` checkout before concluding the feature is missing.
|
||||
|
||||
## Discord Boundaries
|
||||
|
||||
Bot API sync requires configured Discord bot credentials; do not invent token
|
||||
availability. Desktop wiretap mode reads local Discord Desktop artifacts and
|
||||
must not extract credentials, use user tokens, call Discord as the user, or
|
||||
write to Discord application storage. Wiretap/Desktop cache DMs are local-only
|
||||
and must not be described as part of the published Git snapshot. Git-share
|
||||
snapshots must not include secrets or `@me` DM rows.
|
||||
|
||||
## Verification
|
||||
|
||||
For repo edits, prefer existing Go gates:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./...
|
||||
```
|
||||
|
||||
Then run targeted CLI smoke for the touched surface, for example:
|
||||
|
||||
```bash
|
||||
discrawl doctor
|
||||
discrawl status --json
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl search --limit 5 "test"
|
||||
```
|
||||
|
||||
## ClawSweeper Sandbox
|
||||
|
||||
Use the sandbox reader only:
|
||||
|
||||
```bash
|
||||
discrawl-sandbox search --limit 20 "query"
|
||||
discrawl-sandbox messages --channel clawtributors --days 7 --all
|
||||
discrawl-sandbox status --json
|
||||
```
|
||||
|
||||
This reader imports `https://github.com/openclaw/discord-store.git` into
|
||||
`/root/clawsweeper-sandbox-workspace/.discrawl/discrawl.db` with
|
||||
`discord.token_source = "none"`. The published Git snapshot is public-channel
|
||||
filtered; do not use `/root/.discrawl/config.toml` or the rich writer DB from
|
||||
sandboxed public Discord sessions.
|
||||
|
||||
@@ -6,14 +6,16 @@ description: Regenerate OpenClaw release changelog sections from git history bef
|
||||
# OpenClaw Changelog Update
|
||||
|
||||
Use this for release changelog rewrites and GitHub release-note source text.
|
||||
Use it with `release-openclaw-maintainer`; this skill owns changelog content,
|
||||
ordering, and audit discipline.
|
||||
This is mandatory before every beta, beta rerun, stable release, or stable
|
||||
rerun. Use it with `release-openclaw-maintainer`; this skill owns changelog
|
||||
content, ordering, grouping, and attribution discipline.
|
||||
|
||||
## Goal
|
||||
|
||||
Rewrite the target `CHANGELOG.md` version section from history, not from stale
|
||||
draft notes. Produce user-facing release notes sorted by user interest while
|
||||
preserving issue/PR refs and thanks.
|
||||
draft notes. Produce grouped user-facing release notes sorted by user interest
|
||||
while preserving every relevant issue/PR ref and every human `Thanks @...`
|
||||
attribution.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -44,10 +46,18 @@ preserving issue/PR refs and thanks.
|
||||
- `### Highlights`: 5-8 bullets, broad user wins first
|
||||
- `### Changes`: new capabilities and behavior changes
|
||||
- `### Fixes`: user-facing fixes first, grouped by impact and surface
|
||||
- group related changes/fixes by surface and user impact; avoid one bullet
|
||||
per tiny commit when several commits tell one user-facing story
|
||||
6. Preserve attribution:
|
||||
- keep `#issue`, `(#PR)`, `Fixes #...`, and `Thanks @...`
|
||||
- every human-authored merged PR represented by a user-facing entry needs
|
||||
its PR ref and `Thanks @author`, even when the PR had no linked issue
|
||||
- when grouping multiple PRs/issues in one bullet, include every relevant
|
||||
PR/issue ref and every human contributor handle in that same bullet
|
||||
- multiple `Thanks @...` handles in one bullet are expected; do not drop or
|
||||
collapse contributor credit just because the note is grouped
|
||||
- if one grouped bullet covers both direct commits and PRs, keep all PR refs
|
||||
and thanks, plus any issue refs from the direct commits
|
||||
- do not add GHSA references, advisory IDs, or security advisory slugs to
|
||||
changelog entries or GitHub release-note text unless explicitly requested
|
||||
- never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`
|
||||
|
||||
@@ -21,6 +21,30 @@ function jsonGh(args) {
|
||||
return JSON.parse(gh(args));
|
||||
}
|
||||
|
||||
function githubRestJson(pathSuffix) {
|
||||
const result = execFileSync(
|
||||
"bash",
|
||||
[
|
||||
"-lc",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'token="$(gh auth token)"',
|
||||
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
|
||||
].join("\n"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
return JSON.parse(result);
|
||||
}
|
||||
|
||||
function rate() {
|
||||
try {
|
||||
return jsonGh(["api", "rate_limit"]).resources.core;
|
||||
@@ -59,12 +83,30 @@ for (const job of parent.jobs ?? []) {
|
||||
}
|
||||
|
||||
const since = parent.createdAt;
|
||||
const runList = gh([
|
||||
"api",
|
||||
`repos/${repo}/actions/runs?per_page=100`,
|
||||
"--jq",
|
||||
`.workflow_runs[] | select(.created_at >= "${since}") | select(.name=="CI" or .name=="OpenClaw Release Checks" or .name=="Plugin Prerelease" or .name=="NPM Telegram Beta E2E" or .name=="Full Release Validation") | [.id,.name,.status,.conclusion,.head_sha,.html_url] | @tsv`,
|
||||
]).trim();
|
||||
const runsQuery = new URLSearchParams({
|
||||
per_page: "100",
|
||||
created: `>=${since}`,
|
||||
exclude_pull_requests: "true",
|
||||
});
|
||||
const childWorkflowNames = new Set([
|
||||
"CI",
|
||||
"OpenClaw Release Checks",
|
||||
"Plugin Prerelease",
|
||||
"NPM Telegram Beta E2E",
|
||||
"Full Release Validation",
|
||||
]);
|
||||
const runs = githubRestJson(`actions/runs?${runsQuery.toString()}`).workflow_runs ?? [];
|
||||
const runList = runs
|
||||
.filter(
|
||||
(run) =>
|
||||
run.created_at >= since &&
|
||||
run.head_sha === parent.headSha &&
|
||||
childWorkflowNames.has(run.name),
|
||||
)
|
||||
.map((run) =>
|
||||
[run.id, run.name, run.status, run.conclusion ?? "", run.head_sha, run.html_url].join("\t"),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
if (!runList) {
|
||||
console.log("children: none found yet");
|
||||
|
||||
@@ -69,9 +69,13 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
or clawgrit reports. Report regressions explicitly. A major regression is a
|
||||
release blocker unless the operator waives it or the data clearly proves
|
||||
infrastructure noise.
|
||||
- Generate the changelog before version/tag preparation so the top changelog
|
||||
section is deduped and ordered by user impact. Use
|
||||
`$openclaw-changelog-update` for the rewrite.
|
||||
- Generate the changelog before every beta, beta rerun, stable release, or
|
||||
stable rerun, before version/tag preparation. Use
|
||||
`$openclaw-changelog-update` for the rewrite. Do not continue release prep if
|
||||
the target `CHANGELOG.md` section does not have `### Highlights`,
|
||||
`### Changes`, and `### Fixes`, grouped by user-facing surface while
|
||||
preserving every relevant PR/issue ref and every human `Thanks @...`
|
||||
attribution in the grouped bullet.
|
||||
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
|
||||
stable base version section, for example `v2026.4.20-beta.1` uses
|
||||
`## 2026.4.20` release notes.
|
||||
@@ -144,6 +148,9 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
section from history, not existing notes. Use the last reachable stable or
|
||||
beta release tag as the base, then inspect every commit through the target
|
||||
release SHA.
|
||||
- The changelog rewrite is not optional for beta reruns: any `beta.N` after a
|
||||
rebase or backport must refresh the same stable-base `## YYYY.M.D` section
|
||||
before the new version/tag commit.
|
||||
- Include both merged PR commits and direct commits on `main`. Direct commits
|
||||
matter: infer notes from their subject, body, touched files, linked issues,
|
||||
tests, and nearby code when no PR body exists.
|
||||
@@ -157,6 +164,11 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- Add missed user-facing changes, remove internal-only noise, dedupe overlapping
|
||||
PR/direct-commit entries, and sort each section from most to least interesting
|
||||
for users.
|
||||
- Group related highlights, changes, and fixes by user-facing surface and
|
||||
impact, but never lose traceability: each grouped bullet keeps every relevant
|
||||
`#issue`, `(#PR)`, `Fixes #...`, and every human `Thanks @...` handle.
|
||||
Multiple thanks in one bullet are expected when multiple contributor PRs are
|
||||
grouped.
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- GitHub release and prerelease bodies must use the full matching
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
|
||||
57
.github/workflows/ci.yml
vendored
57
.github/workflows/ci.yml
vendored
@@ -28,7 +28,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-v1-{1}', github.workflow, github.run_id) || (github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha))) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -466,8 +466,8 @@ jobs:
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
|
||||
# Warm the lockfile- and pnpm-pinned store once before Linux Node shards fan out.
|
||||
# On a cold key this job owns the save, so later shards restore the exact key.
|
||||
# Warm the lockfile- and pnpm-pinned store without blocking Linux Node shards.
|
||||
# On a cold key this job owns the save for later workflow runs.
|
||||
pnpm-store-warmup:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -532,9 +532,9 @@ jobs:
|
||||
build-artifacts:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
|
||||
@@ -597,6 +597,14 @@ jobs:
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Restore build-all step cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .artifacts/build-all-cache
|
||||
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-all-v3-
|
||||
|
||||
- name: Build dist
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
@@ -694,20 +702,6 @@ jobs:
|
||||
pids+=("$!")
|
||||
}
|
||||
|
||||
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
||||
gateway_watch_log="${RUNNER_TEMP}/gateway-watch.log"
|
||||
echo "starting gateway-watch: node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000"
|
||||
if node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000 >"$gateway_watch_log" 2>&1; then
|
||||
result="success"
|
||||
else
|
||||
result="failure"
|
||||
fi
|
||||
echo "::group::gateway-watch log"
|
||||
cat "$gateway_watch_log"
|
||||
echo "::endgroup::"
|
||||
results["gateway-watch"]="$result"
|
||||
fi
|
||||
|
||||
if [ "$RUN_CHANNELS" = "true" ]; then
|
||||
start_check "channels" env \
|
||||
NODE_OPTIONS=--max-old-space-size=8192 \
|
||||
@@ -722,6 +716,11 @@ jobs:
|
||||
node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts
|
||||
fi
|
||||
|
||||
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
||||
start_check "gateway-watch" \
|
||||
node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000
|
||||
fi
|
||||
|
||||
for index in "${!pids[@]}"; do
|
||||
name="${names[$index]}"
|
||||
log="${logs[$index]}"
|
||||
@@ -764,7 +763,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast_core == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -853,7 +852,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.checkName }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_plugin_contracts_shards == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -933,7 +932,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.checkName }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -1085,7 +1084,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -1191,8 +1190,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' && needs.pnpm-store-warmup.result == 'success' }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -1322,8 +1321,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' && needs.pnpm-store-warmup.result == 'success' }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -1489,7 +1488,7 @@ jobs:
|
||||
check-docs:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_check_docs == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
@@ -2114,7 +2113,7 @@ jobs:
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -72,6 +72,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Plugin SDK exception: shipped external API gets new API first plus named compat/deprecation, small tests/docs if useful, removal plan.
|
||||
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
- Agent run terminal state: normalize/merge via `src/agents/agent-run-terminal-outcome.ts`; do not rederive timeout/cancel precedence in projections.
|
||||
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
|
||||
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
|
||||
- Gateway/plugin metadata is process-stable: installs, manifests, catalogs, generated paths, bundled metadata. Changes require restart or explicit owner reload/install/doctor flow.
|
||||
@@ -227,6 +228,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
|
||||
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
|
||||
- Memory wiki prompt digest stays tiny; prefer `wiki_search` / `wiki_get`; verify contact data before use; source-class provenance for generated people facts.
|
||||
- Rebrand/migration/config warnings: run `openclaw doctor`.
|
||||
- Never edit `node_modules`.
|
||||
|
||||
@@ -4,9 +4,16 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
|
||||
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
|
||||
|
||||
## 2026.5.28
|
||||
@@ -35,6 +42,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.
|
||||
- Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.
|
||||
- Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf.
|
||||
- Codex Supervisor: keep real-home app-server MCP session listing on the loaded/state-DB path, bound stored history scans, and close WebSocket probes cleanly.
|
||||
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
|
||||
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
|
||||
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
|
||||
|
||||
@@ -73,9 +73,10 @@ Release behavior:
|
||||
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
|
||||
- See `apps/ios/VERSIONING.md` for the full workflow.
|
||||
|
||||
Required env for beta builds:
|
||||
Relay behavior for beta builds:
|
||||
|
||||
- `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Archive without upload:
|
||||
@@ -118,7 +119,7 @@ scripts/ios-asc-keychain-setup.sh \
|
||||
|
||||
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
|
||||
|
||||
3. Set the official/TestFlight relay URL for the build:
|
||||
3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
|
||||
652
apps/ios/Sources/Design/TalkProTab.swift
Normal file
652
apps/ios/Sources/Design/TalkProTab.swift
Normal file
@@ -0,0 +1,652 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TalkProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage(TalkSpeechLocale.storageKey) private var talkSpeechLocale: String = TalkSpeechLocale.automaticID
|
||||
@AppStorage(TalkDefaults.speakerphoneEnabledKey) private var talkSpeakerphoneEnabled: Bool =
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@State private var showPermissionPrompt = false
|
||||
var openSettings: () -> Void
|
||||
|
||||
private var state: TalkProState {
|
||||
TalkProState(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
|
||||
permissionState: self.appModel.talkMode.gatewayTalkPermissionState)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.voiceHeroCard
|
||||
self.conversationCard
|
||||
self.voiceModeCard
|
||||
self.controlsCard
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.sheet(isPresented: self.$showPermissionPrompt) {
|
||||
NavigationStack {
|
||||
TalkPermissionPromptView(
|
||||
style: .sheet,
|
||||
onPermissionReady: {
|
||||
self.showPermissionPrompt = false
|
||||
self.startTalk()
|
||||
})
|
||||
.padding()
|
||||
.navigationTitle("Enable Talk")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Not Now") {
|
||||
self.showPermissionPrompt = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.openClawSheetChrome()
|
||||
}
|
||||
.onAppear { self.alignPersistedTalkState() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Talk")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerSubtitle)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.statusChip
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var statusChip: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(self.state.color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(self.state.chipText)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.state.color)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
Capsule(style: .continuous)
|
||||
.fill(self.state.color.opacity(0.11))
|
||||
.overlay {
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(self.state.color.opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceHeroCard: some View {
|
||||
CommandPanel(tint: self.state.color, isProminent: true, padding: 16) {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
TalkProOrb(
|
||||
mode: self.state.waveformMode(micLevel: self.appModel.talkMode.micLevel),
|
||||
color: self.state.color,
|
||||
systemImage: self.state.icon)
|
||||
.frame(height: 188)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(spacing: 5) {
|
||||
Text(self.state.title)
|
||||
.font(.title3.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(self.heroSubtitle)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: self.handlePrimaryAction) {
|
||||
Label(self.state.primaryButtonTitle, systemImage: self.state.primaryButtonIcon)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(self.state.primaryButtonFill)
|
||||
.shadow(color: self.state.color.opacity(0.28), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(self.state.primaryAction == .waiting)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var conversationCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(title: "Conversation", value: self.state.chipText, color: self.state.color)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.activeAgentName)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(
|
||||
icon: "bubble.left.and.text.bubble.right.fill",
|
||||
title: "Session",
|
||||
value: self.appModel.chatSessionKey)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: self.state.icon, title: "Runtime", value: self.appModel.talkMode.statusText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var voiceModeCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Voice mode",
|
||||
value: "Settings ›",
|
||||
color: OpenClawBrand.accent,
|
||||
action: self.openSettings)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(icon: "waveform", title: "Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "antenna.radiowaves.left.and.right", title: "Transport", value: self.transportText)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "key.fill", title: "Permission", value: self.permissionText)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "globe", title: "Speech language", value: self.speechLocaleText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var controlsCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(title: "Controls", value: nil, color: .secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
Toggle("Speakerphone", isOn: self.$talkSpeakerphoneEnabled)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
Divider().padding(.leading, 14)
|
||||
Toggle("Background listening", isOn: self.$talkBackgroundEnabled)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
Divider().padding(.leading, 14)
|
||||
Button(action: self.openSettings) {
|
||||
HStack {
|
||||
Label("Voice & Talk settings", systemImage: "slider.horizontal.3")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
title: String,
|
||||
value: String?,
|
||||
color: Color,
|
||||
action: (() -> Void)? = nil) -> some View
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.bold))
|
||||
Spacer(minLength: 8)
|
||||
if let value {
|
||||
if let action {
|
||||
Button(value, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
} else {
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoRow(icon: String, title: String, value: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.state.color)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.state.color.opacity(0.11))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "—" : value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var headerSubtitle: String {
|
||||
let mode = self.appModel.talkMode.gatewayTalkVoiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let agent = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if mode.isEmpty || mode == "Not loaded" { return agent.isEmpty ? "Realtime voice" : agent }
|
||||
if agent.isEmpty { return mode }
|
||||
return "\(agent) • \(mode)"
|
||||
}
|
||||
|
||||
private var heroSubtitle: String {
|
||||
if self.state
|
||||
.prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." }
|
||||
if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." }
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !subtitle.isEmpty { return subtitle }
|
||||
return "Routes voice to \(self.appModel.activeAgentName)."
|
||||
}
|
||||
|
||||
private var transportText: String {
|
||||
let provider = self.appModel.talkMode.gatewayTalkProviderLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let transport = self.appModel.talkMode.gatewayTalkTransportLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if provider.isEmpty || provider == "Not loaded" { return transport.isEmpty ? "Not loaded" : transport }
|
||||
if transport.isEmpty || transport == "Not loaded" { return provider }
|
||||
return "\(provider) • \(transport)"
|
||||
}
|
||||
|
||||
private var permissionText: String {
|
||||
if let failure = self.appModel.talkMode.gatewayTalkPermissionState.failureMessage {
|
||||
return failure
|
||||
}
|
||||
return self.appModel.talkMode.gatewayTalkPermissionState.statusLabel
|
||||
}
|
||||
|
||||
private var speechLocaleText: String {
|
||||
if self.talkSpeechLocale == TalkSpeechLocale.automaticID { return "Automatic" }
|
||||
return self.talkSpeechLocale
|
||||
}
|
||||
|
||||
private func alignPersistedTalkState() {
|
||||
if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction,
|
||||
self.talkEnabled || self.appModel.talkMode.isEnabled
|
||||
{
|
||||
self.stopTalk()
|
||||
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
self.appModel.setTalkEnabled(self.talkEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePrimaryAction() {
|
||||
switch self.state.primaryAction {
|
||||
case .start:
|
||||
self.startTalk()
|
||||
case .stop:
|
||||
self.stopTalk()
|
||||
case .enablePermission:
|
||||
self.stopTalk()
|
||||
self.showPermissionPrompt = true
|
||||
case .openSettings:
|
||||
self.openSettings()
|
||||
case .waiting:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func startTalk() {
|
||||
self.talkEnabled = true
|
||||
self.appModel.setTalkEnabled(true)
|
||||
}
|
||||
|
||||
private func stopTalk() {
|
||||
self.talkEnabled = false
|
||||
self.appModel.setTalkEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
enum TalkProPrimaryAction: Equatable {
|
||||
case start
|
||||
case stop
|
||||
case enablePermission
|
||||
case openSettings
|
||||
case waiting
|
||||
}
|
||||
|
||||
enum TalkProWaveformMode: Equatable {
|
||||
case level(Double)
|
||||
case inputSpeech
|
||||
case speaking
|
||||
case indeterminate
|
||||
case still
|
||||
}
|
||||
|
||||
struct TalkProState: Equatable {
|
||||
let gatewayConnected: Bool
|
||||
let isEnabled: Bool
|
||||
let statusText: String
|
||||
let isListening: Bool
|
||||
let isSpeaking: Bool
|
||||
let isUserSpeechDetected: Bool
|
||||
let permissionState: TalkGatewayPermissionState
|
||||
|
||||
private var normalizedStatus: String {
|
||||
self.statusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
var title: String {
|
||||
if !self.gatewayConnected { return "Gateway offline" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "Gateway permission required"
|
||||
case .requestingUpgrade:
|
||||
return "Requesting approval"
|
||||
case .upgradeRequested:
|
||||
return "Approval requested"
|
||||
case .apiKeyMissing:
|
||||
return "Voice API key missing"
|
||||
case .loadFailed:
|
||||
return "Voice config failed"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.normalizedStatus.contains("connecting") { return "Connecting" }
|
||||
if self.normalizedStatus.contains("thinking") { return "Asking OpenClaw" }
|
||||
if self.isEnabled { return "Ready to talk" }
|
||||
return "Talk is off"
|
||||
}
|
||||
|
||||
var chipText: String {
|
||||
if !self.gatewayConnected { return "Offline" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "Needs approval"
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return "Pending"
|
||||
case .apiKeyMissing:
|
||||
return "API key"
|
||||
case .loadFailed:
|
||||
return "Config"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.isEnabled { return "Ready" }
|
||||
return "Off"
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
if !self.gatewayConnected { return "wifi.slash" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "key.fill"
|
||||
case .requestingUpgrade:
|
||||
return "paperplane.fill"
|
||||
case .upgradeRequested:
|
||||
return "hourglass"
|
||||
case .apiKeyMissing, .loadFailed:
|
||||
return "exclamationmark.triangle.fill"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return "speaker.wave.2.fill" }
|
||||
if self.isListening { return "mic.fill" }
|
||||
if self.normalizedStatus.contains("thinking") { return "sparkles" }
|
||||
if self.normalizedStatus.contains("connecting") { return "dot.radiowaves.left.and.right" }
|
||||
return "waveform"
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if !self.gatewayConnected { return .secondary }
|
||||
switch self.permissionState {
|
||||
case .requestFailed, .loadFailed:
|
||||
return OpenClawBrand.danger
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .apiKeyMissing:
|
||||
return OpenClawBrand.warn
|
||||
default:
|
||||
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
|
||||
}
|
||||
}
|
||||
|
||||
var primaryAction: TalkProPrimaryAction {
|
||||
if !self.gatewayConnected { return .openSettings }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return .enablePermission
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .waiting
|
||||
case .apiKeyMissing, .loadFailed:
|
||||
return .openSettings
|
||||
default:
|
||||
return self.isEnabled ? .stop : .start
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonTitle: String {
|
||||
switch self.primaryAction {
|
||||
case .start: "Start Talk"
|
||||
case .stop: "Stop Talk"
|
||||
case .enablePermission: "Enable Talk"
|
||||
case .openSettings: self.gatewayConnected ? "Open Voice Settings" : "Open Gateway Settings"
|
||||
case .waiting: "Waiting for Approval"
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonIcon: String {
|
||||
switch self.primaryAction {
|
||||
case .start: "play.fill"
|
||||
case .stop: "stop.fill"
|
||||
case .enablePermission: "key.fill"
|
||||
case .openSettings: "gearshape.fill"
|
||||
case .waiting: "hourglass"
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonFill: AnyShapeStyle {
|
||||
switch self.primaryAction {
|
||||
case .stop:
|
||||
AnyShapeStyle(OpenClawBrand.danger)
|
||||
case .waiting:
|
||||
AnyShapeStyle(OpenClawBrand.warn.opacity(0.72))
|
||||
default:
|
||||
AnyShapeStyle(LinearGradient(
|
||||
colors: [self.color.opacity(0.95), OpenClawBrand.accent],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
}
|
||||
}
|
||||
|
||||
var prefersPermissionCopy: Bool {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func waveformMode(micLevel: Double) -> TalkProWaveformMode {
|
||||
if !self.gatewayConnected { return .still }
|
||||
switch self.permissionState {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .indeterminate
|
||||
case .missingScope, .requestFailed, .apiKeyMissing, .loadFailed:
|
||||
return .still
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return .speaking }
|
||||
if self.isListening, self.isUserSpeechDetected { return .inputSpeech }
|
||||
if self.isListening { return .level(micLevel) }
|
||||
if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") {
|
||||
return .indeterminate
|
||||
}
|
||||
return self.isEnabled ? .indeterminate : .still
|
||||
}
|
||||
}
|
||||
|
||||
private struct TalkProOrb: View {
|
||||
let mode: TalkProWaveformMode
|
||||
let color: Color
|
||||
let systemImage: String
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
|
||||
ZStack {
|
||||
ForEach(0..<3, id: \.self) { ring in
|
||||
Circle()
|
||||
.strokeBorder(self.color.opacity(self.ringOpacity(ring)), lineWidth: 1.4)
|
||||
.scaleEffect(self.ringScale(ring, date: timeline.date))
|
||||
}
|
||||
Circle()
|
||||
.fill(self.color.opacity(0.13))
|
||||
.frame(width: 128, height: 128)
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(self.color.opacity(0.30), lineWidth: 1)
|
||||
}
|
||||
TalkProWaveform(mode: self.mode, tint: self.color, barCount: 18)
|
||||
.frame(width: 116, height: 52)
|
||||
.opacity(self.systemImage == "waveform" || self.systemImage == "mic.fill" ? 1 : 0.34)
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundStyle(self.color)
|
||||
.opacity(self.systemImage == "waveform" || self.systemImage == "mic.fill" ? 0.20 : 1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func ringScale(_ ring: Int, date: Date) -> CGFloat {
|
||||
guard !self.reduceMotion else { return CGFloat(1.0 + (Double(ring) * 0.12)) }
|
||||
let base = 0.88 + (Double(ring) * 0.18)
|
||||
let speed = self.mode == .still ? 0.8 : 1.8
|
||||
let phase = date.timeIntervalSinceReferenceDate * speed + Double(ring) * 0.9
|
||||
return CGFloat(base + (sin(phase) * 0.035))
|
||||
}
|
||||
|
||||
private func ringOpacity(_ ring: Int) -> Double {
|
||||
switch self.mode {
|
||||
case .still:
|
||||
0.10 - (Double(ring) * 0.018)
|
||||
default:
|
||||
0.24 - (Double(ring) * 0.045)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TalkProWaveform: View {
|
||||
let mode: TalkProWaveformMode
|
||||
let tint: Color
|
||||
let barCount: Int
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ForEach(0..<self.barCount, id: \.self) { index in
|
||||
Capsule(style: .continuous)
|
||||
.fill(self.tint.opacity(self.opacity(for: index)))
|
||||
.frame(width: 4, height: self.height(for: index, date: timeline.date))
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func height(for index: Int, date: Date) -> CGFloat {
|
||||
let minimum = 6.0
|
||||
let maximum = 48.0
|
||||
return CGFloat(minimum + ((maximum - minimum) * self.amplitude(for: index, date: date)))
|
||||
}
|
||||
|
||||
private func opacity(for index: Int) -> Double {
|
||||
switch self.mode {
|
||||
case .still:
|
||||
index == self.barCount / 2 ? 0.64 : 0.30
|
||||
default:
|
||||
0.82
|
||||
}
|
||||
}
|
||||
|
||||
private func amplitude(for index: Int, date: Date) -> Double {
|
||||
if self.reduceMotion {
|
||||
switch self.mode {
|
||||
case let .level(level): return min(max(level, 0.10), 1.0)
|
||||
case .inputSpeech: return 0.72
|
||||
case .speaking: return 0.62
|
||||
case .indeterminate: return 0.34
|
||||
case .still: return 0.18
|
||||
}
|
||||
}
|
||||
|
||||
let t = date.timeIntervalSinceReferenceDate
|
||||
let phase = Double(index) * 0.52
|
||||
switch self.mode {
|
||||
case let .level(level):
|
||||
let clamped = min(max(level, 0), 1)
|
||||
let shaped = 0.12 + (0.88 * clamped)
|
||||
let variation = 0.72 + (0.28 * sin((t * 12.0) + phase))
|
||||
return min(max(shaped * variation, 0.10), 1.0)
|
||||
case .inputSpeech:
|
||||
let primary = 0.5 + (0.5 * sin((t * 14.0) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 5.0) + (phase * 1.35)))
|
||||
return min(max(0.16 + (0.60 * primary) + (0.24 * secondary), 0.14), 1.0)
|
||||
case .speaking:
|
||||
let wave = 0.5 + (0.5 * sin((t * 7.5) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 3.0) + (phase * 0.7)))
|
||||
return min(max(0.18 + (0.58 * wave) + (0.24 * secondary), 0.12), 1.0)
|
||||
case .indeterminate:
|
||||
let center = (sin((t * 3.2) + phase) + 1) / 2
|
||||
return 0.16 + (0.42 * center)
|
||||
case .still:
|
||||
return index == self.barCount / 2 ? 0.32 : 0.16
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ private struct RelayGatewayPushRegistrationPayload: Encodable {
|
||||
var topic: String
|
||||
var environment: String
|
||||
var distribution: String
|
||||
var relayOrigin: String
|
||||
var tokenDebugSuffix: String?
|
||||
}
|
||||
|
||||
@@ -107,6 +108,7 @@ actor PushRegistrationManager {
|
||||
topic: topic,
|
||||
environment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
distribution: self.buildConfig.distribution.rawValue,
|
||||
relayOrigin: relayOrigin,
|
||||
tokenDebugSuffix: stored.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
@@ -138,6 +140,7 @@ actor PushRegistrationManager {
|
||||
topic: topic,
|
||||
environment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
distribution: self.buildConfig.distribution.rawValue,
|
||||
relayOrigin: relayOrigin,
|
||||
tokenDebugSuffix: registrationState.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ struct RootTabs: View {
|
||||
private enum AppTab: Hashable {
|
||||
case control
|
||||
case chat
|
||||
case talk
|
||||
case agent
|
||||
case settings
|
||||
}
|
||||
@@ -53,6 +54,8 @@ struct RootTabs: View {
|
||||
switch arguments[valueIndex].lowercased() {
|
||||
case "chat":
|
||||
return .chat
|
||||
case "talk", "voice":
|
||||
return .talk
|
||||
case "agent", "agents":
|
||||
return .agent
|
||||
case "settings":
|
||||
@@ -145,6 +148,14 @@ struct RootTabs: View {
|
||||
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
|
||||
.tag(AppTab.chat)
|
||||
|
||||
TalkProTab(openSettings: { self.selectedTab = .settings })
|
||||
.tabItem {
|
||||
Label(
|
||||
"Talk",
|
||||
systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle")
|
||||
}
|
||||
.tag(AppTab.talk)
|
||||
|
||||
AgentProTab()
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
|
||||
@@ -17,7 +17,7 @@ private func makeRealtimeAudioTapBlock(
|
||||
inputSampleRate: inputSampleRate,
|
||||
targetSampleRate: targetSampleRate)
|
||||
guard !encoded.isEmpty else { return }
|
||||
let timestampMs = ProcessInfo.processInfo.systemUptime * 1000
|
||||
let timestampMs = (ProcessInfo.processInfo.systemUptime * 1000).rounded()
|
||||
let rms = RealtimeTalkRelaySession.rmsLevel(buffer: buffer)
|
||||
onAudio(encoded, timestampMs, rms)
|
||||
}
|
||||
@@ -125,15 +125,24 @@ final class RealtimeTalkRelaySession {
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var outputTask: Task<Void, Never>?
|
||||
private var outputContinuation: AsyncThrowingStream<Data, Error>.Continuation?
|
||||
private var outputIdleTask: Task<Void, Never>?
|
||||
private var outputSessionId = 0
|
||||
private var pendingOutputChunks: [Data] = []
|
||||
private var pendingOutputDone = false
|
||||
private var audioSender: RealtimeAudioSender?
|
||||
private var isClosed = false
|
||||
private var isOutputPlaying = false
|
||||
private var outputStartedAtMs: Double?
|
||||
private var outputPlaybackExpectedEndMs: Double = 0
|
||||
private var lastBargeInAtMs: Double = 0
|
||||
private var micLogFrameCount = 0
|
||||
private var micLogByteCount = 0
|
||||
private var micLogMaxRms: Float = 0
|
||||
private var lastMicLogAtMs: Double = 0
|
||||
private var suppressedEchoFrameCount = 0
|
||||
private var suppressedEchoByteCount = 0
|
||||
private var suppressedEchoMaxRms: Float = 0
|
||||
private var lastSuppressedEchoLogAtMs: Double = 0
|
||||
private var outputAudioChunkCount = 0
|
||||
private var outputAudioByteCount = 0
|
||||
|
||||
@@ -168,7 +177,6 @@ final class RealtimeTalkRelaySession {
|
||||
let eventStream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
self.startEventPump(stream: eventStream)
|
||||
self.configureAudioContract(result.audio)
|
||||
self.startOutputPlayback()
|
||||
try self.startMicrophonePump()
|
||||
self.onStatus("Listening (Realtime)")
|
||||
} catch {
|
||||
@@ -219,7 +227,6 @@ final class RealtimeTalkRelaySession {
|
||||
|
||||
func cancelOutput(reason: String = "user") {
|
||||
self.stopOutputPlayback()
|
||||
self.startOutputPlayback()
|
||||
guard let relaySessionId else { return }
|
||||
Task { [gateway] in
|
||||
let payload: [String: Any] = [
|
||||
@@ -306,12 +313,18 @@ final class RealtimeTalkRelaySession {
|
||||
let data = Data(base64Encoded: base64)
|
||||
else { return }
|
||||
self.recordOutputAudioChunk(byteCount: data.count)
|
||||
self.markOutputAudioStarted(nowMs: ProcessInfo.processInfo.systemUptime * 1000)
|
||||
self.markOutputAudioStarted(byteCount: data.count, nowMs: ProcessInfo.processInfo.systemUptime * 1000)
|
||||
self.onSpeakingChanged(true)
|
||||
if self.outputContinuation == nil, self.outputTask != nil {
|
||||
self.pendingOutputChunks.append(data)
|
||||
return
|
||||
}
|
||||
self.ensureOutputPlaybackStarted()
|
||||
self.outputContinuation?.yield(data)
|
||||
case "audioDone":
|
||||
self.finishOutputPlaybackStream()
|
||||
case "clear":
|
||||
self.stopOutputPlayback()
|
||||
self.startOutputPlayback()
|
||||
case "transcript":
|
||||
self.handleTranscriptEvent(payload)
|
||||
case "toolCall":
|
||||
@@ -337,11 +350,16 @@ final class RealtimeTalkRelaySession {
|
||||
"talk realtime audio: chunks=\(self.outputAudioChunkCount) bytes=\(self.outputAudioByteCount)")
|
||||
}
|
||||
|
||||
private func markOutputAudioStarted(nowMs: Double) {
|
||||
private func markOutputAudioStarted(byteCount: Int, nowMs: Double) {
|
||||
if !self.isOutputPlaying {
|
||||
self.outputStartedAtMs = nowMs
|
||||
self.outputPlaybackExpectedEndMs = nowMs
|
||||
}
|
||||
self.isOutputPlaying = true
|
||||
let bytesPerSecond = max(1, self.outputSampleRateHz * Double(MemoryLayout<Int16>.size))
|
||||
let chunkDurationMs = (Double(byteCount) / bytesPerSecond) * 1000
|
||||
self.outputPlaybackExpectedEndMs = max(nowMs, self.outputPlaybackExpectedEndMs) + chunkDurationMs
|
||||
self.scheduleOutputPlaybackIdle(expectedEndMs: self.outputPlaybackExpectedEndMs)
|
||||
}
|
||||
|
||||
private func handleInputLevelDuringOutput(_ rms: Float, timestampMs: Double) {
|
||||
@@ -537,14 +555,25 @@ final class RealtimeTalkRelaySession {
|
||||
{ [weak self, audioSender = self.audioSender] encoded, timestampMs, rms in
|
||||
guard let audioSender else { return }
|
||||
Task {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.recordMicrophoneFrame(byteCount: encoded.count, rms: rms, timestampMs: timestampMs)
|
||||
}
|
||||
if rms >= Self.bargeInRmsThreshold {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handleInputLevelDuringOutput(rms, timestampMs: timestampMs)
|
||||
let shouldSend = await MainActor.run { [weak self] in
|
||||
guard let self, !self.isClosed else { return false }
|
||||
self.recordMicrophoneFrame(byteCount: encoded.count, rms: rms, timestampMs: timestampMs)
|
||||
self.refreshOutputPlaybackState(timestampMs: timestampMs)
|
||||
if self.isOutputPlaying {
|
||||
if self.shouldSuppressMicrophoneDuringOutput() {
|
||||
self.recordSuppressedOutputEchoFrame(
|
||||
byteCount: encoded.count,
|
||||
rms: rms,
|
||||
timestampMs: timestampMs)
|
||||
return false
|
||||
}
|
||||
if rms >= Self.bargeInRmsThreshold {
|
||||
self.handleInputLevelDuringOutput(rms, timestampMs: timestampMs)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
guard shouldSend else { return }
|
||||
guard let message = await audioSender.send(encoded, timestampMs: timestampMs) else { return }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, !self.isClosed else { return }
|
||||
@@ -561,6 +590,13 @@ final class RealtimeTalkRelaySession {
|
||||
try self.audioEngine.start()
|
||||
}
|
||||
|
||||
private func shouldSuppressMicrophoneDuringOutput() -> Bool {
|
||||
let outputs = AVAudioSession.sharedInstance().currentRoute.outputs
|
||||
// Built-in speaker output bleeds into the microphone even in voiceChat mode; keep the
|
||||
// realtime provider from treating its own speech as user input. Headsets keep barge-in.
|
||||
return outputs.contains { $0.portType == .builtInSpeaker }
|
||||
}
|
||||
|
||||
private func recordMicrophoneFrame(byteCount: Int, rms: Float, timestampMs: Double) {
|
||||
guard !self.isClosed else { return }
|
||||
self.micLogFrameCount += 1
|
||||
@@ -576,13 +612,31 @@ final class RealtimeTalkRelaySession {
|
||||
self.micLogMaxRms = 0
|
||||
}
|
||||
|
||||
private func recordSuppressedOutputEchoFrame(byteCount: Int, rms: Float, timestampMs: Double) {
|
||||
self.suppressedEchoFrameCount += 1
|
||||
self.suppressedEchoByteCount += byteCount
|
||||
self.suppressedEchoMaxRms = max(self.suppressedEchoMaxRms, rms)
|
||||
guard timestampMs - self.lastSuppressedEchoLogAtMs >= 1000 else { return }
|
||||
self.lastSuppressedEchoLogAtMs = timestampMs
|
||||
let maxRms = String(format: "%.4f", Double(self.suppressedEchoMaxRms))
|
||||
GatewayDiagnostics.log(
|
||||
"talk realtime mic suppressed during output: "
|
||||
+ "buffers=\(self.suppressedEchoFrameCount) "
|
||||
+ "bytes=\(self.suppressedEchoByteCount) maxRms=\(maxRms)")
|
||||
self.suppressedEchoFrameCount = 0
|
||||
self.suppressedEchoByteCount = 0
|
||||
self.suppressedEchoMaxRms = 0
|
||||
}
|
||||
|
||||
private func stopMicrophonePump() {
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
self.audioEngine.stop()
|
||||
}
|
||||
|
||||
private func startOutputPlayback() {
|
||||
self.stopOutputPlayback()
|
||||
private func ensureOutputPlaybackStarted() {
|
||||
guard self.outputContinuation == nil, self.outputTask == nil else { return }
|
||||
self.outputSessionId += 1
|
||||
let sessionId = self.outputSessionId
|
||||
let stream = AsyncThrowingStream<Data, Error> { continuation in
|
||||
self.outputContinuation = continuation
|
||||
}
|
||||
@@ -590,28 +644,95 @@ final class RealtimeTalkRelaySession {
|
||||
guard let self else { return }
|
||||
let result = await self.pcmPlayer.play(stream: stream, sampleRate: self.outputSampleRateHz)
|
||||
await MainActor.run {
|
||||
guard self.outputSessionId == sessionId else { return }
|
||||
self.outputTask = nil
|
||||
self.outputContinuation = nil
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.logger.info("realtime output interrupted at \(interruptedAt, privacy: .public)s")
|
||||
}
|
||||
self.markOutputPlaybackFinished()
|
||||
self.startPendingOutputPlaybackIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func markOutputPlaybackFinished() {
|
||||
private func finishOutputPlaybackStream() {
|
||||
guard let continuation = self.outputContinuation else {
|
||||
if self.outputTask != nil, !self.pendingOutputChunks.isEmpty {
|
||||
self.pendingOutputDone = true
|
||||
}
|
||||
return
|
||||
}
|
||||
continuation.finish()
|
||||
self.outputContinuation = nil
|
||||
}
|
||||
|
||||
private func startPendingOutputPlaybackIfNeeded() {
|
||||
guard !self.pendingOutputChunks.isEmpty else {
|
||||
self.pendingOutputDone = false
|
||||
return
|
||||
}
|
||||
let chunks = self.pendingOutputChunks
|
||||
let shouldFinish = self.pendingOutputDone
|
||||
self.pendingOutputChunks = []
|
||||
self.pendingOutputDone = false
|
||||
self.ensureOutputPlaybackStarted()
|
||||
for chunk in chunks {
|
||||
self.markOutputAudioStarted(byteCount: chunk.count, nowMs: ProcessInfo.processInfo.systemUptime * 1000)
|
||||
self.onSpeakingChanged(true)
|
||||
self.outputContinuation?.yield(chunk)
|
||||
}
|
||||
if shouldFinish {
|
||||
self.finishOutputPlaybackStream()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleOutputPlaybackIdle(expectedEndMs: Double) {
|
||||
self.outputIdleTask?.cancel()
|
||||
let nowMs = ProcessInfo.processInfo.systemUptime * 1000
|
||||
let idleDelayMs = max(350, expectedEndMs - nowMs + 500)
|
||||
self.outputIdleTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(idleDelayMs * 1_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, !self.isClosed else { return }
|
||||
let nowMs = ProcessInfo.processInfo.systemUptime * 1000
|
||||
self.refreshOutputPlaybackState(timestampMs: nowMs, cancelIdleTask: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshOutputPlaybackState(timestampMs: Double, cancelIdleTask: Bool = true) {
|
||||
guard self.isOutputPlaying else { return }
|
||||
guard timestampMs >= self.outputPlaybackExpectedEndMs + 500 else { return }
|
||||
self.markOutputPlaybackFinished(cancelIdleTask: cancelIdleTask)
|
||||
}
|
||||
|
||||
private func markOutputPlaybackFinished(cancelIdleTask: Bool = true) {
|
||||
if cancelIdleTask {
|
||||
self.outputIdleTask?.cancel()
|
||||
self.outputIdleTask = nil
|
||||
}
|
||||
self.isOutputPlaying = false
|
||||
self.outputStartedAtMs = nil
|
||||
self.outputPlaybackExpectedEndMs = 0
|
||||
self.onSpeakingChanged(false)
|
||||
}
|
||||
|
||||
private func stopOutputPlayback() {
|
||||
self.outputSessionId += 1
|
||||
self.outputContinuation?.finish()
|
||||
self.outputContinuation = nil
|
||||
self.outputTask?.cancel()
|
||||
self.outputTask = nil
|
||||
self.outputIdleTask?.cancel()
|
||||
self.outputIdleTask = nil
|
||||
self.pendingOutputChunks = []
|
||||
self.pendingOutputDone = false
|
||||
_ = self.pcmPlayer.stop()
|
||||
self.isOutputPlaying = false
|
||||
self.outputStartedAtMs = nil
|
||||
self.outputPlaybackExpectedEndMs = 0
|
||||
self.onSpeakingChanged(false)
|
||||
}
|
||||
|
||||
@@ -684,7 +805,7 @@ final class RealtimeTalkRelaySession {
|
||||
|
||||
extension RealtimeTalkRelaySession {
|
||||
func _test_markOutputAudioStarted(nowMs: Double) {
|
||||
self.markOutputAudioStarted(nowMs: nowMs)
|
||||
self.markOutputAudioStarted(byteCount: 4800, nowMs: nowMs)
|
||||
}
|
||||
|
||||
func _test_markOutputPlaybackFinished() {
|
||||
|
||||
@@ -1141,7 +1141,7 @@ final class TalkModeManager: NSObject {
|
||||
})
|
||||
self.realtimeRelaySession = relaySession
|
||||
do {
|
||||
try Self.configureAudioSession()
|
||||
try Self.configureRealtimeAudioSession()
|
||||
try await relaySession.start()
|
||||
guard self.realtimeRelaySession === relaySession, self.isEnabled else {
|
||||
relaySession.stop()
|
||||
|
||||
@@ -13,6 +13,7 @@ Sources/Design/AgentProNodesDestination.swift
|
||||
Sources/Design/AgentProTab.swift
|
||||
Sources/Design/ChatProTab.swift
|
||||
Sources/Design/CommandCenterTab.swift
|
||||
Sources/Design/TalkProTab.swift
|
||||
Sources/Design/OpenClawProComponents.swift
|
||||
Sources/Design/OpenClawProScreens.swift
|
||||
Sources/Design/SettingsProTab.swift
|
||||
|
||||
@@ -361,6 +361,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"get_goal": {
|
||||
"emoji": "🎯",
|
||||
"title": "Get Goal",
|
||||
"detailKeys": []
|
||||
},
|
||||
"create_goal": {
|
||||
"emoji": "🎯",
|
||||
"title": "Create Goal",
|
||||
"detailKeys": [
|
||||
"objective",
|
||||
"token_budget"
|
||||
]
|
||||
},
|
||||
"update_goal": {
|
||||
"emoji": "🎯",
|
||||
"title": "Update Goal",
|
||||
"detailKeys": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"update_plan": {
|
||||
"emoji": "🗺️",
|
||||
"title": "Update Plan",
|
||||
|
||||
@@ -556,7 +556,7 @@ public struct MessageActionParams: Codable, Sendable {
|
||||
sessionkey: String?,
|
||||
sessionid: String?,
|
||||
inboundturnkind: String? = nil,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
toolcontext: [String: AnyCodable]?,
|
||||
idempotencykey: String)
|
||||
{
|
||||
@@ -617,7 +617,7 @@ public struct SendParams: Codable, Sendable {
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
replytoid: String?,
|
||||
threadid: String?,
|
||||
forcedocument: Bool?,
|
||||
@@ -765,7 +765,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
provider: String?,
|
||||
model: String?,
|
||||
to: String?,
|
||||
@@ -893,7 +893,7 @@ public struct AgentIdentityParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
|
||||
public init(
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
sessionkey: String?)
|
||||
{
|
||||
self.agentid = agentid
|
||||
@@ -1617,7 +1617,7 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
includelastmessage: Bool?,
|
||||
label: String?,
|
||||
spawnedby: String?,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
search: String?)
|
||||
{
|
||||
self.limit = limit
|
||||
@@ -1741,7 +1741,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
key: String?,
|
||||
sessionid: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
spawnedby: String?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?)
|
||||
@@ -1825,6 +1825,7 @@ public struct SessionOperationEvent: Codable, Sendable {
|
||||
public let operation: String
|
||||
public let phase: AnyCodable
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let ts: Int
|
||||
public let completed: Bool?
|
||||
public let reason: String?
|
||||
@@ -1834,6 +1835,7 @@ public struct SessionOperationEvent: Codable, Sendable {
|
||||
operation: String,
|
||||
phase: AnyCodable,
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
ts: Int,
|
||||
completed: Bool?,
|
||||
reason: String?)
|
||||
@@ -1842,6 +1844,7 @@ public struct SessionOperationEvent: Codable, Sendable {
|
||||
self.operation = operation
|
||||
self.phase = phase
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.ts = ts
|
||||
self.completed = completed
|
||||
self.reason = reason
|
||||
@@ -1852,6 +1855,7 @@ public struct SessionOperationEvent: Codable, Sendable {
|
||||
case operation
|
||||
case phase
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case ts
|
||||
case completed
|
||||
case reason
|
||||
@@ -1860,68 +1864,84 @@ public struct SessionOperationEvent: Codable, Sendable {
|
||||
|
||||
public struct SessionsCompactionListParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
key: String,
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionGetParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
agentid: String? = nil,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionBranchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
agentid: String? = nil,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionRestoreParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
agentid: String? = nil,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
@@ -2046,7 +2066,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
label: String?,
|
||||
model: String?,
|
||||
parentsessionkey: String?,
|
||||
@@ -2078,6 +2098,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsSendParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
public let attachments: [AnyCodable]?
|
||||
@@ -2086,6 +2107,7 @@ public struct SessionsSendParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
agentid: String? = nil,
|
||||
message: String,
|
||||
thinking: String?,
|
||||
attachments: [AnyCodable]?,
|
||||
@@ -2093,6 +2115,7 @@ public struct SessionsSendParams: Codable, Sendable {
|
||||
idempotencykey: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.message = message
|
||||
self.thinking = thinking
|
||||
self.attachments = attachments
|
||||
@@ -2102,6 +2125,7 @@ public struct SessionsSendParams: Codable, Sendable {
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case message
|
||||
case thinking
|
||||
case attachments
|
||||
@@ -2112,29 +2136,37 @@ public struct SessionsSendParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsMessagesSubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
key: String,
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
key: String,
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2162,6 +2194,7 @@ public struct SessionsAbortParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
public let label: AnyCodable?
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let fastmode: AnyCodable?
|
||||
@@ -2188,6 +2221,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
agentid: String? = nil,
|
||||
label: AnyCodable?,
|
||||
thinkinglevel: AnyCodable?,
|
||||
fastmode: AnyCodable?,
|
||||
@@ -2213,6 +2247,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
groupactivation: AnyCodable?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.label = label
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.fastmode = fastmode
|
||||
@@ -2240,6 +2275,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case label
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case fastmode = "fastMode"
|
||||
@@ -2320,39 +2356,47 @@ public struct SessionsPluginPatchResult: Codable, Sendable {
|
||||
|
||||
public struct SessionsResetParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
public let reason: AnyCodable?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
agentid: String? = nil,
|
||||
reason: AnyCodable?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.reason = reason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case reason
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsDeleteParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
public let deletetranscript: Bool?
|
||||
public let emitlifecyclehooks: Bool?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
agentid: String? = nil,
|
||||
deletetranscript: Bool?,
|
||||
emitlifecyclehooks: Bool?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.deletetranscript = deletetranscript
|
||||
self.emitlifecyclehooks = emitlifecyclehooks
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case deletetranscript = "deleteTranscript"
|
||||
case emitlifecyclehooks = "emitLifecycleHooks"
|
||||
}
|
||||
@@ -2360,18 +2404,22 @@ public struct SessionsDeleteParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsCompactParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let agentid: String?
|
||||
public let maxlines: Int?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
agentid: String? = nil,
|
||||
maxlines: Int?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.maxlines = maxlines
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case maxlines = "maxLines"
|
||||
}
|
||||
}
|
||||
@@ -2463,7 +2511,7 @@ public struct TaskSummary: Codable, Sendable {
|
||||
runtime: String?,
|
||||
status: AnyCodable,
|
||||
title: String?,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
sessionkey: String?,
|
||||
childsessionkey: String?,
|
||||
ownerkey: String?,
|
||||
@@ -2537,7 +2585,7 @@ public struct TasksListParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
status: AnyCodable?,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
sessionkey: String?,
|
||||
limit: Int?,
|
||||
cursor: String?)
|
||||
@@ -4727,7 +4775,7 @@ public struct CommandsListParams: Codable, Sendable {
|
||||
public let includeargs: Bool?
|
||||
|
||||
public init(
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
provider: String?,
|
||||
scope: AnyCodable?,
|
||||
includeargs: Bool?)
|
||||
@@ -4764,7 +4812,7 @@ public struct SkillsStatusParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
agentid: String?)
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.agentid = agentid
|
||||
}
|
||||
@@ -4779,7 +4827,7 @@ public struct ToolsCatalogParams: Codable, Sendable {
|
||||
public let includeplugins: Bool?
|
||||
|
||||
public init(
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
includeplugins: Bool?)
|
||||
{
|
||||
self.agentid = agentid
|
||||
@@ -4913,7 +4961,7 @@ public struct ToolsEffectiveParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
|
||||
public init(
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
sessionkey: String)
|
||||
{
|
||||
self.agentid = agentid
|
||||
@@ -5058,7 +5106,7 @@ public struct ToolsInvokeParams: Codable, Sendable {
|
||||
name: String,
|
||||
args: [String: AnyCodable]?,
|
||||
sessionkey: String?,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
confirm: Bool?,
|
||||
idempotencykey: String?)
|
||||
{
|
||||
@@ -5232,7 +5280,7 @@ public struct SkillsSecurityVerdictsParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
agentid: String?)
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.agentid = agentid
|
||||
}
|
||||
@@ -5265,7 +5313,7 @@ public struct SkillsSkillCardParams: Codable, Sendable {
|
||||
public let skillkey: String
|
||||
|
||||
public init(
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
skillkey: String)
|
||||
{
|
||||
self.agentid = agentid
|
||||
@@ -5402,7 +5450,7 @@ public struct CronJob: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
sessionkey: String?,
|
||||
name: String,
|
||||
description: String?,
|
||||
@@ -5478,7 +5526,7 @@ public struct CronListParams: Codable, Sendable {
|
||||
lastrunstatus: AnyCodable?,
|
||||
sortby: AnyCodable?,
|
||||
sortdir: AnyCodable?,
|
||||
agentid: String?)
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.includedisabled = includedisabled
|
||||
self.limit = limit
|
||||
@@ -5524,7 +5572,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
agentid: AnyCodable?,
|
||||
agentid: AnyCodable? = nil,
|
||||
sessionkey: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
@@ -5912,7 +5960,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
ask: AnyCodable?,
|
||||
warningtext: AnyCodable?,
|
||||
commandspans: [[String: AnyCodable]]?,
|
||||
agentid: AnyCodable?,
|
||||
agentid: AnyCodable? = nil,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
turnsourcechannel: AnyCodable?,
|
||||
@@ -6019,7 +6067,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
toolname: String?,
|
||||
toolcallid: String?,
|
||||
alloweddecisions: [String]?,
|
||||
agentid: String?,
|
||||
agentid: String? = nil,
|
||||
sessionkey: String?,
|
||||
turnsourcechannel: String?,
|
||||
turnsourceto: String?,
|
||||
@@ -6480,21 +6528,25 @@ public struct DevicePairResolvedEvent: Codable, Sendable {
|
||||
|
||||
public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let limit: Int?
|
||||
public let maxchars: Int?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
limit: Int?,
|
||||
maxchars: Int?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.limit = limit
|
||||
self.maxchars = maxchars
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case limit
|
||||
case maxchars = "maxChars"
|
||||
}
|
||||
@@ -6502,6 +6554,7 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
|
||||
public struct ChatSendParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let sessionid: String?
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
@@ -6519,6 +6572,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
sessionid: String?,
|
||||
message: String,
|
||||
thinking: String?,
|
||||
@@ -6535,6 +6589,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
idempotencykey: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.sessionid = sessionid
|
||||
self.message = message
|
||||
self.thinking = thinking
|
||||
@@ -6553,6 +6608,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case sessionid = "sessionId"
|
||||
case message
|
||||
case thinking
|
||||
@@ -6572,39 +6628,47 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
|
||||
public struct ChatAbortParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let runid: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
runid: String?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.runid = runid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case runid = "runId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatInjectParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let message: String
|
||||
public let label: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
message: String,
|
||||
label: String?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.message = message
|
||||
self.label = label
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case message
|
||||
case label
|
||||
}
|
||||
@@ -6613,6 +6677,7 @@ public struct ChatInjectParams: Codable, Sendable {
|
||||
public struct ChatDeltaEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
public let seq: Int
|
||||
public let state: String
|
||||
@@ -6624,6 +6689,7 @@ public struct ChatDeltaEvent: Codable, Sendable {
|
||||
public init(
|
||||
runid: String,
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
spawnedby: String?,
|
||||
seq: Int,
|
||||
state: String,
|
||||
@@ -6634,6 +6700,7 @@ public struct ChatDeltaEvent: Codable, Sendable {
|
||||
{
|
||||
self.runid = runid
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
self.seq = seq
|
||||
self.state = state
|
||||
@@ -6646,6 +6713,7 @@ public struct ChatDeltaEvent: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
case seq
|
||||
case state
|
||||
@@ -6659,6 +6727,7 @@ public struct ChatDeltaEvent: Codable, Sendable {
|
||||
public struct ChatFinalEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
public let seq: Int
|
||||
public let state: String
|
||||
@@ -6669,6 +6738,7 @@ public struct ChatFinalEvent: Codable, Sendable {
|
||||
public init(
|
||||
runid: String,
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
spawnedby: String?,
|
||||
seq: Int,
|
||||
state: String,
|
||||
@@ -6678,6 +6748,7 @@ public struct ChatFinalEvent: Codable, Sendable {
|
||||
{
|
||||
self.runid = runid
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
self.seq = seq
|
||||
self.state = state
|
||||
@@ -6689,6 +6760,7 @@ public struct ChatFinalEvent: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
case seq
|
||||
case state
|
||||
@@ -6701,6 +6773,7 @@ public struct ChatFinalEvent: Codable, Sendable {
|
||||
public struct ChatAbortedEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
public let seq: Int
|
||||
public let state: String
|
||||
@@ -6710,6 +6783,7 @@ public struct ChatAbortedEvent: Codable, Sendable {
|
||||
public init(
|
||||
runid: String,
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
spawnedby: String?,
|
||||
seq: Int,
|
||||
state: String,
|
||||
@@ -6718,6 +6792,7 @@ public struct ChatAbortedEvent: Codable, Sendable {
|
||||
{
|
||||
self.runid = runid
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
self.seq = seq
|
||||
self.state = state
|
||||
@@ -6728,6 +6803,7 @@ public struct ChatAbortedEvent: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
case seq
|
||||
case state
|
||||
@@ -6739,6 +6815,7 @@ public struct ChatAbortedEvent: Codable, Sendable {
|
||||
public struct ChatErrorEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
public let seq: Int
|
||||
public let state: String
|
||||
@@ -6751,6 +6828,7 @@ public struct ChatErrorEvent: Codable, Sendable {
|
||||
public init(
|
||||
runid: String,
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
spawnedby: String?,
|
||||
seq: Int,
|
||||
state: String,
|
||||
@@ -6762,6 +6840,7 @@ public struct ChatErrorEvent: Codable, Sendable {
|
||||
{
|
||||
self.runid = runid
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
self.seq = seq
|
||||
self.state = state
|
||||
@@ -6775,6 +6854,7 @@ public struct ChatErrorEvent: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
case seq
|
||||
case state
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
c80dea63b0a3786c8999d06aae62c110786f440b4d6748f9838577aaa2816971 config-baseline.json
|
||||
948323a1507817b6580ed976f9f9449239008f40283cc7e6005148ecf0ca4582 config-baseline.core.json
|
||||
f833ffca6bd88162f062bbea4f0eede783373f46674ebbfc3a390c80353930a2 config-baseline.channel.json
|
||||
bc38b58b67132401a030b3b3a77efdb6c88f207ea1fab9abcb4599e1f9552dda config-baseline.plugin.json
|
||||
ac5e91a6adaf02491d2ff6b983f054c813972da3bf79db68cd1d10887a22c594 config-baseline.json
|
||||
023e3b85ee79e85f90257e65a1376b1212cf534b6a9cff4b4388c9092e846549 config-baseline.core.json
|
||||
a9102c0611b8170fac37853cc31771810f31757a9e3b2c6796bbd9625f9b9206 config-baseline.channel.json
|
||||
2f018852d9682871dd22f0920cafc8994a6c0952e8101229210efa6103ae9536 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
59de21361cab0622926ad313caf3f8dc43c28d420a82ba060680ecc30c472453 plugin-sdk-api-baseline.json
|
||||
05adee9037669db4e834d1a0ca9705d5d94df770083862ab149d2f3e559010d2 plugin-sdk-api-baseline.jsonl
|
||||
49a138a9743063067b983c4dd27d047572aef0764c0e5f87a98d91f43d4f8213 plugin-sdk-api-baseline.json
|
||||
cd7ea2f2b4c1d1d073c3077410d44270244e778f33197567f4127a946cc0f7f7 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -102,7 +102,7 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat
|
||||
<Accordion title="Notify defaults for cron and media">
|
||||
Main-session cron tasks use `silent` notify policy by default - they create records for tracking but do not generate notifications. Isolated cron tasks also default to `silent` but are more visible because they run in their own session.
|
||||
|
||||
Session-backed `image_generate`, `music_generate`, and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished media itself. Generated-media completion events require message-tool delivery: the agent must send the finished media with the `message` tool, then reply `NO_REPLY`. If the requester session is no longer active or its active wake fails, and the completion agent misses some or all generated media, OpenClaw sends an idempotent direct fallback with only the missing media to the original channel target.
|
||||
Session-backed `image_generate`, `music_generate`, and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished media itself. The requester agent follows its normal visible-reply contract: automatic final reply when configured, or `message(action="send")` plus `NO_REPLY` when the session requires message-tool replies. If the requester session is no longer active or its active wake fails, and the completion agent misses some or all generated media, OpenClaw sends an idempotent direct fallback with only the missing media to the original channel target.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Concurrent media-generation guardrail">
|
||||
|
||||
@@ -43,6 +43,7 @@ Notes:
|
||||
- Local mode uses the embedded agent runtime directly. Most local tools work, but Gateway-only features are unavailable.
|
||||
- Local mode adds `/auth [provider]` inside the TUI command surface.
|
||||
- Plugin approval gates still apply in local mode. Tools that require approval prompt for a decision in the terminal; nothing is silently auto-approved because the Gateway is not involved.
|
||||
- Session [goals](/tools/goal) appear in the footer and can be managed with `/goal`.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -87,3 +88,4 @@ rerun `openclaw config validate`. See [TUI](/web/tui) and [Config](/cli/config).
|
||||
|
||||
- [CLI reference](/cli)
|
||||
- [TUI](/web/tui)
|
||||
- [Goal](/tools/goal)
|
||||
|
||||
@@ -40,7 +40,7 @@ There are two runtime families:
|
||||
model, execute through Claude CLI." `claude-cli` is not an embedded harness id
|
||||
and must not be passed to AgentHarness selection.
|
||||
|
||||
The `copilot` harness is a separate, opt-in plugin harness for the
|
||||
The `copilot` harness is a separate, opt-in external plugin harness for the
|
||||
GitHub Copilot CLI; see [GitHub Copilot agent runtime](/plugins/copilot)
|
||||
for the user-facing decision between PI, Codex, and GitHub Copilot agent runtime.
|
||||
|
||||
@@ -207,7 +207,7 @@ If `openclaw doctor` warns that the `codex` plugin is enabled while
|
||||
|
||||
## GitHub Copilot agent runtime
|
||||
|
||||
The bundled `copilot` extension registers an opt-in `copilot` runtime
|
||||
The external `@openclaw/copilot` plugin registers an opt-in `copilot` runtime
|
||||
backed by the GitHub Copilot CLI (`@github/copilot-sdk`). It claims the
|
||||
canonical subscription `github-copilot` provider and is **never** selected by
|
||||
`auto`. Opt in per-model or per-provider via `agentRuntime.id`:
|
||||
|
||||
@@ -30,7 +30,7 @@ Treat them differently from normal config:
|
||||
|
||||
## Local model lean mode
|
||||
|
||||
`agents.defaults.experimental.localModelLean: true` is a pressure-release valve for weaker local-model setups. When it is on, OpenClaw drops three default tools — `browser`, `cron`, and `message` — from the model-visible tool surface for every turn. When Code Mode or Tool Search is enabled, those tools can still stay in the hidden catalog behind the compact controls. Use `agents.list[].experimental.localModelLean` to enable or disable the same behavior for one configured agent.
|
||||
`agents.defaults.experimental.localModelLean: true` is a pressure-release valve for weaker local-model setups. When it is on, OpenClaw drops three default tools — `browser`, `cron`, and `message` — from the agent's tool surface for every turn. Nothing else changes. Use `agents.list[].experimental.localModelLean` to enable or disable the same behavior for one configured agent.
|
||||
|
||||
### Why these three tools
|
||||
|
||||
@@ -40,7 +40,7 @@ These three tools have the largest descriptions and the most parameter shapes in
|
||||
- The model picking the right tool vs. emitting malformed tool calls because there are too many similar-looking schemas.
|
||||
- The Chat Completions adapter staying inside the server's structured-output limits vs. tripping a 400 on tool-call payload size.
|
||||
|
||||
Removing them does not silently rewire OpenClaw — it just makes the visible tool list shorter. The model still has `read`, `write`, `edit`, `exec`, `apply_patch`, web search/fetch (when configured), memory, and session/agent tools available. With Code Mode or Tool Search, the compact control can still search for and call hidden catalog tools that policy allowed for the run.
|
||||
Removing them does not silently rewire OpenClaw — it just makes the tool list shorter. The model still has `read`, `write`, `edit`, `exec`, `apply_patch`, web search/fetch (when configured), memory, and session/agent tools available.
|
||||
|
||||
### When to turn it on
|
||||
|
||||
@@ -94,7 +94,7 @@ Restart the Gateway after changing the flag, then confirm the trimmed tool list
|
||||
openclaw status --deep
|
||||
```
|
||||
|
||||
The deep status output lists the active model-visible agent tools; `browser`, `cron`, and `message` should be absent when lean mode is on. If Code Mode or Tool Search is enabled, they may still be available through the hidden catalog.
|
||||
The deep status output lists the active agent tools; `browser`, `cron`, and `message` should be absent when lean mode is on.
|
||||
|
||||
## Experimental does not mean hidden
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ sidebarTitle: "Models CLI"
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Model refs choose a provider and model. They do not usually choose the low-level agent runtime. OpenAI agent refs are the main exception: `openai/gpt-5.5` runs through the Codex app-server runtime by default on the official OpenAI provider. Subscription Copilot refs (`github-copilot/*`) can additionally be opted into the bundled GitHub Copilot agent runtime — that path stays explicit (no `auto` fallback). Explicit runtime overrides belong on provider/model policy, not on the whole agent or session. In Codex runtime mode, the `openai/gpt-*` ref does not imply API-key billing; auth can come from a Codex account or `openai-codex` auth profile. See [Agent runtimes](/concepts/agent-runtimes) and [GitHub Copilot agent runtime](/plugins/copilot).
|
||||
Model refs choose a provider and model. They do not usually choose the low-level agent runtime. OpenAI agent refs are the main exception: `openai/gpt-5.5` runs through the Codex app-server runtime by default on the official OpenAI provider. Subscription Copilot refs (`github-copilot/*`) can additionally be opted into the external GitHub Copilot agent runtime plugin — that path stays explicit (no `auto` fallback). Explicit runtime overrides belong on provider/model policy, not on the whole agent or session. In Codex runtime mode, the `openai/gpt-*` ref does not imply API-key billing; auth can come from a Codex account or `openai-codex` auth profile. See [Agent runtimes](/concepts/agent-runtimes) and [GitHub Copilot agent runtime](/plugins/copilot).
|
||||
|
||||
## How model selection works
|
||||
|
||||
|
||||
@@ -266,6 +266,10 @@ The doctor checks Convex broker env, validates endpoint settings, and verifies a
|
||||
|
||||
Live transport lanes share one contract instead of each inventing their own scenario list shape. `qa-channel` is the broad synthetic product-behavior suite and is not part of the live transport coverage matrix.
|
||||
|
||||
Live transport runners should import the shared scenario ids, baseline
|
||||
coverage helpers, and scenario-selection helper from
|
||||
`openclaw/plugin-sdk/qa-live-transport-scenarios`.
|
||||
|
||||
| Lane | Canary | Mention gating | Bot-to-bot | Allowlist block | Top-level reply | Restart resume | Thread follow-up | Thread isolation | Reaction observation | Help command | Native command registration |
|
||||
| -------- | ------ | -------------- | ---------- | --------------- | --------------- | -------------- | ---------------- | ---------------- | -------------------- | ------------ | --------------------------- |
|
||||
| Matrix | x | x | x | x | x | x | x | x | x | | |
|
||||
|
||||
@@ -1333,6 +1333,7 @@
|
||||
"group": "Agent coordination",
|
||||
"pages": [
|
||||
"tools/agent-send",
|
||||
"tools/goal",
|
||||
"tools/steer",
|
||||
"tools/subagents",
|
||||
"tools/acp-agents",
|
||||
|
||||
@@ -617,7 +617,7 @@ Periodic heartbeat runs.
|
||||
- `every`: duration string (ms/s/m/h). Default: `30m` (API-key auth) or `1h` (OAuth auth). Set to `0m` to disable.
|
||||
- `includeSystemPromptSection`: when false, omits the Heartbeat section from the system prompt and skips `HEARTBEAT.md` injection into bootstrap context. Default: `true`.
|
||||
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
|
||||
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds`.
|
||||
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
|
||||
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
|
||||
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
|
||||
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.
|
||||
|
||||
@@ -386,12 +386,13 @@ Controls inline attachment support for `sessions_spawn`.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Attachment notes">
|
||||
- Attachments are only supported for `runtime: "subagent"`. ACP runtime rejects them.
|
||||
- Files are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
|
||||
- Attachments require `enabled: true`.
|
||||
- Subagent attachments are materialized into the child workspace at `.openclaw/attachments/<uuid>/` with a `.manifest.json`.
|
||||
- ACP attachments are image-only and forwarded inline to the ACP runtime after the same file count, per-file byte, and total byte limits pass.
|
||||
- Attachment content is automatically redacted from transcript persistence.
|
||||
- Base64 inputs are validated with strict alphabet/padding checks and a pre-decode size guard.
|
||||
- File permissions are `0700` for directories and `0600` for files.
|
||||
- Cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
|
||||
- Subagent attachment file permissions are `0700` for directories and `0600` for files.
|
||||
- Subagent cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -337,9 +337,9 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Enable relay-backed push for official iOS builds">
|
||||
Relay-backed push is configured in `openclaw.json`.
|
||||
Relay-backed push uses the hosted OpenClaw relay by default: `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
Set this in gateway config:
|
||||
To use a custom relay, set this in gateway config:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -373,8 +373,8 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
|
||||
End-to-end flow:
|
||||
|
||||
1. Install an official/TestFlight iOS build that was compiled with the same relay base URL.
|
||||
2. Configure `gateway.push.apns.relay.baseUrl` on the gateway.
|
||||
1. Install an official/TestFlight iOS build.
|
||||
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
|
||||
3. Pair the iOS app to the gateway and let both node and operator sessions connect.
|
||||
4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway.
|
||||
5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes.
|
||||
@@ -387,6 +387,7 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
Compatibility note:
|
||||
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides.
|
||||
- Custom gateway relay URLs must match the relay base URL baked into the official/TestFlight iOS build.
|
||||
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config.
|
||||
|
||||
See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model.
|
||||
|
||||
@@ -382,7 +382,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="3c. Session lock cleanup">
|
||||
Doctor scans every agent session directory for stale write-lock files — files left behind when a session exited abnormally. For each lock file found it reports: the path, PID, whether the PID is still alive, lock age, and whether it is considered stale (dead PID, older than 30 minutes, or a live PID that can be proven to belong to a non-OpenClaw process). In `--fix` / `--repair` mode it removes stale lock files automatically; otherwise it prints a note and instructs you to rerun with `--fix`.
|
||||
Doctor scans every agent session directory for stale write-lock files — files left behind when a session exited abnormally. For each lock file found it reports: the path, PID, whether the PID is still alive, lock age, and whether it is considered stale (dead PID, malformed owner metadata, older than 30 minutes, or a live PID that can be proven to belong to a non-OpenClaw process). In `--fix` / `--repair` mode it removes locks with dead, orphaned, recycled, malformed-old, or non-OpenClaw owners automatically. Old locks that are still owned by a live OpenClaw process are reported but left in place so doctor does not cut off an active transcript writer.
|
||||
</Accordion>
|
||||
<Accordion title="3d. Session transcript branch repair">
|
||||
Doctor scans agent session JSONL files for the duplicated branch shape created by the 2026.4.24 prompt transcript rewrite bug: an abandoned user turn with OpenClaw internal runtime context plus an active sibling containing the same visible user prompt. In `--fix` / `--repair` mode, doctor backs up each affected file next to the original and rewrites the transcript to the active branch so gateway history and memory readers no longer see duplicate turns.
|
||||
|
||||
@@ -63,6 +63,7 @@ Example config:
|
||||
|
||||
- Interval: `30m` (or `1h` when Anthropic OAuth/token auth is the detected auth mode, including Claude CLI reuse). Set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable.
|
||||
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
- Timeout: unset heartbeat turns use `agents.defaults.timeoutSeconds` when set. Otherwise, they use the heartbeat cadence capped at 600 seconds. Set `agents.defaults.heartbeat.timeoutSeconds` or per-agent `agents.list[].heartbeat.timeoutSeconds` for longer heartbeat work.
|
||||
- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeat" section only when heartbeats are enabled for the default agent, and the run is flagged internally.
|
||||
- When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md` from bootstrap context so the model does not see heartbeat-only instructions.
|
||||
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone. Outside the window, heartbeats are skipped until the next tick inside the window.
|
||||
@@ -274,6 +275,10 @@ Use `accountId` to target a specific account on multi-account channels like Tele
|
||||
<ParamField path="suppressToolErrorWarnings" type="boolean">
|
||||
When true, suppresses tool error warning payloads during heartbeat runs.
|
||||
|
||||
</ParamField>
|
||||
<ParamField path="timeoutSeconds" type="number" default="global timeout or min(every, 600)">
|
||||
Maximum seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
|
||||
|
||||
</ParamField>
|
||||
<ParamField path="activeHours" type="object">
|
||||
Restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive; use `00:00` for start-of-day), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`.
|
||||
|
||||
@@ -315,7 +315,7 @@ If the model loads cleanly but full agent turns misbehave, work top-down — con
|
||||
openclaw infer model run --gateway --model <provider/model> --prompt "Reply with exactly: pong" --json
|
||||
```
|
||||
|
||||
3. **Try lean mode.** If both probes pass but real agent turns fail with malformed tool calls or oversized prompts, enable `agents.defaults.experimental.localModelLean: true`. It drops the three heaviest default tools (`browser`, `cron`, `message`) from the visible model surface so the prompt shape is smaller and less brittle. When Code Mode or Tool Search is enabled, those tools can still sit behind the compact catalog controls. See [Experimental Features → Local model lean mode](/concepts/experimental-features#local-model-lean-mode) for the full explanation, when to use it, and how to confirm it is on.
|
||||
3. **Try lean mode.** If both probes pass but real agent turns fail with malformed tool calls or oversized prompts, enable `agents.defaults.experimental.localModelLean: true`. It drops the three heaviest default tools (`browser`, `cron`, `message`) so the prompt shape is smaller and less brittle. See [Experimental Features → Local model lean mode](/concepts/experimental-features#local-model-lean-mode) for the full explanation, when to use it, and how to confirm it is on.
|
||||
|
||||
4. **Disable tools entirely as a last resort.** If lean mode is not enough, set `models.providers.<provider>.models[].compat.supportsTools: false` for that model entry. The agent will then operate without tool calls on that model.
|
||||
|
||||
|
||||
@@ -183,6 +183,13 @@ Define providers under `secrets.providers`:
|
||||
passEnv: ["PATH", "VAULT_ADDR"],
|
||||
jsonOnly: true,
|
||||
},
|
||||
"team-secrets": {
|
||||
source: "exec",
|
||||
pluginIntegration: {
|
||||
pluginId: "acme-secrets",
|
||||
integrationId: "secret-store",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
env: "default",
|
||||
@@ -219,6 +226,11 @@ Define providers under `secrets.providers`:
|
||||
- Pair `allowSymlinkCommand` with `trustedDirs` for package-manager paths (for example `["/opt/homebrew"]`).
|
||||
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
|
||||
- Windows fail-closed note: if ACL verification is unavailable for the command path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
|
||||
- Plugin-managed exec providers can use `pluginIntegration` instead of
|
||||
copied `command`/`args`. OpenClaw resolves the current command details
|
||||
from the installed plugin manifest during startup/reload. If the plugin is
|
||||
disabled, removed, untrusted, or no longer declares the integration,
|
||||
active SecretRefs using that provider fail closed.
|
||||
|
||||
Request payload (stdin):
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@ openclaw gateway call node.list --params "{}"
|
||||
Official distributed iOS builds use the external push relay instead of publishing the raw APNs
|
||||
token to the gateway.
|
||||
|
||||
Gateway-side requirement:
|
||||
By default, official/TestFlight builds and gateways use the hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
Custom relay deployments can override the gateway relay URL:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -98,7 +100,7 @@ How the flow works:
|
||||
- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
|
||||
- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
|
||||
- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges.
|
||||
- The gateway relay base URL must match the relay URL baked into the official/TestFlight iOS build.
|
||||
- Custom gateway relay URLs must match the relay URL baked into the official/TestFlight iOS build.
|
||||
- If the app later connects to a different gateway or a build with a different relay base URL, it refreshes the relay registration instead of reusing the old binding.
|
||||
|
||||
What the gateway does **not** need for this path:
|
||||
@@ -109,7 +111,7 @@ What the gateway does **not** need for this path:
|
||||
Expected operator flow:
|
||||
|
||||
1. Install the official/TestFlight iOS build.
|
||||
2. Set `gateway.push.apns.relay.baseUrl` on the gateway.
|
||||
2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
|
||||
3. Pair the app to the gateway and let it finish connecting.
|
||||
4. The app publishes `push.apns.register` automatically after it has an APNs token, the operator session is connected, and relay registration succeeds.
|
||||
5. After that, `push.test`, reconnect wakes, and wake nudges can use the stored relay-backed registration.
|
||||
@@ -128,6 +130,7 @@ compatible but does not count as a durable last-seen update.
|
||||
Compatibility note:
|
||||
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
|
||||
- `OPENCLAW_PUSH_RELAY_BASE_URL` still works as a temporary env override for official/TestFlight iOS builds.
|
||||
|
||||
## Authentication and trust flow
|
||||
|
||||
|
||||
@@ -85,25 +85,25 @@ For an already-running app-server, use WebSocket transport:
|
||||
|
||||
Supported `appServer` fields:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
|
||||
| `postToolRawAssistantCompletionIdleTimeoutMs` | unset | Completion-idle guard used after a tool handoff when Codex emits raw assistant completion or progress but does not send `turn/completed`. Defaults to the assistant completion idle timeout when unset. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
|
||||
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. |
|
||||
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. |
|
||||
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. |
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
|
||||
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
| Field | Default | Meaning |
|
||||
| --------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
|
||||
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle guard used after a tool handoff when Codex emits raw assistant completion or progress but does not send `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
|
||||
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. |
|
||||
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. |
|
||||
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. |
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
|
||||
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
|
||||
The plugin blocks older or unversioned app-server handshakes. Codex app-server
|
||||
must report stable version `0.125.0` or newer.
|
||||
@@ -337,10 +337,15 @@ Codex then goes quiet without `turn/completed`, OpenClaw best-effort interrupts
|
||||
the native turn and releases the session lane. Post-tool raw assistant progress
|
||||
keeps waiting for `turn/completed` while a completion-idle guard stays armed; the
|
||||
guard uses `appServer.postToolRawAssistantCompletionIdleTimeoutMs` when
|
||||
configured and falls back to the assistant completion idle timeout otherwise.
|
||||
Timeout diagnostics include the last app-server notification method and, for raw
|
||||
assistant response items, the item type, role, id, and a bounded assistant text
|
||||
preview.
|
||||
configured and defaults to five minutes otherwise. Replay-safe stdio app-server
|
||||
failures, including turn-completion idle timeouts without assistant, tool,
|
||||
active-item, or side-effect evidence, are retried once on a fresh app-server
|
||||
attempt. Unsafe timeouts still retire the stuck app-server client and release
|
||||
the OpenClaw session lane. They also clear the stale native thread binding and
|
||||
surface a recoverable timeout message for user or maintainer judgment instead of
|
||||
being replayed automatically. Timeout diagnostics include the last
|
||||
app-server notification method and, for raw assistant response items, the item
|
||||
type, role, id, and a bounded assistant text preview.
|
||||
|
||||
## Model discovery
|
||||
|
||||
|
||||
@@ -525,25 +525,25 @@ Supported top-level Codex plugin fields:
|
||||
|
||||
Supported `appServer` fields:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
|
||||
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. Raise this for slow post-tool or status-only synthesis phases. |
|
||||
| `postToolRawAssistantCompletionIdleTimeoutMs` | unset | Completion-idle guard used after a tool handoff when Codex emits raw assistant completion or progress but does not send `turn/completed`. Defaults to the assistant completion idle timeout when unset. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
|
||||
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. |
|
||||
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. |
|
||||
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
| Field | Default | Meaning |
|
||||
| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
|
||||
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. Raise this for slow post-tool or status-only synthesis phases. |
|
||||
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle guard used after a tool handoff when Codex emits raw assistant completion or progress but does not send `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
|
||||
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. |
|
||||
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. |
|
||||
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
|
||||
OpenClaw-owned dynamic tool calls are bounded independently from
|
||||
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
|
||||
@@ -574,10 +574,15 @@ goes quiet without `turn/completed`, OpenClaw best-effort interrupts the native
|
||||
turn and releases the session lane. Post-tool raw assistant progress keeps
|
||||
waiting for `turn/completed` while a completion-idle guard stays armed; the guard
|
||||
uses `appServer.postToolRawAssistantCompletionIdleTimeoutMs` when configured and
|
||||
falls back to the assistant completion idle timeout otherwise. Timeout
|
||||
diagnostics include the last app-server notification method and, for raw
|
||||
assistant response items, the item type, role, id, and a bounded assistant text
|
||||
preview.
|
||||
defaults to five minutes otherwise. Replay-safe stdio app-server failures,
|
||||
including turn-completion idle timeouts without assistant, tool, active-item, or
|
||||
side-effect evidence, are retried once on a fresh app-server attempt. Unsafe
|
||||
timeouts still retire the stuck app-server client and release the OpenClaw
|
||||
session lane. They also clear the stale native thread binding and surface a
|
||||
recoverable timeout message for user or maintainer judgment instead of being
|
||||
replayed automatically. Timeout diagnostics include the last app-server
|
||||
notification method and, for raw assistant response items, the item type, role,
|
||||
id, and a bounded assistant text preview.
|
||||
|
||||
Environment overrides remain available for local testing:
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
summary: "Run OpenClaw embedded agent turns through the bundled GitHub Copilot SDK harness"
|
||||
summary: "Run OpenClaw embedded agent turns through the external GitHub Copilot SDK harness"
|
||||
title: "Copilot SDK harness"
|
||||
read_when:
|
||||
- You want to use the bundled GitHub Copilot SDK harness for an agent
|
||||
- You want to use the GitHub Copilot SDK harness for an agent
|
||||
- You need configuration examples for the `copilot` runtime
|
||||
- You are wiring an agent to subscription Copilot (github / openclaw / copilot) and want it to run through the Copilot CLI
|
||||
---
|
||||
|
||||
The bundled `copilot` extension lets OpenClaw run embedded subscription
|
||||
The external `@openclaw/copilot` plugin lets OpenClaw run embedded subscription
|
||||
Copilot agent turns through the GitHub Copilot CLI (`@github/copilot-sdk`)
|
||||
instead of the built-in PI harness.
|
||||
|
||||
@@ -24,11 +24,11 @@ For the broader model/provider/runtime split, start with
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenClaw with the bundled `copilot` extension available.
|
||||
- OpenClaw with the `@openclaw/copilot` plugin installed.
|
||||
- If your config uses `plugins.allow`, include `copilot` (the manifest
|
||||
id in `extensions/copilot/openclaw.plugin.json`). A restrictive
|
||||
id declared by the plugin). A restrictive
|
||||
allowlist that uses the npm-style `@openclaw/copilot` package name
|
||||
will leave the bundled plugin blocked and the runtime will not load
|
||||
will leave the plugin blocked and the runtime will not load
|
||||
even with `agentRuntime.id: "copilot"`.
|
||||
- A GitHub Copilot subscription that can drive the Copilot CLI (or a
|
||||
`gitHubToken` env / auth-profile entry for headless / cron runs).
|
||||
@@ -38,56 +38,38 @@ For the broader model/provider/runtime split, start with
|
||||
or `~/.config/copilot` elsewhere) is used as the doctor probe fallback when
|
||||
no explicit home is set.
|
||||
|
||||
`openclaw doctor` runs the bundled
|
||||
`openclaw doctor` runs the plugin
|
||||
[doctor contract](#doctor-and-probes) for the extension; failures there are
|
||||
the canonical way to confirm the environment is ready before opting an agent
|
||||
in.
|
||||
|
||||
## On-demand SDK install
|
||||
## Plugin install
|
||||
|
||||
The Copilot agent runtime ships its small TypeScript code bundled inside
|
||||
the openclaw tarball, but the underlying `@github/copilot-sdk` package
|
||||
(and its platform-specific `@github/copilot-<platform>-<arch>` CLI
|
||||
binary) is **not** installed by default — together they add ~260 MB to
|
||||
your openclaw install footprint, and most openclaw users do not select
|
||||
a Copilot model.
|
||||
The Copilot runtime is an external plugin so the core `openclaw` package does
|
||||
not carry the `@github/copilot-sdk` dependency or its platform-specific
|
||||
`@github/copilot-<platform>-<arch>` CLI binary. Together they add roughly
|
||||
260 MB, so install them only for agents that opt into this runtime:
|
||||
|
||||
The wizard offers to install the SDK the first time you select a
|
||||
```bash
|
||||
openclaw plugins install @openclaw/copilot
|
||||
```
|
||||
|
||||
The wizard installs the plugin the first time you select a
|
||||
`github-copilot/*` model **and** your config opts the model (or its
|
||||
provider) into the Copilot agent runtime via
|
||||
`agentRuntime: { id: "copilot" }` (see [Quickstart](#quickstart) below).
|
||||
Without the opt-in, openclaw uses its built-in GitHub Copilot provider
|
||||
and never prompts for the SDK install:
|
||||
|
||||
```
|
||||
The Copilot agent runtime needs @github/copilot-sdk (~260 MB on first
|
||||
install, downloads the @github/copilot CLI binary for your platform).
|
||||
Install now? [Y/n]
|
||||
```
|
||||
|
||||
If you accept, the SDK is installed into
|
||||
`~/.openclaw/npm-runtime/copilot/` and detected on subsequent runs. The
|
||||
install runs `npm ci` against a checked-in `package-lock.json` shipped
|
||||
with openclaw at
|
||||
`src/commands/copilot-sdk-install-manifest/package-lock.json`, so the
|
||||
exact transitive graph reviewed for this release lands on disk on every
|
||||
user machine.
|
||||
|
||||
If you decline, the runtime will fail at first invocation with an
|
||||
actionable install message; re-run `openclaw setup` to retry the install
|
||||
(or copy the pinned manifest into `~/.openclaw/npm-runtime/copilot/` and
|
||||
run `npm ci` yourself if you need to install offline).
|
||||
and never installs the runtime plugin.
|
||||
|
||||
The runtime resolves the SDK in this order:
|
||||
|
||||
1. `import("@github/copilot-sdk")` against the host openclaw install
|
||||
(covers source/dev checkouts and any environment that pre-installs
|
||||
the SDK alongside openclaw).
|
||||
1. `import("@github/copilot-sdk")` from the installed `@openclaw/copilot`
|
||||
package.
|
||||
2. The well-known fallback dir `~/.openclaw/npm-runtime/copilot/` (the
|
||||
wizard install target).
|
||||
legacy on-demand install target).
|
||||
|
||||
A missing SDK surfaces a single error with code `COPILOT_SDK_MISSING`
|
||||
and the manual install command above.
|
||||
and the plugin reinstall command above.
|
||||
|
||||
## Quickstart
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
|
||||
| `modelIdNormalization` | No | `object` | Provider-owned model-id alias/prefix cleanup that must run before provider runtime loads. |
|
||||
| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. |
|
||||
| `providerRequest` | No | `object` | Cheap provider-family and request-compatibility metadata used by generic request policy before provider runtime loads. |
|
||||
| `secretProviderIntegrations` | No | `Record<string, object>` | Declarative SecretRef exec provider presets that setup or install surfaces can offer without hardcoding provider-specific integrations in core. |
|
||||
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
|
||||
| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. |
|
||||
| `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. |
|
||||
@@ -1080,6 +1081,72 @@ Provider fields:
|
||||
| `compatibilityFamily` | `"moonshot"` | Optional provider-family compatibility bucket for shared request helpers. |
|
||||
| `openAICompletions` | `object` | OpenAI-compatible completions request flags, currently `supportsStreamingUsage`. |
|
||||
|
||||
## secretProviderIntegrations reference
|
||||
|
||||
Use `secretProviderIntegrations` when a plugin can publish a reusable SecretRef
|
||||
exec provider preset. OpenClaw reads this metadata before plugin runtime loads,
|
||||
stores plugin ownership in `secrets.providers.<alias>.pluginIntegration`, and
|
||||
leaves actual secret resolution to the SecretRef runtime.
|
||||
Presets are exposed only for bundled plugins and installed plugins discovered
|
||||
from the managed plugin install roots, such as git and ClawHub installs.
|
||||
|
||||
```json
|
||||
{
|
||||
"secretProviderIntegrations": {
|
||||
"secret-store": {
|
||||
"providerAlias": "team-secrets",
|
||||
"displayName": "Team secrets",
|
||||
"source": "exec",
|
||||
"command": "${node}",
|
||||
"args": ["./bin/resolve-secrets.mjs"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The map key is the integration id. If `providerAlias` is omitted, OpenClaw uses
|
||||
the integration id as the SecretRef provider alias. Provider aliases must match
|
||||
the normal SecretRef provider alias pattern, for example `team-secrets` or
|
||||
`onepassword-work`.
|
||||
|
||||
When an operator selects the preset, OpenClaw writes a provider reference like:
|
||||
|
||||
```json
|
||||
{
|
||||
"secrets": {
|
||||
"providers": {
|
||||
"team-secrets": {
|
||||
"source": "exec",
|
||||
"pluginIntegration": {
|
||||
"pluginId": "acme-secrets",
|
||||
"integrationId": "secret-store"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
At startup/reload, OpenClaw resolves that provider by loading current plugin
|
||||
manifest metadata, checking that the owning plugin is installed and active, and
|
||||
materializing the exec command from the manifest. Disabling or removing the
|
||||
plugin revokes the provider for active SecretRefs. Operators who want standalone
|
||||
exec configuration can still write manual `command`/`args` providers directly.
|
||||
|
||||
Only `source: "exec"` presets are currently supported. `command` must be
|
||||
`${node}`, and `args[0]` must be a `./` plugin-root-relative resolver script.
|
||||
OpenClaw materializes it at startup/reload to the current Node executable and
|
||||
the absolute in-plugin script path. Node options such as `--require`, `--import`,
|
||||
`--loader`, `--env-file`, `--eval`, and `--print` are not part of the manifest
|
||||
preset contract. Operators who need non-Node commands can configure standalone
|
||||
manual exec providers directly.
|
||||
|
||||
OpenClaw derives `trustedDirs` for manifest presets from the plugin root and,
|
||||
for `${node}` presets, the current Node executable directory. Manifest-authored
|
||||
`trustedDirs` are ignored. Other exec provider options such as `timeoutMs`,
|
||||
`maxOutputBytes`, `jsonOnly`, `env`, `passEnv`, and `allowInsecurePath` pass
|
||||
through to the normal SecretRef exec provider config.
|
||||
|
||||
## modelPricing reference
|
||||
|
||||
Use `modelPricing` when a provider needs control-plane pricing behavior before
|
||||
|
||||
@@ -66,7 +66,6 @@ commands.
|
||||
| [cloudflare-ai-gateway](/plugins/reference/cloudflare-ai-gateway) | Adds Cloudflare AI Gateway model provider support to OpenClaw. | `@openclaw/cloudflare-ai-gateway-provider`<br />included in OpenClaw | providers: cloudflare-ai-gateway |
|
||||
| [codex-supervisor](/plugins/reference/codex-supervisor) | Supervise Codex app-server sessions from OpenClaw. | `@openclaw/codex-supervisor`<br />included in OpenClaw | contracts: tools |
|
||||
| [comfy](/plugins/reference/comfy) | Adds ComfyUI model provider support to OpenClaw. | `@openclaw/comfy-provider`<br />included in OpenClaw | providers: comfy; contracts: imageGenerationProviders, musicGenerationProviders, videoGenerationProviders |
|
||||
| [copilot](/plugins/reference/copilot) | Registers the GitHub Copilot agent runtime. | `@openclaw/copilot`<br />included in OpenClaw | plugin |
|
||||
| [copilot-proxy](/plugins/reference/copilot-proxy) | Adds Copilot Proxy model provider support to OpenClaw. | `@openclaw/copilot-proxy`<br />included in OpenClaw | providers: copilot-proxy |
|
||||
| [deepgram](/plugins/reference/deepgram) | Adds media understanding provider support. Adds realtime transcription provider support. | `@openclaw/deepgram-provider`<br />included in OpenClaw | contracts: mediaUnderstandingProviders, realtimeTranscriptionProviders |
|
||||
| [deepinfra](/plugins/reference/deepinfra) | Adds DeepInfra model provider support to OpenClaw. | `@openclaw/deepinfra-provider`<br />included in OpenClaw | providers: deepinfra; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, speechProviders, videoGenerationProviders |
|
||||
@@ -126,7 +125,6 @@ commands.
|
||||
| [telegram](/plugins/reference/telegram) | Adds the Telegram channel surface for sending and receiving OpenClaw messages. | `@openclaw/telegram`<br />included in OpenClaw | channels: telegram |
|
||||
| [tencent](/plugins/reference/tencent) | Adds Tencent TokenHub model provider support to OpenClaw. | `@openclaw/tencent-provider`<br />included in OpenClaw | providers: tencent-tokenhub |
|
||||
| [together](/plugins/reference/together) | Adds Together model provider support to OpenClaw. | `@openclaw/together-provider`<br />included in OpenClaw | providers: together; contracts: videoGenerationProviders |
|
||||
| [tokenjuice](/plugins/reference/tokenjuice) | Compacts exec and bash tool results with tokenjuice reducers. | `@openclaw/tokenjuice`<br />included in OpenClaw | contracts: agentToolResultMiddleware |
|
||||
| [tts-local-cli](/plugins/reference/tts-local-cli) | Adds text-to-speech provider support. | `@openclaw/tts-local-cli`<br />included in OpenClaw | contracts: speechProviders |
|
||||
| [venice](/plugins/reference/venice) | Adds Venice model provider support to OpenClaw. | `@openclaw/venice-provider`<br />included in OpenClaw | providers: venice |
|
||||
| [vercel-ai-gateway](/plugins/reference/vercel-ai-gateway) | Adds Vercel AI Gateway model provider support to OpenClaw. | `@openclaw/vercel-ai-gateway-provider`<br />included in OpenClaw | providers: vercel-ai-gateway |
|
||||
@@ -136,7 +134,7 @@ commands.
|
||||
| [vydra](/plugins/reference/vydra) | Adds Vydra model provider support to OpenClaw. | `@openclaw/vydra-provider`<br />included in OpenClaw | providers: vydra; contracts: imageGenerationProviders, speechProviders, videoGenerationProviders |
|
||||
| [web-readability](/plugins/reference/web-readability) | Extract readable article content from local HTML web fetch responses. | `@openclaw/web-readability-plugin`<br />included in OpenClaw | contracts: webContentExtractors |
|
||||
| [webhooks](/plugins/reference/webhooks) | Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows. | `@openclaw/webhooks`<br />included in OpenClaw | plugin |
|
||||
| [workboard](/plugins/reference/workboard) | Dashboard workboard for agent-owned issues and sessions. | `@openclaw/workboard`<br />included in OpenClaw | plugin |
|
||||
| [workboard](/plugins/reference/workboard) | Dashboard workboard for agent-owned issues and sessions. | `@openclaw/workboard`<br />included in OpenClaw | contracts: tools |
|
||||
| [xai](/plugins/reference/xai) | Adds xAI model provider support to OpenClaw. | `@openclaw/xai-plugin`<br />included in OpenClaw | providers: xai; contracts: imageGenerationProviders, mediaUnderstandingProviders, realtimeTranscriptionProviders, speechProviders, tools, videoGenerationProviders, webSearchProviders |
|
||||
| [xiaomi](/plugins/reference/xiaomi) | Adds Xiaomi model provider support to OpenClaw. | `@openclaw/xiaomi-provider`<br />included in OpenClaw | providers: xiaomi; contracts: speechProviders |
|
||||
| [zai](/plugins/reference/zai) | Adds Z.AI model provider support to OpenClaw. | `@openclaw/zai-provider`<br />included in OpenClaw | providers: zai; contracts: mediaUnderstandingProviders |
|
||||
@@ -151,6 +149,7 @@ commands.
|
||||
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
|
||||
| [brave](/plugins/reference/brave) | OpenClaw Brave Search provider plugin for web search. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
|
||||
| [codex](/plugins/reference/codex) | OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
|
||||
| [copilot](/plugins/reference/copilot) | Registers the GitHub Copilot agent runtime. | `@openclaw/copilot`<br />npm; ClawHub: `clawhub:@openclaw/copilot` | plugin |
|
||||
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter for metrics and traces. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
|
||||
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter for runtime metrics. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
|
||||
| [diffs](/plugins/reference/diffs) | OpenClaw read-only diff viewer plugin and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
|
||||
@@ -167,11 +166,12 @@ commands.
|
||||
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | OpenClaw Nextcloud Talk channel plugin for conversations. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
|
||||
| [nostr](/plugins/reference/nostr) | OpenClaw Nostr channel plugin for NIP-04 encrypted direct messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
|
||||
| [openshell](/plugins/reference/openshell) | OpenClaw sandbox backend for the NVIDIA OpenShell CLI with mirrored local workspaces and SSH command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
|
||||
| [pixverse](/plugins/reference/pixverse) | OpenClaw PixVerse video generation provider plugin. | `@openclaw/pixverse-provider`<br />npm; ClawHub | contracts: videoGenerationProviders |
|
||||
| [pixverse](/plugins/reference/pixverse) | OpenClaw PixVerse video generation provider plugin. | `@openclaw/pixverse-provider`<br />npm; ClawHub: `clawhub:@openclaw/pixverse-provider` | contracts: videoGenerationProviders |
|
||||
| [qqbot](/plugins/reference/qqbot) | OpenClaw QQ Bot channel plugin for group and direct-message workflows. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
|
||||
| [slack](/plugins/reference/slack) | OpenClaw Slack channel plugin for channels, DMs, commands, and app events. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
|
||||
| [synology-chat](/plugins/reference/synology-chat) | Synology Chat channel plugin for OpenClaw channels and direct messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
|
||||
| [tlon](/plugins/reference/tlon) | OpenClaw Tlon/Urbit channel plugin for chat workflows. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; skills |
|
||||
| [tokenjuice](/plugins/reference/tokenjuice) | Compacts exec and bash tool results with tokenjuice reducers. | `@openclaw/tokenjuice`<br />npm; ClawHub: `clawhub:@openclaw/tokenjuice` | contracts: agentToolResultMiddleware |
|
||||
| [twitch](/plugins/reference/twitch) | OpenClaw Twitch channel plugin for chat and moderation workflows. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
|
||||
| [voice-call](/plugins/reference/voice-call) | OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
|
||||
| [whatsapp](/plugins/reference/whatsapp) | OpenClaw WhatsApp channel plugin for WhatsApp Web chats. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
|
||||
|
||||
@@ -38,7 +38,7 @@ pnpm plugins:inventory:gen
|
||||
| [codex](/plugins/reference/codex) | OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
|
||||
| [codex-supervisor](/plugins/reference/codex-supervisor) | Supervise Codex app-server sessions from OpenClaw. | `@openclaw/codex-supervisor`<br />included in OpenClaw | contracts: tools |
|
||||
| [comfy](/plugins/reference/comfy) | Adds ComfyUI model provider support to OpenClaw. | `@openclaw/comfy-provider`<br />included in OpenClaw | providers: comfy; contracts: imageGenerationProviders, musicGenerationProviders, videoGenerationProviders |
|
||||
| [copilot](/plugins/reference/copilot) | Registers the GitHub Copilot agent runtime. | `@openclaw/copilot`<br />included in OpenClaw | plugin |
|
||||
| [copilot](/plugins/reference/copilot) | Registers the GitHub Copilot agent runtime. | `@openclaw/copilot`<br />npm; ClawHub: `clawhub:@openclaw/copilot` | plugin |
|
||||
| [copilot-proxy](/plugins/reference/copilot-proxy) | Adds Copilot Proxy model provider support to OpenClaw. | `@openclaw/copilot-proxy`<br />included in OpenClaw | providers: copilot-proxy |
|
||||
| [deepgram](/plugins/reference/deepgram) | Adds media understanding provider support. Adds realtime transcription provider support. | `@openclaw/deepgram-provider`<br />included in OpenClaw | contracts: mediaUnderstandingProviders, realtimeTranscriptionProviders |
|
||||
| [deepinfra](/plugins/reference/deepinfra) | Adds DeepInfra model provider support to OpenClaw. | `@openclaw/deepinfra-provider`<br />included in OpenClaw | providers: deepinfra; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, speechProviders, videoGenerationProviders |
|
||||
@@ -99,7 +99,7 @@ pnpm plugins:inventory:gen
|
||||
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, musicGenerationProviders, speechProviders, videoGenerationProviders |
|
||||
| [openshell](/plugins/reference/openshell) | OpenClaw sandbox backend for the NVIDIA OpenShell CLI with mirrored local workspaces and SSH command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
|
||||
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
|
||||
| [pixverse](/plugins/reference/pixverse) | OpenClaw PixVerse video generation provider plugin. | `@openclaw/pixverse-provider`<br />npm; ClawHub | contracts: videoGenerationProviders |
|
||||
| [pixverse](/plugins/reference/pixverse) | OpenClaw PixVerse video generation provider plugin. | `@openclaw/pixverse-provider`<br />npm; ClawHub: `clawhub:@openclaw/pixverse-provider` | contracts: videoGenerationProviders |
|
||||
| [policy](/plugins/reference/policy) | Adds policy-backed doctor checks for workspace conformance. | `@openclaw/policy`<br />included in OpenClaw | plugin |
|
||||
| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`<br />source checkout only | channels: qa-channel |
|
||||
| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`<br />source checkout only | plugin |
|
||||
@@ -122,7 +122,7 @@ pnpm plugins:inventory:gen
|
||||
| [tencent](/plugins/reference/tencent) | Adds Tencent TokenHub model provider support to OpenClaw. | `@openclaw/tencent-provider`<br />included in OpenClaw | providers: tencent-tokenhub |
|
||||
| [tlon](/plugins/reference/tlon) | OpenClaw Tlon/Urbit channel plugin for chat workflows. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; skills |
|
||||
| [together](/plugins/reference/together) | Adds Together model provider support to OpenClaw. | `@openclaw/together-provider`<br />included in OpenClaw | providers: together; contracts: videoGenerationProviders |
|
||||
| [tokenjuice](/plugins/reference/tokenjuice) | Compacts exec and bash tool results with tokenjuice reducers. | `@openclaw/tokenjuice`<br />included in OpenClaw | contracts: agentToolResultMiddleware |
|
||||
| [tokenjuice](/plugins/reference/tokenjuice) | Compacts exec and bash tool results with tokenjuice reducers. | `@openclaw/tokenjuice`<br />npm; ClawHub: `clawhub:@openclaw/tokenjuice` | contracts: agentToolResultMiddleware |
|
||||
| [tts-local-cli](/plugins/reference/tts-local-cli) | Adds text-to-speech provider support. | `@openclaw/tts-local-cli`<br />included in OpenClaw | contracts: speechProviders |
|
||||
| [twitch](/plugins/reference/twitch) | OpenClaw Twitch channel plugin for chat and moderation workflows. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
|
||||
| [venice](/plugins/reference/venice) | Adds Venice model provider support to OpenClaw. | `@openclaw/venice-provider`<br />included in OpenClaw | providers: venice |
|
||||
@@ -135,7 +135,7 @@ pnpm plugins:inventory:gen
|
||||
| [web-readability](/plugins/reference/web-readability) | Extract readable article content from local HTML web fetch responses. | `@openclaw/web-readability-plugin`<br />included in OpenClaw | contracts: webContentExtractors |
|
||||
| [webhooks](/plugins/reference/webhooks) | Authenticated inbound webhooks that bind external automation to OpenClaw TaskFlows. | `@openclaw/webhooks`<br />included in OpenClaw | plugin |
|
||||
| [whatsapp](/plugins/reference/whatsapp) | OpenClaw WhatsApp channel plugin for WhatsApp Web chats. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
|
||||
| [workboard](/plugins/reference/workboard) | Dashboard workboard for agent-owned issues and sessions. | `@openclaw/workboard`<br />included in OpenClaw | plugin |
|
||||
| [workboard](/plugins/reference/workboard) | Dashboard workboard for agent-owned issues and sessions. | `@openclaw/workboard`<br />included in OpenClaw | contracts: tools |
|
||||
| [xai](/plugins/reference/xai) | Adds xAI model provider support to OpenClaw. | `@openclaw/xai-plugin`<br />included in OpenClaw | providers: xai; contracts: imageGenerationProviders, mediaUnderstandingProviders, realtimeTranscriptionProviders, speechProviders, tools, videoGenerationProviders, webSearchProviders |
|
||||
| [xiaomi](/plugins/reference/xiaomi) | Adds Xiaomi model provider support to OpenClaw. | `@openclaw/xiaomi-provider`<br />included in OpenClaw | providers: xiaomi; contracts: speechProviders |
|
||||
| [zai](/plugins/reference/zai) | Adds Z.AI model provider support to OpenClaw. | `@openclaw/zai-provider`<br />included in OpenClaw | providers: zai; contracts: mediaUnderstandingProviders |
|
||||
|
||||
@@ -17,3 +17,7 @@ Supervise Codex app-server sessions from OpenClaw.
|
||||
## Surface
|
||||
|
||||
contracts: tools
|
||||
|
||||
## Session Listing
|
||||
|
||||
`codex_sessions_list` defaults to loaded Codex sessions only. Set `include_stored` to include stored history; the plugin uses Codex app-server's state-DB-only listing path and caps stored results at 200 by default. Pass `max_stored_sessions` to lower or raise that cap, up to 1000.
|
||||
|
||||
@@ -12,7 +12,7 @@ Registers the GitHub Copilot agent runtime.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/copilot`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm; ClawHub: `clawhub:@openclaw/copilot`
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ OpenClaw PixVerse video generation provider plugin.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/pixverse-provider`
|
||||
- Install route: npm; ClawHub
|
||||
- Install route: npm; ClawHub: `clawhub:@openclaw/pixverse-provider`
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Compacts exec and bash tool results with tokenjuice reducers.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/tokenjuice`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm; ClawHub: `clawhub:@openclaw/tokenjuice`
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Dashboard workboard for agent-owned issues and sessions.
|
||||
|
||||
## Surface
|
||||
|
||||
plugin
|
||||
contracts: tools
|
||||
|
||||
## Related docs
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ Most channel plugins do not need approval-specific code.
|
||||
- If custom approval auth intentionally allows only same-chat fallback, return `markImplicitSameChatApprovalAuthorization({ authorized: true })` from `openclaw/plugin-sdk/approval-auth-runtime`; otherwise core treats the result as explicit approver authorization.
|
||||
- If a channel-owned native callback resolves approvals directly, use `isImplicitSameChatApprovalAuthorization(...)` before resolving so implicit fallback still goes through the channel's normal actor authorization.
|
||||
- If a channel needs native approval delivery, keep channel code focused on target normalization plus transport/presentation facts. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, and `createApproverRestrictedNativeApprovalCapability` from `openclaw/plugin-sdk/approval-runtime`. Put the channel-specific facts behind `approvalCapability.nativeRuntime`, ideally via `createChannelApprovalNativeRuntimeAdapter(...)` or `createLazyChannelApprovalNativeRuntimeAdapter(...)`, so core can assemble the handler and own request filtering, routing, dedupe, expiry, gateway subscription, and routed-elsewhere notices. `nativeRuntime` is split into a few smaller seams:
|
||||
- Use `createNativeApprovalChannelRouteGates` from `openclaw/plugin-sdk/approval-native-runtime` when a channel supports both session-origin native delivery and explicit approval forwarding targets. The helper centralizes approval config selection, `mode` handling, agent/session filters, account binding, session-target matching, and target-list matching while callers still own the channel id, default forwarding mode, account lookup, transport-enabled check, target normalization, and turn-source target resolution. Do not use it to create core-owned channel policy defaults; pass the channel's documented default mode explicitly.
|
||||
- `createChannelNativeOriginTargetResolver` uses the shared channel-route matcher by default for `{ to, accountId, threadId }` targets. Pass `targetsMatch` only when a channel has provider-specific equivalence rules, such as Slack timestamp prefix matching.
|
||||
- Pass `normalizeTargetForMatch` to `createChannelNativeOriginTargetResolver` when the channel needs to canonicalize provider ids before the default route matcher or a custom `targetsMatch` callback runs, while preserving the original target for delivery. Use `normalizeTarget` only when the resolved delivery target itself should be canonicalized.
|
||||
- `availability` - whether the account is configured and whether a request should be handled
|
||||
|
||||
@@ -144,6 +144,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/self-hosted-provider-setup` | Focused OpenAI-compatible self-hosted provider setup helpers |
|
||||
| `plugin-sdk/cli-backend` | CLI backend defaults + watchdog constants |
|
||||
| `plugin-sdk/provider-auth-runtime` | Runtime API-key resolution helpers for provider plugins |
|
||||
| `plugin-sdk/provider-oauth-runtime` | Generic provider OAuth callback types, callback-page rendering, PKCE/state helpers, and abort helpers |
|
||||
| `plugin-sdk/provider-auth-api-key` | API-key onboarding/profile-write helpers such as `upsertApiKeyProfile` |
|
||||
| `plugin-sdk/provider-auth-result` | Standard OAuth auth-result builder |
|
||||
| `plugin-sdk/provider-env-vars` | Provider auth env-var lookup helpers |
|
||||
@@ -179,7 +180,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/approval-gateway-runtime` | Shared approval gateway-resolution helper |
|
||||
| `plugin-sdk/approval-handler-adapter-runtime` | Lightweight native approval adapter loading helpers for hot channel entrypoints |
|
||||
| `plugin-sdk/approval-handler-runtime` | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
|
||||
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers and local native exec prompt suppression |
|
||||
| `plugin-sdk/approval-native-runtime` | Native approval target, account-binding, route-gate, forwarding fallback, and local native exec prompt suppression helpers |
|
||||
| `plugin-sdk/approval-reaction-runtime` | Hardcoded approval reaction bindings, reaction prompt payloads, reaction target stores, and compatibility export for local native exec prompt suppression |
|
||||
| `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers |
|
||||
| `plugin-sdk/approval-runtime` | Exec/plugin approval payload helpers, native approval routing/runtime helpers, and structured approval display helpers such as `formatApprovalDisplayPath` |
|
||||
@@ -192,6 +193,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
|
||||
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract collection helpers for channel/plugin secret surfaces |
|
||||
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
|
||||
| `plugin-sdk/secret-provider-integration` | Type-only SecretRef provider integration manifest and preset contracts for plugins that publish external secret provider presets |
|
||||
| `plugin-sdk/security-runtime` | Shared trust, DM gating, root-bounded file/path helpers including create-only writes, sync/async atomic file replacement, sibling temp writes, cross-device move fallback, private file-store helpers, symlink-parent guards, external-content, sensitive text redaction, constant-time secret comparison, and secret-collection helpers |
|
||||
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
|
||||
| `plugin-sdk/ssrf-dispatcher` | Narrow pinned-dispatcher helpers without the broad infra runtime surface |
|
||||
@@ -219,6 +221,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/lazy-runtime` | Lazy runtime import/binding helpers such as `createLazyRuntimeModule`, `createLazyRuntimeMethod`, and `createLazyRuntimeSurface` |
|
||||
| `plugin-sdk/process-runtime` | Process exec helpers |
|
||||
| `plugin-sdk/cli-runtime` | CLI formatting, wait, version, argument-invocation, and lazy command-group helpers |
|
||||
| `plugin-sdk/qa-live-transport-scenarios` | Shared live transport QA scenario ids, baseline coverage helpers, and scenario-selection helper |
|
||||
| `plugin-sdk/gateway-method-runtime` | Reserved Gateway method dispatch helper for plugin HTTP routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]` |
|
||||
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, and channel-status patch helpers |
|
||||
| `plugin-sdk/config-contracts` | Focused type-only config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types |
|
||||
|
||||
@@ -48,8 +48,8 @@ Each card stores:
|
||||
- optional agent id
|
||||
- optional linked session, run, task, or source URL
|
||||
- optional execution metadata for a Codex or Claude session started from the card
|
||||
- compact metadata for attempts, comments, links, proof, templates, archive state, and stale-session detection
|
||||
- recent card events such as created, moved, linked, attempt, proof, archive, stale, or agent-updated changes
|
||||
- compact metadata for attempts, comments, links, proof, artifacts, claims, diagnostics, notifications, templates, archive state, and stale-session detection
|
||||
- recent card events such as created, moved, linked, claimed, heartbeat, attempt, proof, artifact, diagnostic, notification, archive, stale, or agent-updated changes
|
||||
|
||||
Cards are stored in the plugin's Gateway state. They are local to the Gateway
|
||||
state directory and move with the rest of that Gateway's OpenClaw state.
|
||||
@@ -80,6 +80,31 @@ Each linked execution also records an attempt summary on the same card record.
|
||||
The attempt summary keeps the engine, mode, model, run id, timestamps, status,
|
||||
and rolling failure count so repeated failures remain visible on the board.
|
||||
|
||||
## Agent coordination
|
||||
|
||||
Workboard also exposes optional agent tools for board-aware workflows:
|
||||
|
||||
- `workboard_list` lists compact cards with claim and diagnostic state.
|
||||
- `workboard_read` returns one card plus bounded worker context built from notes,
|
||||
attempts, comments, links, proof, artifacts, and active diagnostics.
|
||||
- `workboard_claim` claims a card for the calling agent and moves backlog or todo
|
||||
cards into `running`.
|
||||
- `workboard_heartbeat` refreshes the claim heartbeat during longer runs.
|
||||
- `workboard_release` releases the claim after completion, pause, or handoff and
|
||||
can move the card to a next status.
|
||||
- `workboard_comment`, `workboard_proof`, and `workboard_unblock` let an agent
|
||||
add handoff notes, attach proof or artifact references, and move blocked work
|
||||
back to `todo`.
|
||||
|
||||
Claimed cards reject agent-tool mutations from other agents unless the caller
|
||||
has the claim token returned by `workboard_claim`. Dashboard operators still use
|
||||
the normal Gateway RPC surface and can recover or reassign cards.
|
||||
|
||||
Workboard diagnostics are computed from local card metadata. The built-in checks
|
||||
flag assigned cards that wait too long, running cards without recent heartbeat,
|
||||
blocked cards that need attention, repeated failures, done cards without proof,
|
||||
and running cards that only have a loose session link.
|
||||
|
||||
## Session lifecycle sync
|
||||
|
||||
Cards can be linked to existing dashboard sessions or to the session created
|
||||
@@ -136,7 +161,10 @@ The plugin registers Gateway RPC methods under the `workboard.*` namespace:
|
||||
|
||||
- `workboard.cards.list` requires `operator.read`
|
||||
- `workboard.cards.export` requires `operator.read`
|
||||
- create, update, move, delete, comment, link, proof, and archive methods require `operator.write`
|
||||
- `workboard.cards.diagnostics` requires `operator.read`
|
||||
- `workboard.cards.diagnostics.refresh` requires `operator.write`
|
||||
- create, update, move, delete, comment, link, proof, artifact, claim, heartbeat,
|
||||
release, unblock, bulk, and archive methods require `operator.write`
|
||||
|
||||
Browsers connected with read-only operator access can inspect the board but
|
||||
cannot mutate cards.
|
||||
|
||||
@@ -3,14 +3,15 @@ summary: "Sign in to GitHub Copilot from OpenClaw using the device flow or non-i
|
||||
read_when:
|
||||
- You want to use GitHub Copilot as a model provider
|
||||
- You need the `openclaw models auth login-github-copilot` flow
|
||||
- You are choosing between the built-in Copilot provider, Copilot SDK harness, and Copilot Proxy
|
||||
title: "GitHub Copilot"
|
||||
---
|
||||
|
||||
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
|
||||
models for your GitHub account and plan. OpenClaw can use Copilot as a model
|
||||
provider in two different ways.
|
||||
provider or agent runtime in three different ways.
|
||||
|
||||
## Two ways to use Copilot in OpenClaw
|
||||
## Three ways to use Copilot in OpenClaw
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Built-in provider (github-copilot)">
|
||||
@@ -46,6 +47,38 @@ provider in two different ways.
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Copilot SDK harness plugin (copilot)">
|
||||
Install the external `@openclaw/copilot` plugin when you want GitHub's
|
||||
Copilot CLI and SDK to own the low-level agent loop for selected
|
||||
`github-copilot/*` models.
|
||||
|
||||
```bash
|
||||
openclaw plugins install clawhub:@openclaw/copilot
|
||||
```
|
||||
|
||||
Then opt a model or provider into the runtime:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "github-copilot/gpt-5.5",
|
||||
models: {
|
||||
"github-copilot/gpt-5.5": {
|
||||
agentRuntime: { id: "copilot" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Choose this when you want native Copilot CLI sessions, SDK-managed thread
|
||||
state, and Copilot-owned compaction for those agent turns. See
|
||||
[Copilot SDK harness](/plugins/copilot) for the full runtime contract.
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Copilot Proxy plugin (copilot-proxy)">
|
||||
Use the **Copilot Proxy** VS Code extension as a local bridge. OpenClaw talks to
|
||||
the proxy's `/v1` endpoint and uses the model list you configure there.
|
||||
|
||||
@@ -679,7 +679,7 @@ Use these as starting points and replace model IDs with the exact names from `ol
|
||||
```
|
||||
|
||||
Use `compat.supportsTools: false` only when the model or server reliably fails on tool schemas. It trades agent capability for stability.
|
||||
`localModelLean` removes the browser, cron, and message tools from the model-visible agent surface, but it does not change Ollama's runtime context or thinking mode. If Code Mode or Tool Search is enabled, those tools can still be called from the hidden catalog. Pair lean mode with explicit `params.num_ctx` and `params.thinking: false` for small Qwen-style thinking models that loop or spend their response budget on hidden reasoning.
|
||||
`localModelLean` removes the browser, cron, and message tools from the agent surface, but it does not change Ollama's runtime context or thinking mode. Pair it with explicit `params.num_ctx` and `params.thinking: false` for small Qwen-style thinking models that loop or spend their response budget on hidden reasoning.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -25,7 +25,7 @@ OpenClaw provides `pixverse` as an official external plugin for hosted PixVerse
|
||||
<Steps>
|
||||
<Step title="Install the plugin">
|
||||
```bash
|
||||
openclaw plugins install @openclaw/pixverse-provider
|
||||
openclaw plugins install clawhub:@openclaw/pixverse-provider
|
||||
openclaw gateway restart
|
||||
```
|
||||
</Step>
|
||||
|
||||
2256
docs/refactor/database-first.md
Normal file
2256
docs/refactor/database-first.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -482,6 +482,42 @@ Notes:
|
||||
- See the [Browserbase docs](https://docs.browserbase.com) for full API
|
||||
reference, SDK guides, and integration examples.
|
||||
|
||||
### Notte
|
||||
|
||||
[Notte](https://www.notte.cc) is a cloud platform for running headless
|
||||
browsers with built-in stealth, residential proxies, and a CDP-native
|
||||
WebSocket gateway.
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
defaultProfile: "notte",
|
||||
remoteCdpTimeoutMs: 3000,
|
||||
remoteCdpHandshakeTimeoutMs: 5000,
|
||||
profiles: {
|
||||
notte: {
|
||||
cdpUrl: "wss://us-prod.notte.cc/sessions/connect?token=<NOTTE_API_KEY>",
|
||||
color: "#7C3AED",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- [Sign up](https://console.notte.cc) and copy your **API Key** from the
|
||||
console settings page.
|
||||
- Replace `<NOTTE_API_KEY>` with your real Notte API key.
|
||||
- Notte auto-creates a browser session on WebSocket connect, so no manual
|
||||
session creation step is needed. The session is destroyed when the
|
||||
WebSocket disconnects.
|
||||
- The free tier allows five concurrent sessions and 100 lifetime browser
|
||||
hours. See [pricing](https://www.notte.cc/#pricing) for paid plan limits.
|
||||
- See the [Notte docs](https://docs.notte.cc) for full API reference, SDK
|
||||
guides, and integration examples.
|
||||
|
||||
## Security
|
||||
|
||||
Key ideas:
|
||||
|
||||
@@ -213,7 +213,7 @@ Common aliases such as `js`, `ts`, `bash`, `md`, `yml`, `c++`, `dockerfile`, `rb
|
||||
Install the Diff Viewer Language Pack plugin to highlight other languages:
|
||||
|
||||
```bash
|
||||
openclaw plugins install diffs-language-pack
|
||||
openclaw plugins install clawhub:@openclaw/diffs-language-pack
|
||||
```
|
||||
|
||||
With the language pack available, OpenClaw automatically uses it for languages outside the default list. Without it, those files stay readable as plain text.
|
||||
|
||||
@@ -109,7 +109,7 @@ Notes:
|
||||
- YOLO comes from the host-policy defaults (`security=full`, `ask=off`), not from `host=auto`. If you want to force gateway or node routing, set `tools.exec.host` or use `/exec host=...`.
|
||||
- In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter or script-preflight rejection layer.
|
||||
- `tools.exec.node` (default: unset)
|
||||
- `tools.exec.strictInlineEval` (default: false): when true, inline interpreter eval forms such as `python -c`, `node -e`, `ruby -e`, `perl -e`, `php -r`, `lua -e`, and `osascript -e` require reviewer or explicit approval. In `mode=auto`, the native auto reviewer may allow a clearly low-risk one-off command; if the reviewer asks, the request goes to a human. `allow-always` can still persist benign interpreter/script invocations, but inline-eval forms do not become durable allow rules.
|
||||
- `tools.exec.strictInlineEval` (default: false): when true, inline interpreter eval forms such as `python -c`, `node -e`, `ruby -e`, `perl -e`, `php -r`, `lua -e`, and `osascript -e` require reviewer or explicit approval. In `mode=auto`, the normal exec approval path may let the native auto reviewer allow a clearly low-risk one-off command; direct node-host `system.run` calls still require an explicit approval because they cannot hand the command to a human approval route. If the reviewer asks, the request goes to a human. `allow-always` can still persist benign interpreter/script invocations, but inline-eval forms do not become durable allow rules.
|
||||
- `tools.exec.commandHighlighting` (default: false): when true, approval prompts can highlight parser-derived command spans in the command text. Set to `true` globally or per agent to enable command text highlighting without changing exec approval policy.
|
||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs (gateway + sandbox only).
|
||||
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries. For behavior details, see [Safe bins](/tools/exec-approvals-advanced#safe-bins-stdin-only).
|
||||
|
||||
217
docs/tools/goal.md
Normal file
217
docs/tools/goal.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
doc-schema-version: 1
|
||||
summary: "Session goals: durable per-session objectives, /goal controls, model goal tools, token budgets, and TUI status"
|
||||
read_when:
|
||||
- You want OpenClaw to keep one objective visible across a long session
|
||||
- You need to pause, resume, block, complete, or clear a session goal
|
||||
- You want to understand the get_goal, create_goal, and update_goal tools
|
||||
- You want to see how goals appear in the TUI
|
||||
title: "Goal"
|
||||
---
|
||||
|
||||
# Goal
|
||||
|
||||
A **goal** is one durable objective attached to the current OpenClaw session.
|
||||
It gives the agent and the operator a shared target for long-running work,
|
||||
without turning that target into a background task, reminder, cron job, or
|
||||
standing order.
|
||||
|
||||
Goals are session state. They move with the session key, survive process
|
||||
restarts, show up in `/goal`, are available to the model through the goal
|
||||
tools, and appear in the TUI footer when the active session has one.
|
||||
|
||||
## Quick start
|
||||
|
||||
Set a goal:
|
||||
|
||||
```text
|
||||
/goal start get CI green for PR 87469 and push the fix
|
||||
```
|
||||
|
||||
Check it:
|
||||
|
||||
```text
|
||||
/goal
|
||||
```
|
||||
|
||||
Pause it when work is intentionally waiting:
|
||||
|
||||
```text
|
||||
/goal pause waiting for CI
|
||||
```
|
||||
|
||||
Resume it:
|
||||
|
||||
```text
|
||||
/goal resume
|
||||
```
|
||||
|
||||
Mark it complete:
|
||||
|
||||
```text
|
||||
/goal complete pushed and verified
|
||||
```
|
||||
|
||||
Clear it:
|
||||
|
||||
```text
|
||||
/goal clear
|
||||
```
|
||||
|
||||
## What goals are for
|
||||
|
||||
Use a goal when a session has a concrete outcome that should remain visible
|
||||
across many turns:
|
||||
|
||||
- A PR closeout: fix, verify, autoreview, push, and open or update the PR.
|
||||
- A debug run: reproduce the bug, identify the owning surface, patch, and prove
|
||||
the fix.
|
||||
- A docs pass: read the relevant docs, write the new page, cross-link it, and
|
||||
verify the docs build.
|
||||
- A maintenance task: inspect current state, make bounded changes, run the right
|
||||
checks, and report what changed.
|
||||
|
||||
A goal is not a task queue. Use [Task Flow](/automation/taskflow),
|
||||
[tasks](/automation/tasks), [cron jobs](/automation/cron-jobs), or
|
||||
[standing orders](/automation/standing-orders) when work should run detached,
|
||||
repeat on a schedule, fan out into managed sub-work, or persist as a policy.
|
||||
|
||||
## Command reference
|
||||
|
||||
`/goal` without arguments prints the current goal summary:
|
||||
|
||||
```text
|
||||
Goal
|
||||
Status: active
|
||||
Objective: get CI green for PR 87469 and push the fix
|
||||
Tokens used: 12k
|
||||
Token budget: 12k/50k
|
||||
|
||||
Commands: /goal pause, /goal complete, /goal clear
|
||||
```
|
||||
|
||||
Commands:
|
||||
|
||||
- `/goal` or `/goal status` shows the current goal.
|
||||
- `/goal start <objective>` creates a new goal for the current session.
|
||||
- `/goal set <objective>` and `/goal create <objective>` are aliases for
|
||||
`start`.
|
||||
- `/goal pause [note]` pauses an active goal.
|
||||
- `/goal resume [note]` resumes a paused, blocked, usage-limited, or
|
||||
budget-limited goal.
|
||||
- `/goal complete [note]` marks the goal achieved.
|
||||
- `/goal done [note]` is an alias for `complete`.
|
||||
- `/goal block [note]` marks the goal blocked.
|
||||
- `/goal blocked [note]` is an alias for `block`.
|
||||
- `/goal clear` removes the goal from the session.
|
||||
|
||||
Only one goal can exist on a session at a time. Starting a second goal fails
|
||||
until the current one is cleared.
|
||||
|
||||
## Statuses
|
||||
|
||||
Goals use a small status set:
|
||||
|
||||
- `active`: the session is pursuing the goal.
|
||||
- `paused`: the operator paused the goal; `/goal resume` makes it active again.
|
||||
- `blocked`: the agent or operator reported a real blocker; `/goal resume`
|
||||
makes it active again when new information or state is available.
|
||||
- `budget_limited`: the configured token budget was reached; `/goal resume`
|
||||
restarts pursuit from the same objective.
|
||||
- `usage_limited`: reserved for usage-limit stop states; `/goal resume`
|
||||
restarts pursuit when allowed.
|
||||
- `complete`: the goal was achieved. Complete goals are terminal; use
|
||||
`/goal clear` before starting another goal.
|
||||
|
||||
`/new` and `/reset` clear the current session goal because they intentionally
|
||||
start fresh session context.
|
||||
|
||||
## Token budgets
|
||||
|
||||
Goals can have an optional positive token budget. The budget is stored with the
|
||||
goal and measured from the session's fresh token count at creation time. If the
|
||||
current session only has stale or unknown token usage when the goal starts,
|
||||
OpenClaw waits for the next fresh session token snapshot and uses that as the
|
||||
baseline, so tokens spent before the goal existed are not charged to the goal.
|
||||
|
||||
When token usage reaches the budget, the goal changes to `budget_limited`. This
|
||||
does not delete the goal or erase the objective. It tells the operator and the
|
||||
agent that the goal is no longer actively being pursued until it is resumed or
|
||||
cleared.
|
||||
|
||||
Token budgets are a session-goal guardrail, not a billing cap. Provider quota,
|
||||
cost reporting, and context-window behavior still use the normal OpenClaw
|
||||
usage and model controls.
|
||||
|
||||
## Model tools
|
||||
|
||||
OpenClaw exposes three core goal tools to agent harnesses:
|
||||
|
||||
- `get_goal`: read the current session goal, including status, objective, token
|
||||
usage, and token budget.
|
||||
- `create_goal`: create a goal only when the user, system, or developer
|
||||
instructions explicitly request one. It fails if the session already has a
|
||||
goal.
|
||||
- `update_goal`: mark the goal `complete` or `blocked`.
|
||||
|
||||
The model cannot silently pause, resume, clear, or replace a goal. Those are
|
||||
operator/session controls through `/goal` and reset commands. This keeps the
|
||||
agent from quietly moving the target while preserving a clean path for the
|
||||
agent to report achievement or a genuine blocker.
|
||||
|
||||
The `update_goal` tool should mark a goal `complete` only when the objective is
|
||||
actually achieved. It should mark a goal `blocked` only when the same blocking
|
||||
condition has repeated and the agent cannot make meaningful progress without
|
||||
new user input or an external-state change.
|
||||
|
||||
## TUI
|
||||
|
||||
The TUI keeps the active session's goal visible in the footer next to the
|
||||
agent, session, model, run controls, and token counts.
|
||||
|
||||
Footer examples:
|
||||
|
||||
- `Pursuing goal (12k/50k)` for an active goal with a token budget.
|
||||
- `Goal paused (/goal resume)` for a paused goal.
|
||||
- `Goal blocked (/goal resume)` for a blocked goal.
|
||||
- `Goal hit usage limits (/goal resume)` for a usage-limited goal.
|
||||
- `Goal unmet (50k/50k)` for a budget-limited goal.
|
||||
- `Goal achieved (42k)` for a completed goal.
|
||||
|
||||
The footer is intentionally compact. Use `/goal` for the full objective, note,
|
||||
token budget, and available commands.
|
||||
|
||||
## Channel behavior
|
||||
|
||||
The `/goal` command works in command-capable OpenClaw sessions, including the
|
||||
TUI and chat surfaces that permit text commands. Goal state is attached to the
|
||||
session key, not the transport. If two surfaces use the same session, they see
|
||||
the same goal.
|
||||
|
||||
Goal state is not a delivery directive. It does not force replies through a
|
||||
channel, change queue behavior, approve tools, or schedule work.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
`Goal error: goal already exists` means the session already has a goal. Use
|
||||
`/goal` to inspect it, `/goal complete` if it is done, or `/goal clear` before
|
||||
starting a different objective.
|
||||
|
||||
`Goal error: goal not found` means the session has no goal yet. Start one with
|
||||
`/goal start <objective>`.
|
||||
|
||||
`Goal error: goal is already complete` means the goal is terminal. Clear it
|
||||
before starting or resuming another objective.
|
||||
|
||||
If token usage looks like `0` or stale, the active session may not have a fresh
|
||||
token snapshot yet. Usage refreshes as OpenClaw records session usage and
|
||||
transcript-derived totals.
|
||||
|
||||
## Related
|
||||
|
||||
- [Slash commands](/tools/slash-commands)
|
||||
- [TUI](/web/tui)
|
||||
- [Session tool](/concepts/session-tool)
|
||||
- [Compaction](/concepts/compaction)
|
||||
- [Task Flow](/automation/taskflow)
|
||||
- [Standing orders](/automation/standing-orders)
|
||||
@@ -11,11 +11,12 @@ sidebarTitle: "Image generation"
|
||||
The `image_generate` tool lets the agent create and edit images using your
|
||||
configured providers. In chat sessions, image generation runs asynchronously:
|
||||
OpenClaw records a background task, returns the task id immediately, and wakes
|
||||
the agent when the provider finishes. The completion agent must send generated
|
||||
images through the `message` tool. If the requester session is inactive or
|
||||
its active wake fails, and some generated images are still missing from
|
||||
message-tool delivery, OpenClaw sends an idempotent direct fallback with only
|
||||
the missing images.
|
||||
the agent when the provider finishes. The completion agent follows the
|
||||
session's normal visible-reply mode: automatic final reply delivery when
|
||||
configured, or `message(action="send")` when the session requires the message
|
||||
tool. If the requester session is inactive or its active wake fails, and some
|
||||
generated images are still missing from the completion reply, OpenClaw sends an
|
||||
idempotent direct fallback with only the missing images.
|
||||
|
||||
<Note>
|
||||
The tool only appears when at least one image-generation provider is
|
||||
|
||||
@@ -78,18 +78,18 @@ The table lists representative tools so you can recognize the surface. It is
|
||||
not the full policy reference. For exact groups, defaults, and allow/deny
|
||||
semantics, use [Tools and custom providers](/gateway/config-tools).
|
||||
|
||||
| Category | Use when the agent needs to... | Representative tools | Read next |
|
||||
| ----------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Runtime | Run commands, manage processes, or use provider-backed Python analysis | `exec`, `process`, `code_execution` | [Exec](/tools/exec), [Code execution](/tools/code-execution) |
|
||||
| Files | Read and change workspace files | `read`, `write`, `edit`, `apply_patch` | [Apply patch](/tools/apply-patch) |
|
||||
| Web | Search the web, search X posts, or fetch readable page content | `web_search`, `x_search`, `web_fetch` | [Web tools](/tools/web), [Web fetch](/tools/web-fetch) |
|
||||
| Browser | Operate a browser session | `browser` | [Browser](/tools/browser) |
|
||||
| Messaging and channels | Send replies or channel actions | `message` | [Agent send](/tools/agent-send) |
|
||||
| Sessions and agents | Inspect sessions, delegate work, steer another run, or report status | `sessions_*`, `subagents`, `agents_list`, `session_status` | [Sub-agents](/tools/subagents), [Session tool](/concepts/session-tool) |
|
||||
| Automation | Schedule work or respond to background events | `cron`, `heartbeat_respond` | [Automation](/automation) |
|
||||
| Gateway and nodes | Inspect Gateway state or paired target devices | `gateway`, `nodes` | [Gateway configuration](/gateway/configuration), [Nodes](/nodes) |
|
||||
| Media | Analyze, generate, or speak media | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` | [Media overview](/tools/media-overview) |
|
||||
| Large OpenClaw catalogs | Search and call many eligible tools without sending every schema to the model | `tool_search_code`, `tool_search`, `tool_describe` | [Tool Search](/tools/tool-search) |
|
||||
| Category | Use when the agent needs to... | Representative tools | Read next |
|
||||
| ----------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Runtime | Run commands, manage processes, or use provider-backed Python analysis | `exec`, `process`, `code_execution` | [Exec](/tools/exec), [Code execution](/tools/code-execution) |
|
||||
| Files | Read and change workspace files | `read`, `write`, `edit`, `apply_patch` | [Apply patch](/tools/apply-patch) |
|
||||
| Web | Search the web, search X posts, or fetch readable page content | `web_search`, `x_search`, `web_fetch` | [Web tools](/tools/web), [Web fetch](/tools/web-fetch) |
|
||||
| Browser | Operate a browser session | `browser` | [Browser](/tools/browser) |
|
||||
| Messaging and channels | Send replies or channel actions | `message` | [Agent send](/tools/agent-send) |
|
||||
| Sessions and agents | Inspect sessions, delegate work, steer another run, or report status | `sessions_*`, `subagents`, `agents_list`, `session_status`, `goal` | [Goal](/tools/goal), [Sub-agents](/tools/subagents), [Session tool](/concepts/session-tool) |
|
||||
| Automation | Schedule work or respond to background events | `cron`, `heartbeat_respond` | [Automation](/automation) |
|
||||
| Gateway and nodes | Inspect Gateway state or paired target devices | `gateway`, `nodes` | [Gateway configuration](/gateway/configuration), [Nodes](/nodes) |
|
||||
| Media | Analyze, generate, or speak media | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` | [Media overview](/tools/media-overview) |
|
||||
| Large OpenClaw catalogs | Search and call many eligible tools without sending every schema to the model | `tool_search_code`, `tool_search`, `tool_describe` | [Tool Search](/tools/tool-search) |
|
||||
|
||||
<Note>
|
||||
Tool Search is an experimental OpenClaw agent surface. Codex harness runs use
|
||||
|
||||
@@ -98,11 +98,12 @@ For async tools, OpenClaw submits the request to the provider, returns a task
|
||||
id immediately, and tracks the job in the task ledger. The agent continues
|
||||
responding to other messages while the job runs. When the provider finishes,
|
||||
OpenClaw wakes the agent with the generated media paths so it can tell the
|
||||
user and relay the result through the message tool. If the requester session
|
||||
is inactive or its active wake fails, and some generated media is still
|
||||
missing from message-tool delivery, OpenClaw sends an idempotent direct
|
||||
fallback with only the missing media. Media already delivered through the
|
||||
message tool is not posted again.
|
||||
user through the session's normal visible-reply mode: automatic final reply
|
||||
delivery when configured, or `message(action="send")` when the session requires
|
||||
the message tool. If the requester session is inactive or its active wake
|
||||
fails, and some generated media is still missing from the completion reply,
|
||||
OpenClaw sends an idempotent direct fallback with only the missing media. Media
|
||||
already delivered by the completion reply is not posted again.
|
||||
|
||||
## Speech-to-text and Voice Call
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ fal, Google, MiniMax, and OpenRouter today.
|
||||
For session-backed agent runs, OpenClaw starts music generation as a
|
||||
background task, tracks it in the task ledger, then wakes the agent again
|
||||
when the track is ready so the agent can tell the user and attach the
|
||||
finished audio. Generated-media completions are delivered by the agent through
|
||||
the message tool. If the requester session is inactive or its active wake
|
||||
fails, and some generated audio is still missing from message-tool delivery,
|
||||
OpenClaw sends an idempotent direct fallback with only the missing audio. The
|
||||
completion wake explicitly warns the agent that normal final replies are
|
||||
private for this route.
|
||||
finished audio. The completion agent follows the session's normal visible-reply
|
||||
mode: automatic final reply delivery when configured, or `message(action="send")`
|
||||
when the session requires the message tool. If the requester session is
|
||||
inactive or its active wake fails, and some generated audio is still missing
|
||||
from the completion reply, OpenClaw sends an idempotent direct fallback with
|
||||
only the missing audio.
|
||||
|
||||
<Note>
|
||||
The built-in shared tool only appears when at least one music-generation
|
||||
|
||||
@@ -153,6 +153,7 @@ Current source-of-truth:
|
||||
- `/commands` shows the generated command catalog.
|
||||
- `/tools [compact|verbose]` shows what the current agent can use right now.
|
||||
- `/status` shows execution/runtime status, Gateway and system uptime, plus provider usage/quota when available.
|
||||
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear` manages the current session's durable [goal](/tools/goal).
|
||||
- `/diagnostics [note]` is the owner-only support-report flow for Gateway bugs and Codex harness runs. It asks for explicit exec approval every time before running `openclaw gateway diagnostics export --json`; do not approve diagnostics with an allow-all rule. After approval, it sends a pasteable report with the local bundle path, manifest summary, privacy notes, and relevant session ids. In group chats, the approval prompt and report go to the owner privately. When the active session uses the OpenAI Codex harness, the same approval also sends relevant Codex feedback to OpenAI servers and the completed reply lists the OpenClaw session ids, Codex thread ids, and `codex resume <thread-id>` commands. See [Diagnostics Export](/gateway/diagnostics).
|
||||
- `/crestodian <request>` runs the Crestodian setup and repair helper from an owner DM.
|
||||
- `/tasks` lists active/recent background tasks for the current session.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
summary: "Compact noisy exec and bash tool results with an optional bundled plugin"
|
||||
summary: "Compact noisy exec and bash tool results with the optional Tokenjuice plugin"
|
||||
title: "Tokenjuice"
|
||||
read_when:
|
||||
- You want shorter `exec` or `bash` tool results in OpenClaw
|
||||
- You want to enable the bundled tokenjuice plugin
|
||||
- You want to install or enable the Tokenjuice plugin
|
||||
- You need to understand what tokenjuice changes and what it leaves raw
|
||||
---
|
||||
|
||||
`tokenjuice` is an optional bundled plugin that compacts noisy `exec` and `bash`
|
||||
`tokenjuice` is an optional external plugin that compacts noisy `exec` and `bash`
|
||||
tool results after the command has already run.
|
||||
|
||||
It changes the returned `tool_result`, not the command itself. Tokenjuice does
|
||||
@@ -19,7 +19,13 @@ trims the output before it goes back into the active harness session.
|
||||
|
||||
## Enable the plugin
|
||||
|
||||
Fast path:
|
||||
Install once:
|
||||
|
||||
```bash
|
||||
openclaw plugins install clawhub:@openclaw/tokenjuice
|
||||
```
|
||||
|
||||
Then enable it:
|
||||
|
||||
```bash
|
||||
openclaw config set plugins.entries.tokenjuice.enabled true
|
||||
@@ -31,9 +37,6 @@ Equivalent:
|
||||
openclaw plugins enable tokenjuice
|
||||
```
|
||||
|
||||
OpenClaw already ships the plugin. There is no separate `plugins install`
|
||||
or `tokenjuice install openclaw` step.
|
||||
|
||||
If you prefer editing config directly:
|
||||
|
||||
```json5
|
||||
|
||||
@@ -62,10 +62,12 @@ session:
|
||||
1. OpenClaw submits the request to the provider and immediately returns a task id.
|
||||
2. The provider processes the job in the background (typically 30 seconds to several minutes depending on the provider and resolution; slow queue-backed providers can run up to the configured timeout).
|
||||
3. When the video is ready, OpenClaw wakes the same session with an internal completion event.
|
||||
4. The agent tells the user and attaches the finished video through the
|
||||
message tool. If the requester session is inactive or its active wake
|
||||
fails, and some generated video is still missing from message-tool delivery,
|
||||
OpenClaw sends an idempotent direct fallback with only the missing video.
|
||||
4. The agent tells the user through the session's normal visible-reply mode:
|
||||
final reply delivery when automatic, or `message(action="send")` when the
|
||||
session requires the message tool. If the requester session is inactive or
|
||||
its active wake fails, and some generated video is still missing from the
|
||||
completion reply, OpenClaw sends an idempotent direct fallback with only the
|
||||
missing video.
|
||||
|
||||
While a job is in flight, duplicate `video_generate` calls in the same
|
||||
session return the current task status instead of starting another
|
||||
|
||||
@@ -54,7 +54,7 @@ Notes:
|
||||
- Header: connection URL, current agent, current session.
|
||||
- Chat log: user messages, assistant replies, system notices, tool cards.
|
||||
- Status line: connection/run state (connecting, running, streaming, idle, error).
|
||||
- Footer: connection state + agent + session + model + think/fast/verbose/trace/reasoning + token counts + deliver.
|
||||
- Footer: connection state + agent + session + model + goal state + think/fast/verbose/trace/reasoning + token counts + deliver.
|
||||
- Input: text editor with autocomplete.
|
||||
|
||||
## Mental model: agents + sessions
|
||||
@@ -68,6 +68,9 @@ Notes:
|
||||
- `per-sender` (default): each agent has many sessions.
|
||||
- `global`: the TUI always uses the `global` session (the picker may be empty).
|
||||
- The current agent + session are always visible in the footer.
|
||||
- If the session has a [goal](/tools/goal), the footer shows its compact state
|
||||
such as `Pursuing goal`, `Goal paused (/goal resume)`, or
|
||||
`Goal achieved`.
|
||||
- When started without `--session`, gateway-mode TUI resumes the last selected session for the same gateway, agent, and session scope if that session still exists. Passing `--session`, `/session`, `/new`, or `/reset` remains explicit.
|
||||
|
||||
## Sending + delivery
|
||||
@@ -116,6 +119,7 @@ Session controls:
|
||||
- `/trace <on|off>`
|
||||
- `/reasoning <on|off|stream>`
|
||||
- `/usage <off|tokens|full>`
|
||||
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
|
||||
- `/elevated <on|off|ask|full>` (alias: `/elev`)
|
||||
- `/activation <mention|always>`
|
||||
- `/deliver <on|off>`
|
||||
|
||||
33
extensions/acpx/README.md
Normal file
33
extensions/acpx/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @openclaw/acpx
|
||||
|
||||
Official ACP runtime backend for OpenClaw.
|
||||
|
||||
ACPx lets OpenClaw run external coding harnesses through the Agent Client Protocol while OpenClaw still owns sessions, channels, delivery, permissions, and Gateway state.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/acpx
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
## What it provides
|
||||
|
||||
- ACP-backed agent runtime sessions.
|
||||
- Plugin-owned session and transport management.
|
||||
- MCP bridge helpers for OpenClaw tools and plugin tools.
|
||||
- Static runtime assets used by the ACP process bridge.
|
||||
|
||||
## Configure
|
||||
|
||||
Use the ACP docs for harness-specific setup, permission modes, and model/runtime selection:
|
||||
|
||||
- https://docs.openclaw.ai/tools/acp-agents-setup
|
||||
- https://docs.openclaw.ai/tools/acp-agents
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `acpx`
|
||||
- Package: `@openclaw/acpx`
|
||||
- Minimum OpenClaw host: `2026.4.25`
|
||||
@@ -17,29 +17,23 @@ vi.mock("./tts.js", async (importOriginal) => {
|
||||
import { buildAzureSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
describe("buildAzureSpeechProvider", () => {
|
||||
const originalEnv = {
|
||||
AZURE_SPEECH_KEY: process.env.AZURE_SPEECH_KEY,
|
||||
AZURE_SPEECH_API_KEY: process.env.AZURE_SPEECH_API_KEY,
|
||||
AZURE_SPEECH_REGION: process.env.AZURE_SPEECH_REGION,
|
||||
AZURE_SPEECH_ENDPOINT: process.env.AZURE_SPEECH_ENDPOINT,
|
||||
SPEECH_KEY: process.env.SPEECH_KEY,
|
||||
SPEECH_REGION: process.env.SPEECH_REGION,
|
||||
};
|
||||
const envKeys = [
|
||||
"AZURE_SPEECH_KEY",
|
||||
"AZURE_SPEECH_API_KEY",
|
||||
"AZURE_SPEECH_REGION",
|
||||
"AZURE_SPEECH_ENDPOINT",
|
||||
"SPEECH_KEY",
|
||||
"SPEECH_REGION",
|
||||
] as const;
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(originalEnv)) {
|
||||
delete process.env[key];
|
||||
for (const key of envKeys) {
|
||||
vi.stubEnv(key, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
vi.unstubAllEnvs();
|
||||
azureSpeechTTSMock.mockClear();
|
||||
listAzureSpeechVoicesMock.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
@@ -52,12 +46,6 @@ describe("buildAzureSpeechProvider", () => {
|
||||
|
||||
it("reports configured only when key plus region or endpoint is available", () => {
|
||||
const provider = buildAzureSpeechProvider();
|
||||
delete process.env.AZURE_SPEECH_KEY;
|
||||
delete process.env.AZURE_SPEECH_API_KEY;
|
||||
delete process.env.SPEECH_KEY;
|
||||
delete process.env.AZURE_SPEECH_REGION;
|
||||
delete process.env.SPEECH_REGION;
|
||||
delete process.env.AZURE_SPEECH_ENDPOINT;
|
||||
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30_000 })).toBe(false);
|
||||
expect(provider.isConfigured({ providerConfig: { apiKey: "key" }, timeoutMs: 30_000 })).toBe(
|
||||
@@ -70,8 +58,8 @@ describe("buildAzureSpeechProvider", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
process.env.AZURE_SPEECH_KEY = "env-key";
|
||||
process.env.AZURE_SPEECH_REGION = "eastus";
|
||||
vi.stubEnv("AZURE_SPEECH_KEY", "env-key");
|
||||
vi.stubEnv("AZURE_SPEECH_REGION", "eastus");
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30_000 })).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
36
extensions/brave/README.md
Normal file
36
extensions/brave/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @openclaw/brave-plugin
|
||||
|
||||
Official Brave Search provider plugin for OpenClaw.
|
||||
|
||||
This plugin registers Brave as a `web_search` provider. It supports normal Brave web search and Brave LLM Context API mode.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/brave-plugin
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
## Configure
|
||||
|
||||
Store a Brave Search API key in plugin config or expose `BRAVE_API_KEY` to the Gateway:
|
||||
|
||||
```bash
|
||||
openclaw config set plugins.entries.brave.enabled true
|
||||
openclaw config set tools.web.search.provider brave
|
||||
```
|
||||
|
||||
Provider-specific options live under `plugins.entries.brave.config.webSearch.*`.
|
||||
|
||||
## Docs
|
||||
|
||||
Full setup, config examples, search modes, and tool parameters:
|
||||
|
||||
- https://docs.openclaw.ai/tools/brave-search
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `brave`
|
||||
- Package: `@openclaw/brave-plugin`
|
||||
- Minimum OpenClaw host: `2026.4.10`
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "../test-support/browser-security.mock.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
@@ -576,6 +577,31 @@ describe("fetchBrowserJson loopback auth", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("caps oversized absolute HTTP timeouts before arming the watchdog", async () => {
|
||||
const timeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
|
||||
vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("timed out");
|
||||
}),
|
||||
);
|
||||
|
||||
await expectThrownBrowserFetchError(
|
||||
() =>
|
||||
fetchBrowserJson<{ ok: boolean }>("http://example.com/", {
|
||||
timeoutMs: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
{
|
||||
contains: [`timed out after ${MAX_TIMER_TIMEOUT_MS}ms`],
|
||||
omits: ["Do NOT retry the browser tool"],
|
||||
},
|
||||
);
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("omits no-retry hint for absolute HTTP abort failures", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parseBrowserHttpUrl } from "openclaw/plugin-sdk/browser-config";
|
||||
import { parseFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
@@ -159,8 +159,7 @@ function appendBrowserToolModelHint(message: string): string {
|
||||
type BrowserFetchFailureKind = "timeout" | "aborted" | "persistent";
|
||||
|
||||
function resolveBrowserFetchTimeoutMs(timeoutMs: number | undefined): number {
|
||||
const parsed = parseFiniteNumber(timeoutMs);
|
||||
return Math.max(1, Math.floor(parsed ?? 5000));
|
||||
return resolveTimerTimeoutMs(timeoutMs, 5000);
|
||||
}
|
||||
|
||||
function classifyBrowserFetchFailure(err: unknown): BrowserFetchFailureKind {
|
||||
|
||||
@@ -75,9 +75,8 @@ describe("browser element commands", () => {
|
||||
await delayProgram.parseAsync(["browser", "click-coords", "10", "20", "--delay-ms", "+0005"], {
|
||||
from: "user",
|
||||
});
|
||||
const delayRequest = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
|
||||
| { body?: { delayMs?: number } }
|
||||
| undefined;
|
||||
const delayCall = mocks.callBrowserRequest.mock.calls.at(-1) as unknown[] | undefined;
|
||||
const delayRequest = delayCall?.[1] as { body?: { delayMs?: number } } | undefined;
|
||||
expect(delayRequest?.body?.delayMs).toBe(5);
|
||||
|
||||
const timeoutProgram = createElementProgram();
|
||||
@@ -85,12 +84,9 @@ describe("browser element commands", () => {
|
||||
["browser", "scrollintoview", "ref-1", "--timeout-ms", "+020000"],
|
||||
{ from: "user" },
|
||||
);
|
||||
const timeoutRequest = mocks.callBrowserRequest.mock.calls.at(-1)?.[1] as
|
||||
| { body?: { timeoutMs?: number } }
|
||||
| undefined;
|
||||
const timeoutOptions = mocks.callBrowserRequest.mock.calls.at(-1)?.[2] as
|
||||
| { timeoutMs?: number }
|
||||
| undefined;
|
||||
const timeoutCall = mocks.callBrowserRequest.mock.calls.at(-1) as unknown[] | undefined;
|
||||
const timeoutRequest = timeoutCall?.[1] as { body?: { timeoutMs?: number } } | undefined;
|
||||
const timeoutOptions = timeoutCall?.[2] as { timeoutMs?: number } | undefined;
|
||||
expect(timeoutRequest?.body?.timeoutMs).toBe(20_000);
|
||||
expect(timeoutOptions?.timeoutMs).toBeGreaterThan(20_000);
|
||||
});
|
||||
|
||||
18
extensions/browser/src/sdk-node-runtime.test.ts
Normal file
18
extensions/browser/src/sdk-node-runtime.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTimeout } from "./sdk-node-runtime.js";
|
||||
|
||||
describe("withTimeout", () => {
|
||||
it("caps oversized timeouts before arming the abort timer", async () => {
|
||||
const timeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
|
||||
vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined);
|
||||
|
||||
await expect(
|
||||
withTimeout(async () => "ok", Number.MAX_SAFE_INTEGER, "browser request"),
|
||||
).resolves.toBe("ok");
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,10 @@ export {
|
||||
type LazyPluginServiceHandle,
|
||||
} from "openclaw/plugin-sdk/plugin-runtime";
|
||||
export { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { clampTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
function normalizeTimeoutMs(timeoutMs: number | undefined): number | undefined {
|
||||
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
|
||||
? Math.max(1, Math.floor(timeoutMs))
|
||||
: undefined;
|
||||
return clampTimerTimeoutMs(timeoutMs);
|
||||
}
|
||||
|
||||
function createTimeoutAbortSignal(timeoutMs: number, label: string | undefined) {
|
||||
@@ -38,7 +37,10 @@ function createTimeoutAbortSignal(timeoutMs: number, label: string | undefined)
|
||||
return { controller, error, timer };
|
||||
}
|
||||
|
||||
function waitForAbort(signal: AbortSignal, fallback: Error): {
|
||||
function waitForAbort(
|
||||
signal: AbortSignal,
|
||||
fallback: Error,
|
||||
): {
|
||||
promise: Promise<never>;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
|
||||
@@ -264,6 +264,7 @@ function websocketMessageToString(data: WebSocket.RawData): string {
|
||||
class WebSocketCodexJsonRpcConnection extends BaseCodexJsonRpcConnection {
|
||||
private readonly ws: WebSocket;
|
||||
private readonly openPromise: Promise<void>;
|
||||
private closing = false;
|
||||
|
||||
constructor(endpoint: Extract<CodexSupervisorEndpoint, { transport: "websocket" }>) {
|
||||
super();
|
||||
@@ -294,7 +295,11 @@ class WebSocketCodexJsonRpcConnection extends BaseCodexJsonRpcConnection {
|
||||
}
|
||||
});
|
||||
this.ws.once("error", (error) => this.fail(error));
|
||||
this.ws.once("close", () => this.fail(new Error("Codex app-server websocket closed")));
|
||||
this.ws.once("close", () => {
|
||||
if (!this.closing) {
|
||||
this.fail(new Error("Codex app-server websocket closed"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
@@ -310,7 +315,27 @@ class WebSocketCodexJsonRpcConnection extends BaseCodexJsonRpcConnection {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.ws.close();
|
||||
this.closing = true;
|
||||
this.fail(new Error("Codex app-server websocket closed"));
|
||||
if (this.ws.readyState === WebSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.ws.terminate();
|
||||
resolve();
|
||||
}, 1000);
|
||||
this.ws.once("close", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
if (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.close();
|
||||
} else {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,10 +159,12 @@ export function registerCodexSupervisorMcpTools(
|
||||
"List Codex sessions visible to the OpenClaw supervisor.",
|
||||
{
|
||||
include_stored: z.boolean().optional(),
|
||||
max_stored_sessions: z.number().int().min(1).max(1000).optional(),
|
||||
},
|
||||
async ({ include_stored }) => {
|
||||
async ({ include_stored, max_stored_sessions }) => {
|
||||
const result = await supervisor.listSessionSnapshot({
|
||||
includeStored: include_stored ?? false,
|
||||
maxStoredSessions: max_stored_sessions,
|
||||
});
|
||||
return textResult(
|
||||
`codex sessions: ${result.sessions.length}`,
|
||||
|
||||
@@ -110,6 +110,40 @@ describe("createCodexSupervisorTools", () => {
|
||||
).rejects.toThrow("Codex write controls are disabled");
|
||||
});
|
||||
|
||||
it("rejects stored session limits outside the runtime bounds", async () => {
|
||||
const { supervisor } = createSupervisorStub();
|
||||
const tools = createCodexSupervisorTools({
|
||||
supervisor,
|
||||
policy: { allowRawTranscripts: false, allowWriteControls: false },
|
||||
});
|
||||
|
||||
await expect(
|
||||
toolByName(tools, "codex_sessions_list").execute("call-1", {
|
||||
include_stored: true,
|
||||
max_stored_sessions: "2",
|
||||
}),
|
||||
).rejects.toThrow("max_stored_sessions must be an integer");
|
||||
|
||||
await expect(
|
||||
toolByName(tools, "codex_sessions_list").execute("call-2", {
|
||||
include_stored: true,
|
||||
max_stored_sessions: 1001,
|
||||
}),
|
||||
).rejects.toThrow("max_stored_sessions must be between 1 and 1000");
|
||||
await expect(
|
||||
toolByName(tools, "codex_sessions_list").execute("call-2", {
|
||||
include_stored: true,
|
||||
max_stored_sessions: null,
|
||||
}),
|
||||
).rejects.toThrow("max_stored_sessions must be an integer");
|
||||
await expect(
|
||||
toolByName(tools, "codex_sessions_list").execute("call-3", {
|
||||
include_stored: true,
|
||||
max_stored_sessions: Number.MAX_SAFE_INTEGER + 1,
|
||||
}),
|
||||
).rejects.toThrow("max_stored_sessions must be between 1 and 1000");
|
||||
});
|
||||
|
||||
it("allows trusted read and write tools when policy enables them", async () => {
|
||||
const { calls, supervisor } = createSupervisorStub();
|
||||
const tools = createCodexSupervisorTools({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { jsonResult, readStringParam, type AnyAgentTool } from "openclaw/plugin-sdk/core";
|
||||
import { asSafeIntegerInRange } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { Type } from "typebox";
|
||||
import {
|
||||
redactCodexSupervisorEndpoint,
|
||||
@@ -13,6 +14,7 @@ const EmptyParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
const SessionsListParamsSchema = Type.Object(
|
||||
{
|
||||
include_stored: Type.Optional(Type.Boolean()),
|
||||
max_stored_sessions: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -67,6 +69,21 @@ function readBooleanParam(params: Record<string, unknown>, key: string): boolean
|
||||
return params[key] === true;
|
||||
}
|
||||
|
||||
function readIntegerParam(params: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = params[key];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const integer = asSafeIntegerInRange(value, { min: 1, max: 1000 });
|
||||
if (integer === undefined) {
|
||||
if (typeof value === "number" && Number.isInteger(value)) {
|
||||
throw new Error(`${key} must be between 1 and 1000`);
|
||||
}
|
||||
throw new Error(`${key} must be an integer`);
|
||||
}
|
||||
return integer;
|
||||
}
|
||||
|
||||
function readModeParam(params: Record<string, unknown>): CodexSupervisorTurnMode | undefined {
|
||||
const mode = readStringParam(params, "mode");
|
||||
if (!mode) {
|
||||
@@ -122,6 +139,7 @@ export function createCodexSupervisorTools({
|
||||
const params = asRecord(rawParams);
|
||||
const result = await supervisor.listSessionSnapshot({
|
||||
includeStored: readBooleanParam(params, "include_stored"),
|
||||
maxStoredSessions: readIntegerParam(params, "max_stored_sessions"),
|
||||
});
|
||||
return jsonResult({
|
||||
summary: `codex sessions: ${result.sessions.length}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { loadCodexSupervisorEndpoints, resolveCodexSupervisorPluginConfig } from "./config.js";
|
||||
import { connectCodexAppServerEndpoint, resolveSafeApprovalResult } from "./json-rpc-client.js";
|
||||
import { CodexSupervisor } from "./supervisor.js";
|
||||
@@ -350,6 +351,7 @@ describe("CodexSupervisor", () => {
|
||||
]);
|
||||
expect(fake.calls.find((call) => call.method === "thread/list")?.params).toMatchObject({
|
||||
sourceKinds: ["cli", "vscode", "exec", "appServer", "unknown"],
|
||||
useStateDbOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,6 +396,51 @@ describe("CodexSupervisor", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds stored session pagination for large real Codex homes", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/list") {
|
||||
return {
|
||||
data: [
|
||||
{ id: "thread-1", status: { type: "notLoaded" }, turns: [] },
|
||||
{ id: "thread-2", status: { type: "notLoaded" }, turns: [] },
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(
|
||||
supervisor.listSessions({ includeStored: true, maxStoredSessions: 1 }),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
endpointId: "local",
|
||||
threadId: "thread-1",
|
||||
status: "notLoaded",
|
||||
},
|
||||
]);
|
||||
expect(fake.calls.filter((call) => call.method === "thread/list")).toEqual([
|
||||
{
|
||||
method: "thread/list",
|
||||
params: {
|
||||
limit: 1,
|
||||
sourceKinds: ["cli", "vscode", "exec", "appServer", "unknown"],
|
||||
useStateDbOnly: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes settled connections when evicting them", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
@@ -508,6 +555,88 @@ describe("CodexSupervisor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a unique loaded endpoint match even when another endpoint is down", async () => {
|
||||
const upEndpoint: CodexSupervisorEndpoint = { id: "up", transport: "stdio-proxy" };
|
||||
const downEndpoint: CodexSupervisorEndpoint = { id: "down", transport: "stdio-proxy" };
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
status: { type: "idle" },
|
||||
turns: [],
|
||||
});
|
||||
const supervisor = new CodexSupervisor([upEndpoint, downEndpoint], async (target) => {
|
||||
if (target.id === "down") {
|
||||
throw new Error("host offline");
|
||||
}
|
||||
return fake;
|
||||
});
|
||||
|
||||
await expect(
|
||||
supervisor.sendToSession({ threadId: "thread-1", text: "continue" }),
|
||||
).resolves.toMatchObject({
|
||||
endpointId: "up",
|
||||
threadId: "thread-1",
|
||||
mode: "start",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves omitted endpoint ids by exact thread read without scanning stored pages", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-old",
|
||||
status: { type: "notLoaded" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/read" && params?.threadId === "thread-old") {
|
||||
return { thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([endpoint], async () => fake);
|
||||
|
||||
await expect(supervisor.readSession({ threadId: "thread-old" })).resolves.toEqual({
|
||||
thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] },
|
||||
});
|
||||
expect(fake.calls.map((call) => call.method)).toEqual([
|
||||
"thread/loaded/list",
|
||||
"thread/read",
|
||||
"thread/read",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves stored threads on healthy endpoints when another endpoint is down", async () => {
|
||||
const downEndpoint: CodexSupervisorEndpoint = { id: "down", transport: "stdio-proxy" };
|
||||
const upEndpoint: CodexSupervisorEndpoint = { id: "up", transport: "stdio-proxy" };
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-old",
|
||||
status: { type: "notLoaded" },
|
||||
turns: [],
|
||||
});
|
||||
fake.request = async (method, params) => {
|
||||
fake.calls.push({ method, params });
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: [], nextCursor: null };
|
||||
}
|
||||
if (method === "thread/read" && params?.threadId === "thread-old") {
|
||||
return { thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
};
|
||||
const supervisor = new CodexSupervisor([downEndpoint, upEndpoint], async (target) => {
|
||||
if (target.id === "down") {
|
||||
throw new Error("host offline");
|
||||
}
|
||||
return fake;
|
||||
});
|
||||
|
||||
await expect(supervisor.readSession({ threadId: "thread-old" })).resolves.toEqual({
|
||||
thread: { id: "thread-old", status: { type: "notLoaded" }, turns: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("steers active sessions when the in-progress turn is readable", async () => {
|
||||
const fake = new FakeCodexConnection({
|
||||
id: "thread-1",
|
||||
@@ -668,6 +797,53 @@ async function waitForFile(filePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
describe("connectCodexAppServerEndpoint", () => {
|
||||
it("rejects pending websocket requests when the supervisor closes intentionally", async () => {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.once("listening", () => {
|
||||
const address = server.address();
|
||||
resolve(typeof address === "object" && address ? address.port : 0);
|
||||
});
|
||||
});
|
||||
const sawProbeRequest = new Promise<void>((resolve) => {
|
||||
server.once("connection", (socket) => {
|
||||
socket.on("message", (data) => {
|
||||
const messageText =
|
||||
typeof data === "string"
|
||||
? data
|
||||
: Array.isArray(data)
|
||||
? Buffer.concat(data).toString()
|
||||
: data instanceof ArrayBuffer
|
||||
? Buffer.from(new Uint8Array(data)).toString()
|
||||
: Buffer.from(data).toString();
|
||||
const request = JSON.parse(messageText) as Record<string, unknown>;
|
||||
if (request.method === "initialize") {
|
||||
socket.send(JSON.stringify({ id: request.id, result: {} }));
|
||||
}
|
||||
if (request.method === "thread/loaded/list") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
const supervisor = new CodexSupervisor(
|
||||
[{ id: "ws", transport: "websocket", url: `ws://127.0.0.1:${port}` }],
|
||||
connectCodexAppServerEndpoint,
|
||||
);
|
||||
|
||||
const probe = supervisor.probeEndpoints();
|
||||
await sawProbeRequest;
|
||||
await supervisor.close();
|
||||
|
||||
await expect(
|
||||
Promise.race([
|
||||
probe,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("probe timed out")), 500)),
|
||||
]),
|
||||
).resolves.toMatchObject([{ endpointId: "ws", ok: false }]);
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it("rejects malformed stdio frames instead of throwing out of band", async () => {
|
||||
const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-supervisor-malformed-"));
|
||||
const marker = path.join(markerDir, "closed");
|
||||
@@ -735,7 +911,7 @@ describe("connectCodexAppServerEndpoint", () => {
|
||||
process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n");
|
||||
return;
|
||||
}
|
||||
if (request.method === "thread/list") {
|
||||
if (request.method === "thread/loaded/list") {
|
||||
process.stdout.write(JSON.stringify({ id: request.id, result: { threads: [] } }) + "\\n");
|
||||
setTimeout(() => process.exit(0), 0);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
type EndpointConnector = (endpoint: CodexSupervisorEndpoint) => Promise<CodexJsonRpcConnection>;
|
||||
|
||||
const ALL_CODEX_THREAD_SOURCE_KINDS = ["cli", "vscode", "exec", "appServer", "unknown"];
|
||||
const DEFAULT_MAX_STORED_SESSIONS = 200;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -137,7 +138,7 @@ export class CodexSupervisor {
|
||||
this.endpoints.map(async (endpoint) => {
|
||||
try {
|
||||
const connection = await this.connectionFor(endpoint.id);
|
||||
await connection.request("thread/list", { limit: 1 });
|
||||
await connection.request("thread/loaded/list", { limit: 1 });
|
||||
return { endpointId: endpoint.id, ok: true };
|
||||
} catch (error) {
|
||||
this.forgetEndpoint(endpoint.id);
|
||||
@@ -151,12 +152,14 @@ export class CodexSupervisor {
|
||||
);
|
||||
}
|
||||
|
||||
async listSessions(params: { includeStored?: boolean } = {}): Promise<CodexSupervisorSession[]> {
|
||||
async listSessions(
|
||||
params: { includeStored?: boolean; maxStoredSessions?: number } = {},
|
||||
): Promise<CodexSupervisorSession[]> {
|
||||
return (await this.listSessionSnapshot(params)).sessions;
|
||||
}
|
||||
|
||||
async listSessionSnapshot(
|
||||
params: { includeStored?: boolean } = {},
|
||||
params: { includeStored?: boolean; maxStoredSessions?: number } = {},
|
||||
): Promise<CodexSupervisorSessionListResult> {
|
||||
const sessions: CodexSupervisorSession[] = [];
|
||||
const errors: CodexSupervisorEndpointHealth[] = [];
|
||||
@@ -273,12 +276,15 @@ export class CodexSupervisor {
|
||||
|
||||
private async listEndpointSessions(
|
||||
endpoint: CodexSupervisorEndpoint,
|
||||
params: { includeStored?: boolean },
|
||||
params: { includeStored?: boolean; maxStoredSessions?: number },
|
||||
): Promise<CodexSupervisorSession[]> {
|
||||
if (params.includeStored === true) {
|
||||
const loaded = await this.listLoadedThreadSessions(endpoint);
|
||||
const sessions = [...loaded];
|
||||
for (const stored of await this.listStoredThreadSessions(endpoint)) {
|
||||
for (const stored of await this.listStoredThreadSessions(
|
||||
endpoint,
|
||||
params.maxStoredSessions,
|
||||
)) {
|
||||
if (!sessions.some((session) => session.threadId === stored.threadId)) {
|
||||
sessions.push(stored);
|
||||
}
|
||||
@@ -318,14 +324,23 @@ export class CodexSupervisor {
|
||||
|
||||
private async listStoredThreadSessions(
|
||||
endpoint: CodexSupervisorEndpoint,
|
||||
maxStoredSessions = DEFAULT_MAX_STORED_SESSIONS,
|
||||
): Promise<CodexSupervisorSession[]> {
|
||||
const sessionLimit = Number.isFinite(maxStoredSessions)
|
||||
? Math.min(1000, Math.max(1, Math.floor(maxStoredSessions)))
|
||||
: DEFAULT_MAX_STORED_SESSIONS;
|
||||
const sessions: CodexSupervisorSession[] = [];
|
||||
const connection = await this.connectionFor(endpoint.id);
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const remaining = sessionLimit - sessions.length;
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
const listed = await connection.request("thread/list", {
|
||||
limit: 100,
|
||||
limit: Math.min(100, remaining),
|
||||
sourceKinds: ALL_CODEX_THREAD_SOURCE_KINDS,
|
||||
useStateDbOnly: true,
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
for (const thread of extractThreadList(listed)) {
|
||||
@@ -340,6 +355,9 @@ export class CodexSupervisor {
|
||||
const session = toSession(endpoint.id, thread);
|
||||
if (session) {
|
||||
sessions.push(session);
|
||||
if (sessions.length >= sessionLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor =
|
||||
@@ -435,7 +453,7 @@ export class CodexSupervisor {
|
||||
if (params.endpointId) {
|
||||
return params.endpointId;
|
||||
}
|
||||
const sessions = await this.listSessions({ includeStored: true });
|
||||
const sessions = await this.listSessions();
|
||||
const matches = sessions.filter((session) => session.threadId === params.threadId);
|
||||
if (matches.length === 1) {
|
||||
return matches[0].endpointId;
|
||||
@@ -443,6 +461,34 @@ export class CodexSupervisor {
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`Codex thread id is ambiguous across endpoints: ${params.threadId}`);
|
||||
}
|
||||
const endpointIds = new Set(matches.map((match) => match.endpointId));
|
||||
for (const endpoint of this.endpoints) {
|
||||
if (endpointIds.has(endpoint.id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const connection = await this.connectionFor(endpoint.id);
|
||||
const read = await this.readThread(connection, params.threadId, false);
|
||||
const thread = extractThread(read);
|
||||
if (thread?.id === params.threadId) {
|
||||
endpointIds.add(endpoint.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isLoadedThreadReadMiss(error)) {
|
||||
continue;
|
||||
}
|
||||
this.forgetEndpoint(endpoint.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (endpointIds.size === 1) {
|
||||
for (const endpointId of endpointIds) {
|
||||
return endpointId;
|
||||
}
|
||||
}
|
||||
if (endpointIds.size > 1) {
|
||||
throw new Error(`Codex thread id is ambiguous across endpoints: ${params.threadId}`);
|
||||
}
|
||||
throw new Error(`Codex thread not found: ${params.threadId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -176,7 +176,8 @@
|
||||
},
|
||||
"postToolRawAssistantCompletionIdleTimeoutMs": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
"minimum": 1,
|
||||
"default": 300000
|
||||
},
|
||||
"approvalPolicy": {
|
||||
"type": "string",
|
||||
@@ -360,7 +361,7 @@
|
||||
},
|
||||
"appServer.postToolRawAssistantCompletionIdleTimeoutMs": {
|
||||
"label": "Post-Tool Raw Assistant Completion Idle Timeout",
|
||||
"help": "Completion-idle guard after a tool handoff when Codex emits raw assistant completion or progress without turn/completed. Defaults to the assistant completion idle timeout when unset.",
|
||||
"help": "Completion-idle guard after a tool handoff when Codex emits raw assistant completion or progress without turn/completed. Defaults to 300000 ms when unset.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.approvalPolicy": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isAssistantCompletionReleaseNotification,
|
||||
isCodexTurnAbortMarkerNotification,
|
||||
isNativeToolProgressNotification,
|
||||
isNativeResponseStreamDeltaNotification,
|
||||
isPendingOpenClawDynamicToolCompletionNotification,
|
||||
isRawAssistantCompletionNotification,
|
||||
isRawReasoningCompletionNotification,
|
||||
@@ -99,9 +100,10 @@ export function applyCodexTurnNotificationState(params: {
|
||||
params.turnId,
|
||||
);
|
||||
const isTurnCompletion = notification.method === "turn/completed" && isCurrentTurnNotification;
|
||||
const isNativeResponseStreamDelta = isNativeResponseStreamDeltaNotification(notification);
|
||||
let turnCrossedToolHandoff = params.turnCrossedToolHandoff;
|
||||
|
||||
if (isCurrentTurnNotification) {
|
||||
if (isCurrentTurnNotification && !isNativeResponseStreamDelta) {
|
||||
turnWatches.touchActivity(`notification:${notification.method}`, {
|
||||
details: describeNotificationActivity(notification),
|
||||
attemptProgress: true,
|
||||
@@ -174,6 +176,9 @@ export function applyCodexTurnNotificationState(params: {
|
||||
} else if (isCurrentTurnNotification && assistantCompletionCanRelease) {
|
||||
turnWatches.armAssistantCompletionIdleWatch(describeNotificationActivity(notification));
|
||||
} else if (postToolRawAssistantCompletionNeedsTerminalGuard) {
|
||||
// A post-tool assistant status can be followed by native Codex streaming a
|
||||
// large custom tool input. Forwarded raw deltas refresh activity at enqueue
|
||||
// time; keep this guard conservative for versions that do not forward them.
|
||||
turnWatches.armCompletionIdleWatch({
|
||||
timeoutMs: params.postToolRawAssistantCompletionIdleTimeoutMs,
|
||||
});
|
||||
@@ -203,6 +208,7 @@ export function applyCodexTurnNotificationState(params: {
|
||||
!turnWatches.isCompletionIdleWatchPinnedByTerminalError() &&
|
||||
notification.method !== "turn/completed" &&
|
||||
isCurrentTurnNotification &&
|
||||
!isNativeResponseStreamDelta &&
|
||||
!trackedDynamicToolCompletion &&
|
||||
!rawToolOutputCompletion &&
|
||||
!postToolRawAssistantCompletionNeedsTerminalGuard &&
|
||||
|
||||
@@ -179,6 +179,12 @@ export function isNativeToolProgressNotification(notification: CodexServerNotifi
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativeResponseStreamDeltaNotification(
|
||||
notification: CodexServerNotification,
|
||||
): boolean {
|
||||
return notification.method.startsWith("response.") && notification.method.endsWith(".delta");
|
||||
}
|
||||
|
||||
export function isRawAssistantCompletionNotification(
|
||||
notification: CodexServerNotification,
|
||||
): boolean {
|
||||
|
||||
@@ -89,6 +89,28 @@ describe("Codex app-server attempt results", () => {
|
||||
replayInvalid: true,
|
||||
livenessState: "abandoned",
|
||||
});
|
||||
expect(
|
||||
buildCodexAppServerPromptTimeoutOutcome({
|
||||
result: createResult({
|
||||
assistantTexts: ["I am changing the data model now..."],
|
||||
}),
|
||||
turnCompletionIdleTimedOut: true,
|
||||
}),
|
||||
).toEqual({
|
||||
message:
|
||||
"Codex stopped before confirming the turn was complete. The response may be incomplete; retry if needed.",
|
||||
});
|
||||
expect(
|
||||
buildCodexAppServerPromptTimeoutOutcome({
|
||||
result: createResult({
|
||||
toolMetas: [{ toolName: "exec" }],
|
||||
}),
|
||||
turnCompletionIdleTimedOut: true,
|
||||
}),
|
||||
).toEqual({
|
||||
message:
|
||||
"Codex stopped before confirming the turn was complete. The response may be incomplete; retry if needed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies replay blocked reasons", () => {
|
||||
|
||||
@@ -27,10 +27,12 @@ export function buildCodexAppServerPromptTimeoutOutcome(params: {
|
||||
const completionIdleTimeoutHadPotentialSideEffects = hasCodexAppServerPotentialSideEffectEvidence(
|
||||
params.result,
|
||||
);
|
||||
const replayBlockedReason = resolveCodexAppServerReplayBlockedReason(params.result);
|
||||
if (
|
||||
!params.turnCompletionIdleTimedOut ||
|
||||
(params.result.itemLifecycle.completedCount === 0 &&
|
||||
!completionIdleTimeoutHadPotentialSideEffects)
|
||||
!completionIdleTimeoutHadPotentialSideEffects &&
|
||||
replayBlockedReason === undefined)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS,
|
||||
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
CODEX_TURN_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
|
||||
@@ -36,6 +37,11 @@ describe("Codex app-server attempt timeouts", () => {
|
||||
});
|
||||
|
||||
it("normalizes turn idle timeout overrides", () => {
|
||||
expect(CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS).toBe(5 * 60_000);
|
||||
expect(CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS).toBeGreaterThan(
|
||||
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
expect(resolveCodexTurnCompletionIdleTimeoutMs(undefined)).toBe(
|
||||
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
);
|
||||
@@ -54,9 +60,21 @@ describe("Codex app-server attempt timeouts", () => {
|
||||
expect(resolveCodexTurnAssistantCompletionIdleTimeoutMs(9.8)).toBe(9);
|
||||
expect(resolveCodexTurnAssistantCompletionIdleTimeoutMs(-10)).toBe(1);
|
||||
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, 123)).toBe(123);
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(Number.NaN, 123)).toBe(123);
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, Number.NaN)).toBe(1);
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, 123)).toBe(
|
||||
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
);
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(Number.NaN, 123)).toBe(
|
||||
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
);
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, 120_000)).toBe(
|
||||
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
);
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, 6 * 60_000)).toBe(
|
||||
6 * 60_000,
|
||||
);
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, Number.NaN)).toBe(
|
||||
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
);
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(7.9, 123)).toBe(7);
|
||||
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(0, 123)).toBe(1);
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { parseFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
|
||||
export const CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS = 100;
|
||||
export const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000;
|
||||
export const CODEX_TURN_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS = 10_000;
|
||||
// Native Codex can stream a large custom tool input after a raw assistant
|
||||
// progress item. Forwarded deltas count as activity, but older native paths may
|
||||
// not surface them, so keep this terminal guard conservative.
|
||||
export const CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS = 5 * 60_000;
|
||||
export const CODEX_POST_REASONING_SOURCE_REPLY_IDLE_TIMEOUT_MS = 5 * 60_000;
|
||||
export const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
|
||||
|
||||
@@ -91,7 +95,11 @@ export function resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(
|
||||
value: number | undefined,
|
||||
fallbackMs: number,
|
||||
): number {
|
||||
return resolvePositiveIntegerTimeoutMs(value, fallbackMs);
|
||||
const defaultMs = Math.max(
|
||||
resolvePositiveIntegerTimeoutMs(undefined, fallbackMs),
|
||||
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
);
|
||||
return resolvePositiveIntegerTimeoutMs(value, defaultMs);
|
||||
}
|
||||
|
||||
export function resolveCodexTurnTerminalIdleTimeoutMs(value: number | undefined): number {
|
||||
|
||||
@@ -380,9 +380,21 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
}
|
||||
scheduleProgressWatches();
|
||||
},
|
||||
noteNotificationReceived: (method: string) => {
|
||||
noteNotificationReceived: (
|
||||
method: string,
|
||||
options?: { details?: Record<string, unknown>; attemptProgress?: boolean },
|
||||
) => {
|
||||
completionLastActivityAt = Date.now();
|
||||
completionLastActivityReason = `notification:${method}`;
|
||||
if (options?.details !== undefined) {
|
||||
completionLastActivityDetails = options.details;
|
||||
}
|
||||
if (options?.attemptProgress) {
|
||||
attemptLastProgressAt = completionLastActivityAt;
|
||||
attemptLastProgressReason = completionLastActivityReason;
|
||||
attemptLastProgressDetails = options.details;
|
||||
params.onAttemptProgress(completionLastActivityReason, options.details);
|
||||
}
|
||||
},
|
||||
scheduleProgressWatches,
|
||||
clearCompletionIdleTimer,
|
||||
|
||||
@@ -102,6 +102,7 @@ export class CodexAppServerClient {
|
||||
private readonly requestHandlers = new Set<CodexServerRequestHandler>();
|
||||
private readonly notificationHandlers = new Set<CodexServerNotificationHandler>();
|
||||
private readonly closeHandlers = new Set<(client: CodexAppServerClient) => void>();
|
||||
private activeSharedLeaseCountProvider: (() => number | undefined) | undefined;
|
||||
private nextId = 1;
|
||||
private initialized = false;
|
||||
private closed = false;
|
||||
@@ -281,6 +282,16 @@ export class CodexAppServerClient {
|
||||
return () => this.notificationHandlers.delete(handler);
|
||||
}
|
||||
|
||||
setActiveSharedLeaseCountProviderForUnscopedNotifications(
|
||||
provider: (() => number | undefined) | undefined,
|
||||
): void {
|
||||
this.activeSharedLeaseCountProvider = provider;
|
||||
}
|
||||
|
||||
getActiveSharedLeaseCountForUnscopedNotifications(): number | undefined {
|
||||
return this.activeSharedLeaseCountProvider?.();
|
||||
}
|
||||
|
||||
addCloseHandler(handler: (client: CodexAppServerClient) => void): () => void {
|
||||
this.closeHandlers.add(handler);
|
||||
return () => this.closeHandlers.delete(handler);
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
isCurrentApprovalTurnRequestParams,
|
||||
isCurrentThreadOptionalTurnRequestParams,
|
||||
isCurrentThreadTurnRequestParams,
|
||||
isNativeResponseStreamDeltaNotification,
|
||||
isTerminalTurnStatus,
|
||||
} from "./attempt-notifications.js";
|
||||
import {
|
||||
@@ -99,7 +100,10 @@ import {
|
||||
resolveCodexTurnTerminalIdleTimeoutMs,
|
||||
withCodexStartupTimeout,
|
||||
} from "./attempt-timeouts.js";
|
||||
import { createCodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
|
||||
import {
|
||||
createCodexAttemptTurnWatchController,
|
||||
type CodexAttemptTurnWatchTimeoutKind,
|
||||
} from "./attempt-turn-watches.js";
|
||||
import {
|
||||
refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerAuthAccountCacheKey,
|
||||
@@ -202,6 +206,7 @@ import {
|
||||
import { releaseCodexSandboxExecServerEnvironment } from "./sandbox-exec-server.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
clearCodexAppServerBindingForThread,
|
||||
readCodexAppServerBinding,
|
||||
type CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
@@ -941,6 +946,7 @@ export async function runCodexAppServerAttempt(
|
||||
let terminalTurnNotificationQueued = false;
|
||||
let timedOut = false;
|
||||
let turnCompletionIdleTimedOut = false;
|
||||
let turnWatchTimeoutKind: CodexAttemptTurnWatchTimeoutKind | undefined;
|
||||
let turnCompletionIdleTimeoutMessage: string | undefined;
|
||||
let clientClosedPromptError: string | undefined;
|
||||
let clientClosedAbort = false;
|
||||
@@ -1019,9 +1025,10 @@ export async function runCodexAppServerAttempt(
|
||||
turnTerminalIdleTimeoutMs,
|
||||
interruptTimeoutMs: CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS,
|
||||
onInterruptTurn: (input) => interruptCodexTurnBestEffort(client, input),
|
||||
onTimeout: () => {
|
||||
onTimeout: (timeout) => {
|
||||
timedOut = true;
|
||||
turnCompletionIdleTimedOut = true;
|
||||
turnWatchTimeoutKind = timeout.kind;
|
||||
turnCompletionIdleTimeoutMessage =
|
||||
"codex app-server turn idle timed out waiting for turn/completed";
|
||||
},
|
||||
@@ -1276,8 +1283,29 @@ export async function runCodexAppServerAttempt(
|
||||
// Touch idle-watch timestamps at receive time, not just after queued
|
||||
// projection. A queued terminal event should suppress short false-idle
|
||||
// guards, while the full attempt watchdog still releases a wedged queue.
|
||||
if (correlation.matchesActiveTurn !== false) {
|
||||
turnWatches.noteNotificationReceived(notification.method);
|
||||
const isNativeResponseStreamDelta = isNativeResponseStreamDeltaNotification(notification);
|
||||
const nativeResponseStreamDeltaMatchesActiveTurn =
|
||||
isNativeResponseStreamDelta &&
|
||||
(correlation.matchesActiveTurn === true ||
|
||||
(isUnscopedCodexNotification(correlation) &&
|
||||
canAttributeUnscopedNativeResponseDeltaToThisTurn(client)));
|
||||
const notificationMatchesActiveTurn =
|
||||
correlation.matchesActiveTurn === true ||
|
||||
(!isNativeResponseStreamDelta && correlation.matchesActiveTurn !== false) ||
|
||||
nativeResponseStreamDeltaMatchesActiveTurn;
|
||||
if (notificationMatchesActiveTurn) {
|
||||
// If a future Codex app-server exposes raw response deltas, treat them as
|
||||
// activity only when scoped to this turn or attributable to a single lease.
|
||||
// Today the durable app-server raw-event surface is rawResponseItem/completed.
|
||||
turnWatches.noteNotificationReceived(
|
||||
notification.method,
|
||||
isNativeResponseStreamDelta
|
||||
? {
|
||||
attemptProgress: true,
|
||||
details: { lastNotificationMethod: notification.method },
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
notificationQueue = notificationQueue.then(
|
||||
() => handleNotification(notification),
|
||||
@@ -1881,11 +1909,15 @@ export async function runCodexAppServerAttempt(
|
||||
const abortListener = () => {
|
||||
const shouldRetireClient = timedOut;
|
||||
if (shouldRetireClient) {
|
||||
void retireCodexAppServerClientAfterTimedOutTurn(client, {
|
||||
threadId: thread.threadId,
|
||||
turnId: activeTurnId,
|
||||
reason: String(runAbortController.signal.reason ?? "timeout"),
|
||||
}).finally(() => {
|
||||
void (async () => {
|
||||
// Timed-out native turns cannot be safely resumed on the same thread.
|
||||
await clearCodexAppServerBindingForThread(activeSessionFile, thread.threadId);
|
||||
await retireCodexAppServerClientAfterTimedOutTurn(client, {
|
||||
threadId: thread.threadId,
|
||||
turnId: activeTurnId,
|
||||
reason: String(runAbortController.signal.reason ?? "timeout"),
|
||||
});
|
||||
})().finally(() => {
|
||||
resolveCompletion?.();
|
||||
});
|
||||
return;
|
||||
@@ -2095,6 +2127,10 @@ export async function runCodexAppServerAttempt(
|
||||
? {
|
||||
codexAppServerFailure: {
|
||||
kind: codexAppServerFailureKind,
|
||||
...(codexAppServerFailureKind === "turn_completion_idle_timeout" &&
|
||||
turnWatchTimeoutKind
|
||||
? { turnWatchTimeoutKind }
|
||||
: {}),
|
||||
transport: appServer.start.transport,
|
||||
threadId: thread.threadId,
|
||||
turnId: activeTurnId,
|
||||
@@ -2202,6 +2238,22 @@ function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function canAttributeUnscopedNativeResponseDeltaToThisTurn(client: CodexAppServerClient): boolean {
|
||||
const activeLeases = client.getActiveSharedLeaseCountForUnscopedNotifications?.();
|
||||
return activeLeases === undefined || activeLeases <= 1;
|
||||
}
|
||||
|
||||
function isUnscopedCodexNotification(
|
||||
correlation: ReturnType<typeof describeCodexNotificationCorrelation>,
|
||||
): boolean {
|
||||
return (
|
||||
!correlation.threadId &&
|
||||
!correlation.turnId &&
|
||||
!correlation.nestedTurnThreadId &&
|
||||
!correlation.nestedTurnId
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRetryContextEngineTurnOnFreshCodexThread(params: {
|
||||
error: unknown;
|
||||
contextEngineActive: boolean;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
turnStartResult,
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import { testing } from "./run-attempt.js";
|
||||
import { resolveCodexAppServerBindingPath } from "./session-binding.js";
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
@@ -70,6 +72,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 200;
|
||||
const bindingPath = resolveCodexAppServerBindingPath(params.sessionFile);
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: { appServer: { turnCompletionIdleTimeoutMs: 5 } },
|
||||
@@ -115,6 +118,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
),
|
||||
{ interval: 1 },
|
||||
);
|
||||
await expect(fs.stat(bindingPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(queueActiveRunMessageForTest("session-1", "after timeout")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1124,7 +1128,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
});
|
||||
|
||||
it("times out post-tool raw assistant progress after the assistant idle timeout", async () => {
|
||||
it("times out post-tool raw assistant progress after the post-tool timeout", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
@@ -1167,6 +1171,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 50,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 5,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
@@ -1331,7 +1336,317 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(completionWarnData?.lastActivityReason).toBe("notification:rawResponseItem/completed");
|
||||
});
|
||||
|
||||
it("times out post-native-tool raw assistant progress after the assistant idle timeout", async () => {
|
||||
it("counts native response deltas as post-tool raw assistant activity", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 60_000;
|
||||
|
||||
let settled = false;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 50,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
|
||||
const toolResult = (await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
})) as { success?: boolean };
|
||||
expect(toolResult.success).toBe(false);
|
||||
await notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "raw-status-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
// This covers the future-compatible path for raw response deltas if Codex
|
||||
// app-server exposes them directly; current Codex primarily emits
|
||||
// rawResponseItem/completed for the raw-event surface.
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
item_id: "ctc-large-edit-1",
|
||||
output_index: 0,
|
||||
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the post-tool guard armed for scoped native response deltas", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session-scoped-delta-timeout.jsonl"),
|
||||
path.join(tempDir, "workspace-scoped-delta-timeout"),
|
||||
);
|
||||
params.timeoutMs = 2_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 50,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
|
||||
await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
});
|
||||
await notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "raw-status-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item_id: "ctc-large-edit-1",
|
||||
output_index: 0,
|
||||
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.promptError).toBe(
|
||||
"codex app-server turn idle timed out waiting for turn/completed",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores unscoped native response deltas while another turn leases the client", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
request,
|
||||
getActiveSharedLeaseCountForUnscopedNotifications: () => 2,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 60_000;
|
||||
|
||||
let settled = false;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 80,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
|
||||
await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
});
|
||||
await notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "raw-status-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
item_id: "foreign-large-edit-1",
|
||||
output_index: 0,
|
||||
delta: '{"cmd":"apply_patch","patch":"other turn"}',
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(settled).toBe(true), fastWait);
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.promptError).toBe(
|
||||
"codex app-server turn idle timed out waiting for turn/completed",
|
||||
);
|
||||
const completionWarnCall = warn.mock.calls.find(
|
||||
([message]) => message === "codex app-server turn idle timed out waiting for completion",
|
||||
);
|
||||
const completionWarnData = completionWarnCall?.[1] as
|
||||
| {
|
||||
lastActivityReason?: string;
|
||||
lastNotificationMethod?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(completionWarnData?.lastActivityReason).toBe("notification:rawResponseItem/completed");
|
||||
expect(completionWarnData?.lastNotificationMethod).toBe("rawResponseItem/completed");
|
||||
});
|
||||
|
||||
it("times out post-native-tool raw assistant progress after the post-tool timeout", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
@@ -1362,6 +1677,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 100,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 5,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
});
|
||||
await vi.waitFor(
|
||||
@@ -1831,6 +2147,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
promptError: "codex app-server turn idle timed out waiting for turn/completed",
|
||||
codexAppServerFailure: {
|
||||
kind: "turn_completion_idle_timeout",
|
||||
turnWatchTimeoutKind: "completion",
|
||||
transport: "stdio",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
clearCodexAppServerBindingForThread,
|
||||
readCodexAppServerBinding,
|
||||
resolveCodexAppServerBindingPath,
|
||||
writeCodexAppServerBinding,
|
||||
@@ -302,4 +303,26 @@ describe("codex app-server session binding", () => {
|
||||
await clearCodexAppServerBinding(sessionFile);
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears a binding only when the thread matches", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.json");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-current",
|
||||
cwd: tempDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
await expect(
|
||||
clearCodexAppServerBindingForThread(sessionFile, "thread-transient"),
|
||||
).resolves.toBe(false);
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-current",
|
||||
});
|
||||
|
||||
await expect(clearCodexAppServerBindingForThread(sessionFile, "thread-current")).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,6 +300,27 @@ export async function clearCodexAppServerBinding(
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearCodexAppServerBindingForThread(
|
||||
sessionFile: string,
|
||||
threadId: string,
|
||||
lookup: Omit<CodexAppServerAuthProfileLookup, "authProfileId"> = {},
|
||||
): Promise<boolean> {
|
||||
const binding = await readCodexAppServerBinding(sessionFile, lookup);
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
if (binding.threadId !== threadId) {
|
||||
embeddedAgentLog.debug("codex app-server binding points at a different thread; preserving", {
|
||||
sessionFile,
|
||||
threadId,
|
||||
boundThreadId: binding.threadId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
await clearCodexAppServerBinding(sessionFile);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ function readLegacySharedCodexAppServerClientState(
|
||||
return value as LegacySharedCodexAppServerClientState;
|
||||
}
|
||||
|
||||
type SharedCodexAppServerClientOptions = {
|
||||
type CodexAppServerClientOptions = {
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
timeoutMs?: number;
|
||||
authProfileId?: string | null;
|
||||
@@ -104,14 +104,47 @@ type SharedCodexAppServerClientOptions = {
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
};
|
||||
|
||||
type ResolvedCodexAppServerClientStartContext = {
|
||||
agentDir: string;
|
||||
usesNativeAuth: boolean;
|
||||
authProfileId: string | undefined;
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
};
|
||||
|
||||
async function resolveCodexAppServerClientStartContext(
|
||||
options?: CodexAppServerClientOptions,
|
||||
): Promise<ResolvedCodexAppServerClientStartContext> {
|
||||
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
||||
const usesNativeAuth = options?.authProfileId === null;
|
||||
const requestedAuthProfileId =
|
||||
options?.authProfileId === null ? undefined : options?.authProfileId;
|
||||
const authProfileId = usesNativeAuth
|
||||
? undefined
|
||||
: resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: requestedAuthProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const requestedStartOptions =
|
||||
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
|
||||
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
|
||||
const startOptions = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: managedStartOptions,
|
||||
agentDir,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
config: options?.config,
|
||||
});
|
||||
return { agentDir, usesNativeAuth, authProfileId, startOptions };
|
||||
}
|
||||
|
||||
export async function getSharedCodexAppServerClient(
|
||||
options?: SharedCodexAppServerClientOptions,
|
||||
options?: CodexAppServerClientOptions,
|
||||
): Promise<CodexAppServerClient> {
|
||||
return (await acquireSharedCodexAppServerClient(options)).client;
|
||||
}
|
||||
|
||||
export async function getLeasedSharedCodexAppServerClient(
|
||||
options?: SharedCodexAppServerClientOptions,
|
||||
options?: CodexAppServerClientOptions,
|
||||
): Promise<CodexAppServerClient> {
|
||||
const acquired = await acquireSharedCodexAppServerClient(options, { leased: true });
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
@@ -139,36 +172,18 @@ export function releaseLeasedSharedCodexAppServerClient(client: CodexAppServerCl
|
||||
}
|
||||
|
||||
async function acquireSharedCodexAppServerClient(
|
||||
options?: SharedCodexAppServerClientOptions,
|
||||
options?: CodexAppServerClientOptions,
|
||||
): Promise<{ client: CodexAppServerClient }>;
|
||||
async function acquireSharedCodexAppServerClient(
|
||||
options: SharedCodexAppServerClientOptions | undefined,
|
||||
options: CodexAppServerClientOptions | undefined,
|
||||
leaseOptions: { leased: true },
|
||||
): Promise<{ client: CodexAppServerClient; release: () => void }>;
|
||||
async function acquireSharedCodexAppServerClient(
|
||||
options?: SharedCodexAppServerClientOptions,
|
||||
options?: CodexAppServerClientOptions,
|
||||
leaseOptions?: { leased: true },
|
||||
): Promise<{ client: CodexAppServerClient; release?: () => void }> {
|
||||
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
||||
const usesNativeAuth = options?.authProfileId === null;
|
||||
const requestedAuthProfileId =
|
||||
options?.authProfileId === null ? undefined : options?.authProfileId;
|
||||
const authProfileId = usesNativeAuth
|
||||
? undefined
|
||||
: resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: requestedAuthProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const requestedStartOptions =
|
||||
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
|
||||
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
|
||||
const startOptions = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: managedStartOptions,
|
||||
agentDir,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
config: options?.config,
|
||||
});
|
||||
const { agentDir, usesNativeAuth, authProfileId, startOptions } =
|
||||
await resolveCodexAppServerClientStartContext(options);
|
||||
const fallbackApiKeyCacheKey = authProfileId
|
||||
? undefined
|
||||
: resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions });
|
||||
@@ -184,6 +199,7 @@ async function acquireSharedCodexAppServerClient(
|
||||
(entry.promise = (async () => {
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
entry.client = client;
|
||||
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
|
||||
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
|
||||
try {
|
||||
await client.initialize();
|
||||
@@ -208,6 +224,7 @@ async function acquireSharedCodexAppServerClient(
|
||||
options?.timeoutMs ?? 0,
|
||||
"codex app-server initialize timed out",
|
||||
);
|
||||
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
|
||||
const release = leaseOptions?.leased ? retainSharedClientEntry(entry) : undefined;
|
||||
return release ? { client, release } : { client };
|
||||
} catch (error) {
|
||||
@@ -219,33 +236,11 @@ async function acquireSharedCodexAppServerClient(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createIsolatedCodexAppServerClient(options?: {
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
timeoutMs?: number;
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
}): Promise<CodexAppServerClient> {
|
||||
const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {});
|
||||
const usesNativeAuth = options?.authProfileId === null;
|
||||
const requestedAuthProfileId =
|
||||
options?.authProfileId === null ? undefined : options?.authProfileId;
|
||||
const authProfileId = usesNativeAuth
|
||||
? undefined
|
||||
: resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: requestedAuthProfileId,
|
||||
agentDir,
|
||||
config: options?.config,
|
||||
});
|
||||
const requestedStartOptions =
|
||||
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
|
||||
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
|
||||
const startOptions = await bridgeCodexAppServerStartOptions({
|
||||
startOptions: managedStartOptions,
|
||||
agentDir,
|
||||
authProfileId: usesNativeAuth ? null : authProfileId,
|
||||
config: options?.config,
|
||||
});
|
||||
export async function createIsolatedCodexAppServerClient(
|
||||
options?: CodexAppServerClientOptions,
|
||||
): Promise<CodexAppServerClient> {
|
||||
const { agentDir, usesNativeAuth, authProfileId, startOptions } =
|
||||
await resolveCodexAppServerClientStartContext(options);
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
const initialize = client.initialize();
|
||||
try {
|
||||
|
||||
@@ -358,6 +358,7 @@ export async function mirrorCodexAppServerTranscript(params: {
|
||||
emitSessionTranscriptUpdate({
|
||||
sessionFile: params.sessionFile,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
message: update.message,
|
||||
messageId: update.messageId,
|
||||
messageSeq: update.messageSeq,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# GitHub Copilot agent runtime (OpenClaw plugin)
|
||||
|
||||
Bundled OpenClaw plugin that registers a `copilot` agent harness backed
|
||||
by `@github/copilot-sdk` and the GitHub Copilot CLI.
|
||||
External OpenClaw plugin that registers a `copilot` agent harness backed by `@github/copilot-sdk` and the GitHub Copilot CLI.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/copilot
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
The harness claims the canonical subscription `github-copilot` provider and
|
||||
is opt-in only — selection requires explicit `agentRuntime.id: "copilot"`
|
||||
@@ -13,3 +20,9 @@ configuration, doctor probes, transcript mirroring, compaction, side
|
||||
questions, replay, and the supported-surface contract.
|
||||
See [qa/copilot-capabilities.md](../../qa/copilot-capabilities.md)
|
||||
for the SDK capability inventory the harness is pinned to.
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `copilot`
|
||||
- Package: `@openclaw/copilot`
|
||||
- Minimum OpenClaw host: `2026.5.28`
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openclaw-copilot-sdk-bootstrap",
|
||||
"version": "1.0.0",
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.5.28",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openclaw-copilot-sdk-bootstrap",
|
||||
"version": "1.0.0",
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.5.28",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "1.0.0-beta.4"
|
||||
}
|
||||
@@ -1,43 +1,39 @@
|
||||
{
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.5.28",
|
||||
"description": "OpenClaw GitHub Copilot agent runtime plugin (registers a `github-copilot` AgentHarness backed by @github/copilot-sdk over JSON-RPC to the bundled GitHub Copilot CLI)",
|
||||
"description": "OpenClaw GitHub Copilot agent runtime plugin (registers a `github-copilot` AgentHarness backed by @github/copilot-sdk over JSON-RPC to the GitHub Copilot CLI)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@github/copilot": "1.0.48",
|
||||
"@github/copilot-sdk": "1.0.0-beta.4",
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "1.0.0-beta.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@github/copilot-sdk": {
|
||||
"optional": true
|
||||
}
|
||||
"devDependencies": {
|
||||
"@github/copilot": "1.0.48",
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/copilot",
|
||||
"npmSpec": "@openclaw/copilot",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.1-beta.1"
|
||||
"minHostVersion": ">=2026.5.28"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.28"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.28"
|
||||
"openclawVersion": "2026.5.28",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": false,
|
||||
"publishToNpm": false
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function probeCopilotCliVersion(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Many version commands (notably the bundled `copilot --version`)
|
||||
// Many version commands (notably the GitHub Copilot CLI's `copilot --version`)
|
||||
// print a banner plus an "update available" hint on subsequent
|
||||
// lines. Surface only the first non-empty line as `version` so the
|
||||
// doctor UI gets a clean string; keep the full stdout in
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("sdk-loader", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("throws an actionable error with install instructions when both probes fail", async () => {
|
||||
it("throws an actionable error with plugin install instructions when both probes fail", async () => {
|
||||
const primaryImport = vi.fn(async () => {
|
||||
throw new Error("Cannot find module '@github/copilot-sdk'");
|
||||
});
|
||||
@@ -134,7 +134,7 @@ describe("sdk-loader", () => {
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "COPILOT_SDK_MISSING",
|
||||
message: expect.stringContaining(COPILOT_SDK_SPEC),
|
||||
message: expect.stringContaining("openclaw plugins install @openclaw/copilot"),
|
||||
});
|
||||
|
||||
expect(fallbackImport).not.toHaveBeenCalled();
|
||||
@@ -160,7 +160,8 @@ describe("sdk-loader", () => {
|
||||
const message = captured?.message ?? "";
|
||||
expect(message).toContain("primary boom");
|
||||
expect(message).toContain(path.join(fallbackDir, "node_modules", "@github", "copilot-sdk"));
|
||||
expect(message).toContain("pnpm add");
|
||||
expect(message).toContain(COPILOT_SDK_SPEC);
|
||||
expect(message).toContain("openclaw plugins install @openclaw/copilot");
|
||||
});
|
||||
|
||||
it("caches successful loads across calls when cache is enabled", async () => {
|
||||
@@ -217,13 +218,8 @@ describe("sdk-loader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("contract with core copilot-sdk-install", () => {
|
||||
// We assert literal values rather than importing core's exports because
|
||||
// extension test files must stay on public plugin-sdk surfaces. The
|
||||
// symmetric test in src/commands/copilot-sdk-install.test.ts asserts the
|
||||
// same literals against core's exports, so any drift on either side fails
|
||||
// one of the two tests.
|
||||
it("COPILOT_SDK_FALLBACK_DIR matches the canonical core install fallback path", () => {
|
||||
describe("sdk dependency constants", () => {
|
||||
it("COPILOT_SDK_FALLBACK_DIR keeps the legacy fallback path stable", () => {
|
||||
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
|
||||
});
|
||||
it("COPILOT_SDK_SPEC pins the canonical SDK spec", () => {
|
||||
|
||||
@@ -85,14 +85,17 @@ function createMissingSdkError(
|
||||
const lines = [
|
||||
"[copilot] @github/copilot-sdk is not installed.",
|
||||
"",
|
||||
"The Copilot agent runtime requires @github/copilot-sdk (~260 MB",
|
||||
"after pulling its platform-specific @github/copilot CLI binary).",
|
||||
"Install it once with:",
|
||||
"The external @openclaw/copilot plugin depends on @github/copilot-sdk",
|
||||
"(~260 MB after pulling its platform-specific @github/copilot CLI binary).",
|
||||
"Reinstall the plugin once with:",
|
||||
"",
|
||||
` pnpm add ${COPILOT_SDK_SPEC}`,
|
||||
` # or: npm install ${COPILOT_SDK_SPEC}`,
|
||||
" openclaw plugins install @openclaw/copilot",
|
||||
"",
|
||||
`Alternatively, install into the on-demand fallback location at\n ${fallbackPath}`,
|
||||
"For source checkouts or offline repair, install the SDK directly:",
|
||||
"",
|
||||
` npm install ${COPILOT_SDK_SPEC}`,
|
||||
"",
|
||||
`The legacy fallback location is still probed at\n ${fallbackPath}`,
|
||||
"",
|
||||
"Primary resolution error:",
|
||||
` ${summarizeError(primaryErr)}`,
|
||||
|
||||
27
extensions/diagnostics-otel/README.md
Normal file
27
extensions/diagnostics-otel/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @openclaw/diagnostics-otel
|
||||
|
||||
Official OpenTelemetry diagnostics exporter for OpenClaw.
|
||||
|
||||
This plugin exports OpenClaw Gateway traces, metrics, and logs to an OTLP collector for observability stacks such as Grafana, Datadog, Honeycomb, New Relic, Tempo, and compatible collectors.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/diagnostics-otel
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
## Configure
|
||||
|
||||
Enable the plugin and set the OTLP endpoint in `plugins.entries.diagnostics-otel.config`.
|
||||
|
||||
The full config surface, metric names, span names, and collector examples live in the docs:
|
||||
|
||||
- https://docs.openclaw.ai/gateway/opentelemetry
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `diagnostics-otel`
|
||||
- Package: `@openclaw/diagnostics-otel`
|
||||
- Minimum OpenClaw host: `2026.4.25`
|
||||
27
extensions/diagnostics-prometheus/README.md
Normal file
27
extensions/diagnostics-prometheus/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @openclaw/diagnostics-prometheus
|
||||
|
||||
Official Prometheus diagnostics exporter for OpenClaw.
|
||||
|
||||
This plugin exposes OpenClaw Gateway runtime metrics in Prometheus text format for Prometheus, Grafana, VictoriaMetrics, and compatible scrapers.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/diagnostics-prometheus
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
## Configure
|
||||
|
||||
Enable the plugin and set the scrape endpoint options in `plugins.entries.diagnostics-prometheus.config`.
|
||||
|
||||
The full config surface, metric names, and scrape examples live in the docs:
|
||||
|
||||
- https://docs.openclaw.ai/gateway/prometheus
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `diagnostics-prometheus`
|
||||
- Package: `@openclaw/diagnostics-prometheus`
|
||||
- Minimum OpenClaw host: `2026.4.25`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user