Skip to content

feat: local Slack poller, cbcode hive provider, and worker token-cap fix - #689

Draft
Monilnarang wants to merge 18 commits into
HarnessMD:mainfrom
Monilnarang:t92-local-slack-poller
Draft

Monilnarang wants to merge 18 commits into
HarnessMD:mainfrom
Monilnarang:t92-local-slack-poller

Conversation

@Monilnarang

Copy link
Copy Markdown

What & why

This branch is the local Slack poller work (t92 through thread replies, file upload, and read-acks), Coinbase cbcode as a hive provider, and a fix for ephemeral workers that were being killed after one short burst.

Workers were reaped for crossing their token cap after a handful of tool calls. Two accounting bugs stacked. Claude writes one turn as several transcript lines (thinking, tool call, text) and copies the full usage onto every line, so the total was about double. The cap also counted prompt-cache re-reads, which repeat the standing prompt on every tool call. A carry-over worker was killed at 3,070,099 tokens against a 3,000,000 cap; 2,932,412 of that was cache reads and 17,783 was output, and it had not created its file yet. Usage is now counted once per message id, and cache re-reads no longer count toward the worker cap.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs
  • Build / CI

Evidence

Before

A live worker, worker-da-carryover-build-1791101700, was reaped with this log line after the transcript double-count had already been removed:

[worker] reaping worker-da-carryover-build-1791101700 — token cap (3,070,099 > 3,000,000)

Counted once per message id, that session was input 62, output 17,783, cache write 119,842, cache read 2,932,412. The same shape killed the date-picker and carry-over workers a few minutes after each spawn, before they wrote the feature file.

After

tokensAgainstWorkerCap on those same figures is 137,687, which is under the 3,000,000 cap. node --test test/worker-tokens.test.cjs and node test/transcript-usage.test.cjs pass, including the case where one turn is split across three lines and a later sibling line with the same message id is not counted again.

npm run typecheck passed. npm run test:focused passed (0 failures), which also runs the Slack poller, thread, upload, and cbcode tests already on this branch.

How I tested it

  • OS: macOS (darwin 25.6.0)
  • Steps: Reproduced the reap from the running hive log and the worker's Claude transcript. Confirmed the reaper's 3,070,099 matched the deduped transcript sum, and that excluding cache reads leaves 137,687. Ran npm run typecheck and npm run test:focused.

Discord (optional)

Discord:

Checklist

  • Before and after evidence is attached above, under both headings.
  • npm run typecheck passes.
  • npm run test:focused passes.
  • npm run build succeeds.
  • This PR is one change. Unrelated fixes belong in their own PR.
  • I read the diff myself before opening this, and there is no debug output,
    commented-out code, or unrelated formatting churn in it.
  • Any new UI derives from DESIGN.md / tokens.ts — no ad-hoc colors,
    spacing, or fonts.
  • If I added art, it's my own or compatibly licensed, and listed in
    ATTRIBUTION.md.

Made with Cursor

Monilnarang and others added 18 commits August 25, 2026 23:24
cbcode is a Claude Code fork that adds a security layer. Out of the box the
app can't drive it: cbcode rejects `--permission-mode bypassPermissions`
("not allowed for security reasons") and exits on spawn, it isn't recognized
as hive-aware, and it lacks `/remote-control`. This teaches the app to treat
cbcode as the Claude provider family and use the `auto` permission mode it
accepts, so agents spawn and run under cbcode without per-user hacks.

- inferAgentProvider: recognize `cbcode` as the claude family (hive-aware,
  same CLI surface: --settings/--append-system-prompt/--permission-mode/--resume).
- agentProvider preset + hiddenClaude: use `--permission-mode auto` instead of
  `bypassPermissions` (cbcode directs callers to auto; auto is stricter, not a
  security downgrade).
- pty: normalizePermissionMode() defensively rewrites any stored agent recipe
  still carrying bypassPermissions -> auto when the resolved command is cbcode.
- useHive: gate `/remote-control` to real `claude` only, so cbcode agents boot
  straight into the orientation prompt instead of stranding the command text.

Co-Authored-By: Claude <noreply@anthropic.com>
Scope auto mode to cbcode so Coinbase laptops can launch agents without changing the existing Claude flow, and add regression coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
The cbcode detection regex was copy-pasted into three call sites, so the
definition of "is this cbcode" could drift between the permission-mode rewrite
and the hidden-session launcher. Extract it as isCbcodeCommand() next to
inferAgentProvider and reuse the existing commandBinary() helper, which already
strips paths and .cmd/.exe shim extensions the way every other provider check in
that file does.

