@openhands/extensions 0.4.2 → 0.6.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 (134) hide show
  1. package/.github/workflows/pypi-publish.yml +104 -0
  2. package/.github/workflows/tests.yml +6 -0
  3. package/.release-please-manifest.json +1 -1
  4. package/AGENTS.md +1 -1
  5. package/README.md +31 -3
  6. package/automations/catalog/slack-channel-monitor.json +2 -2
  7. package/integrations/README.md +17 -7
  8. package/integrations/catalog/airtable.json +40 -10
  9. package/integrations/catalog/apify.json +0 -2
  10. package/integrations/catalog/asana.json +38 -0
  11. package/integrations/catalog/atlassian.json +5 -8
  12. package/integrations/catalog/bitbucket.json +39 -0
  13. package/integrations/catalog/box.json +39 -0
  14. package/integrations/catalog/brave-search.json +1 -2
  15. package/integrations/catalog/browser-mcp.json +0 -3
  16. package/integrations/catalog/canva.json +35 -0
  17. package/integrations/catalog/clickhouse.json +1 -2
  18. package/integrations/catalog/clickup.json +36 -0
  19. package/integrations/catalog/cloudflare-bindings.json +5 -8
  20. package/integrations/catalog/cloudflare-browser-rendering.json +5 -8
  21. package/integrations/catalog/cloudflare-builds.json +6 -9
  22. package/integrations/catalog/cloudflare-docs.json +4 -7
  23. package/integrations/catalog/cloudflare-observability.json +5 -8
  24. package/integrations/catalog/confluence.json +38 -0
  25. package/integrations/catalog/datadog.json +34 -0
  26. package/integrations/catalog/deepwiki.json +3 -7
  27. package/integrations/catalog/discord.json +39 -0
  28. package/integrations/catalog/dropbox.json +38 -0
  29. package/integrations/catalog/elevenlabs.json +27 -28
  30. package/integrations/catalog/everything.json +0 -2
  31. package/integrations/catalog/exa.json +0 -2
  32. package/integrations/catalog/fetch.json +0 -2
  33. package/integrations/catalog/figma.json +41 -9
  34. package/integrations/catalog/filesystem.json +0 -3
  35. package/integrations/catalog/firecrawl.json +0 -2
  36. package/integrations/catalog/freshdesk.json +38 -0
  37. package/integrations/catalog/git.json +0 -3
  38. package/integrations/catalog/github.json +51 -34
  39. package/integrations/catalog/gitlab.json +38 -0
  40. package/integrations/catalog/gmail.json +38 -0
  41. package/integrations/catalog/google-calendar.json +38 -0
  42. package/integrations/catalog/google-docs.json +38 -0
  43. package/integrations/catalog/google-drive.json +38 -0
  44. package/integrations/catalog/google-sheets.json +38 -0
  45. package/integrations/catalog/hubspot.json +34 -0
  46. package/integrations/catalog/huggingface.json +1 -2
  47. package/integrations/catalog/intercom.json +39 -0
  48. package/integrations/catalog/jira.json +38 -0
  49. package/integrations/catalog/kagi.json +2 -3
  50. package/integrations/catalog/linear.json +47 -16
  51. package/integrations/catalog/mailchimp.json +38 -0
  52. package/integrations/catalog/memory.json +0 -2
  53. package/integrations/catalog/microsoft-outlook.json +38 -0
  54. package/integrations/catalog/microsoft-teams.json +38 -0
  55. package/integrations/catalog/miro.json +38 -0
  56. package/integrations/catalog/monday.json +38 -0
  57. package/integrations/catalog/mongodb.json +1 -2
  58. package/integrations/catalog/neon.json +0 -2
  59. package/integrations/catalog/netlify.json +38 -0
  60. package/integrations/catalog/notion.json +37 -10
  61. package/integrations/catalog/obsidian.json +1 -3
  62. package/integrations/catalog/okta.json +38 -0
  63. package/integrations/catalog/onedrive.json +38 -0
  64. package/integrations/catalog/ordinal.json +33 -0
  65. package/integrations/catalog/paypal.json +5 -8
  66. package/integrations/catalog/pipedrive.json +39 -0
  67. package/integrations/catalog/plaid.json +31 -0
  68. package/integrations/catalog/playwright.json +0 -2
  69. package/integrations/catalog/posthog.json +38 -0
  70. package/integrations/catalog/quickbooks.json +38 -0
  71. package/integrations/catalog/redis.json +1 -3
  72. package/integrations/catalog/resend.json +1 -2
  73. package/integrations/catalog/salesforce.json +38 -0
  74. package/integrations/catalog/sentry.json +52 -14
  75. package/integrations/catalog/sequential-thinking.json +0 -2
  76. package/integrations/catalog/servicenow.json +39 -0
  77. package/integrations/catalog/sharepoint.json +38 -0
  78. package/integrations/catalog/shopify.json +38 -0
  79. package/integrations/catalog/slack.json +49 -10
  80. package/integrations/catalog/stripe.json +44 -14
  81. package/integrations/catalog/supabase.json +36 -9
  82. package/integrations/catalog/tavily.json +0 -2
  83. package/integrations/catalog/time.json +0 -2
  84. package/integrations/catalog/trello.json +31 -0
  85. package/integrations/catalog/vercel.json +38 -0
  86. package/integrations/catalog/webflow.json +34 -0
  87. package/integrations/catalog/xero.json +38 -0
  88. package/integrations/catalog/zendesk.json +38 -0
  89. package/integrations/catalog/zoom.json +38 -0
  90. package/integrations/catalog-index.js +171 -0
  91. package/integrations/index.d.ts +26 -60
  92. package/integrations/index.js +29 -207
  93. package/marketplaces/openhands-extensions.json +14 -1
  94. package/package.json +4 -7
  95. package/plugins/cobol-modernization/README.md +3 -3
  96. package/plugins/cobol-modernization/skills/{cobol-modernization-overview → cobol-modernization}/SKILL.md +1 -1
  97. package/plugins/magic-test/skills/magic-word/SKILL.md +1 -0
  98. package/plugins/migration-scoring/README.md +1 -1
  99. package/pyproject.toml +17 -3
  100. package/python/openhands_extensions/__init__.py +24 -0
  101. package/python/openhands_extensions/_version.py +24 -0
  102. package/python/openhands_extensions/integrations.py +92 -0
  103. package/release-please-config.json +2 -1
  104. package/scripts/build-integration-catalog.mjs +45 -0
  105. package/skills/agent-canvas-environment/.plugin/plugin.json +19 -0
  106. package/skills/agent-canvas-environment/README.md +5 -0
  107. package/skills/agent-canvas-environment/SKILL.md +141 -0
  108. package/skills/github-pr-reviewer/references/state-schema.md +18 -4
  109. package/skills/github-pr-reviewer/scripts/main.py +66 -12
  110. package/skills/github-repo-monitor/references/state-schema.md +11 -4
  111. package/skills/github-repo-monitor/scripts/main.py +67 -19
  112. package/skills/github-repo-monitor/tests/test_main.py +67 -8
  113. package/skills/index.js +19 -5
  114. package/skills/openhands-api/README.md +5 -1
  115. package/skills/openhands-api/SKILL.md +74 -5
  116. package/skills/openhands-automation/SKILL.md +3 -1
  117. package/skills/openhands-automation/references/custom-automation.md +166 -0
  118. package/skills/openhands-sdk/SKILL.md +8 -2
  119. package/skills/slack-channel-monitor/README.md +9 -5
  120. package/skills/slack-channel-monitor/SKILL.md +16 -6
  121. package/skills/slack-channel-monitor/references/slack-api.md +5 -3
  122. package/skills/slack-channel-monitor/references/state-schema.md +70 -36
  123. package/skills/slack-channel-monitor/scripts/main.py +335 -63
  124. package/tests/test_catalogs.py +21 -3
  125. package/tests/test_integration_catalog_in_sync.py +204 -0
  126. package/tests/test_slack_channel_monitor.py +142 -0
  127. package/tests/test_version_alignment.py +64 -0
  128. package/integrations/logos.d.ts +0 -5
  129. package/integrations/logos.js +0 -94
  130. package/integrations/oauth-provider-catalog.js +0 -553
  131. package/integrations/oauth-provider-registration-defaults.js +0 -610
  132. /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/SKILL.md +0 -0
  133. /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/references/cics-transformation-examples.md +0 -0
  134. /package/plugins/migration-scoring/skills/{migration-scoring-overview → migration-scoring}/SKILL.md +0 -0
