@openhands/extensions 0.4.2 → 0.5.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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.4.2"
2
+ ".": "0.5.0"
3
3
  }
@@ -2,10 +2,10 @@
2
2
  "id": "slack-channel-monitor",
3
3
  "name": "Slack channel monitor",
4
4
  "category": "Team communication",
5
- "description": "Watch Slack channels for @openhands mentions, open a conversation with the message context, and reply in the thread when the agent finishes.",
5
+ "description": "Watch Slack channels for @openhands mentions, open a conversation with the message context, reply when the agent finishes, and continue the same conversation from triggered Slack thread follow-ups.",
6
6
  "requiredIntegrationIds": ["slack"],
7
7
  "popularityRank": 92,
8
8
  "estimatedSetupMinutes": 7,
9
9
  "prompt": "/slack-monitor:poll",
10
- "exampleImplementation": "Trigger: cron, every minute\nRequired secret: SLACK_BOT_TOKEN or SLACK_USER_TOKEN\n\n1. Poll up to 10 configured Slack channels for new messages since the last run.\n2. Match messages containing the trigger phrase (default: @openhands).\n3. React with 👀 and start an OpenHands conversation with the message and recent channel context.\n4. Post a thread reply with a link to the conversation.\n5. Forward subsequent thread replies to the running conversation and post the agent's response when done."
10
+ "exampleImplementation": "Trigger: cron, every minute\nRequired secret: SLACK_BOT_TOKEN or SLACK_USER_TOKEN\n\n1. Poll up to 10 configured Slack channels for new messages since the last run.\n2. Match messages containing the trigger phrase (default: @openhands).\n3. React with 👀 and start an OpenHands conversation with the message and recent channel context.\n4. Post a thread reply with a link to the conversation, then post the agent response when done.\n5. Keep the thread in a short follow-up watch window and forward only replies that repeat the trigger phrase to the same conversation, with per-thread backoff for quiet threads."
11
11
  }
@@ -12,7 +12,6 @@
12
12
  "code"
13
13
  ],
14
14
  "popularityRank": 100,
15
- "installHint": "Requires Docker because the legacy npm package was deprecated upstream.",
16
15
  "kind": "mcp",
17
16
  "defaultConnectionOptionId": "api",
18
17
  "connectionOptions": [
@@ -20,31 +19,16 @@
20
19
  "id": "api",
21
20
  "provider": "mcp",
22
21
  "transport": {
23
- "kind": "stdio",
24
- "serverName": "github",
25
- "command": "docker",
26
- "args": [
27
- "run",
28
- "-i",
29
- "--rm",
30
- "-e",
31
- "GITHUB_PERSONAL_ACCESS_TOKEN",
32
- "ghcr.io/github/github-mcp-server"
33
- ],
34
- "envFields": [
35
- {
36
- "key": "GITHUB_PERSONAL_ACCESS_TOKEN",
37
- "label": "Personal access token",
38
- "type": "password",
39
- "placeholder": "github_pat_...",
40
- "required": true,
41
- "helperText": "Classic or fine-grained personal access token from GitHub settings.",
42
- "helperLink": "https://github.com/settings/tokens"
43
- }
44
- ]
22
+ "kind": "shttp",
23
+ "url": "https://api.githubcopilot.com/mcp/"
45
24
  },
46
25
  "auth": {
47
- "strategy": "api_key"
26
+ "strategy": "api_key",
27
+ "credentialLabel": "Personal access token",
28
+ "credentialPlaceholder": "github_pat_...",
29
+ "credentialHelp": "Classic or fine-grained personal access token from GitHub settings.",
30
+ "credentialSecretName": "GITHUB_PERSONAL_ACCESS_TOKEN",
31
+ "saveCredentialAsSecretByDefault": true
48
32
  }
49
33
  }
50
34
  ]
