@pompeii-labs/bridge 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/README.md +97 -0
- package/dist/src/claude-code.d.ts +3 -0
- package/dist/src/claude-code.js +69 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +45 -0
- package/dist/src/codex.d.ts +3 -0
- package/dist/src/codex.js +73 -0
- package/dist/src/commands/logs.d.ts +1 -0
- package/dist/src/commands/logs.js +16 -0
- package/dist/src/commands/setup.d.ts +1 -0
- package/dist/src/commands/setup.js +107 -0
- package/dist/src/commands/start.d.ts +1 -0
- package/dist/src/commands/start.js +63 -0
- package/dist/src/commands/status.d.ts +1 -0
- package/dist/src/commands/status.js +23 -0
- package/dist/src/commands/stop.d.ts +1 -0
- package/dist/src/commands/stop.js +11 -0
- package/dist/src/config.d.ts +27 -0
- package/dist/src/config.js +98 -0
- package/dist/src/daemon.d.ts +17 -0
- package/dist/src/daemon.js +83 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +128 -0
- package/dist/src/paths.d.ts +6 -0
- package/dist/src/paths.js +8 -0
- package/dist/src/sse.d.ts +6 -0
- package/dist/src/sse.js +23 -0
- package/dist/src/tunnel.d.ts +1 -0
- package/dist/src/tunnel.js +62 -0
- package/dist/src/verify.d.ts +1 -0
- package/dist/src/verify.js +8 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @pompeii-labs/bridge
|
|
2
|
+
|
|
3
|
+
CLI for running a [Pompeii](https://pompeii.ai) agent bridge on your local machine. The bridge connects your local dev environment to Pompeii so AI agents can work on your codebase when @mentioned in conversations.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm i -g @pompeii-labs/bridge
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) for the tunnel.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
brew install cloudflared
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
pompeii bridge start
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
On first run this launches interactive setup, which asks for:
|
|
24
|
+
|
|
25
|
+
1. **Pompeii API key** - from your workspace settings at pompeii.ai
|
|
26
|
+
2. **Webhook secret** - from the agent config page
|
|
27
|
+
3. **Repository paths** - local paths the agent can access
|
|
28
|
+
4. **Backend** - Claude Code or OpenAI Codex
|
|
29
|
+
|
|
30
|
+
Config is saved to `~/.pompeii/`. After setup, the bridge starts as a background daemon with a Cloudflare tunnel and automatically registers the webhook URL with Pompeii.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pompeii bridge setup # interactive config
|
|
36
|
+
pompeii bridge start # start daemon + tunnel (runs setup if no config)
|
|
37
|
+
pompeii bridge stop # stop the daemon
|
|
38
|
+
pompeii bridge status # show PID, port, tunnel URL, active requests
|
|
39
|
+
pompeii bridge logs # tail ~/.pompeii/bridge.log
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Options
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pompeii bridge start --port 3001 # custom port (default: 3001)
|
|
46
|
+
pompeii bridge logs --lines 100 # number of lines to show (default: 50)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Config
|
|
50
|
+
|
|
51
|
+
All config lives in `~/.pompeii/`:
|
|
52
|
+
|
|
53
|
+
| File | Contents |
|
|
54
|
+
|------|----------|
|
|
55
|
+
| `config.json` | repos, backend, port, tunnel settings |
|
|
56
|
+
| `.env` | `POMPEII_API_KEY`, `WEBHOOK_SECRET` |
|
|
57
|
+
| `daemon.json` | runtime state (PID, tunnel URL, start time) |
|
|
58
|
+
| `bridge.log` | daemon stdout/stderr |
|
|
59
|
+
|
|
60
|
+
Environment variables always take precedence over file config:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
WEBHOOK_SECRET, POMPEII_API_KEY, REPOS, BACKEND, PORT,
|
|
64
|
+
MAX_TURNS, MAX_CONCURRENT, ALLOWED_TOOLS, POMPEII_API_URL
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## How it works
|
|
68
|
+
|
|
69
|
+
1. `pompeii bridge start` spawns the bridge server as a detached background process
|
|
70
|
+
2. The server starts a Cloudflare quick tunnel to get a public URL
|
|
71
|
+
3. The tunnel URL is registered with the Pompeii API as your agent's webhook
|
|
72
|
+
4. When someone @mentions your agent in Pompeii, the platform sends a webhook to the tunnel
|
|
73
|
+
5. The bridge runs the request through Claude Code (or Codex) against your local repos
|
|
74
|
+
6. Responses stream back to Pompeii via SSE
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
cd bridge
|
|
80
|
+
bun install
|
|
81
|
+
bun run dev
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For local dev without the daemon, set env vars directly:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
WEBHOOK_SECRET=xxx POMPEII_API_KEY=xxx REPOS=/path/to/repo TUNNEL=1 bun run dev
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Publishing
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
cd bridge
|
|
94
|
+
npm publish --access public
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The `prepublishOnly` script runs `tsc` automatically before publish.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import * as sse from "./sse.js";
|
|
3
|
+
export async function runClaudeCode(prompt, res, abort, config) {
|
|
4
|
+
const toolNames = new Map();
|
|
5
|
+
for await (const message of query({
|
|
6
|
+
prompt,
|
|
7
|
+
options: {
|
|
8
|
+
cwd: config.repos[0],
|
|
9
|
+
additionalDirectories: config.repos.slice(1),
|
|
10
|
+
allowedTools: config.allowedTools,
|
|
11
|
+
maxTurns: config.maxTurns,
|
|
12
|
+
abortController: abort,
|
|
13
|
+
permissionMode: "bypassPermissions",
|
|
14
|
+
allowDangerouslySkipPermissions: true,
|
|
15
|
+
includePartialMessages: true,
|
|
16
|
+
},
|
|
17
|
+
})) {
|
|
18
|
+
if (abort.signal.aborted)
|
|
19
|
+
break;
|
|
20
|
+
if (message.type === "stream_event") {
|
|
21
|
+
const event = message.event;
|
|
22
|
+
if (event.type === "content_block_start" &&
|
|
23
|
+
event.content_block.type === "tool_use") {
|
|
24
|
+
toolNames.set(event.content_block.id, event.content_block.name);
|
|
25
|
+
sse.activity(res, event.content_block.name, "running");
|
|
26
|
+
}
|
|
27
|
+
else if (event.type === "content_block_delta" &&
|
|
28
|
+
event.delta.type === "text_delta") {
|
|
29
|
+
sse.chunk(res, event.delta.text);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else if (message.type === "user") {
|
|
33
|
+
const blocks = message.message.content;
|
|
34
|
+
if (Array.isArray(blocks)) {
|
|
35
|
+
for (const block of blocks) {
|
|
36
|
+
if (block.type === "tool_result") {
|
|
37
|
+
const toolName = toolNames.get(block.tool_use_id) ?? "unknown";
|
|
38
|
+
const isError = block.is_error === true;
|
|
39
|
+
const resultText = typeof block.content === "string"
|
|
40
|
+
? block.content
|
|
41
|
+
: Array.isArray(block.content)
|
|
42
|
+
? block.content
|
|
43
|
+
.filter((c) => c.type === "text" && c.text)
|
|
44
|
+
.map((c) => c.text)
|
|
45
|
+
.join("\n")
|
|
46
|
+
: "";
|
|
47
|
+
const preview = resultText.length > 500
|
|
48
|
+
? resultText.slice(0, 500) + "..."
|
|
49
|
+
: resultText;
|
|
50
|
+
sse.activity(res, toolName, isError ? "error" : "success", preview);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (message.type === "result") {
|
|
56
|
+
if (message.subtype === "success") {
|
|
57
|
+
sse.done(res);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const errors = "errors" in message ? message.errors.join("; ") : message.subtype;
|
|
61
|
+
sse.error(res, errors);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!res.writableEnded && !abort.signal.aborted) {
|
|
67
|
+
sse.done(res);
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { runSetup } from "./commands/setup.js";
|
|
4
|
+
import { startCommand } from "./commands/start.js";
|
|
5
|
+
import { stopCommand } from "./commands/stop.js";
|
|
6
|
+
import { statusCommand } from "./commands/status.js";
|
|
7
|
+
import { logsCommand } from "./commands/logs.js";
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program.name("pompeii").description("Pompeii CLI").version("0.1.0");
|
|
10
|
+
const bridge = program
|
|
11
|
+
.command("bridge")
|
|
12
|
+
.description("Manage the Pompeii bridge daemon");
|
|
13
|
+
bridge
|
|
14
|
+
.command("setup")
|
|
15
|
+
.description("Interactive configuration for the bridge")
|
|
16
|
+
.action(async () => {
|
|
17
|
+
await runSetup();
|
|
18
|
+
});
|
|
19
|
+
bridge
|
|
20
|
+
.command("start")
|
|
21
|
+
.description("Start the bridge daemon with tunnel")
|
|
22
|
+
.option("-p, --port <port>", "Port to listen on", "3001")
|
|
23
|
+
.action(async (opts) => {
|
|
24
|
+
await startCommand(parseInt(opts.port, 10));
|
|
25
|
+
});
|
|
26
|
+
bridge
|
|
27
|
+
.command("stop")
|
|
28
|
+
.description("Stop the bridge daemon")
|
|
29
|
+
.action(() => {
|
|
30
|
+
stopCommand();
|
|
31
|
+
});
|
|
32
|
+
bridge
|
|
33
|
+
.command("status")
|
|
34
|
+
.description("Show bridge daemon status")
|
|
35
|
+
.action(async () => {
|
|
36
|
+
await statusCommand();
|
|
37
|
+
});
|
|
38
|
+
bridge
|
|
39
|
+
.command("logs")
|
|
40
|
+
.description("Tail bridge daemon logs")
|
|
41
|
+
.option("-n, --lines <lines>", "Number of lines to show", "50")
|
|
42
|
+
.action((opts) => {
|
|
43
|
+
logsCommand(parseInt(opts.lines, 10));
|
|
44
|
+
});
|
|
45
|
+
program.parse();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Codex } from "@openai/codex-sdk";
|
|
2
|
+
import * as sse from "./sse.js";
|
|
3
|
+
export async function runCodex(prompt, res, abort, config) {
|
|
4
|
+
const codex = new Codex();
|
|
5
|
+
const thread = codex.startThread({
|
|
6
|
+
workingDirectory: config.repos[0],
|
|
7
|
+
additionalDirectories: config.repos.slice(1),
|
|
8
|
+
skipGitRepoCheck: true,
|
|
9
|
+
});
|
|
10
|
+
const { events } = await thread.runStreamed(prompt, { signal: abort.signal });
|
|
11
|
+
for await (const event of events) {
|
|
12
|
+
if (abort.signal.aborted)
|
|
13
|
+
break;
|
|
14
|
+
switch (event.type) {
|
|
15
|
+
case "item.started": {
|
|
16
|
+
const item = event.item;
|
|
17
|
+
if (item.type === "command_execution") {
|
|
18
|
+
sse.activity(res, "Bash", "running");
|
|
19
|
+
}
|
|
20
|
+
else if (item.type === "file_change") {
|
|
21
|
+
sse.activity(res, "Edit", "running");
|
|
22
|
+
}
|
|
23
|
+
else if (item.type === "mcp_tool_call") {
|
|
24
|
+
sse.activity(res, item.tool, "running");
|
|
25
|
+
}
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
case "item.updated": {
|
|
29
|
+
const item = event.item;
|
|
30
|
+
if (item.type === "agent_message") {
|
|
31
|
+
if (item.text)
|
|
32
|
+
sse.chunk(res, item.text);
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case "item.completed": {
|
|
37
|
+
const item = event.item;
|
|
38
|
+
if (item.type === "command_execution") {
|
|
39
|
+
const ok = item.exit_code === 0;
|
|
40
|
+
const output = item.aggregated_output;
|
|
41
|
+
const preview = output.length > 500 ? output.slice(0, 500) + "..." : output;
|
|
42
|
+
sse.activity(res, "Bash", ok ? "success" : "error", preview);
|
|
43
|
+
}
|
|
44
|
+
else if (item.type === "file_change") {
|
|
45
|
+
const ok = item.status === "completed";
|
|
46
|
+
sse.activity(res, "Edit", ok ? "success" : "error");
|
|
47
|
+
}
|
|
48
|
+
else if (item.type === "agent_message") {
|
|
49
|
+
if (item.text)
|
|
50
|
+
sse.chunk(res, item.text);
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case "turn.completed": {
|
|
55
|
+
sse.done(res);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
case "turn.failed": {
|
|
59
|
+
const msg = event.error?.message ?? "Turn failed";
|
|
60
|
+
sse.error(res, msg);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
case "error": {
|
|
64
|
+
const msg = event.message ?? "Thread error";
|
|
65
|
+
sse.error(res, msg);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!res.writableEnded && !abort.signal.aborted) {
|
|
71
|
+
sse.done(res);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function logsCommand(lines: number): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { LOG_FILE } from "../paths.js";
|
|
4
|
+
export function logsCommand(lines) {
|
|
5
|
+
if (!existsSync(LOG_FILE)) {
|
|
6
|
+
console.log("No log file found. Start the bridge first.");
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const tail = spawn("tail", ["-n", String(lines), "-f", LOG_FILE], {
|
|
10
|
+
stdio: "inherit",
|
|
11
|
+
});
|
|
12
|
+
process.on("SIGINT", () => {
|
|
13
|
+
tail.kill();
|
|
14
|
+
process.exit(0);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runSetup(): Promise<boolean>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { POMPEII_DIR, CONFIG_FILE, ENV_FILE } from "../paths.js";
|
|
5
|
+
function loadExisting() {
|
|
6
|
+
let config = {};
|
|
7
|
+
const env = {};
|
|
8
|
+
if (existsSync(CONFIG_FILE)) {
|
|
9
|
+
try {
|
|
10
|
+
config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
11
|
+
}
|
|
12
|
+
catch { }
|
|
13
|
+
}
|
|
14
|
+
if (existsSync(ENV_FILE)) {
|
|
15
|
+
try {
|
|
16
|
+
const content = readFileSync(ENV_FILE, "utf-8");
|
|
17
|
+
for (const line of content.split("\n")) {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
20
|
+
continue;
|
|
21
|
+
const eqIdx = trimmed.indexOf("=");
|
|
22
|
+
if (eqIdx === -1)
|
|
23
|
+
continue;
|
|
24
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
25
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
26
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
27
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
28
|
+
value = value.slice(1, -1);
|
|
29
|
+
}
|
|
30
|
+
env[key] = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
}
|
|
35
|
+
return { config, env };
|
|
36
|
+
}
|
|
37
|
+
export async function runSetup() {
|
|
38
|
+
p.intro("Pompeii Bridge Setup");
|
|
39
|
+
const { config: existing } = loadExisting();
|
|
40
|
+
const apiKey = await p.password({
|
|
41
|
+
message: "Pompeii API key",
|
|
42
|
+
validate: (v) => {
|
|
43
|
+
if (!v)
|
|
44
|
+
return "API key is required";
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
if (p.isCancel(apiKey)) {
|
|
48
|
+
p.cancel("Setup cancelled.");
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const webhookSecret = await p.password({
|
|
52
|
+
message: "Webhook secret",
|
|
53
|
+
validate: (v) => {
|
|
54
|
+
if (!v)
|
|
55
|
+
return "Webhook secret is required";
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (p.isCancel(webhookSecret)) {
|
|
59
|
+
p.cancel("Setup cancelled.");
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const reposInput = await p.text({
|
|
63
|
+
message: "Repository paths (comma-separated)",
|
|
64
|
+
placeholder: "/path/to/repo1, /path/to/repo2",
|
|
65
|
+
initialValue: existing.repos?.join(", ") ?? "",
|
|
66
|
+
validate: (v) => {
|
|
67
|
+
if (!v.trim())
|
|
68
|
+
return "At least one repo path is required";
|
|
69
|
+
const paths = v.split(",").map((s) => s.trim());
|
|
70
|
+
for (const p of paths) {
|
|
71
|
+
const resolved = resolve(p);
|
|
72
|
+
if (!existsSync(resolved))
|
|
73
|
+
return `Path not found: ${resolved}`;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
if (p.isCancel(reposInput)) {
|
|
78
|
+
p.cancel("Setup cancelled.");
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const backend = await p.select({
|
|
82
|
+
message: "Agent backend",
|
|
83
|
+
initialValue: existing.backend ?? "claude-code",
|
|
84
|
+
options: [
|
|
85
|
+
{ value: "claude-code", label: "Claude Code" },
|
|
86
|
+
{ value: "codex", label: "OpenAI Codex" },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
if (p.isCancel(backend)) {
|
|
90
|
+
p.cancel("Setup cancelled.");
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const repos = reposInput.split(",").map((s) => resolve(s.trim()));
|
|
94
|
+
const config = {
|
|
95
|
+
...existing,
|
|
96
|
+
repos,
|
|
97
|
+
backend: backend,
|
|
98
|
+
tunnel: true,
|
|
99
|
+
};
|
|
100
|
+
mkdirSync(POMPEII_DIR, { recursive: true });
|
|
101
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
|
|
102
|
+
writeFileSync(ENV_FILE, `POMPEII_API_KEY=${apiKey}\nWEBHOOK_SECRET=${webhookSecret}\n`, {
|
|
103
|
+
mode: 0o600,
|
|
104
|
+
});
|
|
105
|
+
p.outro("Config saved to ~/.pompeii/");
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startCommand(port: number): Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { hasConfig } from "../config.js";
|
|
3
|
+
import { isDaemonRunning, spawnDaemon, healthCheck, readDaemonState, } from "../daemon.js";
|
|
4
|
+
import { runSetup } from "./setup.js";
|
|
5
|
+
import { LOG_FILE } from "../paths.js";
|
|
6
|
+
const HEALTH_POLL_MS = 300;
|
|
7
|
+
const HEALTH_TIMEOUT_MS = 15000;
|
|
8
|
+
async function waitForHealth(port) {
|
|
9
|
+
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
|
10
|
+
while (Date.now() < deadline) {
|
|
11
|
+
const { ok } = await healthCheck(port);
|
|
12
|
+
if (ok)
|
|
13
|
+
return true;
|
|
14
|
+
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
async function waitForTunnel() {
|
|
19
|
+
const deadline = Date.now() + 20000;
|
|
20
|
+
while (Date.now() < deadline) {
|
|
21
|
+
const state = readDaemonState();
|
|
22
|
+
if (state?.tunnelUrl)
|
|
23
|
+
return state.tunnelUrl;
|
|
24
|
+
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export async function startCommand(port) {
|
|
29
|
+
const { running, state } = isDaemonRunning();
|
|
30
|
+
if (running && state) {
|
|
31
|
+
console.log(`Bridge already running (PID ${state.pid}, port ${state.port})`);
|
|
32
|
+
if (state.tunnelUrl) {
|
|
33
|
+
console.log(`Tunnel: ${state.tunnelUrl}`);
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!hasConfig()) {
|
|
38
|
+
console.log("No config found. Running setup...\n");
|
|
39
|
+
const ok = await runSetup();
|
|
40
|
+
if (!ok)
|
|
41
|
+
return;
|
|
42
|
+
console.log();
|
|
43
|
+
}
|
|
44
|
+
const spinner = p.spinner();
|
|
45
|
+
spinner.start("Starting bridge daemon...");
|
|
46
|
+
const daemon = spawnDaemon(port);
|
|
47
|
+
const healthy = await waitForHealth(daemon.port);
|
|
48
|
+
if (!healthy) {
|
|
49
|
+
spinner.stop("Failed to start bridge daemon.");
|
|
50
|
+
console.log(`Check logs: ${LOG_FILE}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
spinner.message("Waiting for tunnel...");
|
|
54
|
+
const tunnelUrl = await waitForTunnel();
|
|
55
|
+
if (tunnelUrl) {
|
|
56
|
+
spinner.stop(`Bridge running (PID ${daemon.pid})`);
|
|
57
|
+
console.log(`Tunnel: ${tunnelUrl}`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
spinner.stop(`Bridge running (PID ${daemon.pid})`);
|
|
61
|
+
console.log("Tunnel URL not yet available. Check: pompeii bridge status");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function statusCommand(): Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isDaemonRunning, healthCheck } from "../daemon.js";
|
|
2
|
+
export async function statusCommand() {
|
|
3
|
+
const { running, state } = isDaemonRunning();
|
|
4
|
+
if (!running) {
|
|
5
|
+
console.log("Bridge: stopped");
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
console.log("Bridge: running");
|
|
9
|
+
console.log(` PID: ${state.pid}`);
|
|
10
|
+
console.log(` Port: ${state.port}`);
|
|
11
|
+
console.log(` Started: ${state.startedAt}`);
|
|
12
|
+
if (state.tunnelUrl) {
|
|
13
|
+
console.log(` Tunnel: ${state.tunnelUrl}`);
|
|
14
|
+
}
|
|
15
|
+
const { ok, data } = await healthCheck(state.port);
|
|
16
|
+
if (ok && data) {
|
|
17
|
+
console.log(` Backend: ${data.backend}`);
|
|
18
|
+
console.log(` Active: ${data.active}`);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
console.log(" Health: unreachable");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stopCommand(): void;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { isDaemonRunning, stopDaemon } from "../daemon.js";
|
|
2
|
+
export function stopCommand() {
|
|
3
|
+
const { running, state } = isDaemonRunning();
|
|
4
|
+
if (!running) {
|
|
5
|
+
console.log("Bridge is not running.");
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
console.log(`Stopping bridge (PID ${state.pid})...`);
|
|
9
|
+
stopDaemon();
|
|
10
|
+
console.log("Bridge stopped.");
|
|
11
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type Backend = "claude-code" | "codex";
|
|
2
|
+
export interface Config {
|
|
3
|
+
webhookSecret: string;
|
|
4
|
+
repos: string[];
|
|
5
|
+
backend: Backend;
|
|
6
|
+
port: number;
|
|
7
|
+
maxTurns: number;
|
|
8
|
+
maxConcurrent: number;
|
|
9
|
+
allowedTools: string[];
|
|
10
|
+
heartbeatIntervalMs: number;
|
|
11
|
+
pompeiiApiKey: string | null;
|
|
12
|
+
pompeiiApiUrl: string;
|
|
13
|
+
tunnel: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface FileConfig {
|
|
16
|
+
repos?: string[];
|
|
17
|
+
backend?: Backend;
|
|
18
|
+
port?: number;
|
|
19
|
+
maxTurns?: number;
|
|
20
|
+
maxConcurrent?: number;
|
|
21
|
+
allowedTools?: string[];
|
|
22
|
+
heartbeatIntervalMs?: number;
|
|
23
|
+
pompeiiApiUrl?: string;
|
|
24
|
+
tunnel?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function hasConfig(): boolean;
|
|
27
|
+
export declare function loadConfig(): Config;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { CONFIG_FILE, ENV_FILE } from "./paths.js";
|
|
3
|
+
function loadEnvFile() {
|
|
4
|
+
if (!existsSync(ENV_FILE))
|
|
5
|
+
return;
|
|
6
|
+
const content = readFileSync(ENV_FILE, "utf-8");
|
|
7
|
+
for (const line of content.split("\n")) {
|
|
8
|
+
const trimmed = line.trim();
|
|
9
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
10
|
+
continue;
|
|
11
|
+
const eqIdx = trimmed.indexOf("=");
|
|
12
|
+
if (eqIdx === -1)
|
|
13
|
+
continue;
|
|
14
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
15
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
16
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
17
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
18
|
+
value = value.slice(1, -1);
|
|
19
|
+
}
|
|
20
|
+
if (!process.env[key]) {
|
|
21
|
+
process.env[key] = value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function loadFileConfig() {
|
|
26
|
+
if (!existsSync(CONFIG_FILE))
|
|
27
|
+
return {};
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function hasConfig() {
|
|
36
|
+
return existsSync(CONFIG_FILE) && existsSync(ENV_FILE);
|
|
37
|
+
}
|
|
38
|
+
export function loadConfig() {
|
|
39
|
+
const isDaemon = process.env.POMPEII_DAEMON === "1";
|
|
40
|
+
if (isDaemon) {
|
|
41
|
+
loadEnvFile();
|
|
42
|
+
}
|
|
43
|
+
const file = isDaemon ? loadFileConfig() : {};
|
|
44
|
+
const webhookSecret = process.env.WEBHOOK_SECRET ?? "";
|
|
45
|
+
if (!webhookSecret) {
|
|
46
|
+
console.error("Missing required: WEBHOOK_SECRET");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
const reposRaw = process.env.REPOS ?? (file.repos ? file.repos.join(",") : "");
|
|
50
|
+
if (!reposRaw) {
|
|
51
|
+
console.error("Missing required: REPOS");
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
const repos = reposRaw.split(",").map((p) => p.trim());
|
|
55
|
+
const backend = (process.env.BACKEND ??
|
|
56
|
+
file.backend ??
|
|
57
|
+
"claude-code");
|
|
58
|
+
if (backend !== "claude-code" && backend !== "codex") {
|
|
59
|
+
console.error(`Invalid BACKEND: ${backend}. Must be "claude-code" or "codex"`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const tunnel = process.env.TUNNEL === "1" ||
|
|
63
|
+
process.env.TUNNEL === "true" ||
|
|
64
|
+
(file.tunnel ?? isDaemon);
|
|
65
|
+
const pompeiiApiKey = process.env.POMPEII_API_KEY ?? null;
|
|
66
|
+
if (tunnel && !pompeiiApiKey) {
|
|
67
|
+
console.error("POMPEII_API_KEY is required when tunnel is enabled");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
webhookSecret,
|
|
72
|
+
repos,
|
|
73
|
+
backend,
|
|
74
|
+
port: parseInt(process.env.PORT ?? String(file.port ?? 3001), 10),
|
|
75
|
+
maxTurns: parseInt(process.env.MAX_TURNS ?? String(file.maxTurns ?? 25), 10),
|
|
76
|
+
maxConcurrent: parseInt(process.env.MAX_CONCURRENT ?? String(file.maxConcurrent ?? 3), 10),
|
|
77
|
+
allowedTools: process.env.ALLOWED_TOOLS
|
|
78
|
+
? process.env.ALLOWED_TOOLS.split(",")
|
|
79
|
+
: (file.allowedTools ?? [
|
|
80
|
+
"Read",
|
|
81
|
+
"Edit",
|
|
82
|
+
"Bash",
|
|
83
|
+
"Glob",
|
|
84
|
+
"Grep",
|
|
85
|
+
"Write",
|
|
86
|
+
"Task",
|
|
87
|
+
"WebSearch",
|
|
88
|
+
"WebFetch",
|
|
89
|
+
]),
|
|
90
|
+
heartbeatIntervalMs: parseInt(process.env.HEARTBEAT_INTERVAL_MS ??
|
|
91
|
+
String(file.heartbeatIntervalMs ?? 15000), 10),
|
|
92
|
+
pompeiiApiKey,
|
|
93
|
+
pompeiiApiUrl: process.env.POMPEII_API_URL ??
|
|
94
|
+
file.pompeiiApiUrl ??
|
|
95
|
+
"https://api.pompeii.ai",
|
|
96
|
+
tunnel,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface DaemonState {
|
|
2
|
+
pid: number;
|
|
3
|
+
port: number;
|
|
4
|
+
startedAt: string;
|
|
5
|
+
tunnelUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function readDaemonState(): DaemonState | null;
|
|
8
|
+
export declare function isDaemonRunning(): {
|
|
9
|
+
running: boolean;
|
|
10
|
+
state: DaemonState | null;
|
|
11
|
+
};
|
|
12
|
+
export declare function spawnDaemon(port: number): DaemonState;
|
|
13
|
+
export declare function stopDaemon(): boolean;
|
|
14
|
+
export declare function healthCheck(port: number): Promise<{
|
|
15
|
+
ok: boolean;
|
|
16
|
+
data?: any;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, createWriteStream, } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { POMPEII_DIR, DAEMON_FILE, LOG_FILE } from "./paths.js";
|
|
5
|
+
export function readDaemonState() {
|
|
6
|
+
if (!existsSync(DAEMON_FILE))
|
|
7
|
+
return null;
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(DAEMON_FILE, "utf-8"));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function isProcessAlive(pid) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function isDaemonRunning() {
|
|
25
|
+
const state = readDaemonState();
|
|
26
|
+
if (!state)
|
|
27
|
+
return { running: false, state: null };
|
|
28
|
+
if (!isProcessAlive(state.pid))
|
|
29
|
+
return { running: false, state: null };
|
|
30
|
+
return { running: true, state };
|
|
31
|
+
}
|
|
32
|
+
export function spawnDaemon(port) {
|
|
33
|
+
mkdirSync(POMPEII_DIR, { recursive: true });
|
|
34
|
+
const entrypoint = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
35
|
+
const logStream = createWriteStream(LOG_FILE, { flags: "a" });
|
|
36
|
+
const child = spawn(process.execPath, [entrypoint], {
|
|
37
|
+
detached: true,
|
|
38
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
POMPEII_DAEMON: "1",
|
|
42
|
+
PORT: String(port),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
child.stdout?.pipe(logStream);
|
|
46
|
+
child.stderr?.pipe(logStream);
|
|
47
|
+
child.unref();
|
|
48
|
+
const state = {
|
|
49
|
+
pid: child.pid,
|
|
50
|
+
port,
|
|
51
|
+
startedAt: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
writeFileSync(DAEMON_FILE, JSON.stringify(state, null, 2));
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
export function stopDaemon() {
|
|
57
|
+
const state = readDaemonState();
|
|
58
|
+
if (!state)
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
process.kill(state.pid, "SIGTERM");
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(DAEMON_FILE);
|
|
66
|
+
}
|
|
67
|
+
catch { }
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
export async function healthCheck(port) {
|
|
71
|
+
try {
|
|
72
|
+
const host = "localhost";
|
|
73
|
+
const res = await fetch(`http://${host}:${port}/health`);
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
const data = await res.json();
|
|
76
|
+
return { ok: true, data };
|
|
77
|
+
}
|
|
78
|
+
return { ok: false };
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { ok: false };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { verifySignature } from "./verify.js";
|
|
5
|
+
import * as sse from "./sse.js";
|
|
6
|
+
import { runClaudeCode } from "./claude-code.js";
|
|
7
|
+
import { runCodex } from "./codex.js";
|
|
8
|
+
import { startTunnel } from "./tunnel.js";
|
|
9
|
+
import { DAEMON_FILE } from "./paths.js";
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
let activeRequests = 0;
|
|
12
|
+
function buildPrompt(payload, repos) {
|
|
13
|
+
const parts = [];
|
|
14
|
+
parts.push("You have access to the following repositories:");
|
|
15
|
+
for (const repo of repos) {
|
|
16
|
+
parts.push(` - ${repo}`);
|
|
17
|
+
}
|
|
18
|
+
parts.push("Determine which repository is relevant to the user's request and work in that directory.", "");
|
|
19
|
+
if (payload.context.length > 0) {
|
|
20
|
+
parts.push("Recent conversation context:");
|
|
21
|
+
for (const msg of payload.context) {
|
|
22
|
+
parts.push(`[${msg.user_name}]: ${msg.content}`);
|
|
23
|
+
}
|
|
24
|
+
parts.push("");
|
|
25
|
+
}
|
|
26
|
+
parts.push(`[${payload.message.user_name}]: ${payload.message.content}`);
|
|
27
|
+
return parts.join("\n");
|
|
28
|
+
}
|
|
29
|
+
function readBody(req) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
33
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
34
|
+
req.on("error", reject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const server = createServer(async (req, res) => {
|
|
38
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
39
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
40
|
+
res.end(JSON.stringify({
|
|
41
|
+
status: "ok",
|
|
42
|
+
backend: config.backend,
|
|
43
|
+
active: activeRequests,
|
|
44
|
+
}));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (req.method !== "POST" || req.url !== "/webhook") {
|
|
48
|
+
res.writeHead(404);
|
|
49
|
+
res.end("Not found");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (activeRequests >= config.maxConcurrent) {
|
|
53
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
54
|
+
res.end(JSON.stringify({ error: "Too many concurrent requests" }));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const body = await readBody(req);
|
|
58
|
+
const signature = req.headers["x-webhook-signature"];
|
|
59
|
+
if (!signature || !verifySignature(body, signature, config.webhookSecret)) {
|
|
60
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
61
|
+
res.end(JSON.stringify({ error: "Invalid signature" }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let payload;
|
|
65
|
+
try {
|
|
66
|
+
payload = JSON.parse(body);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
70
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
res.writeHead(200, {
|
|
74
|
+
"Content-Type": "text/event-stream",
|
|
75
|
+
"Cache-Control": "no-cache",
|
|
76
|
+
Connection: "keep-alive",
|
|
77
|
+
});
|
|
78
|
+
const heartbeatTimer = setInterval(() => sse.heartbeat(res), config.heartbeatIntervalMs);
|
|
79
|
+
const abort = new AbortController();
|
|
80
|
+
res.on("close", () => {
|
|
81
|
+
clearInterval(heartbeatTimer);
|
|
82
|
+
abort.abort();
|
|
83
|
+
});
|
|
84
|
+
activeRequests++;
|
|
85
|
+
const prompt = buildPrompt(payload, config.repos);
|
|
86
|
+
try {
|
|
87
|
+
const run = config.backend === "claude-code" ? runClaudeCode : runCodex;
|
|
88
|
+
await run(prompt, res, abort, config);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (!abort.signal.aborted) {
|
|
92
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
93
|
+
sse.error(res, msg);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
activeRequests--;
|
|
98
|
+
clearInterval(heartbeatTimer);
|
|
99
|
+
if (!res.writableEnded)
|
|
100
|
+
res.end();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
function shutdown() {
|
|
104
|
+
console.log("Shutting down...");
|
|
105
|
+
server.close(() => {
|
|
106
|
+
try {
|
|
107
|
+
unlinkSync(DAEMON_FILE);
|
|
108
|
+
}
|
|
109
|
+
catch { }
|
|
110
|
+
process.exit(0);
|
|
111
|
+
});
|
|
112
|
+
setTimeout(() => process.exit(1), 5000);
|
|
113
|
+
}
|
|
114
|
+
process.on("SIGTERM", shutdown);
|
|
115
|
+
process.on("SIGINT", shutdown);
|
|
116
|
+
server.listen(config.port, async () => {
|
|
117
|
+
console.log(`Bridge running on :${config.port} [backend=${config.backend}]`);
|
|
118
|
+
if (config.tunnel && config.pompeiiApiKey) {
|
|
119
|
+
try {
|
|
120
|
+
const url = await startTunnel(config.port, config.pompeiiApiKey, config.pompeiiApiUrl);
|
|
121
|
+
console.log(`Tunnel: ${url}`);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.error(`Tunnel failed: ${err instanceof Error ? err.message : err}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export const POMPEII_DIR = join(homedir(), ".pompeii");
|
|
4
|
+
export const CONFIG_FILE = join(POMPEII_DIR, "config.json");
|
|
5
|
+
export const ENV_FILE = join(POMPEII_DIR, ".env");
|
|
6
|
+
export const DAEMON_FILE = join(POMPEII_DIR, "daemon.json");
|
|
7
|
+
export const LOG_FILE = join(POMPEII_DIR, "bridge.log");
|
|
8
|
+
export const TUNNEL_LOG = join(POMPEII_DIR, "tunnel.log");
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
export declare function chunk(res: ServerResponse, content: string): void;
|
|
3
|
+
export declare function activity(res: ServerResponse, tool: string, status: "running" | "success" | "error", result?: string): void;
|
|
4
|
+
export declare function done(res: ServerResponse): void;
|
|
5
|
+
export declare function error(res: ServerResponse, message: string): void;
|
|
6
|
+
export declare function heartbeat(res: ServerResponse): void;
|
package/dist/src/sse.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function send(res, data) {
|
|
2
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
3
|
+
}
|
|
4
|
+
export function chunk(res, content) {
|
|
5
|
+
send(res, { type: "chunk", content });
|
|
6
|
+
}
|
|
7
|
+
export function activity(res, tool, status, result) {
|
|
8
|
+
const data = { type: "activity", tool, status };
|
|
9
|
+
if (result !== undefined)
|
|
10
|
+
data.result = result;
|
|
11
|
+
send(res, data);
|
|
12
|
+
}
|
|
13
|
+
export function done(res) {
|
|
14
|
+
send(res, { type: "done" });
|
|
15
|
+
res.end();
|
|
16
|
+
}
|
|
17
|
+
export function error(res, message) {
|
|
18
|
+
send(res, { type: "error", message });
|
|
19
|
+
res.end();
|
|
20
|
+
}
|
|
21
|
+
export function heartbeat(res) {
|
|
22
|
+
res.write(": heartbeat\n\n");
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startTunnel(port: number, apiKey: string, apiUrl: string): Promise<string>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { POMPEII_DIR, TUNNEL_LOG, DAEMON_FILE } from "./paths.js";
|
|
5
|
+
const CLOUDFLARED_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
6
|
+
const MAX_POLL_ATTEMPTS = 30;
|
|
7
|
+
const POLL_INTERVAL_MS = 500;
|
|
8
|
+
async function pollForUrl() {
|
|
9
|
+
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
|
|
10
|
+
try {
|
|
11
|
+
const log = await readFile(TUNNEL_LOG, "utf-8");
|
|
12
|
+
const match = log.match(CLOUDFLARED_URL_RE);
|
|
13
|
+
if (match)
|
|
14
|
+
return match[0];
|
|
15
|
+
}
|
|
16
|
+
catch { }
|
|
17
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
18
|
+
}
|
|
19
|
+
throw new Error("Timed out waiting for tunnel URL");
|
|
20
|
+
}
|
|
21
|
+
async function updatePompeiiWebhook(tunnelUrl, apiKey, apiUrl) {
|
|
22
|
+
const webhookUrl = `${tunnelUrl}/webhook`;
|
|
23
|
+
const res = await fetch(`${apiUrl}/v1/bot`, {
|
|
24
|
+
method: "PATCH",
|
|
25
|
+
headers: {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
"X-Agent-Key": apiKey,
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify({ webhook_url: webhookUrl }),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const body = await res.text().catch(() => "");
|
|
33
|
+
console.error(`Webhook update failed (${res.status}): ${body}`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.log(`Webhook URL updated: ${webhookUrl}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function updateDaemonFile(tunnelUrl) {
|
|
40
|
+
if (!existsSync(DAEMON_FILE))
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
const daemon = JSON.parse(readFileSync(DAEMON_FILE, "utf-8"));
|
|
44
|
+
daemon.tunnelUrl = tunnelUrl;
|
|
45
|
+
writeFileSync(DAEMON_FILE, JSON.stringify(daemon, null, 2));
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
}
|
|
49
|
+
export async function startTunnel(port, apiKey, apiUrl) {
|
|
50
|
+
await mkdir(POMPEII_DIR, { recursive: true });
|
|
51
|
+
const child = spawn("cloudflared", ["tunnel", "--url", `http://0.0.0.0:${port}`], {
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
54
|
+
});
|
|
55
|
+
const logStream = await import("node:fs").then((fs) => fs.createWriteStream(TUNNEL_LOG, { flags: "w" }));
|
|
56
|
+
child.stderr?.pipe(logStream);
|
|
57
|
+
child.unref();
|
|
58
|
+
const url = await pollForUrl();
|
|
59
|
+
updateDaemonFile(url);
|
|
60
|
+
await updatePompeiiWebhook(url, apiKey, apiUrl);
|
|
61
|
+
return url;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function verifySignature(payload: string, signature: string, secret: string): boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export function verifySignature(payload, signature, secret) {
|
|
3
|
+
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
|
4
|
+
const sig = signature.replace(/^sha256=/, "");
|
|
5
|
+
if (sig.length !== expected.length)
|
|
6
|
+
return false;
|
|
7
|
+
return timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"));
|
|
8
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompeii-labs/bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"pompeii": "dist/src/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "bun --watch src/index.ts",
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"start": "node dist/src/index.js",
|
|
15
|
+
"prepublishOnly": "tsc"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@anthropic-ai/claude-agent-sdk": "latest",
|
|
19
|
+
"@clack/prompts": "^0.7.0",
|
|
20
|
+
"@openai/codex-sdk": "latest",
|
|
21
|
+
"commander": "^12.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.13.4",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
}
|
|
27
|
+
}
|