From 2ba2b64387d7d6dd57f6886cf7feb7e70c900d29 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Mon, 8 Jun 2026 15:26:42 -0500 Subject: [PATCH 1/5] docs: add Slack module design (manage TaskYou from Slack) From TaskYou task #3723. Proposes modules/slack/, modeled on modules/linear/linear-poll.mjs: watch a Slack channel/DM/@mention, classify intent, drive ty, post replies, and tail notifications.jsonl to push task.blocked/completed back to the channel. Enabled via SLACK_ENABLED. Separates the chat-control bridge (recommended) from the harder hosted/remote-MCP path (deferred to the credential-proxy work). Co-Authored-By: Claude Opus 4.8 --- docs/plans/2026-06-08-slack-module-design.md | 161 +++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/plans/2026-06-08-slack-module-design.md diff --git a/docs/plans/2026-06-08-slack-module-design.md b/docs/plans/2026-06-08-slack-module-design.md new file mode 100644 index 0000000..9872964 --- /dev/null +++ b/docs/plans/2026-06-08-slack-module-design.md @@ -0,0 +1,161 @@ +# Slack module — manage TaskYou from Slack + +> Status: design / recommendation (not yet implemented) +> Origin: TaskYou task #3723 — "Explore taskyou MCP sidecar extension for Slack" +> TL;DR: Add a **`modules/slack/`** integration that mirrors `modules/linear/` — +> watch a Slack channel/DM/@mention, classify intent, drive `ty`, post replies, +> and push `task.blocked`/`task.completed` notifications back. We've already +> built this exact pattern twice (the Linear poller here, and `ty-email` in the +> workflow repo), so this is mostly copy-adapt-configure. + +--- + +## 1. What prompted this + +A Slack thread in `#product`: Bruno asks the cloud "Claude in Slack" integration +whether it can reach the TaskYou agents server. It can't — + +> "that's a separate integration running in your Slack workspace — not something +> I can access from this remote execution environment." + +So from Slack there's no way to see, create, or unblock TaskYou tasks. This doc +asks whether TaskYou-OS should close that gap, and how. + +## 2. Prior art — we've built this twice already + +| What exists | Where | Relevance | +|---|---|---| +| **Linear poller** (`linear-poll.mjs`) | `modules/linear/` (this repo) | **The template.** Polls Linear for `@agent` comments → `ty create/execute` → routes to projects by issue label → **posts diffs/results back** as Linear comments. State in `.linear-poll-state.json`, `LINEAR_TOKEN` auth, `.auth-failed` graceful degradation. A Slack module is a near-exact analog. | +| **`notifications.jsonl`** + hooks (`templates/hooks/task.{blocked,completed,started}.tmpl`) | this repo | The push substrate. Each hook appends a JSON event the GM already tails. A Slack module tails the same file → `chat.postMessage`. | +| **`ty-email` sidecar** (PR #371/#385) | `bborn/taskyou → extensions/ty-email` | The standalone-Go version of the same idea: email → LLM classify intent → `ty` CLI → reply/notify. A clean alternative template (adapter + classifier + bridge + state). | +| Module convention | `config.env` flags (`LINEAR_ENABLED`, `R2_ENABLED`, `GITHUB_REPOS`, `NONO_ENABLED`) | Modules ship in-repo and toggle via env. A Slack module follows suit with `SLACK_ENABLED`. | + +There is **no** existing Slack control surface. The only "Slack" in the workflow +repo is marketing copy (PR #433 / task #1172), not an integration. So this is +low-risk: the architecture is proven, only the adapter is new. + +## 3. Two readings of "Slack integration" + +The screenshot depicts the *harder* one; we should ship the easier, higher-value +one first. + +### Pattern A — chat → TaskYou control bridge — RECOMMENDED + +DM or @-mention a bot; it classifies intent, drives `ty`, replies in-thread, and +pings you when a task needs input or finishes. The literal analog of both the +Linear poller and `ty-email`. + +``` +Slack user ──@mention/DM──▶ Slack (Socket Mode / Events API) + │ + ▼ + ┌──────────────┐ + │ modules/slack│──▶ LLM classify intent + │ (poll/socket)──▶ ty CLI (or ty serve API) + │ │──▶ chat.postMessage (reply) + └──────────────┘ + ▲ + notifications.jsonl (task.blocked / completed) ──┘ push +``` + +### Pattern B — hosted/remote MCP endpoint (what the screenshot literally shows) + +The cloud "Claude in Slack" wants to call `taskyou_*` MCP tools directly, but +TaskYou's MCP server (`internal/mcp/server.go` in the workflow repo) is +**stdio-only** — spawned per-task by Claude Code via `.mcp.json` +(`ty mcp-server --task-id`), not network-reachable. Letting a remote Claude use +it needs a **network-exposed, authenticated MCP server** (HTTP/SSE) wrapping the +`ty serve` REST API. This overlaps directly with the **credential-proxy design** +(`docs/plans/2026-03-11-credential-proxy-design.md`) and the Cloudflare Code Mode +MCP review (workflow PR #402). Defer — it's an infra/security project, not a +module. + +## 4. Why Pattern A is cheap: every piece exists + +1. **A working channel-bridge template** — `modules/linear/linear-poll.mjs` already + does detect → `ty create` → route by label → `ty execute` → post results back. + Swap "Linear comment" for "Slack message" and the Linear CLI for + `chat.postMessage`. +2. **Intent classification** is solved in `ty-email/internal/classifier` (direct + Claude API call, no permission prompts). Reuse the approach. +3. **Action surface** — the `ty` CLI (what both precedents use) or the `ty serve` + HTTP API (`/api/tasks`, `/tasks/{id}/input`, `/execute`, `/logs`, `/stream` + SSE, `/board`) if the module runs off-box from the daemon. +4. **Push is already aggregated** — `notifications.jsonl` is a single tail-able + stream of "needs attention" events. Tail it → `chat.postMessage`. "Ping me in + Slack when a task blocks/finishes" comes almost free, and the GM keeps using + the same file. + +Genuinely new code: a Slack **adapter** (Socket Mode or Events API + +`chat.postMessage`), thread↔task state (mirror `.linear-poll-state.json`), and a +config block. + +## 5. Proposed shape + +``` +modules/slack/ + slack-poll.mjs # mirrors linear-poll.mjs: ingest → ty → reply/notify + AGENTS.md # how agents should talk to Slack (like linear-cli/AGENTS.md) + .slack-poll-state.json # thread_ts ↔ task_id, processed ids, pending tasks +``` + +`config.env` additions (mirroring the Linear block): + +```bash +# === Optional: Slack integration === +# Set SLACK_ENABLED=true to manage TaskYou from Slack +# SLACK_ENABLED="true" +# SLACK_MODE="socket" # socket (no public URL) | events (needs HTTPS) +# SLACK_BOT_TOKEN="xoxb-..." # chat:write, app_mentions:read, im:history +# SLACK_APP_TOKEN="xapp-..." # socket mode +# SLACK_SIGNING_SECRET="..." # events mode +# SLACK_NOTIFY_CHANNEL="#taskyou" # where blocked/completed pings go +# SLACK_ALLOWED_USERS="U012ABC,U034DEF" # only these Slack user IDs may drive ty +# SLACK_PROJECT_MAP='{"#eng":"workflow","#content":"content"}' # channel → project +``` + +### Interaction examples + +- `@taskyou fix the checkout 500s and run it` → creates + executes a task, replies + in-thread with the task id and a board link. +- Task blocks → module reads `task.blocked` from `notifications.jsonl` → posts + *"Task #312 needs input: which migration strategy?"* to the channel. Reply + in-thread → routed to `ty input 312 …`. +- `@taskyou what's happening with 312?` → posts recent `ty output 312`. + +## 6. Security (same model as the Linear module) + +- **Allowlist by Slack user ID** (`SLACK_ALLOWED_USERS`) — channel membership is + not enough to create/execute tasks. +- **Verify authenticity** — Slack signing-secret check (Events API) or Socket + Mode's authenticated socket; never act on unverified payloads. +- **No code execution from chat** — the LLM only *classifies*; the module only + calls `ty` subcommands. Pair with `nono` (`NONO_ENABLED`) for executor + credential isolation. +- **Local secrets** — bot/app tokens in `config.env` (like `LINEAR_TOKEN`), never + sent to the LLM. `.auth-failed`-style degradation keeps polling alive when + tokens lapse, as the Linear module already does. + +## 7. Recommendation + +1. **Build Pattern A as `modules/slack/slack-poll.mjs`**, closely modeled on + `modules/linear/linear-poll.mjs`, enabled via `SLACK_ENABLED=true`, tailing + `notifications.jsonl` for push. +2. **Defer Pattern B** (network-exposed MCP for cloud Claude-in-Slack) to a + separate spike built on the credential-proxy design + workflow PR #402. +3. The **"ping me in Slack"** half can ship immediately: extend the + `task.blocked`/`task.completed` hook templates (or a tiny tail of + `notifications.jsonl`) to POST to a Slack incoming webhook — independent of the + full bridge. + +## 8. Open questions for Bruno + +- **Shared bot vs per-user:** one workspace bot (multi-user routing + per-user + allowlists become the main design work) vs. a personal bot per operator like + `ty-email`? +- **Socket Mode vs Events API:** Socket Mode needs no public URL (simplest on the + exe.dev VM / agent server); Events API suits an always-on shared bot but needs + an HTTPS endpoint. +- **Also pursue Pattern B?** Do we want the *existing* cloud Claude-in-Slack to + call `taskyou_*` tools directly (remote MCP), or is a dedicated TaskYou bot + enough? From 09bc98676e21ae896ab7eb9a2a7a6128f28b64b3 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 9 Jun 2026 07:07:45 -0500 Subject: [PATCH 2/5] docs: reconcile Slack design with in-flight channels work (#28/#31/#32) notifications.jsonl survives as the push substrate, but the channels refactor changes three things: copy taskyou-channel.ts (bun/TS) instead of linear-poll.mjs; reuse assigned_gm (#32 + workflow#561) for per-GM routing (answers the shared-bot vs per-user question); adopt the IS_LOCAL/SSH runRemote pattern + OS-aware hooks dir (#31). Adds a dependencies section and merge order (#28 -> #31/#32 -> Slack). Co-Authored-By: Claude Opus 4.8 --- docs/plans/2026-06-08-slack-module-design.md | 70 +++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/docs/plans/2026-06-08-slack-module-design.md b/docs/plans/2026-06-08-slack-module-design.md index 9872964..707099d 100644 --- a/docs/plans/2026-06-08-slack-module-design.md +++ b/docs/plans/2026-06-08-slack-module-design.md @@ -94,11 +94,16 @@ config block. ``` modules/slack/ - slack-poll.mjs # mirrors linear-poll.mjs: ingest → ty → reply/notify + slack-bridge.ts # poll notifications.jsonl + runRemote() (from the + # channel, §8) + Slack adapter: ingest → ty → reply/notify AGENTS.md # how agents should talk to Slack (like linear-cli/AGENTS.md) - .slack-poll-state.json # thread_ts ↔ task_id, processed ids, pending tasks + .slack-state.json # thread_ts ↔ task_id, processed ids, pending tasks ``` +> Earlier framing was `slack-poll.mjs` mirroring `linear-poll.mjs`; §8 revises the +> template to the bun/TS channel so the two share `pollNotifications` + +> `runRemote`. A `.mjs` Linear-style poller remains a viable fallback. + `config.env` additions (mirroring the Linear block): ```bash @@ -138,9 +143,11 @@ modules/slack/ ## 7. Recommendation -1. **Build Pattern A as `modules/slack/slack-poll.mjs`**, closely modeled on - `modules/linear/linear-poll.mjs`, enabled via `SLACK_ENABLED=true`, tailing - `notifications.jsonl` for push. +1. **Build Pattern A as `modules/slack/`**, modeled on the in-flight **channel** + (`templates/channel/taskyou-channel.ts`, see §8) rather than the older + `linear-poll.mjs`, enabled via `SLACK_ENABLED=true`. Reuse the channel's + `notifications.jsonl` poll loop + `runRemote()` + `assigned_gm` filtering; + add a Slack adapter (in/out) and the LLM classifier. 2. **Defer Pattern B** (network-exposed MCP for cloud Claude-in-Slack) to a separate spike built on the credential-proxy design + workflow PR #402. 3. The **"ping me in Slack"** half can ship immediately: extend the @@ -148,14 +155,57 @@ modules/slack/ `notifications.jsonl`) to POST to a Slack incoming webhook — independent of the full bridge. -## 8. Open questions for Bruno - -- **Shared bot vs per-user:** one workspace bot (multi-user routing + per-user - allowlists become the main design work) vs. a personal bot per operator like - `ty-email`? +## 8. Interaction with the in-flight channels work (#28 / #31 / #32) + +A "channels" refactor is open and **reshapes the substrate this design assumes — +mostly in our favor.** Build the Slack module *after* these land; they shrink the +work and answer the routing question below. + +- **#28 — Claude Code channel for push events** (`mergeable: false`, needs rebase). + Adds `templates/channel/taskyou-channel.ts` — a bun/TS MCP server that polls + `notifications.jsonl` (`pollNotifications()`, cursor via `lastLineCount` + + `tail -n +N`), exposes `ty_command` / `ssh_command` via `runRemote()`, and + pushes events into the GM session. It **replaces** the old "background agent + `tail -f`" approach. + - *Effect:* `notifications.jsonl` stays the source of truth, so our push + mechanism holds. But the **template to copy is now `taskyou-channel.ts`, not + `linear-poll.mjs`** — a Slack module is that poll loop + `runRemote()`, minus + "emit into the GM session," plus a Slack adapter. Prefer **TS/bun** to share + code (bun is already a prereq). + - *Terminology:* after #28, "channel" = *push into a Claude Code session*. Slack + is a **chat surface for a human** — a different layer. Slack stays a **module** + (a sibling consumer of `notifications.jsonl`), **not** a Claude Code channel — + so it runs as its own daemon and **sidesteps #28's research-preview + constraints** (`--dangerously-load-development-channels`, CC v2.1.80+, + claude.ai-login-only). +- **#32 — per-GM scoping (`assigned_gm`)** (stacked on #28, clean). Adds + `assigned_gm` end to end (`ty create --assigned-gm`, `TASK_ASSIGNED_GM` on + hooks, `assigned_gm` in each notification line, `GM_SLUG` / `SEE_UNASSIGNED` + filtering), riding on workflow PR #561. + - *Effect:* **this answers "shared bot vs per-user" below.** A single shared + Slack bot maps Slack user/channel → GM slug, stamps `ty create --assigned-gm`, + and filters notifications by `assigned_gm` to route the right ping to the right + person. Multi-user routing is no longer net-new — reuse the field. +- **#31 — local mode + macOS** (stacked on #28, clean). Adds the `IS_LOCAL` + (`bash -lc`) vs SSH branch to `runRemote()`, an OS-aware hooks dir + (`~/Library/Application Support/task/hooks` on macOS), and `setup_server_local()` + that creates `notifications.jsonl`. + - *Effect:* replaces our vague "ty CLI vs `ty serve` API" with the project's + actual local/SSH pattern; rely on setup having created `notifications.jsonl` + and use the OS-correct hooks dir instead of hardcoding paths. + +**Merge order:** #28 → #31 / #32 → this Slack module. + +## 9. Open questions for Bruno + +- ~~**Shared bot vs per-user**~~ — largely answered by #32: a single shared bot + can fan out correctly using `assigned_gm`. Remaining choice is just the + user→GM-slug mapping (per Slack user? per channel?). - **Socket Mode vs Events API:** Socket Mode needs no public URL (simplest on the exe.dev VM / agent server); Events API suits an always-on shared bot but needs an HTTPS endpoint. +- **TS/bun vs `.mjs`:** lean TS/bun to share `pollNotifications` + `runRemote` + with the channel (§8). Confirm before implementation. - **Also pursue Pattern B?** Do we want the *existing* cloud Claude-in-Slack to call `taskyou_*` tools directly (remote MCP), or is a dedicated TaskYou bot enough? From a1870f7e0264c66feb0651b56c2bc411bcbfa0d8 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 9 Jun 2026 07:51:45 -0500 Subject: [PATCH 3/5] docs: land Slack design on merged channel work; drop per-GM scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #28 (channels) and #31 (local/macOS) are merged to main, so the design is concrete: a Slack module is taskyou-channel.ts with its two ends re-pointed — reuse pollNotifications() + runRemote() verbatim; net-new is just a Slack adapter + LLM classifier. Ship outbound (hook -> Slack webhook) first, inbound control second. Removes the assigned_gm / per-GM scoping dependency (out of scope: single bot, single operator). Co-Authored-By: Claude Opus 4.8 --- docs/plans/2026-06-08-slack-module-design.md | 122 +++++++++---------- 1 file changed, 59 insertions(+), 63 deletions(-) diff --git a/docs/plans/2026-06-08-slack-module-design.md b/docs/plans/2026-06-08-slack-module-design.md index 707099d..b51c846 100644 --- a/docs/plans/2026-06-08-slack-module-design.md +++ b/docs/plans/2026-06-08-slack-module-design.md @@ -2,11 +2,13 @@ > Status: design / recommendation (not yet implemented) > Origin: TaskYou task #3723 — "Explore taskyou MCP sidecar extension for Slack" -> TL;DR: Add a **`modules/slack/`** integration that mirrors `modules/linear/` — -> watch a Slack channel/DM/@mention, classify intent, drive `ty`, post replies, -> and push `task.blocked`/`task.completed` notifications back. We've already -> built this exact pattern twice (the Linear poller here, and `ty-email` in the -> workflow repo), so this is mostly copy-adapt-configure. +> TL;DR: Add a **`modules/slack/`** integration. Now that the channels work +> (#28) and local/macOS support (#31) are **merged**, this is small: a Slack +> module is the merged `templates/channel/taskyou-channel.ts` with its two ends +> re-pointed — poll `notifications.jsonl` → `chat.postMessage` (out), and Slack +> message → classify → `runRemote("ty …")` (in). Net-new code = a Slack adapter +> + an LLM classifier. Ship outbound notifications first (a hook → Slack +> webhook), inbound control second. See §7. --- @@ -110,13 +112,11 @@ modules/slack/ # === Optional: Slack integration === # Set SLACK_ENABLED=true to manage TaskYou from Slack # SLACK_ENABLED="true" -# SLACK_MODE="socket" # socket (no public URL) | events (needs HTTPS) # SLACK_BOT_TOKEN="xoxb-..." # chat:write, app_mentions:read, im:history -# SLACK_APP_TOKEN="xapp-..." # socket mode -# SLACK_SIGNING_SECRET="..." # events mode +# SLACK_APP_TOKEN="xapp-..." # Socket Mode (recommended — no public URL) # SLACK_NOTIFY_CHANNEL="#taskyou" # where blocked/completed pings go # SLACK_ALLOWED_USERS="U012ABC,U034DEF" # only these Slack user IDs may drive ty -# SLACK_PROJECT_MAP='{"#eng":"workflow","#content":"content"}' # channel → project +# SLACK_PROJECT_MAP='{"#eng":"workflow","#content":"content"}' # Slack channel → ty project ``` ### Interaction examples @@ -143,69 +143,65 @@ modules/slack/ ## 7. Recommendation -1. **Build Pattern A as `modules/slack/`**, modeled on the in-flight **channel** - (`templates/channel/taskyou-channel.ts`, see §8) rather than the older - `linear-poll.mjs`, enabled via `SLACK_ENABLED=true`. Reuse the channel's - `notifications.jsonl` poll loop + `runRemote()` + `assigned_gm` filtering; - add a Slack adapter (in/out) and the LLM classifier. +The channels work (#28) and local/macOS support (#31) are now **merged to +`main`**, so this is no longer speculative — `templates/channel/taskyou-channel.ts` +is real code to copy, and a Slack module is **that channel with its two ends +re-pointed at Slack.** + +1. **Build `modules/slack/slack-bridge.ts`** (bun/TS) by lifting two functions + straight from the merged channel: `pollNotifications()` (cursor via + `lastLineCount` + `tail -n +N` over `notifications.jsonl`) and `runRemote()` + (the `IS_LOCAL` `bash -lc` vs SSH branch from #31). Then: + - **Outbound:** in `pollNotifications`, replace "emit into the GM session" + (`mcp.notification`) with `chat.postMessage` to Slack. + - **Inbound:** Slack message → LLM classify intent → `runRemote("ty …")` + (the same call `ty_command` already makes). + The only genuinely new code is the **Slack adapter** (Socket Mode in/out) and + the **LLM classifier** (lift from `ty-email/internal/classifier`). 2. **Defer Pattern B** (network-exposed MCP for cloud Claude-in-Slack) to a separate spike built on the credential-proxy design + workflow PR #402. -3. The **"ping me in Slack"** half can ship immediately: extend the - `task.blocked`/`task.completed` hook templates (or a tiny tail of - `notifications.jsonl`) to POST to a Slack incoming webhook — independent of the - full bridge. - -## 8. Interaction with the in-flight channels work (#28 / #31 / #32) - -A "channels" refactor is open and **reshapes the substrate this design assumes — -mostly in our favor.** Build the Slack module *after* these land; they shrink the -work and answer the routing question below. - -- **#28 — Claude Code channel for push events** (`mergeable: false`, needs rebase). - Adds `templates/channel/taskyou-channel.ts` — a bun/TS MCP server that polls - `notifications.jsonl` (`pollNotifications()`, cursor via `lastLineCount` + - `tail -n +N`), exposes `ty_command` / `ssh_command` via `runRemote()`, and - pushes events into the GM session. It **replaces** the old "background agent - `tail -f`" approach. - - *Effect:* `notifications.jsonl` stays the source of truth, so our push - mechanism holds. But the **template to copy is now `taskyou-channel.ts`, not - `linear-poll.mjs`** — a Slack module is that poll loop + `runRemote()`, minus - "emit into the GM session," plus a Slack adapter. Prefer **TS/bun** to share - code (bun is already a prereq). - - *Terminology:* after #28, "channel" = *push into a Claude Code session*. Slack - is a **chat surface for a human** — a different layer. Slack stays a **module** - (a sibling consumer of `notifications.jsonl`), **not** a Claude Code channel — + +### Ship it in two phases + +- **Phase 1 — outbound only (hours).** Extend the merged `task.blocked` / + `task.completed` hook templates to `curl` a Slack incoming webhook. Delivers + "ping me in Slack when a task blocks/finishes" with no new daemon. +- **Phase 2 — inbound control.** Add the Socket Mode bot + classifier so you can + create / unblock / query tasks from Slack. Two-way bridge. + +## 8. What the merged channel work (#28, #31) settles + +Both are merged to `main`; the substrate is fixed code now, not a moving branch. + +- **#28 — Claude Code channel for push events** (merged). `templates/channel/ + taskyou-channel.ts` polls `notifications.jsonl` (`pollNotifications()`, + `lastLineCount` + `tail -n +N`) and exposes `ty_command` / `ssh_command` via + `runRemote()`, replacing the old "background agent `tail -f`" approach. + - *For Slack:* `notifications.jsonl` is the settled source of truth, and the + **template to copy is `taskyou-channel.ts`** — reuse `pollNotifications()` + + `runRemote()` verbatim. There's also a `smoke-test.ts` to model tests on. + - *Terminology:* "channel" = *push into a Claude Code GM session*. Slack is a + **chat surface for a human** — a different layer. Slack stays a **module** + (a sibling consumer of `notifications.jsonl`), **not** a Claude Code channel, so it runs as its own daemon and **sidesteps #28's research-preview constraints** (`--dangerously-load-development-channels`, CC v2.1.80+, claude.ai-login-only). -- **#32 — per-GM scoping (`assigned_gm`)** (stacked on #28, clean). Adds - `assigned_gm` end to end (`ty create --assigned-gm`, `TASK_ASSIGNED_GM` on - hooks, `assigned_gm` in each notification line, `GM_SLUG` / `SEE_UNASSIGNED` - filtering), riding on workflow PR #561. - - *Effect:* **this answers "shared bot vs per-user" below.** A single shared - Slack bot maps Slack user/channel → GM slug, stamps `ty create --assigned-gm`, - and filters notifications by `assigned_gm` to route the right ping to the right - person. Multi-user routing is no longer net-new — reuse the field. -- **#31 — local mode + macOS** (stacked on #28, clean). Adds the `IS_LOCAL` - (`bash -lc`) vs SSH branch to `runRemote()`, an OS-aware hooks dir +- **#31 — local mode + macOS** (merged). `runRemote()` now has the `IS_LOCAL` + (`bash -lc`) vs SSH branch, hooks install to the OS-correct dir (`~/Library/Application Support/task/hooks` on macOS), and `setup_server_local()` - that creates `notifications.jsonl`. - - *Effect:* replaces our vague "ty CLI vs `ty serve` API" with the project's - actual local/SSH pattern; rely on setup having created `notifications.jsonl` - and use the OS-correct hooks dir instead of hardcoding paths. + creates `notifications.jsonl`. + - *For Slack:* reuse `runRemote()` as-is — the bridge runs identically whether + the daemon is on the same Mac or a remote Linux box. Rely on setup having + created `notifications.jsonl`; don't hardcode paths. -**Merge order:** #28 → #31 / #32 → this Slack module. +*(Per-GM scoping / `assigned_gm` is out of scope: a single bot for a single +operator, like `ty-email`. Routing by GM is not needed here.)* ## 9. Open questions for Bruno -- ~~**Shared bot vs per-user**~~ — largely answered by #32: a single shared bot - can fan out correctly using `assigned_gm`. Remaining choice is just the - user→GM-slug mapping (per Slack user? per channel?). -- **Socket Mode vs Events API:** Socket Mode needs no public URL (simplest on the - exe.dev VM / agent server); Events API suits an always-on shared bot but needs - an HTTPS endpoint. -- **TS/bun vs `.mjs`:** lean TS/bun to share `pollNotifications` + `runRemote` - with the channel (§8). Confirm before implementation. +- **Socket Mode vs Events API:** recommend **Socket Mode** — no public URL, works + identically on the exe.dev VM or a local Mac (#31). Events API only if we later + want an always-on shared bot with an HTTPS endpoint. - **Also pursue Pattern B?** Do we want the *existing* cloud Claude-in-Slack to call `taskyou_*` tools directly (remote MCP), or is a dedicated TaskYou bot - enough? + enough? (I'd say bot first, Pattern B later.) From 1584275f248487e7e3e339bded379c872e39e304 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 9 Jun 2026 08:15:48 -0500 Subject: [PATCH 4/5] feat(slack): implement two-way Slack module (modules/slack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the Slack integration end to end (outbound + inbound), per the design in docs/plans/2026-06-08-slack-module-design.md. modules/slack/slack-bridge.mjs — zero-dependency Node daemon (raw fetch + global WebSocket, like the Linear poller). Runs on the agents server next to ty + notifications.jsonl: • Outbound: tails notifications.jsonl → chat.postMessage (blocked/ completed/failed/started). Slack-originated tasks answer in-thread; everything else goes to SLACK_NOTIFY_CHANNEL. • Inbound: Slack Socket Mode (no public URL) → allowlist check → intent classification (Anthropic API, with a keyword-heuristic fallback when no key) → ty create/execute/input/list. Replies in-thread. Also: unit tests for the pure logic (node --test, 11 cases), a README, the ty-slack systemd service template, and setup.sh wiring (server + exe.dev install the service; local mode renders files + .env) plus SLACK_* in config.example.env and the README modules list. The module is gated behind SLACK_ENABLED=true. Co-Authored-By: Claude Opus 4.8 --- README.md | 1 + config.example.env | 14 + docs/plans/2026-06-08-slack-module-design.md | 10 +- modules/slack/README.md | 95 +++ modules/slack/slack-bridge.mjs | 675 +++++++++++++++++++ modules/slack/slack-bridge.test.mjs | 112 +++ setup.sh | 109 +++ templates/ty-slack.service.tmpl | 17 + 8 files changed, 1032 insertions(+), 1 deletion(-) create mode 100644 modules/slack/README.md create mode 100755 modules/slack/slack-bridge.mjs create mode 100644 modules/slack/slack-bridge.test.mjs create mode 100644 templates/ty-slack.service.tmpl diff --git a/README.md b/README.md index 0b98927..0dd00e8 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ This re-renders templates from your `config.env` and uploads them. It won't touc These are configured via flags in `config.env` during setup. They're part of the repo, not separate installs. - **Linear** (`LINEAR_ENABLED=true`) — Agent-to-human handoff via Linear issues, plus `@agent` comments for revisions +- **Slack** (`SLACK_ENABLED=true`) — Manage TaskYou from Slack: task events pushed to Slack, `@mentions`/DMs drive `ty` ([details](modules/slack/README.md)) - **Cloudflare R2** (`R2_ENABLED=true`) — Public URLs for files and assets agents generate - **GitHub** (`GITHUB_REPOS=workspace:org/repo`) — Push agent work to your repositories - **nono** (`NONO_ENABLED=true`) — Credential isolation for agents via sandboxed executor wrappers diff --git a/config.example.env b/config.example.env index a6e6100..667f199 100644 --- a/config.example.env +++ b/config.example.env @@ -56,6 +56,20 @@ PROJECT_DESCRIPTION="My Project does X, Y, and Z." # LINEAR_WORKSPACE_URL="https://linear.app/myproject" # LINEAR_TOKEN_AGENTS="lin_api_..." # Separate token for agents to post comments +# === Optional: Slack integration === +# Set SLACK_ENABLED=true to manage TaskYou from Slack (see modules/slack/README.md). +# Two-way: task events get pushed to Slack; @mentions/DMs drive ty. +# Installs the ty-slack systemd service on the server (Socket Mode — no public URL). + +# SLACK_ENABLED="true" +# SLACK_BOT_TOKEN="xoxb-..." # bot token: chat:write, app_mentions:read, im:history +# SLACK_APP_TOKEN="xapp-..." # app token (connections:write). Omit for outbound-only +# SLACK_NOTIFY_CHANNEL="#taskyou" # where task pings go when not tied to a Slack thread +# SLACK_ALLOWED_USERS="U012ABC,U034DEF" # Slack user IDs allowed to create/run tasks +# SLACK_PROJECT_MAP='{"#eng":"workflow","#content":"content"}' # Slack channel → ty project +# SLACK_ANTHROPIC_API_KEY="sk-ant-..." # optional — enables LLM intent classification +# SLACK_CLASSIFIER_MODEL="claude-haiku-4-5-20251001" + # === Optional: Cloudflare R2 asset hosting === # Set R2_ENABLED=true to enable R2 upload for deliverable assets diff --git a/docs/plans/2026-06-08-slack-module-design.md b/docs/plans/2026-06-08-slack-module-design.md index b51c846..9e1570c 100644 --- a/docs/plans/2026-06-08-slack-module-design.md +++ b/docs/plans/2026-06-08-slack-module-design.md @@ -1,6 +1,14 @@ # Slack module — manage TaskYou from Slack -> Status: design / recommendation (not yet implemented) +> Status: **implemented** in this PR — see `modules/slack/` (`slack-bridge.mjs`, +> `README.md`, tests), `templates/ty-slack.service.tmpl`, and the `SLACK_*` +> wiring in `setup.sh` + `config.example.env`. This doc is the design rationale. +> +> One refinement landed during build: the bridge runs **on the agents server** +> (next to `ty` + `notifications.jsonl`), so it reads the file and runs `ty` +> **directly** — no `runRemote()`/SSH needed. It's a zero-dependency Node `.mjs` +> (matching the Linear poller) rather than bun/TS, because the server has Node +> (bun is a GM-machine prereq). Outbound + inbound both shipped. > Origin: TaskYou task #3723 — "Explore taskyou MCP sidecar extension for Slack" > TL;DR: Add a **`modules/slack/`** integration. Now that the channels work > (#28) and local/macOS support (#31) are **merged**, this is small: a Slack diff --git a/modules/slack/README.md b/modules/slack/README.md new file mode 100644 index 0000000..225f044 --- /dev/null +++ b/modules/slack/README.md @@ -0,0 +1,95 @@ +# Slack module + +Manage TaskYou from Slack. A two-way bridge: task events get pushed into Slack, +and Slack messages drive the `ty` CLI. + +``` +Slack @mention / DM ─▶ slack-bridge ─▶ classify intent ─▶ ty create/execute/input +notifications.jsonl ─▶ slack-bridge ─▶ chat.postMessage (blocked / completed / failed) +``` + +It runs on the agents server next to `ty` and `notifications.jsonl`, installed +as the `ty-slack` systemd user service. Enable with `SLACK_ENABLED=true` in +`config.env`, then `./setup.sh server `. + +## How it works + +- **Outbound.** The bridge tails `notifications.jsonl` (the same file the + `task.blocked` / `task.completed` hooks and the Claude Code channel use) and + posts each event to Slack. Events for tasks that were created/run from Slack + go back into their originating thread; everything else goes to + `SLACK_NOTIFY_CHANNEL`. +- **Inbound.** Slack **Socket Mode** (no public URL) delivers `app_mention` and + DM events. The bridge checks the sender against `SLACK_ALLOWED_USERS`, + classifies intent (Anthropic API if `ANTHROPIC_API_KEY` is set, otherwise a + built-in keyword heuristic), and runs the matching `ty` command. Replies go + in-thread. + +The bridge is **dependency-free** — raw `fetch` to the Slack and Anthropic HTTP +APIs plus the global `WebSocket` (Node 22+). No `npm install`, mirroring the +Linear poller. + +## Setup + +1. Create a Slack app (https://api.slack.com/apps). + - **Socket Mode:** on. Generate an app-level token with `connections:write` + → `SLACK_APP_TOKEN` (`xapp-…`). + - **Bot token scopes:** `chat:write`, `app_mentions:read`, `im:history`, + `channels:read` → install to workspace → `SLACK_BOT_TOKEN` (`xoxb-…`). + - **Event Subscriptions:** subscribe to bot events `app_mention` and + `message.im`. + - Invite the bot to the channel(s) you want it to watch / post in. +2. Fill in the Slack section of `config.env` (see `config.example.env`). +3. `./setup.sh server ` (or `exe`). This installs the bridge to + `~/scripts/slack/`, writes `~/scripts/slack/.env`, and enables the + `ty-slack` systemd service. + +## Config + +| Variable | Purpose | +|----------|---------| +| `SLACK_ENABLED` | `true` to install the module | +| `SLACK_BOT_TOKEN` | `xoxb-…` bot token | +| `SLACK_APP_TOKEN` | `xapp-…` app token (Socket Mode). Omit to run **outbound-only** | +| `SLACK_NOTIFY_CHANNEL` | channel for task pings not tied to a Slack thread (e.g. `#taskyou`) | +| `SLACK_ALLOWED_USERS` | comma-separated Slack user IDs allowed to drive `ty` | +| `SLACK_PROJECT_MAP` | JSON map of Slack channel → ty project, e.g. `{"#eng":"workflow"}` | +| `SLACK_ANTHROPIC_API_KEY` | optional — enables LLM intent classification | +| `SLACK_CLASSIFIER_MODEL` | classifier model (default `claude-haiku-4-5-20251001`) | + +## Usage + +- `@taskyou fix the checkout 500s and run it` — create a task (say “run it” to + execute immediately). +- Reply in a task's thread — routed to `ty input `. +- `@taskyou run task 312` — execute an existing task. +- `@taskyou status of 312` / `@taskyou what's on the board?` — status. + +## Security + +- **Allowlist by user ID** (`SLACK_ALLOWED_USERS`) — channel membership alone + can't create or run tasks. +- **Socket Mode** uses an authenticated WebSocket; there's no inbound HTTP + endpoint to expose or verify. +- **No code execution from chat** — the LLM only *classifies*; the bridge only + shells out to `ty`. Pair with `nono` (`NONO_ENABLED`) for executor credential + isolation. +- **Local secrets** — tokens live in `~/scripts/slack/.env` (chmod 600), never + sent to the LLM. + +## Run / debug + +```bash +systemctl --user status ty-slack +tail -f ~/log/ty-slack.log + +# run the unit tests for the pure logic +cd modules/slack && node --test +``` + +## Scope + +Single bot, single operator/team (the `ty-email` model). Per-GM routing is out +of scope. The hosted/remote-MCP path (letting cloud "Claude in Slack" call +`taskyou_*` tools directly) is a separate, larger project — see +`docs/plans/2026-06-08-slack-module-design.md`. diff --git a/modules/slack/slack-bridge.mjs b/modules/slack/slack-bridge.mjs new file mode 100755 index 0000000..bc47b70 --- /dev/null +++ b/modules/slack/slack-bridge.mjs @@ -0,0 +1,675 @@ +#!/usr/bin/env node + +// TaskYou Slack bridge +// ──────────────────── +// A two-way bridge between Slack and TaskYou. Runs on the agents server next +// to the `ty` binary and notifications.jsonl. Long-running (Slack Socket Mode +// holds a WebSocket), so it's installed as a systemd user service — not a cron +// job like the Linear poller. +// +// Outbound: tails notifications.jsonl → chat.postMessage (task blocked/done) +// Inbound: Slack @mention / DM → classify intent → ty +// +// Zero npm dependencies: raw fetch to the Slack + Anthropic HTTP APIs, the +// global WebSocket (Node 22+/24), fs, and child_process. Mirrors the +// dependency-free approach of modules/linear/linear-poll.mjs. +// +// Config is read from environment or a .env file next to this script. +// SLACK_BOT_TOKEN xoxb- token (chat:write, app_mentions:read, im:history) +// SLACK_APP_TOKEN xapp- token (connections:write) — Socket Mode +// SLACK_NOTIFY_CHANNEL channel for task pings not tied to a Slack thread (e.g. #taskyou) +// SLACK_ALLOWED_USERS comma-separated Slack user IDs allowed to drive ty +// SLACK_PROJECT_MAP JSON map of Slack channel name/id → ty project +// DEFAULT_PROJECT project when no channel mapping matches +// TY_PATH path to the ty binary +// NOTIFICATIONS_FILE path to notifications.jsonl +// ANTHROPIC_API_KEY optional — enables LLM intent classification +// ANTHROPIC_MODEL classifier model (default claude-haiku-4-5-20251001) + +import { + readFileSync, + writeFileSync, + existsSync, + statSync, + openSync, + readSync, + closeSync, +} from "fs"; +import { execFileSync } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ── .env loading (same shape as linear-poll.mjs) ───────────────────────────── + +function loadEnv(path) { + if (!existsSync(path)) return; + for (const line of readFileSync(path, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq); + if (!process.env[key]) process.env[key] = trimmed.slice(eq + 1); + } +} + +loadEnv(join(__dirname, ".env")); + +const CONFIG = { + botToken: process.env.SLACK_BOT_TOKEN || "", + appToken: process.env.SLACK_APP_TOKEN || "", + notifyChannel: process.env.SLACK_NOTIFY_CHANNEL || "", + allowedUsers: (process.env.SLACK_ALLOWED_USERS || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + projectMap: parseJSON(process.env.SLACK_PROJECT_MAP, {}), + defaultProject: process.env.DEFAULT_PROJECT || "", + tyPath: process.env.TY_PATH || "ty", + notificationsFile: + process.env.NOTIFICATIONS_FILE || + join(process.env.HOME || ".", "notifications.jsonl"), + anthropicKey: process.env.ANTHROPIC_API_KEY || "", + anthropicModel: process.env.ANTHROPIC_MODEL || "claude-haiku-4-5-20251001", + pollIntervalMs: parseInt(process.env.SLACK_POLL_INTERVAL_MS || "5000", 10), +}; + +const STATE_FILE = join(__dirname, ".slack-state.json"); + +function parseJSON(s, fallback) { + if (!s) return fallback; + try { + return JSON.parse(s); + } catch { + return fallback; + } +} + +// ── State (thread ↔ task mapping + notifications cursor) ────────────────────── + +function loadState() { + if (existsSync(STATE_FILE)) { + try { + return JSON.parse(readFileSync(STATE_FILE, "utf8")); + } catch { + /* fall through to fresh state */ + } + } + return { notifyOffset: null, threads: {}, taskThreads: {} }; +} + +function saveState(state) { + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Pure helpers (exported for tests — no I/O, no network) +// ═════════════════════════════════════════════════════════════════════════════ + +// Remove a leading bot mention (<@U123>) and trailing/leading whitespace. +export function stripMention(text, botUserId) { + if (!text) return ""; + let out = text; + if (botUserId) { + out = out.replace(new RegExp(`<@${botUserId}>`, "g"), ""); + } + // Strip any other leading <@...> mention too. + out = out.replace(/^\s*<@[^>]+>\s*/g, ""); + return out.trim(); +} + +export function isAllowed(userId, allowedUsers) { + if (!userId) return false; + if (!allowedUsers || allowedUsers.length === 0) return false; + return allowedUsers.includes(userId); +} + +// Map a Slack channel (by id or name) to a ty project. Falls back to default. +export function mapChannelToProject(channelKey, projectMap, defaultProject) { + if (channelKey && projectMap) { + if (projectMap[channelKey]) return projectMap[channelKey]; + const bare = channelKey.replace(/^#/, ""); + if (projectMap[bare]) return projectMap[bare]; + if (projectMap[`#${bare}`]) return projectMap[`#${bare}`]; + } + return defaultProject || ""; +} + +// Pull a task id out of `ty create`/`ty show` output ("#123", "id: 123", JSON). +export function extractTaskId(output) { + if (!output) return null; + const hash = output.match(/#(\d+)/); + if (hash) return hash[1]; + const json = output.match(/"id"\s*:\s*"?(\d+)"?/); + if (json) return json[1]; + const id = output.match(/\bid[:=]\s*(\d+)/i); + if (id) return id[1]; + return null; +} + +// Robustly extract the JSON object from an LLM response that may wrap it in +// prose or a ```json fence. +export function parseIntentResponse(text) { + if (!text) return null; + let body = text.trim(); + const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence) body = fence[1].trim(); + const start = body.indexOf("{"); + const end = body.lastIndexOf("}"); + if (start === -1 || end === -1 || end < start) return null; + try { + return JSON.parse(body.slice(start, end + 1)); + } catch { + return null; + } +} + +// Dependency-free fallback used when no ANTHROPIC_API_KEY is set (or the API +// call fails). Good enough to keep the bridge usable; the LLM path is better. +export function heuristicIntent(text, { threadTaskId } = {}) { + const t = (text || "").trim(); + const lower = t.toLowerCase(); + const idMatch = t.match(/(?:#|task\s*)(\d+)/i); + const explicitId = idMatch ? idMatch[1] : null; + const id = explicitId || threadTaskId || null; + + if (/^(help|commands|what can you do)\b/.test(lower) || !t) { + return { action: "help" }; + } + // Execute an EXISTING task only when the message *starts* with a run verb + // ("run it", "execute 5"). A longer instruction that merely ends in "...and + // run it" is a create-and-execute, handled below. "go" is intentionally + // excluded so "go with option 2" stays input. + if (id && /^(run|execute|start)\b/.test(lower) && lower.length < 60) { + return { action: "execute_task", task_id: id }; + } + if ( + /\b(status|what'?s happening|how('?s| is) it going|update|progress)\b/.test( + lower + ) || + (id && lower.includes("?")) + ) { + return { action: "query_status", task_id: id }; + } + // In a known task thread, a short freeform message is most likely input. + if (threadTaskId && lower.length < 280 && !/^(create|new task|add task)\b/.test(lower)) { + return { action: "provide_input", task_id: threadTaskId, input: t }; + } + // Default: create a task from the message. + const title = + t.split("\n")[0].replace(/^(create|new task|add task)[:\s]*/i, "").slice(0, 120) || + t.slice(0, 120); + const execute = /\b(and )?(run|execute) it\b/.test(lower); + return { action: "create_task", title, body: t, execute }; +} + +// Format a notifications.jsonl event into a Slack message line. +export function formatNotification(event) { + const id = event.task_id || "?"; + const title = event.title || ""; + const project = event.project ? ` _(${event.project})_` : ""; + switch (event.event) { + case "completed": + return `:white_check_mark: *Task #${id} completed*: ${title}${project}`; + case "blocked": + return `:warning: *Task #${id} needs input*: ${title}${project}\nReply in this thread to respond, or \`@taskyou input ${id} \`.`; + case "failed": + return `:x: *Task #${id} failed*: ${title}${project}`; + case "started": + return `:hourglass_flowing_sand: *Task #${id} started*: ${title}${project}`; + default: + return `*Task #${id}* (${event.event || "update"}): ${title}${project}`; + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Slack Web API +// ═════════════════════════════════════════════════════════════════════════════ + +async function slack(method, body) { + const res = await fetch(`https://slack.com/api/${method}`, { + method: "POST", + headers: { + Authorization: `Bearer ${CONFIG.botToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(body || {}), + }); + const json = await res.json(); + if (!json.ok) { + throw new Error(`slack ${method} failed: ${json.error || res.status}`); + } + return json; +} + +async function postMessage(channel, text, threadTs) { + if (!channel) return; + try { + await slack("chat.postMessage", { + channel, + text, + ...(threadTs ? { thread_ts: threadTs } : {}), + unfurl_links: false, + }); + } catch (err) { + log(`postMessage failed: ${err.message}`); + } +} + +// Resolve a channel id → "#name" once, cached, for project mapping. +const channelNameCache = new Map(); +async function channelName(channelId) { + if (!channelId) return ""; + if (channelNameCache.has(channelId)) return channelNameCache.get(channelId); + try { + const info = await slack("conversations.info", { channel: channelId }); + const name = info.channel?.name ? `#${info.channel.name}` : channelId; + channelNameCache.set(channelId, name); + return name; + } catch { + return channelId; + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// ty bridge (runs ty locally — the bridge lives on the agents server) +// ═════════════════════════════════════════════════════════════════════════════ + +function ty(args) { + try { + return execFileSync(CONFIG.tyPath, args, { + encoding: "utf8", + timeout: 30_000, + }).trim(); + } catch (err) { + const stderr = err.stderr ? err.stderr.toString().trim() : ""; + throw new Error(stderr || err.message); + } +} + +function createTask(project, title, body, execute) { + const args = ["create", title, "--type", "draft"]; + if (project) args.push("--project", project); + if (body) args.push("--body", body); + if (execute) args.push("--execute"); + return ty(args); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Intent classification +// ═════════════════════════════════════════════════════════════════════════════ + +const CLASSIFIER_SYSTEM = [ + "You route Slack messages to TaskYou (a task runner driven by the `ty` CLI).", + "Classify the message into exactly one action and reply with ONLY a JSON object:", + '{"action":"create_task|provide_input|execute_task|query_status|help",', + ' "title":"short task title (create_task)",', + ' "body":"full task description (create_task)",', + ' "execute":true|false (create_task: run immediately if the user says so),', + ' "task_id":"id (provide_input/execute_task/query_status)",', + ' "input":"the answer text (provide_input)",', + ' "reply":"optional one-line human reply"}', + "Rules: if the message is a reply within a known task thread, prefer provide_input for that task.", + "If the user clearly asks to run/execute an existing task, use execute_task.", + "If they ask how things are going / for status, use query_status.", + "Otherwise create_task. Never include commentary outside the JSON.", +].join("\n"); + +async function classifyIntent(text, { threadTaskId, openTasks } = {}) { + if (!CONFIG.anthropicKey) { + return heuristicIntent(text, { threadTaskId }); + } + const context = [ + threadTaskId ? `This message is in the thread for task #${threadTaskId}.` : "", + openTasks && openTasks.length + ? `Open tasks: ${openTasks + .slice(0, 20) + .map((t) => `#${t.id} ${t.title}`) + .join("; ")}` + : "", + `Message: ${text}`, + ] + .filter(Boolean) + .join("\n"); + + try { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": CONFIG.anthropicKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: CONFIG.anthropicModel, + max_tokens: 512, + system: CLASSIFIER_SYSTEM, + messages: [{ role: "user", content: context }], + }), + }); + const json = await res.json(); + const out = json.content?.map((c) => c.text || "").join("") || ""; + const parsed = parseIntentResponse(out); + if (parsed && parsed.action) return parsed; + } catch (err) { + log(`classifier error, falling back to heuristic: ${err.message}`); + } + return heuristicIntent(text, { threadTaskId }); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Inbound: Slack message → ty +// ═════════════════════════════════════════════════════════════════════════════ + +let BOT_USER_ID = ""; +const seenEvents = new Set(); // event_id dedup (Slack retries) + +async function handleMessageEvent(event, state) { + // Ignore anything from a bot (including ourselves) and edited/system messages. + if (event.bot_id || event.subtype) return; + if (event.user && event.user === BOT_USER_ID) return; + + const requester = event.user; + if (!isAllowed(requester, CONFIG.allowedUsers)) { + log(`ignoring message from non-allowlisted user ${requester}`); + return; + } + + const text = stripMention(event.text || "", BOT_USER_ID); + if (!text) return; + + const channel = event.channel; + const threadTs = event.thread_ts || event.ts; + const threadTaskId = state.threads[event.thread_ts] || null; + + let openTasks = []; + try { + openTasks = JSON.parse(ty(["list", "--all", "--json"])); + } catch { + /* listing is best-effort context only */ + } + + const intent = await classifyIntent(text, { threadTaskId, openTasks }); + log(`intent: ${intent.action}${intent.task_id ? ` #${intent.task_id}` : ""}`); + + try { + await dispatchIntent(intent, { channel, threadTs, requester, text }, state); + } catch (err) { + await postMessage(channel, `:x: ${err.message}`, threadTs); + } +} + +async function dispatchIntent(intent, ctx, state) { + const { channel, threadTs } = ctx; + + switch (intent.action) { + case "create_task": { + const chanName = await channelName(channel); + const project = + intent.project || + mapChannelToProject(chanName, CONFIG.projectMap, CONFIG.defaultProject); + const title = intent.title || ctx.text.slice(0, 120); + const out = createTask(project, title, intent.body || ctx.text, !!intent.execute); + const taskId = extractTaskId(out); + if (taskId) { + state.threads[threadTs] = taskId; + state.taskThreads[taskId] = { channel, thread_ts: threadTs }; + saveState(state); + } + const ran = intent.execute ? " and started it" : ""; + await postMessage( + channel, + intent.reply || + `:memo: Created *task #${taskId || "?"}*${project ? ` in _${project}_` : ""}${ran}: ${title}`, + threadTs + ); + return; + } + + case "provide_input": { + const taskId = intent.task_id || state.threads[threadTs]; + if (!taskId) { + await postMessage(channel, "Which task is this for? Mention a task number.", threadTs); + return; + } + ty(["input", String(taskId), intent.input || ctx.text]); + await postMessage( + channel, + intent.reply || `:incoming_envelope: Sent your input to *task #${taskId}*.`, + threadTs + ); + return; + } + + case "execute_task": { + const taskId = intent.task_id; + if (!taskId) { + await postMessage(channel, "Which task should I run? Mention a task number.", threadTs); + return; + } + ty(["execute", String(taskId)]); + state.taskThreads[taskId] = { channel, thread_ts: threadTs }; + saveState(state); + await postMessage( + channel, + intent.reply || + `:rocket: Started *task #${taskId}*. I'll post back here when it finishes or needs you.`, + threadTs + ); + return; + } + + case "query_status": { + let body; + if (intent.task_id) { + body = ty(["show", String(intent.task_id)]); + } else { + body = ty(["list", "--all"]); + } + const trimmed = body.length > 3500 ? body.slice(0, 3500) + "\n…(truncated)" : body; + await postMessage(channel, "```\n" + trimmed + "\n```", threadTs); + return; + } + + case "help": + default: + await postMessage(channel, HELP_TEXT, threadTs); + } +} + +const HELP_TEXT = [ + "*TaskYou Slack bridge* — mention me or DM me:", + "• `@taskyou fix the checkout 500s and run it` — create a task (add “run it” to execute now)", + "• reply in a task's thread — send input to that task", + "• `@taskyou run task 312` — execute an existing task", + "• `@taskyou status of 312` / `@taskyou what's on the board?` — get status", +].join("\n"); + +// ═════════════════════════════════════════════════════════════════════════════ +// Outbound: notifications.jsonl → Slack +// ═════════════════════════════════════════════════════════════════════════════ + +function readNewLines(path, fromOffset) { + // Returns { lines, offset }. On first read (fromOffset === null) we skip the + // existing backlog and just record EOF. Handles truncation/rotation. + if (!existsSync(path)) return { lines: [], offset: fromOffset }; + const size = statSync(path).size; + if (fromOffset === null) return { lines: [], offset: size }; + if (size < fromOffset) fromOffset = 0; // file shrank → rotated/truncated + if (size === fromOffset) return { lines: [], offset: size }; + + const fd = openSync(path, "r"); + try { + const len = size - fromOffset; + const buf = Buffer.alloc(len); + readSync(fd, buf, 0, len, fromOffset); + const text = buf.toString("utf8"); + const lines = text.split("\n").filter((l) => l.trim()); + return { lines, offset: size }; + } finally { + closeSync(fd); + } +} + +async function pollNotifications(state) { + const { lines, offset } = readNewLines(CONFIG.notificationsFile, state.notifyOffset); + if (offset !== state.notifyOffset) { + state.notifyOffset = offset; + saveState(state); + } + for (const line of lines) { + let event; + try { + event = JSON.parse(line); + } catch { + continue; + } + const msg = formatNotification(event); + const mapping = event.task_id ? state.taskThreads[String(event.task_id)] : null; + if (mapping) { + // Task originated from / was run via Slack — answer in its thread. + await postMessage(mapping.channel, msg, mapping.thread_ts); + } else if (CONFIG.notifyChannel) { + await postMessage(CONFIG.notifyChannel, msg); + } + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Socket Mode (inbound transport — no public URL needed) +// ═════════════════════════════════════════════════════════════════════════════ + +async function openSocket() { + const res = await fetch("https://slack.com/api/apps.connections.open", { + method: "POST", + headers: { + Authorization: `Bearer ${CONFIG.appToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + const json = await res.json(); + if (!json.ok) throw new Error(`apps.connections.open failed: ${json.error}`); + return json.url; +} + +function connectSocket(state) { + let ws; + openSocket() + .then((url) => { + ws = new WebSocket(url); + + ws.addEventListener("open", () => log("Socket Mode connected")); + + ws.addEventListener("message", (ev) => { + let frame; + try { + frame = JSON.parse(ev.data); + } catch { + return; + } + + // Slack asks us to reconnect before it drops the socket. + if (frame.type === "disconnect") { + log(`Socket disconnect (${frame.reason || "?"}); reconnecting`); + try { + ws.close(); + } catch {} + return; + } + + // Ack every envelope immediately (Slack requires < 3s). + if (frame.envelope_id) { + try { + ws.send(JSON.stringify({ envelope_id: frame.envelope_id })); + } catch {} + } + + if (frame.type !== "events_api") return; + const payload = frame.payload || {}; + const eventId = payload.event_id; + if (eventId) { + if (seenEvents.has(eventId)) return; // Slack retry + seenEvents.add(eventId); + if (seenEvents.size > 1000) { + // bound memory + seenEvents.delete(seenEvents.values().next().value); + } + } + const event = payload.event; + if (!event) return; + if (event.type === "app_mention" || event.type === "message") { + handleMessageEvent(event, state).catch((err) => + log(`handler error: ${err.message}`) + ); + } + }); + + ws.addEventListener("close", () => { + log("Socket closed; reconnecting in 3s"); + setTimeout(() => connectSocket(state), 3000); + }); + + ws.addEventListener("error", (err) => { + log(`Socket error: ${err?.message || err}`); + try { + ws.close(); + } catch {} + }); + }) + .catch((err) => { + log(`openSocket failed: ${err.message}; retrying in 10s`); + setTimeout(() => connectSocket(state), 10_000); + }); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Main +// ═════════════════════════════════════════════════════════════════════════════ + +function log(msg) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +async function main() { + if (!CONFIG.botToken) { + console.error("SLACK_BOT_TOKEN is required"); + process.exit(1); + } + + const state = loadState(); + + // Resolve our own bot user id (to strip mentions and ignore our own posts). + try { + const auth = await slack("auth.test", {}); + BOT_USER_ID = auth.user_id || ""; + log(`authenticated as ${auth.user || BOT_USER_ID} in ${auth.team || "?"}`); + } catch (err) { + log(`auth.test failed (outbound only until fixed): ${err.message}`); + } + + // Outbound: poll notifications.jsonl. + await pollNotifications(state); + setInterval(() => { + pollNotifications(state).catch((err) => log(`poll error: ${err.message}`)); + }, CONFIG.pollIntervalMs); + + // Inbound: Socket Mode (only if an app token is configured). + if (CONFIG.appToken) { + connectSocket(state); + } else { + log("no SLACK_APP_TOKEN — outbound notifications only (no inbound control)"); + } + + log("slack-bridge started"); +} + +const isMain = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + main().catch((err) => { + console.error(`fatal: ${err.message}`); + process.exit(1); + }); +} diff --git a/modules/slack/slack-bridge.test.mjs b/modules/slack/slack-bridge.test.mjs new file mode 100644 index 0000000..7402355 --- /dev/null +++ b/modules/slack/slack-bridge.test.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +// Unit tests for the pure helpers in slack-bridge.mjs. +// Run: node --test (from modules/slack/) +// +// These cover the logic that can be tested without Slack/Anthropic/ty: +// intent parsing + fallback, allow-listing, channel→project mapping, +// task-id extraction, and notification formatting. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + stripMention, + isAllowed, + mapChannelToProject, + extractTaskId, + parseIntentResponse, + heuristicIntent, + formatNotification, +} from "./slack-bridge.mjs"; + +test("stripMention removes the bot mention and trims", () => { + assert.equal(stripMention("<@U123> fix the bug", "U123"), "fix the bug"); + assert.equal(stripMention("<@U999> hello", "U123"), "hello"); // any leading mention + assert.equal(stripMention("no mention here", "U123"), "no mention here"); + assert.equal(stripMention("", "U123"), ""); +}); + +test("isAllowed enforces the allowlist", () => { + assert.equal(isAllowed("U1", ["U1", "U2"]), true); + assert.equal(isAllowed("U3", ["U1", "U2"]), false); + assert.equal(isAllowed("U1", []), false); // empty allowlist denies all + assert.equal(isAllowed("", ["U1"]), false); +}); + +test("mapChannelToProject matches by name, #name, or id, else default", () => { + const map = { "#eng": "workflow", content: "content" }; + assert.equal(mapChannelToProject("#eng", map, "default"), "workflow"); + assert.equal(mapChannelToProject("eng", map, "default"), "workflow"); // bare name + assert.equal(mapChannelToProject("#content", map, "default"), "content"); // add # + assert.equal(mapChannelToProject("#random", map, "default"), "default"); + assert.equal(mapChannelToProject("C123", {}, "default"), "default"); +}); + +test("extractTaskId handles #N, JSON, and id: forms", () => { + assert.equal(extractTaskId("Created task #312"), "312"); + assert.equal(extractTaskId('{"id":"45","title":"x"}'), "45"); + assert.equal(extractTaskId('{"id": 99}'), "99"); + assert.equal(extractTaskId("id: 7"), "7"); + assert.equal(extractTaskId("nothing here"), null); +}); + +test("parseIntentResponse extracts JSON from fences and prose", () => { + assert.deepEqual(parseIntentResponse('{"action":"help"}'), { action: "help" }); + assert.deepEqual( + parseIntentResponse('```json\n{"action":"create_task","title":"x"}\n```'), + { action: "create_task", title: "x" } + ); + assert.deepEqual( + parseIntentResponse('Sure! {"action":"execute_task","task_id":"5"} done'), + { action: "execute_task", task_id: "5" } + ); + assert.equal(parseIntentResponse("not json"), null); + assert.equal(parseIntentResponse(""), null); +}); + +test("heuristicIntent: execute when a thread/task id + run verb", () => { + const r = heuristicIntent("run it", { threadTaskId: "10" }); + assert.equal(r.action, "execute_task"); + assert.equal(r.task_id, "10"); +}); + +test("heuristicIntent: status questions", () => { + assert.equal(heuristicIntent("what's the status of #5?").action, "query_status"); + assert.equal(heuristicIntent("how's it going?", { threadTaskId: "5" }).action, "query_status"); +}); + +test("heuristicIntent: reply in a task thread becomes input", () => { + const r = heuristicIntent("go with option 2", { threadTaskId: "8" }); + assert.equal(r.action, "provide_input"); + assert.equal(r.task_id, "8"); + assert.equal(r.input, "go with option 2"); +}); + +test("heuristicIntent: default is create, detects 'run it'", () => { + const a = heuristicIntent("Fix the checkout 500s"); + assert.equal(a.action, "create_task"); + assert.equal(a.execute, false); + + const b = heuristicIntent("Fix the checkout and run it"); + assert.equal(b.action, "create_task"); + assert.equal(b.execute, true); + assert.ok(b.title.length > 0); +}); + +test("heuristicIntent: empty/help", () => { + assert.equal(heuristicIntent("").action, "help"); + assert.equal(heuristicIntent("help").action, "help"); +}); + +test("formatNotification renders each event type", () => { + assert.match( + formatNotification({ event: "completed", task_id: "1", title: "Ship it", project: "web" }), + /completed.*Ship it.*web/s + ); + assert.match( + formatNotification({ event: "blocked", task_id: "2", title: "Need answer" }), + /needs input.*Need answer/s + ); + assert.match(formatNotification({ event: "failed", task_id: "3", title: "Oops" }), /failed.*Oops/); + assert.match(formatNotification({ event: "weird", task_id: "4", title: "Huh" }), /Task #4/); +}); diff --git a/setup.sh b/setup.sh index 43dd5b5..33802a8 100755 --- a/setup.sh +++ b/setup.sh @@ -249,6 +249,14 @@ export EXE_DEV_VM_NAME="${EXE_DEV_VM_NAME:-}" export NONO_ENABLED="${NONO_ENABLED:-false}" export NONO_CREDENTIALS="${NONO_CREDENTIALS:-}" export NONO_PROXY_HOSTS="${NONO_PROXY_HOSTS:-}" +export SLACK_ENABLED="${SLACK_ENABLED:-false}" +export SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN:-}" +export SLACK_APP_TOKEN="${SLACK_APP_TOKEN:-}" +export SLACK_NOTIFY_CHANNEL="${SLACK_NOTIFY_CHANNEL:-}" +export SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS:-}" +export SLACK_PROJECT_MAP="${SLACK_PROJECT_MAP:-}" +export SLACK_ANTHROPIC_API_KEY="${SLACK_ANTHROPIC_API_KEY:-}" +export SLACK_CLASSIFIER_MODEL="${SLACK_CLASSIFIER_MODEL:-claude-haiku-4-5-20251001}" # Generate nono proxy flags for wrapper scripts if [[ "$NONO_ENABLED" == "true" && -n "$NONO_PROXY_HOSTS" ]]; then @@ -517,6 +525,67 @@ setup_nono() { log "nono credential isolation setup complete" } +# ── Slack module ────────────────────────────────────────────────────────────── + +# Install the Slack bridge on a remote host as the ty-slack systemd user +# service. Long-running (Socket Mode holds a WebSocket), so unlike the Linear +# poller it's a service, not a cron job. $1 = ssh target, $2 = remote home. +setup_slack_remote() { + local ssh_target="$1" + local remote_home="$2" + + log "Setting up Slack integration" + + local scripts_dir="$remote_home/scripts/slack" + ssh "$ssh_target" "mkdir -p $scripts_dir $remote_home/log $remote_home/.config/systemd/user" + scp -q "$MODULES_DIR/slack/slack-bridge.mjs" "$ssh_target:$scripts_dir/slack-bridge.mjs" + ssh "$ssh_target" "chmod +x $scripts_dir/slack-bridge.mjs" + + # .env (chmod 600 — holds bot/app tokens). Written via stdin to keep tokens + # out of the process list. + local project_map_json="$SLACK_PROJECT_MAP" + [[ -z "$project_map_json" ]] && project_map_json="{}" + local default_project + default_project=$(echo "$PROJECTS" | cut -d',' -f1 | xargs) + ssh "$ssh_target" "cat > $scripts_dir/.env && chmod 600 $scripts_dir/.env" </dev/null | tr -d '\r' | tail -1) + if [[ -z "$node_bin" ]]; then + warn "node not found on $ssh_target — install Node 22+ so the bridge can run" + node_bin="node" + fi + export NODE_BIN="$node_bin" + + render_file "$TEMPLATES_DIR/ty-slack.service.tmpl" "/tmp/ty-slack.service" + scp -q "/tmp/ty-slack.service" "$ssh_target:$remote_home/.config/systemd/user/ty-slack.service" + rm -f "/tmp/ty-slack.service" + + ssh "$ssh_target" "systemctl --user daemon-reload && systemctl --user enable ty-slack && systemctl --user restart ty-slack" 2>/dev/null \ + || warn "could not enable ty-slack service (needs lingering — see ty-daemon setup)" + sleep 2 + if ssh "$ssh_target" "systemctl --user is-active ty-slack" 2>/dev/null | grep -q "active"; then + ok "ty-slack running (systemd user service)" + ok "Logs: $remote_home/log/ty-slack.log" + else + warn "ty-slack may not have started. Debug: ssh $ssh_target 'systemctl --user status ty-slack'" + fi +} + # ── Daemon systemd service ──────────────────────────────────────────────────── install_daemon_service() { @@ -638,6 +707,36 @@ setup_server_local() { touch "$home_dir/notifications.jsonl" ok "notifications.jsonl" + # Slack module (local mode: render files + .env; no systemd on macOS, so the + # operator starts it — directly or via a launchd agent). + if [[ "$SLACK_ENABLED" == "true" ]]; then + log "Setting up Slack integration (local)" + local slack_dir="$home_dir/scripts/slack" + mkdir -p "$slack_dir" + cp "$MODULES_DIR/slack/slack-bridge.mjs" "$slack_dir/slack-bridge.mjs" + chmod +x "$slack_dir/slack-bridge.mjs" + + local project_map_json="$SLACK_PROJECT_MAP" + [[ -z "$project_map_json" ]] && project_map_json="{}" + local default_project + default_project=$(echo "$PROJECTS" | cut -d',' -f1 | xargs) + cat > "$slack_dir/.env" < Date: Tue, 9 Jun 2026 09:48:28 -0500 Subject: [PATCH 5/5] feat(slack): default classifier to on-box `claude` CLI + runaway guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classifier no longer requires a separate ANTHROPIC_API_KEY: it prefers the already-authenticated on-box `claude` CLI (claude -p — the same primitive the GM/executors use), falls back to the Anthropic API if a key is set, then a keyword heuristic. Override via SLACK_CLASSIFIER. Loop / token-burn hardening (per review): - claude -p sandboxed per call: --strict-mcp-config --mcp-config {} + --disallowedTools … keep it to a single turn (no agentic spiral); --max-budget-usd (default $0.05) hard-caps cost; timeout + maxBuffer; no retry. - Self-loop guards: ignore own/bot/edited messages (bot_id/subtype/BOT_USER_ID). - Concurrency cap (SLACK_MAX_CONCURRENT, default 3) bounds in-flight classifications; excess messages declined, not queued. - Socket generation guard: only the newest connection's handlers stay live, so a reconnect race can't double-deliver events. - Outbound poller: per-tick cap (SLACK_MAX_NOTIFS_PER_POLL, default 25) with byte-accurate gradual drain; only complete lines consumed (partial hook line held); lost/corrupt state skips backlog instead of replaying. Tests: +2 (buildClassifierContext, readNewChunk partial-line/rotation) → 13/13. Docs: README "Runaway / cost protection", config knobs, design-doc note. Co-Authored-By: Claude Opus 4.8 --- config.example.env | 9 +- docs/plans/2026-06-08-slack-module-design.md | 17 +- modules/slack/README.md | 46 +++- modules/slack/slack-bridge.mjs | 230 +++++++++++++++---- modules/slack/slack-bridge.test.mjs | 39 ++++ 5 files changed, 286 insertions(+), 55 deletions(-) diff --git a/config.example.env b/config.example.env index 667f199..ef2469a 100644 --- a/config.example.env +++ b/config.example.env @@ -67,8 +67,15 @@ PROJECT_DESCRIPTION="My Project does X, Y, and Z." # SLACK_NOTIFY_CHANNEL="#taskyou" # where task pings go when not tied to a Slack thread # SLACK_ALLOWED_USERS="U012ABC,U034DEF" # Slack user IDs allowed to create/run tasks # SLACK_PROJECT_MAP='{"#eng":"workflow","#content":"content"}' # Slack channel → ty project -# SLACK_ANTHROPIC_API_KEY="sk-ant-..." # optional — enables LLM intent classification +# Intent classification uses the on-box `claude` CLI by default (claude.ai login — +# no API key, same tool the GM/executors use). It's hardened: no MCP/tools, a +# hard per-call cost cap, and a wall-clock timeout, so a single message can't +# trigger an agentic loop or runaway spend. With no claude CLI and no key it +# falls back to a keyword heuristic. +# SLACK_ANTHROPIC_API_KEY="sk-ant-..." # optional — use the Anthropic API instead of the CLI # SLACK_CLASSIFIER_MODEL="claude-haiku-4-5-20251001" +# SLACK_CLASSIFY_BUDGET_USD="0.05" # hard $/call cap for claude -p classification +# SLACK_MAX_CONCURRENT="3" # max in-flight classifications (burst guard) # === Optional: Cloudflare R2 asset hosting === # Set R2_ENABLED=true to enable R2 upload for deliverable assets diff --git a/docs/plans/2026-06-08-slack-module-design.md b/docs/plans/2026-06-08-slack-module-design.md index 9e1570c..c05b644 100644 --- a/docs/plans/2026-06-08-slack-module-design.md +++ b/docs/plans/2026-06-08-slack-module-design.md @@ -4,11 +4,18 @@ > `README.md`, tests), `templates/ty-slack.service.tmpl`, and the `SLACK_*` > wiring in `setup.sh` + `config.example.env`. This doc is the design rationale. > -> One refinement landed during build: the bridge runs **on the agents server** -> (next to `ty` + `notifications.jsonl`), so it reads the file and runs `ty` -> **directly** — no `runRemote()`/SSH needed. It's a zero-dependency Node `.mjs` -> (matching the Linear poller) rather than bun/TS, because the server has Node -> (bun is a GM-machine prereq). Outbound + inbound both shipped. +> Two refinements landed during build: +> 1. The bridge runs **on the agents server** (next to `ty` + `notifications.jsonl`), +> so it reads the file and runs `ty` **directly** — no `runRemote()`/SSH. It's a +> zero-dependency Node `.mjs` (matching the Linear poller) rather than bun/TS, +> because the server has Node (bun is a GM-machine prereq). +> 2. Intent classification defaults to the **on-box `claude` CLI** (`claude -p`, +> claude.ai login — no API key, the same primitive the GM/executors use), with +> the Anthropic API as an optional fallback and a keyword heuristic last. The +> CLI call is hardened against runaway spend: no MCP/tools (single turn), +> `--max-budget-usd` cap, timeout; plus a concurrency cap, self-loop guards, +> and an outbound rate cap. See "Runaway / cost protection" in +> `modules/slack/README.md`. Outbound + inbound both shipped. > Origin: TaskYou task #3723 — "Explore taskyou MCP sidecar extension for Slack" > TL;DR: Add a **`modules/slack/`** integration. Now that the channels work > (#28) and local/macOS support (#31) are **merged**, this is small: a Slack diff --git a/modules/slack/README.md b/modules/slack/README.md index 225f044..1d40c29 100644 --- a/modules/slack/README.md +++ b/modules/slack/README.md @@ -21,13 +21,40 @@ as the `ty-slack` systemd user service. Enable with `SLACK_ENABLED=true` in `SLACK_NOTIFY_CHANNEL`. - **Inbound.** Slack **Socket Mode** (no public URL) delivers `app_mention` and DM events. The bridge checks the sender against `SLACK_ALLOWED_USERS`, - classifies intent (Anthropic API if `ANTHROPIC_API_KEY` is set, otherwise a - built-in keyword heuristic), and runs the matching `ty` command. Replies go - in-thread. - -The bridge is **dependency-free** — raw `fetch` to the Slack and Anthropic HTTP -APIs plus the global `WebSocket` (Node 22+). No `npm install`, mirroring the -Linear poller. + classifies intent, and runs the matching `ty` command. Replies go in-thread. + +**Classifier (no API key by default).** Intent classification prefers the on-box +`claude` CLI (`claude -p`) — the same already-authenticated tool the GM and +executors use, so no separate `ANTHROPIC_API_KEY` is needed. It falls back to the +Anthropic API if `SLACK_ANTHROPIC_API_KEY` is set, then to a dependency-free +keyword heuristic. Override with `SLACK_CLASSIFIER=claude|api|heuristic`. + +The bridge is **dependency-free** — raw `fetch` to the Slack (and optional +Anthropic) HTTP APIs plus the global `WebSocket` (Node 22+). No `npm install`, +mirroring the Linear poller. + +## Runaway / cost protection + +Because classification spends tokens, the bridge is bounded on every axis: + +- **No self-loop.** The bridge ignores its own posts and any bot/edited/system + message (`bot_id` / `subtype` / `BOT_USER_ID`), so a reply can never trigger + another classification. +- **`claude -p` is sandboxed per call:** `--strict-mcp-config --mcp-config {}` + (no MCP servers) + `--disallowedTools …` (no Bash/Read/Write/…) keep it to a + **single turn** with no agentic spiral; `--max-budget-usd` (default `0.05`) is + a hard cost ceiling; plus a wall-clock timeout and capped output buffer. One + attempt, no retry — on any failure it falls back. +- **Concurrency cap.** At most `SLACK_MAX_CONCURRENT` (default 3) classifications + run at once; extra messages are politely declined, not queued — so a burst + can't fan out into unbounded subprocesses or spend. +- **Slack-retry safe.** Envelopes are acked in <3s (stops Slack re-sending) and + deduped by `event_id`. +- **Outbound rate cap.** The notifications poller posts at most + `SLACK_MAX_NOTIFS_PER_POLL` (default 25) per tick and drains the rest later, so + an abnormal flood can't storm Slack. A lost/corrupt state file skips the + backlog rather than replaying history. Only complete lines are consumed (a + half-written hook line is held for the next tick). ## Setup @@ -54,8 +81,11 @@ Linear poller. | `SLACK_NOTIFY_CHANNEL` | channel for task pings not tied to a Slack thread (e.g. `#taskyou`) | | `SLACK_ALLOWED_USERS` | comma-separated Slack user IDs allowed to drive `ty` | | `SLACK_PROJECT_MAP` | JSON map of Slack channel → ty project, e.g. `{"#eng":"workflow"}` | -| `SLACK_ANTHROPIC_API_KEY` | optional — enables LLM intent classification | +| `SLACK_ANTHROPIC_API_KEY` | optional — use the Anthropic API instead of the on-box `claude` CLI | | `SLACK_CLASSIFIER_MODEL` | classifier model (default `claude-haiku-4-5-20251001`) | +| `SLACK_CLASSIFY_BUDGET_USD` | hard `$`/call cap for `claude -p` (default `0.05`) | +| `SLACK_MAX_CONCURRENT` | max in-flight classifications (default `3`) | +| `SLACK_MAX_NOTIFS_PER_POLL` | max Slack posts per poll tick (default `25`) | ## Usage diff --git a/modules/slack/slack-bridge.mjs b/modules/slack/slack-bridge.mjs index bc47b70..4df4658 100755 --- a/modules/slack/slack-bridge.mjs +++ b/modules/slack/slack-bridge.mjs @@ -71,8 +71,21 @@ const CONFIG = { notificationsFile: process.env.NOTIFICATIONS_FILE || join(process.env.HOME || ".", "notifications.jsonl"), + // Classifier: prefer the on-box, already-authenticated `claude` CLI (claude.ai + // login — same primitive the GM/executors use, no API key). Fall back to the + // Anthropic API only if a key is set, then a dependency-free keyword heuristic. + classifierMode: process.env.SLACK_CLASSIFIER || "auto", // auto | claude | api | heuristic + claudeBin: process.env.CLAUDE_BIN || "claude", anthropicKey: process.env.ANTHROPIC_API_KEY || "", - anthropicModel: process.env.ANTHROPIC_MODEL || "claude-haiku-4-5-20251001", + classifierModel: + process.env.SLACK_CLASSIFIER_MODEL || + process.env.ANTHROPIC_MODEL || + "claude-haiku-4-5-20251001", + // Runaway-cost guards (see classifyViaClaude): + classifyBudgetUsd: process.env.SLACK_CLASSIFY_BUDGET_USD || "0.05", // hard $/call cap + classifyTimeoutMs: parseInt(process.env.SLACK_CLASSIFY_TIMEOUT_MS || "60000", 10), + maxConcurrentClassify: parseInt(process.env.SLACK_MAX_CONCURRENT || "3", 10), + maxNotifsPerPoll: parseInt(process.env.SLACK_MAX_NOTIFS_PER_POLL || "25", 10), pollIntervalMs: parseInt(process.env.SLACK_POLL_INTERVAL_MS || "5000", 10), }; @@ -317,11 +330,9 @@ const CLASSIFIER_SYSTEM = [ "Otherwise create_task. Never include commentary outside the JSON.", ].join("\n"); -async function classifyIntent(text, { threadTaskId, openTasks } = {}) { - if (!CONFIG.anthropicKey) { - return heuristicIntent(text, { threadTaskId }); - } - const context = [ +// Pure: assemble the user-facing context block (exported for tests). +export function buildClassifierContext(text, { threadTaskId, openTasks } = {}) { + return [ threadTaskId ? `This message is in the thread for task #${threadTaskId}.` : "", openTasks && openTasks.length ? `Open tasks: ${openTasks @@ -333,7 +344,67 @@ async function classifyIntent(text, { threadTaskId, openTasks } = {}) { ] .filter(Boolean) .join("\n"); +} +// Detect the on-box claude CLI once (cached). +let _claudeChecked = false; +let _claudeAvailable = false; +function hasClaudeCli() { + if (_claudeChecked) return _claudeAvailable; + _claudeChecked = true; + try { + execFileSync(CONFIG.claudeBin, ["--version"], { stdio: "ignore", timeout: 10_000 }); + _claudeAvailable = true; + } catch { + _claudeAvailable = false; + } + return _claudeAvailable; +} + +// Classify via the local, already-authenticated `claude` CLI — no API key. +// Hardened so a hostile or odd Slack message can't trigger an agentic loop or +// runaway spend: +// --strict-mcp-config --mcp-config {} → no MCP servers (no taskyou/other tools) +// --disallowedTools ... → no Bash/Read/Write/etc. → single turn +// --max-budget-usd → hard per-call cost ceiling +// timeout + maxBuffer, no retry → bounded wall-clock + memory +// Any failure returns null so the caller falls back (API → heuristic). +function classifyViaClaude(prompt) { + try { + const out = execFileSync( + CONFIG.claudeBin, + [ + "-p", + "--output-format", "json", + "--model", CONFIG.classifierModel, + "--strict-mcp-config", + "--mcp-config", '{"mcpServers":{}}', + "--disallowedTools", + "Bash,Read,Edit,Write,WebFetch,WebSearch,Task,Glob,Grep,NotebookEdit", + "--max-budget-usd", String(CONFIG.classifyBudgetUsd), + ], + { + input: prompt, + encoding: "utf8", + timeout: CONFIG.classifyTimeoutMs, + maxBuffer: 4 * 1024 * 1024, + } + ); + let text = out; + try { + const env = JSON.parse(out); + if (env && typeof env.result === "string") text = env.result; + } catch { + /* not a JSON envelope — treat stdout as the raw answer */ + } + return parseIntentResponse(text); + } catch (err) { + log(`claude -p classify failed (${err.message}); falling back`); + return null; + } +} + +async function classifyViaApi(context) { try { const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", @@ -343,19 +414,36 @@ async function classifyIntent(text, { threadTaskId, openTasks } = {}) { "content-type": "application/json", }, body: JSON.stringify({ - model: CONFIG.anthropicModel, - max_tokens: 512, + model: CONFIG.classifierModel, + max_tokens: 512, // bounded output system: CLASSIFIER_SYSTEM, messages: [{ role: "user", content: context }], }), }); const json = await res.json(); const out = json.content?.map((c) => c.text || "").join("") || ""; - const parsed = parseIntentResponse(out); - if (parsed && parsed.action) return parsed; + return parseIntentResponse(out); } catch (err) { - log(`classifier error, falling back to heuristic: ${err.message}`); + log(`anthropic api classify failed (${err.message}); falling back`); + return null; + } +} + +async function classifyIntent(text, { threadTaskId, openTasks } = {}) { + const mode = CONFIG.classifierMode; + const context = buildClassifierContext(text, { threadTaskId, openTasks }); + + // 1) On-box claude CLI (default). No API key; reuses claude.ai login. + if ((mode === "auto" || mode === "claude") && hasClaudeCli()) { + const r = classifyViaClaude(`${CLASSIFIER_SYSTEM}\n\n${context}`); + if (r && r.action) return r; + } + // 2) Anthropic API, only if a key is explicitly configured. + if ((mode === "auto" || mode === "api") && CONFIG.anthropicKey) { + const r = await classifyViaApi(context); + if (r && r.action) return r; } + // 3) Dependency-free keyword heuristic — no network, no spend. return heuristicIntent(text, { threadTaskId }); } @@ -365,9 +453,13 @@ async function classifyIntent(text, { threadTaskId, openTasks } = {}) { let BOT_USER_ID = ""; const seenEvents = new Set(); // event_id dedup (Slack retries) +let inFlight = 0; // concurrent classify/dispatch — bounds claude -p spawns + spend async function handleMessageEvent(event, state) { - // Ignore anything from a bot (including ourselves) and edited/system messages. + // Self-loop guard: never react to our own posts or any bot/edited/system + // message. Our replies carry bot_id (and subtype bot_message), so these + // filters prevent an infinite reply→classify→reply loop even if BOT_USER_ID + // failed to resolve at startup. if (event.bot_id || event.subtype) return; if (event.user && event.user === BOT_USER_ID) return; @@ -382,22 +474,38 @@ async function handleMessageEvent(event, state) { const channel = event.channel; const threadTs = event.thread_ts || event.ts; - const threadTaskId = state.threads[event.thread_ts] || null; - let openTasks = []; - try { - openTasks = JSON.parse(ty(["list", "--all", "--json"])); - } catch { - /* listing is best-effort context only */ + // Concurrency cap: don't let a burst of messages spawn unbounded classifier + // subprocesses (CPU + token spend). Excess requests are declined, not queued. + if (inFlight >= CONFIG.maxConcurrentClassify) { + log(`busy (${inFlight} in flight) — declining message from ${requester}`); + try { + await postMessage(channel, ":hourglass: One sec — finishing a few requests. Try again in a moment.", threadTs); + } catch {} + return; } - const intent = await classifyIntent(text, { threadTaskId, openTasks }); - log(`intent: ${intent.action}${intent.task_id ? ` #${intent.task_id}` : ""}`); - + inFlight++; try { - await dispatchIntent(intent, { channel, threadTs, requester, text }, state); - } catch (err) { - await postMessage(channel, `:x: ${err.message}`, threadTs); + const threadTaskId = state.threads[event.thread_ts] || null; + + let openTasks = []; + try { + openTasks = JSON.parse(ty(["list", "--all", "--json"])); + } catch { + /* listing is best-effort context only */ + } + + const intent = await classifyIntent(text, { threadTaskId, openTasks }); + log(`intent: ${intent.action}${intent.task_id ? ` #${intent.task_id}` : ""}`); + + try { + await dispatchIntent(intent, { channel, threadTs, requester, text }, state); + } catch (err) { + await postMessage(channel, `:x: ${err.message}`, threadTs); + } + } finally { + inFlight--; } } @@ -491,35 +599,63 @@ const HELP_TEXT = [ // Outbound: notifications.jsonl → Slack // ═════════════════════════════════════════════════════════════════════════════ -function readNewLines(path, fromOffset) { - // Returns { lines, offset }. On first read (fromOffset === null) we skip the - // existing backlog and just record EOF. Handles truncation/rotation. - if (!existsSync(path)) return { lines: [], offset: fromOffset }; +export function readNewChunk(path, fromOffset) { + // Returns { lines, baseOffset } where `lines` are COMPLETE raw lines only (up + // to the last newline) — a half-written hook line is left for the next tick + // instead of being parsed-then-skipped (which would lose it). On first read + // (fromOffset === null) we skip the existing backlog. Handles truncation. + if (!existsSync(path)) return { lines: [], baseOffset: fromOffset }; const size = statSync(path).size; - if (fromOffset === null) return { lines: [], offset: size }; - if (size < fromOffset) fromOffset = 0; // file shrank → rotated/truncated - if (size === fromOffset) return { lines: [], offset: size }; + if (fromOffset === null) return { lines: [], baseOffset: size }; + let base = fromOffset; + if (size < base) base = 0; // file shrank → rotated/truncated + if (size === base) return { lines: [], baseOffset: base }; const fd = openSync(path, "r"); try { - const len = size - fromOffset; + const len = size - base; const buf = Buffer.alloc(len); - readSync(fd, buf, 0, len, fromOffset); - const text = buf.toString("utf8"); - const lines = text.split("\n").filter((l) => l.trim()); - return { lines, offset: size }; + readSync(fd, buf, 0, len, base); + const lastNl = buf.lastIndexOf(0x0a); + if (lastNl === -1) return { lines: [], baseOffset: base }; // no complete line yet + const text = buf.toString("utf8", 0, lastNl + 1); + const lines = text.split("\n").slice(0, -1); // raw lines, keep byte parity + return { lines, baseOffset: base }; } finally { closeSync(fd); } } async function pollNotifications(state) { - const { lines, offset } = readNewLines(CONFIG.notificationsFile, state.notifyOffset); - if (offset !== state.notifyOffset) { - state.notifyOffset = offset; + const { lines, baseOffset } = readNewChunk(CONFIG.notificationsFile, state.notifyOffset); + + // First run (no cursor yet): just record EOF, skipping the backlog. This also + // means a lost/corrupt state file does NOT replay history into Slack. + if (state.notifyOffset === null) { + state.notifyOffset = baseOffset; saveState(state); + return; + } + if (lines.length === 0) { + if (baseOffset !== state.notifyOffset) { + state.notifyOffset = baseOffset; + saveState(state); + } + return; + } + + // Rate cap: an abnormal flood (or a misconfigured file) can't storm Slack. + // Excess lines aren't dropped — we advance the cursor only past what we post + // and drain the rest on later ticks (byte-accurate so nothing is skipped). + const take = lines.slice(0, CONFIG.maxNotifsPerPoll); + if (take.length < lines.length) { + log(`notifications: ${take.length}/${lines.length} this tick (cap ${CONFIG.maxNotifsPerPoll}); draining rest`); } - for (const line of lines) { + state.notifyOffset = baseOffset + Buffer.byteLength(take.join("\n") + "\n", "utf8"); + saveState(state); // advance BEFORE posting so a failed post never re-storms + + for (const line of take) { + if (!line.trim()) continue; let event; try { event = JSON.parse(line); @@ -554,15 +690,23 @@ async function openSocket() { return json.url; } +// Monotonic generation: only the newest connection's handlers stay live, so a +// reconnect race can't leave two sockets both delivering events (which would +// double-process every message and multiply classifier spend). +let socketGen = 0; + function connectSocket(state) { + const gen = ++socketGen; let ws; openSocket() .then((url) => { + if (gen !== socketGen) return; // superseded while connecting — abandon ws = new WebSocket(url); ws.addEventListener("open", () => log("Socket Mode connected")); ws.addEventListener("message", (ev) => { + if (gen !== socketGen) return; // stale socket — ignore let frame; try { frame = JSON.parse(ev.data); @@ -579,7 +723,9 @@ function connectSocket(state) { return; } - // Ack every envelope immediately (Slack requires < 3s). + // Ack every envelope immediately (Slack requires < 3s). Acking before we + // process also stops Slack from retrying the event (and re-triggering + // classification); event_id dedup below catches any that still slip. if (frame.envelope_id) { try { ws.send(JSON.stringify({ envelope_id: frame.envelope_id })); @@ -607,6 +753,7 @@ function connectSocket(state) { }); ws.addEventListener("close", () => { + if (gen !== socketGen) return; // a newer socket already took over log("Socket closed; reconnecting in 3s"); setTimeout(() => connectSocket(state), 3000); }); @@ -619,6 +766,7 @@ function connectSocket(state) { }); }) .catch((err) => { + if (gen !== socketGen) return; log(`openSocket failed: ${err.message}; retrying in 10s`); setTimeout(() => connectSocket(state), 10_000); }); diff --git a/modules/slack/slack-bridge.test.mjs b/modules/slack/slack-bridge.test.mjs index 7402355..1fcfeef 100644 --- a/modules/slack/slack-bridge.test.mjs +++ b/modules/slack/slack-bridge.test.mjs @@ -8,6 +8,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; import { stripMention, @@ -17,6 +19,8 @@ import { parseIntentResponse, heuristicIntent, formatNotification, + buildClassifierContext, + readNewChunk, } from "./slack-bridge.mjs"; test("stripMention removes the bot mention and trims", () => { @@ -110,3 +114,38 @@ test("formatNotification renders each event type", () => { assert.match(formatNotification({ event: "failed", task_id: "3", title: "Oops" }), /failed.*Oops/); assert.match(formatNotification({ event: "weird", task_id: "4", title: "Huh" }), /Task #4/); }); + +test("buildClassifierContext includes thread + open tasks + message", () => { + const ctx = buildClassifierContext("do the thing", { + threadTaskId: "12", + openTasks: [{ id: 1, title: "A" }, { id: 2, title: "B" }], + }); + assert.match(ctx, /thread for task #12/); + assert.match(ctx, /#1 A; #2 B/); + assert.match(ctx, /Message: do the thing/); + // minimal case: just the message + assert.equal(buildClassifierContext("hi", {}), "Message: hi"); +}); + +test("readNewChunk: only complete lines; partial last line is held back", () => { + const f = join(import.meta.dirname, ".test-notif.tmp"); + try { + // three complete lines, then a half-written one (no trailing newline) + writeFileSync(f, 'a\nb\nc\n{"half":'); + const r = readNewChunk(f, 0); + assert.deepEqual(r.lines, ["a", "b", "c"]); // partial line excluded + assert.equal(r.baseOffset, 0); + + // cursor at EOF of the complete portion → nothing new + const consumed = Buffer.byteLength("a\nb\nc\n", "utf8"); + assert.deepEqual(readNewChunk(f, consumed).lines, []); + + // first run (null) skips the backlog entirely + assert.deepEqual(readNewChunk(f, null).lines, []); + + // truncation/rotation: offset past EOF resets to start + assert.deepEqual(readNewChunk(f, 9999).lines, ["a", "b", "c"]); + } finally { + rmSync(f, { force: true }); + } +});