@@ -59,6 +59,8 @@ export interface IntegrationAuthConfig {
59
59
  credentialLabel?: string;
60
60
  credentialPlaceholder?: string;
61
61
  credentialHelp?: string;
62
+ credentialSecretName?: string;
63
+ saveCredentialAsSecretByDefault?: boolean;
62
64
  apiKeyHeaderName?: string;
63
65
  apiKeyOptional?: boolean;
64
66
  oauth?: IntegrationOAuthConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openhands/extensions",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "extensions"
3
- version = "0.4.2"
3
+ version = "0.5.0"
4
4
  description = "OpenHands extensions, plugins, and skills"
5
5
  requires-python = ">=3.12"
6
6
 
package/skills/index.js CHANGED
@@ -398,7 +398,7 @@ export const SKILLS_CATALOG = [
398
398
  "triggers": [
399
399
  "/slack-monitor:poll"
400
400
  ],
401
- "content": "# Slack Channel Monitor\n\nCreate a cron automation that polls up to 10 Slack channels every minute.\nWhen a message containing the **trigger phrase** (default: `@openhands`) is\ndetected it:\n\n1. Adds a 👀 reaction to the triggering message.\n2. Opens an OpenHands conversation with the message and recent channel context.\n3. Posts a reply in the Slack thread with a link to the conversation.\n\nOn every subsequent run:\n- Replies in the thread are forwarded to the running conversation.\n- When the conversation finishes (or errors), the agent's final response is\n posted back to the Slack thread.\n\n> **Local mode only.** This automation targets the local OpenHands setup\n> (`dev:automation` stack). A cloud/webhook-based variant is out of scope here.\n\n---\n\n## Prerequisites\n\n### Required secrets\n\nVerify that at least one of the following secrets is set in\n**OpenHands Settings → Secrets** before proceeding:\n\n| Secret name | Token type | Minimum scopes |\n|---|---|---|\n| `SLACK_BOT_TOKEN` | Bot (`xoxb-…`) | `channels:history`, `channels:read`, `reactions:write`, `chat:write` |\n| `SLACK_USER_TOKEN` | User (`xoxp-…`) | Same as bot, plus `search:read` for multi-channel efficiency |\n\nCheck with:\n```bash\n# For bot token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_BOT_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n\n# For user token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_USER_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n```\n\nIf neither token is present, inform the user and stop - the automation cannot\nfunction without Slack credentials.\n\n### Optional secret\n\n| Secret name | Default | Purpose |\n|---|---|---|\n| `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links posted in Slack |\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Collect channels\n\nAsk the user: *\"Which Slack channels should be monitored? You can provide\nchannel names (e.g. `#general`) or IDs (e.g. `C0123456789`).\"*\n\n**If the user provides channel names**, resolve them to IDs:\n\n```bash\nSLACK_TOKEN=\"${SLACK_BOT_TOKEN:-$SLACK_USER_TOKEN}\"\ncurl -s \"https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true\" \\\n -H \"Authorization: Bearer $SLACK_TOKEN\" \\\n | python3 -c \"\nimport json, sys\ndata = json.load(sys.stdin)\nif not data.get('ok'):\n print('ERROR:', data.get('error'))\n exit(1)\nnames = set(n.lstrip('#') for n in ['CHANNEL_NAMES_HERE'.split(',')])\nfor ch in data.get('channels', []):\n if ch['name'] in names:\n print(f\\\"{ch['name']} → {ch['id']}\\\")\n\"\n```\n\nReplace `CHANNEL_NAMES_HERE` with the comma-separated names the user provided.\n\n**If `conversations.list` returns `missing_scope` or `not_authed`:**\nInform the user: *\"The token doesn't have permission to list channels. Please\nprovide the channel IDs directly (right-click a channel in Slack → Copy link - \nthe last path segment starting with `C` is the ID).\"*\n\n**If the bot token lacks `channels:read`** for private channels, the user can\neither invite the bot first (`/invite @botname`) or switch to a user token.\n\nCollect up to 10 channel IDs. Record them as a Python list literal, e.g.:\n```python\n[\"C0123456789\", \"C9876543210\"]\n```\n\n### Step 2 - Collect trigger phrase\n\nAsk the user: *\"What trigger phrase should OpenHands respond to?\n(Press Enter to use the default: `@openhands`)\"*\n\nAccepted values: any non-empty string unlikely to appear accidentally, e.g.\n`@openhands`, `jazz hands`, `take-me-to-funky-town`.\n\n### Step 3 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory and **copy it verbatim**.\nApply exactly three constant substitutions near the top of the file:\n\n> **Do not reimplement, simplify, or hand-write a replacement script.**\n> The template already contains the correct secret-loading, state-path,\n> conversation-creation, and context-forwarding logic. Only the three\n> configuration constants below should change unless syntax validation fails.\n\n| Placeholder | Replace with |\n|---|---|\n| `TRIGGER_PHRASE = \"@openhands\"` | `TRIGGER_PHRASE = \"{user_phrase}\"` |\n| `CHANNEL_IDS: list[str] = []` | `CHANNEL_IDS: list[str] = {channel_id_list}` |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | `DEFAULT_OPENHANDS_URL = \"{url}\"` (keep default if user has no preference) |\n\nWrite the customised script to a temporary directory:\n```bash\nmkdir -p /tmp/slack-monitor-build\n# copy scripts/main.py to /tmp/slack-monitor-build/main.py\n# then replace only the three constants above\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/slack-monitor-build/main.py && echo \"Syntax OK\"\n```\n\nThen run a quick integrity check to confirm the template structure is still\npresent and only the configuration block was customised:\n```bash\ngrep -n 'TRIGGER_PHRASE = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'CHANNEL_IDS: list\\[str\\] =' /tmp/slack-monitor-build/main.py\ngrep -n 'DEFAULT_OPENHANDS_URL = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'def get_secret' /tmp/slack-monitor-build/main.py\ngrep -n 'def _state_file_path' /tmp/slack-monitor-build/main.py\ngrep -n 'def create_conversation' /tmp/slack-monitor-build/main.py\n```\n\nIf any of those checks fail, stop and re-copy the template instead of trying to\nrepair a hand-written variant.\n\n### Step 4 - Package and upload\n\nDetermine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`\nblock in your system context:\n- Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`\n- Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\nIf no Automation backend is listed in `<RUNTIME_SERVICES>`, stop and tell\nthe user to start the full automation stack.\n\n```bash\ntar -czf /tmp/slack-monitor.tar.gz -C /tmp/slack-monitor-build .\n\n# OPENHANDS_HOST: read from <RUNTIME_SERVICES> Automation backend url_from_agent\nOPENHANDS_HOST=\"<automation-url-from-runtime-services>\"\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=slack-channel-monitor\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/slack-monitor.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\nIf the upload fails with a size error, the tarball must be under 1 MB.\n`main.py` is under 15 KB so this should never trigger.\n\n### Step 5 - Create the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"Slack Channel Monitor\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"* * * * *\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 55\n }\" | python3 -m json.tool\n```\n\nA 55-second timeout keeps runs well within the 60-second cron window.\n\nRecord the returned `id` - share it with the user as confirmation.\n\n### Step 6 - Confirm\n\nTell the user:\n\n> ✅ **Slack Channel Monitor** is running!\n>\n> - Automation ID: `{id}`\n> - Channels: `{channel list}`\n> - Trigger phrase: `{phrase}`\n> - Polling every minute via cron `* * * * *`\n> - State file: `~/.openhands/workspaces/automation-state/slack_poller_{id}.json`\n>\n> Send a message containing `{phrase}` in any monitored channel to test it.\n> The bot will react with 👀 and reply with a link to the new conversation.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which runs **10 polling iterations** (every\n5 seconds) within the 55-second timeout window. Each iteration:\n\n1. **Loads state** from the JSON file (see `references/state-schema.md`).\n2. **Resolves the Slack token** - checks `SLACK_USER_TOKEN` then `SLACK_BOT_TOKEN`.\n3. **Fetches new messages:**\n - User token + `search:read` + > 1 channel → single `search.messages` call\n (searches for the trigger phrase across all channels).\n - Otherwise → one `conversations.history` call per channel.\n4. **Fetches thread replies** - one `conversations.replies` call per tracked thread.\n5. **Processes messages** in chronological order:\n - Skips messages already in `processed_ts` (dedup across the overlap window).\n - Skips bot messages and any `ts` in `bot_message_ts`.\n - Reply in a tracked thread (active **or** closed) → forwards to the existing\n conversation; re-opens closed conversations automatically.\n - Contains trigger phrase → 👀 reaction, create conversation, post link.\n - Thread replies: agent receives full thread history for context.\n - Root messages: agent receives the trigger text only.\n6. **Checks conversation statuses** - for each active conversation where\n `time.time() - last_activity > 15 s`:\n - If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's\n final response via `/api/conversations/{id}/agent_final_response` and post\n it to the Slack thread. Mark conversation `closed`.\n7. **Advances `last_poll`** to `now - 10 s` (overlap window prevents boundary\n races). If a conversation creation failed, pins `last_poll` further back to\n retry on the next iteration.\n8. **Saves state** (including `processed_ts`) and continues to the next iteration.\n9. After all iterations, fires the completion callback.\n\nDebug output is written to both stdout and a persistent log at:\n```\n{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log\n```\n\n---\n\n## Additional Resources\n\n### Reference Files\n\n- **`references/slack-api.md`** - Slack token types, required scopes, API\n endpoint reference, rate limits, and common error codes.\n- **`references/state-schema.md`** - State JSON schema, field definitions,\n example file, and conversation lifecycle diagram.\n\n### Script Template\n\n- **`scripts/main.py`** - The complete automation script. Customise the three\n constants at the top (`TRIGGER_PHRASE`, `CHANNEL_IDS`, `DEFAULT_OPENHANDS_URL`)\n before packaging.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot doesn't react to messages | Token missing or bot not in channel | Verify token with `auth.test`; `/invite @botname` |\n| `not_in_channel` error in run logs | Bot token used but bot not a member | Invite bot or switch to user token |\n| `missing_scope` error | Token lacks required scopes | Re-install Slack app with correct scopes (see `references/slack-api.md`) |\n| No messages detected | `last_poll` timestamp is in the future | Delete the state file to reset; it will be recreated on next run |\n| Conversation link 404 | `OPENHANDS_URL` points to wrong host | Set the `OPENHANDS_URL` secret to the correct base URL |\n| Summary never posted | Conversation stuck in `running` state | Check conversation in the OpenHands UI; the agent may need intervention |\n| Duplicate conversations created | `processed_ts` state missing or corrupted | Delete the state file to reset; dedup will rebuild on next run |\n| Trigger message processed on each cron run | State file deleted between runs | Ensure `automation-state/` directory is persistent across runs |\n| Debug info needed | Need detailed per-message trace | Check `{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log` |"
401
+ "content": "# Slack Channel Monitor\n\nCreate a cron automation that polls up to 10 Slack channels every minute.\nWhen a message containing the **trigger phrase** (default: `@openhands`) is\ndetected it:\n\n1. Adds a 👀 reaction to the triggering message.\n2. Opens an OpenHands conversation with the message and recent channel context.\n3. Posts a reply in the Slack thread with a link to the conversation.\n\nOn every subsequent run:\n- New Slack thread replies are forwarded only when they contain the trigger\n phrase, so unrelated conversation in the thread is ignored.\n- When the conversation finishes (or errors), the agent's final response is\n posted back to the Slack thread.\n- Completed conversations stay in a short follow-up watch window, allowing\n triggered Slack replies to continue the same OpenHands conversation.\n\n> **Local mode only.** This automation targets the local OpenHands setup\n> (`dev:automation` stack). A cloud/webhook-based variant is out of scope here.\n\n---\n\n## Prerequisites\n\n### Required secrets\n\nVerify that at least one of the following secrets is set in\n**OpenHands Settings → Secrets** before proceeding:\n\n| Secret name | Token type | Minimum scopes |\n|---|---|---|\n| `SLACK_BOT_TOKEN` | Bot (`xoxb-…`) | `channels:history`, `channels:read`, `reactions:write`, `chat:write` |\n| `SLACK_USER_TOKEN` | User (`xoxp-…`) | Same as bot, plus `search:read` for multi-channel efficiency |\n\nCheck with:\n```bash\n# For bot token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_BOT_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n\n# For user token:\ncurl -s https://slack.com/api/auth.test -H \"Authorization: Bearer $SLACK_USER_TOKEN\" \\\n | python3 -c \"import json,sys; d=json.load(sys.stdin); print('ok' if d.get('ok') else d.get('error'))\"\n```\n\nIf neither token is present, inform the user and stop - the automation cannot\nfunction without Slack credentials.\n\n### Optional secret\n\n| Secret name | Default | Purpose |\n|---|---|---|\n| `OPENHANDS_URL` | `http://localhost:8000` | Base URL used to build conversation links posted in Slack |\n\n---\n\n## Setup Workflow\n\nFollow these steps in order.\n\n### Step 1 - Collect channels\n\nAsk the user: *\"Which Slack channels should be monitored? You can provide\nchannel names (e.g. `#general`) or IDs (e.g. `C0123456789`).\"*\n\n**If the user provides channel names**, resolve them to IDs:\n\n```bash\nSLACK_TOKEN=\"${SLACK_BOT_TOKEN:-$SLACK_USER_TOKEN}\"\ncurl -s \"https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true\" \\\n -H \"Authorization: Bearer $SLACK_TOKEN\" \\\n | python3 -c \"\nimport json, sys\ndata = json.load(sys.stdin)\nif not data.get('ok'):\n print('ERROR:', data.get('error'))\n exit(1)\nnames = set(n.lstrip('#') for n in ['CHANNEL_NAMES_HERE'.split(',')])\nfor ch in data.get('channels', []):\n if ch['name'] in names:\n print(f\\\"{ch['name']} → {ch['id']}\\\")\n\"\n```\n\nReplace `CHANNEL_NAMES_HERE` with the comma-separated names the user provided.\n\n**If `conversations.list` returns `missing_scope` or `not_authed`:**\nInform the user: *\"The token doesn't have permission to list channels. Please\nprovide the channel IDs directly (right-click a channel in Slack → Copy link - \nthe last path segment starting with `C` is the ID).\"*\n\n**If the bot token lacks `channels:read`** for private channels, the user can\neither invite the bot first (`/invite @botname`) or switch to a user token.\n\nCollect up to 10 channel IDs. Record them as a Python list literal, e.g.:\n```python\n[\"C0123456789\", \"C9876543210\"]\n```\n\n### Step 2 - Collect trigger phrase\n\nAsk the user: *\"What trigger phrase should OpenHands respond to?\n(Press Enter to use the default: `@openhands`)\"*\n\nAccepted values: any non-empty string unlikely to appear accidentally, e.g.\n`@openhands`, `jazz hands`, `take-me-to-funky-town`.\n\n### Step 3 - Generate the automation script\n\nRead `scripts/main.py` from this skill's directory and **copy it verbatim**.\nApply exactly three constant substitutions near the top of the file:\n\n> **Do not reimplement, simplify, or hand-write a replacement script.**\n> The template already contains the correct secret-loading, state-path,\n> conversation-creation, and context-forwarding logic. Only the three\n> configuration constants below should change unless syntax validation fails.\n\n| Placeholder | Replace with |\n|---|---|\n| `TRIGGER_PHRASE = \"@openhands\"` | `TRIGGER_PHRASE = \"{user_phrase}\"` |\n| `CHANNEL_IDS: list[str] = []` | `CHANNEL_IDS: list[str] = {channel_id_list}` |\n| `DEFAULT_OPENHANDS_URL = \"http://localhost:8000\"` | `DEFAULT_OPENHANDS_URL = \"{url}\"` (keep default if user has no preference) |\n\nWrite the customised script to a temporary directory:\n```bash\nmkdir -p /tmp/slack-monitor-build\n# copy scripts/main.py to /tmp/slack-monitor-build/main.py\n# then replace only the three constants above\n```\n\nValidate syntax before packaging:\n```bash\npython3 -m py_compile /tmp/slack-monitor-build/main.py && echo \"Syntax OK\"\n```\n\nThen run a quick integrity check to confirm the template structure is still\npresent and only the configuration block was customised:\n```bash\ngrep -n 'TRIGGER_PHRASE = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'CHANNEL_IDS: list\\[str\\] =' /tmp/slack-monitor-build/main.py\ngrep -n 'DEFAULT_OPENHANDS_URL = \"' /tmp/slack-monitor-build/main.py\ngrep -n 'def get_secret' /tmp/slack-monitor-build/main.py\ngrep -n 'def _state_file_path' /tmp/slack-monitor-build/main.py\ngrep -n 'def create_conversation' /tmp/slack-monitor-build/main.py\n```\n\nIf any of those checks fail, stop and re-copy the template instead of trying to\nrepair a hand-written variant.\n\n### Step 4 - Package and upload\n\nDetermine the Automation backend URL and auth from the `<RUNTIME_SERVICES>`\nblock in your system context:\n- Use the **Automation backend** `url_from_agent` as `OPENHANDS_HOST`\n- Auth: `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY`\n\nIf no Automation backend is listed in `<RUNTIME_SERVICES>`, stop and tell\nthe user to start the full automation stack.\n\n```bash\ntar -czf /tmp/slack-monitor.tar.gz -C /tmp/slack-monitor-build .\n\n# OPENHANDS_HOST: read from <RUNTIME_SERVICES> Automation backend url_from_agent\nOPENHANDS_HOST=\"<automation-url-from-runtime-services>\"\n\nTARBALL_PATH=$(curl -s -X POST \\\n \"${OPENHANDS_HOST}/api/automation/v1/uploads?name=slack-channel-monitor\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/gzip\" \\\n --data-binary @/tmp/slack-monitor.tar.gz \\\n | python3 -c \"import json,sys; print(json.load(sys.stdin)['tarball_path'])\")\n\necho \"Uploaded: $TARBALL_PATH\"\n```\n\nIf the upload fails with a size error, the tarball must be under 1 MB.\n`main.py` is under 15 KB so this should never trigger.\n\n### Step 5 - Create the automation\n\n```bash\ncurl -s -X POST \"${OPENHANDS_HOST}/api/automation/v1\" \\\n -H \"X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d \"{\n \\\"name\\\": \\\"Slack Channel Monitor\\\",\n \\\"trigger\\\": {\\\"type\\\": \\\"cron\\\", \\\"schedule\\\": \\\"* * * * *\\\"},\n \\\"tarball_path\\\": \\\"$TARBALL_PATH\\\",\n \\\"entrypoint\\\": \\\"python3 main.py\\\",\n \\\"timeout\\\": 55\n }\" | python3 -m json.tool\n```\n\nA 55-second timeout keeps runs well within the 60-second cron window.\n\nRecord the returned `id` - share it with the user as confirmation.\n\n### Step 6 - Confirm\n\nTell the user:\n\n> ✅ **Slack Channel Monitor** is running!\n>\n> - Automation ID: `{id}`\n> - Channels: `{channel list}`\n> - Trigger phrase: `{phrase}`\n> - Polling every minute via cron `* * * * *`\n> - State file: `~/.openhands/workspaces/automation-state/slack_poller_{id}.json`\n>\n> Send a message containing `{phrase}` in any monitored channel to test it.\n> The bot will react with 👀 and reply with a link to the new conversation.\n\n---\n\n## Runtime Behaviour (per poll)\n\nEach cron run executes `main.py`, which runs **10 polling iterations** (every\n5 seconds) within the 55-second timeout window. Each iteration:\n\n1. **Loads state** from the JSON file (see `references/state-schema.md`).\n2. **Resolves the Slack token** - checks `SLACK_USER_TOKEN` then `SLACK_BOT_TOKEN`.\n3. **Fetches new messages:**\n - User token + `search:read` + > 1 channel → single `search.messages` call\n (searches for the trigger phrase across all channels).\n - Otherwise → one `conversations.history` call per channel.\n4. **Fetches due thread replies** - polls at most one tracked thread per\n iteration using per-thread exponential backoff to stay within Slack rate\n limits.\n5. **Processes messages** in chronological order:\n - Skips messages already in `processed_ts` (dedup across the overlap window).\n - Skips bot messages and any `ts` in `bot_message_ts`.\n - Reply in a tracked thread whose text contains the trigger phrase → forwards\n a follow-up request to the existing conversation and resets the follow-up\n watch window. Replies without the trigger phrase are marked processed and\n ignored.\n - Contains trigger phrase outside a tracked conversation → 👀 reaction, create\n a new conversation, post link.\n - Thread replies: agent receives full thread history for context.\n - Root messages: agent receives the trigger text only.\n6. **Checks conversation statuses** - for each active conversation where\n `time.time() - last_activity > 15 s`:\n - If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's\n final response via `/api/conversations/{id}/agent_final_response` and post\n it to the Slack thread. Mark the record `watching` for five minutes so\n triggered follow-up replies can continue the same conversation.\n7. **Advances `last_poll`** to `now - 10 s` (overlap window prevents boundary\n races). If a conversation creation failed, pins `last_poll` further back to\n retry on the next iteration.\n8. **Saves state** (including `processed_ts`) and continues to the next iteration.\n9. After all iterations, fires the completion callback.\n\nDebug output is written to both stdout and a persistent log at:\n```\n{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log\n```\n\n---\n\n## Additional Resources\n\n### Reference Files\n\n- **`references/slack-api.md`** - Slack token types, required scopes, API\n endpoint reference, rate limits, and common error codes.\n- **`references/state-schema.md`** - State JSON schema, field definitions,\n example file, and conversation lifecycle diagram.\n\n### Script Template\n\n- **`scripts/main.py`** - The complete automation script. Customise the three\n constants at the top (`TRIGGER_PHRASE`, `CHANNEL_IDS`, `DEFAULT_OPENHANDS_URL`)\n before packaging.\n\n---\n\n## Troubleshooting\n\n| Symptom | Likely cause | Fix |\n|---|---|---|\n| Bot doesn't react to messages | Token missing or bot not in channel | Verify token with `auth.test`; `/invite @botname` |\n| `not_in_channel` error in run logs | Bot token used but bot not a member | Invite bot or switch to user token |\n| `missing_scope` error | Token lacks required scopes | Re-install Slack app with correct scopes (see `references/slack-api.md`) |\n| No messages detected | `last_poll` timestamp is in the future | Delete the state file to reset; it will be recreated on next run |\n| Conversation link 404 | `OPENHANDS_URL` points to wrong host | Set the `OPENHANDS_URL` secret to the correct base URL |\n| Summary never posted | Conversation stuck in `running` state | Check conversation in the OpenHands UI; the agent may need intervention |\n| Duplicate conversations created | `processed_ts` state missing or corrupted | Delete the state file to reset; dedup will rebuild on next run |\n| Trigger message processed on each cron run | State file deleted between runs | Ensure `automation-state/` directory is persistent across runs |\n| Debug info needed | Need detailed per-message trace | Check `{WORKSPACE_BASE_ROOT}/automation-state/slack_poller_debug.log` |"
402
402
  },
403
403
  {
404
404
  "name": "slack-standup-digest",
@@ -27,8 +27,10 @@ This skill is activated by keywords:
27
27
  - **Efficient polling**: single `search.messages` call for multi-channel user
28
28
  tokens with `search:read`; falls back to one `conversations.history` call
29
29
  per channel for bot tokens
30
- - **Thread tracking**: new replies in a triggered thread are forwarded to the
31
- running OpenHands conversation
30
+ - **Triggered follow-ups**: Slack thread replies that repeat the trigger phrase
31
+ are forwarded to the existing OpenHands conversation
32
+ - **Watch window**: completed conversations keep watching their Slack thread for
33
+ short follow-up periods, with quiet threads backed off automatically
32
34
  - **Reaction acknowledgement**: adds a 👀 to every message containing the
33
35
  trigger phrase
34
36
  - **Conversation link**: posts a link to the new conversation in the Slack
@@ -79,9 +81,11 @@ Each cron run (every minute):
79
81
  3. Creates an OpenHands conversation with the message and recent channel
80
82
  context as the initial prompt; posts a link to the conversation in the
81
83
  Slack thread
82
- 4. Forwards new replies in tracked threads to the running conversation
84
+ 4. Forwards triggered replies in tracked threads to the existing conversation,
85
+ while ignoring replies that do not contain the trigger phrase
83
86
  5. Checks active conversations - posts the agent's final response back to
84
- Slack when the conversation completes
87
+ Slack when the conversation completes, then watches briefly for triggered
88
+ follow-up replies
85
89
 
86
90
  ## See Also
87
91
 
@@ -23,9 +23,12 @@ detected it:
23
23
  3. Posts a reply in the Slack thread with a link to the conversation.
24
24
 
25
25
  On every subsequent run:
26
- - Replies in the thread are forwarded to the running conversation.
26
+ - New Slack thread replies are forwarded only when they contain the trigger
27
+ phrase, so unrelated conversation in the thread is ignored.
27
28
  - When the conversation finishes (or errors), the agent's final response is
28
29
  posted back to the Slack thread.
30
+ - Completed conversations stay in a short follow-up watch window, allowing
31
+ triggered Slack replies to continue the same OpenHands conversation.
29
32
 
30
33
  > **Local mode only.** This automation targets the local OpenHands setup
31
34
  > (`dev:automation` stack). A cloud/webhook-based variant is out of scope here.
@@ -235,20 +238,26 @@ Each cron run executes `main.py`, which runs **10 polling iterations** (every
235
238
  - User token + `search:read` + > 1 channel → single `search.messages` call
236
239
  (searches for the trigger phrase across all channels).
237
240
  - Otherwise → one `conversations.history` call per channel.
238
- 4. **Fetches thread replies** - one `conversations.replies` call per tracked thread.
241
+ 4. **Fetches due thread replies** - polls at most one tracked thread per
242
+ iteration using per-thread exponential backoff to stay within Slack rate
243
+ limits.
239
244
  5. **Processes messages** in chronological order:
240
245
  - Skips messages already in `processed_ts` (dedup across the overlap window).
241
246
  - Skips bot messages and any `ts` in `bot_message_ts`.
242
- - Reply in a tracked thread (active **or** closed) → forwards to the existing
243
- conversation; re-opens closed conversations automatically.
244
- - Contains trigger phrase → 👀 reaction, create conversation, post link.
247
+ - Reply in a tracked thread whose text contains the trigger phrase → forwards
248
+ a follow-up request to the existing conversation and resets the follow-up
249
+ watch window. Replies without the trigger phrase are marked processed and
250
+ ignored.
251
+ - Contains trigger phrase outside a tracked conversation → 👀 reaction, create
252
+ a new conversation, post link.
245
253
  - Thread replies: agent receives full thread history for context.
246
254
  - Root messages: agent receives the trigger text only.
247
255
  6. **Checks conversation statuses** - for each active conversation where
248
256
  `time.time() - last_activity > 15 s`:
249
257
  - If status is `idle`, `finished`, `error`, or `stuck` → fetch the agent's
250
258
  final response via `/api/conversations/{id}/agent_final_response` and post
251
- it to the Slack thread. Mark conversation `closed`.
259
+ it to the Slack thread. Mark the record `watching` for five minutes so
260
+ triggered follow-up replies can continue the same conversation.
252
261
  7. **Advances `last_poll`** to `now - 10 s` (overlap window prevents boundary
253
262
  races). If a conversation creation failed, pins `last_poll` further back to
254
263
  retry on the next iteration.
@@ -198,10 +198,12 @@ With < 10 channels polled every minute:
198
198
  | Method | Tier | Calls/min | Headroom |
199
199
  |--------|------|-----------|---------|
200
200
  | `conversations.history` | Tier 3 | ≤ 10 | Comfortable |
201
- | `conversations.replies` | Tier 3 | ≤ active threads | Fine unless hundreds of threads |
201
+ | `conversations.replies` | Tier 3 | ≤ 1 due tracked thread per iteration | Throttled with per-thread backoff |
202
202
  | `search.messages` | Tier 2 | 1 | Fine |
203
203
  | `reactions.add` | Tier 2 | ≤ triggers/min | Fine |
204
204
  | `chat.postMessage` | Tier 3 | ≤ triggers + summaries | Fine |
205
205
 
206
- No rate-limit handling is implemented in the script. If you hit limits the
207
- run will fail and retry on the next cron tick.
206
+ The script surfaces Slack HTTP 429 responses with `Retry-After`. Thread reply
207
+ polling catches that signal, delays the affected thread, and increases its
208
+ per-thread backoff. Other Slack methods still fail fast so the next cron tick can
209
+ retry with the persisted state.
@@ -52,7 +52,7 @@ variable (field `automation_id`).
52
52
 
53
53
  ## `conversations` Map
54
54
 
55
- Key: `"{channel_id}:{thread_root_ts}"` - uniquely identifies a Slack thread.
55
+ Key: `"{channel_id}:{thread_root_ts}"` - uniquely identifies a Slack thread.
56
56
 
57
57
  Value: **ConversationRecord**
58
58
 
@@ -61,14 +61,25 @@ Value: **ConversationRecord**
61
61
  // Required fields
62
62
  "conversation_id": "550e8400-e29b-41d4-a716-446655440000",
63
63
  // OpenHands conversation UUID
64
- "channel_id": "C0123456789", // Slack channel ID
64
+ "channel_id": "C0123456789", // Slack channel ID
65
65
  "thread_ts": "1716576000.000100",
66
66
  // Slack thread root timestamp
67
67
  // (= msg_ts for top-level trigger messages)
68
- "status": "active", // "active" | "closed"
69
- "last_activity": 1716576060.0, // float Unix timestamp of the last time the
70
- // script sent a message to this conversation
71
- // (creation time, or time a reply was forwarded)
68
+ "status": "active", // "active" | "watching" | "closed"
69
+ "last_activity": 1716576060.0, // float Unix timestamp of the last time the
70
+ // script created, resumed, or summarized the
71
+ // OpenHands conversation
72
+
73
+ // Follow-up polling fields
74
+ "last_seen_reply_ts": "1716576000.000100",
75
+ // newest Slack reply ts observed for this thread
76
+ "reply_poll_backoff_seconds": 5, // current per-thread conversations.replies backoff
77
+ "next_reply_poll_at": 1716576065.0,// next Unix timestamp when replies may be polled
78
+ "watch_until": 1716576360.0, // follow-up watch expiry for completed conversations
79
+
80
+ // Present after expiry
81
+ "closed_reason": "followup_watch_expired",
82
+ "closed_at": 1716576360.0
72
83
  }
73
84
  ```
74
85
 
@@ -76,13 +87,13 @@ Value: **ConversationRecord**
76
87
 
77
88
  | Value | Meaning |
78
89
  |-------|---------|
79
- | `active` | Conversation is running or awaiting more input; replies will be forwarded |
80
- | `closed` | Summary has been posted to Slack; no further processing |
90
+ | `active` | Conversation is running or awaiting completion; triggered replies are forwarded to it |
91
+ | `watching` | A summary has been posted and the thread remains open briefly for triggered follow-ups |
92
+ | `closed` | The follow-up watch expired; future triggered replies create a new conversation |
81
93
 
82
- Closed conversations are retained in the map indefinitely (the map stays small
83
- since there are < 10 channels and the trigger rate is typically low). If the
84
- map grows unexpectedly, closed entries older than a configurable TTL can be
85
- pruned.
94
+ `watching` records use `watch_until` to avoid keeping every completed thread hot
95
+ forever. Quiet watched threads use exponential backoff for `conversations.replies`
96
+ polling, and each automation iteration polls at most one due thread.
86
97
 
87
98
  ---
88
99
 
@@ -109,9 +120,9 @@ exactly `now`, messages near the boundary are re-fetched on the next iteration.
109
120
  re-fetched messages from being processed twice (e.g., triggering a duplicate
110
121
  conversation or forwarding the same reply multiple times).
111
122
 
112
- Entries are added when a message is either skipped (not human, already handled)
113
- or successfully processed (trigger detected and conversation created, or reply
114
- forwarded).
123
+ Entries are added when a message is either skipped (not human, already handled,
124
+ or an untriggered reply in a watched thread) or successfully processed (trigger
125
+ detected and conversation created, or triggered reply forwarded).
115
126
 
116
127
  ---
117
128
 
@@ -119,25 +130,33 @@ forwarded).
119
130
 
120
131
  ```
121
132
  [trigger detected]
122
- │
123
- ▼
133
+ |
134
+ v
124
135
  status = "active"
125
136
  last_activity = now
126
- │
127
- (next run or later runs)
128
- │
129
- ┌─────┴──────────────────────────────────────────┐
130
- │ User sends a reply in the thread │
131
- │ → send_to_conversation() called │
132
- │ → last_activity = now │
133
- └─────────────────────────────────────────────────┘
134
- │
137
+ follow-up poll fields initialized
138
+ |
139
+ | triggered Slack reply while active
140
+ | -> send_to_conversation()
141
+ | -> last_activity = now
142
+ | -> watch window/backoff reset
143
+ |
135
144
  (when time.time() - last_activity > DONE_DEBOUNCE
136
- AND conversation_status ∈ {idle, finished, error, stuck})
137
- │
138
- ▼
145
+ AND conversation_status in {idle, finished, error, stuck})
146
+ |
147
+ v
139
148
  Post summary to Slack thread
149
+ status = "watching"
150
+ watch_until = now + THREAD_FOLLOWUP_WATCH_SECONDS
151
+ |
152
+ | triggered Slack reply before watch_until
153
+ | -> send_to_conversation()
154
+ | -> status = "active"
155
+ |
156
+ | no triggered reply before watch_until
157
+ v
140
158
  status = "closed"
159
+ closed_reason = "followup_watch_expired"
141
160
  ```
142
161
 
143
162
  ---
@@ -158,14 +177,22 @@ forwarded).
158
177
  "channel_id": "C0123456789",
159
178
  "thread_ts": "1716575900.000100",
160
179
  "status": "active",
161
- "last_activity": 1716575902.3
180
+ "last_activity": 1716575902.3,
181
+ "last_seen_reply_ts": "1716575900.000100",
182
+ "reply_poll_backoff_seconds": 5,
183
+ "next_reply_poll_at": 1716575907.3,
184
+ "watch_until": 1716576202.3
162
185
  },
163
186
  "C9876543210:1716570000.000500": {
164
187
  "conversation_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
165
188
  "channel_id": "C9876543210",
166
189
  "thread_ts": "1716570000.000500",
167
- "status": "closed",
168
- "last_activity": 1716572100.0
190
+ "status": "watching",
191
+ "last_activity": 1716572100.0,
192
+ "last_seen_reply_ts": "1716572050.000100",
193
+ "reply_poll_backoff_seconds": 20,
194
+ "next_reply_poll_at": 1716572120.0,
195
+ "watch_until": 1716572400.0
169
196
  }
170
197
  },
171
198
  "bot_message_ts": [
@@ -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
 
@@ -85,6 +86,18 @@ CONTEXT_MESSAGE_LIMIT = 15
85
86
  # How far back (seconds) to look for context when creating a new conversation.
86
87
  CONTEXT_LOOKBACK_SECONDS = 3600 # 1 hour of recent messages for context
87
88
 
89
+ # Keep completed conversations available for triggered follow-up replies for a
90
+ # short inactivity window. Polling conversations.replies is intentionally throttled:
91
+ # bot-token Slack apps may have a very small per-method quota, so each thread
92
+ # carries its own exponential backoff and each automation run polls at most one
93
+ # due thread. Start hot immediately after each bot reply, then back off
94
+ # on quiet threads.
95
+ THREAD_FOLLOWUP_WATCH_SECONDS = 300
96
+ THREAD_REPLY_INITIAL_BACKOFF_SECONDS = 5
97
+ THREAD_REPLY_MAX_BACKOFF_SECONDS = 300
98
+ THREAD_REPLY_BACKOFF_MULTIPLIER = 2
99
+ MAX_THREAD_REPLY_POLLS_PER_RUN = 1
100
+
88
101
 
89
102
  # ── Stdlib helpers ─────────────────────────────────────────────────────────────
90
103
 
@@ -108,6 +121,55 @@ def get_secret(name: str) -> str:
108
121
  return r.read().decode().strip()
109
122
 
110
123
 
124
+ def _is_loopback_openhands_url(url: str) -> bool:
125
+ return url.startswith((
126
+ "http://localhost",
127
+ "https://localhost",
128
+ "http://127.0.0.1",
129
+ "https://127.0.0.1",
130
+ ))
131
+
132
+
133
+ def _ngrok_public_openhands_url() -> str | None:
134
+ try:
135
+ with urllib.request.urlopen(
136
+ "http://127.0.0.1:4040/api/tunnels", timeout=2
137
+ ) as response:
138
+ tunnels = json.loads(response.read()).get("tunnels", [])
139
+ except Exception as exc:
140
+ print(f"Could not discover ngrok public URL: {exc}")
141
+ return None
142
+
143
+ for tunnel in tunnels:
144
+ public_url = str(tunnel.get("public_url", "")).rstrip("/")
145
+ addr = str(tunnel.get("config", {}).get("addr", ""))
146
+ if (
147
+ public_url.startswith("https://")
148
+ and ("localhost:8000" in addr or "127.0.0.1:8000" in addr)
149
+ ):
150
+ return public_url
151
+
152
+ for tunnel in tunnels:
153
+ public_url = str(tunnel.get("public_url", "")).rstrip("/")
154
+ if public_url.startswith("https://"):
155
+ return public_url
156
+
157
+ return None
158
+
159
+
160
+ def resolve_openhands_url() -> str:
161
+ try:
162
+ configured = get_secret("OPENHANDS_URL").rstrip()
163
+ except Exception:
164
+ configured = ""
165
+
166
+ configured = (configured or DEFAULT_OPENHANDS_URL).rstrip("/")
167
+ if not _is_loopback_openhands_url(configured):
168
+ return configured
169
+
170
+ return _ngrok_public_openhands_url() or configured
171
+
172
+
111
173
  def fire_callback(
112
174
  status: str = "COMPLETED",
113
175
  error: str | None = None,
@@ -195,8 +257,14 @@ def _slack_call(
195
257
  }
196
258
  data = json.dumps(body).encode() if body is not None else None
197
259
  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())
260
+ try:
261
+ with urllib.request.urlopen(req) as r:
262
+ result = json.loads(r.read())
263
+ except urllib.error.HTTPError as exc:
264
+ if exc.code == 429:
265
+ retry_after = exc.headers.get("Retry-After", "60")
266
+ raise RuntimeError(f"Slack {endpoint}: rate_limited retry_after={retry_after}") from exc
267
+ raise
200
268
  if not result.get("ok"):
201
269
  raise RuntimeError(f"Slack {endpoint}: {result.get('error', 'unknown_error')}")
202
270
  return result
@@ -273,6 +341,36 @@ def thread_replies(token: str, channel: str, thread_ts: str, oldest: str) -> lis
273
341
  return [m for m in messages if m.get("ts") != thread_ts]
274
342
 
275
343
 
344
+ def _trigger_index(text: str) -> int:
345
+ """Return the start index of an exact trigger phrase match, or -1."""
346
+ lowered = text.lower()
347
+ trigger = TRIGGER_PHRASE.lower()
348
+ start = 0
349
+ while True:
350
+ idx = lowered.find(trigger, start)
351
+ if idx < 0:
352
+ return -1
353
+ before = lowered[idx - 1] if idx > 0 else ""
354
+ after_idx = idx + len(trigger)
355
+ after = lowered[after_idx] if after_idx < len(lowered) else ""
356
+ before_ok = not before or not (before.isalnum() or before in "_-")
357
+ after_ok = not after or not (after.isalnum() or after in "_-")
358
+ if before_ok and after_ok:
359
+ return idx
360
+ start = idx + 1
361
+
362
+
363
+ def _has_trigger(text: str) -> bool:
364
+ return _trigger_index(text) >= 0
365
+
366
+
367
+ def _request_after_trigger(text: str) -> str:
368
+ idx = _trigger_index(text)
369
+ if idx < 0:
370
+ return text
371
+ return text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
372
+
373
+
276
374
  def full_thread_history(
277
375
  token: str, channel: str, thread_ts: str,
278
376
  bot_user_id: str, bot_message_ts: list[str],
@@ -567,14 +665,99 @@ def _gather_channel_context(
567
665
  return context_lines
568
666
 
569
667
 
668
+ def _expire_inactive_thread_watches(active_convs: dict[str, dict], now: float) -> None:
669
+ for conv_key, rec in active_convs.items():
670
+ if rec.get("status") != "watching":
671
+ continue
672
+ watch_until = float(rec.get("watch_until") or 0)
673
+ if watch_until and now >= watch_until:
674
+ rec["status"] = "closed"
675
+ rec["closed_reason"] = "followup_watch_expired"
676
+ rec["closed_at"] = now
677
+ print(f" Follow-up watch expired for {conv_key}")
678
+
679
+
680
+ def _poll_due_thread_replies(
681
+ slack_token: str,
682
+ active_convs: dict[str, dict],
683
+ bot_user_id: str,
684
+ bot_message_ts: list[str],
685
+ ) -> list[tuple[str, dict]]:
686
+ now = time.time()
687
+ _expire_inactive_thread_watches(active_convs, now)
688
+ due: list[tuple[float, str, dict]] = []
689
+ for conv_key, rec in active_convs.items():
690
+ if rec.get("status") not in {"active", "watching"}:
691
+ continue
692
+ next_poll = float(rec.get("next_reply_poll_at") or 0)
693
+ if next_poll <= now:
694
+ due.append((next_poll, conv_key, rec))
695
+
696
+ reply_messages: list[tuple[str, dict]] = []
697
+ for _next_poll, conv_key, rec in sorted(due)[:MAX_THREAD_REPLY_POLLS_PER_RUN]:
698
+ cid = rec["channel_id"]
699
+ thread_ts = rec["thread_ts"]
700
+ oldest = rec.get("last_seen_reply_ts") or thread_ts
701
+ try:
702
+ replies = thread_replies(slack_token, cid, thread_ts, oldest)
703
+ except Exception as exc:
704
+ message = str(exc)
705
+ retry_after = THREAD_REPLY_MAX_BACKOFF_SECONDS
706
+ marker = "retry_after="
707
+ if marker in message:
708
+ raw = message.split(marker, 1)[1].split()[0]
709
+ try:
710
+ retry_after = max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, int(raw))
711
+ except ValueError:
712
+ pass
713
+ rec["next_reply_poll_at"] = now + retry_after
714
+ rec["reply_poll_backoff_seconds"] = min(
715
+ THREAD_REPLY_MAX_BACKOFF_SECONDS,
716
+ max(retry_after, int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)),
717
+ )
718
+ print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
719
+ continue
720
+
721
+ if replies:
722
+ rec["last_seen_reply_ts"] = max(r.get("ts", oldest) for r in replies)
723
+
724
+ human_replies = [r for r in replies if _is_human_message(r, bot_user_id, bot_message_ts)]
725
+ triggered_replies = [
726
+ r for r in human_replies
727
+ if _has_trigger(r.get("text", "") or "")
728
+ ]
729
+ if triggered_replies:
730
+ rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
731
+ rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
732
+ rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
733
+ for r in triggered_replies:
734
+ reply_messages.append((cid, r))
735
+ print(f" {conv_key}: {len(triggered_replies)} triggered follow-up reply/replies")
736
+ else:
737
+ if human_replies:
738
+ print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
739
+ current = int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)
740
+ next_backoff = min(
741
+ THREAD_REPLY_MAX_BACKOFF_SECONDS,
742
+ max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, current * THREAD_REPLY_BACKOFF_MULTIPLIER),
743
+ )
744
+ rec["reply_poll_backoff_seconds"] = next_backoff
745
+ rec["next_reply_poll_at"] = now + next_backoff
746
+ print(f" {conv_key}: no follow-ups; next reply poll in {next_backoff}s")
747
+
748
+ return reply_messages
749
+
750
+
570
751
  def _poll_new_messages(
571
752
  slack_token: str,
572
753
  use_search: bool,
573
754
  oldest_by_channel: dict[str, str],
574
755
  global_oldest: str,
575
756
  active_convs: dict[str, dict],
757
+ bot_user_id: str,
758
+ bot_message_ts: list[str],
576
759
  ) -> list[tuple[str, dict]]:
577
- """Collect and sort new top-level messages and thread replies from Slack."""
760
+ """Collect and sort new top-level messages and due thread replies from Slack."""
578
761
  new_messages: list[tuple[str, dict]] = []
579
762
 
580
763
  if use_search:
@@ -602,19 +785,9 @@ def _poll_new_messages(
602
785
  except Exception as exc:
603
786
  print(f" Warning: could not fetch history for {cid}: {exc}")
604
787
 
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}")
788
+ reply_messages = _poll_due_thread_replies(
789
+ slack_token, active_convs, bot_user_id, bot_message_ts
790
+ )
618
791
 
619
792
  return sorted(
620
793
  new_messages + reply_messages,
@@ -669,10 +842,7 @@ def _process_trigger_message(
669
842
  print(f" Warning: could not fetch thread history: {exc}")
670
843
 
671
844
  # 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(" :–—")
845
+ request_part = _request_after_trigger(text)
676
846
 
677
847
  initial_prompt = (
678
848
  f"You are an AI assistant responding to a Slack message.\n\n"
@@ -697,12 +867,17 @@ def _process_trigger_message(
697
867
  conv_id = create_conversation(agent_url, api_key, initial_prompt)
698
868
  conv_url = f"{openhands_url}/conversations/{conv_id}"
699
869
 
870
+ now = time.time()
700
871
  active_convs[conv_key] = {
701
872
  "conversation_id": conv_id,
702
873
  "channel_id": channel_id,
703
874
  "thread_ts": thread_root,
704
875
  "status": "active",
705
- "last_activity": time.time(),
876
+ "last_activity": now,
877
+ "last_seen_reply_ts": msg_ts,
878
+ "reply_poll_backoff_seconds": THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
879
+ "next_reply_poll_at": now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
880
+ "watch_until": now + THREAD_FOLLOWUP_WATCH_SECONDS,
706
881
  }
707
882
 
708
883
  link_text = f"🤖 On it! View progress here: {conv_url}"
@@ -760,8 +935,16 @@ def _check_conversation_completion(
760
935
  if ts_back:
761
936
  bot_message_ts.append(ts_back)
762
937
 
763
- rec["status"] = "closed"
764
- print(f" Posted summary for {conv_key}")
938
+ now = time.time()
939
+ rec["status"] = "watching"
940
+ rec["last_activity"] = now
941
+ rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
942
+ rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
943
+ rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
944
+ print(
945
+ f" Posted summary for {conv_key}; watching for follow-ups "
946
+ f"until {rec['watch_until']:.0f}"
947
+ )
765
948
 
766
949
 
767
950
  # ── Main ───────────────────────────────────────────────────────────────────────
@@ -776,10 +959,7 @@ def main() -> str | None:
776
959
 
777
960
  slack_token, token_is_user = _resolve_slack_token()
778
961
 
779
- try:
780
- openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
781
- except Exception:
782
- openhands_url = DEFAULT_OPENHANDS_URL
962
+ openhands_url = resolve_openhands_url()
783
963
 
784
964
  # Raises RuntimeError immediately if the token is invalid - no point polling.
785
965
  bot_user_id_new, scopes = _slack_auth_test(slack_token)
@@ -808,7 +988,8 @@ def main() -> str | None:
808
988
  active_convs: dict[str, dict] = state.get("conversations", {})
809
989
 
810
990
  all_incoming = _poll_new_messages(
811
- slack_token, use_search, oldest_by_channel, global_oldest, active_convs
991
+ slack_token, use_search, oldest_by_channel, global_oldest,
992
+ active_convs, bot_user_id, bot_message_ts,
812
993
  )
813
994
 
814
995
  print(f" all_incoming: {len(all_incoming)} message(s), "
@@ -848,14 +1029,16 @@ def main() -> str | None:
848
1029
  thread_root: str = thread_ts if thread_ts and thread_ts != msg_ts else msg_ts
849
1030
  conv_key = f"{channel_id}:{thread_root}"
850
1031
 
851
- has_trigger = TRIGGER_PHRASE.lower() in text.lower()
1032
+ has_trigger = _has_trigger(text)
852
1033
  is_thread_reply = (
853
1034
  thread_ts is not None
854
1035
  and thread_ts != msg_ts
855
1036
  )
1037
+ tracked_rec = active_convs.get(conv_key)
856
1038
  is_reply_in_tracked = (
857
1039
  is_thread_reply
858
- and conv_key in active_convs
1040
+ and tracked_rec is not None
1041
+ and tracked_rec.get("status") in {"active", "watching"}
859
1042
  )
860
1043
 
861
1044
  print(f" EVAL: ts={msg_ts} trigger={has_trigger} reply={is_thread_reply} "
@@ -863,20 +1046,39 @@ def main() -> str | None:
863
1046
  f"text={text[:60]!r}")
864
1047
 
865
1048
  # ── 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.
1049
+ # Route to the existing conversation while it is active or in its
1050
+ # follow-up watch window, but only when the reply includes the trigger
1051
+ # phrase. Once the watch expires, a new trigger in the thread creates a
1052
+ # fresh conversation instead.
869
1053
  if is_reply_in_tracked:
870
1054
  rec = active_convs[conv_key]
871
- print(f" → Case A: Forwarding reply {msg_ts} → conversation {rec['conversation_id']}")
1055
+ rec["last_seen_reply_ts"] = max(rec.get("last_seen_reply_ts", msg_ts), msg_ts)
1056
+ if not has_trigger:
1057
+ processed_ts.add(msg_ts)
1058
+ print(f" → Case A ignored (tracked reply without trigger): {msg_ts}")
1059
+ continue
1060
+
1061
+ request_part = _request_after_trigger(text)
1062
+ print(f" → Case A: Forwarding triggered reply {msg_ts} → conversation {rec['conversation_id']}")
872
1063
  try:
873
- send_to_conversation(agent_url, api_key, rec["conversation_id"],
874
- f"User replied in Slack thread: {text}")
1064
+ send_to_conversation(
1065
+ agent_url, api_key, rec["conversation_id"],
1066
+ (
1067
+ f"User <@{msg.get('user', '?')}> replied in Slack thread.\n"
1068
+ f"The reply was activated by the trigger phrase: `{TRIGGER_PHRASE}`\n"
1069
+ f"Full reply: {text}\n"
1070
+ f"User request: {request_part or '(no explicit request — use your best judgement)'}"
1071
+ ),
1072
+ )
1073
+ now = time.time()
875
1074
  rec["status"] = "active"
876
- rec["last_activity"] = time.time()
1075
+ rec["last_activity"] = now
1076
+ rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
1077
+ rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
1078
+ rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
877
1079
  except Exception as exc:
878
1080
  print(f" Warning: failed to forward reply: {exc}")
879
- if has_trigger and can_react:
1081
+ if can_react:
880
1082
  add_reaction(slack_token, channel_id, msg_ts)
881
1083
  processed_ts.add(msg_ts)
882
1084
  continue
@@ -922,7 +1124,7 @@ def main() -> str | None:
922
1124
  print(f" last_poll set to {effective_last_poll}")
923
1125
 
924
1126
  for conv_key, rec in list(active_convs.items()):
925
- if rec.get("status") != "closed":
1127
+ if rec.get("status") == "active":
926
1128
  _check_conversation_completion(
927
1129
  conv_key, rec, agent_url, api_key, slack_token, bot_message_ts,
928
1130
  )