@openhands/extensions 0.29.0 → 0.31.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.
Files changed (97) hide show
  1. package/.agents/skills/custom-codereview-guide.md +47 -5
  2. package/.claude-plugin/marketplace.json +20 -5
  3. package/.codex-plugin/marketplace.json +20 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/pr-artifacts.yml +4 -83
  7. package/.github/workflows/tests.yml +14 -1
  8. package/.plugin/marketplace.json +20 -5
  9. package/.release-please-manifest.json +1 -1
  10. package/AGENTS.md +4 -3
  11. package/README.md +6 -5
  12. package/apps/vscode/README.md +8 -6
  13. package/apps/vscode/canvas-extension.json +11 -3
  14. package/apps/vscode/extension.js +60 -12
  15. package/apps/vscode/scripts/package.mjs +20 -74
  16. package/automations/bundle-index.js +10 -8
  17. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  18. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  19. package/automations/catalog/github-pr-reviewer/manifest.json +7 -6
  20. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  21. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  22. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  23. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  24. package/integrations/catalog/atlassian-rovo.json +4 -3
  25. package/integrations/catalog/bitbucket.json +16 -1
  26. package/integrations/catalog/github.json +1 -0
  27. package/integrations/catalog/gitlab.json +1 -1
  28. package/marketplaces/openhands-extensions.json +20 -5
  29. package/package.json +1 -1
  30. package/plugins/pr-review/README.md +1 -0
  31. package/plugins/pr-review/scripts/prompt.py +44 -11
  32. package/plugins/runtime-api-configs/SKILL.md +1 -1
  33. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  34. package/pyproject.toml +1 -1
  35. package/python/openhands_extensions/_version.py +1 -1
  36. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  37. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  38. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  39. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  40. package/skills/github/scripts/agent_conversation.py +224 -8
  41. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  42. package/skills/github-issue-triage/README.md +73 -14
  43. package/skills/github-issue-triage/SKILL.md +26 -46
  44. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  45. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  46. package/skills/github-issue-triage/scripts/worker.py +153 -103
  47. package/skills/github-pr-reviewer/README.md +109 -1
  48. package/skills/github-pr-reviewer/SKILL.md +42 -8
  49. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  50. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  51. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  52. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  53. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  54. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  55. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  56. package/skills/gitlab-issue-to-mr/README.md +10 -3
  57. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  58. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  59. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  60. package/skills/index.js +18 -8
  61. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  62. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/README.md +18 -11
  65. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  66. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  67. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  68. package/skills/slack-channel-monitor/README.md +12 -3
  69. package/skills/slack-channel-monitor/SKILL.md +35 -20
  70. package/skills/slack-channel-monitor/references/windows.md +6 -2
  71. package/skills/slack-channel-monitor/scripts/main.py +189 -12
  72. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  73. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  74. package/skills/software-factory/.plugin/plugin.json +19 -0
  75. package/skills/software-factory/README.md +64 -0
  76. package/skills/software-factory/SKILL.md +229 -0
  77. package/skills/software-factory/commands/software-factory.md +8 -0
  78. package/skills/software-factory/scripts/factory.py +630 -0
  79. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  80. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  81. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  82. package/skills/ticket-to-code-change/SKILL.md +5 -3
  83. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  84. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  85. package/tests/test_agent_conversation_dispatch.py +173 -6
  86. package/tests/test_automation_llm_provenance.py +1209 -0
  87. package/tests/test_github_review_publication.py +422 -0
  88. package/tests/test_github_reviewer_delivery.py +174 -21
  89. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  90. package/tests/test_github_triage_delivery.py +411 -238
  91. package/tests/test_gitlab_issue_to_mr.py +284 -0
  92. package/tests/test_jira_issue_to_pr.py +200 -6
  93. package/tests/test_pr_review_prompt_injection.py +353 -0
  94. package/tests/test_slack_channel_monitor.py +32 -1
  95. package/tests/test_software_factory_script.py +231 -0
  96. package/tests/test_vscode_app.py +8 -2
  97. package/tests/vscode-app.test.mjs +8 -2
@@ -31,8 +31,10 @@ On every subsequent run:
31
31
  - Completed conversations stay in a short follow-up watch window, allowing
32
32
  triggered Slack replies to continue the same OpenHands conversation.
33
33
 