Gating on the resolved binary (never the provider family) is what keeps a stock
claude install byte-identical, so the predicate is tested directly for both
directions.

Co-authored-by: Cursor <cursoragent@cursor.com>
Gating the GOD boot chain's /remote-control opener on the binary being literally
`claude` silently dropped it for any other Claude command — a renamed shim, or a
session whose provider was set explicitly rather than inferred. Only cbcode
lacks the slash command, so only cbcode should be excluded.

Move the decision out of the renderer hook and into providerAutomation, beside
the existing provider gate, as remoteControlCommandForAgent(). The policy now
lives with the rest of the provider automation and is unit-testable, which the
inline regex was not.

Co-authored-by: Cursor <cursoragent@cursor.com>
cbcode's launcher (injectClaudeSettingsOverlay) strips the --settings flag
munder-difflin passes and substitutes a gateway-headers-only overlay, so the
lifecycle hooks wired through --settings never register for a cbcode agent —
the floor bridge (live status, Stop->inbox drain, session id, context gauge)
is inert, even though cbcode spawns real bundled Claude Code.

Deliver the SAME generated hookSettings through a tier cbcode leaves alone:
the project-scope <cwd>/.claude/settings.local.json, which bundled Claude Code
reads and deep-merges natively. Same cth-hook.cjs shim, same HIVE_SOCK env,
no second hooks format.

- hive.ts: new isCbcode opt on ensureAgent; when set (and cwd valid) write the
  hookSettings to <cwd>/.claude/settings.local.json in addition to keeping the
  --settings flag (harmless for cbcode, still needed for stock claude/others).
