diff --git a/README.md b/README.md index 99b80c6..9d00ead 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,95 @@ Latest local verification from the Petrus machine, dogfooded against `v0.6.1`: ## Integrations +### MCP server (`src/mcp-server.mjs`) + +Exposes IAK's tmux-backed wake / list / run primitives as MCP tools so any +MCP-aware client (Claude Desktop / Code, Cursor, custom agents) can drive the +agent fleet directly without re-implementing tmux send-keys. + +Tools exposed (stdio transport): + +| Tool | Args | Notes | +|-----------------|-----------------------------------|-------| +| `wake_ide` | `session`, `text?` (default `"check rooms"`) | Sends nudge text and presses Enter in the named tmux session. | +| `list_sessions` | (none) | Returns every live tmux session on the host with attach state + window count. | +| `wake_all` | `text?` (default `"check rooms"`) | Sends the same nudge to every session IAK knows about (per-session pass/fail). Configure via `mcp.sessions: ["...", ...]`. Falls back to `tmux.ide_session` + `tmux.default_session`. | +| `read_session` | `session`, `lines?` (default 50) | `tmux capture-pane` of the named session — see what the agent printed in response to a `wake_ide`. | +| `tmux_run` | `cmd`, `session?`, `cwd?`, `timeoutSec?` | Runs an allowlisted command in a tmux session. **Only registered when `tmux.allow` is non-empty or `mcp.allow_unrestricted: true` is set.** Otherwise omitted entirely from the tool list (fail-closed). Same allowlist as the CLI's `tmux run` subcommand. | + +### MCP-specific config keys + +Added to `ide-agent-kit.json` (or your own config path passed via `--config`): + +```jsonc +{ + "mcp": { + // Explicit list of sessions wake_all should target. + // If omitted, falls back to [tmux.ide_session, tmux.default_session]. + "sessions": ["claudemb", "antigravity", "codex"], + + // Set true to expose tmux_run with NO allowlist filter — any command runs. + // Default: false. Use only on a trusted host with a trusted MCP client. + "allow_unrestricted": false, + + // User-confirmation flow (request_confirmation, list_intents, + // approve_intent, deny_intent tools). Tools are only registered if at + // least one channel below is configured. + "confirmations": { + "port": 8788, // HTTP port for /intent/:id/decision + "host": "127.0.0.1", // bind host (keep local unless tunneled) + "auth_token": "", // optional bearer for the HTTP endpoint + "callback_base": "http://...", // URL the watch / chat reach back on; defaults to http://host:port + "room": "thinkoff-development", // GroupMind room to post the prompt in (uses poller.api_key) + "codewatch_gate_url": "http://family@localhost:18791/intent", + "codewatch_gate_token": "" // bearer for CLAWWATCH_GATE + } + } +} +``` + +### Confirmation flow (request_confirmation tool) + +When `mcp.confirmations` is configured, four extra tools appear: + +| Tool | Args | Notes | +|-----------------------|---------------------------------------------------|-------| +| `request_confirmation`| `prompt`, `session?`, `channels?`, `timeoutSec?` | Posts an Approve / Deny prompt to GroupMind and/or Codewatch and BLOCKS until user decides or timeout. Returns `{decision: "approve"\|"deny"}` or `{status: "timeout", id}`. | +| `list_intents` | (none) | All intents — pending and recently decided. | +| `approve_intent` | `id` | Manually settle a pending intent (e.g. MCP override). | +| `deny_intent` | `id` | | + +End-to-end: +1. MCP-aware agent calls `request_confirmation({prompt: "Drop production DB?"})`. +2. The IAK MCP server posts to GroupMind room (`/approve ` / `/deny ` quick replies) and to the CLAWWATCH_GATE (Android interactive notification with Approve / Deny buttons that vibrate the watch). +3. User taps Approve / Deny on the watch — Codewatch's notification action POSTs to `http:///intent//decision` with `{decision: "approve"}`. +4. The MCP tool's blocking `request_confirmation` call resolves with the decision. +5. The agent proceeds (or doesn't) based on the decision. + +Run standalone: + +```bash +node bin/iak-mcp.mjs # default config +node bin/iak-mcp.mjs --config /path/to/config.json +npm run mcp # via package.json script +``` + +Wire into Claude Desktop / Code: + +```json +{ + "mcpServers": { + "ide-agent-kit": { + "command": "node", + "args": ["/absolute/path/to/ide-agent-kit/bin/iak-mcp.mjs"] + } + } +} +``` + +After install: restart the MCP client. The four tools above appear in the tool +picker and can be called directly. + ### GitHub Webhooks (`src/webhook-server.mjs`) Receives GitHub webhook events, verifies HMAC signatures, normalizes them to a stable JSON schema, and appends to a local JSONL queue. Optionally nudges a tmux session when events arrive. diff --git a/bin/iak-mcp-daemon.mjs b/bin/iak-mcp-daemon.mjs new file mode 100755 index 0000000..66e00c6 --- /dev/null +++ b/bin/iak-mcp-daemon.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only +// +// Long-running daemon flavor of the MCP confirmation flow. Keeps the HTTP +// listener up + watches the configured GroupMind room for `/approve ` and +// `/deny ` quick-reply messages and routes them to the local +// /intent/:id/decision endpoint. +// +// Used together with the MCP server: an MCP client triggers +// request_confirmation which posts to the room with an intent id; the user +// replies "/approve abc12345" from the watch / chat; this daemon catches the +// reply and POSTs it to the same HTTP listener that the MCP tool is waiting +// on; the MCP tool resolves with {decision: "approve"}. +// +// Run: node bin/iak-mcp-daemon.mjs [--config path/to/config.json] + +import { loadConfig } from '../src/config.mjs'; +import { + startConfirmationsServer, + decideIntent, + getIntent, + createIntent, + makeGroupmindAnnouncer, + makeCodewatchAnnouncer, + composeAnnouncers, +} from '../src/confirmations.mjs'; + +const argv = process.argv.slice(2); +let configPath; +for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--config' && argv[i + 1]) { configPath = argv[i + 1]; i++; } +} +const config = await loadConfig(configPath); +const cc = config?.mcp?.confirmations || {}; +if (!cc.room && !cc.codewatch_gate_url) { + process.stderr.write('[iak-mcp-daemon] no mcp.confirmations channels configured — exiting\n'); + process.exit(2); +} + +// Build the announcer map up-front so the HTTP server can use it for +// externally-created intents (POST /intent). +const apiKey = config?.poller?.api_key; +const room = cc.room; + +const serverAnnouncerMap = {}; +if (cc.room && apiKey) { + serverAnnouncerMap.groupmind = makeGroupmindAnnouncer({ + apiKey, room: cc.room, callbackBase: cc.callback_base || `http://127.0.0.1:${cc.port || 8788}`, + }); +} +if (cc.codewatch_gate_url) { + serverAnnouncerMap.codewatch = makeCodewatchAnnouncer({ + gateUrl: cc.codewatch_gate_url, gateToken: cc.codewatch_gate_token, + }); +} +const serverAnnounce = composeAnnouncers(serverAnnouncerMap); + +// Start the HTTP listener first so any decisions can settle. +// Wake script: defaults to scripts/claudemb-wake.sh in this repo. +// Override via mcp.confirmations.wake_script in config. +const wakeScript = cc.wake_script || + new URL('../scripts/claudemb-wake.sh', import.meta.url).pathname; + +startConfirmationsServer({ + port: cc.port || 8788, + host: cc.host || '127.0.0.1', + authToken: cc.auth_token || '', + receiptsPath: config?.receipts?.path, + announce: serverAnnounce, + wakeScript, +}); +console.log(`[iak-mcp-daemon] HTTP listener on http://${cc.host || '127.0.0.1'}:${cc.port || 8788} (POST /intent enabled: ${Object.keys(serverAnnouncerMap).join(',') || 'no announcers'})`); + +// Chat-reply poller: watch the configured GroupMind room for "/approve " +// and "/deny " messages and route them to the local intent endpoint. +if (!apiKey) { + console.warn('[iak-mcp-daemon] poller.api_key missing — chat-reply poller disabled'); +} else if (!room) { + console.warn('[iak-mcp-daemon] mcp.confirmations.room missing — chat-reply poller disabled'); +} else { + startChatReplyPoller({ apiKey, room, intervalMs: 5000 }); + console.log(`[iak-mcp-daemon] chat-reply poller watching room "${room}" every 5s`); +} + +// Codewatch path: just announce when a message arrives at /push (not implemented +// here, would route through CodexMB's watch-gate.py — see PR #8). + +// Manual ping: prove the round-trip by creating one demo intent on first run if +// --demo is passed. +if (argv.includes('--demo')) { + const announcerMap = {}; + if (cc.room && apiKey) { + announcerMap.groupmind = makeGroupmindAnnouncer({ + apiKey, room: cc.room, callbackBase: cc.callback_base || `http://127.0.0.1:${cc.port || 8788}`, + }); + } + if (cc.codewatch_gate_url) { + announcerMap.codewatch = makeCodewatchAnnouncer({ + gateUrl: cc.codewatch_gate_url, gateToken: cc.codewatch_gate_token, + }); + } + const announce = composeAnnouncers(announcerMap); + const id = await createIntent({ + prompt: 'iak-mcp-daemon demo: approve to confirm the round-trip works.', + session: 'demo', + channels: Object.keys(announcerMap), + announce, + receiptsPath: config?.receipts?.path, + }); + console.log(`[iak-mcp-daemon] demo intent created: id=${id}`); +} + +// Keep the process alive. +process.stdin.resume(); + +// --- helpers -------------------------------------------------------------- + +function startChatReplyPoller({ apiKey, room, intervalMs }) { + const seen = new Set(); + let primed = false; + const poll = async () => { + try { + const url = `https://groupmind.one/api/v1/rooms/${encodeURIComponent(room)}/messages?limit=30`; + const res = await fetch(url, { headers: { 'X-API-Key': apiKey } }); + if (!res.ok) return; + const body = await res.json(); + const messages = body?.messages || []; + for (const m of messages) { + if (seen.has(m.id)) continue; + seen.add(m.id); + if (!primed) continue; // ignore historical messages on first pass + const text = (m.body || '').trim(); + const match = text.match(/^\/(approve|deny)\s+([a-f0-9]+)$/i); + if (!match) continue; + const decision = match[1].toLowerCase(); + const id = match[2]; + const intent = getIntent(id); + if (!intent) { + console.log(`[iak-mcp-daemon] /${decision} ${id} from ${m.from}: unknown intent, ignoring`); + continue; + } + const r = decideIntent(id, decision); + console.log(`[iak-mcp-daemon] /${decision} ${id} from ${m.from}: ${r.ok ? 'settled' : r.error}`); + } + primed = true; + } catch (e) { + console.warn(`[iak-mcp-daemon] poll error: ${e.message}`); + } + }; + poll(); + setInterval(poll, intervalMs); +} diff --git a/bin/iak-mcp.mjs b/bin/iak-mcp.mjs new file mode 100755 index 0000000..b21f0d4 --- /dev/null +++ b/bin/iak-mcp.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only +// +// Entry point for the IAK MCP server. Designed to be invoked by an MCP +// client over stdio. Example Claude Desktop / Code config: +// +// { +// "mcpServers": { +// "ide-agent-kit": { +// "command": "node", +// "args": ["/path/to/ide-agent-kit/bin/iak-mcp.mjs"] +// } +// } +// } +// +// Or via npx if installed as a package: `npx ide-agent-kit-mcp`. + +import { runMcpServer } from '../src/mcp-server.mjs'; + +const argv = process.argv.slice(2); +let configPath; +for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--config' && argv[i + 1]) { + configPath = argv[i + 1]; + i++; + } +} + +runMcpServer({ configPath }).catch((e) => { + process.stderr.write(`[iak-mcp] fatal: ${e.message}\n`); + process.exit(1); +}); diff --git a/docs/auto-wake.md b/docs/auto-wake.md new file mode 100644 index 0000000..e308eef --- /dev/null +++ b/docs/auto-wake.md @@ -0,0 +1,147 @@ +# Auto-wake for Claude Code (desktop app) + +Background daemon + AppleScript injector that automatically wakes a Claude +Code instance when new messages land in a watched Ant Farm / GroupMind room. +The agent then reads the messages via a `UserPromptSubmit` hook, with no +human typing needed. + +## How it works + +``` + Ant Farm /messages /tmp/iak-new-messages.txt + ┌─────────────────┐ ┌────────────────────────┐ + │ thinkoff- │ poll │ appended on new msgs │ + │ development │ ───┐ └──────────┬─────────────┘ + └─────────────────┘ │ │ read + prepend + ▼ ▼ + ┌──────────────────────────────────────┐ + │ scripts/claudemb-poll.sh │ + │ (tmux session "claudemb-poll") │ + └──────────────┬───────────────────────┘ + │ on new msgs + ▼ + ┌──────────────────────────────────────┐ + │ scripts/claudemb-wake.sh │ + │ osascript → "Claude" desktop app │ + │ types "check rooms" + Return │ + └──────────────┬───────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────┐ + │ Claude desktop app │ + │ UserPromptSubmit hook fires │ + │ → scripts/check-rooms-hook.sh │ + │ → prepends /tmp/iak-new-messages.txt│ + └──────────────────────────────────────┘ +``` + +The wake script restores focus to whatever app was frontmost before, so +typing into the IDE in the background does not steal your foreground app. + +## Setup + +1. Drop the three scripts somewhere on PATH or referenceable: + - `scripts/claudemb-poll.sh` — the room poller + - `scripts/claudemb-wake.sh` — the AppleScript injector + - `scripts/check-rooms-hook.sh` — the UserPromptSubmit hook + +2. Wire the hook into Claude Code's `~/.claude/settings.json`: + ```json + { + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { "type": "command", + "command": "bash /absolute/path/to/check-rooms-hook.sh" } + ] + } + ] + } + } + ``` + +3. Start the poller in a tmux session so it survives reboots / log-outs: + ```bash + tmux new-session -d -s claudemb-poll \ + 'IAK_API_KEY=xfb_... bash /path/to/scripts/claudemb-poll.sh' + ``` + +4. Confirm: + - The Claude **desktop app** is running and has at least one chat open. + The wake uses `osascript` to activate it and send keystrokes; it cannot + wake a CLI Claude instance attached to a regular terminal. + - macOS Accessibility permissions are granted to whatever process runs + osascript (usually `tmux` or your shell's parent). + +## Environment variables + +`claudemb-poll.sh`: +- `IAK_CONFIG_JSON` — path to ide-agent-kit config (default `config/macbook.json`) +- `IAK_API_KEY` — Ant Farm API key (overrides config) +- `ROOM` — room slug to watch (default `thinkoff-development`) +- `BASE_URL` — Ant Farm API base (default `https://antfarm.world/api/v1`) +- `POLL_INTERVAL` — seconds between polls (default 15) +- `WAKE_COOLDOWN_SEC` — minimum seconds between wakes (default 45) — avoids + hammering the IDE when many messages arrive in quick succession +- `IAK_NEW_FILE` — file the poller appends new messages to (default + `/tmp/iak-new-messages.txt`) +- `IAK_NUDGE_TEXT` — what the wake script types (default `check rooms`) + +`claudemb-wake.sh`: +- `CLAUDEMB_APP_NAME` — desktop app name to activate (default `Claude`) +- `CLAUDEMB_WAKE_LOG` — log file (default `/tmp/claudemb_wake.log`) + +## Troubleshooting + +- **No nudge appears in the IDE**: confirm the desktop app is running + (`pgrep -xq Claude`). The wake script logs to + `/tmp/claudemb_wake.log`. +- **AppleScript permission errors**: System Settings → Privacy & Security → + Accessibility — grant the parent process (tmux / Terminal / iTerm). +- **Hook fires but no messages appear**: check `/tmp/iak-new-messages.txt` is + non-empty before the hook runs. Permissions on `/tmp` should be world-r/w. +- **Desktop app focus steals**: this script restores the previously-frontmost + app after sending the keystroke (~0.5s flicker). If your app is not + restored, check the `frontApp` block in `claudemb-wake.sh`. +- **CLI Claude (claude in terminal) does not wake**: correct — it cannot. + Use `tmux send-keys` against the Claude pane instead, but be aware the + send-keys target must be the actual `claude` process, not a wrapper shell. + +## Alternative: Stop-hook resume (no Accessibility permission) + +If macOS rejects adding `osascript` to Accessibility (a known modern macOS +limitation that hits some users), use `scripts/claudecode-stop-resume.sh` +as a Stop hook instead. No Accessibility permission needed. + +**Mechanism:** Claude Code fires the Stop hook after every assistant turn. +The script checks `/tmp/iak-new-messages.txt`; if non-empty, it prints to +stderr and exits 2, which tells Claude Code to **resume the turn** with that +content as additional context. The user sees new room messages appear +without typing anything. + +**Wire it into `~/.claude/settings.json`:** + +```json +{ + "hooks": { + "Stop": [ + { "matcher": "", + "hooks": [ + { "type": "command", + "command": "bash /path/to/scripts/claudecode-stop-resume.sh" } + ] + } + ] + } +} +``` + +**Caveat:** Stop hooks only fire at the end of an active turn. If Claude +is fully idle (no turn in flight), this hook never runs and the messages +sit in the file until the next turn happens. For from-idle wake, pair +with the AppleScript path or a Cron-based wake. The two hooks are +complementary — keep both wired and you cover both cases. + +Credit: original idea + reference impl by @claudemm on the Mac mini. diff --git a/package-lock.json b/package-lock.json index eebb11c..a06ac48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,1145 @@ { "name": "ide-agent-kit", - "version": "0.5.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ide-agent-kit", - "version": "0.5.0", + "version": "0.6.1", "license": "AGPL-3.0-only", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + }, "bin": { "ide-agent-kit": "bin/cli.mjs" }, "engines": { "node": ">=18" } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.16", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", + "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", + "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index ab29cc7..cb60db4 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,20 @@ "description": "Built for OpenClaw workflows — ACP session orchestration, room-triggered automation, comment polling, Discord + Moltbook + GitHub connectors, receipts, exec approvals", "type": "module", "bin": { - "ide-agent-kit": "./bin/cli.mjs" + "ide-agent-kit": "./bin/cli.mjs", + "ide-agent-kit-mcp": "./bin/iak-mcp.mjs" }, "scripts": { "test": "node --test test/*.test.mjs", - "start": "node bin/cli.mjs serve" + "start": "node bin/cli.mjs serve", + "mcp": "node bin/iak-mcp.mjs" }, "keywords": [ "ide", "agent", "ai", + "mcp", + "model-context-protocol", "webhook", "tmux", "receipts", @@ -40,5 +44,8 @@ "author": "ThinkOff", "bugs": { "url": "https://github.com/ThinkOffApp/ide-agent-kit/issues" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" } } diff --git a/scripts/check-rooms-hook.sh b/scripts/check-rooms-hook.sh new file mode 100755 index 0000000..2f6edcf --- /dev/null +++ b/scripts/check-rooms-hook.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# UserPromptSubmit hook for Claude Code (desktop app or CLI). +# +# Reads new room messages dropped by claudemb-poll.sh into +# /tmp/iak-new-messages.txt and prepends them to whatever the user typed. +# Then clears the file so the same messages aren't injected again. +# +# Wire it into ~/.claude/settings.json: +# +# { +# "hooks": { +# "UserPromptSubmit": [ +# { +# "matcher": "", +# "hooks": [ +# { +# "type": "command", +# "command": "bash /path/to/check-rooms-hook.sh" +# } +# ] +# } +# ] +# } +# } + +MSG_FILE="${IAK_NEW_FILE:-/tmp/iak-new-messages.txt}" +if [ -s "$MSG_FILE" ]; then + echo "" + echo "=== NEW ROOM MESSAGES ===" + cat "$MSG_FILE" + echo "=========================" + : > "$MSG_FILE" +fi diff --git a/scripts/claudecode-stop-resume.sh b/scripts/claudecode-stop-resume.sh new file mode 100755 index 0000000..9c847f8 --- /dev/null +++ b/scripts/claudecode-stop-resume.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Claude Code Stop hook for auto-resuming with new room messages. +# +# Alternative to the osascript wake path — does NOT require Accessibility +# permission, works on every macOS version including Sequoia. +# +# Mechanism: Claude Code fires the Stop hook every time the assistant +# finishes a turn. We check /tmp/iak-new-messages.txt — if it contains +# fresh content, we print it to stderr and exit 2, which tells Claude +# Code to RESUME the turn with that content as additional context. The +# user sees the new room messages appear without typing anything. +# +# Caveats: +# - Stop hooks only fire at the end of an active turn. If Claude is +# fully idle (no turn in flight), this hook never runs and new +# messages just sit in the file. Pair with an external nudge +# (claudemb-wake.sh, a Cron task, etc.) if you need from-idle wake. +# - Some Claude Code versions cap how many times a Stop hook can +# resume the same turn. The cap is high enough for normal use. +# +# Wire it into ~/.claude/settings.json: +# +# { +# "hooks": { +# "Stop": [ +# { +# "matcher": "", +# "hooks": [ +# { +# "type": "command", +# "command": "bash /path/to/claudecode-stop-resume.sh" +# } +# ] +# } +# ] +# } +# } +# +# Credit: original idea + reference impl by @claudemm on the Mac mini. + +MSG_FILE="${IAK_NEW_FILE:-/tmp/iak-new-messages.txt}" +if [ -s "$MSG_FILE" ]; then + { + echo "" + echo "=== NEW ROOM MESSAGES ===" + cat "$MSG_FILE" + echo "=========================" + } >&2 + : > "$MSG_FILE" + exit 2 +fi +exit 0 diff --git a/scripts/claudemb-poll.sh b/scripts/claudemb-poll.sh index 4a63d44..d41add6 100755 --- a/scripts/claudemb-poll.sh +++ b/scripts/claudemb-poll.sh @@ -4,7 +4,7 @@ set -euo pipefail -CONFIG_JSON="${IAK_CONFIG_JSON:-/Users/petrus/AndroidStudioProjects/ThinkOff/ide-agent-kit-codex.json}" +CONFIG_JSON="${IAK_CONFIG_JSON:-/Users/petrus/ide-agent-kit/config/macbook.json}" read_json_value() { local path="$1" python3 - "$CONFIG_JSON" "$path" <<'PY' @@ -34,8 +34,9 @@ FETCH_LIMIT="${FETCH_LIMIT:-20}" HTTP_CONNECT_TIMEOUT_SEC="${HTTP_CONNECT_TIMEOUT_SEC:-5}" HTTP_TIMEOUT_SEC="${HTTP_TIMEOUT_SEC:-20}" SEEN_IDS_FILE="${SEEN_IDS_FILE:-/tmp/claudemb_seen_ids.txt}" +DM_SEEN_IDS_FILE="${DM_SEEN_IDS_FILE:-/tmp/claudemb_dm_seen_ids.txt}" SESSION="${SESSION:-claudemb-poll}" -WAKE_SCRIPT="$(dirname "$0")/claudemb_wake.sh" +WAKE_SCRIPT="$(dirname "$0")/claudemb-wake.sh" WAKE_SESSION="${CLAUDEMB_SESSION:-claude}" NUDGE_TEXT="${IAK_NUDGE_TEXT:-${CONFIG_NUDGE_TEXT:-check rooms}}" WAKE_COOLDOWN_SEC="${WAKE_COOLDOWN_SEC:-45}" @@ -108,7 +109,7 @@ can_wake_now() { } # --- polling loop --- -touch "$SEEN_IDS_FILE" "$NEW_FILE" "$NEW_FILE_COMPAT" "$LAST_WAKE_FILE" +touch "$SEEN_IDS_FILE" "$DM_SEEN_IDS_FILE" "$NEW_FILE" "$NEW_FILE_COMPAT" "$LAST_WAKE_FILE" if [[ ! -s "$LAST_WAKE_FILE" ]]; then echo 0 > "$LAST_WAKE_FILE" fi @@ -131,7 +132,7 @@ while true; do new_count=0 while IFS=$'\t' read -r msg_id msg_from msg_body; do [[ -z "$msg_id" ]] && continue - if ! grep -qF "$msg_id" "$SEEN_IDS_FILE"; then + if ! grep -qF -- "$msg_id" "$SEEN_IDS_FILE"; then echo "$msg_id" >> "$SEEN_IDS_FILE" new_count=$((new_count + 1)) body_preview="${msg_body:0:120}" @@ -155,6 +156,47 @@ except Exception: pass " 2>/dev/null) + # --- DM polling --- + dm_response=$(curl -sS --connect-timeout "$HTTP_CONNECT_TIMEOUT_SEC" --max-time "$HTTP_TIMEOUT_SEC" -H "X-API-Key: $API_KEY" \ + "$BASE_URL/messages?to=@claudeMB&limit=10" 2>&1) || { + echo "[$(date +%H:%M:%S)] DM fetch error: $dm_response" + } + + if [[ -n "$dm_response" ]]; then + while IFS=$'\t' read -r msg_id msg_from msg_body; do + # Skip rows with no id, no sender, or empty body. The antfarm DM + # endpoint occasionally returns blank rows that produce + # "[DM from ] :" notification lines and (when $msg_id starts with + # a dash) crash grep with "invalid option" errors that loop forever. + [[ -z "$msg_id" ]] && continue + [[ -z "$msg_from" ]] && continue + [[ -z "$msg_body" ]] && continue + # `--` terminates grep options; required for ids that begin with `-`. + if ! grep -qF -- "$msg_id" "$DM_SEEN_IDS_FILE"; then + echo "$msg_id" >> "$DM_SEEN_IDS_FILE" + new_count=$((new_count + 1)) + body_preview="${msg_body:0:120}" + echo "[$(date +%H:%M:%S)] DM ${msg_from}: ${body_preview}" + + ts="$(date -u +%FT%TZ)" + line="[${ts}] [DM from ${msg_from}] ${msg_from}: ${msg_body:0:600}" + printf '%s\n---\n' "$line" >> "$NEW_FILE" + printf '%s\n---\n' "$line" >> "$NEW_FILE_COMPAT" + fi + done < <(echo "$dm_response" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + for m in data.get('messages', []): + mid = m.get('id', '') + mfrom = m.get('from', '?') + mbody = m.get('body', '').replace('\\\\n', ' ').replace('\\\\t', ' ') + print(f'{mid}\\t{mfrom}\\t{mbody}') +except Exception: + pass +" 2>/dev/null) + fi + if [[ $new_count -gt 0 ]]; then echo "[$(date +%H:%M:%S)] $new_count new message(s)" if [[ -x "$WAKE_SCRIPT" ]]; then diff --git a/scripts/claudemb-wake.sh b/scripts/claudemb-wake.sh index 2cb5d66..e69681b 100755 --- a/scripts/claudemb-wake.sh +++ b/scripts/claudemb-wake.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # ClaudeMB wake-up script -# Sends a nudge keystroke into the Claude Code tmux session so it triggers -# the UserPromptSubmit hook and reads new room messages. +# Uses osascript to send a nudge into the Claude Code desktop app +# then restores focus to the previously active app (no focus steal). set -euo pipefail -WAKE_SESSION="${CLAUDEMB_SESSION:-claude}" +APP_NAME="${CLAUDEMB_APP_NAME:-Claude}" MSG="${1:-check rooms}" LOCK="/tmp/claudemb_wake.lock" LOG_FILE="${CLAUDEMB_WAKE_LOG:-/tmp/claudemb_wake.log}" @@ -17,15 +17,36 @@ if ! mkdir "$LOCK" 2>/dev/null; then fi trap "rmdir \"$LOCK\"" EXIT -if ! tmux has-session -t "$WAKE_SESSION" 2>/dev/null; then - printf "[%s] wake failed: tmux session '%s' not found\n" "$(date -u +%FT%TZ)" "$WAKE_SESSION" >> "$LOG_FILE" +# Check if the app is running +if ! pgrep -xq "$APP_NAME"; then + printf "[%s] wake failed: '%s' app not running\n" "$(date -u +%FT%TZ)" "$APP_NAME" >> "$LOG_FILE" exit 1 fi { - printf "[%s] wake: sending nudge to tmux session '%s': %s\n" "$(date -u +%FT%TZ)" "$WAKE_SESSION" "$MSG" - tmux send-keys -t "$WAKE_SESSION" -l "$MSG" - sleep 0.3 - tmux send-keys -t "$WAKE_SESSION" Enter - printf "[%s] wake: nudge sent\n" "$(date -u +%FT%TZ)" + printf "[%s] wake: sending nudge to '%s' app (no focus steal): %s\n" "$(date -u +%FT%TZ)" "$APP_NAME" "$MSG" + osascript - "$APP_NAME" "$MSG" <<'APPLESCRIPT' +on run argv + set appName to item 1 of argv + set promptText to item 2 of argv + + -- Remember which app has focus + tell application "System Events" + set frontApp to name of first application process whose frontmost is true + end tell + + -- Briefly activate Claude, send nudge + tell application appName to activate + delay 0.3 + tell application "System Events" + keystroke promptText + key code 36 + end tell + delay 0.2 + + -- Restore focus to the previous app + tell application frontApp to activate +end run +APPLESCRIPT + printf "[%s] wake: nudge sent, focus restored\n" "$(date -u +%FT%TZ)" } >> "$LOG_FILE" 2>&1 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..183fe47 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# IAK one-shot installer for macOS. +# +# Run from a fresh terminal: +# curl -fsSL https://raw.githubusercontent.com/ThinkOffApp/ide-agent-kit/main/scripts/install.sh | bash +# +# Idempotent — safe to re-run. Prompts before any destructive action. +# +# What it does: +# 1. Verifies prereqs (node 20+, git, tmux). Installs missing ones via brew if available. +# 2. Clones the repo to ~/ide-agent-kit (or pulls latest if already there). +# 3. npm install. +# 4. Writes a starter config to ide-agent-kit.json with sensible defaults +# (PORT 8788, host 0.0.0.0 so phone on LAN can reach it). Skips if +# config already exists. +# 5. Installs ~/.claude/scripts/check-rooms-hook.sh + claudemb-poll/wake +# shims and registers the UserPromptSubmit + Stop hooks in +# ~/.claude/settings.json. Skips registrations already present. +# 6. Starts the daemon in a tmux session named "iak-mcp". +# 7. Prints the LAN URL the user should paste into CodeWatch. +# +# Does NOT: +# - Generate any signing keys. +# - Touch macOS Accessibility permissions (user must grant manually +# for the osascript wake to work — the script prints the System +# Settings deep-link). +# - Install Claude Code itself. + +set -euo pipefail + +REPO="https://github.com/ThinkOffApp/ide-agent-kit.git" +INSTALL_DIR="${IAK_INSTALL_DIR:-$HOME/ide-agent-kit}" +TMUX_SESSION="${IAK_TMUX_SESSION:-iak-mcp}" + +bold() { printf "\033[1m%s\033[0m\n" "$*"; } +green() { printf "\033[32m%s\033[0m\n" "$*"; } +yellow() { printf "\033[33m%s\033[0m\n" "$*"; } +red() { printf "\033[31m%s\033[0m\n" "$*" >&2; } + +bold "ide-agent-kit one-shot installer" +echo + +# 1. prereqs +need_brew=() +command -v node >/dev/null || need_brew+=(node) +command -v git >/dev/null || need_brew+=(git) +command -v tmux >/dev/null || need_brew+=(tmux) + +if [ "${#need_brew[@]}" -gt 0 ]; then + if command -v brew >/dev/null; then + yellow "Installing missing prereqs via brew: ${need_brew[*]}" + brew install "${need_brew[@]}" + else + red "Missing: ${need_brew[*]}. Install Homebrew (https://brew.sh) first, then re-run." + exit 1 + fi +fi + +NODE_MAJOR=$(node -v | sed 's/^v//; s/\..*//') +if [ "$NODE_MAJOR" -lt 20 ]; then + red "node $NODE_MAJOR is too old; need 20+. brew upgrade node." + exit 1 +fi + +# 2. clone or pull +if [ -d "$INSTALL_DIR/.git" ]; then + yellow "$INSTALL_DIR exists; pulling latest" + (cd "$INSTALL_DIR" && git pull --ff-only) +else + yellow "Cloning $REPO into $INSTALL_DIR" + git clone "$REPO" "$INSTALL_DIR" +fi + +# 3. npm install +(cd "$INSTALL_DIR" && npm install --silent) + +# 4. starter config +CONFIG="$INSTALL_DIR/ide-agent-kit.json" +if [ ! -f "$CONFIG" ]; then + yellow "Writing starter config to $CONFIG" + cat > "$CONFIG" <:8788" + echo +fi + +# 5. Claude Code hook wiring +SETTINGS="$HOME/.claude/settings.json" +SCRIPTS_DIR="$INSTALL_DIR/scripts" +if [ -f "$SETTINGS" ]; then + yellow "Wiring UserPromptSubmit + Stop hooks in $SETTINGS" + python3 - "$SETTINGS" "$SCRIPTS_DIR" <<'PY' +import json, sys, os +settings_path, scripts_dir = sys.argv[1], sys.argv[2] +data = json.load(open(settings_path)) +data.setdefault("hooks", {}) +def ensure_hook(event, cmd): + arr = data["hooks"].setdefault(event, []) + for entry in arr: + for h in entry.get("hooks", []): + if h.get("command") == cmd: return False + arr.append({"matcher":"","hooks":[{"type":"command","command":cmd}]}) + return True +changed = False +changed |= ensure_hook("UserPromptSubmit", f"bash {scripts_dir}/check-rooms-hook.sh") +changed |= ensure_hook("Stop", f"bash {scripts_dir}/claudecode-stop-resume.sh") +if changed: + json.dump(data, open(settings_path,"w"), indent=2) + print("Hooks installed.") +else: + print("Hooks already present.") +PY +else + yellow "No ~/.claude/settings.json yet — skipping hook wiring. Re-run after first Claude Code launch." +fi + +# 6. start daemon in tmux +if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + yellow "Restarting daemon in tmux session $TMUX_SESSION" + tmux kill-session -t "$TMUX_SESSION" +fi +tmux new-session -d -s "$TMUX_SESSION" \ + "cd $INSTALL_DIR && node bin/iak-mcp-daemon.mjs" +sleep 2 + +# 7. report +LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "127.0.0.1") +LAN_URL="http://${LAN_IP}:8788" +echo +green "Installed." +echo +bold "Daemon: tmux session '$TMUX_SESSION'" +echo " - logs: tmux attach -t $TMUX_SESSION" +echo " - listener: $LAN_URL" +echo +bold "CodeWatch on phone:" +echo " - Account tab → IAK gate URLs → paste: $LAN_URL" +echo +bold "macOS Accessibility (one-time, for osascript-based desktop-app wake):" +echo " - System Settings → Privacy & Security → Accessibility" +echo " - Add: /usr/bin/osascript (or whatever process runs the daemon — usually iTerm/Terminal/tmux)" +echo +bold "Edit your config:" +echo " - $CONFIG" +echo " - poller.api_key + mcp.confirmations.room are required for chat-reply support" diff --git a/src/config.mjs b/src/config.mjs index f5ed1ed..2c2d48e 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -106,6 +106,9 @@ export function loadConfig(configPath) { ...raw.background?.timeouts } }, - openclaw: raw.openclaw || {} + openclaw: raw.openclaw || {}, + // Pass-through for MCP server-specific config (sessions, allow_unrestricted). + // See src/mcp-server.mjs. + mcp: raw.mcp || {} }; } diff --git a/src/confirmations.mjs b/src/confirmations.mjs new file mode 100644 index 0000000..efa925c --- /dev/null +++ b/src/confirmations.mjs @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// Confirmation registry for MCP-driven approval flows. +// +// An MCP client (e.g. an agent that wants user sign-off before destructive +// work) calls request_confirmation. The registry: +// 1. Generates a unique intent id. +// 2. Posts a confirmation prompt to the configured channels — currently +// GroupMind (the rooms chat) with `/approve ` / `/deny ` quick +// replies, and Codewatch via the CLAWWATCH_GATE Intent receiver +// (CodexMB's PR #8 work) when configured. +// 3. Listens on an HTTP endpoint for the decision (POST /intent/:id/decision +// with `{decision: "approve"|"deny"}`). Codewatch's notification action +// buttons + a future GroupMind quick-reply poller both POST here. +// 4. Resolves the in-memory promise so the MCP tool returns synchronously. +// +// All state is in-memory (intents are short-lived, typically minutes). For +// audit, every transition is appended to receipts. + +import { createServer } from 'node:http'; +import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto'; +import { appendFileSync } from 'node:fs'; +import { spawn } from 'node:child_process'; + +// --- registry --------------------------------------------------------------- + +// id -> {prompt, session, channels, status, createdAt, decidedAt, decision, resolvers} +const intents = new Map(); + +function postReceipt(receiptsPath, entry) { + if (!receiptsPath) return; + try { + appendFileSync(receiptsPath, JSON.stringify(entry) + '\n'); + } catch { + // never crash the bridge on a receipt write + } +} + +export function listIntents() { + return [...intents.entries()].map(([id, i]) => ({ + id, + prompt: i.prompt, + session: i.session, + channels: i.channels, + status: i.status, + createdAt: i.createdAt, + decidedAt: i.decidedAt, + decision: i.decision, + })); +} + +export function getIntent(id) { + const i = intents.get(id); + if (!i) return null; + return { + id, + prompt: i.prompt, + session: i.session, + channels: i.channels, + status: i.status, + createdAt: i.createdAt, + decidedAt: i.decidedAt, + decision: i.decision, + }; +} + +// Decide an intent. Returns true if decided, false if id unknown or already +// decided. Idempotent for same decision; rejects different decision after +// settle. +export function decideIntent(id, decision, { receiptsPath } = {}) { + if (decision !== 'approve' && decision !== 'deny') { + return { ok: false, error: 'decision must be "approve" or "deny"' }; + } + const i = intents.get(id); + if (!i) return { ok: false, error: `unknown intent ${id}` }; + if (i.status !== 'pending') { + if (i.decision === decision) return { ok: true, idempotent: true }; + return { ok: false, error: `intent ${id} already decided as ${i.decision}` }; + } + i.status = 'decided'; + i.decision = decision; + i.decidedAt = Date.now(); + postReceipt(receiptsPath, { + kind: 'intent.decided', id, decision, decidedAt: i.decidedAt, prompt: i.prompt, + }); + // Resolve waiters. + for (const r of i.resolvers) { + try { r({ decision, id }); } catch {} + } + i.resolvers = []; + return { ok: true }; +} + +// Create + announce a confirmation intent. Returns intent id immediately. +// `announce` is an injectable side-effect (groupmindPost / codewatchPush) for +// testability — production code passes the real posters. +export async function createIntent({ + prompt, + session, + channels = ['groupmind'], + timeoutSec = 600, + announce = async () => {}, + receiptsPath, +}) { + const id = randomUUID().slice(0, 8); + const intent = { + prompt, + session, + channels, + status: 'pending', + createdAt: Date.now(), + decidedAt: null, + decision: null, + resolvers: [], + timeoutSec, + }; + intents.set(id, intent); + postReceipt(receiptsPath, { + kind: 'intent.created', id, prompt, session, channels, createdAt: intent.createdAt, + }); + // Side effects — never let an announce failure block the intent itself. + try { + await announce({ id, prompt, session, channels }); + } catch (e) { + postReceipt(receiptsPath, { + kind: 'intent.announce_failed', id, error: e.message, + }); + } + return id; +} + +// Wait for a decision on intent id. Resolves on decide or timeout. +export function waitForDecision(id, { timeoutMs }) { + const i = intents.get(id); + if (!i) return Promise.resolve({ status: 'unknown' }); + if (i.status === 'decided') { + return Promise.resolve({ status: 'decided', decision: i.decision }); + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + // Remove this resolver from the list before resolving so a later + // decideIntent doesn't try to resolve us twice. + const idx = i.resolvers.indexOf(resolverWithCleanup); + if (idx >= 0) i.resolvers.splice(idx, 1); + resolve({ status: 'timeout' }); + }, timeoutMs); + const resolverWithCleanup = (val) => { + clearTimeout(timer); + resolve({ status: 'decided', decision: val.decision }); + }; + i.resolvers.push(resolverWithCleanup); + }); +} + +// --- HTTP listener --------------------------------------------------------- + +// Tiny built-in HTTP server. POST /intent/:id/decision accepts the decision +// from any caller (Codewatch action, GroupMind reply poller, manual curl). +// Auth is a shared bearer token if configured; otherwise local-only by host bind. +export function startConfirmationsServer({ + port = 8788, + host = '127.0.0.1', + authToken = '', + receiptsPath, + announce, // optional: enables POST /intent to create new intents externally + wakeScript, // optional: shell script path; enables POST /wake to nudge the local IDE +} = {}) { + const server = createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + // Auth check (constant-time when token configured). + if (authToken) { + const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); + const a = Buffer.from(got); + const b = Buffer.from(authToken); + const ok = a.length === b.length && timingSafeEqual(a, b); + if (!ok) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'unauthorized' })); + return; + } + } + const m = url.pathname.match(/^\/intent\/([^/]+)\/decision$/); + if (req.method === 'POST' && m) { + const id = m[1]; + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let payload; + try { payload = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'invalid json' })); + return; + } + const result = decideIntent(id, payload.decision, { receiptsPath }); + res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + }); + return; + } + if (req.method === 'GET' && url.pathname === '/intents') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(listIntents())); + return; + } + // POST /intent — create a new pending intent. Body: {prompt, session, channels}. + // Used by external callers (test scripts, MCP wrappers, etc.) to add intents + // to the live registry without going through stdio MCP. Fires announcements + // via whatever announcer was passed to startConfirmationsServer. + if (req.method === 'POST' && url.pathname === '/intent') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', async () => { + let payload; + try { payload = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'invalid json' })); + return; + } + if (!payload.prompt || typeof payload.prompt !== 'string') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'missing prompt' })); + return; + } + try { + const id = await createIntent({ + prompt: payload.prompt, + session: payload.session || 'external', + channels: Array.isArray(payload.channels) ? payload.channels : (announce ? ['groupmind'] : []), + announce: announce || (async () => {}), + receiptsPath, + }); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, id })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: e.message || String(e) })); + } + }); + return; + } + // Tiny mobile-first HTML UI: pending intents with Approve / Deny buttons. + // Auto-refresh every 2s. Same origin, no auth (caller is the local LAN + // unless authToken is set on the server, in which case the page is + // unreachable without it). Renders fine on Wear OS browser + phone + Mac. + if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/intents.html')) { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(renderIntentsHtml()); + return; + } + // POST /wake — nudge the local IDE / desktop app. Body: {text?}. + // Runs the configured wakeScript with the text as the only arg + // (defaults to "check rooms"). Used by other agents to keep this + // agent responsive without going through the room-poll roundtrip. + if (req.method === 'POST' && url.pathname === '/wake') { + if (!wakeScript) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'wake disabled — no wakeScript configured' })); + return; + } + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let text = 'check rooms'; + try { + const payload = JSON.parse(body || '{}'); + if (typeof payload.text === 'string' && payload.text.trim().length > 0) text = payload.text.trim(); + } catch { /* allow empty body */ } + try { + // Spawn detached; don't block the response. Wake script handles its own logging. + const child = spawn(wakeScript, [text], { detached: true, stdio: 'ignore' }); + child.unref(); + res.writeHead(202, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, text })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: e.message || String(e) })); + } + }); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'not found' })); + }); + server.listen(port, host); + return server; +} + +// Tiny self-contained HTML UI for tap-to-approve. Inlined so the +// confirmations server has no external assets / templates to ship. +function renderIntentsHtml() { + return ` + + + + +IAK confirmations + + + +