@@ -9,7 +9,8 @@ trigger phrase is detected it:
9
9
  3. Posts a reply in the Slack thread with a link to the conversation.
10
10
 
11
11
  On subsequent runs:
12
- - New replies in a tracked thread are forwarded to the running conversation.
12
+ - New replies in a tracked thread are forwarded only when they contain the
13
+ trigger phrase.
13
14
  - When the conversation reaches a terminal/idle state the agent's final
14
15
  response (or an error notice) is posted back to the Slack thread.
15
16
 
@@ -39,11 +40,10 @@ import urllib.request
39
40
  from datetime import datetime, timezone
40
41
  from urllib.parse import urlencode
41
42
 
42
- # ── Debug logging to a persistent file ────────────────────────────────────────
43
+ # ── Debug logging to a per-run file ───────────────────────────────────────────
43
44
  _DEBUG_LOG_PATH = os.path.join(
44
- os.path.dirname(os.path.dirname(os.path.abspath(
45
- os.environ.get("WORKSPACE_BASE", "/tmp")))),
46
- "automation-state", "slack_poller_debug.log",
45
+ os.environ.get("WORKSPACE_BASE", "/tmp"),
46
+ "slack_poller_debug.log",
47
47
  )
48
48
  os.makedirs(os.path.dirname(_DEBUG_LOG_PATH), exist_ok=True)