34
- > **Local mode only.** This automation targets the local OpenHands setup
35
- > (`dev:automation` stack). A cloud/webhook-based variant is out of scope here.
34
+ > Runs on a local Agent Canvas (`dev:automation` stack) and on OpenHands Cloud
35
+ > and Enterprise. On Cloud and Enterprise each conversation is started through
36
+ > the OpenHands API in a sandbox of its own, with the user's own settings, and
37
+ > the automation keeps its state in the automation service's KV store.
36
38
 
37
39
  ---
38
40
 
@@ -66,7 +68,7 @@ function without Slack credentials.
66
68
 
67
69
  | Secret name | Default | Purpose |
68
70
  |---|---|---|
69
- | `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links posted in Slack |
71
+ | `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links posted in Slack (local Agent Canvas only; on OpenHands Cloud and Enterprise the links open the deployment's own Canvas) |
70
72
 
71
73
  ---
72
74
 
@@ -165,23 +167,27 @@ repair a hand-written variant.
165
167
 
166
168
  ### Step 4 - Package and upload
167
169
 
168
- Determine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`
169
- block in your system context:
170
- - Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`
171
- - Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`
170
+ Set `OPENHANDS_HOST` and `AUTH_HEADER` for the commands below:
171
+ - **OpenHands Cloud or Enterprise** - a `<HOST>` value is in your system prompt:
172
+ use it as `OPENHANDS_HOST`, with `AUTH_HEADER="Authorization: Bearer $OPENHANDS_API_KEY"`
173
+ - **Local Agent Canvas** - an **Automation backend** is listed in the
174
+ `<RUNTIME_SERVICES>` block of your system context: use its `url_from_agent` as
175
+ `OPENHANDS_HOST`, with `AUTH_HEADER="X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY"`
172
176
 
173
- If no Automation backend is listed in `<RUNTIME_SERVICES>`, stop and tell
174
- the user to start the full automation stack.
177
+ If neither is available, stop and tell the user to start the full automation
178
+ stack.
179
+
180
+ On OpenHands Cloud or Enterprise each run starts in a fresh sandbox, so the
181
+ automation keeps its state in the KV store: check that `kvStore` is listed under
182
+ `features` in `GET ${OPENHANDS_HOST}/api/automation/v1/capabilities` before
183
+ deploying.
175
184
 
176
185
  ```bash
177
186
  tar -czf /tmp/slack-monitor.tar.gz -C /tmp/slack-monitor-build .
178
187
 
179
- # OPENHANDS_HOST: read from <RUNTIME_SERVICES> Automation backend url_from_agent
180
- OPENHANDS_HOST="<automation-url-from-runtime-services>"
181
-
182
188
  TARBALL_PATH=$(curl -s -X POST \
183
189
  "${OPENHANDS_HOST}/api/automation/v1/uploads?name=slack-channel-monitor" \
184
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
190
+ -H "$AUTH_HEADER" \
185
191
  -H "Content-Type: application/gzip" \
186
192
  --data-binary @/tmp/slack-monitor.tar.gz \
187
193
  | python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])")
@@ -196,7 +202,7 @@ If the upload fails with a size error, the tarball must be under 1 MB.
196
202
 