IAK confirmations

+
+
+ + +`; +} + +// --- announcers ------------------------------------------------------------ + +// Post the intent prompt to a GroupMind room with quick-reply text the user +// can copy / type, and a curl example for the watch-gate. Idempotent (same +// id is harmless). +export function makeGroupmindAnnouncer({ apiKey, room, callbackBase }) { + return async ({ id, prompt, session }) => { + if (!apiKey || !room) return; + const uiLink = callbackBase ? `${callbackBase}/` : null; + const body = + `[Confirmation needed] **${prompt}**\n` + + `Target session: \`${session || '(none)'}\`\n` + + (uiLink ? `Tap to decide: ${uiLink}\n` : '') + + `Or reply: \`/approve ${id}\` · \`/deny ${id}\``; + // Attach metadata so the GroupMind chat UI can render inline Approve/Deny + // buttons. Frontend reads `metadata.actions` + `metadata.intent_id` and + // POSTs `/approve ` (or `/deny `) chat replies on tap, which the + // chat-reply poller in iak-mcp-daemon catches and routes to the local + // /intent/:id/decision endpoint. No new backend route needed. + const metadata = { + actions: ['Approve', 'Deny'], + intent_id: id, + intent_prompt: prompt, + intent_session: session || null, + }; + const data = JSON.stringify({ room, body, metadata }); + const req = await import('node:https'); + return new Promise((resolve, reject) => { + const r = req.request( + 'https://groupmind.one/api/v1/messages', + { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey } }, + (res) => { + // drain + resolve regardless; the message-id isn't useful here. + res.resume(); + res.on('end', () => resolve()); + res.on('error', reject); + } + ); + r.on('error', reject); + r.write(data); + r.end(); + }); + }; +} + +// Post the intent to the CLAWWATCH_GATE proxy (CodexMB's PR #8). The proxy +// then renders an Android interactive notification with Approve / Deny +// buttons that POST back to this server's /intent/:id/decision. +export function makeCodewatchAnnouncer({ gateUrl, gateToken }) { + return async ({ id, prompt, session }) => { + if (!gateUrl) return; + const data = JSON.stringify({ id, prompt, session }); + const url = new URL(gateUrl); + const lib = await import(url.protocol === 'https:' ? 'node:https' : 'node:http'); + return new Promise((resolve, reject) => { + const headers = { 'Content-Type': 'application/json' }; + if (gateToken) headers.Authorization = `Bearer ${gateToken}`; + const r = lib.request(gateUrl, { method: 'POST', headers }, (res) => { + res.resume(); + res.on('end', () => resolve()); + res.on('error', reject); + }); + r.on('error', reject); + r.write(data); + r.end(); + }); + }; +} + +// Fan-out: build a single announce function from per-channel announcers. +export function composeAnnouncers(map) { + return async (intent) => { + for (const ch of intent.channels) { + const fn = map[ch]; + if (!fn) continue; + try { await fn(intent); } catch (e) { + // log but continue to other channels + process.stderr.write(`[iak-mcp] announce ${ch} failed: ${e.message}\n`); + } + } + }; +} + +// --- testing helpers --------------------------------------------------------- + +// Reset all state. Used by the test suite between cases. Not exported via +// the package surface for production use. +export function _resetForTests() { + for (const i of intents.values()) { + for (const r of i.resolvers) { + try { r({ decision: 'deny', id: '__reset__' }); } catch {} + } + } + intents.clear(); +} diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs new file mode 100644 index 0000000..18aa1b7 --- /dev/null +++ b/src/mcp-server.mjs @@ -0,0 +1,520 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// MCP server for ide-agent-kit. Exposes tmux-backed "wake the IDE" primitives +// as MCP tools so any MCP-aware client (Claude Desktop / Code, Cursor, +// custom agents) can drive the IAK fleet without re-implementing the +// nudge / list / send-keys protocol. +// +// Tools exposed: +// * wake_ide — send a nudge string to a tmux session and press Enter +// * list_sessions — list all live tmux sessions on the host +// * wake_all — wake every configured IDE/agent session at once +// * read_session — capture-pane and return the last N lines of output +// * tmux_run — run an allowlisted command (mirrors `cli.mjs tmux run`) +// +// Security note: tmux_run is only registered when config.tmux.allow is a +// non-empty array or mcp.allow_unrestricted is explicitly true. Otherwise the +// tool is omitted entirely so an MCP client cannot turn it into an arbitrary +// shell over stdio. See decideTmuxRunMode(). +// +// Transport: stdio. Compatible with Claude Desktop / Code MCP client config. + +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import { nudgeTmux } from './common/notify.mjs'; +import { tmuxRun } from './ide/tmux-runner.mjs'; +import { loadConfig } from './config.mjs'; +import { + createIntent, + decideIntent, + waitForDecision, + listIntents, + startConfirmationsServer, + makeGroupmindAnnouncer, + makeCodewatchAnnouncer, + composeAnnouncers, +} from './confirmations.mjs'; + +// Read package.json once at module load so the advertised server version +// tracks future package bumps without code edits. +const __pkgDir = dirname(dirname(fileURLToPath(import.meta.url))); +let SERVER_VERSION = '0.0.0'; +try { + SERVER_VERSION = JSON.parse(readFileSync(join(__pkgDir, 'package.json'), 'utf8')).version; +} catch { + // leave default; not fatal +} + +// --- helpers ---------------------------------------------------------------- + +function listTmuxSessions() { + try { + // Use a literal pipe as the field delimiter rather than \t — single-quoted + // shell strings do NOT interpret \t, so tmux would receive a literal + // backslash-t and emit it verbatim instead of a tab. + const out = execSync( + `tmux list-sessions -F '#{session_name}|#{?session_attached,attached,detached}|#{session_windows}'`, + { encoding: 'utf8' } + ); + return out + .trim() + .split('\n') + .filter(Boolean) + .map((line) => { + const [name, attached, windows] = line.split('|'); + return { name, attached: attached === 'attached', windows: parseInt(windows, 10) }; + }); + } catch { + // tmux not running or no sessions + return []; + } +} + +export function configuredAgentSessions(config) { + // Sessions IAK explicitly knows about. Resolution order: + // 1. config.mcp.sessions (explicit array of strings) — preferred. + // 2. config.tmux.ide_session + config.tmux.default_session — fallback. + // The previous "scan all top-level keys for objects with a .session string" + // heuristic was dropped because it would silently pick up unrelated + // future config keys (e.g. {sentry: {session: "warn"}}). + const sessions = new Set(); + if (Array.isArray(config?.mcp?.sessions)) { + for (const s of config.mcp.sessions) { + if (typeof s === 'string' && s.length > 0) sessions.add(s); + } + return [...sessions]; + } + if (config?.tmux?.ide_session) sessions.add(config.tmux.ide_session); + if (config?.tmux?.default_session) sessions.add(config.tmux.default_session); + return [...sessions]; +} + +// Decides whether tmux_run should be exposed and why. Returns +// {enabled: boolean, reason: string} so the boot log can explain itself. +export function decideTmuxRunMode(config) { + if (config?.mcp?.allow_unrestricted === true) { + return { enabled: true, reason: 'mcp.allow_unrestricted=true (any command will run)' }; + } + const allow = config?.tmux?.allow; + if (Array.isArray(allow) && allow.length > 0) { + return { enabled: true, reason: `tmux.allow has ${allow.length} pattern(s)` }; + } + return { + enabled: false, + reason: + 'tmux.allow is missing or empty — refusing to expose tmux_run as an arbitrary shell. ' + + 'Set tmux.allow to a non-empty list, or mcp.allow_unrestricted=true to override.', + }; +} + +export function captureTmuxPane(session, lines = 50) { + // tmux capture-pane: -p print to stdout, -t target, -S start (-N = N lines back). + // Returns last `lines` lines of the session's active pane. + const safeLines = Math.max(1, Math.min(2000, parseInt(lines, 10) || 50)); + try { + return execSync( + `tmux capture-pane -p -t ${JSON.stringify(session)} -S -${safeLines}`, + { encoding: 'utf8' } + ); + } catch (e) { + throw new Error(`capture-pane failed for "${session}": ${e.message}`); + } +} + +function ok(text) { + return { content: [{ type: 'text', text }] }; +} + +function err(text) { + return { content: [{ type: 'text', text }], isError: true }; +} + +// --- server ----------------------------------------------------------------- + +export async function runMcpServer({ configPath } = {}) { + let config = {}; + try { + config = await loadConfig(configPath); + } catch (e) { + // The MCP server should still start even if the config is missing — the + // tools just degrade (wake_all won't know the configured sessions). + process.stderr.write(`[iak-mcp] warning: config not loaded: ${e.message}\n`); + } + + const server = new Server( + { name: 'ide-agent-kit', version: SERVER_VERSION }, + { capabilities: { tools: {} } } + ); + + // Decide tmux_run exposure once at boot so the tool list is stable for the + // session. + const tmuxRunMode = decideTmuxRunMode(config); + process.stderr.write(`[iak-mcp] tmux_run: ${tmuxRunMode.enabled ? 'enabled' : 'disabled'} — ${tmuxRunMode.reason}\n`); + + // Confirmation server — starts only when at least one channel is configured. + // GroupMind needs (poller.api_key, mcp.confirmations.room); Codewatch needs + // mcp.confirmations.codewatch_gate_url. Both optional. + const confirmCfg = config?.mcp?.confirmations || {}; + const announcerMap = {}; + if (confirmCfg.room && config?.poller?.api_key) { + announcerMap.groupmind = makeGroupmindAnnouncer({ + apiKey: config.poller.api_key, + room: confirmCfg.room, + callbackBase: confirmCfg.callback_base || `http://127.0.0.1:${confirmCfg.port || 8788}`, + }); + } + if (confirmCfg.codewatch_gate_url) { + announcerMap.codewatch = makeCodewatchAnnouncer({ + gateUrl: confirmCfg.codewatch_gate_url, + gateToken: confirmCfg.codewatch_gate_token, + }); + } + const announce = composeAnnouncers(announcerMap); + const confirmEnabled = Object.keys(announcerMap).length > 0; + + // Try to detect a separately-running iak-mcp-daemon on the configured port. + // When present, the MCP server forwards intent creation + decision polling + // to the daemon's HTTP endpoints — this lets multiple MCP clients share a + // single intent registry (one daemon, many agents). When absent, the MCP + // server starts its own confirmations server in-process. + const daemonHost = confirmCfg.host || '127.0.0.1'; + const daemonPort = confirmCfg.port || 8788; + const daemonBase = `http://${daemonHost === '0.0.0.0' ? '127.0.0.1' : daemonHost}:${daemonPort}`; + let daemonAvailable = false; + try { + const probe = await fetch(`${daemonBase}/intents`, { method: 'GET', signal: AbortSignal.timeout(500) }); + daemonAvailable = probe.ok; + } catch { /* not running */ } + + let confirmServer = null; + if (daemonAvailable) { + process.stderr.write( + `[iak-mcp] confirmations: forwarding to live daemon at ${daemonBase}\n` + ); + } else if (confirmEnabled) { + confirmServer = startConfirmationsServer({ + port: daemonPort, + host: confirmCfg.host || '127.0.0.1', + authToken: confirmCfg.auth_token || '', + receiptsPath: config?.receipts?.path, + announce, + }); + process.stderr.write( + `[iak-mcp] confirmations: enabled on ${daemonBase} (in-process) — channels: ${Object.keys(announcerMap).join(', ')}\n` + ); + } else { + process.stderr.write( + '[iak-mcp] confirmations: disabled — set mcp.confirmations.room (+ poller.api_key) and/or mcp.confirmations.codewatch_gate_url\n' + ); + } + + const tools = [ + { + name: 'wake_ide', + description: + 'Wake an IDE / agent by sending a text nudge to its tmux session and pressing Enter. ' + + 'Use list_sessions first to discover available session names.', + inputSchema: { + type: 'object', + properties: { + session: { type: 'string', description: 'tmux session name (e.g. "claude", "claudemb", "antigravity")' }, + text: { type: 'string', description: 'Text to type before pressing Enter. Default: "check rooms".', default: 'check rooms' }, + }, + required: ['session'], + }, + }, + { + name: 'list_sessions', + description: 'List every live tmux session on this host with attach state and window count.', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'wake_all', + description: + 'Send the same nudge to every IDE / agent session that IAK is configured to know about ' + + '(via mcp.sessions in config, falling back to tmux.ide_session + tmux.default_session). ' + + 'Returns per-session success / failure.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string', description: 'Nudge text. Default: "check rooms".', default: 'check rooms' }, + }, + }, + }, + { + name: 'wake_remote', + description: + 'Wake a remote agent by POSTing to its IAK daemon /wake endpoint. The remote daemon ' + + 'runs its configured wake script (typically scripts/claudemb-wake.sh, an osascript ' + + 'injector for the Claude desktop app) so the remote agent gets a "check rooms" prompt ' + + 'within ~500ms regardless of room-poll cadence. Use this for direct cross-machine ' + + 'agent-to-agent coordination (e.g. claudemm has a question that needs claudemb).', + inputSchema: { + type: 'object', + properties: { + gateUrl: { type: 'string', description: 'Base URL of the remote IAK daemon, e.g. http://192.168.50.240:8788.' }, + text: { type: 'string', description: 'Nudge text. Default: "check rooms".', default: 'check rooms' }, + }, + required: ['gateUrl'], + }, + }, + { + name: 'read_session', + description: + 'Capture the current visible content of a tmux session pane. Use this after wake_ide ' + + 'to see what the agent printed in response, or to inspect what an IDE is currently showing.', + inputSchema: { + type: 'object', + properties: { + session: { type: 'string', description: 'tmux session name' }, + lines: { type: 'integer', description: 'How many lines back to capture (1..2000). Default 50.', default: 50 }, + }, + required: ['session'], + }, + }, + ]; + if (confirmEnabled) { + tools.push( + { + name: 'request_confirmation', + description: + 'Ask the user for an Approve / Deny decision. Posts the prompt to the configured ' + + 'channels (GroupMind room, Codewatch notification) and BLOCKS until the user decides ' + + 'or the timeout expires. Returns {decision: "approve"|"deny"} on decide, ' + + '{status: "timeout", id} on timeout. Use the id to follow up via approve_intent / deny_intent.', + inputSchema: { + type: 'object', + properties: { + prompt: { type: 'string', description: 'Human-readable question to show the user. Keep it short — fits in a watch notification.' }, + session: { type: 'string', description: 'tmux session that triggered the request, for context. Optional.' }, + channels: { + type: 'array', + items: { type: 'string', enum: ['groupmind', 'codewatch'] }, + description: 'Which channels to post to. Default: all configured channels.', + }, + timeoutSec: { type: 'number', description: 'How long to wait for a decision before returning timeout. Default 600 (10 min).', default: 600 }, + }, + required: ['prompt'], + }, + }, + { + name: 'list_intents', + description: 'List every confirmation intent the server knows about (pending, decided, recent).', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'approve_intent', + description: 'Manually approve a pending intent by id (e.g. for an MCP-driven override).', + inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + { + name: 'deny_intent', + description: 'Manually deny a pending intent by id.', + inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + } + ); + } + if (tmuxRunMode.enabled) { + tools.push({ + name: 'tmux_run', + description: + 'Run a command in a tmux session. Subject to the same allowlist as `ide-agent-kit tmux run`. ' + + 'Captures output and exit code, appends a receipt entry.', + inputSchema: { + type: 'object', + properties: { + cmd: { type: 'string', description: 'Command to run (must match tmux.allow patterns in config)' }, + session: { type: 'string', description: 'tmux session name (defaults to config tmux.default_session)' }, + cwd: { type: 'string', description: 'Working directory' }, + timeoutSec: { type: 'number', description: 'Hard timeout in seconds', default: 60 }, + }, + required: ['cmd'], + }, + }); + } + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const name = req.params.name; + const args = req.params.arguments || {}; + try { + switch (name) { + case 'wake_ide': { + if (!args.session) return err('wake_ide: session is required'); + const text = typeof args.text === 'string' ? args.text : 'check rooms'; + const success = nudgeTmux(args.session, text); + return success + ? ok(`Nudged ${args.session} with: ${JSON.stringify(text)}`) + : err(`Could not nudge ${args.session} — session not found or tmux not running.`); + } + case 'list_sessions': { + const sessions = listTmuxSessions(); + if (sessions.length === 0) return ok('No tmux sessions running.'); + const lines = sessions.map((s) => ` ${s.name}\t${s.attached ? 'attached' : 'detached'}\t${s.windows} window(s)`); + return ok(`tmux sessions (${sessions.length}):\n${lines.join('\n')}`); + } + case 'wake_all': { + const text = typeof args.text === 'string' ? args.text : 'check rooms'; + const targets = configuredAgentSessions(config); + if (targets.length === 0) return ok('No agent sessions configured. Add one to config.tmux.ide_session.'); + const live = new Set(listTmuxSessions().map((s) => s.name)); + const results = targets.map((session) => { + if (!live.has(session)) return { session, success: false, reason: 'not running' }; + return { session, success: nudgeTmux(session, text), reason: null }; + }); + const lines = results.map((r) => + r.success ? ` ✓ ${r.session}` : ` ✗ ${r.session}${r.reason ? ` (${r.reason})` : ''}` + ); + return ok(`Woke with ${JSON.stringify(text)}:\n${lines.join('\n')}`); + } + case 'wake_remote': { + if (!args.gateUrl) return err('wake_remote: gateUrl is required'); + const text = typeof args.text === 'string' ? args.text : 'check rooms'; + try { + const res = await fetch(`${args.gateUrl}/wake`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + signal: AbortSignal.timeout(5000), + }); + const body = await res.text(); + if (res.status >= 200 && res.status < 300) { + return ok(`wake_remote ${args.gateUrl}: ${res.status} — ${body}`); + } + return err(`wake_remote ${args.gateUrl}: HTTP ${res.status} — ${body}`); + } catch (e) { + return err(`wake_remote ${args.gateUrl}: ${e.message || String(e)}`); + } + } + case 'read_session': { + if (!args.session) return err('read_session: session is required'); + try { + const out = captureTmuxPane(args.session, args.lines); + return ok(out); + } catch (e) { + return err(e.message); + } + } + case 'request_confirmation': { + if (!confirmEnabled && !daemonAvailable) return err('request_confirmation: confirmations not configured. Set mcp.confirmations.room (+ poller.api_key) and/or codewatch_gate_url.'); + if (!args.prompt) return err('request_confirmation: prompt is required'); + const timeoutSec = Math.max(1, Math.min(86400, args.timeoutSec || 600)); + + // Daemon mode: forward to the running iak-mcp-daemon so the intent + // is in the SHARED registry that CodeWatch and the chat-reply + // poller see. This is the production path when a daemon is up. + if (daemonAvailable) { + const createRes = await fetch(`${daemonBase}/intent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: args.prompt, + session: args.session, + channels: Array.isArray(args.channels) ? args.channels : undefined, + }), + }); + const created = await createRes.json(); + if (!created.ok) return err(`daemon createIntent: ${created.error}`); + const id = created.id; + // Poll for decision. + const deadline = Date.now() + timeoutSec * 1000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 1000)); + try { + const list = await (await fetch(`${daemonBase}/intents`)).json(); + const found = list.find((i) => i.id === id); + if (found && found.status === 'decided') { + return ok(JSON.stringify({ id, decision: found.decision }, null, 2)); + } + } catch { /* retry */ } + } + return ok(JSON.stringify({ id, status: 'timeout', timeoutSec }, null, 2)); + } + + // In-process fallback (no daemon). + const channels = Array.isArray(args.channels) && args.channels.length > 0 + ? args.channels.filter((c) => announcerMap[c]) + : Object.keys(announcerMap); + const id = await createIntent({ + prompt: args.prompt, + session: args.session, + channels, + timeoutSec, + announce, + receiptsPath: config?.receipts?.path, + }); + const result = await waitForDecision(id, { timeoutMs: timeoutSec * 1000 }); + if (result.status === 'decided') { + return ok(JSON.stringify({ id, decision: result.decision }, null, 2)); + } + return ok(JSON.stringify({ id, status: 'timeout', timeoutSec }, null, 2)); + } + case 'list_intents': { + if (!confirmEnabled) return err('list_intents: confirmations not configured.'); + return ok(JSON.stringify(listIntents(), null, 2)); + } + case 'approve_intent': { + if (!confirmEnabled) return err('approve_intent: confirmations not configured.'); + if (!args.id) return err('approve_intent: id is required'); + const r = decideIntent(args.id, 'approve', { receiptsPath: config?.receipts?.path }); + return r.ok ? ok(`Approved ${args.id}`) : err(r.error); + } + case 'deny_intent': { + if (!confirmEnabled) return err('deny_intent: confirmations not configured.'); + if (!args.id) return err('deny_intent: id is required'); + const r = decideIntent(args.id, 'deny', { receiptsPath: config?.receipts?.path }); + return r.ok ? ok(`Denied ${args.id}`) : err(r.error); + } + case 'tmux_run': { + if (!tmuxRunMode.enabled) { + return err(`tmux_run is disabled in this MCP session: ${tmuxRunMode.reason}`); + } + if (!args.cmd) return err('tmux_run: cmd is required'); + const result = await tmuxRun({ + session: args.session, + cmd: args.cmd, + cwd: args.cwd, + timeoutSec: args.timeoutSec || 60, + config, + }); + return ok(JSON.stringify(result, null, 2)); + } + default: + return err(`Unknown tool: ${name}`); + } + } catch (e) { + return err(`${name} failed: ${e.message}`); + } + }); + + const transport = new StdioServerTransport(); + await server.connect(transport); + process.stderr.write('[iak-mcp] ready on stdio\n'); +} + +// Run directly when invoked as a script. +if (import.meta.url === `file://${process.argv[1]}`) { + // Allow --config on the command line (mirrors other CLI subcommands). + const argv = process.argv.slice(2); + let configPath; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--config' && argv[i + 1]) { + configPath = argv[i + 1]; + i++; + } + } + runMcpServer({ configPath }).catch((e) => { + process.stderr.write(`[iak-mcp] fatal: ${e.message}\n`); + process.exit(1); + }); +} diff --git a/test/confirmations.test.mjs b/test/confirmations.test.mjs new file mode 100644 index 0000000..5ddce10 --- /dev/null +++ b/test/confirmations.test.mjs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createIntent, + decideIntent, + waitForDecision, + listIntents, + startConfirmationsServer, + composeAnnouncers, + _resetForTests, +} from '../src/confirmations.mjs'; + +// --- registry primitives --------------------------------------------------- + +test('createIntent calls the announce hook with the new intent + a fresh id', async () => { + _resetForTests(); + const seen = []; + const id = await createIntent({ + prompt: 'Approve drop database?', + session: 'claude', + channels: ['groupmind'], + announce: async (i) => seen.push(i), + }); + assert.match(id, /^[0-9a-f]+$/); + assert.equal(seen.length, 1); + assert.equal(seen[0].prompt, 'Approve drop database?'); + assert.equal(seen[0].session, 'claude'); + assert.deepEqual(seen[0].channels, ['groupmind']); + assert.equal(seen[0].id, id); +}); + +test('decideIntent resolves a pending intent and rejects bad decisions', async () => { + _resetForTests(); + const id = await createIntent({ prompt: 'p', announce: async () => {} }); + const bad = decideIntent(id, 'maybe'); + assert.equal(bad.ok, false); + assert.match(bad.error, /must be "approve" or "deny"/); + const good = decideIntent(id, 'approve'); + assert.equal(good.ok, true); +}); + +test('decideIntent on unknown id reports error', () => { + const r = decideIntent('does-not-exist', 'approve'); + assert.equal(r.ok, false); +}); + +test('decideIntent is idempotent for the same decision and rejects flip-flops', async () => { + _resetForTests(); + const id = await createIntent({ prompt: 'p', announce: async () => {} }); + decideIntent(id, 'approve'); + const same = decideIntent(id, 'approve'); + assert.equal(same.ok, true); + assert.equal(same.idempotent, true); + const flip = decideIntent(id, 'deny'); + assert.equal(flip.ok, false); + assert.match(flip.error, /already decided/); +}); + +test('waitForDecision resolves immediately when already decided', async () => { + _resetForTests(); + const id = await createIntent({ prompt: 'p', announce: async () => {} }); + decideIntent(id, 'approve'); + const r = await waitForDecision(id, { timeoutMs: 50 }); + assert.equal(r.status, 'decided'); + assert.equal(r.decision, 'approve'); +}); + +test('waitForDecision blocks until decideIntent settles', async () => { + _resetForTests(); + const id = await createIntent({ prompt: 'p', announce: async () => {} }); + setTimeout(() => decideIntent(id, 'deny'), 30); + const r = await waitForDecision(id, { timeoutMs: 500 }); + assert.equal(r.status, 'decided'); + assert.equal(r.decision, 'deny'); +}); + +test('waitForDecision returns timeout if no decision before deadline', async () => { + _resetForTests(); + const id = await createIntent({ prompt: 'p', announce: async () => {} }); + const r = await waitForDecision(id, { timeoutMs: 60 }); + assert.equal(r.status, 'timeout'); +}); + +test('listIntents shows status transitions', async () => { + _resetForTests(); + const id = await createIntent({ prompt: 'something', announce: async () => {} }); + assert.equal(listIntents().length, 1); + assert.equal(listIntents()[0].status, 'pending'); + decideIntent(id, 'approve'); + assert.equal(listIntents()[0].status, 'decided'); + assert.equal(listIntents()[0].decision, 'approve'); +}); + +test('createIntent does NOT fail when announce throws', async () => { + _resetForTests(); + const id = await createIntent({ + prompt: 'p', + announce: async () => { throw new Error('chat down'); }, + }); + assert.match(id, /^[0-9a-f]+$/); + assert.equal(listIntents()[0].status, 'pending'); +}); + +// --- composeAnnouncers ------------------------------------------------------ + +test('composeAnnouncers fans out only to the channels in intent.channels', async () => { + const calls = { groupmind: 0, codewatch: 0 }; + const announce = composeAnnouncers({ + groupmind: async () => { calls.groupmind++; }, + codewatch: async () => { calls.codewatch++; }, + }); + await announce({ id: 'x', prompt: 'p', channels: ['groupmind'] }); + assert.deepEqual(calls, { groupmind: 1, codewatch: 0 }); + await announce({ id: 'x', prompt: 'p', channels: ['groupmind', 'codewatch'] }); + assert.deepEqual(calls, { groupmind: 2, codewatch: 1 }); +}); + +test('composeAnnouncers continues other channels when one throws', async () => { + let okCalled = 0; + const announce = composeAnnouncers({ + groupmind: async () => { throw new Error('chat 500'); }, + codewatch: async () => { okCalled++; }, + }); + await announce({ id: 'x', prompt: 'p', channels: ['groupmind', 'codewatch'] }); + assert.equal(okCalled, 1); +}); + +// --- HTTP listener --------------------------------------------------------- + +let httpServer; +const TEST_PORT = 18788; + +after(() => { try { httpServer?.close(); } catch {} }); + +test('HTTP /intent/:id/decision settles a pending intent end-to-end', async () => { + _resetForTests(); + httpServer = startConfirmationsServer({ port: TEST_PORT, host: '127.0.0.1' }); + const id = await createIntent({ prompt: 'p', announce: async () => {} }); + // Wait in parallel with a HTTP POST that resolves it. + const wait = waitForDecision(id, { timeoutMs: 1500 }); + await fetch(`http://127.0.0.1:${TEST_PORT}/intent/${id}/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision: 'approve' }), + }); + const r = await wait; + assert.equal(r.status, 'decided'); + assert.equal(r.decision, 'approve'); +}); + +test('HTTP rejects unknown intent + bad json + missing decision', async () => { + _resetForTests(); + // Server already listening from previous test. + const r1 = await fetch(`http://127.0.0.1:${TEST_PORT}/intent/missing/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision: 'approve' }), + }); + assert.equal(r1.status, 400); + const r2 = await fetch(`http://127.0.0.1:${TEST_PORT}/intent/whatever/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not json', + }); + assert.equal(r2.status, 400); +}); + +test('HTTP /intents lists current intents', async () => { + _resetForTests(); + await createIntent({ prompt: 'one', announce: async () => {} }); + await createIntent({ prompt: 'two', announce: async () => {} }); + const r = await fetch(`http://127.0.0.1:${TEST_PORT}/intents`); + const list = await r.json(); + assert.equal(list.length, 2); + assert.deepEqual(list.map((i) => i.prompt).sort(), ['one', 'two']); +}); + +test('HTTP auth gate rejects missing/wrong bearer token when configured', async () => { + _resetForTests(); + const port = TEST_PORT + 1; + const srv = startConfirmationsServer({ port, host: '127.0.0.1', authToken: 's3cret' }); + try { + const noAuth = await fetch(`http://127.0.0.1:${port}/intents`); + assert.equal(noAuth.status, 401); + const wrong = await fetch(`http://127.0.0.1:${port}/intents`, { + headers: { Authorization: 'Bearer nope' }, + }); + assert.equal(wrong.status, 401); + const right = await fetch(`http://127.0.0.1:${port}/intents`, { + headers: { Authorization: 'Bearer s3cret' }, + }); + assert.equal(right.status, 200); + } finally { + srv.close(); + } +}); diff --git a/test/mcp-server.test.mjs b/test/mcp-server.test.mjs new file mode 100644 index 0000000..eb4a1bb --- /dev/null +++ b/test/mcp-server.test.mjs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// Unit tests for the MCP server's pure functions. +// Coverage focus: the security-critical decideTmuxRunMode() and the explicit +// session-discovery in configuredAgentSessions(), plus a smoke check that the +// stdio server boots, advertises tools, and omits tmux_run by default. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { decideTmuxRunMode, configuredAgentSessions } from '../src/mcp-server.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BIN = join(__dirname, '..', 'bin', 'iak-mcp.mjs'); + +// --- decideTmuxRunMode ------------------------------------------------------- + +test('decideTmuxRunMode: missing config disables tmux_run (fail-closed)', () => { + const r = decideTmuxRunMode({}); + assert.equal(r.enabled, false); + assert.match(r.reason, /tmux\.allow is missing or empty/); +}); + +test('decideTmuxRunMode: empty allowlist disables tmux_run', () => { + const r = decideTmuxRunMode({ tmux: { allow: [] } }); + assert.equal(r.enabled, false); + assert.match(r.reason, /tmux\.allow is missing or empty/); +}); + +test('decideTmuxRunMode: non-empty allowlist enables tmux_run', () => { + const r = decideTmuxRunMode({ tmux: { allow: ['npm test', 'git status'] } }); + assert.equal(r.enabled, true); + assert.match(r.reason, /tmux\.allow has 2 pattern\(s\)/); +}); + +test('decideTmuxRunMode: mcp.allow_unrestricted=true enables tmux_run even with empty allowlist', () => { + const r = decideTmuxRunMode({ tmux: { allow: [] }, mcp: { allow_unrestricted: true } }); + assert.equal(r.enabled, true); + assert.match(r.reason, /allow_unrestricted=true/); +}); + +test('decideTmuxRunMode: mcp.allow_unrestricted=false does not enable when allowlist empty', () => { + const r = decideTmuxRunMode({ tmux: { allow: [] }, mcp: { allow_unrestricted: false } }); + assert.equal(r.enabled, false); +}); + +// --- configuredAgentSessions ------------------------------------------------ + +test('configuredAgentSessions: returns empty array when nothing is configured', () => { + assert.deepEqual(configuredAgentSessions({}), []); + assert.deepEqual(configuredAgentSessions(null), []); +}); + +test('configuredAgentSessions: prefers explicit mcp.sessions array', () => { + const sessions = configuredAgentSessions({ + mcp: { sessions: ['claude', 'claudemb', 'antigravity'] }, + tmux: { ide_session: 'should-be-ignored', default_session: 'also-ignored' }, + }); + assert.deepEqual(sessions, ['claude', 'claudemb', 'antigravity']); +}); + +test('configuredAgentSessions: falls back to tmux.ide_session + tmux.default_session', () => { + const sessions = configuredAgentSessions({ + tmux: { ide_session: 'claudemb', default_session: 'iak-mb-runner' }, + }); + assert.deepEqual(sessions.sort(), ['claudemb', 'iak-mb-runner']); +}); + +test('configuredAgentSessions: deduplicates ide_session and default_session', () => { + const sessions = configuredAgentSessions({ + tmux: { ide_session: 'same', default_session: 'same' }, + }); + assert.deepEqual(sessions, ['same']); +}); + +test('configuredAgentSessions: does NOT scan unrelated config keys (anti-fragility)', () => { + // Without the explicit mcp.sessions, only the tmux fields are honored. + // The previous implementation would have picked up hypothetical adapter + // entries with .session keys and might pull in unrelated future config. + const sessions = configuredAgentSessions({ + tmux: { ide_session: 'claude' }, + sentry: { session: 'this-should-not-leak' }, + discord: { session: 'this-too' }, + }); + assert.deepEqual(sessions, ['claude']); +}); + +test('configuredAgentSessions: drops empty strings and non-strings from mcp.sessions', () => { + const sessions = configuredAgentSessions({ + mcp: { sessions: ['ok', '', null, 42, 'also-ok'] }, + }); + assert.deepEqual(sessions, ['ok', 'also-ok']); +}); + +// --- end-to-end stdio smoke ------------------------------------------------ + +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +function rpc(line) { return JSON.stringify(line) + '\n'; } + +async function bootAndListTools(configPath) { + const args = configPath ? [BIN, '--config', configPath] : [BIN]; + const child = spawn('node', args, { stdio: ['pipe', 'pipe', 'pipe'] }); + const stdout = []; + child.stdout.on('data', (b) => stdout.push(b)); + child.stdin.write(rpc({ jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '0' } } })); + child.stdin.write(rpc({ jsonrpc: '2.0', method: 'notifications/initialized' })); + child.stdin.write(rpc({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })); + await new Promise((r) => setTimeout(r, 1500)); + child.kill('SIGTERM'); + await new Promise((r) => child.on('exit', r)); + const messages = Buffer.concat(stdout).toString('utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); + return { + init: messages.find((m) => m.id === 1), + tools: (messages.find((m) => m.id === 2)?.result?.tools || []).map((t) => t.name).sort(), + }; +} + +test('iak-mcp.mjs boots, advertises name + a real semver, exposes the safe tools', async () => { + const { init, tools } = await bootAndListTools(); + assert.ok(init, 'expected initialize response'); + assert.equal(init.result.serverInfo.name, 'ide-agent-kit'); + assert.match(init.result.serverInfo.version, /^\d+\.\d+\.\d+$/); + assert.ok(tools.includes('wake_ide')); + assert.ok(tools.includes('list_sessions')); + assert.ok(tools.includes('wake_all')); + assert.ok(tools.includes('read_session')); +}); + +test('iak-mcp.mjs with empty tmux.allow OMITS tmux_run from the tool list (fail-closed)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'iak-mcp-test-')); + const cfgPath = join(dir, 'config.json'); + writeFileSync(cfgPath, JSON.stringify({ tmux: { allow: [], default_session: 't' } })); + try { + const { tools } = await bootAndListTools(cfgPath); + assert.deepEqual(tools, ['list_sessions', 'read_session', 'wake_all', 'wake_ide', 'wake_remote']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('iak-mcp.mjs with mcp.allow_unrestricted=true INCLUDES tmux_run even when allowlist empty', async () => { + const dir = mkdtempSync(join(tmpdir(), 'iak-mcp-test-')); + const cfgPath = join(dir, 'config.json'); + writeFileSync(cfgPath, JSON.stringify({ + tmux: { allow: [], default_session: 't' }, + mcp: { allow_unrestricted: true }, + })); + try { + const { tools } = await bootAndListTools(cfgPath); + assert.ok(tools.includes('tmux_run'), `expected tmux_run, got ${tools.join(',')}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/real-agent-demo.mjs b/test/real-agent-demo.mjs new file mode 100644 index 0000000..906e8b9 --- /dev/null +++ b/test/real-agent-demo.mjs @@ -0,0 +1,38 @@ +// Real MCP-driven request_confirmation demo. +// +// Acts as a Claude Code agent: spawns the IAK MCP server, calls +// request_confirmation as it would in production, blocks until the user +// decides. The intent goes through the production MCP code path, forwarded +// to the live iak-mcp-daemon, surfaces in CodeWatch + GroupMind, and the +// decision flows back through the same channels. + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const transport = new StdioClientTransport({ + command: 'node', + args: ['/Users/petrus/ide-agent-kit/src/mcp-server.mjs'], +}); + +const client = new Client( + { name: 'iak-real-agent-demo', version: '0.0.1' }, + { capabilities: {} } +); + +await client.connect(transport); + +console.log('[demo] connected — calling request_confirmation...'); + +const result = await client.callTool({ + name: 'request_confirmation', + arguments: { + prompt: process.argv[2] || 'Real agent test: deploy build to production server?', + session: 'claudemb-real-demo', + timeoutSec: 600, + }, +}); + +console.log('[demo] result:'); +console.log(JSON.stringify(result, null, 2)); + +await client.close();