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
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.
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.
@@ -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.
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.
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.
@@ -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.
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.
@@ -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:
PikachatDaemonLike interface: abstracts the daemon for testability, defining methods like sendMessage, sendMedia, sendMediaBatch, sendReaction, listGroups, listMembers, acceptWelcome, shutdown, etc.
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.
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.
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.
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.
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.
detectMention(params): returns true if the message text contains the bot's npub, pubkey, or any of the configured mentionPatterns (case-insensitive substring match).
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.
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:
Creates an McpServer instance with the plugin name and version.
Instantiates PikachatClaudeChannel with resolvePikachatClaudeConfig(process.env).
Sets the onNotification callback to emit notifications/claude/channel events through the MCP transport with source: "pikachat", content, and meta.
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
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.