- Additive + non-clobbering merge (mergeAdditive): an existing file is read and
  merged into, never blind-overwritten; existing keys win on conflict and hook
  arrays concatenate (ours appended), so a user- or cbcode-managed key/hook is
  never dropped (Coinbase rule #2). A corrupt file is left untouched.
- index.ts: gate on the RESOLVED binary via isCbcodeCommand(opts.command)
  (cbcode infers as the 'claude' provider, so the provider field can't tell it
  apart). Stock claude -> isCbcode false -> unchanged.
- Targets settings.local.json (the git-ignored personal overlay by Claude Code
  convention), never the committed settings.json and never ~/.claude.

Tests: new test/hive-cbcode-project-hooks.test.cjs (recovery, additive merge,
corrupt-file safety, stock-claude no-op). Full focused suite: 574 pass.

Co-Authored-By: Claude <noreply@anthropic.com>
…lization

Probes on a machine with cbcode show it accepts --settings (exit 0) but never
fires the lifecycle hooks, so a cbcode agent (which infers as claude/hiveAware)
had its standing goal delivered by neither hooks nor the PTY fallback and sat
idle. usesHookStandingGoal now returns false for a cbcode command so the goal is
prepended on the PTY.

The same probes show cbcode accepts every other hive flag (--remote-control-
session-name-prefix, --max-turns, --model, --add-dir, --disallowedTools) and
refuses only --permission-mode bypassPermissions (exit 1). So normalizePermission
Mode is generalized into normalizeArgsForBinary — one choke point that only
rewrites bypassPermissions -> auto (never the reverse) and strips nothing; a
non-cbcode binary is returned by reference, byte-identical to today.

Docs note the two limitations: cbcode runs without lifecycle hooks (no live
status, Stop-to-inbox drain, or session ids), and a shim named `claude` that is
really cbcode is not detected.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add resources/md-slack-poller.cjs: a zero-dependency standalone poller that
PULLS a Slack channel via conversations.history/replies with the laptop's own
bot token and re-delivers each new message to MD's existing local
SlackWebhookServer (127.0.0.1) as an HMAC-signed synthetic event_callback.

This reuses the entire existing ingestion path unchanged (verify -> slack-
trigger shouldTrigger -> channel:ts dedup -> file download -> renderer IPC ->
reply-in-thread), so one security-approved Slack app is reused by every
colleague, each on their own laptop, with outbound-only HTTPS that works behind
corporate NAT (no shared relay, no per-laptop tunnel, no inbound exposure).

- Per-channel + per-thread last-seen ts state file (0600, no secrets); baselines
  on first run (no history replay), --backfill N to opt in; persists after each
  forward so a crash never re-forwards.
- Self-loop guard drops bot-authored messages; server remains the single source
  of truth for what triggers a run.
- test/slack-poller.test.cjs: 12 unit tests (arg parse, ts compare,
  selectNewMessages dedup/order/self-loop, payload shape, signature parity).
- docs/local-slack-poller.md: design rationale, the shared-app scopes + one-time
  approval model, Slack 2025 rate-limit note, per-colleague setup (cron/launchd),
  security/tenant-isolation, and the NAT tunnel-decoupling follow-up for god.

Verified live: auth.test + conversations.history baseline pass; a poller-signed
POST to the running endpoint returns 200 (no side effects) and a bad signature
403. No changes to slack.ts or index.ts.

Co-Authored-By: Claude <noreply@anthropic.com>
The poller's PULL half already works behind NAT, but the REPLY half
(md-slack-reply.cjs -> loopback SlackReplyServer -> chat.postMessage) only came
up as part of tunnel bring-up: startSlackServer() treated a tunnelmole failure as
fatal and dropped slackServer, so on a no-tunnel laptop the reply endpoint +
done-observer never started.

Add an explicit opt-in `slackPollingOnly` config flag (default OFF, so the
classic single-user tunnel push flow is byte-for-byte unchanged — no regression):

- slack.ts: new `skipTunnel` option on SlackWebhookServer. When set, listen()
  binds to 127.0.0.1 ONLY (never a public listener) and start() SKIPS
  openTunnel(), resolving { ok: true } with no url.
- index.ts: pass skipTunnel: cfg.slackPollingOnly === true. Because start() now
  returns ok:true in poll-only mode, startSlackServer() proceeds to start the
  reply endpoint + done-observer — which is exactly what makes replies work with
  no tunnel and no inbound exposure.
- config.ts: slackPollingOnly?: boolean, default false.

SECURITY: no control downgraded. HMAC verify() is unchanged and still enforced on
every request incl. the poller's own loopback delivery (bad signature -> 403);
poll-only binds LESS (loopback only), never opening a public listener.

test/slack-polling-only.test.cjs (5 tests, real socket): start() ok with no
tunnel url; valid signed loopback event -> 200 and reaches onMessage; bad
signature -> 403; url_verification still answered; default construction leaves
poll-only off. Full focused suite: 580 pass. typecheck:node clean.
docs/local-slack-poller.md: removed the open-item caveat; documented the
poll-only reply flow + the slackPollingOnly setup step.

Co-Authored-By: Claude <noreply@anthropic.com>
… survive restart

Fixes the recurring "user's plain thread reply never reaches the bot" problem via
a shared, persistent ledger of threads the bot has replied in. Both gates fixed:

GATE 2 (trigger, slack.ts + index.ts): persist thread activation to disk and
treat any thread the bot has replied in as activated, so a subsequent human reply
there triggers a run with NO @-mention — even after an app restart.
- New userData/slack-bot-threads.json ledger (threadTs -> {channel, lastBotTs,
  updated}, mode 0600, channel+ts only, NEVER a token, bounded to 500 newest).
  Written by main whenever the bot replies: the loopback /reply path (onReplied,
  now passing channel + the posted message ts) and the done-summary fallback.
- SlackWebhookServer gains initialActivatedThreads (seeded at startup from the
  ledger, filtered to the channel) + activateThread() (live, on each bot reply).
  Scoped strictly to bot-participated threads, so unrelated channel messages
  never trigger. @-mention trigger and the self-loop guard are unchanged.
- postSlackReply now returns the posted message ts (used to baseline the poller).

GATE 1 (poller, md-slack-poller.cjs): auto-follow every thread the bot has posted
in — not just ones where the poller forwarded an @-mention — and persist the
follow-set across restarts.
- Reads the SAME ledger (loadBotThreadRoots) and merges its roots for this channel
  into the follow-set (mergeLedgerThreads), baselining each new thread's reply
  cursor to the bot's own last reply (lastBotTs) so no thread history is replayed
  and there's no race with a reply that arrived just before adoption. Keeps the
  50-thread bound + --no-threads; adds --ledger override.

SECURITY: HMAC verify() untouched (bad sig still 403); self-loop guard intact
(bot's own messages dropped); no token ever logged or written to any state/ledger
file; no new dependency or network entitlement.

TESTS: test/slack-poller.test.cjs extended (ledger read + mergeLedgerThreads:
baseline-to-lastBotTs, channel filter, never-clobber, malformed-input); new
test/slack-thread-activation.test.cjs (4 real-socket tests: seeded thread plain
reply fires; unrelated thread/top-level do not; activateThread live; @-mention
still works). typecheck:node clean; electron-vite build clean; focused suite
passing. Verified end-to-end with real slack.ts + a real ledger file across a
simulated restart: a plain reply (no @-mention) fired a run before AND after
tearing down and rebooting the server from the persisted ledger.

Co-Authored-By: Claude <noreply@anthropic.com>
The auto-follow set must not grow forever. On each poll pass the poller now drops
any followed thread with no activity (root ts or last-seen reply ts) in the
trailing 90 days (secondary to the newest-50 cap), via pruneStaleThreads(); and
mergeLedgerThreads() skips adopting ledger roots already past the cutoff.

Main applies the same 90-day cutoff to the persisted bot-thread ledger — stale
entries are dropped both on load (so they aren't re-seeded as activations) and on
write — so stale activations are cleaned up on the trigger side too.

Tests: pruneStaleThreads (keeps a thread whose last *reply* is recent even if the
root is ancient), mergeLedgerThreads cutoff skip, MAX_THREAD_AGE_SEC = 90d.
typecheck:node clean.

Co-Authored-By: Claude <noreply@anthropic.com>
…s just-over)

Co-Authored-By: Claude <noreply@anthropic.com>
…ing thread replies)

Root cause: replies go through this CLI (loopback /reply), not the MD main
process the poller comment assumed "owns" ledger writes — so slack-bot-threads.json
was never created (ENOENT), no recent thread was followed, and user thread-replies
(which conversations.history never returns) were silently dropped.

Fix: after a successful post, UPSERT the ledger the poller reads
(loadBotThreadRoots -> mergeLedgerThreads): threads[thread_ts] = { channel,
lastBotTs, ts, updated }. lastBotTs uses the posted message ts from the loopback
response when present (MD build with the T102 postSlackReply ts), else falls back
to the thread root. Merges/preserves existing entries, keeps the newest lastBotTs,
atomic 0600 write, and is FAIL-SOFT — a ledger error logs a warning but never
breaks a reply that already posted. Ledger holds channel + timestamps only, never
a token. Path resolves alongside the discovery config = same userData dir the
poller uses, so writer and reader agree. Refactored to a require.main guard +
exports for testability.

Verified: backfilled the 3 recent roots (channel C0BTJ8REX5Z) into the real
ledger; the poller's loadBotThreadRoots reads all 3 and mergeLedgerThreads adopts
them baselined at lastBotTs (no history replay). Poller runs via launchd every
120s (com.munderdifflin.slackpoller, RunAtLoad + StartInterval 120) — no user
action needed. test/slack-reply-ledger.test.cjs: 8 tests (create/merge/newest-
lastBotTs/root-fallback/fail-soft/corrupt-tolerant/poller shape compatibility).

Co-Authored-By: Claude <noreply@anthropic.com>
… MCP

Adds docs/cursor-n8n-slack-integration.md (Part A: spawn a Cursor-provider agent
in MD + the MCP-is-Cursor's-job gotcha; Part B: copy-pasteable Cursor-agent prompt
for an n8n Slack poll->reply workflow mirroring md-slack-poller/reply). Plus a
section on giving an existing MD *Claude* agent the same n8n MCP via Claude Code's
native config (claude mcp add --scope user / .mcp.json with env-expanded secrets),
since MD's MCP catalog is a Claude-only closed allowlist and can't add n8n.

Co-Authored-By: Claude <noreply@anthropic.com>
…al files

Root cause (src/main/index.ts downloadSlackFile): the fetch sent the bot token
but only rejected status >= 400, so Slack's 302 redirect on a files-pri URL passed
through and its 2-line HTML body (`<a href="…redir=…">Found</a>`) was written to
disk as the "file" — silent data loss on every Slack attachment.

Fix:
- Prefer url_private_download (threaded through slack-trigger.cjs extraction +
  SlackEventFile / SlackPayload types), fall back to url_private.
- FOLLOW 3xx redirects (up to SLACK_FILE_MAX_REDIRECTS), re-sending the
  Authorization: Bearer <botToken> header ONLY to Slack hosts (isSlackHost) so the
  token is never leaked to a signed CDN/redirect target.
- VALIDATE before writing: sniff the first chunk + content-type (looksLikeHtmlStub)
  and REFUSE to save an HTML auth-redirect/login stub — log a clear error instead
  of silently persisting a stub. Guarded by expectHtml so a genuinely-HTML upload
  (file.mimetype is html) is still saved.
- Keep the 10MB size cap and existing cleanup.

Pure helpers (slackFileUrl, isSlackHost, looksLikeHtmlStub) live in slack-trigger.cjs
so they're unit-testable without electron; re-exported to index.ts via slack.ts.

Test: test/slack-file-download.test.cjs (6 tests), incl. the EXACT stub from the
bug report detected, the token-leak host guard, and real binary/markdown bodies
NOT false-flagged. typecheck:node + electron-vite build clean. No token logged.

Co-Authored-By: Claude <noreply@anthropic.com>
… PDF/images

OUTBOUND (new): agents can now send media to the user on Slack without ever
touching the bot token, mirroring md-slack-reply.cjs.
- slack.ts: uploadSlackFile() implements Slack's CURRENT external-upload flow
  (files.getUploadURLExternal -> POST raw bytes to the pre-signed upload_url ->
  files.completeUploadExternal with channel_id + thread_ts [+ initial_comment]);
  files.upload is deprecated and not used. Token only in the Authorization header,
  never logged.
- SlackReplyServer gains a POST /upload route (same loopback bind + x-md-reply-token
  gate as /reply). The agent sends only a LOCAL PATH + target; main statSyncs
  (regular file, <=100MB), reads the bytes, and runs the upload — so the token
  stays in main. On success it records the thread as bot-participated (onReplied),
  so the poller follows it for the user's future replies. uploadFn is injectable
  for tests (Slack mocked, no real HTTP).
- resources/md-slack-upload.cjs: tokenless helper —
  `md-slack-upload.cjs --channel C.. --thread <ts> --file /abs/path [--title ..] [--comment ..]`
  POSTs the path to the local /upload endpoint (finds it via MD_SLACK_REPLY_CONFIG,
  same discovery file as the reply helper). require.main guard + exports.

INBOUND (verify): confirmed the e6f0c9c download fix covers the reported symptom —
looksLikeHtmlStub passes real application/pdf and image/png/jpeg/gif bodies while
still catching the html auth-redirect stub (incl. a .pdf url that 302s to login).

Tests: test/slack-upload.test.cjs (8, Slack mocked via uploadFn): reads real bytes
+ target, defaults filename to basename, token from main not caller, onReplied
recorded, missing/nonexistent file -> 400, wrong token -> 401, no bot token -> 503,
/reply unregressed; test/slack-file-download.test.cjs extended for PDF/images.
typecheck:node + electron-vite build green. No index.ts change.

Co-Authored-By: Claude <noreply@anthropic.com>
When SlackWebhookServer accepts a new inbound user message (the point it
becomes a kanban card), add a Slack reaction (reactions.add, name='+1') so
the sender sees it was read. Covers both the tunnel push path and the local
poller (which replays events through the same server).

- addSlackReaction() + pure mapReactionResult(): idempotent (already_reacted
  → ok) and surfaces missing_scope so the caller can log a one-time hint.
- Token isolation: the bot token is read lazily from main's config via
  getBotToken() (same pattern as /upload) and only ever appears in the
  Authorization header — never in a helper arg, file, or log.
- Best-effort/fire-and-forget: any reaction failure (network, missing scope,
  already_reacted) can never block or break ingestion; missing_scope logs the
  "grant reactions:write + reinstall" fix exactly once per run.
- Deduped: one reaction per logical message (shares the onMessage dedup).
- test/slack-reaction.test.cjs: reaction fires with correct channel/ts/name
  and main-supplied token; not for bot posts or unrelated chatter; once per
  dup; disabled without a token accessor; failures don't break ingestion;
  plus unit tests for mapReactionResult.

Needs rebuild + restart to take effect. Requires the bot scope
reactions:write (add + reinstall the Slack app if absent).

Co-Authored-By: Claude <noreply@anthropic.com>
…wn bot token

The hive's general Slack MCP gateway tool is bound to the user's corp Slack
workspace, not MD's dedicated app — so it always errors on this channel
(wrong workspace, not a scope/auth regression). This gives agents/god a
working ad hoc read path using the same config discovery as the poller.

Co-Authored-By: Claude <noreply@anthropic.com>
One Claude turn is written as several transcript lines that each repeat the
full usage, and the worker cap also counted prompt-cache re-reads, so a short
search burst crossed the cap and the worker was killed.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

🚫 This PR is missing its before/after evidence

Every pull request here has to show its work. Screenshots or a short screen recording, before the change and after it.

  • Before — no image or video under that heading
  • After — no image or video under that heading

How to fix it: edit the description, keep the ### Before and ### After headings from the template, and drag an image or video under each. GitHub uploads it inline. This check re-runs the moment you save.

A bug fix with no visible surface still needs it: show the failing behaviour, then the same steps passing. A terminal recording is fine.

Genuinely nothing to show — a CI tweak, a typo, a dependency bump? A maintainer can apply the no-visual-change label. Please don't ask unless it truly has no observable effect.

📖 CONTRIBUTING.md → Evidence is mandatory

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant