@openhands/extensions 0.30.0 → 0.32.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 (47) hide show
  1. package/.agents/skills/custom-codereview-guide.md +3 -4
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.codex-plugin/marketplace.json +14 -0
  4. package/.github/workflows/pr-artifacts.yml +4 -83
  5. package/.github/workflows/tests.yml +13 -0
  6. package/.plugin/marketplace.json +14 -0
  7. package/.release-please-manifest.json +1 -1
  8. package/AGENTS.md +4 -3
  9. package/README.md +3 -2
  10. package/automations/README.md +9 -2
  11. package/automations/bundle-index.js +1 -1
  12. package/automations/catalog/github-pr-reviewer/manifest.json +2 -2
  13. package/automations/catalog/incident-retrospective-drafter/manifest.json +4 -0
  14. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +3 -0
  15. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +3 -0
  16. package/automations/catalog/jira-issue-to-pr/manifest.json +3 -0
  17. package/automations/catalog/linear-issue-to-bitbucket-pr/manifest.json +4 -0
  18. package/automations/catalog/linear-issue-to-github-pr/manifest.json +4 -0
  19. package/automations/catalog/linear-issue-to-gitlab-mr/manifest.json +4 -0
  20. package/automations/catalog/linear-triage-assistant/manifest.json +4 -0
  21. package/automations/catalog/research-brief-writer/manifest.json +3 -0
  22. package/automations/catalog/slack-channel-monitor/manifest.json +3 -0
  23. package/automations/catalog/slack-standup-digest/manifest.json +3 -0
  24. package/automations/catalog.schema.json +18 -1
  25. package/automations/index.d.ts +8 -0
  26. package/marketplaces/openhands-extensions.json +14 -0
  27. package/package.json +1 -1
  28. package/pyproject.toml +1 -1
  29. package/python/openhands_extensions/_version.py +1 -1
  30. package/skills/bitbucket-data-center/SKILL.md +26 -9
  31. package/skills/github-pr-reviewer/README.md +6 -0
  32. package/skills/github-pr-reviewer/SKILL.md +7 -0
  33. package/skills/github-stale-ci-pr-closer/scripts/worker.py +8 -2
  34. package/skills/index.js +15 -4
  35. package/skills/slack-channel-monitor/README.md +6 -2
  36. package/skills/slack-channel-monitor/SKILL.md +29 -17
  37. package/skills/slack-channel-monitor/references/windows.md +6 -2
  38. package/skills/slack-channel-monitor/scripts/main.py +156 -11
  39. package/skills/software-factory/.claude-plugin/plugin.json +19 -0
  40. package/skills/software-factory/.codex-plugin/plugin.json +19 -0
  41. package/skills/software-factory/.plugin/plugin.json +19 -0
  42. package/skills/software-factory/README.md +64 -0
  43. package/skills/software-factory/SKILL.md +229 -0
  44. package/skills/software-factory/commands/software-factory.md +8 -0
  45. package/skills/software-factory/scripts/factory.py +630 -0
  46. package/tests/test_github_stale_ci_pr_closer.py +52 -1
  47. package/tests/test_software_factory_script.py +231 -0
