Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,13 @@ SLACK_BOT_TOKEN=xoxb-your-token-here
# Must have connections:write scope
SLACK_APP_TOKEN=xapp-your-token-here

# Redis URL for state management
# Redis URL for state management (and inbound-webhook dedup)
REDIS_URL=redis://localhost:6379

# Shared secret for inbound custom webhooks. Senders must include it as the
# `X-Webhook-Secret` header. Pick something long and random.
WEBHOOK_SECRET=change-me

# Slack channel ID for #edison-os-updates (the target for meeting wrap-ups).
# Find it in Slack: channel name > "View channel details" > scroll to bottom > "Channel ID".
GRANOLA_SLACK_CHANNEL_ID=
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,19 @@ Slack bot using [chat-sdk](https://chat-sdk.dev) with `@chat-adapter/slack` (web

- Runtime: Bun (auto-loads `.env`, no dotenv needed)
- HTTP server: `Bun.serve()` on port 3123
- State: local Redis on `localhost:6379`
- State: local Redis on `localhost:6379` (also used for inbound-webhook dedup via `Bun.redis`)
- Slack setup and known issues: `docs/SLACK_INTEGRATION.md`
- Deployment (launchd LaunchAgent, external-volume Full Disk Access, codex cert gotcha): `docs/DEPLOY.md`

## Inbound webhooks (custom, non-Slack)

Pluggable webhook handlers live in `webhooks/`. Each module exports a factory `(ctx: { bot }) => { path, handler }`; `index.ts` mounts them under `Bun.serve({ routes })`. Add a source by creating `webhooks/<name>.ts` and pushing the factory into the `webhooks` array in `index.ts`.

Shared conventions:
- Auth: senders must include `X-Webhook-Secret: $WEBHOOK_SECRET`.
- Dedup: handlers `SET key NX EX 604800` in Redis; duplicates return 200 without re-processing.
- Async: handlers return 200 immediately and process in the background. Failures post a `:warning:` notice to the same Slack channel.
- Codex: reuse `lib/codex.ts#streamCodex` and pipe it into `bot.channel("slack:Cxxx").post(...)` for a streamed Slack reply.

Current sources:
- `webhooks/granola.ts` — `POST /api/webhooks/granola`. Expects `{ meeting_id, title, transcript, notes?, attendees? }`. Posts a Codex summary to `GRANOLA_SLACK_CHANNEL_ID` (intended: `#edison-os-updates`).
41 changes: 41 additions & 0 deletions deploy/com.edison.assistant-bot.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
<!--
launchd LaunchAgent template for the Slack bot. See docs/DEPLOY.md.

This is a TEMPLATE: __USER__ and __WORKDIR__ are placeholders that must be
substituted before installing to ~/Library/LaunchAgents/. The install command
in docs/DEPLOY.md renders it with sed. Do NOT add secrets — env comes from
.env in WorkingDirectory (auto-loaded by Bun).
-->
<!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.edison.assistant-bot</string>

<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/bun</string>
<string>run</string>
<string>index.ts</string>
</array>

<!-- Absolute path to your checkout (real path, not a symlink). -->
<key>WorkingDirectory</key>
<string>__WORKDIR__</string>

<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>

<key>StandardOutPath</key><string>/Users/__USER__/Library/Logs/assistant-bot.log</string>
<key>StandardErrorPath</key><string>/Users/__USER__/Library/Logs/assistant-bot.log</string>

<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/__USER__/.bun/bin</string>
<key>HOME</key>
<string>/Users/__USER__</string>
</dict>
</dict>
</plist>
102 changes: 102 additions & 0 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Deployment (macOS launchd)

The bot runs as a per-user launchd **LaunchAgent** so it auto-starts on login and
auto-restarts on crash. Template: [`deploy/com.edison.assistant-bot.plist`](../deploy/com.edison.assistant-bot.plist).

## Install

The template contains two placeholders — `__USER__` and `__WORKDIR__` — that must
be substituted before installing. `sed` handles both:

```sh
# 1. Render the template into ~/Library/LaunchAgents/, substituting the current
# user and the absolute path to this checkout.
sed -e "s|__USER__|$USER|g" -e "s|__WORKDIR__|$(pwd -P)|g" \
deploy/com.edison.assistant-bot.plist \
> ~/Library/LaunchAgents/com.edison.assistant-bot.plist

# 2. Make sure Redis is running (used for chat state + inbound-webhook dedup).
brew services start redis

# 3. Load it (bootstrap into the GUI session so it starts on login).
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.edison.assistant-bot.plist

# 4. Verify.
tail -n 20 ~/Library/Logs/assistant-bot.log # expect "Bot is running on http://localhost:3123"
lsof -i :3123 # expect a bun process LISTEN
curl -i -X POST http://localhost:3123/api/webhooks/slack -d '{}' # expect 401 (adapter reachable)
```

Run the install as the Unix user the bot should run under, from the repo root:
`$USER`, `$(id -u)`, and `~/Library/…` in the block above all resolve against that
same user. `pwd -P` gives the real checkout path without symlinks — important
because a symlinked `WorkingDirectory` may hit the external-volume TCC gotcha
below. `Label` intentionally stays `com.edison.assistant-bot` (the reverse-DNS
prefix refers to the org, not the local user).

Env vars (`SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `REDIS_URL`, `WEBHOOK_SECRET`,
`GRANOLA_SLACK_CHANNEL_ID`) are auto-loaded by Bun from `.env` in `WorkingDirectory`.
**Do not put secrets in the plist.**

## Managing the agent

```sh
launchctl kickstart -k gui/$(id -u)/com.edison.assistant-bot # restart
launchctl bootout gui/$(id -u)/com.edison.assistant-bot # stop + unload
launchctl list | grep assistant-bot # status (col 1 = PID, col 2 = last exit)
```

## Gotcha: repo on an external / non-boot volume needs Full Disk Access

If the checkout lives on an **external or removable volume** (e.g. a USB drive under
`/Volumes/...`, possibly reached via a `~/Github` symlink), macOS **TCC blocks
launchd-spawned processes from reading file *contents* there** — metadata/`ls` is
allowed, but `read()` returns `EPERM` ("Operation not permitted"). Symptoms: the agent
shows a PID but the log is empty, port 3123 never binds, and `curl` gets connection
refused. It works from an interactive terminal only because Terminal already holds the
grant.

**Fix:** grant **Full Disk Access to the bun binary**:

1. System Settings → Privacy & Security → **Full Disk Access** → **+**
2. Add `/opt/homebrew/bin/bun` (⌘⇧G to type the path), toggle it **on**.
3. Restart the agent: `launchctl kickstart -k gui/$(id -u)/com.edison.assistant-bot`

The grant is keyed to the binary, so after `brew upgrade bun` (its Cellar path/cdhash
changes) you may need to re-add it. If the volume is slow to mount at boot, the first
launch attempt exits and `KeepAlive`+`ThrottleInterval` retry every 10s until the volume
is readable — the agent self-heals.

Diagnose a suspected TCC block with a boot-volume probe script that `ls`es then `head`s a
file on the external volume from a throwaway LaunchAgent: `ls` succeeds, `head` fails with
`Operation not permitted`.

## Gotcha: codex CLI must not be on a revoked-cert build

The Granola webhook shells out to the `codex` CLI (`lib/codex.ts`). If codex hangs on
**every** invocation — even `codex --version`, stuck in `_dyld_start` before `main()` —
its signing certificate has likely been **revoked**. Check:

```sh
# Inspect the SAME codex the bot actually spawns. lib/codex.ts runs bare
# `codex`, which the LaunchAgent resolves via its fixed PATH — not the
# interactive shell's PATH (which nvm/nodenv/asdf may shadow). Reproduce that
# PATH here, then find the arch-specific vendor binary near the resolved codex.
# BSD-safe; no `readlink -f`; works on arm64 and x86_64.
BOT_PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$HOME/.bun/bin"
NPM_BIN="$(PATH="$BOT_PATH" command -v npm)"
[ -z "$NPM_BIN" ] && { echo "npm not on the bot's PATH"; return 1 2>/dev/null || exit 1; }
NPM_ROOT="$("$NPM_BIN" root -g)"
CODEX_BIN="$(find "$NPM_ROOT" -type f -name codex -path '*/vendor/*/bin/codex' 2>/dev/null | head -n 1)"
if [ -z "$CODEX_BIN" ]; then
echo "codex arch binary not found under $NPM_ROOT"
else
spctl --assess -vvv --type execute "$CODEX_BIN"
fi
# CSSMERR_TP_CERT_REVOKED => revoked
```

**Fix:** upgrade to a build signed with a valid cert: `npm install -g @openai/codex@latest`.
(Observed: 0.129.0 was revoked and hung; 0.144.0 assesses clean and works.) Note `codex
exec` also requires running from a git repo or trusted dir — the bot satisfies this because
its `WorkingDirectory` is this repo.
59 changes: 9 additions & 50 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,12 @@
import { Chat, toAiMessages } from "chat";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";
import { streamCodex } from "./lib/codex";
import { granolaWebhook } from "./webhooks/granola";
import type { WebhookRoute } from "./webhooks/types";

const slackAdapter = createSlackAdapter();

async function* streamCodex(prompt: string): AsyncIterable<string> {
const proc = Bun.spawn(
["codex", "exec", "--ephemeral", "-s", "read-only", "--json", "-"],
{ stdin: "pipe", stdout: "pipe", stderr: "ignore" },
);
proc.stdin.write(prompt);
proc.stdin.end();

const decoder = new TextDecoder();
let buffer = "";
let messageCount = 0;

for await (const chunk of proc.stdout) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";

for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (
event.type === "item.completed" &&
event.item?.type === "agent_message" &&
event.item.text
) {
if (messageCount > 0) yield "\n\n---\n\n";
yield event.item.text;
messageCount++;
}
} catch {}
}
}

if (buffer.trim()) {
try {
const event = JSON.parse(buffer);
if (
event.type === "item.completed" &&
event.item?.type === "agent_message" &&
event.item.text
) {
if (messageCount > 0) yield "\n\n---\n\n";
yield event.item.text;
}
} catch {}
}
}

const bot = new Chat({
userName: "edison-bot",
adapters: {
Expand All @@ -76,6 +30,8 @@ bot.onNewMention(async (thread, message) => {

await bot.initialize();

const webhooks: WebhookRoute[] = [granolaWebhook({ bot })];

const port = 3123;

Bun.serve({
Expand All @@ -86,10 +42,13 @@ Bun.serve({
return slackAdapter.handleWebhook(req);
},
},
...Object.fromEntries(webhooks.map((w) => [w.path, { POST: w.handler }])),
},
fetch(req) {
return new Response("Not found", { status: 404 });
},
});

console.log("Bot is running! Webhook listening on http://localhost:3123/api/webhooks/slack");
console.log(`Bot is running on http://localhost:${port}`);
console.log(` POST /api/webhooks/slack`);
for (const w of webhooks) console.log(` POST ${w.path}`);
53 changes: 53 additions & 0 deletions lib/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export async function* streamCodex(prompt: string): AsyncIterable<string> {
const proc = Bun.spawn(
["codex", "exec", "--ephemeral", "-s", "read-only", "--json", "-"],
{ stdin: "pipe", stdout: "pipe", stderr: "ignore" },
);
proc.stdin.write(prompt);
proc.stdin.end();

const decoder = new TextDecoder();
let buffer = "";
let messageCount = 0;

for await (const chunk of proc.stdout) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";

for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (
event.type === "item.completed" &&
event.item?.type === "agent_message" &&
event.item.text
) {
if (messageCount > 0) yield "\n\n---\n\n";
yield event.item.text;
messageCount++;
}
} catch {}
}
}

if (buffer.trim()) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
try {
const event = JSON.parse(buffer);
if (
event.type === "item.completed" &&
event.item?.type === "agent_message" &&
event.item.text
) {
if (messageCount > 0) yield "\n\n---\n\n";
yield event.item.text;
}
} catch {}
}

const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`codex exited with code ${exitCode}`);
}
}
Loading