[object Object]

← back to Claude Slack

claude-slack: two-way Slack chat channel (poller + self-heal), send-side ready, gated on channels:history scope + bot invite

eefb03314b588e4947fd1ceca3d1221853d46848 · 2026-06-25 17:30:04 -0700 · Steve

Files touched

Diff

commit eefb03314b588e4947fd1ceca3d1221853d46848
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 25 17:30:04 2026 -0700

    claude-slack: two-way Slack chat channel (poller + self-heal), send-side ready, gated on channels:history scope + bot invite
---
 .gitignore                   |   7 +++
 README.md                    |  41 +++++++++++++
 com.steve.claude-slack.plist |  39 +++++++++++++
 health.js                    |  84 +++++++++++++++++++++++++++
 lib.js                       | 135 +++++++++++++++++++++++++++++++++++++++++++
 package.json                 |  12 ++++
 poller.js                    | 103 +++++++++++++++++++++++++++++++++
 probe.js                     |  27 +++++++++
 run.sh                       |   4 ++
 9 files changed, 452 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a56c19b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.env
+.env.*
+.cursors.json
+.health.json
+*.log
+node_modules/
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..60122e1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# claude-slack
+
+Two-way Slack chat channel for Claude — the Slack sibling of `claude-mail`.
+A launchd job polls configured channels every 10s, runs the local `claude` CLI
+on any message addressed to the bot, and posts the reply **in-thread**.
+
+- **Bot:** `dw_reports_bot` (`U0BC1PWB1SS`) in the Designerwallcoverings workspace.
+- **Channels** (`SLACK_CHANNELS` in `.env`):
+  - `slack-steve` → `#claude-programming` (`C09QUHKM20L`), `requireMention:false` — your 1:1 line; every message gets answered.
+  - `slack-general` → `#general` (`C03T1SQNJBH`), `requireMention:true` — shared team channel; Claude only answers messages that say `claude …` / `@claude`.
+
+## How it works
+`poller.js` (one pass per launchd fire):
+1. `auth.test` → resolve the bot's own user id (loop guard).
+2. For each channel: `conversations.history` since a saved per-channel cursor.
+   On **first sight** of a channel the cursor is anchored to "now" — it never
+   replies to backlog.
+3. `gate()` drops bot/own/system messages, off-allowlist authors, and (in
+   `requireMention` channels) anything not addressed to Claude.
+4. `runClaude()` runs `claude -p` headless with Slack-tuned rails.
+5. Reply posted with `chat.postMessage` + `thread_ts`.
+
+`health.js` pages Steve (CNCP + macOS notification + George email — all
+independent of the Slack token) after 3 consecutive failed passes, and again
+when it recovers.
+
+## Go-live blockers (one-time, Steve)
+The bot token is currently **send-only**. To enable two-way chat:
+1. **Add scopes** at api.slack.com → your app → OAuth & Permissions → Bot Token Scopes:
+   `channels:history` (public), `groups:history` (private), optionally `users:read`.
+2. **Reinstall** the app to the workspace (re-issues the token). If the token
+   changes, re-run the secrets route so `~/.claude.json` + this `.env` update.
+3. **Invite the bot** into each channel: `/invite @dw_reports_bot` in
+   `#claude-programming` and `#general` (reading history requires membership).
+4. Verify: `node --env-file=.env probe.js` — every channel should read "OK".
+5. Go live: `cp com.steve.claude-slack.plist ~/Library/LaunchAgents/ && \
+   launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.claude-slack.plist`
+
+## Commands
+- `npm run probe` — read-only auth + per-channel history/membership check.
+- `npm run poll`  — run one pass by hand.
diff --git a/com.steve.claude-slack.plist b/com.steve.claude-slack.plist
new file mode 100644
index 0000000..dde9335
--- /dev/null
+++ b/com.steve.claude-slack.plist
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>Label</key>
+    <string>com.steve.claude-slack</string>
+
+    <key>ProgramArguments</key>
+    <array>
+        <string>/bin/zsh</string>
+        <string>/Users/stevestudio2/Projects/claude-slack/run.sh</string>
+    </array>
+
+    <key>WorkingDirectory</key>
+    <string>/Users/stevestudio2/Projects/claude-slack</string>
+
+    <key>EnvironmentVariables</key>
+    <dict>
+        <key>PATH</key>
+        <string>/usr/local/bin:/Users/stevestudio2/.npm-global/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+    </dict>
+
+    <!-- Poll every 10s. launchd never starts a 2nd instance of this label while
+         one is still running, so a long claude run just defers the next fire. -->
+    <key>StartInterval</key>
+    <integer>10</integer>
+
+    <key>RunAtLoad</key>
+    <true/>
+
+    <key>KeepAlive</key>
+    <false/>
+
+    <key>StandardOutPath</key>
+    <string>/Users/stevestudio2/Projects/claude-slack/poller.log</string>
+    <key>StandardErrorPath</key>
+    <string>/Users/stevestudio2/Projects/claude-slack/poller.log</string>
+</dict>
+</plist>
diff --git a/health.js b/health.js
new file mode 100644
index 0000000..01134b1
--- /dev/null
+++ b/health.js
@@ -0,0 +1,84 @@
+'use strict';
+// claude-slack self-heal / alert layer (mirrors claude-mail/health.js).
+// poller calls recordFailure(msg) when a pass throws, recordSuccess() when one
+// completes. We count CONSECUTIVE failures and, past a threshold, page Steve
+// over channels that DO NOT depend on the (possibly-broken) Slack token:
+// CNCP (local HTTP), a native macOS notification, and George/Gmail email.
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { execFile } = require('node:child_process');
+
+const STATE = path.join(__dirname, '.health.json');
+const THRESHOLD = Number(process.env.HEALTH_FAIL_THRESHOLD || 3);          // consecutive fails before alerting
+const REALERT_MS = Number(process.env.HEALTH_REALERT_MS || 30 * 60 * 1000); // re-page at most every 30 min
+const CNCP = process.env.CNCP_URL || 'http://127.0.0.1:3333';
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const ALERT_TO = process.env.HEALTH_ALERT_EMAIL || 'steveabramsdesigns@gmail.com';
+
+function read() {
+  try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); }
+  catch { return { consecutiveFailures: 0, firstFailureAt: null, lastAlertAt: null, lastError: null }; }
+}
+function write(s) { try { fs.writeFileSync(STATE, JSON.stringify(s, null, 2)); } catch {} }
+const log = (...a) => console.log(new Date().toISOString(), '[health]', ...a);
+
+function macNotify(title, msg) {
+  return new Promise((res) => {
+    const safe = (s) => String(s).replace(/["\\]/g, ' ').slice(0, 240);
+    execFile('osascript', ['-e', `display notification "${safe(msg)}" with title "${safe(title)}" sound name "Basso"`], () => res());
+  });
+}
+async function cncpAlert(title, note) {
+  for (const ep of ['/api/parking-lot', '/api/parkinglot', '/api/wins']) {
+    try {
+      const body = ep.includes('wins')
+        ? { project: 'claude-slack', title, summary: note }
+        : { title, note, source: 'claude-slack-health' };
+      const r = await fetch(CNCP + ep, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+      if (r.ok) { log(`CNCP alert posted via ${ep}`); return true; }
+    } catch {}
+  }
+  return false;
+}
+async function georgeAlert(subject, text) {
+  try {
+    const r = await fetch(GEORGE + '/api/send', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + Buffer.from('admin:').toString('base64') },
+      body: JSON.stringify({ account: 'steve-office', to: ALERT_TO, subject, body: `<pre>${text}</pre>` }),
+    });
+    if (r.ok) { log('George alert email sent'); return true; }
+  } catch {}
+  return false;
+}
+
+async function fireAlert(kind, detail) {
+  const title = kind === 'down' ? '🔴 claude-slack poller is FAILING' : '🟢 claude-slack poller recovered';
+  const note = kind === 'down'
+    ? `The claude-slack poller has failed ${detail.count}x in a row since ${detail.since}. Last error: ${detail.error}. The Slack chat channel is NOT reading messages. Check: cd ~/Projects/claude-slack && node --env-file=.env probe.js`
+    : `claude-slack poller is back: it failed ${detail.count}x but a pass just succeeded. The Slack channel is reading again.`;
+  log(`FIRING ${kind} alert: ${note}`);
+  await Promise.all([macNotify(title, note), cncpAlert(title, note), georgeAlert(title, note)]);
+}
+
+async function recordFailure(errMsg) {
+  const s = read();
+  s.consecutiveFailures += 1;
+  s.lastError = String(errMsg || 'unknown');
+  if (!s.firstFailureAt) s.firstFailureAt = new Date().toISOString();
+  const now = Date.now();
+  const due = !s.lastAlertAt || (now - new Date(s.lastAlertAt).getTime()) > REALERT_MS;
+  if (s.consecutiveFailures >= THRESHOLD && due) {
+    s.lastAlertAt = new Date().toISOString();
+    write(s);
+    await fireAlert('down', { count: s.consecutiveFailures, since: s.firstFailureAt, error: s.lastError });
+  } else { write(s); }
+}
+async function recordSuccess() {
+  const s = read();
+  if (s.consecutiveFailures >= THRESHOLD) await fireAlert('recovered', { count: s.consecutiveFailures });
+  if (s.consecutiveFailures !== 0 || s.lastAlertAt) write({ consecutiveFailures: 0, firstFailureAt: null, lastAlertAt: null, lastError: null });
+}
+
+module.exports = { recordFailure, recordSuccess };
diff --git a/lib.js b/lib.js
new file mode 100644
index 0000000..08ac046
--- /dev/null
+++ b/lib.js
@@ -0,0 +1,135 @@
+'use strict';
+// claude-slack shared lib: config, Slack Web API client, message gate, claude runner.
+// Env is loaded by `node --env-file=.env` (Node 22+). No external deps — Node 22's
+// global fetch talks to Slack, and child_process spawns the local `claude` CLI.
+
+const { spawn } = require('node:child_process');
+
+// ---- Config ----------------------------------------------------------------
+// CHANNELS is a JSON array in .env: each entry { key, id, requireMention }.
+//  - requireMention:true  → Claude only answers messages that address it
+//    ("claude …", "@claude", "<@BOT>") — correct for shared/team channels.
+//  - requireMention:false → Claude answers every human message in the channel —
+//    correct for a 1:1 line. Pair with ALLOWED_SLACK_USERS to keep it private.
+function parseChannels() {
+  try { return JSON.parse(process.env.SLACK_CHANNELS || '[]'); }
+  catch (e) { console.error('SLACK_CHANNELS is not valid JSON:', e.message); return []; }
+}
+
+const cfg = {
+  token: process.env.SLACK_BOT_TOKEN,
+  botUserId: process.env.SLACK_BOT_USER_ID || '',          // resolved at runtime via auth.test if empty
+  channels: parseChannels(),
+  // Optional author allowlist (Slack user IDs, comma-separated). Empty = any
+  // human in the workspace may invoke Claude (the private workspace is the
+  // auth boundary). Resolving names→IDs needs the users:read scope.
+  allowedUsers: (process.env.ALLOWED_SLACK_USERS || '')
+    .split(',').map((s) => s.trim()).filter(Boolean),
+  // Regex that marks a message as "addressed to Claude" in requireMention channels.
+  mentionRe: new RegExp(process.env.MENTION_PATTERN || '(^|[^a-z])@?claude\\b', 'i'),
+  maxReply: Number(process.env.SLACK_MAX_REPLY_CHARS || 3500), // Slack hard cap ~4000/block
+  claudeBin: process.env.CLAUDE_BIN || 'claude',
+  workdir: process.env.CLAUDE_WORKDIR || process.env.HOME,
+  permissionMode: process.env.CLAUDE_PERMISSION_MODE || 'acceptEdits',
+  timeoutMs: Number(process.env.CLAUDE_TIMEOUT_MS || 900000),
+};
+
+// ---- Slack Web API client (token-auth, JSON) -------------------------------
+const SLACK = 'https://slack.com/api/';
+
+async function slack(method, params = {}, post = false) {
+  const headers = { Authorization: `Bearer ${cfg.token}` };
+  let url = SLACK + method, opts = { method: post ? 'POST' : 'GET', headers };
+  if (post) {
+    headers['Content-Type'] = 'application/json; charset=utf-8';
+    opts.body = JSON.stringify(params);
+  } else {
+    const q = new URLSearchParams(params).toString();
+    if (q) url += '?' + q;
+  }
+  const r = await fetch(url, opts);
+  const j = await r.json();
+  if (!j.ok) {
+    const err = new Error(`slack ${method} failed: ${j.error}` + (j.needed ? ` (needs ${j.needed})` : ''));
+    err.slackError = j.error; err.needed = j.needed; err.provided = j.provided;
+    throw err;
+  }
+  return j;
+}
+
+const api = {
+  authTest: () => slack('auth.test'),
+  history: (channel, oldest, limit = 50) =>
+    slack('conversations.history', oldest ? { channel, oldest, limit } : { channel, limit }),
+  post: (channel, text, thread_ts) =>
+    slack('chat.postMessage', { channel, text, thread_ts, unfurl_links: false, unfurl_media: false }, true),
+};
+
+// ---- Message gate ----------------------------------------------------------
+// Returns { ok, reason, prompt }. Drops the bot's own/loop messages, system
+// subtypes, off-allowlist authors, and (in requireMention channels) anything
+// not addressed to Claude. `prompt` is the cleaned text handed to claude.
+function gate(msg, channel, botUserId) {
+  if (!msg || typeof msg.ts !== 'string') return { ok: false, reason: 'no ts' };
+  // Skip anything that isn't a plain human message: joins/leaves/edits/bot posts.
+  if (msg.subtype) return { ok: false, reason: `subtype ${msg.subtype}` };
+  if (msg.bot_id) return { ok: false, reason: 'bot message (loop guard)' };
+  if (botUserId && msg.user === botUserId) return { ok: false, reason: 'own message (loop guard)' };
+
+  const text = (msg.text || '').trim();
+  if (!text) return { ok: false, reason: 'empty text' };
+
+  if (cfg.allowedUsers.length && !cfg.allowedUsers.includes(msg.user)) {
+    return { ok: false, reason: `author not allowlisted: ${msg.user}` };
+  }
+
+  let prompt = text;
+  if (channel.requireMention) {
+    if (!cfg.mentionRe.test(text) && !(botUserId && text.includes(`<@${botUserId}>`))) {
+      return { ok: false, reason: 'not addressed to claude' };
+    }
+    // Strip the address token so the prompt is just the actual request.
+    prompt = text
+      .replace(new RegExp(`<@${botUserId}>`, 'g'), '')
+      .replace(/^\s*(hey\s+|hi\s+|ok\s+)?@?claude\b[:,]?\s*/i, '')
+      .trim() || text;
+  }
+  return { ok: true, reason: 'ok', prompt };
+}
+
+// ---- Claude headless runner ------------------------------------------------
+const RAILS = [
+  'You are answering a Slack message from Steve (or an allowlisted teammate) in the Designer Wallcoverings workspace, as the bot dw_reports_bot.',
+  'You have full local access to this Mac and its tools, and Steve\'s global CLAUDE.md standing rules apply.',
+  'AUTONOMY: Do safe, reversible, local, read-only work directly and report the result.',
+  'GATED: For anything gated — production writes, sending messages to third parties, DNS/domain changes, spending money, deleting/overwriting, customer-facing changes, pushing to remotes — DO NOT execute it. Describe exactly what you would do and ask the person to confirm in a live session.',
+  'FORMAT: Reply in concise Slack-friendly plain text. Lead with the answer. Use Slack mrkdwn sparingly (*bold*, `code`); avoid long markdown, headers, or big code fences. Keep it short — this is a chat message, not an email.',
+].join(' ');
+
+function runClaude(prompt) {
+  return new Promise((resolve) => {
+    const env = { ...process.env };
+    // Force Max-plan OAuth on the home network (mirror Steve's `claude` shell fn).
+    delete env.ANTHROPIC_API_KEY;
+    delete env.ANTHROPIC_AUTH_TOKEN;
+    const args = [
+      '-p', prompt,
+      '--append-system-prompt', RAILS,
+      '--permission-mode', cfg.permissionMode,
+      '--output-format', 'text',
+    ];
+    const child = spawn(cfg.claudeBin, args, { cwd: cfg.workdir, env });
+    let out = '', err = '';
+    const timer = setTimeout(() => { child.kill('SIGKILL'); }, cfg.timeoutMs);
+    child.stdout.on('data', (d) => { out += d; });
+    child.stderr.on('data', (d) => { err += d; });
+    child.on('close', (code) => {
+      clearTimeout(timer);
+      if (code === 0 && out.trim()) return resolve({ ok: true, text: out.trim() });
+      resolve({ ok: false, text: (out.trim() || err.trim() || `claude exited ${code}`) });
+    });
+    child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, text: `spawn error: ${e.message}` }); });
+  });
+}
+
+module.exports = { cfg, api, gate, runClaude };
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f82fa64
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "claude-slack",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Two-way Slack chat channel for Claude — poll configured channels, answer messages addressed to the bot, reply in-thread. Sibling of claude-mail.",
+  "main": "poller.js",
+  "engines": { "node": ">=22" },
+  "scripts": {
+    "probe": "node --env-file=.env probe.js",
+    "poll": "node --env-file=.env poller.js"
+  }
+}
diff --git a/poller.js b/poller.js
new file mode 100644
index 0000000..265d412
--- /dev/null
+++ b/poller.js
@@ -0,0 +1,103 @@
+'use strict';
+// claude-slack poller — one pass: for each configured channel, read messages
+// newer than our saved cursor, run claude on the ones addressed to it, post the
+// reply in-thread, and advance the cursor. Run on an interval by launchd
+// (com.steve.claude-slack). Idempotent: a per-channel `latest ts` cursor means
+// no message is ever handled twice, and on FIRST sight of a channel we only set
+// the cursor to "now" (we never reply to backlog).
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { cfg, api, gate, runClaude } = require('./lib');
+const { recordSuccess, recordFailure } = require('./health');
+
+const CURSORS = path.join(__dirname, '.cursors.json');
+const log = (...a) => console.log(new Date().toISOString(), ...a);
+
+function readCursors() {
+  try { return JSON.parse(fs.readFileSync(CURSORS, 'utf8')); } catch { return {}; }
+}
+function writeCursors(c) {
+  try { fs.writeFileSync(CURSORS, JSON.stringify(c, null, 2)); } catch (e) { log('cursor write failed:', e.message); }
+}
+
+async function processChannel(ch, botUserId, cursors) {
+  const prev = cursors[ch.id];
+  // conversations.history returns newest-first; `oldest` is inclusive, so we
+  // filter strictly greater than the cursor below.
+  const res = await api.history(ch.id, prev, 50);
+  const msgs = (res.messages || []).filter((m) => m.ts && (!prev || Number(m.ts) > Number(prev)));
+
+  if (!prev) {
+    // First time we've seen this channel: anchor the cursor to the latest
+    // message so we answer only what arrives AFTER now — never the backlog.
+    const newest = (res.messages || [])[0]?.ts;
+    if (newest) cursors[ch.id] = newest;
+    log(`[${ch.key}] first run — anchored cursor at ${newest || '(empty channel)'}`);
+    return;
+  }
+  if (msgs.length === 0) { log(`[${ch.key}] no new messages`); return; }
+
+  // Oldest → newest so the conversation is answered in order.
+  msgs.sort((a, b) => Number(a.ts) - Number(b.ts));
+  log(`[${ch.key}] ${msgs.length} new message(s)`);
+
+  for (const msg of msgs) {
+    const d = gate(msg, ch, botUserId);
+    if (!d.ok) {
+      log(`[${ch.key}] skip ts=${msg.ts} user=${msg.user || '?'} reason="${d.reason}"`);
+      cursors[ch.id] = msg.ts;                  // advance past anything we deliberately ignore
+      writeCursors(cursors);
+      continue;
+    }
+
+    log(`[${ch.key}] ANSWER ts=${msg.ts} user=${msg.user} -> running claude`);
+    const res2 = await runClaude(d.prompt);
+    let reply = res2.ok ? res2.text : `I hit a problem running that locally:\n\n${res2.text}`;
+    if (reply.length > cfg.maxReply) reply = reply.slice(0, cfg.maxReply) + '\n…(truncated)';
+
+    try {
+      // Thread the reply under the original (or its parent thread) so shared
+      // channels stay tidy and the exchange is self-contained.
+      const threadTs = msg.thread_ts || msg.ts;
+      await api.post(ch.id, reply, threadTs);
+      log(`[${ch.key}] REPLIED in thread ${threadTs}`);
+    } catch (e) {
+      // Posting failed (network/scope) — leave the cursor BEFORE this message so
+      // the next pass retries instead of dropping Steve's question on the floor.
+      log(`[${ch.key}] POST FAILED ts=${msg.ts}: ${e.message}`);
+      return;
+    }
+    cursors[ch.id] = msg.ts;
+    writeCursors(cursors);
+  }
+}
+
+async function main() {
+  if (!cfg.token) throw new Error('SLACK_BOT_TOKEN missing');
+  if (!cfg.channels.length) throw new Error('SLACK_CHANNELS is empty');
+
+  // Resolve the bot's own user id once so the loop guard can skip its own posts.
+  let botUserId = cfg.botUserId;
+  if (!botUserId) { try { botUserId = (await api.authTest()).user_id; } catch (e) { log('auth.test failed:', e.message); throw e; } }
+
+  const cursors = readCursors();
+  for (const ch of cfg.channels) {
+    try { await processChannel(ch, botUserId, cursors); }
+    catch (e) {
+      // A per-channel failure (e.g. not invited, missing history scope) must not
+      // sink the other channels. Surface it; the pass still counts as degraded.
+      log(`[${ch.key}] ERROR: ${e.message}`);
+      throw e; // bubble so health records a failure when a scope/auth issue is global
+    }
+  }
+  writeCursors(cursors);
+}
+
+main()
+  .then(async () => { await recordSuccess(); process.exit(0); })
+  .catch(async (e) => {
+    log('FATAL', e.message);
+    try { await recordFailure(e.message); } catch (he) { log('health.recordFailure errored:', he.message); }
+    process.exit(1);
+  });
diff --git a/probe.js b/probe.js
new file mode 100644
index 0000000..c9639aa
--- /dev/null
+++ b/probe.js
@@ -0,0 +1,27 @@
+'use strict';
+// READ-ONLY diagnostic: can the bot authenticate, READ history, and is it a
+// member of each configured channel? Surfaces the exact missing scope / missing
+// invite so the go-live blockers are unambiguous. Run:
+//   node --env-file=.env probe.js
+const { cfg, api } = require('./lib');
+
+(async () => {
+  if (!cfg.token) { console.log('SLACK_BOT_TOKEN: MISSING'); process.exit(1); }
+  let botId = '';
+  try { const a = await api.authTest(); botId = a.user_id; console.log(`auth.test: OK — bot @${a.user} (${a.user_id}) team ${a.team}`); }
+  catch (e) { console.log('auth.test: FAIL —', e.message); process.exit(1); }
+
+  if (!cfg.channels.length) { console.log('SLACK_CHANNELS: empty'); process.exit(1); }
+  for (const ch of cfg.channels) {
+    try {
+      const r = await api.history(ch.id, undefined, 1);
+      console.log(`[${ch.key}] ${ch.id} read history: OK (${(r.messages || []).length} msg sampled, requireMention=${!!ch.requireMention})`);
+    } catch (e) {
+      let hint = '';
+      if (e.slackError === 'missing_scope') hint = `  → ADD SCOPE: ${e.needed} (reinstall app)`;
+      else if (e.slackError === 'not_in_channel') hint = `  → INVITE the bot: /invite @bot in that channel`;
+      else if (e.slackError === 'channel_not_found') hint = '  → wrong channel id, or private + bot not a member';
+      console.log(`[${ch.key}] ${ch.id} read history: FAIL — ${e.slackError || e.message}${hint}`);
+    }
+  }
+})();
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..41cc78a
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,4 @@
+#!/bin/zsh
+# launchd entrypoint — one poll pass, env from .env (Node 22 --env-file).
+cd "$(dirname "$0")" || exit 1
+exec /usr/local/bin/node --env-file=.env poller.js

(oldest)  ·  back to Claude Slack  ·  chore: macstudio3 migration — reconcile from mac2 + repoint 59d33fd →