@openhands/extensions 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/check-extensions.yml +4 -0
- package/.github/workflows/pr-artifacts.yml +294 -0
- package/.github/workflows/pr-description-check.yml +4 -2
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +28 -4
- package/README.md +5 -2
- package/automations/README.md +52 -36
- package/automations/bundle-index.js +13 -3
- package/automations/catalog/custom-automation/manifest.json +229 -0
- package/automations/catalog/github-agents-md-maintainer/manifest.json +2 -1
- package/automations/catalog/github-issue-to-pr/manifest.json +3 -2
- package/automations/catalog/github-issue-triage/manifest.json +72 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +3 -2
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +124 -0
- package/automations/catalog/qa-changes/manifest.json +1 -1
- package/automations/catalog-index.js +23 -17
- package/automations/catalog.schema.json +81 -7
- package/automations/index.d.ts +52 -2
- package/automations/interface.json +2 -1
- package/integrations/catalog/gitlab.json +27 -5
- package/integrations/catalog/reportportal.json +76 -0
- package/integrations/catalog-index.js +6 -4
- package/marketplaces/openhands-extensions.json +36 -0
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/github/scripts/github_client.py +204 -0
- package/skills/github-agents-md-maintainer/SKILL.md +7 -1
- package/skills/github-agents-md-maintainer/scripts/main.py +4 -40
- package/skills/github-issue-to-pr/SKILL.md +8 -1
- package/skills/github-issue-to-pr/scripts/main.py +3 -40
- package/skills/github-issue-triage/.plugin/plugin.json +9 -0
- package/skills/github-issue-triage/README.md +9 -0
- package/skills/github-issue-triage/SKILL.md +37 -0
- package/skills/github-issue-triage/commands/github-issue-triage.md +8 -0
- package/skills/github-issue-triage/scripts/worker.py +123 -0
- package/skills/github-pr-reviewer/SKILL.md +8 -1
- package/skills/github-pr-reviewer/scripts/main.py +3 -41
- package/skills/github-repo-monitor/SKILL.md +7 -1
- package/skills/github-repo-monitor/scripts/main.py +4 -46
- package/skills/gitlab-issue-to-mr/.plugin/plugin.json +19 -0
- package/skills/gitlab-issue-to-mr/README.md +92 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +408 -0
- package/skills/gitlab-issue-to-mr/commands/issue-to-mr-setup.md +8 -0
- package/skills/gitlab-issue-to-mr/references/state-schema.md +140 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +1448 -0
- package/skills/index.js +43 -6
- package/skills/openhands-automation/README.md +1 -1
- package/skills/openhands-automation/SKILL.md +18 -8
- package/skills/openhands-enterprise-troubleshooting/.plugin/plugin.json +20 -0
- package/skills/openhands-enterprise-troubleshooting/README.md +79 -0
- package/skills/openhands-enterprise-troubleshooting/SKILL.md +339 -0
- package/skills/openhands-enterprise-troubleshooting/references/diagnostics.md +698 -0
- package/skills/openhands-enterprise-troubleshooting/references/support-bundle-analysis.md +570 -0
- package/skills/openhands-enterprise-troubleshooting/scripts/bundle_triage.py +532 -0
- package/skills/openhands-sdk/SKILL.md +2 -0
- package/tests/fixtures/automations/custom-automation.json +398 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/github_automation_helpers.py +25 -0
- package/tests/test_automation_setup.py +340 -92
- package/tests/test_github_automation_foundation.py +73 -0
- package/tests/test_github_skill_installation.py +63 -0
- package/tests/test_github_triage_delivery.py +53 -0
- package/tests/test_gitlab_issue_to_mr.py +823 -0
|
@@ -58,6 +58,8 @@ jobs:
|
|
|
58
58
|
run: python scripts/sync_extensions.py --check
|
|
59
59
|
|
|
60
60
|
sync-sdk-skill:
|
|
61
|
+
permissions:
|
|
62
|
+
contents: read
|
|
61
63
|
runs-on: ubuntu-latest
|
|
62
64
|
steps:
|
|
63
65
|
- name: Checkout repository
|
|
@@ -69,4 +71,6 @@ jobs:
|
|
|
69
71
|
python-version: "3.12"
|
|
70
72
|
|
|
71
73
|
- name: Check SDK skill is up to date
|
|
74
|
+
env:
|
|
75
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
72
76
|
run: python scripts/sync_openhands_sdk_skill.py --check
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: PR Artifacts
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
workflow_dispatch: # Manual trigger for testing
|
|
6
|
+
pull_request_target:
|
|
7
|
+
types: [opened, synchronize, reopened, closed]
|
|
8
|
+
branches: [main]
|
|
9
|
+
pull_request_review:
|
|
10
|
+
types: [submitted]
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
# Auto-remove .pr/ directory from same-repository PRs when approved.
|
|
14
|
+
cleanup-on-approval:
|
|
15
|
+
concurrency:
|
|
16
|
+
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
|
|
17
|
+
cancel-in-progress: false
|
|
18
|
+
if: >-
|
|
19
|
+
github.event_name == 'pull_request_review' &&
|
|
20
|
+
github.event.review.state == 'approved' &&
|
|
21
|
+
github.event.pull_request.head.repo.full_name == github.repository
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
permissions:
|
|
24
|
+
contents: write
|
|
25
|
+
pull-requests: write
|
|
26
|
+
steps:
|
|
27
|
+
# Use PAT so the push triggers CI workflows that will complete and
|
|
28
|
+
# satisfy branch protection. We can't use [skip ci] because GitHub Apps
|
|
29
|
+
# can create stuck checks that block merging.
|
|
30
|
+
- uses: actions/checkout@v7
|
|
31
|
+
with:
|
|
32
|
+
ref: ${{ github.event.pull_request.head.ref }}
|
|
33
|
+
token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
|
|
34
|
+
|
|
35
|
+
- name: Remove .pr/ directory
|
|
36
|
+
id: remove
|
|
37
|
+
run: |
|
|
38
|
+
if [ -d ".pr" ]; then
|
|
39
|
+
git config user.name "allhands-bot"
|
|
40
|
+
git config user.email "allhands-bot@users.noreply.github.com"
|
|
41
|
+
git rm -rf .pr/
|
|
42
|
+
git commit -m "chore: Remove PR-only artifacts [automated]"
|
|
43
|
+
git push || {
|
|
44
|
+
echo "::error::Failed to push cleanup commit. Check branch protection rules."
|
|
45
|
+
exit 1
|
|
46
|
+
}
|
|
47
|
+
echo "removed=true" >> "$GITHUB_OUTPUT"
|
|
48
|
+
echo "::notice::Removed .pr/ directory"
|
|
49
|
+
else
|
|
50
|
+
echo "removed=false" >> "$GITHUB_OUTPUT"
|
|
51
|
+
echo "::notice::No .pr/ directory to remove"
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
- name: Update PR comment after cleanup
|
|
55
|
+
if: steps.remove.outputs.removed == 'true'
|
|
56
|
+
uses: actions/github-script@v9
|
|
57
|
+
with:
|
|
58
|
+
script: |
|
|
59
|
+
const marker = '<!-- pr-artifacts-notice -->';
|
|
60
|
+
const body = `${marker}
|
|
61
|
+
✅ **PR Artifacts Cleaned Up**
|
|
62
|
+
|
|
63
|
+
The \`.pr/\` directory has been automatically removed.
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const comments = await github.paginate(
|
|
67
|
+
github.rest.issues.listComments,
|
|
68
|
+
{
|
|
69
|
+
owner: context.repo.owner,
|
|
70
|
+
repo: context.repo.repo,
|
|
71
|
+
issue_number: context.issue.number,
|
|
72
|
+
per_page: 100,
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const existing = comments.find(c => c.body.includes(marker));
|
|
77
|
+
if (existing) {
|
|
78
|
+
await github.rest.issues.updateComment({
|
|
79
|
+
owner: context.repo.owner,
|
|
80
|
+
repo: context.repo.repo,
|
|
81
|
+
comment_id: existing.id,
|
|
82
|
+
body: body,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Inspect the fork through the API; never check out untrusted code with a write token.
|
|
87
|
+
check-pr-artifacts:
|
|
88
|
+
if: >-
|
|
89
|
+
github.event_name == 'pull_request_target' &&
|
|
90
|
+
github.event.action != 'closed'
|
|
91
|
+
runs-on: ubuntu-latest
|
|
92
|
+
permissions:
|
|
93
|
+
contents: read
|
|
94
|
+
pull-requests: write
|
|
95
|
+
steps:
|
|
96
|
+
- name: Post or update PR comment
|
|
97
|
+
uses: actions/github-script@v9
|
|
98
|
+
with:
|
|
99
|
+
script: |
|
|
100
|
+
const marker = '<!-- pr-artifacts-notice -->';
|
|
101
|
+
const pullRequest = context.payload.pull_request;
|
|
102
|
+
const headRepository = pullRequest.head.repo;
|
|
103
|
+
let exists = true;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await github.rest.repos.getContent({
|
|
107
|
+
owner: headRepository.owner.login,
|
|
108
|
+
repo: headRepository.name,
|
|
109
|
+
path: '.pr',
|
|
110
|
+
ref: pullRequest.head.sha,
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error.status === 404) {
|
|
114
|
+
exists = false;
|
|
115
|
+
} else {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const comments = await github.paginate(
|
|
121
|
+
github.rest.issues.listComments,
|
|
122
|
+
{
|
|
123
|
+
owner: context.repo.owner,
|
|
124
|
+
repo: context.repo.repo,
|
|
125
|
+
issue_number: context.issue.number,
|
|
126
|
+
per_page: 100,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
const existing = comments.find(c => c.body.includes(marker));
|
|
130
|
+
|
|
131
|
+
if (!exists) {
|
|
132
|
+
if (existing) {
|
|
133
|
+
await github.rest.issues.updateComment({
|
|
134
|
+
owner: context.repo.owner,
|
|
135
|
+
repo: context.repo.repo,
|
|
136
|
+
comment_id: existing.id,
|
|
137
|
+
body: `${marker}
|
|
138
|
+
✅ **PR Artifacts Cleaned Up**
|
|
139
|
+
|
|
140
|
+
The \`.pr/\` directory is no longer present.
|
|
141
|
+
`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const isFork = headRepository.full_name !== context.payload.repository.full_name;
|
|
148
|
+
const cleanup = isFork
|
|
149
|
+
? 'Because this is a fork PR, the workflow will **open or update a cleanup PR against `main` after merge**.'
|
|
150
|
+
: 'The directory will be **automatically removed when the PR is approved**.';
|
|
151
|
+
const body = `${marker}
|
|
152
|
+
📁 **PR Artifacts Notice**
|
|
153
|
+
|
|
154
|
+
This PR contains a \`.pr/\` directory with temporary PR-specific documents. ${cleanup}
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
core.warning('.pr/ directory contains temporary PR-only artifacts');
|
|
158
|
+
if (existing) {
|
|
159
|
+
await github.rest.issues.updateComment({
|
|
160
|
+
owner: context.repo.owner,
|
|
161
|
+
repo: context.repo.repo,
|
|
162
|
+
comment_id: existing.id,
|
|
163
|
+
body: body,
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
await github.rest.issues.createComment({
|
|
167
|
+
owner: context.repo.owner,
|
|
168
|
+
repo: context.repo.repo,
|
|
169
|
+
issue_number: context.issue.number,
|
|
170
|
+
body: body,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Fork branches cannot be modified reliably. If artifacts reach the trusted base,
|
|
175
|
+
# remove them through a pull request so branch protections remain enforced.
|
|
176
|
+
cleanup-after-merge:
|
|
177
|
+
concurrency:
|
|
178
|
+
group: cleanup-pr-artifacts-${{ github.event.pull_request.base.ref }}
|
|
179
|
+
cancel-in-progress: false
|
|
180
|
+
if: >-
|
|
181
|
+
github.event_name == 'pull_request_target' &&
|
|
182
|
+
github.event.action == 'closed' &&
|
|
183
|
+
github.event.pull_request.merged == true
|
|
184
|
+
runs-on: ubuntu-latest
|
|
185
|
+
permissions:
|
|
186
|
+
contents: write
|
|
187
|
+
pull-requests: write
|
|
188
|
+
steps:
|
|
189
|
+
- uses: actions/checkout@v7
|
|
190
|
+
with:
|
|
191
|
+
ref: ${{ github.event.pull_request.base.ref }}
|
|
192
|
+
token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
|
|
193
|
+
|
|
194
|
+
- name: Create or update cleanup PR
|
|
195
|
+
id: cleanup
|
|
196
|
+
env:
|
|
197
|
+
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
198
|
+
CLEANUP_BRANCH: automation/remove-pr-artifacts
|
|
199
|
+
GH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
|
|
200
|
+
run: |
|
|
201
|
+
if [ ! -d ".pr" ]; then
|
|
202
|
+
echo "created=false" >> "$GITHUB_OUTPUT"
|
|
203
|
+
echo "::notice::No .pr/ directory to remove from $BASE_REF"
|
|
204
|
+
exit 0
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
git config user.name "allhands-bot"
|
|
208
|
+
git config user.email "allhands-bot@users.noreply.github.com"
|
|
209
|
+
git fetch origin \
|
|
210
|
+
"+refs/heads/$CLEANUP_BRANCH:refs/remotes/origin/$CLEANUP_BRANCH" || true
|
|
211
|
+
git checkout -B "$CLEANUP_BRANCH"
|
|
212
|
+
git rm -rf .pr/
|
|
213
|
+
git commit \
|
|
214
|
+
-m "chore: remove merged PR artifacts" \
|
|
215
|
+
-m "Co-authored-by: openhands <openhands@all-hands.dev>"
|
|
216
|
+
git push --force-with-lease origin "HEAD:$CLEANUP_BRANCH"
|
|
217
|
+
|
|
218
|
+
cleanup_pr_url=$(gh pr list \
|
|
219
|
+
--base "$BASE_REF" \
|
|
220
|
+
--head "$CLEANUP_BRANCH" \
|
|
221
|
+
--state open \
|
|
222
|
+
--json url \
|
|
223
|
+
--jq '.[0].url')
|
|
224
|
+
|
|
225
|
+
if [ -z "$cleanup_pr_url" ]; then
|
|
226
|
+
cleanup_pr_url=$(gh pr create \
|
|
227
|
+
--base "$BASE_REF" \
|
|
228
|
+
--head "$CLEANUP_BRANCH" \
|
|
229
|
+
--title "chore: remove merged PR artifacts" \
|
|
230
|
+
--body-file - <<EOF
|
|
231
|
+
## Why
|
|
232
|
+
|
|
233
|
+
Temporary \`.pr/\` artifacts reached \`$BASE_REF\` through one or more merged PRs.
|
|
234
|
+
|
|
235
|
+
## Summary
|
|
236
|
+
|
|
237
|
+
- Remove the temporary \`.pr/\` directory from \`$BASE_REF\`.
|
|
238
|
+
|
|
239
|
+
## How to Test
|
|
240
|
+
|
|
241
|
+
- Confirm this PR only deletes files under \`.pr/\`.
|
|
242
|
+
- Let the required checks validate the cleanup commit.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
_This PR was automatically created by the PR Artifacts workflow._
|
|
247
|
+
EOF
|
|
248
|
+
)
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
echo "created=true" >> "$GITHUB_OUTPUT"
|
|
252
|
+
echo "pr_url=$cleanup_pr_url" >> "$GITHUB_OUTPUT"
|
|
253
|
+
echo "::notice::Cleanup PR ready: $cleanup_pr_url"
|
|
254
|
+
|
|
255
|
+
- name: Update source PR comment
|
|
256
|
+
if: steps.cleanup.outputs.created == 'true'
|
|
257
|
+
uses: actions/github-script@v9
|
|
258
|
+
env:
|
|
259
|
+
CLEANUP_PR_URL: ${{ steps.cleanup.outputs.pr_url }}
|
|
260
|
+
with:
|
|
261
|
+
script: |
|
|
262
|
+
const marker = '<!-- pr-artifacts-notice -->';
|
|
263
|
+
const body = `${marker}
|
|
264
|
+
🧹 **PR Artifact Cleanup Queued**
|
|
265
|
+
|
|
266
|
+
The \`.pr/\` directory reached \`${context.payload.pull_request.base.ref}\` after merge. A cleanup PR has been opened or updated: ${process.env.CLEANUP_PR_URL}
|
|
267
|
+
`;
|
|
268
|
+
|
|
269
|
+
const comments = await github.paginate(
|
|
270
|
+
github.rest.issues.listComments,
|
|
271
|
+
{
|
|
272
|
+
owner: context.repo.owner,
|
|
273
|
+
repo: context.repo.repo,
|
|
274
|
+
issue_number: context.issue.number,
|
|
275
|
+
per_page: 100,
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
const existing = comments.find(c => c.body.includes(marker));
|
|
279
|
+
|
|
280
|
+
if (existing) {
|
|
281
|
+
await github.rest.issues.updateComment({
|
|
282
|
+
owner: context.repo.owner,
|
|
283
|
+
repo: context.repo.repo,
|
|
284
|
+
comment_id: existing.id,
|
|
285
|
+
body: body,
|
|
286
|
+
});
|
|
287
|
+
} else {
|
|
288
|
+
await github.rest.issues.createComment({
|
|
289
|
+
owner: context.repo.owner,
|
|
290
|
+
repo: context.repo.repo,
|
|
291
|
+
issue_number: context.issue.number,
|
|
292
|
+
body: body,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
@@ -17,13 +17,15 @@ jobs:
|
|
|
17
17
|
validate-pr-description:
|
|
18
18
|
name: Validate PR description
|
|
19
19
|
# Draft PRs may still have incomplete descriptions; validate when review starts.
|
|
20
|
-
# Release PRs are generated by
|
|
20
|
+
# Release and artifact-cleanup PRs are generated by trusted workflows without
|
|
21
21
|
# the standard human-authored PR template.
|
|
22
22
|
if: >-
|
|
23
23
|
github.event.pull_request.draft == false &&
|
|
24
24
|
github.event.pull_request.user.login != 'dependabot[bot]' &&
|
|
25
25
|
!(github.event.pull_request.head.repo.full_name == github.repository &&
|
|
26
|
-
startsWith(github.event.pull_request.head.ref, 'release-please-')
|
|
26
|
+
(startsWith(github.event.pull_request.head.ref, 'release-please-') ||
|
|
27
|
+
(github.event.pull_request.base.ref == 'main' &&
|
|
28
|
+
github.event.pull_request.head.ref == 'automation/remove-pr-artifacts')))
|
|
27
29
|
runs-on: ubuntu-latest
|
|
28
30
|
steps:
|
|
29
31
|
- name: Checkout trusted workflow scripts
|
package/AGENTS.md
CHANGED
|
@@ -10,12 +10,11 @@ This repository owns the public registry of reusable OpenHands skills, plugins,
|
|
|
10
10
|
|
|
11
11
|
Related repositories have distinct responsibilities:
|
|
12
12
|
|
|
13
|
-
- [`OpenHands/software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) owns the Python SDK, Agent Server, agent/tool behavior, conversations, workspaces, events, and
|
|
14
|
-
- [`OpenHands/typescript-client`](https://github.com/OpenHands/typescript-client) owns the browser-compatible typed Agent Server client.
|
|
13
|
+
- [`OpenHands/software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) owns the Python SDK, Agent Server, agent/tool behavior, conversations, workspaces, events, canonical API, and browser-compatible TypeScript Agent Server client under `clients/typescript/`.
|
|
15
14
|
- [`OpenHands/OpenHands`](https://github.com/OpenHands/OpenHands) owns Agent Canvas UI and local-stack orchestration.
|
|
16
15
|
- [`OpenHands/automation`](https://github.com/OpenHands/automation) owns scheduling, webhooks, run history, dispatch, and sandbox lifecycle orchestration.
|
|
17
16
|
|
|
18
|
-
Put reusable skills, plugins, automations, and integrations here; put backend execution behavior
|
|
17
|
+
Put reusable skills, plugins, automations, and integrations here; put backend execution behavior and typed API access in the SDK, application UI in Agent Canvas, and scheduling/dispatch lifecycle code in `automation`. If a PR is opened in the wrong repository, explicitly recommend closing and moving it to the owning repository. PRs must follow this repository's applicable contribution and code-review guidance.
|
|
19
18
|
|
|
20
19
|
## What this repo contains
|
|
21
20
|
|
|
@@ -106,7 +105,7 @@ When editing or adding skills in this repo, follow these rules (and add new skil
|
|
|
106
105
|
- If you change a skill’s behavior or scope, update its `README.md` (if present) accordingly.
|
|
107
106
|
- If you change top-level documentation, ensure links still resolve.
|
|
108
107
|
- `integrations/catalog/*.json` is the single hand-authored source of truth consumed by `@openhands/extensions`; adding or editing an integration should require changing exactly one JSON file in that directory. Do not reintroduce `integrations/integration-catalog.json`, separate provider files, per-language catalog duplicates, or provider-specific runtime code. Run `npm run build:integrations` after catalog edits to regenerate `integrations/catalog-index.js`, which statically imports each individual JSON file for the JS package. The Python package includes the same individual JSON files via wheel data and reads them directly. Agent-canvas and integrations-hub import this package directly, so integration marketplace fixes belong here rather than in app-local constants. When upstream MCP projects move repos, verify both `docsUrl` and the connection option (`transport`, `command`/`args`, or URL), not just links. JS exposes `listIntegrationCatalog({ mcp, oauth })` / `getIntegrationCatalogEntry`; Python mirrors with `list_integration_catalog_models(mcp=, oauth=)` / `get_integration_catalog_entry_model`, which return validated Pydantic models (the raw-dict accessors were deprecated in 0.10.0 and removed in 0.12.0). The legacy provider-catalog compatibility layer and `managedConnectorMigration` / `legacyScopeBundles` / `canonicalServerUrl` / `errorHints` mechanisms were removed intentionally; providers declare only standard OAuth config as integration data.
|
|
109
|
-
- `automations/catalog/<id>/` follows the same rule, as a directory per automation so an automation can ship the scripts it uploads to the automations service alongside its metadata. `manifest.json` is the single hand-authored file, carrying both the card metadata and an optional nested `setup` block (the extension-owned configuration experience). Every entry is validated against `automations/catalog.schema.json`. Run `npm run build:automations` after catalog edits to regenerate `automations/catalog-index.js`. JS exposes `listAutomationCatalog()` / `getAutomationCatalogEntry(id)` alongside `AUTOMATION_CATALOG`. The governing rule is that a manifest states only what varies between automations, and states it once; anything derivable is absent and the host generates it. Derived, so never written in a manifest: the command that launches the card (read from the `triggers:` frontmatter of the skill named by `skill`, which defaults to `id` and is stated only where the two differ - editing an automation must never require editing a skill), the setup route (`/automations/new/<id>`), the trigger
|
|
108
|
+
- `automations/catalog/<id>/` follows the same rule, as a directory per automation so an automation can ship the scripts it uploads to the automations service alongside its metadata. `manifest.json` is the single hand-authored file, carrying both the card metadata and an optional nested `setup` block (the extension-owned configuration experience). Every entry is validated against `automations/catalog.schema.json`. Run `npm run build:automations` after catalog edits to regenerate `automations/catalog-index.js`. JS exposes `listAutomationCatalog()` / `getAutomationCatalogEntry(id)` alongside `AUTOMATION_CATALOG`. The governing rule is that a manifest states only what varies between automations, and states it once; anything derivable is absent and the host generates it. Derived, so never written in a manifest: the command that launches the card (read from the `triggers:` frontmatter of the skill named by `skill`, which defaults to `id` and is stated only where the two differ - editing an automation must never require editing a skill), the setup route (`/automations/new/<id>`), the trigger variants a deployment can offer (the keys of `setup.form.triggers`; multiple keys mean any supported selected variant can run), schedule limits, timezone lists, event choices, and model profiles (the `cron`, `timezone`, `event-source`, `event-type`, and `llm-profile` field types), the preflight call, the create request's `name`/`repos`/`model`/`timeout`/`trigger` (rebuilt from same-named form fields when present, the repo-picker field, and the selected key and fields under `setup.form.triggers`, where a field is named after the property it fills), the mapping from a rejected payload path back to the input at fault, the review screen, the create endpoint, the post-success navigation, and the analytics stages. `requires.integrations` is keyed by integration id and lives on the entry, not in `setup`, because a card lists its integrations whether or not it ships a setup flow; there is no key anywhere for a credential, because the credential comes with the connection. `setup.form` splits inputs into `triggers` (keyed by trigger kind - what decides when the automation runs) and `args` (everything else), both keyed by field name. `setup` declares a `prompt` (plus a `filter` for an event trigger) when `mode` is `direct`, and a `message` when it is `assisted`. Contract fixtures are test vectors, not catalog data: they live in `tests/fixtures/automations/`, are optional per automation, and are reachable only through the `@openhands/extensions/testing/automations/*.json` subpath - never from the runtime entry point.
|
|
110
109
|
- For Python test runs, prefer `uv sync --group test` followed by `uv run pytest -q`; the full suite depends on `openhands-sdk`, which is not available in the base environment.
|
|
111
110
|
- Agent-driven plugins (for example `plugins/pr-review` and `plugins/release-notes`) use `uv run --with openhands-sdk --with openhands-tools ...` and require an `LLM_API_KEY` in addition to `GITHUB_TOKEN`.
|
|
112
111
|
- For OpenHands Cloud API guidance, automations, and CLI integration, use `plugins/openhands`. It is the canonical unified OpenHands plugin covering the V1 Cloud API, Automations API, and CLI. The individual skills (`skills/openhands-api`, `skills/openhands-automation`) are also available standalone.
|
|
@@ -118,6 +117,31 @@ When editing or adding skills in this repo, follow these rules (and add new skil
|
|
|
118
117
|
- `plugins/release-notes` now has a standalone validator at `plugins/release-notes/scripts/validate_release_notes.py`; it rebuilds the deterministic tag-range context, fails if a change bullet omits explicit PR/commit refs or matching author handles, and enforces full PR/author coverage by appending a compact `### 🔎 Small Fixes/Internal Changes` appendix grouped by author when the agent omits lower-signal items. New contributor detection in `generate_release_notes.py` should use merged PR history for human authors (excluding bots) rather than commit-author lookup.
|
|
119
118
|
|
|
120
119
|
|
|
120
|
+
## PR-specific documents (`.pr/`)
|
|
121
|
+
|
|
122
|
+
When working on a PR that requires design documents, live-test logs, development-only scripts, or other temporary artifacts that should **not** be merged to `main`, store them in a `.pr/` directory at the repository root.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
mkdir -p .pr
|
|
126
|
+
|
|
127
|
+
.pr/
|
|
128
|
+
├── design.md # Design decisions and architecture notes
|
|
129
|
+
├── analysis.md # Investigation or debugging notes
|
|
130
|
+
└── notes.md # Any other PR-specific content
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The `PR Artifacts` workflow (`.github/workflows/pr-artifacts.yml`) owns the lifecycle of this directory:
|
|
134
|
+
|
|
135
|
+
1. **Notification**: When a PR contains `.pr/`, a single comment is posted to the PR conversation alerting reviewers.
|
|
136
|
+
2. **Auto-cleanup on approval**: For same-repository PRs, the directory is automatically removed by a follow-up commit when the PR is approved.
|
|
137
|
+
3. **Post-merge cleanup**: If artifacts reach `main`, including through a fork PR, the workflow opens or updates a cleanup PR against `main`.
|
|
138
|
+
|
|
139
|
+
Important notes:
|
|
140
|
+
|
|
141
|
+
- Do not put anything in `.pr/` that needs to be preserved.
|
|
142
|
+
- The `.pr/` check is informational during development; it posts a notice rather than blocking the PR.
|
|
143
|
+
- Cleanup PRs follow the normal review and required-check protections for `main`.
|
|
144
|
+
|
|
121
145
|
## CI / validation gotchas
|
|
122
146
|
|
|
123
147
|
- The test suite expects **every directory under `skills/`** to be listed in a marketplace. If you add a new skill (or rebase onto a main branch that added skills), update the appropriate marketplace file or CI will fail with `Skills missing from marketplace: [...]`.
|
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ The JS and Python versions are kept in lock-step by `release-please` and guarded
|
|
|
96
96
|
## Extensions Catalog
|
|
97
97
|
|
|
98
98
|
<!-- BEGIN AUTO-GENERATED CATALOG -->
|
|
99
|
-
This repository contains **2 marketplace(s)** with **
|
|
99
|
+
This repository contains **2 marketplace(s)** with **71 extensions** (61 skills, 10 plugins).
|
|
100
100
|
|
|
101
101
|
### large-codebase
|
|
102
102
|
|
|
@@ -115,7 +115,7 @@ OpenHands skills for interacting, improving, and refactoring large codebases
|
|
|
115
115
|
|
|
116
116
|
Official skills and plugins for OpenHands — the open-source AI software engineer.
|
|
117
117
|
|
|
118
|
-
**
|
|
118
|
+
**67 extensions** (59 skills, 8 plugins)
|
|
119
119
|
|
|
120
120
|
| Name | Type | Description | Commands |
|
|
121
121
|
|------|------|-------------|----------|
|
|
@@ -142,10 +142,12 @@ Official skills and plugins for OpenHands — the open-source AI software engine
|
|
|
142
142
|
| github-actions | skill | Create, debug, and test GitHub Actions workflows and custom actions. Use when building CI/CD pipelines, automating wo... | — |
|
|
143
143
|
| github-agents-md-maintainer | skill | Create an automation that keeps AGENTS.md current in one or more GitHub repositories. On a schedule an agent reads th... | `/agents-md:setup` |
|
|
144
144
|
| github-issue-to-pr | skill | Create an automation that implements GitHub issues when a configurable trigger label is applied. Clones the default b... | `/issue-to-pr:setup` |
|
|
145
|
+
| github-issue-triage | skill | Prioritize open issues and establish acceptance criteria before marking them ready for development. | `/github-issue-triage` |
|
|
145
146
|
| github-pr-review | skill | Post structured PR reviews to GitHub with inline comments/suggestions in a single API call. | `/github-pr-review` |
|
|
146
147
|
| github-pr-reviewer | skill | Create an automation that reviews GitHub pull requests when they are opened or updated. Inspects the diff, changed fi... | `/pr-reviewer:setup` |
|
|
147
148
|
| github-repo-monitor | skill | Create a cron automation that polls a GitHub repository for issue and PR comments containing a configurable trigger p... | `/github-monitor:poll` |
|
|
148
149
|
| gitlab | skill | Interact with GitLab repositories, merge requests, and APIs using the GITLAB_TOKEN environment variable. Use when wor... | — |
|
|
150
|
+
| gitlab-issue-to-mr | skill | Create an automation that implements GitLab issues when a configurable trigger label is applied. Clones the default b... | `/issue-to-mr:setup` |
|
|
149
151
|
| incident-retrospective | skill | Create an automation that drafts incident retrospectives by gathering incident-channel messages from Slack, collectin... | `/incident-retro:setup` |
|
|
150
152
|
| iterate | skill | Iterate on a GitHub pull request — drive it through CI, code review, and QA until merge-ready. Monitors state, fixes ... | `/iterate`, `/verify`, `/babysit` |
|
|
151
153
|
| jira-issue-to-pr | skill | Deploy a cron-based OpenHands automation that watches a Jira Cloud project for issues labeled with a configurable lab... | — |
|
|
@@ -162,6 +164,7 @@ Official skills and plugins for OpenHands — the open-source AI software engine
|
|
|
162
164
|
| openhands | plugin | Unified OpenHands plugin — bundles Cloud CLI, REST API (openhands-api), and Automations (openhands-automation) into a... | `/openhands-cloud` |
|
|
163
165
|
| openhands-api | skill | Use the OpenHands Cloud REST API (V1) and agent-server APIs to create and manage Cloud or local backend conversations... | — |
|
|
164
166
|
| openhands-automation | skill | Create and manage OpenHands automations - scheduled tasks that run in sandboxes. Use the prompt preset to create auto... | `/automation:create` |
|
|
167
|
+
| openhands-enterprise-troubleshooting | skill | Diagnose and resolve common issues on OpenHands Enterprise self-hosted installations. Covers sandbox startup failures... | — |
|
|
165
168
|
| openhands-sdk | skill | Reference skill for the OpenHands Software Agent SDK - build AI agents with custom tools, LLM configuration, conversa... | `/sdk` |
|
|
166
169
|
| pdflatex | skill | Install and use pdflatex to compile LaTeX documents into PDFs on Linux. Use when generating academic papers, research... | — |
|
|
167
170
|
| plain-english-content | skill | Write and edit clear, accessible prose in a plain English content style: active voice, front-loaded content, sentence... | — |
|
package/automations/README.md
CHANGED
|
@@ -42,16 +42,17 @@ two records of the same fact from drifting apart, and it is why the file is as s
|
|
|
42
42
|
| The setup route | `/automations/new/<id>` |
|
|
43
43
|
| The integrations the card lists | `requires.integrations`, which every entry carries |
|
|
44
44
|
| The capabilities endpoint | The host queries it for every automation |
|
|
45
|
-
| The trigger
|
|
46
|
-
| Schedule limits and
|
|
47
|
-
| Local validation rules | The `required` flag and `constraints` on each field |
|
|
45
|
+
| The trigger variants a deployment can offer | The keys of `setup.form.triggers`; an entry with multiple keys is usable when at least one variant is supported |
|
|
46
|
+
| Schedule limits, timezone list, event choices, and model profiles | The `cron`, `timezone`, `event-source`, `event-type`, and `llm-profile` field types |
|
|
47
|
+
| Local validation rules | The `required` flag and `constraints` on each field, applied to the selected trigger variant |
|
|
48
48
|
| The preflight call | `POST /v1/validate` with the entry id, the create endpoint, and the rendered payload |
|
|
49
|
-
| The created automation's name |
|
|
50
|
-
| `repos` in the create request | The repo-picker field, its declared `provider`, and a field named `ref` if there is one |
|
|
51
|
-
| `
|
|
49
|
+
| The created automation's name | A form field named `name`, or the entry's `name` plus the repository that was picked |
|
|
50
|
+
| `repos` in the create request | The repo-picker field, its declared `provider`, and a non-empty field named `ref` if there is one |
|
|
51
|
+
| `model` and `timeout` in the create request | Same-named form fields when they have values |
|
|
52
|
+
| `trigger` in the create request | The selected key under `form.triggers`, and the fields under it named after trigger properties |
|
|
52
53
|
| Which input a rejected payload path belongs to | Rebuilding that body with each field standing in for its own value |
|
|
53
54
|
| The review screen | The fields and their labels |
|
|
54
|
-
| The create endpoint | `POST /v1/preset/prompt`, or `POST /v1` for an
|
|
55
|
+
| The create endpoint | `POST /v1/preset/prompt`, `POST /v1/preset/plugin`, or `POST /v1` for an uploaded or bundled tarball |
|
|
55
56
|
| The files a bundle packs | `setup.bundle.files`, read from this repository at build time |
|
|
56
57
|
| Where a success navigates | The created automation, or the started conversation |
|
|
57
58
|
| The analytics stages | The same stages for every automation |
|
|
@@ -64,7 +65,7 @@ checks them against the recorded fixtures, so these deletions stay honest rather
|
|
|
64
65
|
```jsonc
|
|
65
66
|
{
|
|
66
67
|
"id": "github-pr-reviewer",
|
|
67
|
-
"name": "GitHub
|
|
68
|
+
"name": "GitHub code review",
|
|
68
69
|
"category": "Code review",
|
|
69
70
|
"description": "...",
|
|
70
71
|
"requires": {
|
|
@@ -97,32 +98,40 @@ three entries whose names differ from their skill state it. The command that lau
|
|
|
97
98
|
repeated here: it lives once, in the skill's own `triggers:` frontmatter, and the skills catalog exposes it.
|
|
98
99
|
That way a skill can rename its trigger without leaving a stale copy behind in this catalog.
|
|
99
100
|
|
|
100
|
-
`setup` is optional.
|
|
101
|
-
`
|
|
101
|
+
`setup` is optional. Entries that carry one never repeat `id`, `name`, `category`, or `description`.
|
|
102
|
+
`setup.version` selects how the block is interpreted; a future format ships a new constant.
|
|
102
103
|
|
|
103
104
|
### Triggers and args
|
|
104
105
|
|
|
105
106
|
`form` separates the two things a user is configuring, and both are keyed by field name:
|
|
106
107
|
|
|
107
108
|
- **`form.triggers`** decides *when* the automation runs, keyed by trigger kind (`cron` or `event`).
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
If an entry declares more than one key, the host renders those keys as selectable variants and validates
|
|
110
|
+
only the selected variant's fields before building the payload.
|
|
111
|
+
- **`form.args`** is everything else that is common to every creation path, such as the automation name,
|
|
112
|
+
model profile, or timeout.
|
|
113
|
+
- **`setup.actions`** is optional and declares user-selectable creation paths for one card. The host renders
|
|
114
|
+
the common form plus the selected action's `args`, checks that action's `features`, and creates through
|
|
115
|
+
the endpoint implied by the action key: `prompt` -> `/v1/preset/prompt`, `plugin` -> `/v1/preset/plugin`,
|
|
116
|
+
and `upload` -> `/v1/uploads` followed by `/v1`.
|
|
111
117
|
|
|
112
118
|
An assisted entry declares no triggers, because the trigger is settled during the conversation.
|
|
113
119
|
|
|
114
120
|
### What the form produces
|
|
115
121
|
|
|
116
|
-
- **`mode: "direct"`** declares
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
- **`mode: "direct"`** declares exactly one creation archetype: a `prompt`, a repository-shipped `bundle`,
|
|
123
|
+
or `actions`. A prompt creates through `/v1/preset/prompt`. A bundle is packed and uploaded by the host,
|
|
124
|
+
then creates through `/v1`. An actions block lets the user choose between prompt, plugin, and uploaded
|
|
125
|
+
tarball paths in one card. The rest of the create request restates the form, so it is not written out. A
|
|
126
|
+
form field named `name`, `model`, or `timeout` fills the matching top-level create property. An event
|
|
127
|
+
trigger may also declare a `filter`, because composing form values into a JMESPath expression is the one
|
|
128
|
+
part of an event trigger that cannot always be read off the form.
|
|
120
129
|
- **`mode: "assisted"`** declares a `message`: setup context handed to an agent conversation that finishes
|
|
121
130
|
the job. The command that opens that conversation comes from the skill, so it is not repeated here.
|
|
122
131
|
|
|
123
132
|
A direct entry may also declare a `message`. It is the seed for the fallback conversation the host offers
|
|
124
|
-
when the deployment cannot run the direct path - a deployment whose capabilities lack
|
|
125
|
-
|
|
133
|
+
when the deployment cannot run the direct path - a deployment whose capabilities lack every declared trigger
|
|
134
|
+
variant or required features. The same 2000-character cap applies, and because the fallback fires before the
|
|
126
135
|
form is trustworthy, a direct `message` should not reference `{{form.*}}`.
|
|
127
136
|
|
|
128
137
|
A `repo-picker` may declare `multiple: true`, and then it collects several
|
|
@@ -135,17 +144,17 @@ repository when there is one and after the count when there are several, since a
|
|
|
135
144
|
list of names does not fit a name.
|
|
136
145
|
|
|
137
146
|
A form field is named after the property it fills. `schedule` and `timezone` under `triggers.cron` become
|
|
138
|
-
`trigger.schedule` and `trigger.timezone`; `on` under `triggers.event`
|
|
139
|
-
`ref` becomes `repos[].ref
|
|
140
|
-
to `filter` rather than a trigger property.
|
|
147
|
+
`trigger.schedule` and `trigger.timezone`; `source` and `on` under `triggers.event` become `trigger.source`
|
|
148
|
+
and `trigger.on`; a field named `ref` becomes `repos[].ref` when it has a value. Any other field under a
|
|
149
|
+
trigger kind, such as a phrase to match, is an input to `filter` rather than a trigger property.
|
|
141
150
|
|
|
142
151
|
### Entries that ship a script
|
|
143
152
|
|
|
144
|
-
`mode: "direct"` produces
|
|
145
|
-
is the right shape when the automation *is* the judgement: the agent
|
|
146
|
-
prompt and does the work. A bundle is the right shape when most of
|
|
147
|
-
automation does is deterministic machinery - polling, dedupe, state,
|
|
148
|
-
calls - and the agent is needed only for the part that genuinely needs judgement.
|
|
153
|
+
`mode: "direct"` produces exactly one of `prompt`, `bundle`, or `actions`. A
|
|
154
|
+
prompt is the right shape when the automation *is* the judgement: the agent
|
|
155
|
+
reads the prompt and does the work. A bundle is the right shape when most of
|
|
156
|
+
what the automation does is deterministic machinery - polling, dedupe, state,
|
|
157
|
+
fixed API calls - and the agent is needed only for the part that genuinely needs judgement.
|
|
149
158
|
`github-pr-reviewer` is the first: its script owns discovery, label-event
|
|
150
159
|
dedupe, per-repo state and the review checkout, and starts a conversation only
|
|
151
160
|
once a pull request actually needs reviewing.
|
|
@@ -188,7 +197,10 @@ Two things follow from the archetype rather than being stated:
|
|
|
188
197
|
A bundle declares `requires.features: ["customTarball"]`: a deployment that
|
|
189
198
|
cannot run a client-supplied tarball cannot run the entry, whatever trigger kinds
|
|
190
199
|
it offers. It never declares `repos` - the raw create endpoint has no such field,
|
|
191
|
-
and a bundle fetches what it needs itself.
|
|
200
|
+
and a bundle fetches what it needs itself. In an `actions` entry, creation-path
|
|
201
|
+
features move down to each action (`presetPrompt`, `presetPlugin`, or
|
|
202
|
+
`customTarball`) so a deployment can offer the supported choices without needing
|
|
203
|
+
every creation path.
|
|
192
204
|
|
|
193
205
|
### Format constraints
|
|
194
206
|
|
|
@@ -208,17 +220,19 @@ trust boundary. It enforces:
|
|
|
208
220
|
Placeholders are namespaced and the schema rejects any other namespace: `{{form.*}}` for what the user
|
|
209
221
|
entered and `{{automation.*}}` for the entry itself. There is deliberately no secrets namespace.
|
|
210
222
|
|
|
211
|
-
### The
|
|
223
|
+
### The archetypes
|
|
212
224
|
|
|
213
225
|
| Entry | Archetype | Trigger | Produces |
|
|
214
226
|
| --- | --- | --- | --- |
|
|
215
227
|
| `github-pr-reviewer` | Direct scheduled, script bundle | `cron` | an upload, then a create payload |
|
|
216
228
|
| `github-repo-monitor` | Direct scheduled | `cron` | a create payload |
|
|
229
|
+
| `qa-changes` | Direct event | `event` | a create payload |
|
|
230
|
+
| `custom-automation` | Direct custom action with prompt, plugin, or upload variants | `cron` or `event` | a create payload, or an upload then create payload |
|
|
217
231
|
| `incident-retrospective-drafter` | Assisted conversation | decided during the conversation | a seed message |
|
|
218
232
|
|
|
219
|
-
An
|
|
220
|
-
|
|
221
|
-
|
|
233
|
+
An entry can declare both `cron` and `event` under `setup.form.triggers`. The host treats those keys as
|
|
234
|
+
selectable variants and creates the payload from the selected one; a deployment that only supports one of
|
|
235
|
+
the variants can still offer that supported path.
|
|
222
236
|
|
|
223
237
|
The assisted archetype has no payload and no preflight, because at the end of its flow no automation exists
|
|
224
238
|
yet. The agent creates it during the conversation, and the service validates it there. That is the defining
|
|
@@ -300,9 +314,11 @@ import scenarios from "@openhands/extensions/testing/automations/github-pr-revie
|
|
|
300
314
|
must result. That pairing is the contract: form shape and API shape genuinely differ, the create endpoint
|
|
301
315
|
is declared `extra="forbid"`, and a mapping mistake is a 422 discovered only at creation time.
|
|
302
316
|
|
|
303
|
-
Each scenario carries whichever blocks apply: `formValues`, `
|
|
304
|
-
`preflight`, `create`, `conversation`, `expectedFieldErrors`,
|
|
305
|
-
`
|
|
317
|
+
Each scenario carries whichever blocks apply: `formValues`, `selectedTrigger`, `integrationState`,
|
|
318
|
+
`localValidation`, `preflight`, `create`, `conversation`, `expectedFieldErrors`,
|
|
319
|
+
`expectedPrerequisiteOutcome`. `selectedTrigger` is required when an entry declares multiple trigger
|
|
320
|
+
variants and the scenario records a create or preflight payload. `capabilities.json` holds deployment
|
|
321
|
+
shapes so the unsupported paths have coverage too.
|
|
306
322
|
|
|
307
323
|
Beyond the derivation checks, every request body here has been verified against the live Pydantic models in
|
|
308
324
|
`OpenHands/automation`:
|
|
@@ -359,7 +375,7 @@ questions to this work. What changed and why:
|
|
|
359
375
|
| `analytics` | Removed. The same stages fire for every automation, so they belong in shared host code. |
|
|
360
376
|
| `workflow.steps` | Removed. It restated which keys were present, creating a second source of truth that could contradict the file. |
|
|
361
377
|
| `form.intent: "seed"` | Removed. Derivable from `setup.mode: "assisted"`. |
|
|
362
|
-
| `triggerKindsAnyOf` | Removed. The keys of `form.triggers` are the trigger
|
|
378
|
+
| `triggerKindsAnyOf` | Removed. The keys of `form.triggers` are the available trigger variants, and multiple keys already mean the host can offer any supported one. |
|
|
363
379
|
| `{{form.filledCount}}` | Removed. It overloaded the `form` namespace with a computed value that names no field. |
|
|
364
380
|
| `submit.message` | Kept as `setup.message`, capped at 2000 characters, so seed messages cannot grow back into the giant runtime prompts that recommended automation cards were already fixed to stop sending. |
|
|
365
381
|
|