Back to feed

feat/claude-channel-plugin

sledtools/pika · branch #21 · target master · updated 2026-03-20 17:46:37

branch: merged tutorial: ready ci: success

CI

Run #25 · success · 2 lane(s)

head 795a93c49c4bc8b32677ffc1f24815423da268e0 · queued 2026-03-20 17:40:33

started 2026-03-20 17:40:38

finished 2026-03-20 17:40:40

Lane #62 · check-pika-rust · success · ./scripts/pikaci-staged-linux-remote.sh run pre-merge-pika-rust

lane id pika_rust · retries 0 · queued 2026-03-20 17:40:33

pikaci run 20260320T174040Z-666680de · target pre-merge-pika-rust

started 2026-03-20 17:40:38

finished 2026-03-20 17:40:40

[pikaci] run started: 20260320T174040Z-666680de · pre-merge-pika-rust · Run the VM-backed Rust tests from the pre-merge pika lane
[pikaci] run finished: 20260320T174040Z-666680de · status=skipped · skipped; no changed files matched 13 filter(s)
[pikaci-tools] staged-linux-remote: resolution=nix-build package_root=/nix/store/fcidc2klz0ih3i2vdxh864702gy0paa5-pikaci-0.1.0
[pikaci-tools] staged-linux-remote: pikaci=/nix/store/fcidc2klz0ih3i2vdxh864702gy0paa5-pikaci-0.1.0/bin/pikaci
[pikaci-tools] staged-linux-remote: helper=/nix/store/fcidc2klz0ih3i2vdxh864702gy0paa5-pikaci-0.1.0/bin/pikaci-fulfill-prepared-output
[pikaci-tools] staged-linux-remote: launcher=/nix/store/fcidc2klz0ih3i2vdxh864702gy0paa5-pikaci-0.1.0/bin/pikaci-launch-fulfill-prepared-output
Lane #63 · check-pika-followup · success · ./scripts/pikaci-staged-linux-remote.sh run pre-merge-pika-followup

lane id pika_followup · retries 0 · queued 2026-03-20 17:40:33

pikaci run 20260320T174040Z-47809f4a · target pre-merge-pika-followup

started 2026-03-20 17:40:38

finished 2026-03-20 17:40:40

[pikaci] run started: 20260320T174040Z-47809f4a · pre-merge-pika-followup · Run the VM-backed non-Rust follow-up checks from the pre-merge pika lane
[pikaci] run finished: 20260320T174040Z-47809f4a · status=skipped · skipped; no changed files matched 23 filter(s)
error (ignored): SQLite database '/var/lib/pika-news/.cache/nix/eval-cache-v6/28be4fe6c8fd29484988a7fd81382905406ff607c4c3e787e421bc0ebd2f3a42.sqlite' is busy
[pikaci-tools] staged-linux-remote: resolution=nix-build package_root=/nix/store/fcidc2klz0ih3i2vdxh864702gy0paa5-pikaci-0.1.0
[pikaci-tools] staged-linux-remote: pikaci=/nix/store/fcidc2klz0ih3i2vdxh864702gy0paa5-pikaci-0.1.0/bin/pikaci
[pikaci-tools] staged-linux-remote: helper=/nix/store/fcidc2klz0ih3i2vdxh864702gy0paa5-pikaci-0.1.0/bin/pikaci-fulfill-prepared-output
[pikaci-tools] staged-linux-remote: launcher=/nix/store/fcidc2klz0ih3i2vdxh864702gy0paa5-pikaci-0.1.0/bin/pikaci-launch-fulfill-prepared-output

merge commit b048d93ddbaa084c0e0c7be0dbbc86cb519bf20b

Summary