49
49
  _debug_log_fh = open(_DEBUG_LOG_PATH, "a")
@@ -85,6 +85,18 @@ CONTEXT_MESSAGE_LIMIT = 15
85
85
  # How far back (seconds) to look for context when creating a new conversation.
86
86
  CONTEXT_LOOKBACK_SECONDS = 3600 # 1 hour of recent messages for context
87
87
 
88
+ # Keep completed conversations available for triggered follow-up replies for a
89
+ # short inactivity window. Polling conversations.replies is intentionally throttled:
90
+ # bot-token Slack apps may have a very small per-method quota, so each thread
91
+ # carries its own exponential backoff and each automation run polls at most one
92
+ # due thread. Start hot immediately after each bot reply, then back off
93
+ # on quiet threads.
94
+ THREAD_FOLLOWUP_WATCH_SECONDS = 300
95
+ THREAD_REPLY_INITIAL_BACKOFF_SECONDS = 5
96
+ THREAD_REPLY_MAX_BACKOFF_SECONDS = 300
97
+ THREAD_REPLY_BACKOFF_MULTIPLIER = 2
98
+ MAX_THREAD_REPLY_POLLS_PER_RUN = 1
99
+
88
100
 
89
101
  # ── Stdlib helpers ─────────────────────────────────────────────────────────────
90
102
 
@@ -108,6 +120,55 @@ def get_secret(name: str) -> str:
108
120
  return r.read().decode().strip()
109
121
 
110
122
 