197
203
  ```bash
198
204
  curl -s -X POST "${OPENHANDS_HOST}/api/automation/v1" \
199
- -H "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY" \
205
+ -H "$AUTH_HEADER" \
200
206
  -H "Content-Type: application/json" \
201
207
  -d "{
202
208
  \"name\": \"Slack Channel Monitor\",
@@ -221,7 +227,8 @@ Tell the user:
221
227
  > - Channels: `{channel list}`
222
228
  > - Trigger phrase: `{phrase}`
223
229
  > - Polling every minute via cron `* * * * *`
224
- > - State file: `~/.openhands/workspaces/automation-state/slack_poller_{id}.json`
230
+ > - State: the automation service's KV store (a local Agent Canvas without one
231
+ > uses `~/.openhands/workspaces/automation-state/slack_poller_{id}.json`)
225
232
  >
226
233
  > Send a message containing `{phrase}` in any monitored channel to test it.
227
234
  > The bot will react with 👀 and reply with a link to the new conversation.
@@ -233,7 +240,8 @@ Tell the user:
233
240
  Each cron run executes `main.py`, which runs **10 polling iterations** (every
234
241
  5 seconds) within the 55-second timeout window. Each iteration:
235
242
 
236
- 1. **Loads state** from the JSON file (see `references/state-schema.md`).
243
+ 1. **Loads state** from the KV store, or from the JSON file on a local Agent
244
+ Canvas without one (see `references/state-schema.md`).
237
245
  2. **Resolves the Slack token** - checks `SLACK_USER_TOKEN` then `SLACK_BOT_TOKEN`.
238
246
  3. **Fetches new messages:**
239
247
  - User token + `search:read` + > 1 channel → single `search.messages` call
@@ -251,14 +259,21 @@ Each cron run executes `main.py`, which runs **10 polling iterations** (every
251
259
  ignored.
252
260
  - Contains trigger phrase outside a tracked conversation → 👀 reaction, create
253
261
  a new conversation, post link.
262
+ - Records `default` and the concrete model from the server settings already
263
+ used to start the conversation, for the response footer.
264
+ - On OpenHands Cloud and Enterprise the conversation is started through the
265
+ OpenHands API in a sandbox of its own, with the user's own settings; the
266
+ link opens it in the deployment's Canvas, and the footer names the model
267
+ the conversation reports once it is up.
254
268
  - Thread replies: agent receives full thread history for context.
255
269
  - Root messages: agent receives the trigger text only.
256
270
  6. **Checks conversation statuses** - for each active conversation where
257
271
  `time.time() - last_activity > 15 s`:
258
272
  - If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's
259
- final response via `/api/conversations/{id}/agent_final_response` and post
260
- it to the Slack thread using Slack's `markdown_text` field so Markdown
261
- formatting renders correctly. Mark the record `watching` for five minutes
273
+ final response via `/api/conversations/{id}/agent_final_response`, append
274
+ the LLM profile and model, and post it to the Slack thread using Slack's
275
+ `markdown_text` field so Markdown formatting renders correctly. Mark the
276
+ record `watching` for five minutes
262
277
  so triggered follow-up replies can continue the same conversation.
263
278
  7. **Advances `last_poll`** to `now - 10 s` (overlap window prevents boundary
264
279
  races). If a conversation creation failed, pins `last_poll` further back to
@@ -298,7 +313,7 @@ Debug output is written to both stdout and a persistent log at:
298
313
  | `not_in_channel` error in run logs | Bot token used but bot not a member | Invite bot or switch to user token |
299
314
  | `missing_scope` error | Token lacks required scopes | Re-install Slack app with correct scopes (see `references/slack-api.md`) |
300
315
  | No messages detected | `last_poll` timestamp is in the future | Delete the state file to reset; it will be recreated on next run |
301
- | Conversation link 404 | `OPENHANDS_URL` points to wrong host | Set the `OPENHANDS_URL` secret to the correct base URL |
316
+ | Conversation link 404 | `OPENHANDS_URL` points to wrong host (local Agent Canvas) | Set the `OPENHANDS_URL` secret to the correct base URL |
302
317
  | Summary never posted | Conversation stuck in `running` state | Check conversation in the OpenHands UI; the agent may need intervention |
303
318
  | Duplicate conversations created | `processed_ts` state missing or corrupted | Delete the state file to reset; dedup will rebuild on next run |
304
319
  | Trigger message processed on each cron run | State file deleted between runs | Ensure `automation-state/` directory is persistent across runs |
@@ -31,13 +31,17 @@ Select-String -Path $main -Pattern 'def create_conversation'
31
31
 
32
32
  ## Package and Upload
33
33
 
34
+ Set `$env:OPENHANDS_HOST` and `$env:AUTH_HEADER` as SKILL.md Step 4 describes:
35
+ `"Authorization: Bearer $env:OPENHANDS_API_KEY"` on OpenHands Cloud or Enterprise,
36
+ `"X-Session-API-Key: $env:OPENHANDS_AUTOMATION_API_KEY"` on a local Agent Canvas.
37
+
34
38
  ```powershell
35
39
  $tarball = Join-Path $env:TEMP "slack-monitor.tar.gz"
36
40
  tar.exe -czf $tarball -C $buildDir .
37
41
 
38
42
  $upload = curl.exe -s -X POST `
39
43
  "$env:OPENHANDS_HOST/api/automation/v1/uploads?name=slack-channel-monitor" `
40
- -H "X-Session-API-Key: $env:OPENHANDS_AUTOMATION_API_KEY" `
44
+ -H $env:AUTH_HEADER `
41
45
  -H "Content-Type: application/gzip" `
42
46
  --data-binary "@$tarball" | ConvertFrom-Json
43
47
 
@@ -58,7 +62,7 @@ $body = @"
58
62
  "@
59
63
 
60
64
  curl.exe -s -X POST "$env:OPENHANDS_HOST/api/automation/v1" `
