Skip to content

Commit 91a314f

Browse files
zmanianclaude
andcommitted
Add wisp channel plugin (MCP server + launcher)
New plugin architecture replaces the monolithic bridge.py: - server.ts: MCP server + HTTP server on port 39281, handles /message, /events (SSE), /status, /interrupt, /routes, and reverse proxy. Uses @modelcontextprotocol/sdk for channel notifications to Claude. - launcher.py: Lightweight Sprites service wrapper that starts Claude with PTY, auto-accepts prompts, pre-configures settings, and restarts on exit. Validated end-to-end on test-desktop sprite: message sent via HTTP, forwarded as MCP channel notification, Claude responded via reply tool, response streamed back via SSE. No rate limit hit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4852a52 commit 91a314f

File tree

7 files changed

+1047
-0
lines changed

7 files changed

+1047
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "wisp",
3+
"description": "Chat with Claude Code from the Wisp iOS app on Sprites.dev cloud VMs",
4+
"version": "0.0.1",
5+
"keywords": ["wisp", "sprites", "ios", "mobile", "chat", "channel"]
6+
}

wisp-plugin/LICENSE

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Apache License
2+
Version 2.0, January 2004
3+
http://www.apache.org/licenses/
4+
5+
Copyright 2026 Iqlusion Inc.
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.