123
+ def _is_loopback_openhands_url(url: str) -> bool:
124
+ return url.startswith((
125
+ "http://localhost",
126
+ "https://localhost",
127
+ "http://127.0.0.1",
128
+ "https://127.0.0.1",
129
+ ))
130
+
131
+
132
+ def _ngrok_public_openhands_url() -> str | None:
133
+ try:
134
+ with urllib.request.urlopen(
135
+ "http://127.0.0.1:4040/api/tunnels", timeout=2
136
+ ) as response:
137
+ tunnels = json.loads(response.read()).get("tunnels", [])
138
+ except Exception as exc:
139
+ print(f"Could not discover ngrok public URL: {exc}")
140
+ return None
141
+
142
+ for tunnel in tunnels:
143
+ public_url = str(tunnel.get("public_url", "")).rstrip("/")
144
+ addr = str(tunnel.get("config", {}).get("addr", ""))
145
+ if (
146
+ public_url.startswith("https://")
147
+ and ("localhost:8000" in addr or "127.0.0.1:8000" in addr)
148
+ ):
149
+ return public_url
150
+
151
+ for tunnel in tunnels:
152
+ public_url = str(tunnel.get("public_url", "")).rstrip("/")
153
+ if public_url.startswith("https://"):
154
+ return public_url
155
+
156
+ return None
157
+
158
+
159
+ def resolve_openhands_url() -> str:
160
+ try:
161
+ configured = get_secret("OPENHANDS_URL").rstrip()
162
+ except Exception:
163
+ configured = ""
164
+
165
+ configured = (configured or DEFAULT_OPENHANDS_URL).rstrip("/")
166
+ if not _is_loopback_openhands_url(configured):
167
+ return configured
168
+
169
+ return _ngrok_public_openhands_url() or configured
170
+
171
+
111
172
  def fire_callback(
112
173
  status: str = "COMPLETED",
113
174
  error: str | None = None,
@@ -136,14 +197,47 @@ def fire_callback(
136
197
  print(f"Callback error (non-fatal): {exc}")
137
198
 
138
199
 
139
- # ── State management ───────────────────────────────────────────────────────────
200
+ # ── State persistence (KV store with local-file fallback) ─────────────────────
140
201
 
141
- def _state_file_path() -> str:
142
- """Derive a persistent storage path from WORKSPACE_BASE.
202
+ _KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
203
+ _KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
204
+ _STATE_KEY = "state"
143
205
 
144
- WORKSPACE_BASE = {root}/automation-runs/{run_id}
145
- State lives two levels up at {root}/automation-state/.
146
- """
206
+
207
+ def _kv_available() -> bool:
208
+ return bool(_KV_TOKEN and _KV_BASE)
209
+
210
+
211
+ def _kv_get(key: str) -> dict | None:
212
+ req = urllib.request.Request(
213
+ f"{_KV_BASE}/v1/kv/{key}",
214
+ headers={"Authorization": f"Bearer {_KV_TOKEN}"},
215
+ )
216
+ try:
217
+ with urllib.request.urlopen(req) as r:
218
+ return json.loads(r.read())["value"]
219
+ except urllib.error.HTTPError as exc:
220
+ if exc.code == 404:
221
+ return None
222
+ raise
223
+
224
+
225
+ def _kv_set(key: str, value: dict) -> None:
226
+ req = urllib.request.Request(
227
+ f"{_KV_BASE}/v1/kv/{key}",
228
+ data=json.dumps(value).encode(),
229
+ headers={
230
+ "Authorization": f"Bearer {_KV_TOKEN}",
231
+ "Content-Type": "application/json",
232
+ },
233
+ method="PUT",
234
+ )
235
+ with urllib.request.urlopen(req) as r:
236
+ r.read()
237
+
238
+
239
+ def _state_file_path() -> str:
240
+ """Derive a persistent storage path from WORKSPACE_BASE (file fallback only)."""
147
241
  workspace_base = os.environ.get("WORKSPACE_BASE", "")
148
242
  event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
149
243
  automation_id = event_payload.get("automation_id", "default")
@@ -158,22 +252,42 @@ def _state_file_path() -> str:
158
252
  return os.path.join(state_dir, f"slack_poller_{automation_id}.json")
159
253
 
160
254
 
161
- def load_state(path: str) -> dict:
162
- if os.path.exists(path):
163
- return json.load(open(path))
255
+ def _default_state() -> dict:
164
256
  return {
165
257
  "version": 1,
166
258
  "bot_user_id": None,
167
- "last_poll": {}, # channel_id → float timestamp string
168
- "conversations": {}, # conv_key → ConversationRecord (see schema docs)
169
- "bot_message_ts": [], # ts strings of messages posted by this bot
170
- "processed_ts": [], # ts strings of messages already handled (dedup)
259
+ "last_poll": {},
260
+ "conversations": {},
261
+ "bot_message_ts": [],
262
+ "processed_ts": [],
171
263
  }
172
264
 
173
265
 
174
- def save_state(path: str, state: dict) -> None:
266
+ def load_state() -> dict:
267
+ if _kv_available():
268
+ data = _kv_get(_STATE_KEY)
269
+ if data is not None:
270
+ print("State loaded from KV store")
271
+ return data
272
+ return _default_state()
273
+ path = _state_file_path()
274
+ if os.path.exists(path):
275
+ try:
276
+ return json.load(open(path))
277
+ except Exception as exc:
278
+ print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
279
+ return _default_state()
280
+
281
+
282
+ def save_state(state: dict) -> None:
283
+ if _kv_available():
284
+ _kv_set(_STATE_KEY, state)
285
+ print("State saved to KV store")
286
+ return
287
+ path = _state_file_path()
175
288
  with open(path, "w") as f:
176
289
  json.dump(state, f, indent=2)
290
+ print(f"State saved to {path}")
177
291
 
178
292
 
179
293
  # ── Slack API helpers ──────────────────────────────────────────────────────────
@@ -195,8 +309,14 @@ def _slack_call(
195
309
  }
196
310
  data = json.dumps(body).encode() if body is not None else None
197
311
  req = urllib.request.Request(url, data=data, headers=headers, method=method)
198
- with urllib.request.urlopen(req) as r:
199
- result = json.loads(r.read())
312
+ try:
313
+ with urllib.request.urlopen(req) as r:
314
+ result = json.loads(r.read())
315
+ except urllib.error.HTTPError as exc:
316
+ if exc.code == 429:
317
+ retry_after = exc.headers.get("Retry-After", "60")
318
+ raise RuntimeError(f"Slack {endpoint}: rate_limited retry_after={retry_after}") from exc
319
+ raise
200
320
  if not result.get("ok"):
201
321
  raise RuntimeError(f"Slack {endpoint}: {result.get('error', 'unknown_error')}")
202
322
  return result
@@ -243,7 +363,12 @@ def add_reaction(token: str, channel: str, ts: str, emoji: str = "eyes") -> None
243
363
 
244
364
  def post_message(token: str, channel: str, text: str, thread_ts: str | None = None) -> str:
245
365
  """Post a Slack message and return its timestamp."""
246
- body: dict = {"channel": channel, "text": text}
366
+ body: dict = {
367
+ "channel": channel,
368
+ "markdown_text": text,
369
+ "unfurl_links": False,
370
+ "unfurl_media": False,
371
+ }
247
372
  if thread_ts:
248
373
  body["thread_ts"] = thread_ts
249
374
  return slack_post(token, "chat.postMessage", body).get("ts", "")
@@ -273,6 +398,36 @@ def thread_replies(token: str, channel: str, thread_ts: str, oldest: str) -> lis
273
398
  return [m for m in messages if m.get("ts") != thread_ts]
274
399
 
275
400
 
401
+ def _trigger_index(text: str) -> int:
402
+ """Return the start index of an exact trigger phrase match, or -1."""
403
+ lowered = text.lower()
404
+ trigger = TRIGGER_PHRASE.lower()
405
+ start = 0
406
+ while True:
407
+ idx = lowered.find(trigger, start)
408
+ if idx < 0:
409
+ return -1
410
+ before = lowered[idx - 1] if idx > 0 else ""
411
+ after_idx = idx + len(trigger)
412
+ after = lowered[after_idx] if after_idx < len(lowered) else ""
413
+ before_ok = not before or not (before.isalnum() or before in "_-")
414
+ after_ok = not after or not (after.isalnum() or after in "_-")
415
+ if before_ok and after_ok:
416
+ return idx
417
+ start = idx + 1
418
+
419
+
420
+ def _has_trigger(text: str) -> bool:
421
+ return _trigger_index(text) >= 0
422
+
423
+
424
+ def _request_after_trigger(text: str) -> str:
425
+ idx = _trigger_index(text)
426
+ if idx < 0:
427
+ return text
428
+ return text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
429
+
430
+
276
431
  def full_thread_history(
277
432
  token: str, channel: str, thread_ts: str,
278
433
  bot_user_id: str, bot_message_ts: list[str],
@@ -567,14 +722,114 @@ def _gather_channel_context(
567
722
  return context_lines
568
723
 
569
724
 
725
+ def _close_thread_watch(rec: dict, conv_key: str, now: float) -> None:
726
+ rec["status"] = "closed"
727
+ rec["closed_reason"] = "followup_watch_expired"
728
+ rec["closed_at"] = now
729
+ print(f" Follow-up watch expired for {conv_key}")
730
+
731
+
732
+ def _next_reply_poll_at(rec: dict, now: float, delay: int) -> float:
733
+ next_poll_at = now + delay
734
+ watch_until = float(rec.get("watch_until") or 0)
735
+ if rec.get("status") == "watching" and watch_until:
736
+ next_poll_at = min(next_poll_at, watch_until)
737
+ return next_poll_at
738
+
739
+
740
+ def _poll_due_thread_replies(
741
+ slack_token: str,
742
+ active_convs: dict[str, dict],
743
+ bot_user_id: str,
744
+ bot_message_ts: list[str],
745
+ ) -> list[tuple[str, dict]]:
746
+ now = time.time()
747
+ due: list[tuple[float, str, dict]] = []
748
+ for conv_key, rec in active_convs.items():
749
+ if rec.get("status") not in {"active", "watching"}:
750
+ continue
751
+ next_poll = float(rec.get("next_reply_poll_at") or 0)
752
+ watch_until = float(rec.get("watch_until") or 0)
753
+ if rec.get("status") == "watching" and watch_until and now >= watch_until:
754
+ next_poll = min(next_poll or watch_until, watch_until)
755
+ if next_poll <= now:
756
+ due.append((next_poll, conv_key, rec))
757
+
758
+ reply_messages: list[tuple[str, dict]] = []
759
+ for _next_poll, conv_key, rec in sorted(due)[:MAX_THREAD_REPLY_POLLS_PER_RUN]:
760
+ cid = rec["channel_id"]
761
+ thread_ts = rec["thread_ts"]
762
+ oldest = rec.get("last_seen_reply_ts") or thread_ts
763
+ watch_until = float(rec.get("watch_until") or 0)
764
+ watch_expired = rec.get("status") == "watching" and watch_until and now >= watch_until
765
+ try:
766
+ replies = thread_replies(slack_token, cid, thread_ts, oldest)
767
+ except Exception as exc:
768
+ message = str(exc)
769
+ retry_after = THREAD_REPLY_MAX_BACKOFF_SECONDS
770
+ marker = "retry_after="
771
+ if marker in message:
772
+ raw = message.split(marker, 1)[1].split()[0]
773
+ try:
774
+ retry_after = max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, int(raw))
775
+ except ValueError:
776
+ pass
777
+ if watch_expired:
778
+ _close_thread_watch(rec, conv_key, now)
779
+ else:
780
+ rec["next_reply_poll_at"] = _next_reply_poll_at(rec, now, retry_after)
781
+ rec["reply_poll_backoff_seconds"] = min(
782
+ THREAD_REPLY_MAX_BACKOFF_SECONDS,
783
+ max(retry_after, int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)),
784
+ )
785
+ print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
786
+ continue
787
+
788
+ if replies:
789
+ rec["last_seen_reply_ts"] = max(r.get("ts", oldest) for r in replies)
790
+
791
+ human_replies = [r for r in replies if _is_human_message(r, bot_user_id, bot_message_ts)]
792
+ triggered_replies = [
793
+ r for r in human_replies
794
+ if _has_trigger(r.get("text", "") or "")
795
+ ]
796
+ if triggered_replies:
797
+ rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
798
+ rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
799
+ rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
800
+ for r in triggered_replies:
801
+ reply_messages.append((cid, r))
802
+ print(f" {conv_key}: {len(triggered_replies)} triggered follow-up reply/replies")
803
+ elif watch_expired:
804
+ if human_replies:
805
+ print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
806
+ _close_thread_watch(rec, conv_key, now)
807
+ else:
808
+ if human_replies:
809
+ print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
810
+ current = int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)
811
+ next_backoff = min(
812
+ THREAD_REPLY_MAX_BACKOFF_SECONDS,
813
+ max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, current * THREAD_REPLY_BACKOFF_MULTIPLIER),
814
+ )
815
+ rec["reply_poll_backoff_seconds"] = next_backoff
816
+ rec["next_reply_poll_at"] = _next_reply_poll_at(rec, now, next_backoff)
817
+ next_delay = max(0, int(rec["next_reply_poll_at"] - now))
818
+ print(f" {conv_key}: no follow-ups; next reply poll in {next_delay}s")
819
+
820
+ return reply_messages
821
+
822
+
570
823
  def _poll_new_messages(
571
824
  slack_token: str,
572
825
  use_search: bool,
573
826
  oldest_by_channel: dict[str, str],
574
827
  global_oldest: str,
575
828
  active_convs: dict[str, dict],
829
+ bot_user_id: str,
830
+ bot_message_ts: list[str],
576
831
  ) -> list[tuple[str, dict]]:
577
- """Collect and sort new top-level messages and thread replies from Slack."""
832
+ """Collect and sort new top-level messages and due thread replies from Slack."""
578
833
  new_messages: list[tuple[str, dict]] = []
579
834
 
580
835
  if use_search:
@@ -602,19 +857,9 @@ def _poll_new_messages(
602
857
  except Exception as exc:
603
858
  print(f" Warning: could not fetch history for {cid}: {exc}")
604
859
 
605
- reply_messages: list[tuple[str, dict]] = []
606
- for _conv_key, rec in active_convs.items():
607
- if rec.get("status") == "closed":
608
- continue
609
- cid = rec["channel_id"]
610
- thread_ts = rec["thread_ts"]
611
- oldest = oldest_by_channel.get(cid, global_oldest)
612
- try:
613
- replies = thread_replies(slack_token, cid, thread_ts, oldest)
614
- for r in replies:
615
- reply_messages.append((cid, r))
616
- except Exception as exc:
617
- print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
860
+ reply_messages = _poll_due_thread_replies(
861
+ slack_token, active_convs, bot_user_id, bot_message_ts
862
+ )
618
863
 
619
864
  return sorted(
620
865
  new_messages + reply_messages,
@@ -669,10 +914,7 @@ def _process_trigger_message(
669
914
  print(f" Warning: could not fetch thread history: {exc}")
670
915
 
671
916
  # Extract the user's request: the text that follows the trigger phrase.
672
- request_part = text
673
- idx = text.lower().find(TRIGGER_PHRASE.lower())
674
- if idx >= 0:
675
- request_part = text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
917
+ request_part = _request_after_trigger(text)
676
918
 
677
919
  initial_prompt = (
678
920
  f"You are an AI assistant responding to a Slack message.\n\n"
@@ -697,12 +939,17 @@ def _process_trigger_message(
697
939
  conv_id = create_conversation(agent_url, api_key, initial_prompt)
698
940
  conv_url = f"{openhands_url}/conversations/{conv_id}"
699
941
 
942
+ now = time.time()
700
943
  active_convs[conv_key] = {
701
944
  "conversation_id": conv_id,
702
945
  "channel_id": channel_id,
703
946
  "thread_ts": thread_root,
704
947
  "status": "active",
705
- "last_activity": time.time(),
948
+ "last_activity": now,
949
+ "last_seen_reply_ts": msg_ts,
950
+ "reply_poll_backoff_seconds": THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
951
+ "next_reply_poll_at": now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
952
+ "watch_until": now + THREAD_FOLLOWUP_WATCH_SECONDS,
706
953
  }
707
954
 
708
955
  link_text = f"🤖 On it! View progress here: {conv_url}"
@@ -760,26 +1007,30 @@ def _check_conversation_completion(
760
1007
  if ts_back:
761
1008
  bot_message_ts.append(ts_back)
762
1009
 
763
- rec["status"] = "closed"
764
- print(f" Posted summary for {conv_key}")
1010
+ now = time.time()
1011
+ rec["status"] = "watching"
1012
+ rec["last_activity"] = now
1013
+ rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
1014
+ rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
1015
+ rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
1016
+ print(
1017
+ f" Posted summary for {conv_key}; watching for follow-ups "
1018
+ f"until {rec['watch_until']:.0f}"
1019
+ )
765
1020
 
766
1021
 
767
1022
  # ── Main ───────────────────────────────────────────────────────────────────────
768
1023
 
769
1024
  def main() -> str | None:
770
1025
  """Run one polling cycle. Returns the last conversation ID created, if any."""
771
- state_path = _state_file_path()
772
- state = load_state(state_path)
1026
+ state = load_state()
773
1027
 
774
1028
  agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
775
1029
  api_key = _get_env_key()
776
1030
 
777
1031
  slack_token, token_is_user = _resolve_slack_token()
778
1032
 
779
- try:
780
- openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
781
- except Exception:
782
- openhands_url = DEFAULT_OPENHANDS_URL
1033
+ openhands_url = resolve_openhands_url()
783
1034
 
784
1035
  # Raises RuntimeError immediately if the token is invalid - no point polling.
785
1036
  bot_user_id_new, scopes = _slack_auth_test(slack_token)
@@ -808,7 +1059,8 @@ def main() -> str | None:
808
1059
  active_convs: dict[str, dict] = state.get("conversations", {})
809
1060
 
810
1061
  all_incoming = _poll_new_messages(
811
- slack_token, use_search, oldest_by_channel, global_oldest, active_convs
1062
+ slack_token, use_search, oldest_by_channel, global_oldest,
1063
+ active_convs, bot_user_id, bot_message_ts,
812
1064
  )
813
1065
 
814
1066
  print(f" all_incoming: {len(all_incoming)} message(s), "
@@ -848,14 +1100,16 @@ def main() -> str | None:
848
1100
  thread_root: str = thread_ts if thread_ts and thread_ts != msg_ts else msg_ts
849
1101
  conv_key = f"{channel_id}:{thread_root}"
850
1102
 
851
- has_trigger = TRIGGER_PHRASE.lower() in text.lower()
1103
+ has_trigger = _has_trigger(text)
852
1104
  is_thread_reply = (
853
1105
  thread_ts is not None
854
1106
  and thread_ts != msg_ts
855
1107
  )
1108
+ tracked_rec = active_convs.get(conv_key)
856
1109
  is_reply_in_tracked = (
857
1110
  is_thread_reply
858
- and conv_key in active_convs
1111
+ and tracked_rec is not None
1112
+ and tracked_rec.get("status") in {"active", "watching"}
859
1113
  )
860
1114
 
861
1115
  print(f" EVAL: ts={msg_ts} trigger={has_trigger} reply={is_thread_reply} "
@@ -863,20 +1117,39 @@ def main() -> str | None:
863
1117
  f"text={text[:60]!r}")
864
1118
 
865
1119
  # ── Case A: reply in a thread that has a tracked conversation ──────────
866
- # Route to the existing conversation regardless of its status (active or
867
- # closed). If the conversation was closed, re-activate it so the agent
868
- # processes the new message and the completion check fires again later.
1120
+ # Route to the existing conversation while it is active or in its
1121
+ # follow-up watch window, but only when the reply includes the trigger
1122
+ # phrase. Once the watch expires, a new trigger in the thread creates a
1123
+ # fresh conversation instead.
869
1124
  if is_reply_in_tracked:
870
1125
  rec = active_convs[conv_key]
871
- print(f" → Case A: Forwarding reply {msg_ts} → conversation {rec['conversation_id']}")
1126
+ rec["last_seen_reply_ts"] = max(rec.get("last_seen_reply_ts", msg_ts), msg_ts)
1127
+ if not has_trigger:
1128
+ processed_ts.add(msg_ts)
1129
+ print(f" → Case A ignored (tracked reply without trigger): {msg_ts}")
1130
+ continue
1131
+
1132
+ request_part = _request_after_trigger(text)
1133
+ print(f" → Case A: Forwarding triggered reply {msg_ts} → conversation {rec['conversation_id']}")
872
1134
  try:
873
- send_to_conversation(agent_url, api_key, rec["conversation_id"],
874
- f"User replied in Slack thread: {text}")
1135
+ send_to_conversation(
1136
+ agent_url, api_key, rec["conversation_id"],
1137
+ (
1138
+ f"User <@{msg.get('user', '?')}> replied in Slack thread.\n"
1139
+ f"The reply was activated by the trigger phrase: `{TRIGGER_PHRASE}`\n"
1140
+ f"Full reply: {text}\n"
1141
+ f"User request: {request_part or '(no explicit request — use your best judgement)'}"
1142
+ ),
1143
+ )
1144
+ now = time.time()
875
1145
  rec["status"] = "active"
876
- rec["last_activity"] = time.time()
1146
+ rec["last_activity"] = now
1147
+ rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
1148
+ rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
1149
+ rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
877
1150
  except Exception as exc:
878
1151
  print(f" Warning: failed to forward reply: {exc}")
879
- if has_trigger and can_react:
1152
+ if can_react:
880
1153
  add_reaction(slack_token, channel_id, msg_ts)
881
1154
  processed_ts.add(msg_ts)
882
1155
  continue
@@ -922,7 +1195,7 @@ def main() -> str | None:
922
1195
  print(f" last_poll set to {effective_last_poll}")
923
1196
 
924
1197
  for conv_key, rec in list(active_convs.items()):
925
- if rec.get("status") != "closed":
1198
+ if rec.get("status") == "active":
926
1199
  _check_conversation_completion(
927
1200
  conv_key, rec, agent_url, api_key, slack_token, bot_message_ts,
928
1201
  )
@@ -937,8 +1210,7 @@ def main() -> str | None:
937
1210
  state["processed_ts"] = processed_list[-MAX_PROCESSED_TS:]
938
1211
 
939
1212
  state["conversations"] = active_convs
940
- save_state(state_path, state)
941
- print(f"State saved to {state_path}")
1213
+ save_state(state)
942
1214
  return last_conversation_id
943
1215
 
944
1216
 
@@ -40,10 +40,10 @@ def test_catalog_entries_have_required_fields():
40
40
  assert entry["id"]
41
41
  assert entry["name"]
42
42
  assert entry["description"]
43
- assert entry["kind"] in {"mcp", "http"}
44
- assert entry["iconBg"]
43
+ # iconBg/iconColor are optional UI styling hints (OAuth-only entries may
44
+ # ship without a bespoke icon background).
45
+ assert "iconBg" not in entry or entry["iconBg"]
45
46
  assert entry["connectionOptions"]
46
- assert entry["defaultConnectionOptionId"]
47
47
  for option in entry["connectionOptions"]:
48
48
  assert option["id"]
49
49
  assert option["provider"] in {"mcp", "http"}
@@ -72,6 +72,24 @@ def test_catalog_entries_have_required_fields():
72
72
  assert isinstance(entry["estimatedSetupMinutes"], int)
73
73
 
74
74
 
75
+ def test_remote_no_auth_mcp_entries_are_intentionally_public():
76
+ public_remote_mcp_ids = {"cloudflare-docs", "deepwiki", "huggingface"}
77
+
78
+ actual = set()
79
+ for entry in load_catalog_entries("integrations/catalog"):
80
+ for option in entry["connectionOptions"]:
81
+ transport = option.get("transport", {})
82
+ if (
83
+ option["provider"] == "mcp"
84
+ and option["auth"]["strategy"] == "none"
85
+ and transport.get("url", "").startswith("https://")
86
+ ):
87
+ actual.add(entry["id"])
88
+ assert transport["kind"] == "shttp"
89
+
90
+ assert actual == public_remote_mcp_ids
91
+
92
+
75
93
  def test_credential_fields_have_helper_text_and_link():
76
94
  """All password fields must have helperText plus a link (either a helperLink field or a
77
95
  markdown link embedded in helperText) so users know how to get credentials."""