This branch introduces the pikachat-claude plugin, a new Claude Code channel plugin that bridges Pika MLS chats to Claude via the pikachat daemon. The plugin exposes inbound chat messages as Claude channel notifications and provides MCP tools for replying, reacting, and sending files. It implements a complete DM/group access model with pairing-code onboarding, per-sender allowlists, and per-group mention gating. The daemon lifecycle (launch, relay configuration, key-package publishing, welcome acceptance) is managed by the plugin process, which acts as a stdio MCP server. The branch adds a full TypeScript codebase under pikachat-claude/ with configuration resolution, daemon client/protocol layer, channel runtime, message formatting, access-state persistence, and comprehensive deterministic tests plus a local-relay end-to-end harness.

Tutorial Steps

Add .gitignore entries for the new plugin

Intent: Exclude pikachat-claude build artifacts, dependencies, and lockfile from version control, matching the pattern already used for pikachat-openclaw.

Affected files: .gitignore

Evidence
@@ -7,6 +7,9 @@ pikachat-openclaw/.state/
+pikachat-claude/node_modules/
+pikachat-claude/package-lock.json
+pikachat-claude/dist/

Three lines are added to .gitignore so that node_modules/, package-lock.json, and the esbuild dist/ output directory inside pikachat-claude/ are not tracked. This mirrors the existing ignore rules for the pikachat-openclaw sibling package.

Create the implementation brief

Intent: Document the design contract, acceptance criteria, architecture, access model, and phased plan for the Claude channel plugin before writing code.

Affected files: docs/claude-channel-plugin-brief.md

Evidence
@@ -0,0 +1,193 @@
+---
+summary: Implementation brief for a Claude Code channel plugin backed by pikachat daemon
+---
+# Pikachat Claude Channel Plugin Brief

A 193-line markdown brief is added under docs/. It defines:

  • Goal: expose Pika MLS chats to Claude through the channel notification contract, with sender gating.
  • Acceptance criteria: DM routing with pairing, group routing with mention gating, reply/react/file-send tools, and a local-relay e2e proof.
  • Constraints: reuse TypeScript patterns from pikachat-openclaw, avoid direct SQLite, treat edit_message as non-MVP.
  • Architecture: the plugin process is the MCP stdio server; it spawns pikachat daemon as a child, consumes its JSONL event stream, and applies access policy before emitting Claude channel notifications.
  • Access model: stored in ~/.claude/channels/pikachat/access.json with DM policies (pairing/allowlist/disabled) and per-group enablement with optional sender allowlists and mention gating.
  • Phases: MCP wrapper → access model → parity gaps → packaging and tests.

This document serves as the authoritative design reference for the rest of the branch.

Scaffold the Claude plugin manifest and MCP server config

Intent: Register the plugin with Claude Code by providing the required plugin.json manifest and .mcp.json server definition.

Affected files: pikachat-claude/.claude-plugin/plugin.json, pikachat-claude/.mcp.json