wisp-plugin/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Wisp Channel Plugin
2+
3+
Channel plugin for [Claude Code](https://code.claude.com) that enables chat from the [Wisp](https://github.com/mcintyre94/wisp) iOS app on [Sprites.dev](https://sprites.dev) cloud VMs.
4+
5+
## How it works
6+
7+
The plugin runs as an MCP server alongside Claude Code on a Sprite. It provides:
8+
9+
- **HTTP server** (port 39281) for receiving messages from the Wisp iOS app
10+
- **SSE streaming** for delivering Claude's responses back to the app
11+
- **Reverse proxy** for routing user HTTP traffic to app backends on the sprite
12+
13+
## Setup
14+
15+
The Wisp iOS app handles installation automatically. When you open a chat on a sprite, Wisp:
16+
17+
1. Uploads the plugin files to `~/.wisp/plugin/`
18+
2. Registers the MCP server in Claude's settings
19+
3. Starts Claude as a managed Sprites service with `--channels server:wisp`
20+
21+
## Development
22+
23+
```bash
24+
cd wisp-plugin
25+
bun install
26+
```
27+
28+
## License
29+
30+
Apache 2.0

wisp-plugin/bun.lock

Lines changed: 195 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wisp-plugin/launcher.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
#!/usr/bin/env python3
2+
"""Wisp launcher — starts Claude Code with PTY and channel plugin.
3+
4+
Runs as a Sprites managed service. Handles:
5+
- Pre-configuring Claude to skip interactive onboarding
6+
- Starting Claude with PTY for interactive mode
7+
- Auto-accepting any remaining interactive prompts
8+
- Restarting Claude if it exits unexpectedly
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import os
15+
import pty
16+
import re
17+
import signal
18+
import subprocess
19+
import sys
20+
import threading
21+
import time
22+
from pathlib import Path
23+
24+
PLUGIN_DIR = Path.home() / ".wisp" / "plugin"
25+
ANSI_CSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
26+
ANSI_OSC_RE = re.compile(r"\x1b\].*?(?:\x07|\x1b\\)")
27+
ANSI_SINGLE_RE = re.compile(r"\x1b[@-Z\\-_]")
28+
29+
30+
def log(message: str) -> None:
31+
print(f"[wisp-launcher] {message}", file=sys.stderr, flush=True)
32+
33+
34+
def strip_terminal_control(value: str) -> str:
35+
value = ANSI_OSC_RE.sub("", value)
36+
value = ANSI_CSI_RE.sub("", value)
37+
value = ANSI_SINGLE_RE.sub("", value)
38+
return value.replace("\r", "")
39+
40+
41+
def ensure_claude_config() -> None:
42+
"""Pre-configure Claude to skip interactive onboarding prompts."""
43+
global_config_path = Path.home() / ".claude.json"
44+
try:
45+
config = json.loads(global_config_path.read_text(encoding="utf-8"))
46+
except (FileNotFoundError, json.JSONDecodeError):
47+
config = {}
48+
49+
changed = False
50+
if not config.get("hasCompletedOnboarding"):
51+
config["hasCompletedOnboarding"] = True
52+
changed = True
53+
if config.get("numStartups") is None:
54+
config["numStartups"] = 1
55+
changed = True
56+
if changed:
57+
global_config_path.write_text(json.dumps(config, sort_keys=True), encoding="utf-8")
58+
log("Pre-configured Claude global config")
59+
60+
settings_dir = Path.home() / ".claude"
61+
settings_dir.mkdir(parents=True, exist_ok=True)
62+
settings_path = settings_dir / "settings.json"
63+
try:
64+
settings = json.loads(settings_path.read_text(encoding="utf-8"))
65+
except (FileNotFoundError, json.JSONDecodeError):
66+
settings = {}
67+
68+
changed_settings = False
69+
if not settings.get("skipDangerousModePermissionPrompt"):
70+
settings["skipDangerousModePermissionPrompt"] = True
71+
changed_settings = True
72+
permissions = settings.setdefault("permissions", {})
73+
if permissions.get("defaultMode") != "bypassPermissions":
74+
permissions["defaultMode"] = "bypassPermissions"
75+
changed_settings = True
76+
if changed_settings:
77+
settings_path.write_text(json.dumps(settings, sort_keys=True), encoding="utf-8")
78+
log("Configured Claude permissions")
79+
80+
81+
def ensure_mcp_config(working_directory: str) -> None:
82+
"""Register the wisp MCP server in Claude's project settings."""
83+
settings_path = Path.home() / ".claude.json"
84+
try:
85+
config = json.loads(settings_path.read_text(encoding="utf-8"))
86+
except (FileNotFoundError, json.JSONDecodeError):
87+
config = {}
88+
89+
projects = config.setdefault("projects", {})
90+
project = projects.setdefault(working_directory, {})
91+
project["hasTrustDialogAccepted"] = True
92+
mcp_servers = project.setdefault("mcpServers", {})
93+
mcp_servers["wisp"] = {
94+
"command": "bun",
95+
"args": ["run", str(PLUGIN_DIR / "server.ts")],
96+
}
97+
settings_path.write_text(json.dumps(config, sort_keys=True), encoding="utf-8")
98+
99+
100+
def pump_pty_output(master_fd: int, stdout_handle) -> None:
101+
"""Read PTY output and auto-accept interactive prompts."""
102+
accepted_theme = False
103+
accepted_trust = False
104+
accepted_dev_channels = False
105+
accepted_bypass = False
106+
prompt_buffer = ""
107+
108+
try:
109+
while True:
110+
try:
111+
chunk = os.read(master_fd, 4096)
112+
except OSError:
113+
break
114+
if not chunk:
115+
break
116+
117+
stdout_handle.write(chunk)
118+
stdout_handle.flush()
119+
120+
prompt_buffer = strip_terminal_control(
121+
(prompt_buffer + chunk.decode("utf-8", errors="ignore"))[-8000:]
122+
)
123+
compact = re.sub(r"\s+", "", prompt_buffer)
124+
125+
if not accepted_theme and "Choosethetextstyle" in compact and "Darkmode" in compact:
126+
os.write(master_fd, b"\r")
127+
accepted_theme = True
128+
log("Accepted theme prompt")
129+
130+
if not accepted_trust and "Quicksafetycheck" in compact and "Yes,Itrustthisfolder" in compact:
131+
os.write(master_fd, b"\r")
132+
accepted_trust = True
133+
log("Accepted workspace trust prompt")
134+
135+
if (
136+
not accepted_dev_channels
137+
and "Loadingdevelopmentchannels" in compact
138+
and "Iamusingthisforlocaldevelopment" in compact
139+
):
140+
os.write(master_fd, b"\r")
141+
accepted_dev_channels = True
142+
log("Accepted development channels prompt")
143+
144+
if (
145+
not accepted_bypass
146+
and "BypassPermissionsmode" in compact
147+
and "Yes,Iaccept" in compact
148+
):
149+
os.write(master_fd, b"\x1b[B")
150+
time.sleep(0.1)
151+
os.write(master_fd, b"\r")
152+
accepted_bypass = True
153+
log("Accepted bypass permissions prompt")
154+
finally:
155+
try:
156+
os.close(master_fd)
157+
except OSError:
158+
pass
159+
stdout_handle.close()
160+
161+
162+
def start_claude(working_directory: str) -> subprocess.Popen:
163+
"""Start Claude with PTY and channel plugin."""
164+
Path(working_directory).mkdir(parents=True, exist_ok=True)
165+
ensure_mcp_config(working_directory)
166+
167+
command = [
168+
"claude",
169+
"--dangerously-skip-permissions",
170+
"--dangerously-load-development-channels",
171+
"server:wisp",
172+
"--add-dir",
173+
working_directory,
174+
]
175+
176+
env = os.environ.copy()
177+
env["NO_DNA"] = "1"
178+
179+
log_dir = PLUGIN_DIR / "logs"
180+
log_dir.mkdir(parents=True, exist_ok=True)
181+
stdout_handle = (log_dir / "claude.stdout.log").open("ab")
182+
183+
master_fd, slave_fd = pty.openpty()
184+
try:
185+
process = subprocess.Popen(
186+
command,
187+
cwd=working_directory,
188+
env=env,
189+
stdin=slave_fd,
190+
stdout=slave_fd,
191+
stderr=slave_fd,
192+
preexec_fn=os.setsid,
193+
close_fds=True,
194+
)
195+
finally:
196+
os.close(slave_fd)
197+
198+
threading.Thread(
199+
target=pump_pty_output,
200+
args=(master_fd, stdout_handle),
201+
daemon=True,
202+
).start()
203+
204+
log(f"Started Claude pid={process.pid}")
205+
return process
206+
207+
208+
def main() -> None:
209+
working_directory = os.environ.get("WISP_WORKING_DIRECTORY", "/home/sprite/project")
210+
ensure_claude_config()
211+
212+
while True:
213+
process = start_claude(working_directory)
214+
exit_code = process.wait()
215+
log(f"Claude exited with code {exit_code}")
216+
217+
# Brief pause before restart to avoid tight loops
218+
time.sleep(2)
219+
log("Restarting Claude...")
220+
221+
222+
if __name__ == "__main__":
223+
main()

wisp-plugin/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "wisp-channel-plugin",
3+
"version": "0.0.1",
4+
"private": true,
5+
"scripts": {
6+
"start": "bun run server.ts"
7+
},
8+
"dependencies": {
9+
"@modelcontextprotocol/sdk": "^1.12.1"
10+
}
11+
}

0 commit comments

Comments
 (0)