@@ -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
@@ -253,6 +261,10 @@ Each cron run executes `main.py`, which runs **10 polling iterations** (every
253
261
  a new conversation, post link.
254
262
  - Records `default` and the concrete model from the server settings already
255
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.
256
268
  - Thread replies: agent receives full thread history for context.
257
269
  - Root messages: agent receives the trigger text only.
258
270
  6. **Checks conversation statuses** - for each active conversation where
@@ -301,7 +313,7 @@ Debug output is written to both stdout and a persistent log at:
301
313
  | `not_in_channel` error in run logs | Bot token used but bot not a member | Invite bot or switch to user token |
302
314
  | `missing_scope` error | Token lacks required scopes | Re-install Slack app with correct scopes (see `references/slack-api.md`) |
303
315
  | No messages detected | `last_poll` timestamp is in the future | Delete the state file to reset; it will be recreated on next run |
304
- | 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 |
305
317
  | Summary never posted | Conversation stuck in `running` state | Check conversation in the OpenHands UI; the agent may need intervention |
306
318
  | Duplicate conversations created | `processed_ts` state missing or corrupted | Delete the state file to reset; dedup will rebuild on next run |
307
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,7 +30,8 @@ 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
  """
@@ -38,6 +43,7 @@ import sys
38
43
  import time
39
44
  import urllib.error
40
45
  import urllib.request
46
+ import uuid
41
47
  from datetime import datetime, timezone
42
48
  from urllib.parse import urlencode
43
49
 
@@ -101,6 +107,12 @@ MAX_THREAD_REPLY_POLLS_PER_RUN = 1
101
107
 
102
108
  # ── Stdlib helpers ─────────────────────────────────────────────────────────────
103
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
+
104
116
  def _get_env_key() -> str:
105
117
  return (
106
118
  os.environ.get("SESSION_API_KEY")
@@ -110,13 +122,14 @@ def _get_env_key() -> str:
110
122
 
111
123
 
112
124
  def get_secret(name: str) -> str:
113
- """Fetch a named secret from the agent server."""
114
- url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
125
+ """Fetch a named secret from the agent server, or from the OpenHands API on cloud."""
115
126
  key = _get_env_key()
116
- req = urllib.request.Request(
117
- f"{url}/api/settings/secrets/{name}",
118
- headers={"X-Session-API-Key": key},
119
- )
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})
120
133
  with urllib.request.urlopen(req) as r:
121
134
  return r.read().decode().strip()
122
135
 
@@ -158,6 +171,9 @@ def _ngrok_public_openhands_url() -> str | None:
158
171
 
159
172
 
160
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"
161
177
  try:
162
178
  configured = get_secret("OPENHANDS_URL").rstrip()
163
179
  except Exception:
@@ -184,12 +200,14 @@ def fire_callback(
184
200
  body["error"] = error
185
201
  if conversation_id:
186
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", "")
187
205
  req = urllib.request.Request(
188
206
  url,
189
207
  data=json.dumps(body).encode(),
190
208
  headers={
191
209
  "Content-Type": "application/json",
192
- "Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}",
210
+ "Authorization": f"Bearer {api_key}",
193
211
  },
194
212
  )
195
213
  try:
@@ -578,11 +596,100 @@ def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
578
596
  return secrets
579
597
 
580
598
 
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
+
581
687
  def create_conversation(
582
688
  agent_url: str,
583
689
  api_key: str,
584
690
  initial_message: str,
585
691
  agent: dict | None = None,
692
+ title: str = "",
586
693
  ) -> str:
587
694
  """Create a conversation and return its ID.
588
695
 
@@ -592,7 +699,13 @@ def create_conversation(
592
699
 
593
700
  Inherits the user's secrets (as LookupSecret references) and MCP
594
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).
595
705
  """
706
+ if IS_CLOUD:
707
+ return _create_cloud_conversation(initial_message, title)
708
+
596
709
  # Use a dedicated directory for spawned conversations rather than the
597
710
  # automation run's WORKSPACE_BASE, which may be cleaned up between runs.
598
711
  workspace_base = os.environ.get("WORKSPACE_BASE", "")
@@ -625,6 +738,15 @@ def create_conversation(
625
738
 
626
739
  def send_to_conversation(agent_url: str, api_key: str, conv_id: str, text: str) -> None:
627
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
628
750
  _oh_request(agent_url, api_key, "POST", f"/api/conversations/{conv_id}/events", {
629
751
  "role": "user",
630
752
  "content": [{"text": text}],
@@ -633,11 +755,15 @@ def send_to_conversation(agent_url: str, api_key: str, conv_id: str, text: str)
633
755
 
634
756
 
635
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)
636
760
  result = _oh_request(agent_url, api_key, "GET", f"/api/conversations/{conv_id}")
637
761
  return result.get("execution_status", "unknown")
638
762
 
639
763
 
640
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)
641
767
  result = _oh_request(
642
768
  agent_url, api_key, "GET", f"/api/conversations/{conv_id}/agent_final_response"
643
769
  )
@@ -956,9 +1082,12 @@ def _process_trigger_message(
956
1082
  )
957
1083
 
958
1084
  try:
959
- agent = _get_agent_dict(agent_url, api_key)
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)
960
1088
  conv_id = create_conversation(
961
- agent_url, api_key, initial_prompt, agent=agent
1089
+ agent_url, api_key, initial_prompt, agent=agent,
1090
+ title=f"Slack: {request_part or text}"[:80],
962
1091
  )
963
1092
  conv_url = f"{openhands_url}/conversations/{conv_id}"
964
1093
 
@@ -969,7 +1098,7 @@ def _process_trigger_message(
969
1098
  "thread_ts": thread_root,
970
1099
  "status": "active",
971
1100
  "llm_profile": "default",
972
- "llm_model": agent["llm"].get("model") or "unknown",
1101
+ "llm_model": (agent["llm"].get("model") if agent else None) or "unknown",
973
1102
  "last_activity": now,
974
1103
  "last_seen_reply_ts": msg_ts,
975
1104
  "reply_poll_backoff_seconds": THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
@@ -1027,6 +1156,14 @@ def _check_conversation_completion(
1027
1156
  )
1028
1157
  else:
1029
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}")
1030
1167
  # The values recorded when this script started the conversation; a
1031
1168
  # thread follow-up reuses it, so they still describe its LLM. A record
1032
1169
  # written before provenance was tracked cannot name either value.
@@ -1056,6 +1193,14 @@ def _check_conversation_completion(
1056
1193
 
1057
1194
  def main() -> str | None:
1058
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
+
1059
1204
  state = load_state()
1060
1205
 
1061
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.