Evidence
@@ -0,0 +1,6 @@
+{
+  "name": "pikachat-claude",
+  "version": "0.1.0",
+  "mcpServers": "./.mcp.json"
+}
@@ -0,0 +1,12 @@
+{
+  "mcpServers": {
+    "pikachat": {
+      "command": "node",
+      "args": ["${CLAUDE_PLUGIN_ROOT}/dist/server.js"],

plugin.json registers the plugin name, version, and points to .mcp.json for MCP server definitions.

.mcp.json declares a single pikachat MCP server that runs node dist/server.js (the esbuild bundle) with a 30-second startup timeout. The CLAUDE_PLUGIN_ROOT variable is resolved by Claude Code at runtime. The PIKACHAT_CHANNEL_SOURCE env var is set to "pikachat" so the channel runtime knows its source identifier.

Add package.json with build, typecheck, and test scripts

Intent: Define the Node project structure with esbuild bundling for the MCP server and tsx-based test runner.

Affected files: pikachat-claude/package.json, pikachat-claude/tsconfig.json

Evidence
@@ -0,0 +1,20 @@
+{
+  "name": "pikachat-claude",
+  "type": "module",
+  "scripts": {
+    "build": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js --banner:js='#!/usr/bin/env node'",

The package uses ESM ("type": "module") and defines four scripts:

  • build: bundles src/server.ts into a single dist/server.js using esbuild with Node platform targeting and ESM format.
  • typecheck: runs tsc --noEmit for type validation without emitting.
  • test: uses node --import tsx --test to run all *.test.ts files with the Node built-in test runner.
  • test:e2e-local-relay: gated behind RUN_PIKACHAT_CLAUDE_E2E=1, runs the local relay end-to-end test.

Dev dependencies include @modelcontextprotocol/sdk for MCP server primitives, esbuild for bundling, tsx for TypeScript test execution, and typescript for type checking.

Implement configuration resolution

Intent: Parse environment variables into a strongly-typed config object with sensible defaults for relays, daemon backend, paths, and access file location.

Affected files: pikachat-claude/src/config.ts, pikachat-claude/src/config.test.ts

Evidence
@@ -0,0 +1,91 @@
+const DEFAULT_MESSAGE_RELAYS = [
+  "wss://relay.primal.net",
+  "wss://nos.lol",
+  "wss://relay.damus.io",
+  "wss://us-east.nostr.pikachat.org",
+  "wss://eu.nostr.pikachat.org",
+];
@@ -0,0 +1,42 @@
+  it("falls back to the default pikachat relay profile", () => {
+    const config = resolvePikachatClaudeConfig({});
+    assert.deepEqual(config.relays, defaultPikachatRelays());
+  });

config.ts exports resolvePikachatClaudeConfig(env) which reads PIKACHAT_RELAYS, PIKACHAT_STATE_DIR, PIKACHAT_DAEMON_CMD, PIKACHAT_DAEMON_ARGS, PIKACHAT_DAEMON_VERSION, PIKACHAT_DAEMON_BACKEND, PIKACHAT_AUTO_ACCEPT_WELCOMES, PIKACHAT_CHANNEL_SOURCE, and PIKACHAT_CLAUDE_HOME.

Key design decisions:

  • Relay fallback: when PIKACHAT_RELAYS is unset, the five default Nostr relays from the Pika relay profile are used.
  • Tilde expansion: ~/... paths are expanded to os.homedir() before path.resolve().
  • Channel home: defaults to ~/.claude/channels/pikachat/, with access.json and inbox/ derived from it.
  • Daemon args: parsed as either a JSON array or comma-separated string.
  • Boolean parsing: parseBoolean() treats 0, false, no, off as false; everything else (including empty) as the provided fallback.

The test file validates relay fallback, explicit relay override, tilde-based home resolution, boolean parsing, and daemon args parsing.

Define the daemon JSONL protocol types

Intent: Create a TypeScript mirror of the Rust daemon's JSONL protocol so the client and runtime can type-check all inbound events and outbound commands.

Affected files: pikachat-claude/src/daemon-protocol.ts

Evidence
@@ -0,0 +1,99 @@
+export type PikachatDaemonInCmd =
+  | { cmd: "set_relays"; ...}
+  | { cmd: "publish_keypackage"; ...}
+  | { cmd: "send_message"; ...}

daemon-protocol.ts defines two discriminated union types:

  • PikachatDaemonInCmd: all commands the plugin can send to the daemon (e.g., set_relays, publish_keypackage, send_message, send_media, send_media_batch, react, send_typing, list_groups, list_members, get_messages, list_pending_welcomes, accept_welcome, shutdown).
  • PikachatDaemonOutMsg: all events the daemon can emit (e.g., ready, message_received, group_joined, group_created, group_updated, welcome_received, error, response).

Each variant includes the fields from the Rust protocol. The message_received event includes optional media attachments with local_path, filename, mime_type, url, original_hash_hex, nonce_hex, and scheme_version. A PikachatDaemonEventHandler type alias is exported for the event callback signature.

Build the daemon client with JSONL IPC and send throttling

Intent: Implement the child-process spawning, JSONL line protocol, request/response correlation, event dispatching, and outbound send throttling for the daemon.

Affected files: pikachat-claude/src/daemon-client.ts, pikachat-claude/src/daemon-client.test.ts

Evidence
@@ -0,0 +1,378 @@
+export class PikachatDaemonClient implements PikachatDaemonLike {
+  static readonly DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
+  #proc: ChildProcessWithoutNullStreams;
@@ -0,0 +1,26 @@
+  it("continues running queued sends after a failure", async () => {
+    const throttle = new SendThrottle(0);

daemon-client.ts exports three key constructs:

  1. PikachatDaemonLike interface: abstracts the daemon for testability, defining methods like sendMessage, sendMedia, sendMediaBatch, sendReaction, listGroups, listMembers, acceptWelcome, shutdown, etc.

  2. PikachatDaemonClient class: spawns the daemon as a child process, reads its stdout line-by-line with readline, and dispatches:

    • ready events to the ready promise
    • response events to pending request resolvers (correlated by request_id)
    • all other events to the registered onEvent handler

    Commands are serialized as JSON lines to the child's stdin. Each request gets a unique request_id and a configurable timeout (default 30s). The #pending map tracks in-flight requests.

  3. SendThrottle class: serializes outbound sends through a promise chain with a configurable minimum interval between sends. Failures are propagated to the caller without breaking the chain for subsequent enqueued operations.

The test verifies that SendThrottle continues processing queued sends even after a prior send throws an error.

Add daemon launch spec resolution and binary installation

Intent: Resolve the daemon binary path, build launch arguments, and handle auto-installation from GitHub releases when no local binary is available.

Affected files: pikachat-claude/src/daemon-launch.ts, pikachat-claude/src/daemon-install.ts

Evidence
@@ -0,0 +1,88 @@
+export async function buildPikachatDaemonLaunchSpec(params: {
+  config: PikachatClaudeConfig;
+  log?: PikachatLogger;
+}): Promise<{ cmd: string; args: string[]; autoAcceptWelcomes: boolean }>

daemon-launch.ts exports buildPikachatDaemonLaunchSpec() which determines how to start the daemon:

  • If daemonCmd is explicitly set in config, it uses that directly.
  • Otherwise it searches $PATH for a pikachat binary.
  • If no binary is found, it calls into daemon-install.ts to download the appropriate release from GitHub (matching the current platform/arch).
  • The function builds the argument list including daemon, optional --state-dir, optional --auto-accept-welcomes, and returns whether auto-accept is handled at the launch level.

daemon-install.ts handles downloading, extracting, and caching the pikachat binary in a local directory, reusing the same installation patterns established by pikachat-openclaw.

Implement the DM/group access model with pairing lifecycle

Intent: Provide pure-functional access state helpers for DM pairing, allowlists, group enablement, and mention gating, backed by JSON file persistence.

Affected files: pikachat-claude/src/access.ts, pikachat-claude/src/access.test.ts

Evidence
@@ -0,0 +1,251 @@
+export type DmPolicy = "pairing" | "allowlist" | "disabled";
+export function evaluateDmAccess(state: AccessState, senderId: string): "allowed" | "pairing" | "blocked"
@@ -0,0 +1,79 @@
+  it("creates and approves pairings", () => {
+    const created = ensurePendingPairing(defaultAccessState(), "AABB", "ccdd", 1000);
+    assert.match(created.pairing.code, /^[0-9a-f]{6}$/);

access.ts implements the access model described in the brief as pure functions over an immutable AccessState type:

  • AccessState: contains dmPolicy, allowFrom[], groups{}, mentionPatterns[], and pendingPairings{}.
  • DM policy evaluation (evaluateDmAccess): checks if the sender is in allowFrom; if not, returns "pairing" or "blocked" depending on the policy.
  • Pairing lifecycle: ensurePendingPairing() generates a 6-hex-char code, approvePairing() moves the sender to the allowlist, denyPairing() removes the pending entry. Codes expire after 24 hours via pruneExpiredPairings().
  • Group access (evaluateGroupAccess): checks if the group is enabled, applies optional per-group allowFrom, and reports requireMention.
  • Persistence: loadAccessState() reads and normalizes from disk (all IDs lowercased), saveAccessState() writes atomically via temp-file-then-rename.

All sender and group IDs are normalized to lowercase. The test suite covers pairing creation, approval, denial, expiry, reuse of live pairings, DM policy transitions, and per-group allowlist/mention behavior.

Add message formatting and mention detection

Intent: Transform raw daemon message events into Claude-facing notification content, handling attachment augmentation and bot-mention detection.

Affected files: pikachat-claude/src/message-format.ts, pikachat-claude/src/message-format.test.ts

Evidence
@@ -0,0 +1,67 @@
+export function augmentMessageText(text: string, media: MediaAttachment[]): string
+export function detectMention(params: { text: string; botPubkey: string; botNpub: string; mentionPatterns: string[] }): boolean
+export function sanitizeMeta(meta: Record<string, string>): Record<string, string>

message-format.ts exports three utilities:

  1. augmentMessageText(text, media): appends [Attachment: filename (mime_type) → local_path] lines for each media item that has a local_path. This gives Claude direct filesystem access to inbound attachments.

  2. detectMention(params): returns true if the message text contains the bot's npub, pubkey, or any of the configured mentionPatterns (case-insensitive substring match).

  3. sanitizeMeta(meta): strips undefined/empty values from the metadata object before it's included in the channel notification.

The test suite verifies attachment augmentation formatting, mention detection for npub/pubkey/custom patterns, and meta sanitization behavior.

Implement the channel runtime orchestrating daemon, access, and notifications

Intent: Wire together the daemon lifecycle, access policy, and Claude channel notification emission into a single runtime class that the MCP server drives.

Affected files: pikachat-claude/src/channel-runtime.ts, pikachat-claude/src/channel-runtime.test.ts

Evidence
@@ -0,0 +1,500 @@
+export class PikachatClaudeChannel {
+  async start(): Promise<void>
+  async reply(request: ReplyRequest): Promise<{ eventIds: string[]; notes: string[] }>
+  async react(request: ReactRequest): Promise<{ event_id?: string }>
@@ -0,0 +1,335 @@
+  it("pairs unknown DM senders instead of delivering them", async () => {
+  it("delivers allowlisted DMs", async () => {
+  it("enforces group mention gating", async () => {

channel-runtime.ts contains PikachatClaudeChannel, the central orchestrator:

Startup (start()):

  • Creates channel home and inbox directories
  • Loads and prunes the access state
  • Builds the daemon launch spec and spawns the daemon via the injected factory
  • Waits for the ready event to capture botPubkey and botNpub
  • Configures relays, publishes key packages, and seeds known group member counts
  • On any startup failure, cleans up the daemon before re-throwing

Inbound message handling (#handleInboundMessage):

  • Ignores messages from self
  • Resolves chat type (direct vs group) from cached member counts, falling back to a listMembers query
  • For DMs: evaluates access → if allowed, emits notification; if pairing, generates code and sends it back to the sender; if blocked, drops silently
  • For groups: checks group enablement and sender allowlist, then applies mention gating before emitting

Outbound tools:

  • reply(): sends text via sendMessage, files via sendMedia/sendMediaBatch, resolves file paths via realpath(). Returns event IDs and notes (e.g., reply_to not yet supported).
  • react(): proxies to daemon.sendReaction()

Access management: allowSender(), removeSender(), enableGroup(), disableGroup(), approvePairing(), denyPairing(), setDmPolicy() — all serialized through #withAccessLock to prevent concurrent file corruption.

Test factory: createInMemoryChannelForTests() wires a fake daemon into the runtime with configurable paths and time functions.

The test suite uses a FakeDaemon class and covers:

  • Unknown DM senders get a pairing code (not delivered)
  • Concurrent pairings for different senders are both persisted
  • Allowlisted DMs are delivered with attachment augmentation
  • Group messages without mention are filtered; with mention they pass through
  • Daemon cleanup on startup failure
  • Outbound file sends return media event IDs

Build the MCP stdio server exposing tools and channel notifications

Intent: Create the entry point that Claude Code launches, registering MCP tools for reply, react, access management, and status, and wiring channel notifications into the MCP notification stream.

Affected files: pikachat-claude/src/server.ts

Evidence
@@ -0,0 +1,229 @@
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

server.ts is the bundled entry point (dist/server.js). It:

  1. Creates an McpServer instance with the plugin name and version.
  2. Instantiates PikachatClaudeChannel with resolvePikachatClaudeConfig(process.env).
  3. Sets the onNotification callback to emit notifications/claude/channel events through the MCP transport with source: "pikachat", content, and meta.
  4. Registers MCP tools:
    • reply: accepts chatId, optional text, optional replyTo, optional files[]; calls channel.reply()
    • react: accepts chatId, eventId, emoji; calls channel.react()
    • approve_pairing: accepts code; calls channel.approvePairing()
    • deny_pairing: accepts code; calls channel.denyPairing()
    • set_dm_policy: accepts policy; calls channel.setDmPolicy()
    • allow_sender / remove_sender: manage the DM allowlist
    • enable_group / disable_group: manage group access
    • access_status: returns the current access state
    • status: returns bot pubkey, npub, and known groups
  5. Starts the channel, connects the stdio transport, and handles graceful shutdown on SIGINT/SIGTERM.

The server instructions embedded in the MCP registration tell Claude how to interpret <channel source="pikachat" ...> tags and which tools to use for replies and reactions.

Add the local-relay end-to-end test harness

Intent: Prove the full round-trip: remote Pika message → daemon → plugin notification → plugin reply tool → remote user receives reply, using a real daemon and local Nostr relay.

Affected files: pikachat-claude/src/local-relay-e2e.test.ts

Evidence
@@ -0,0 +1,148 @@
+import { describe, it } from "node:test";
+const RUN_E2E = process.env.RUN_PIKACHAT_CLAUDE_E2E === "1";

local-relay-e2e.test.ts is gated behind RUN_PIKACHAT_CLAUDE_E2E=1 and requires working cargo and go toolchains. When enabled, it:

  1. Starts a local Nostr relay (using the Go-based relay from the monorepo)
  2. Spawns two pikachat daemon instances ("bot" and "remote user") pointed at the local relay
  3. Has the remote user create a DM group and send a message
  4. Verifies the plugin channel runtime emits a channel notification
  5. Calls the plugin's reply() method
  6. Verifies the remote user's daemon receives the reply

This proves the full transport chain without requiring external infrastructure.

Add the plugin README with local dev and identity instructions

Intent: Document how to build, test, configure, and run the plugin locally, including identity management and startup verification.

Affected files: pikachat-claude/README.md

Evidence
@@ -0,0 +1,101 @@
+# pikachat-claude
+## Local development
+```sh
+cd /Users/futurepaul/dev/sec/other-peoples-code/pika/pikachat-claude
+npm install
+npm run build
+```

The README covers:

  • Scope: DM routing with pairing, group mention gating, reply/react/file-send tools, attachment surfacing, local relay e2e
  • Local dev: npm install && npm run build, then launch Claude with --plugin-dir ./pikachat-claude --dangerously-load-development-channels
  • Environment variables: full reference for PIKACHAT_RELAYS, PIKACHAT_STATE_DIR, PIKACHAT_DAEMON_CMD, PIKACHAT_DAEMON_ARGS, PIKACHAT_DAEMON_VERSION, PIKACHAT_DAEMON_BACKEND, PIKACHAT_AUTO_ACCEPT_WELCOMES, PIKACHAT_CHANNEL_SOURCE
  • Testing: npm test for unit tests, npm run test:e2e-local-relay for the full round-trip
  • Identity: how to inspect the daemon's npub using pikachat identity
  • Startup sanity check: ps command to verify the process tree (Claude → node server.js → pikachat daemon)

Diff