61
- -H "X-Session-API-Key: $env:OPENHANDS_AUTOMATION_API_KEY" `
65
+ -H $env:AUTH_HEADER `
62
66
  -H "Content-Type: application/json" `
63
67
  -d $body
64
68
  ```
@@ -17,6 +17,10 @@ On subsequent runs:
17
17
  Configuration constants are embedded at automation-creation time by the skill.
18
18
  See SKILL.md for the full setup workflow.
19
19
 
20
+ Runs against a local agent server (AGENT_SERVER_URL is injected) or on OpenHands
21
+ Cloud and Enterprise (OPENHANDS_CLOUD_API_URL / OPENHANDS_API_KEY / SANDBOX_ID are
22
+ injected instead).
23
+
20
24
  Required secrets (set in OpenHands Settings → Secrets):
21
25
  SLACK_BOT_TOKEN - bot token (xoxb-…) with scopes:
22
26
  channels:history, channels:read,
@@ -26,17 +30,20 @@ Required secrets (set in OpenHands Settings → Secrets):
26
30
  channels:history, search:read (for multi-channel),
27
31
  reactions:write, chat:write
28
32
 
29
- Optional secret:
33
+ Optional secret (local Agent Canvas only; on OpenHands Cloud and Enterprise the
34
+ links open the deployment's own Canvas):
30
35
  OPENHANDS_URL - base URL of your OpenHands instance for conversation
31
36
  links (default: http://localhost:8000)
32
37
  """
33
38
 
34
39
  import json
35
40
  import os
41
+ import re
36
42
  import sys
37
43
  import time
38
44
  import urllib.error
39
45
  import urllib.request
46
+ import uuid
40
47
  from datetime import datetime, timezone
41
48
  from urllib.parse import urlencode
42
49
 
@@ -100,6 +107,12 @@ MAX_THREAD_REPLY_POLLS_PER_RUN = 1
100
107
 
101
108
  # ── Stdlib helpers ─────────────────────────────────────────────────────────────
102
109
 
110
+ # A local Agent Canvas run is handed the Agent Server URL. A run on OpenHands
111
+ # Cloud or Enterprise is handed the OpenHands API instead.
112
+ _CLOUD_API_URL = os.environ.get("OPENHANDS_CLOUD_API_URL", "").rstrip("/")
113
+ IS_CLOUD = bool(_CLOUD_API_URL) and not os.environ.get("AGENT_SERVER_URL")
114
+
115
+
103
116
  def _get_env_key() -> str:
104
117
  return (
105
118
  os.environ.get("SESSION_API_KEY")
@@ -109,13 +122,14 @@ def _get_env_key() -> str:
109
122
 
110
123
 
111
124
  def get_secret(name: str) -> str:
112
- """Fetch a named secret from the agent server."""
113
- url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
125
+ """Fetch a named secret from the agent server, or from the OpenHands API on cloud."""
114
126
  key = _get_env_key()
115
- req = urllib.request.Request(
116
- f"{url}/api/settings/secrets/{name}",
117
- headers={"X-Session-API-Key": key},
118
- )
127
+ if IS_CLOUD:
128
+ # The OpenHands API serves the secrets of this run's sandbox.
129
+ url = f"{_CLOUD_API_URL}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}"
130
+ else:
131
+ url = f"{os.environ.get('AGENT_SERVER_URL', '').rstrip('/')}/api/settings/secrets/{name}"
132
+ req = urllib.request.Request(url, headers={"X-Session-API-Key": key})
119
133
  with urllib.request.urlopen(req) as r:
120
134
  return r.read().decode().strip()
121
135
 
@@ -157,6 +171,9 @@ def _ngrok_public_openhands_url() -> str | None:
157
171
 
158
172
 
159
173
  def resolve_openhands_url() -> str:
174
+ if IS_CLOUD:
175
+ # Conversations are opened in the Canvas of the deployment itself.
176
+ return f"{_CLOUD_API_URL}/canvas"
160
177
  try:
161
178
  configured = get_secret("OPENHANDS_URL").rstrip()
162
179
  except Exception:
@@ -183,12 +200,14 @@ def fire_callback(
183
200
  body["error"] = error
184
201
  if conversation_id:
185
202
  body["conversation_id"] = conversation_id
203
+ # A cloud run is not given a callback key; it authenticates with its API key.
204
+ api_key = os.environ.get("AUTOMATION_CALLBACK_API_KEY") or os.environ.get("OPENHANDS_API_KEY", "")
186
205
  req = urllib.request.Request(
187
206
  url,
188
207
  data=json.dumps(body).encode(),
189
208
  headers={
190
209
  "Content-Type": "application/json",
191
- "Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}",
210
+ "Authorization": f"Bearer {api_key}",
192
211
  },
193
212
  )
194
213
  try:
@@ -577,7 +596,101 @@ def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
577
596
  return secrets
578
597
 
579
598
 
580
- def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str:
599
+ def _cloud_request(method: str, path: str, body: dict | None = None):
600
+ """Call the OpenHands API with the API key this cloud run was given."""
601
+ req = urllib.request.Request(
602
+ f"{_CLOUD_API_URL}{path}",
603
+ data=json.dumps(body).encode() if body is not None else None,
604
+ headers={
605
+ "Authorization": f"Bearer {os.environ.get('OPENHANDS_API_KEY', '')}",
606
+ "Content-Type": "application/json",
607
+ },
608
+ method=method,
609
+ )
610
+ try:
611
+ with urllib.request.urlopen(req) as r:
612
+ raw = r.read()
613
+ return json.loads(raw) if raw.strip() else {}
614
+ except urllib.error.HTTPError as exc:
615
+ body_text = exc.read().decode()
616
+ raise RuntimeError(f"OpenHands API {method} {path} → {exc.code}: {body_text}") from exc
617
+
618
+
619
+ def _create_cloud_conversation(initial_message: str, title: str) -> str:
620
+ """Start a conversation through the OpenHands API and return its id.
621
+
622
+ OpenHands runs it in a sandbox of its own, which outlives this run's, with
623
+ the user's own model, MCP servers and secrets, so none of the local
624
+ forwarding in create_conversation applies. The API answers with the task
625
+ that starts the conversation, not with a started conversation.
626
+ """
627
+ conversation_id = uuid.uuid4().hex
628
+ task = _cloud_request(
629
+ "POST",
630
+ "/api/v1/app-conversations",
631
+ {
632
+ "conversation_id": conversation_id,
633
+ "title": title,
634
+ "initial_message": {
635
+ "role": "user",
636
+ "content": [{"type": "text", "text": initial_message}],
637
+ },
638
+ },
639
+ )
640
+ if task.get("status") == "ERROR":
641
+ raise RuntimeError(f"Starting a conversation failed: {task.get('detail') or 'no detail'}")
642
+ return conversation_id
643
+
644
+
645
+ def _cloud_conversation(conv_id: str) -> dict | None:
646
+ """The conversation's record, or None while OpenHands is still bringing it up."""
647
+ found = _cloud_request("GET", f"/api/v1/app-conversations?ids={conv_id}")
648
+ return found[0] if found else None
649
+
650
+
651
+ def _cloud_conversation_status(conv_id: str) -> str:
652
+ conversation = _cloud_conversation(conv_id)
653
+ if conversation is None:
654
+ return "starting"
655
+ if conversation.get("execution_status"):
656
+ return conversation["execution_status"]
657
+ # The status is only reported while the conversation's sandbox is running.
658
+ # Once it is paused, the last status the agent reported is in its events.
659
+ events = _cloud_request(
660
+ "GET",
661
+ f"/api/v1/conversation/{conv_id}/events/search"
662
+ "?kind__eq=ConversationStateUpdateEvent&sort_order=TIMESTAMP_DESC&limit=100",
663
+ )
664
+ for event in events.get("items", []):
665
+ if event.get("key") == "execution_status":
666
+ return event.get("value") or "unknown"
667
+ return "unknown"
668
+
669
+
670
+ def _cloud_conversation_final_response(conv_id: str) -> str:
671
+ """The agent's closing message, read from the conversation's stored events."""
672
+ events = _cloud_request(
673
+ "GET",
674
+ f"/api/v1/conversation/{conv_id}/events/search?sort_order=TIMESTAMP_DESC&limit=100",
675
+ )
676
+ for event in events.get("items", []):
677
+ if event.get("source") != "agent":
678
+ continue
679
+ if event.get("kind") == "ActionEvent" and event.get("tool_name") == "finish":
680
+ return (event.get("action") or {}).get("message") or ""
681
+ if event.get("kind") == "MessageEvent":
682
+ content = (event.get("llm_message") or {}).get("content") or []
683
+ return "".join(part.get("text", "") for part in content)
684
+ return ""
685
+
686
+
687
+ def create_conversation(
688
+ agent_url: str,
689
+ api_key: str,
690
+ initial_message: str,
691
+ agent: dict | None = None,
692
+ title: str = "",
693
+ ) -> str:
581
694
  """Create a conversation and return its ID.
582
695
 
583
696
  The server auto-starts the agent when initial_message is provided
@@ -586,7 +699,13 @@ def create_conversation(agent_url: str, api_key: str, initial_message: str) -> s
586
699
 
587
700
  Inherits the user's secrets (as LookupSecret references) and MCP
588
701
  server configuration so the spawned agent has the same capabilities.
702
+
703
+ On OpenHands Cloud and Enterprise the conversation is started through
704
+ the OpenHands API instead (see _create_cloud_conversation).
589
705
  """
706
+ if IS_CLOUD:
707
+ return _create_cloud_conversation(initial_message, title)
708
+
590
709
  # Use a dedicated directory for spawned conversations rather than the
591
710
  # automation run's WORKSPACE_BASE, which may be cleaned up between runs.
592
711
  workspace_base = os.environ.get("WORKSPACE_BASE", "")
@@ -597,10 +716,9 @@ def create_conversation(agent_url: str, api_key: str, initial_message: str) -> s
597
716
  workspace_dir = os.path.join(root, "slack-monitor-conversations")
598
717
  os.makedirs(workspace_dir, exist_ok=True)
599
718
 
600
- agent = _get_agent_dict(agent_url, api_key)
601
719
  payload: dict = {
602
720
  "workspace": {"working_dir": workspace_dir},
603
- "agent": agent,
721
+ "agent": agent or _get_agent_dict(agent_url, api_key),
604
722
  "initial_message": {"content": [{"text": initial_message}]},
605
723
  }
606
724
 
@@ -620,6 +738,15 @@ def create_conversation(agent_url: str, api_key: str, initial_message: str) -> s
620
738
 
621
739
  def send_to_conversation(agent_url: str, api_key: str, conv_id: str, text: str) -> None:
622
740
  """Send a user message to an existing conversation and resume the agent."""
741
+ if IS_CLOUD:
742
+ # Answers 409 while the conversation's sandbox is paused and 404 while
743
+ # OpenHands is still bringing it up; the caller logs either.
744
+ _cloud_request("POST", f"/api/v1/app-conversations/{conv_id}/send-message", {
745
+ "role": "user",
746
+ "content": [{"type": "text", "text": text}],
747
+ "run": True,
748
+ })
749
+ return
623
750
  _oh_request(agent_url, api_key, "POST", f"/api/conversations/{conv_id}/events", {
624
751
  "role": "user",
625
752
  "content": [{"text": text}],
@@ -628,17 +755,35 @@ def send_to_conversation(agent_url: str, api_key: str, conv_id: str, text: str)
628
755
 
629
756
 
630
757
  def conversation_status(agent_url: str, api_key: str, conv_id: str) -> str:
758
+ if IS_CLOUD:
759
+ return _cloud_conversation_status(conv_id)
631
760
  result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
632
761
  return result.get("execution_status", "unknown")
633
762
 
634
763
 
635
764
  def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> str:
765
+ if IS_CLOUD:
766
+ return _cloud_conversation_final_response(conv_id)
636
767
  result = _oh_request(
637
768
  agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response"
638
769
  )
639
770
  return result.get("response", "")
640
771
 
641
772
 
773
+ def _llm_provenance(profile: str, model: str) -> str:
774
+ return f"LLM profile: `{profile}` · Model: `{model}`"
775
+
776
+
777
+ def _with_llm_provenance(body: str, profile: str, model: str) -> str:
778
+ provenance = _llm_provenance(profile, model)
779
+ body = "\n".join(
780
+ line
781
+ for line in (body or "").splitlines()
782
+ if not re.fullmatch(r"LLM profile: .* · Model: .*", line.strip())
783
+ ).strip()
784
+ return f"{body}\n\n{provenance}" if body else provenance
785
+
786
+
642
787
  # ── Message filtering ──────────────────────────────────────────────────────────
643
788
 
644
789
  def _is_human_message(msg: dict, bot_user_id: str, bot_message_ts: list[str]) -> bool:
@@ -937,7 +1082,13 @@ def _process_trigger_message(
937
1082
  )
938
1083
 
939
1084
  try:
940
- conv_id = create_conversation(agent_url, api_key, initial_prompt)
1085
+ # A cloud conversation runs with the user's own settings; its model is
1086
+ # read back from the conversation once it is up.
1087
+ agent = None if IS_CLOUD else _get_agent_dict(agent_url, api_key)
1088
+ conv_id = create_conversation(
1089
+ agent_url, api_key, initial_prompt, agent=agent,
1090
+ title=f"Slack: {request_part or text}"[:80],
1091
+ )
941
1092
  conv_url = f"{openhands_url}/conversations/{conv_id}"
942
1093
 
943
1094
  now = time.time()
@@ -946,6 +1097,8 @@ def _process_trigger_message(
946
1097
  "channel_id": channel_id,
947
1098
  "thread_ts": thread_root,
948
1099
  "status": "active",
1100
+ "llm_profile": "default",
1101
+ "llm_model": (agent["llm"].get("model") if agent else None) or "unknown",
949
1102
  "last_activity": now,
950
1103
  "last_seen_reply_ts": msg_ts,
951
1104
  "reply_poll_backoff_seconds": THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
@@ -1003,6 +1156,22 @@ def _check_conversation_completion(
1003
1156
  )
1004
1157
  else:
1005
1158
  summary = final if final else "Success (no message available)."
1159
+ if IS_CLOUD:
1160
+ # The model is the conversation's own, known once OpenHands started it.
1161
+ try:
1162
+ rec["llm_model"] = (
1163
+ (_cloud_conversation(conv_id) or {}).get("llm_model") or rec.get("llm_model")
1164
+ )
1165
+ except Exception as exc:
1166
+ print(f" Warning: could not read the model for {conv_id}: {exc}")
1167
+ # The values recorded when this script started the conversation; a
1168
+ # thread follow-up reuses it, so they still describe its LLM. A record
1169
+ # written before provenance was tracked cannot name either value.
1170
+ summary = _with_llm_provenance(
1171
+ summary,
1172
+ rec.get("llm_profile") or "unknown",
1173
+ rec.get("llm_model") or "unknown",
1174
+ )
1006
1175
 
1007
1176
  ts_back = post_message(slack_token, channel_id, summary, thread_ts=thread_ts)
1008
1177
  if ts_back:
@@ -1024,6 +1193,14 @@ def _check_conversation_completion(
1024
1193
 
1025
1194
  def main() -> str | None:
1026
1195
  """Run one polling cycle. Returns the last conversation ID created, if any."""
1196
+ if IS_CLOUD and not _kv_available():
1197
+ # A cloud run starts in a fresh sandbox, so a state file would not
1198
+ # survive it and every run would start over.
1199
+ raise RuntimeError(
1200
+ "The automation KV store is required on OpenHands Cloud and Enterprise, "
1201
+ "but AUTOMATION_KV_TOKEN is not set for this run."
1202
+ )
1203
+
1027
1204
  state = load_state()
1028
1205
 
1029
1206
  agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "software-factory",
3
+ "version": "1.0.0",
4
+ "description": "Set up a GitHub software factory fast: ask only for the repositories and the ticket source (GitHub issues, Jira or Linear), create a working issue-to-PR and PR-review pipeline from catalog automations with defaults, then let the user stop or extend it step by step.",
5
+ "author": {
6
+ "name": "OpenHands",
7
+ "email": "contact@all-hands.dev"
8
+ },
9
+ "homepage": "https://github.com/OpenHands/extensions",
10
+ "repository": "https://github.com/OpenHands/extensions",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "onboarding",
14
+ "software-factory",
15
+ "github",
16
+ "automation",
17
+ "pull-request"
18
+ ]
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "software-factory",
3
+ "version": "1.0.0",
4
+ "description": "Set up a GitHub software factory fast: ask only for the repositories and the ticket source (GitHub issues, Jira or Linear), create a working issue-to-PR and PR-review pipeline from catalog automations with defaults, then let the user stop or extend it step by step.",
5
+ "author": {
6
+ "name": "OpenHands",
7
+ "email": "contact@all-hands.dev"
8
+ },
9
+ "homepage": "https://github.com/OpenHands/extensions",
10
+ "repository": "https://github.com/OpenHands/extensions",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "onboarding",
14
+ "software-factory",
15
+ "github",
16
+ "automation",
17
+ "pull-request"
18
+ ]
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "software-factory",
3
+ "version": "1.0.0",
4
+ "description": "Set up a GitHub software factory fast: ask only for the repositories and the ticket source (GitHub issues, Jira or Linear), create a working issue-to-PR and PR-review pipeline from catalog automations with defaults, then let the user stop or extend it step by step.",
5
+ "author": {
6
+ "name": "OpenHands",
7
+ "email": "contact@all-hands.dev"
8
+ },
9
+ "homepage": "https://github.com/OpenHands/extensions",
10
+ "repository": "https://github.com/OpenHands/extensions",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "onboarding",
14
+ "software-factory",
15
+ "github",
16
+ "automation",
17
+ "pull-request"
18
+ ]
19
+ }
@@ -0,0 +1,64 @@
1
+ # Software Factory
2
+
3
+ Set up a GitHub "software factory" fast, then grow it step by step. The skill
4
+ asks only for the repositories, creates a working pipeline from catalog
5
+ automations with their defaults, shows what runs, and lets the user stop there
6
+ or extend it.
7
+
8
+ ## Trigger
9
+
10
+ This skill is activated by:
11
+
12
+ - `/software-factory`
13
+ - the phrase "software factory" (only when the skill is enabled)
14
+
15
+ ## What it sets up
16
+
17
+ | Station | Catalog id | Phase |
18
+ |---|---|---|
19
+ | Issue to PR | `github-issue-to-pr`, or the Jira or Linear station | 1 |
20
+ | PR reviewer | `github-pr-reviewer` | 1 |
21
+ | AGENTS.md maintainer | `github-agents-md-maintainer` | 1 |
22
+ | Issue triage | `github-issue-triage` | add-on |
23
+ | Auto-merge watchdog | `github-delivery-watchdog` | add-on |
24
+ | Stale-CI PR closer | `github-stale-ci-pr-closer` | add-on |
25
+ | QA changes | `qa-changes` | add-on (webhooks only) |
26
+
27
+ `scripts/factory.py` (Python standard library, no LLM) does the catalog and API
28
+ work - preflight checks, bundle upload and create, in-place tune, smoke run and
29
+ enable - so Phase 1 takes four agent steps. The skill adds no automation logic of its own. Each station is the unmodified
30
+ catalog bundle from `automations/catalog/<id>/`, configured from its `setup`
31
+ block, so fixes to a station reach factories created by this skill too. A Jira
32
+ or Linear issue to PR station is created by its own skill instead.
33
+
34
+ ## Flow
35
+
36
+ 1. **Phase 1**: verify `GITHUB_PERSONAL_ACCESS_TOKEN` (asked for with the
37
+ `request_secret` tool if missing, never in chat), ask for the repositories
38
+ and the ticket source (GitHub issues, Jira or Linear),
39
+ create the three Phase 1 stations with defaults, smoke-run issue to PR and
40
+ the reviewer, and enable those that pass. The AGENTS.md maintainer is
41
+ enabled at once and opens an `AGENTS.md` pull request per repository.
42
+ 2. **Checkpoint**: show the stations, their settings and how to use the
43
+ factory. The user can stop here or pick a next step: try it with a test
44
+ issue, tune settings, or add a station.
45
+ 3. **Phase 2**: one round per pick, asking only the questions for it, then back
46
+ to the Checkpoint.
47
+
48
+ ## Requirements
49
+
50
+ - An automation backend whose `/api/automation/v1/capabilities` reports the
51
+ features each station's manifest requires (`customTarball`, plus
52
+ `agentProfiles` for issue to PR, reviewer and triage; QA needs webhook delivery).
53
+ - A GitHub token with Contents, Issues, Pull requests and Workflows read and
54
+ write on every selected repository.
55
+ - An OpenHands (not ACP) agent profile that may use that token.
56
+ - Jira tickets: a connected Atlassian Rovo server or a Jira API token. Linear
57
+ tickets: a Linear connection.
58
+
59
+ ## Scope
60
+
61
+ Code lives on GitHub. Tickets can come from GitHub issues, Jira (through the
62
+ `jira-issue-to-pr` skill) or Linear (through the `ticket-to-code-change`
63
+ skill); only the issue to PR station changes. GitLab and Bitbucket code hosts
64
+ are not part of this flow.