musecode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +31 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/codex-mcp.mjs +64 -0
- package/codex-sessions.mjs +153 -0
- package/config.example.json +19 -0
- package/desktop-mcp.mjs +64 -0
- package/lib.mjs +225 -0
- package/live.mjs +95 -0
- package/package.json +30 -0
- package/run-server.ps1 +9 -0
- package/server.mjs +1131 -0
- package/sessions.mjs +102 -0
- package/start.ps1 +45 -0
- package/threads.mjs +59 -0
package/.env.example
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Required: generate independent random values, for example with
|
|
2
|
+
# node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
|
|
3
|
+
BRIDGE_TOKEN=replace-with-a-random-32-byte-base64url-token
|
|
4
|
+
BRIDGE_APPROVAL_SECRET=replace-with-a-different-random-32-byte-base64url-secret
|
|
5
|
+
|
|
6
|
+
# Required when BRIDGE_REQUIRE_APPROVAL=true. Treat the topic name as a secret.
|
|
7
|
+
BRIDGE_NTFY_TOPIC=replace-with-a-private-ntfy-topic
|
|
8
|
+
|
|
9
|
+
# Core settings
|
|
10
|
+
BRIDGE_PORT=8787
|
|
11
|
+
BRIDGE_WORKDIR=workspace
|
|
12
|
+
BRIDGE_DATA=data
|
|
13
|
+
BRIDGE_REQUIRE_APPROVAL=true
|
|
14
|
+
BRIDGE_APPROVAL_TIMEOUT_MINUTES=30
|
|
15
|
+
BRIDGE_TASK_TIMEOUT_MINUTES=30
|
|
16
|
+
BRIDGE_PERMISSION_MODE=bypassPermissions
|
|
17
|
+
BRIDGE_ALLOWED_TOOLS=
|
|
18
|
+
BRIDGE_MODEL=
|
|
19
|
+
|
|
20
|
+
# Agent executables. Commands on PATH or absolute paths are accepted.
|
|
21
|
+
BRIDGE_CLAUDE_PATH=claude
|
|
22
|
+
BRIDGE_CODEX_PATH=codex
|
|
23
|
+
|
|
24
|
+
# Network and notification settings
|
|
25
|
+
BRIDGE_NTFY_SERVER=https://ntfy.sh
|
|
26
|
+
BRIDGE_PUBLIC_URL=
|
|
27
|
+
BRIDGE_ALLOWED_IPS=
|
|
28
|
+
BRIDGE_IP_HEADER=cf-connecting-ip
|
|
29
|
+
|
|
30
|
+
# Optional path to a JSON config file. Environment variables override JSON values.
|
|
31
|
+
BRIDGE_CONFIG_PATH=config.json
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Claude Desktop Bridge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Claude Desktop Bridge
|
|
2
|
+
|
|
3
|
+
Claude Desktop Bridge is a small, dependency-free Node.js service that lets a remote client hand work to Claude Code or Codex on a Windows PC. It exposes the same capabilities as an HTTP API and an MCP server, keeps task state on disk, and can require a human approval before an agent runs.
|
|
4
|
+
|
|
5
|
+
The bridge listens only on `127.0.0.1`. Remote access is expected to arrive through a tunnel or private network that you configure separately.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
Remote client
|
|
11
|
+
|
|
|
12
|
+
| Bearer-authenticated HTTP
|
|
13
|
+
v
|
|
14
|
+
server.mjs ── /mcp (MCP over HTTP)
|
|
15
|
+
| └─ /api/* + /openapi.json (REST/OpenAPI)
|
|
16
|
+
|
|
|
17
|
+
+─ task store + audit log ─────────────── data/
|
|
18
|
+
+─ inbox queues (to-desktop / to-muse) ─ data/*.jsonl
|
|
19
|
+
+─ Claude Code / Claude Desktop sessions
|
|
20
|
+
+─ Codex Desktop tasks (via the official app-server thread API)
|
|
21
|
+
+─ side threads ───────────────────────── data/threads/
|
|
22
|
+
|
|
23
|
+
Claude Desktop or Codex
|
|
24
|
+
|
|
|
25
|
+
└─ desktop-mcp.mjs / codex-mcp.mjs (local stdio MCP)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **HTTP API:** `server.mjs` serves MCP JSON-RPC at `POST /mcp` and equivalent REST operations under `/api/*`. `/openapi.json` describes the REST surface.
|
|
29
|
+
- **Claude and Codex agents:** `ask_claude` can start or resume Claude sessions, including delivery into an open Claude Desktop session. `list_codex_sessions` and `read_codex_session` expose real Codex Desktop tasks; `ask_codex` with a session ID continues one through Codex app-server.
|
|
30
|
+
- **Approvals:** when enabled, a task remains pending until the user opens a signed approval link delivered through ntfy. The approval code is derived from a separate HMAC secret.
|
|
31
|
+
- **Side channels:** a per-session side thread lets a remote client coordinate with a read-only Claude fork without adding messages to the user's main conversation.
|
|
32
|
+
- **Inbox:** append-only JSONL queues carry asynchronous messages between the remote client and local desktop agents. The two stdio MCP entry points expose tools to read and write those queues.
|
|
33
|
+
- **Persistence:** tasks, queue cursors, side threads, results, and audit logs live under `data/`. This directory can contain sensitive conversation content and is ignored by Git.
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Windows with PowerShell
|
|
38
|
+
- Node.js 20 or newer
|
|
39
|
+
- Claude Code and/or Codex CLI installed and authenticated
|
|
40
|
+
- Optional: [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) for the bundled quick-tunnel launcher
|
|
41
|
+
- Optional: an ntfy server/app for approval notifications
|
|
42
|
+
|
|
43
|
+
The bridge has no npm runtime dependencies.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
1. Clone or copy this directory.
|
|
48
|
+
2. Create a private environment file:
|
|
49
|
+
|
|
50
|
+
```powershell
|
|
51
|
+
Copy-Item .env.example .env
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
3. Generate two independent secrets and put them in `BRIDGE_TOKEN` and `BRIDGE_APPROVAL_SECRET`:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run the command twice. If approvals are enabled, also choose a long, unguessable `BRIDGE_NTFY_TOPIC`; the topic name acts as a credential.
|
|
61
|
+
|
|
62
|
+
4. Set `BRIDGE_CLAUDE_PATH` and `BRIDGE_CODEX_PATH` if the commands are not available on `PATH`.
|
|
63
|
+
5. Start the local server:
|
|
64
|
+
|
|
65
|
+
```powershell
|
|
66
|
+
npm start
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or run `./start.ps1` to create a temporary Cloudflare quick tunnel and start the server. `run-server.ps1` is a restart loop suitable for a Windows scheduled task.
|
|
70
|
+
|
|
71
|
+
On first launch, any omitted credentials are generated into the ignored `config.json`. Supplying all credentials through `.env` is recommended because it makes configuration explicit.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Configuration precedence is environment variables (including `.env`), then `config.json`, then built-in defaults. Copy `config.example.json` to `config.json` if you prefer JSON. Never commit `.env` or `config.json`.
|
|
76
|
+
|
|
77
|
+
| Variable | Default | Purpose |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| `BRIDGE_TOKEN` | generated | Bearer token protecting MCP and REST calls |
|
|
80
|
+
| `BRIDGE_APPROVAL_SECRET` | generated | HMAC secret for per-task approval links |
|
|
81
|
+
| `BRIDGE_NTFY_TOPIC` | generated | Private ntfy topic for approval notifications |
|
|
82
|
+
| `BRIDGE_PORT` | `8787` | Local HTTP port |
|
|
83
|
+
| `BRIDGE_WORKDIR` | `workspace` | Working directory for new agent tasks |
|
|
84
|
+
| `BRIDGE_DATA` | `data` | Runtime state directory |
|
|
85
|
+
| `BRIDGE_FILE_DROP` | `%LOCALAPPDATA%\\ClaudeDesktopBridge\\shared-files` | Shared-file storage directory; must be outside the source tree |
|
|
86
|
+
| `BRIDGE_REQUIRE_APPROVAL` | `true` | Require approval before running tasks |
|
|
87
|
+
| `BRIDGE_PERMISSION_MODE` | `bypassPermissions` | Claude Code permission mode |
|
|
88
|
+
| `BRIDGE_ALLOWED_TOOLS` | empty | Comma-separated Claude tool allowlist |
|
|
89
|
+
| `BRIDGE_CLAUDE_PATH` | auto-detected/`claude` | Claude executable or command |
|
|
90
|
+
| `BRIDGE_CODEX_PATH` | `codex.exe` | Codex executable or command |
|
|
91
|
+
| `BRIDGE_PUBLIC_URL` | empty | Fixed public URL used in approval links and Host validation |
|
|
92
|
+
| `BRIDGE_ALLOWED_IPS` | empty | Comma-separated caller IPs or CIDRs |
|
|
93
|
+
| `BRIDGE_IP_HEADER` | `cf-connecting-ip` | Trusted proxy header carrying the caller IP; set empty when unavailable |
|
|
94
|
+
| `BRIDGE_NTFY_SERVER` | `https://ntfy.sh` | ntfy base URL |
|
|
95
|
+
| `BRIDGE_APPROVAL_TIMEOUT_MINUTES` | `30` | Approval expiry |
|
|
96
|
+
| `BRIDGE_TASK_TIMEOUT_MINUTES` | `30` | Agent task timeout |
|
|
97
|
+
| `BRIDGE_MODEL` | empty | Optional Claude model override |
|
|
98
|
+
| `BRIDGE_CONFIG_PATH` | `config.json` | Optional JSON config path |
|
|
99
|
+
|
|
100
|
+
`bypassPermissions` gives Claude broad local access. Keep approval enabled, protect the bearer token and ntfy topic, and use a restricted permission mode plus `BRIDGE_ALLOWED_TOOLS` when full access is unnecessary.
|
|
101
|
+
|
|
102
|
+
## Connect the local MCP inbox
|
|
103
|
+
|
|
104
|
+
Claude Desktop/Claude Code can launch `desktop-mcp.mjs` as a stdio MCP server. Codex can use `codex-mcp.mjs`. Point the command at your clone and make sure the process sees the same `BRIDGE_DATA` directory as the HTTP server.
|
|
105
|
+
|
|
106
|
+
Example Claude MCP configuration:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"mcpServers": {
|
|
111
|
+
"muse-inbox": {
|
|
112
|
+
"command": "node",
|
|
113
|
+
"args": ["C:\\path\\to\\claude-desktop-bridge\\desktop-mcp.mjs"],
|
|
114
|
+
"env": {
|
|
115
|
+
"BRIDGE_DATA": "C:\\path\\to\\claude-desktop-bridge\\data"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Use `codex-mcp.mjs` in the same way for a Codex MCP configuration.
|
|
123
|
+
|
|
124
|
+
## Send a task
|
|
125
|
+
|
|
126
|
+
With the server running, send a task to Codex over REST:
|
|
127
|
+
|
|
128
|
+
```powershell
|
|
129
|
+
$headers = @{ Authorization = "Bearer <your-BRIDGE_TOKEN>" }
|
|
130
|
+
$body = @{ prompt = "List the top-level files in the workspace."; wait_seconds = 0 } | ConvertTo-Json
|
|
131
|
+
$task = Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8787/api/ask_codex -Headers $headers -ContentType application/json -Body $body
|
|
132
|
+
$task
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
If approval is enabled, approve the notification, then poll the returned task ID:
|
|
136
|
+
|
|
137
|
+
```powershell
|
|
138
|
+
$body = @{ task_id = $task.task_id; wait_seconds = 30 } | ConvertTo-Json
|
|
139
|
+
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8787/api/get_task -Headers $headers -ContentType application/json -Body $body
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Replace `ask_codex` with `ask_claude` to route the task to Claude. A client can also initialize MCP at `POST /mcp` and call the same tools through JSON-RPC.
|
|
143
|
+
|
|
144
|
+
For immediate agent-to-agent delivery, send a `send_to_desktop` message beginning with `FOR CODEX`. The bridge writes an acknowledgment back to Muse immediately, selects the most recent Codex Desktop task, and starts the message there. This works while that task is idle because the bridge runs independently as a Windows scheduled task.
|
|
145
|
+
|
|
146
|
+
## Shared file drop
|
|
147
|
+
|
|
148
|
+
The bearer-authenticated REST API can exchange files up to 25 MB. Files are stored under `BRIDGE_FILE_DROP`, which defaults outside the repository so uploaded content cannot be committed accidentally.
|
|
149
|
+
|
|
150
|
+
- `POST /api/upload_file` accepts JSON with `name`, base64-encoded `content_base64`, and optional `content_type`; it returns a `file_id` plus metadata.
|
|
151
|
+
- `GET /api/download_file?file_id=...` downloads the original bytes with an attachment filename.
|
|
152
|
+
- `GET /api/list_files` returns file IDs, names, sizes, content types, and creation timestamps.
|
|
153
|
+
- `DELETE /api/delete_file?file_id=...` removes the stored file and its metadata.
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
TOKEN="your-BRIDGE_TOKEN"
|
|
157
|
+
BASE64=$(base64 < ./example.txt | tr -d '\n')
|
|
158
|
+
curl -X POST http://127.0.0.1:8787/api/upload_file \
|
|
159
|
+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
160
|
+
--data "{\"name\":\"example.txt\",\"content_base64\":\"$BASE64\",\"content_type\":\"text/plain\"}"
|
|
161
|
+
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8787/api/list_files
|
|
162
|
+
curl -H "Authorization: Bearer $TOKEN" -OJ "http://127.0.0.1:8787/api/download_file?file_id=FILE_ID"
|
|
163
|
+
curl -X DELETE -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8787/api/delete_file?file_id=FILE_ID"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Security notes
|
|
167
|
+
|
|
168
|
+
- The service binds to localhost; do not change that unless you also provide equivalent network controls.
|
|
169
|
+
- A tunnel makes the service internet-reachable. Use TLS, keep the bearer token private, pin `BRIDGE_PUBLIC_URL`, and consider an IP allowlist when your proxy provides a trustworthy client-IP header.
|
|
170
|
+
- Approval links contain a task-specific secret. Keep approval enabled for agents with broad filesystem permissions.
|
|
171
|
+
- `data/`, `workspace/`, `.env`, and `config.json` may contain credentials or private conversation content and must remain untracked.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
[MIT](LICENSE)
|
package/codex-mcp.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Local stdio MCP server for Codex: read what Muse sent, reply to Muse,
|
|
2
|
+
// participate in side threads, and inspect tasks Muse has run on this PC.
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { createMcpHandler, postMessage, readMessages, loadTasks, TO_DESKTOP, TO_MUSE } from "./lib.mjs";
|
|
5
|
+
import { appendMessage, readThread, listThreads } from "./threads.mjs";
|
|
6
|
+
|
|
7
|
+
const handle = createMcpHandler({
|
|
8
|
+
name: "muse-inbox",
|
|
9
|
+
version: "1.1.0",
|
|
10
|
+
instructions: "Messages and tasks exchanged between Codex and Meta Muse on the user's phone.",
|
|
11
|
+
tools: [
|
|
12
|
+
{
|
|
13
|
+
name: "read_muse_messages",
|
|
14
|
+
description: "Read messages Meta Muse has left for desktop agents. Returns unread ones and marks them read.",
|
|
15
|
+
inputSchema: { type: "object", properties: { include_read: { type: "boolean", description: "Return the last 50 instead of only unread." } } },
|
|
16
|
+
run: async ({ include_read }) => {
|
|
17
|
+
const messages = readMessages(TO_DESKTOP, { unreadOnly: !include_read });
|
|
18
|
+
return messages.length ? { messages } : { messages: [], note: "No new messages from Muse." };
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "send_to_muse",
|
|
23
|
+
description: "Send a message from Codex to Meta Muse. Muse sees it next time it checks desktop messages.",
|
|
24
|
+
inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
|
|
25
|
+
run: async ({ text }) => {
|
|
26
|
+
if (!text?.trim()) throw new Error("text is required");
|
|
27
|
+
return { sent: postMessage(TO_MUSE, text, "codex-desktop") };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "read_side_thread",
|
|
32
|
+
description:
|
|
33
|
+
"Read the side thread between Meta Muse and one of the user's sessions. Messages are labeled muse, " +
|
|
34
|
+
"side-claude, desktop-claude, desktop-codex, or user. Omit session_id to list threads.",
|
|
35
|
+
inputSchema: { type: "object", properties: { session_id: { type: "string" } } },
|
|
36
|
+
run: async ({ session_id }) => (session_id ? { messages: readThread(session_id).slice(-100) } : { threads: listThreads() }),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "post_to_side_thread",
|
|
40
|
+
description: "Post into a session's side thread as desktop-codex. Muse sees it with side_thread_read.",
|
|
41
|
+
inputSchema: { type: "object", properties: { session_id: { type: "string" }, text: { type: "string" } }, required: ["session_id", "text"] },
|
|
42
|
+
run: async ({ session_id, text }) => {
|
|
43
|
+
if (!text?.trim()) throw new Error("text is required");
|
|
44
|
+
return { posted: appendMessage(session_id, "desktop-codex", text) };
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "list_muse_tasks",
|
|
49
|
+
description: "List recent tasks Meta Muse sent to agents on this PC, with prompts and results.",
|
|
50
|
+
inputSchema: { type: "object", properties: {} },
|
|
51
|
+
run: async () =>
|
|
52
|
+
Object.values(loadTasks()).sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, 20),
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
58
|
+
rl.on("line", async (line) => {
|
|
59
|
+
if (!line.trim()) return;
|
|
60
|
+
let msg;
|
|
61
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
62
|
+
const reply = await handle(msg);
|
|
63
|
+
if (reply) process.stdout.write(JSON.stringify(reply) + "\n");
|
|
64
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Codex app-server client used to discover, inspect, and continue real Codex threads.
|
|
2
|
+
// The protocol is JSONL over stdio. Keeping this client short-lived avoids owning a
|
|
3
|
+
// second long-running Codex service while still using the same persisted thread store
|
|
4
|
+
// as Codex Desktop.
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const FINAL_TURN_STATUSES = new Set(["completed", "failed", "interrupted"]);
|
|
9
|
+
|
|
10
|
+
class CodexAppServer {
|
|
11
|
+
constructor(codexPath, { cwd, timeoutMs = 120_000, onNotification } = {}) {
|
|
12
|
+
this.child = spawn(codexPath, ["app-server", "--stdio"], {
|
|
13
|
+
cwd, windowsHide: true, env: process.env, stdio: ["pipe", "pipe", "pipe"],
|
|
14
|
+
});
|
|
15
|
+
this.pending = new Map();
|
|
16
|
+
this.nextId = 1;
|
|
17
|
+
this.stderr = "";
|
|
18
|
+
this.timeoutMs = timeoutMs;
|
|
19
|
+
this.onNotification = onNotification;
|
|
20
|
+
this.ready = new Promise((resolve, reject) => {
|
|
21
|
+
this.child.once("spawn", resolve);
|
|
22
|
+
this.child.once("error", reject);
|
|
23
|
+
});
|
|
24
|
+
this.child.stderr.on("data", (chunk) => { this.stderr = (this.stderr + chunk).slice(-8_000); });
|
|
25
|
+
readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.handleLine(line));
|
|
26
|
+
this.child.on("close", (code) => {
|
|
27
|
+
const error = new Error(this.stderr.trim() || `Codex app-server exited with code ${code}`);
|
|
28
|
+
for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(error); }
|
|
29
|
+
this.pending.clear();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
handleLine(line) {
|
|
34
|
+
let msg;
|
|
35
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
36
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
37
|
+
const pending = this.pending.get(msg.id);
|
|
38
|
+
this.pending.delete(msg.id);
|
|
39
|
+
clearTimeout(pending.timer);
|
|
40
|
+
if (msg.error) pending.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
41
|
+
else pending.resolve(msg.result);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
this.onNotification?.(msg);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async request(method, params = {}) {
|
|
48
|
+
await this.ready;
|
|
49
|
+
const id = this.nextId++;
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
this.pending.delete(id);
|
|
53
|
+
reject(new Error(`Timed out waiting for ${method}`));
|
|
54
|
+
}, this.timeoutMs);
|
|
55
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
56
|
+
this.child.stdin.write(JSON.stringify({ method, id, params }) + "\n");
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
notify(method, params = {}) {
|
|
61
|
+
this.child.stdin.write(JSON.stringify({ method, params }) + "\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
close() {
|
|
65
|
+
this.child.stdin.end();
|
|
66
|
+
setTimeout(() => this.child.kill(), 1_000).unref();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function withServer(codexPath, options, fn) {
|
|
71
|
+
const server = new CodexAppServer(codexPath, options);
|
|
72
|
+
try {
|
|
73
|
+
await server.request("initialize", {
|
|
74
|
+
clientInfo: { name: "claude-muse-bridge", title: "Claude Muse Bridge", version: "1.2.0" },
|
|
75
|
+
capabilities: { experimentalApi: true },
|
|
76
|
+
});
|
|
77
|
+
server.notify("initialized", {});
|
|
78
|
+
return await fn(server);
|
|
79
|
+
} finally {
|
|
80
|
+
server.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function listCodexSessions(codexPath, { cwd, limit = 30 } = {}) {
|
|
85
|
+
return withServer(codexPath, { cwd }, async (server) => {
|
|
86
|
+
const result = await server.request("thread/list", {
|
|
87
|
+
limit, sortKey: "recency_at", sortDirection: "desc", sourceKinds: [],
|
|
88
|
+
});
|
|
89
|
+
return result?.data || result?.threads || [];
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function readCodexSession(codexPath, threadId, { cwd } = {}) {
|
|
94
|
+
return withServer(codexPath, { cwd }, async (server) => {
|
|
95
|
+
const result = await server.request("thread/read", { threadId, includeTurns: true });
|
|
96
|
+
return result?.thread || result;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function agentText(item) {
|
|
101
|
+
if (!item || item.type !== "agentMessage") return "";
|
|
102
|
+
if (typeof item.text === "string") return item.text;
|
|
103
|
+
if (typeof item.content === "string") return item.content;
|
|
104
|
+
if (Array.isArray(item.content)) return item.content.map((part) => part?.text || "").join("");
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function runCodexSessionTurn(codexPath, threadId, prompt, {
|
|
109
|
+
cwd, timeoutMs = 30 * 60_000, onUpdate,
|
|
110
|
+
} = {}) {
|
|
111
|
+
let turnId = null;
|
|
112
|
+
let text = "";
|
|
113
|
+
let finish;
|
|
114
|
+
const completed = new Promise((resolve, reject) => { finish = { resolve, reject }; });
|
|
115
|
+
|
|
116
|
+
const onNotification = (msg) => {
|
|
117
|
+
const method = msg.method || "";
|
|
118
|
+
const params = msg.params || {};
|
|
119
|
+
const notificationTurnId = params.turn?.id || params.turnId;
|
|
120
|
+
if (turnId && notificationTurnId && notificationTurnId !== turnId) return;
|
|
121
|
+
if (method === "item/agentMessage/delta" && typeof params.delta === "string") {
|
|
122
|
+
text += params.delta;
|
|
123
|
+
onUpdate?.(text);
|
|
124
|
+
} else if (method === "item/completed") {
|
|
125
|
+
const completeText = agentText(params.item);
|
|
126
|
+
if (completeText) { text = completeText; onUpdate?.(text); }
|
|
127
|
+
} else if (method === "turn/completed" && (!turnId || notificationTurnId === turnId)) {
|
|
128
|
+
const status = params.turn?.status || "completed";
|
|
129
|
+
if (status === "completed") finish.resolve({ text, turn: params.turn });
|
|
130
|
+
else finish.reject(new Error(params.turn?.error?.message || `Codex turn ${status}`));
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return withServer(codexPath, { cwd, timeoutMs, onNotification }, async (server) => {
|
|
135
|
+
await server.request("thread/resume", {
|
|
136
|
+
threadId, cwd: cwd || null, approvalPolicy: "never", sandbox: "danger-full-access", excludeTurns: true,
|
|
137
|
+
});
|
|
138
|
+
const started = await server.request("turn/start", {
|
|
139
|
+
threadId,
|
|
140
|
+
input: [{ type: "text", text: prompt }],
|
|
141
|
+
cwd: cwd || null,
|
|
142
|
+
approvalPolicy: "never",
|
|
143
|
+
sandboxPolicy: { type: "dangerFullAccess" },
|
|
144
|
+
turnTrigger: "muse-bridge",
|
|
145
|
+
});
|
|
146
|
+
turnId = started?.turn?.id || started?.id || null;
|
|
147
|
+
const result = await completed;
|
|
148
|
+
if (result.turn?.status && !FINAL_TURN_STATUSES.has(result.turn.status)) {
|
|
149
|
+
throw new Error(`Unexpected Codex turn status: ${result.turn.status}`);
|
|
150
|
+
}
|
|
151
|
+
return { ...result, threadId, turnId };
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"port": 8787,
|
|
3
|
+
"workdir": "workspace",
|
|
4
|
+
"permissionMode": "bypassPermissions",
|
|
5
|
+
"allowedTools": [],
|
|
6
|
+
"requireApproval": true,
|
|
7
|
+
"approvalTimeoutMinutes": 30,
|
|
8
|
+
"ntfyServer": "https://ntfy.sh",
|
|
9
|
+
"publicUrl": null,
|
|
10
|
+
"allowedIps": [],
|
|
11
|
+
"ipHeader": "cf-connecting-ip",
|
|
12
|
+
"model": null,
|
|
13
|
+
"taskTimeoutMinutes": 30,
|
|
14
|
+
"claudePath": "claude",
|
|
15
|
+
"codexPath": "codex",
|
|
16
|
+
"token": null,
|
|
17
|
+
"approvalSecret": null,
|
|
18
|
+
"ntfyTopic": null
|
|
19
|
+
}
|
package/desktop-mcp.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Local stdio MCP server for Claude Desktop / Claude Code: read what Muse sent, reply to Muse,
|
|
2
|
+
// and see tasks Muse has run. Never exposed to the network.
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { createMcpHandler, postMessage, readMessages, loadTasks, TO_DESKTOP, TO_MUSE } from "./lib.mjs";
|
|
5
|
+
import { appendMessage, readThread, listThreads } from "./threads.mjs";
|
|
6
|
+
|
|
7
|
+
const handle = createMcpHandler({
|
|
8
|
+
name: "muse-inbox",
|
|
9
|
+
version: "1.0.0",
|
|
10
|
+
instructions: "Messages and tasks exchanged with Meta Muse on the user's phone.",
|
|
11
|
+
tools: [
|
|
12
|
+
{
|
|
13
|
+
name: "read_muse_messages",
|
|
14
|
+
description: "Read messages Meta Muse (on the user's phone) has left for Claude Desktop. Returns unread ones and marks them read.",
|
|
15
|
+
inputSchema: { type: "object", properties: { include_read: { type: "boolean", description: "Return the last 50 instead of only unread." } } },
|
|
16
|
+
run: async ({ include_read }) => {
|
|
17
|
+
const messages = readMessages(TO_DESKTOP, { unreadOnly: !include_read });
|
|
18
|
+
return messages.length ? { messages } : { messages: [], note: "No new messages from Muse." };
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "send_to_muse",
|
|
23
|
+
description: "Send a message to Meta Muse on the user's phone. Muse sees it next time it checks read_desktop_messages.",
|
|
24
|
+
inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
|
|
25
|
+
run: async ({ text }) => {
|
|
26
|
+
if (!text?.trim()) throw new Error("text is required");
|
|
27
|
+
return { sent: postMessage(TO_MUSE, text, "claude-desktop") };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "read_side_thread",
|
|
32
|
+
description:
|
|
33
|
+
"Read the side thread between Meta Muse and one of the user's sessions (an agent-to-agent channel kept out of the " +
|
|
34
|
+
"user's conversation). Messages are labeled muse / side-claude / desktop-claude / user. Omit session_id to list threads.",
|
|
35
|
+
inputSchema: { type: "object", properties: { session_id: { type: "string" } } },
|
|
36
|
+
run: async ({ session_id }) => (session_id ? { messages: readThread(session_id).slice(-100) } : { threads: listThreads() }),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "post_to_side_thread",
|
|
40
|
+
description: "Post into a session's side thread with Meta Muse, labeled as desktop-claude. Muse sees it with side_thread_read.",
|
|
41
|
+
inputSchema: { type: "object", properties: { session_id: { type: "string" }, text: { type: "string" } }, required: ["session_id", "text"] },
|
|
42
|
+
run: async ({ session_id, text }) => {
|
|
43
|
+
if (!text?.trim()) throw new Error("text is required");
|
|
44
|
+
return { posted: appendMessage(session_id, "desktop-claude", text) };
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "list_muse_tasks",
|
|
49
|
+
description: "List recent tasks Meta Muse sent to Claude on this PC, with prompts and results.",
|
|
50
|
+
inputSchema: { type: "object", properties: {} },
|
|
51
|
+
run: async () =>
|
|
52
|
+
Object.values(loadTasks()).sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, 20),
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
58
|
+
rl.on("line", async (line) => {
|
|
59
|
+
if (!line.trim()) return;
|
|
60
|
+
let msg;
|
|
61
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
62
|
+
const reply = await handle(msg);
|
|
63
|
+
if (reply) process.stdout.write(JSON.stringify(reply) + "\n");
|
|
64
|
+
});
|