@neuralmux/omp-superwhisper 1.0.1
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/README.md +205 -0
- package/bin/com.superwhisper.bridge.plist +28 -0
- package/bin/install-bridge-service.ts +140 -0
- package/bin/superwhisper-bridge.ts +287 -0
- package/extensions/constants.ts +4 -0
- package/extensions/host.ts +237 -0
- package/extensions/inbox.ts +83 -0
- package/extensions/message.ts +56 -0
- package/extensions/poll.ts +115 -0
- package/extensions/superwhisper.ts +356 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# @neuralmux/omp-superwhisper
|
|
2
|
+
|
|
3
|
+
Superwhisper voice integration extension for [Oh My Pi](https://github.com/neuralmux/oh-my-pi).
|
|
4
|
+
|
|
5
|
+
Get voice notifications when your AI coding tasks complete, and respond with your voice. Your voice response is sent back to OMP as the next prompt, creating a hands-free coding loop.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- [Oh My Pi](https://github.com/neuralmux/oh-my-pi) (`@oh-my-pi/pi-coding-agent`) installed
|
|
10
|
+
- [Superwhisper](https://superwhisper.com) app for macOS
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
### Via `omp plugin` (recommended)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Local path — install directly from a local clone
|
|
18
|
+
omp plugin install /workspaces/superwhisper-omp
|
|
19
|
+
|
|
20
|
+
# Local development — symlink so edits are picked up live
|
|
21
|
+
omp plugin link /workspaces/superwhisper-omp
|
|
22
|
+
|
|
23
|
+
# From npm (once published)
|
|
24
|
+
omp plugin install @neuralmux/omp-superwhisper
|
|
25
|
+
|
|
26
|
+
# From a git repository
|
|
27
|
+
omp plugin install github.com/neuralmux/pi-superwhisper
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
OMP installs the package into `~/.omp/plugins/node_modules/`, discovers
|
|
31
|
+
`omp.extensions` from `package.json`, and wires extension modules into the
|
|
32
|
+
runtime. Restart OMP to activate.
|
|
33
|
+
|
|
34
|
+
### Manual (user-level)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
mkdir -p ~/.omp/agent/extensions
|
|
38
|
+
cp extensions/*.ts ~/.omp/agent/extensions/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Manual (project-level)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
mkdir -p .omp/extensions
|
|
45
|
+
cp extensions/*.ts .omp/extensions/
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## How It Works
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
You speak → OMP works → Extension notifies Superwhisper → You speak back → loop
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
1. **Task completes** → OMP fires `agent_end` with `stopReason: "stop"`
|
|
55
|
+
2. **Extension extracts the response** → reads the last assistant text content
|
|
56
|
+
3. **Extension notifies Superwhisper** → writes message to temp file, opens deeplink
|
|
57
|
+
4. **Superwhisper shows notification** → displays summary with voice recording UI
|
|
58
|
+
5. **You speak your response** → Superwhisper transcribes and writes to response file
|
|
59
|
+
6. **Extension reads response** → polls the response file, sends back to OMP via `pi.sendUserMessage`
|
|
60
|
+
7. **OMP continues** → processes your voice input as the next instruction
|
|
61
|
+
|
|
62
|
+
## Events
|
|
63
|
+
|
|
64
|
+
| OMP Event | Superwhisper Status | Description |
|
|
65
|
+
|-------------------|---------------------|------------------------------|
|
|
66
|
+
| `agent_end` (stop)| `completed` | Task finished |
|
|
67
|
+
|
|
68
|
+
OMP has no built-in permission popups or elicitation system, so only end-of-turn completions are surfaced today.
|
|
69
|
+
|
|
70
|
+
## Using from Devcontainers / Docker
|
|
71
|
+
|
|
72
|
+
When running OMP inside a devcontainer or Docker container, the extension cannot directly access the host's Superwhisper app. You need to run the **bridge daemon** on the macOS host to proxy communication.
|
|
73
|
+
|
|
74
|
+
### 1. Start the bridge daemon on your macOS host
|
|
75
|
+
|
|
76
|
+
**Quick start (foreground):**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
bun run bin/superwhisper-bridge.ts
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Install as a background service (starts on login):**
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
bun run bin/install-bridge-service.ts
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
You'll see:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
✓ Wrote ~/Library/LaunchAgents/com.superwhisper.bridge.plist
|
|
92
|
+
✓ Service loaded: com.superwhisper.bridge.plist
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The daemon is now running and will restart automatically on login.
|
|
96
|
+
|
|
97
|
+
Check it's running:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
launchctl list | grep superwhisper
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
View logs:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
tail -f /tmp/superwhisper-bridge.log
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Uninstall the service:**
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
bun run bin/install-bridge-service.ts --uninstall
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Enable debug logging:**
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bun run bin/install-bridge-service.ts --debug
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 2. Configure your devcontainer
|
|
122
|
+
|
|
123
|
+
In your `.devcontainer/devcontainer.json` or `docker-compose.yml`, set the environment variable inside the container:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"containerEnv": {
|
|
128
|
+
"SUPERWHISPER_BRIDGE_URL": "http://host.docker.internal:19550"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Or in a `docker-compose.yml`:
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
services:
|
|
137
|
+
dev:
|
|
138
|
+
environment:
|
|
139
|
+
- SUPERWHISPER_BRIDGE_URL=http://host.docker.internal:19550
|
|
140
|
+
extra_hosts:
|
|
141
|
+
- "host.docker.internal:host-gateway"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 3. That's it
|
|
145
|
+
|
|
146
|
+
When the extension detects `SUPERWHISPER_BRIDGE_URL`, it automatically switches to bridge mode. All Superwhisper interactions (inbox delivery, message/response file I/O, deeplink wakes) are proxied through the host daemon.
|
|
147
|
+
|
|
148
|
+
> **Note:** You need one bridge daemon per Mac. Multiple devcontainers can share the same daemon.
|
|
149
|
+
|
|
150
|
+
## Controlling Superwhisper During a Session
|
|
151
|
+
|
|
152
|
+
You can ask the agent to enable or disable Superwhisper voice notifications at any time during a session. The extension exposes a `superwhisper_toggle` tool the agent will use automatically when instructed.
|
|
153
|
+
|
|
154
|
+
**Disable Superwhisper for the current session:**
|
|
155
|
+
> "Disable Superwhisper" / "Turn off voice notifications" / "Stop Superwhisper"
|
|
156
|
+
|
|
157
|
+
**Re-enable Superwhisper for the current session:**
|
|
158
|
+
> "Enable Superwhisper" / "Turn voice notifications back on" / "Re-enable Superwhisper"
|
|
159
|
+
|
|
160
|
+
The toggle is session-scoped — it only affects the current OMP session and resets when you start a new one.
|
|
161
|
+
|
|
162
|
+
## Slash Commands
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
/superwhisper on — enable voice notifications
|
|
166
|
+
/superwhisper off — disable voice notifications
|
|
167
|
+
/superwhisper test — send a test notification
|
|
168
|
+
/superwhisper status — show current state
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Environment Variables
|
|
172
|
+
|
|
173
|
+
| Variable | Default | Description |
|
|
174
|
+
|-----------------------------|---------|----------------------------------------------------------------------|
|
|
175
|
+
| `SUPERWHISPER_DEBUG` | unset | Set to `1` to write debug logs to `/tmp/superwhisper-agent/debug.log`|
|
|
176
|
+
| `SUPERWHISPER_SCHEME` | auto | Override deeplink scheme (`superwhisper` vs `superwhisper-debug`) |
|
|
177
|
+
| `SUPERWHISPER_BRIDGE_URL` | unset | Bridge daemon URL for devcontainer support (e.g. `http://host.docker.internal:19550`) |
|
|
178
|
+
|
|
179
|
+
**Bridge daemon env vars** (set on the macOS host, not in the container):
|
|
180
|
+
|
|
181
|
+
| Variable | Default | Description |
|
|
182
|
+
|-----------------------------|---------|----------------------------------------------------------------------|
|
|
183
|
+
| `SUPERWHISPER_BRIDGE_PORT` | `19550` | Port for the bridge daemon to listen on |
|
|
184
|
+
| `SUPERWHISPER_BRIDGE_HOST` | `127.0.0.1` | Bind address for the bridge daemon |
|
|
185
|
+
| `SUPERWHISPER_DEBUG` | unset | Set to `1` for verbose bridge daemon logging to stderr |
|
|
186
|
+
|
|
187
|
+
## Project Structure
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
extensions/
|
|
191
|
+
superwhisper.ts # Extension entry — OMP loads this directly
|
|
192
|
+
host.ts # HostOps abstraction (direct vs bridge mode)
|
|
193
|
+
constants.ts # Constants and shared types
|
|
194
|
+
inbox.ts # Inbox payload writes
|
|
195
|
+
message.ts # AgentMessage helpers (extract text, summary, end-turn)
|
|
196
|
+
poll.ts # Response file polling
|
|
197
|
+
bin/
|
|
198
|
+
superwhisper-bridge.ts # Host bridge daemon for devcontainer support
|
|
199
|
+
install-bridge-service.ts # Launchd service installer
|
|
200
|
+
com.superwhisper.bridge.plist # Launchd plist template
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
3
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4
|
+
<plist version="1.0">
|
|
5
|
+
<dict>
|
|
6
|
+
<key>Label</key>
|
|
7
|
+
<string>com.superwhisper.bridge</string>
|
|
8
|
+
<key>ProgramArguments</key>
|
|
9
|
+
<array>
|
|
10
|
+
<string>__BUN_PATH__</string>
|
|
11
|
+
<string>run</string>
|
|
12
|
+
<string>__SCRIPT_PATH__</string>
|
|
13
|
+
</array>
|
|
14
|
+
<key>RunAtLoad</key>
|
|
15
|
+
<true/>
|
|
16
|
+
<key>KeepAlive</key>
|
|
17
|
+
<true/>
|
|
18
|
+
<key>StandardOutPath</key>
|
|
19
|
+
<string>/tmp/superwhisper-bridge.log</string>
|
|
20
|
+
<key>StandardErrorPath</key>
|
|
21
|
+
<string>/tmp/superwhisper-bridge.err</string>
|
|
22
|
+
<key>EnvironmentVariables</key>
|
|
23
|
+
<dict>
|
|
24
|
+
<key>SUPERWHISPER_DEBUG</key>
|
|
25
|
+
<string>__DEBUG__</string>
|
|
26
|
+
</dict>
|
|
27
|
+
</dict>
|
|
28
|
+
</plist>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* install-bridge-service — installs the superwhisper-bridge launchd service.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun run bin/install-bridge-service.ts [--uninstall] [--debug]
|
|
7
|
+
*
|
|
8
|
+
* Installs ~/Library/LaunchAgents/com.superwhisper.bridge.plist and
|
|
9
|
+
* loads it with launchctl so the bridge daemon starts on login and
|
|
10
|
+
* stays running.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs"
|
|
14
|
+
import { homedir } from "node:os"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Paths
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const LAUNCH_AGENTS_DIR = join(homedir(), "Library", "LaunchAgents")
|
|
22
|
+
const PLIST_NAME = "com.superwhisper.bridge.plist"
|
|
23
|
+
const PLIST_DEST = join(LAUNCH_AGENTS_DIR, PLIST_NAME)
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Args
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const args = process.argv.slice(2)
|
|
30
|
+
const uninstall = args.includes("--uninstall")
|
|
31
|
+
const debug = args.includes("--debug")
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function findBunPath(): string {
|
|
38
|
+
return process.execPath
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findScriptPath(): string {
|
|
42
|
+
return join(import.meta.dir, "superwhisper-bridge.ts")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadService(): void {
|
|
46
|
+
try {
|
|
47
|
+
Bun.spawnSync(["launchctl", "load", PLIST_DEST], { stdio: ["inherit", "inherit", "inherit"] })
|
|
48
|
+
} catch {
|
|
49
|
+
// launchctl load may fail if already loaded; try bootstrap
|
|
50
|
+
try {
|
|
51
|
+
const uid = (process as any).getuid?.() ?? 501
|
|
52
|
+
Bun.spawnSync(["launchctl", "bootstrap", `gui/${uid}`, PLIST_DEST], {
|
|
53
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
54
|
+
})
|
|
55
|
+
} catch {
|
|
56
|
+
console.log("⚠ Could not load the service — it may already be loaded, or you may need to log out and back in.")
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function unloadService(): void {
|
|
62
|
+
try {
|
|
63
|
+
Bun.spawnSync(["launchctl", "unload", PLIST_DEST], { stdio: ["inherit", "inherit", "inherit"] })
|
|
64
|
+
} catch {
|
|
65
|
+
try {
|
|
66
|
+
const uid = (process as any).getuid?.() ?? 501
|
|
67
|
+
Bun.spawnSync(["launchctl", "bootout", `gui/${uid}`, PLIST_DEST], {
|
|
68
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
69
|
+
})
|
|
70
|
+
} catch {
|
|
71
|
+
// fine
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Main
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
if (uninstall) {
|
|
81
|
+
console.log("Uninstalling superwhisper-bridge launchd service...")
|
|
82
|
+
|
|
83
|
+
if (existsSync(PLIST_DEST)) {
|
|
84
|
+
unloadService()
|
|
85
|
+
unlinkSync(PLIST_DEST)
|
|
86
|
+
console.log(`✓ Removed ${PLIST_DEST}`)
|
|
87
|
+
} else {
|
|
88
|
+
console.log("Service plist not found — nothing to uninstall.")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log("Done.")
|
|
92
|
+
process.exit(0)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- Install ---
|
|
96
|
+
|
|
97
|
+
const bunPath = findBunPath()
|
|
98
|
+
const scriptPath = findScriptPath()
|
|
99
|
+
const templatePath = join(import.meta.dir, "com.superwhisper.bridge.plist")
|
|
100
|
+
|
|
101
|
+
if (!existsSync(scriptPath)) {
|
|
102
|
+
console.error(`✗ Bridge script not found at: ${scriptPath}`)
|
|
103
|
+
console.error(" Make sure bin/superwhisper-bridge.ts exists in the package.")
|
|
104
|
+
process.exit(1)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!existsSync(templatePath)) {
|
|
108
|
+
console.error(`✗ Plist template not found at: ${templatePath}`)
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Read template and substitute
|
|
113
|
+
const template = readFileSync(templatePath, "utf8")
|
|
114
|
+
const plist = template
|
|
115
|
+
.replace(/__BUN_PATH__/g, bunPath)
|
|
116
|
+
.replace(/__SCRIPT_PATH__/g, scriptPath)
|
|
117
|
+
.replace(/__DEBUG__/g, debug ? "1" : "")
|
|
118
|
+
|
|
119
|
+
// Write plist
|
|
120
|
+
mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true })
|
|
121
|
+
writeFileSync(PLIST_DEST, plist)
|
|
122
|
+
console.log(`✓ Wrote ${PLIST_DEST}`)
|
|
123
|
+
|
|
124
|
+
// Load service
|
|
125
|
+
console.log("Loading service...")
|
|
126
|
+
loadService()
|
|
127
|
+
console.log(`✓ Service loaded: ${PLIST_NAME}`)
|
|
128
|
+
|
|
129
|
+
// Done
|
|
130
|
+
console.log()
|
|
131
|
+
console.log("Bridge daemon is now running and will start automatically on login.")
|
|
132
|
+
console.log()
|
|
133
|
+
console.log("Check status:")
|
|
134
|
+
console.log(` launchctl list | grep superwhisper`)
|
|
135
|
+
console.log()
|
|
136
|
+
console.log("View logs:")
|
|
137
|
+
console.log(` tail -f /tmp/superwhisper-bridge.log`)
|
|
138
|
+
console.log()
|
|
139
|
+
console.log("Uninstall:")
|
|
140
|
+
console.log(` bun run ${join(import.meta.dir, "install-bridge-service.ts")} --uninstall`)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* superwhisper-bridge — HTTP daemon that runs on the macOS host.
|
|
4
|
+
*
|
|
5
|
+
* It acts as a proxy between OMP extensions running inside devcontainers (or
|
|
6
|
+
* any other isolated environment) and the Superwhisper macOS app. The daemon
|
|
7
|
+
* manages the inbox, message/response files, and deeplink wakes on the host
|
|
8
|
+
* filesystem so the containerized extension doesn't need host access.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* bun run bin/superwhisper-bridge.ts [--port PORT]
|
|
12
|
+
*
|
|
13
|
+
* Environment:
|
|
14
|
+
* SUPERWHISPER_BRIDGE_PORT – port to listen on (default: 19550)
|
|
15
|
+
* SUPERWHISPER_BRIDGE_HOST – bind address (default: 127.0.0.1)
|
|
16
|
+
* SUPERWHISPER_SCHEME – override scheme (default: auto-detect)
|
|
17
|
+
* SUPERWHISPER_DEBUG – enable debug logging
|
|
18
|
+
*
|
|
19
|
+
* API:
|
|
20
|
+
* GET /health → { running, scheme, version }
|
|
21
|
+
* POST /inbox body: InboxPayload (JSON) → { ok: bool }
|
|
22
|
+
* GET /session/:id/message → 200 text/plain | 404
|
|
23
|
+
* PUT /session/:id/message body: text → 204
|
|
24
|
+
* DELETE /session/:id/message → 204
|
|
25
|
+
* GET /session/:id/response?timeout=MS → 200 { kind, text? } | 408
|
|
26
|
+
* DELETE /session/:id/response → 204
|
|
27
|
+
* GET /session/:id/disabled → { disabled: bool }
|
|
28
|
+
* PUT /session/:id/disabled → 204 (set disabled)
|
|
29
|
+
* DELETE /session/:id/disabled → 204 (clear disabled)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"
|
|
33
|
+
import { $ } from "bun"
|
|
34
|
+
|
|
35
|
+
// Re-use the existing extension modules for inbox and polling logic.
|
|
36
|
+
import type { InboxPayload } from "../extensions/inbox"
|
|
37
|
+
import { deliverAgentPayload } from "../extensions/inbox"
|
|
38
|
+
import { waitForResponse } from "../extensions/poll"
|
|
39
|
+
import { MESSAGE_DIR } from "../extensions/constants"
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Config
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const PORT = parseInt(process.env.SUPERWHISPER_BRIDGE_PORT || "19550", 10)
|
|
46
|
+
const HOST = process.env.SUPERWHISPER_BRIDGE_HOST || "127.0.0.1"
|
|
47
|
+
const DEBUG = !!process.env.SUPERWHISPER_DEBUG
|
|
48
|
+
const VERSION = "1.0.0"
|
|
49
|
+
|
|
50
|
+
function debugLog(level: string, msg: string) {
|
|
51
|
+
if (!DEBUG) return
|
|
52
|
+
const ts = new Date().toISOString()
|
|
53
|
+
process.stderr.write(`[${ts}] [${level}] [superwhisper-bridge] ${msg}\n`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Scheme detection
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
let cachedScheme: string | null = process.env.SUPERWHISPER_SCHEME || null
|
|
61
|
+
|
|
62
|
+
async function detectScheme(): Promise<string> {
|
|
63
|
+
if (cachedScheme) return cachedScheme
|
|
64
|
+
try {
|
|
65
|
+
await $`pgrep -f DerivedData.*superwhisper.app`.quiet()
|
|
66
|
+
cachedScheme = "superwhisper-debug"
|
|
67
|
+
} catch {
|
|
68
|
+
cachedScheme = "superwhisper"
|
|
69
|
+
}
|
|
70
|
+
return cachedScheme
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function checkRunning(): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
await $`pgrep -x superwhisper`.quiet()
|
|
76
|
+
return true
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Path helpers
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function messagePath(sessionId: string) {
|
|
87
|
+
return `${MESSAGE_DIR}/${sessionId}-message.txt`
|
|
88
|
+
}
|
|
89
|
+
function responsePath(sessionId: string) {
|
|
90
|
+
return `${MESSAGE_DIR}/${sessionId}-response.txt`
|
|
91
|
+
}
|
|
92
|
+
function disabledPath(sessionId: string) {
|
|
93
|
+
return `${MESSAGE_DIR}/disabled-${sessionId}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Response helpers
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
101
|
+
return new Response(JSON.stringify(data), {
|
|
102
|
+
status,
|
|
103
|
+
headers: { "content-type": "application/json" },
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function textResponse(body: string, status = 200): Response {
|
|
108
|
+
return new Response(body, {
|
|
109
|
+
status,
|
|
110
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function emptyResponse(status = 204): Response {
|
|
115
|
+
return new Response(null, { status })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function corsHeaders(): Record<string, string> {
|
|
119
|
+
return {
|
|
120
|
+
"access-control-allow-origin": "*",
|
|
121
|
+
"access-control-allow-methods": "GET,PUT,POST,DELETE,OPTIONS",
|
|
122
|
+
"access-control-allow-headers": "content-type",
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function extractSessionId(url: URL): string | null {
|
|
127
|
+
// /session/<id>/(message|response|disabled)
|
|
128
|
+
const m = url.pathname.match(/^\/session\/([^/]+)\/(message|response|disabled)/)
|
|
129
|
+
return m ? decodeURIComponent(m[1]) : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Router
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
async function handleRequest(req: Request): Promise<Response> {
|
|
137
|
+
const url = new URL(req.url)
|
|
138
|
+
const method = req.method.toUpperCase()
|
|
139
|
+
|
|
140
|
+
debugLog("info", `${method} ${url.pathname}${url.search}`)
|
|
141
|
+
|
|
142
|
+
if (method === "OPTIONS") {
|
|
143
|
+
return new Response(null, { status: 204, headers: corsHeaders() })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// ---- /health ----
|
|
148
|
+
if (url.pathname === "/health" && method === "GET") {
|
|
149
|
+
const scheme = await detectScheme()
|
|
150
|
+
const running = await checkRunning()
|
|
151
|
+
return jsonResponse({ running, scheme, version: VERSION })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- /inbox ----
|
|
155
|
+
if (url.pathname === "/inbox" && method === "POST") {
|
|
156
|
+
let payload: InboxPayload
|
|
157
|
+
try {
|
|
158
|
+
payload = await req.json()
|
|
159
|
+
} catch {
|
|
160
|
+
return jsonResponse({ error: "invalid JSON" }, 400)
|
|
161
|
+
}
|
|
162
|
+
const scheme = await detectScheme()
|
|
163
|
+
// Override hookPid with the bridge's own PID so Superwhisper can
|
|
164
|
+
// validate it against running host processes. Container PIDs would
|
|
165
|
+
// otherwise be silently rejected.
|
|
166
|
+
payload.hookPid = process.pid
|
|
167
|
+
const ok = await deliverAgentPayload(payload, scheme)
|
|
168
|
+
return jsonResponse({ ok }, ok ? 200 : 500)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- /session/:id/message ----
|
|
172
|
+
const sessionId = extractSessionId(url)
|
|
173
|
+
if (sessionId && url.pathname.endsWith("/message")) {
|
|
174
|
+
mkdirSync(MESSAGE_DIR, { recursive: true })
|
|
175
|
+
|
|
176
|
+
if (method === "GET") {
|
|
177
|
+
try {
|
|
178
|
+
const content = readFileSync(messagePath(sessionId), "utf8")
|
|
179
|
+
return textResponse(content)
|
|
180
|
+
} catch {
|
|
181
|
+
return jsonResponse({ error: "not found" }, 404)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (method === "PUT") {
|
|
186
|
+
const body = await req.text()
|
|
187
|
+
writeFileSync(messagePath(sessionId), body)
|
|
188
|
+
return emptyResponse()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (method === "DELETE") {
|
|
192
|
+
try { unlinkSync(messagePath(sessionId)) } catch {}
|
|
193
|
+
return emptyResponse()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- /session/:id/response ----
|
|
198
|
+
if (sessionId && url.pathname.endsWith("/response")) {
|
|
199
|
+
mkdirSync(MESSAGE_DIR, { recursive: true })
|
|
200
|
+
|
|
201
|
+
if (method === "GET") {
|
|
202
|
+
const timeoutParam = url.searchParams.get("timeout")
|
|
203
|
+
const timeoutMs = timeoutParam ? parseInt(timeoutParam, 10) : 1_800_000
|
|
204
|
+
|
|
205
|
+
const rp = responsePath(sessionId)
|
|
206
|
+
|
|
207
|
+
// Long-poll: waitForResponse handles fs.watch + interval internally.
|
|
208
|
+
const result = await waitForResponse(rp, {
|
|
209
|
+
timeoutMs,
|
|
210
|
+
signal: undefined,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return jsonResponse(result)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (method === "DELETE") {
|
|
217
|
+
try { unlinkSync(responsePath(sessionId)) } catch {}
|
|
218
|
+
return emptyResponse()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- /session/:id/disabled ----
|
|
223
|
+
if (sessionId && url.pathname.endsWith("/disabled")) {
|
|
224
|
+
mkdirSync(MESSAGE_DIR, { recursive: true })
|
|
225
|
+
|
|
226
|
+
if (method === "GET") {
|
|
227
|
+
return jsonResponse({ disabled: existsSync(disabledPath(sessionId)) })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (method === "PUT") {
|
|
231
|
+
writeFileSync(disabledPath(sessionId), "")
|
|
232
|
+
return emptyResponse()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (method === "DELETE") {
|
|
236
|
+
try { unlinkSync(disabledPath(sessionId)) } catch {}
|
|
237
|
+
return emptyResponse()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---- 404 ----
|
|
242
|
+
return jsonResponse({ error: "not found" }, 404)
|
|
243
|
+
} catch (err) {
|
|
244
|
+
debugLog("error", `Request failed: ${err}`)
|
|
245
|
+
return jsonResponse({ error: "internal server error" }, 500)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Start
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
mkdirSync(MESSAGE_DIR, { recursive: true })
|
|
254
|
+
|
|
255
|
+
const server = Bun.serve({
|
|
256
|
+
port: PORT,
|
|
257
|
+
hostname: HOST,
|
|
258
|
+
fetch(req) {
|
|
259
|
+
const res = handleRequest(req)
|
|
260
|
+
// Attach CORS headers to every response
|
|
261
|
+
return res.then((r) => {
|
|
262
|
+
for (const [k, v] of Object.entries(corsHeaders())) {
|
|
263
|
+
r.headers.set(k, v)
|
|
264
|
+
}
|
|
265
|
+
return r
|
|
266
|
+
})
|
|
267
|
+
},
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
console.log(`superwhisper-bridge v${VERSION} listening on http://${HOST}:${PORT}`)
|
|
271
|
+
console.log(`Health check: http://${HOST}:${PORT}/health`)
|
|
272
|
+
console.log(`Message dir: ${MESSAGE_DIR}`)
|
|
273
|
+
if (process.env.SUPERWHISPER_BRIDGE_URL) {
|
|
274
|
+
console.log(`⚠ SUPERWHISPER_BRIDGE_URL is set — this daemon should NOT be run with that variable.`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Graceful shutdown
|
|
278
|
+
process.on("SIGINT", () => {
|
|
279
|
+
debugLog("info", "Shutting down...")
|
|
280
|
+
server.stop()
|
|
281
|
+
process.exit(0)
|
|
282
|
+
})
|
|
283
|
+
process.on("SIGTERM", () => {
|
|
284
|
+
debugLog("info", "Shutting down...")
|
|
285
|
+
server.stop()
|
|
286
|
+
process.exit(0)
|
|
287
|
+
})
|