mixdog 0.9.66 → 0.9.67
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/package.json +1 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -0
- package/src/runtime/channels/lib/config.mjs +7 -0
- package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
- package/src/runtime/channels/lib/webhook.mjs +23 -201
- package/src/runtime/shared/config.mjs +0 -9
- package/src/runtime/shared/time-format.mjs +56 -0
- package/src/runtime/shared/tool-card-model.mjs +740 -0
- package/src/session-runtime/channel-config-api.mjs +5 -0
- package/src/session-runtime/lifecycle-api.mjs +5 -0
- package/src/session-runtime/prewarm.mjs +13 -7
- package/src/session-runtime/runtime-core.mjs +28 -9
- package/src/session-runtime/session-text.mjs +6 -0
- package/src/session-runtime/settings-api.mjs +0 -12
- package/src/standalone/channel-admin.mjs +35 -21
- package/src/tui/App.jsx +0 -15
- package/src/tui/app/channel-pickers.mjs +18 -42
- package/src/tui/app/doctor.mjs +0 -1
- package/src/tui/components/ToolExecution.jsx +47 -196
- package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
- package/src/tui/components/tool-execution/text-format.mjs +27 -104
- package/src/tui/dist/index.mjs +306 -185
- package/src/tui/engine/live-share.mjs +9 -0
- package/src/tui/engine/session-api-ext.mjs +0 -10
- package/src/tui/time-format.mjs +4 -51
- package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
package/package.json
CHANGED
|
@@ -152,6 +152,9 @@ export function _sessionSummary(session) {
|
|
|
152
152
|
preview: messageProjection.preview,
|
|
153
153
|
generation: typeof session.generation === 'number' ? session.generation : 0,
|
|
154
154
|
implicitBashSessionId: session.implicitBashSessionId || null,
|
|
155
|
+
// Lifecycle provenance for catalog filters: resume-machinery scratch
|
|
156
|
+
// forks (detachedReason='cli-resume') must never surface in Recent.
|
|
157
|
+
detachedReason: session.detachedReason || null,
|
|
155
158
|
};
|
|
156
159
|
}
|
|
157
160
|
|
|
@@ -184,6 +187,7 @@ function _normalizeSummaryRow(row) {
|
|
|
184
187
|
preview: _cleanPreview(row.preview || ''),
|
|
185
188
|
generation: typeof row.generation === 'number' ? row.generation : 0,
|
|
186
189
|
implicitBashSessionId: row.implicitBashSessionId || null,
|
|
190
|
+
detachedReason: row.detachedReason || null,
|
|
187
191
|
};
|
|
188
192
|
}
|
|
189
193
|
|
|
@@ -49,6 +49,11 @@ function cleanText(value, maximum = 240) {
|
|
|
49
49
|
return String(value || '')
|
|
50
50
|
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
|
|
51
51
|
.replace(/<mcp-instructions>[\s\S]*?<\/mcp-instructions>/gi, ' ')
|
|
52
|
+
// Session-context envelope (mirror of session-text.mjs
|
|
53
|
+
// stripSessionDisplayEnvelope): the "# Session / Cwd / Model /
|
|
54
|
+
// Workflow" header must never become a Recent title.
|
|
55
|
+
.replace(/^\s*# Session\r?\n(?:(?:Cwd|Model|Workflow):[^\r\n]*(?:\r?\n|$))+(?:\r?\n)?/i, ' ')
|
|
56
|
+
.replace(/^\s*#\s*Session\s+Cwd:\s+\S+(?:\s+Model:[^\r\n]*?)?(?:\s+Workflow:\s+\S+)?\s*/i, ' ')
|
|
52
57
|
.replace(/\s+/g, ' ')
|
|
53
58
|
.trim()
|
|
54
59
|
.slice(0, maximum);
|
|
@@ -105,6 +110,7 @@ function normalizedRow(row, heartbeatAt = 0) {
|
|
|
105
110
|
preview: cleanText(row.preview),
|
|
106
111
|
generation: typeof row.generation === 'number' ? row.generation : 0,
|
|
107
112
|
implicitBashSessionId: row.implicitBashSessionId || null,
|
|
113
|
+
detachedReason: row.detachedReason || null,
|
|
108
114
|
};
|
|
109
115
|
}
|
|
110
116
|
|
|
@@ -112,6 +118,10 @@ function rowFromSession(session, heartbeatAt = 0) {
|
|
|
112
118
|
const messages = Array.isArray(session?.messages) ? session.messages : [];
|
|
113
119
|
const preview = messages
|
|
114
120
|
.filter((message) => message?.role === 'user')
|
|
121
|
+
// Cold-path mirror of isSessionPreviewNoise's synthetic skips: compact
|
|
122
|
+
// handoffs and runtime notices must not become session titles.
|
|
123
|
+
.filter((message) => !/^\s*(?:a previous model worked on this task|re-attached after compaction\b|reference files:\s|\[mixdog-runtime\]|the async (?:agent|shell) task\b)/i
|
|
124
|
+
.test(messageText(message.content)))
|
|
115
125
|
.map((message) => cleanText(messageText(message.content)))
|
|
116
126
|
.find(Boolean) || '';
|
|
117
127
|
return normalizedRow({
|
|
@@ -156,6 +156,13 @@ const HEADLESS_BACKEND = {
|
|
|
156
156
|
}
|
|
157
157
|
};
|
|
158
158
|
function createBackend(config) {
|
|
159
|
+
// Channels-module toggle is MESSAGING-ONLY: automation (scheduler/webhooks)
|
|
160
|
+
// keeps the worker alive, so a disabled module runs the headless backend
|
|
161
|
+
// instead of blocking the whole runtime.
|
|
162
|
+
if (config.enabled === false) {
|
|
163
|
+
process.stderr.write("mixdog: channels messaging disabled; channel runtime running in headless mode\n");
|
|
164
|
+
return HEADLESS_BACKEND;
|
|
165
|
+
}
|
|
159
166
|
// Single-backend select: exactly one backend is constructed based on
|
|
160
167
|
// config.backend (discord|telegram). The two are mutually exclusive.
|
|
161
168
|
if (config.backend === "telegram") {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Writes <DATA_DIR>/channels/status-snapshot.json every 10 seconds so that
|
|
5
5
|
* setup-server can read cross-process state (cron next-fire, deferred count,
|
|
6
|
-
* Discord unread,
|
|
6
|
+
* Discord unread, relay hook URL) without IPC.
|
|
7
7
|
*
|
|
8
8
|
* Atomic write: tmp → rename so readers never see a partial file.
|
|
9
9
|
*
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import * as fs from 'fs';
|
|
16
|
-
import * as http from 'http';
|
|
17
16
|
import * as path from 'path';
|
|
18
17
|
import { DATA_DIR } from './config.mjs';
|
|
19
18
|
import { writeJsonAtomicSync } from '../../shared/atomic-file.mjs';
|
|
19
|
+
import { readHookPublicBase } from './webhook/relay-tunnel.mjs';
|
|
20
20
|
|
|
21
21
|
const SNAPSHOT_DIR = path.join(DATA_DIR, 'channels');
|
|
22
22
|
const SNAPSHOT_PATH = path.join(SNAPSHOT_DIR, 'status-snapshot.json');
|
|
@@ -79,30 +79,6 @@ export function recordFetchedMessages(channelId, channelLabel, messages, options
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// ── Ngrok tunnel URL probe ───────────────────────────────────────────────────
|
|
83
|
-
async function probeNgrokUrl() {
|
|
84
|
-
return new Promise((resolve) => {
|
|
85
|
-
const timer = setTimeout(() => { try { req && req.destroy(); } catch {} resolve(null); }, 400);
|
|
86
|
-
let req;
|
|
87
|
-
try {
|
|
88
|
-
req = http.get('http://127.0.0.1:4040/api/tunnels', (res) => {
|
|
89
|
-
clearTimeout(timer);
|
|
90
|
-
let body = '';
|
|
91
|
-
res.on('data', d => { body += d; });
|
|
92
|
-
res.on('end', () => {
|
|
93
|
-
try {
|
|
94
|
-
const parsed = JSON.parse(body);
|
|
95
|
-
const tunnel = (parsed.tunnels || []).find(t => t.public_url);
|
|
96
|
-
resolve(tunnel ? tunnel.public_url : null);
|
|
97
|
-
} catch { resolve(null); }
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
req.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
101
|
-
req.setTimeout(400, () => { clearTimeout(timer); try { req.destroy(); } catch {} resolve(null); });
|
|
102
|
-
} catch { clearTimeout(timer); resolve(null); }
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
82
|
// ── Snapshot computation ─────────────────────────────────────────────────────
|
|
107
83
|
// The legacy HH:MM / everyNm / hourly next-fire fallback was removed: the
|
|
108
84
|
// scheduler accepts cron expressions exclusively (scheduler.mjs:68), so the
|
|
@@ -172,8 +148,8 @@ async function computeSnapshot(scheduler) {
|
|
|
172
148
|
totalUnread += entry.unseenCount;
|
|
173
149
|
}
|
|
174
150
|
|
|
175
|
-
// ──
|
|
176
|
-
const
|
|
151
|
+
// ── Relay hook URL (identity file read; assigned on first tunnel start) ────
|
|
152
|
+
const hookPublicUrl = readHookPublicBase();
|
|
177
153
|
|
|
178
154
|
return {
|
|
179
155
|
writtenAt: now,
|
|
@@ -188,8 +164,8 @@ async function computeSnapshot(scheduler) {
|
|
|
188
164
|
unread: unreadList,
|
|
189
165
|
totalUnread,
|
|
190
166
|
},
|
|
191
|
-
|
|
192
|
-
|
|
167
|
+
hook: {
|
|
168
|
+
publicUrl: hookPublicUrl,
|
|
193
169
|
},
|
|
194
170
|
};
|
|
195
171
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Relay-backed public webhook tunnel — the ngrok child process replacement.
|
|
2
|
+
//
|
|
3
|
+
// The channel worker keeps ONE outbound WebSocket to the Mixdog relay
|
|
4
|
+
// (apps/relay/server.mjs `/hookleg`). Inbound requests on
|
|
5
|
+
// https://<relay>/hook/<deviceId>/webhook/<name>
|
|
6
|
+
// arrive over that leg as JSON frames and are replayed against the LOCAL
|
|
7
|
+
// webhook HTTP server; the response returns verbatim. Endpoint HMAC
|
|
8
|
+
// verification stays local — the relay never inspects payloads. Works out
|
|
9
|
+
// of the box: no binary, no authtoken, no reserved domain.
|
|
10
|
+
import * as http from "http";
|
|
11
|
+
import { randomBytes, randomUUID } from "crypto";
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import WebSocket from "ws";
|
|
15
|
+
import { DATA_DIR } from "../config.mjs";
|
|
16
|
+
import { logWebhook } from "./log.mjs";
|
|
17
|
+
|
|
18
|
+
/** Packaged default mirrors the desktop pairing relay. */
|
|
19
|
+
const DEFAULT_RELAY_URL = "wss://192-255-139-161.sslip.io";
|
|
20
|
+
const MAX_TUNNEL_BODY_BYTES = 1024 * 1024;
|
|
21
|
+
const HEARTBEAT_MS = 25_000;
|
|
22
|
+
const LOCAL_TIMEOUT_MS = 25_000;
|
|
23
|
+
|
|
24
|
+
export function resolveHookRelayUrl(env = process.env) {
|
|
25
|
+
const raw = String(env.MIXDOG_RELAY_URL || "").trim();
|
|
26
|
+
const flag = raw.toLowerCase();
|
|
27
|
+
if (flag === "0" || flag === "false" || flag === "off") return null;
|
|
28
|
+
return raw || DEFAULT_RELAY_URL;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hookIdentityPath() {
|
|
32
|
+
return join(DATA_DIR, "relay-hook-device.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Stable per-install identity (trust-on-first-use at the relay, mirroring
|
|
36
|
+
// the desktop leg). The secret never leaves this machine except toward the
|
|
37
|
+
// relay; the deviceId doubles as the public URL path segment.
|
|
38
|
+
export function loadOrCreateHookIdentity() {
|
|
39
|
+
const path = hookIdentityPath();
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
42
|
+
if (typeof parsed.deviceId === "string" && /^[0-9a-f-]{8,64}$/.test(parsed.deviceId)
|
|
43
|
+
&& typeof parsed.deviceSecret === "string" && parsed.deviceSecret.length >= 16) {
|
|
44
|
+
return { deviceId: parsed.deviceId, deviceSecret: parsed.deviceSecret };
|
|
45
|
+
}
|
|
46
|
+
} catch { /* first run */ }
|
|
47
|
+
const identity = { deviceId: randomUUID(), deviceSecret: randomBytes(24).toString("hex") };
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
50
|
+
writeFileSync(path, JSON.stringify(identity, null, 2), "utf8");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
logWebhook(`hook tunnel: identity persist failed — ${err?.message || err}`);
|
|
53
|
+
}
|
|
54
|
+
return identity;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hookPublicBase(relayUrl, deviceId) {
|
|
58
|
+
const url = new URL(relayUrl);
|
|
59
|
+
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
60
|
+
url.pathname = `/hook/${deviceId}`;
|
|
61
|
+
url.search = "";
|
|
62
|
+
url.hash = "";
|
|
63
|
+
return url.toString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Public base URL for status surfaces; null until the first tunnel start
|
|
67
|
+
* persisted an identity (no identity is ever created here). */
|
|
68
|
+
export function readHookPublicBase(env = process.env) {
|
|
69
|
+
const relayUrl = resolveHookRelayUrl(env);
|
|
70
|
+
if (!relayUrl) return null;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(readFileSync(hookIdentityPath(), "utf8"));
|
|
73
|
+
if (typeof parsed.deviceId === "string" && parsed.deviceId) {
|
|
74
|
+
return hookPublicBase(relayUrl, parsed.deviceId);
|
|
75
|
+
}
|
|
76
|
+
} catch { /* tunnel has not started yet */ }
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function startHookTunnel({ relayUrl, getLocalPort }) {
|
|
81
|
+
const { deviceId, deviceSecret } = loadOrCreateHookIdentity();
|
|
82
|
+
let socket = null;
|
|
83
|
+
let closed = false;
|
|
84
|
+
let retryMs = 1_000;
|
|
85
|
+
let reconnectTimer = null;
|
|
86
|
+
let announced = false;
|
|
87
|
+
|
|
88
|
+
const scheduleReconnect = () => {
|
|
89
|
+
if (closed) return;
|
|
90
|
+
reconnectTimer = setTimeout(connect, retryMs);
|
|
91
|
+
reconnectTimer.unref?.();
|
|
92
|
+
retryMs = Math.min(30_000, retryMs * 2);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const respond = (ws, id, status, headers, bodyBuffer) => {
|
|
96
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
97
|
+
try {
|
|
98
|
+
ws.send(JSON.stringify({
|
|
99
|
+
type: "http-response",
|
|
100
|
+
id,
|
|
101
|
+
status,
|
|
102
|
+
headers: headers || {},
|
|
103
|
+
body: bodyBuffer && bodyBuffer.length ? bodyBuffer.toString("base64") : "",
|
|
104
|
+
}));
|
|
105
|
+
} catch { /* relay vanished; it times the request out */ }
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const forwardToLocal = (frame, ws) => {
|
|
109
|
+
const port = getLocalPort();
|
|
110
|
+
if (!port) {
|
|
111
|
+
respond(ws, frame.id, 503, { "content-type": "application/json" },
|
|
112
|
+
Buffer.from('{"error":"webhook server not listening"}'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const body = frame.body ? Buffer.from(String(frame.body), "base64") : null;
|
|
116
|
+
const request = http.request({
|
|
117
|
+
host: "127.0.0.1",
|
|
118
|
+
port,
|
|
119
|
+
method: typeof frame.method === "string" ? frame.method : "GET",
|
|
120
|
+
path: typeof frame.path === "string" && frame.path.startsWith("/") ? frame.path : "/",
|
|
121
|
+
headers: frame.headers && typeof frame.headers === "object" ? frame.headers : {},
|
|
122
|
+
timeout: LOCAL_TIMEOUT_MS,
|
|
123
|
+
}, (response) => {
|
|
124
|
+
const chunks = [];
|
|
125
|
+
let total = 0;
|
|
126
|
+
response.on("data", (chunk) => {
|
|
127
|
+
total += chunk.length;
|
|
128
|
+
if (total <= MAX_TUNNEL_BODY_BYTES) chunks.push(chunk);
|
|
129
|
+
});
|
|
130
|
+
response.on("end", () => respond(ws, frame.id, response.statusCode || 502,
|
|
131
|
+
{ "content-type": response.headers["content-type"] || "application/json" },
|
|
132
|
+
Buffer.concat(chunks)));
|
|
133
|
+
response.on("error", () => respond(ws, frame.id, 502, {}, null));
|
|
134
|
+
});
|
|
135
|
+
request.on("timeout", () => request.destroy(new Error("local webhook timeout")));
|
|
136
|
+
request.on("error", (err) => respond(ws, frame.id, 502, { "content-type": "application/json" },
|
|
137
|
+
Buffer.from(JSON.stringify({ error: String(err?.message || err) }))));
|
|
138
|
+
if (body && body.length) request.write(body);
|
|
139
|
+
request.end();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const connect = () => {
|
|
143
|
+
if (closed) return;
|
|
144
|
+
const target = new URL(relayUrl);
|
|
145
|
+
target.pathname = "/hookleg";
|
|
146
|
+
target.search = `device=${encodeURIComponent(deviceId)}&secret=${encodeURIComponent(deviceSecret)}`;
|
|
147
|
+
let ws;
|
|
148
|
+
try {
|
|
149
|
+
ws = new WebSocket(target.toString(), { maxPayload: 8 * 1024 * 1024 });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
logWebhook(`hook tunnel: dial failed — ${err?.message || err}`);
|
|
152
|
+
scheduleReconnect();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
socket = ws;
|
|
156
|
+
// NAT paths silently drop idle sockets; protocol pings keep the leg warm
|
|
157
|
+
// and detect a half-dead link so the reconnect loop restores it.
|
|
158
|
+
let alive = true;
|
|
159
|
+
ws.on("pong", () => { alive = true; });
|
|
160
|
+
const heartbeat = setInterval(() => {
|
|
161
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
162
|
+
if (!alive) { try { ws.terminate(); } catch { /* close reconnects */ } return; }
|
|
163
|
+
alive = false;
|
|
164
|
+
try { ws.ping(); } catch { /* close reconnects */ }
|
|
165
|
+
}, HEARTBEAT_MS);
|
|
166
|
+
heartbeat.unref?.();
|
|
167
|
+
ws.on("open", () => {
|
|
168
|
+
retryMs = 1_000;
|
|
169
|
+
if (!announced) {
|
|
170
|
+
announced = true;
|
|
171
|
+
logWebhook(`hook tunnel up: ${hookPublicBase(relayUrl, deviceId)}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
ws.on("message", (raw) => {
|
|
175
|
+
alive = true;
|
|
176
|
+
let frame;
|
|
177
|
+
try { frame = JSON.parse(String(raw)); } catch { return; }
|
|
178
|
+
if (frame?.type !== "http" || typeof frame.id !== "string") return;
|
|
179
|
+
forwardToLocal(frame, ws);
|
|
180
|
+
});
|
|
181
|
+
ws.on("error", () => { /* surfaced as close */ });
|
|
182
|
+
ws.on("close", () => {
|
|
183
|
+
clearInterval(heartbeat);
|
|
184
|
+
if (socket === ws) socket = null;
|
|
185
|
+
scheduleReconnect();
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
connect();
|
|
190
|
+
return {
|
|
191
|
+
deviceId,
|
|
192
|
+
publicBase: hookPublicBase(relayUrl, deviceId),
|
|
193
|
+
close() {
|
|
194
|
+
if (closed) return;
|
|
195
|
+
closed = true;
|
|
196
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
197
|
+
if (socket) {
|
|
198
|
+
try { socket.terminate(); } catch { /* already gone */ }
|
|
199
|
+
socket = null;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import * as http from "http";
|
|
2
|
-
import { spawn, spawnSync } from "child_process";
|
|
3
2
|
import { randomUUID } from "crypto";
|
|
4
|
-
import { getWebhookAuthtoken } from "../../shared/config.mjs";
|
|
5
3
|
import { logWebhook } from "./webhook/log.mjs";
|
|
6
4
|
import { SIGNATURE_HEADERS, extractSignature, STRIPE_TOLERANCE_MS, verifySignature } from "./webhook/signature.mjs";
|
|
7
|
-
import { detachedSpawnOpts } from "../../shared/spawn-flags.mjs";
|
|
8
5
|
import {
|
|
9
6
|
loadEndpointConfig,
|
|
10
7
|
readEndpointSecret,
|
|
@@ -15,20 +12,7 @@ import {
|
|
|
15
12
|
extractDeliveryId,
|
|
16
13
|
buildHeadersSummary,
|
|
17
14
|
} from "./webhook/deliveries.mjs";
|
|
18
|
-
import {
|
|
19
|
-
NGROK_MAX_AGE_MS,
|
|
20
|
-
resolveNgrokBin,
|
|
21
|
-
normalizeDomain,
|
|
22
|
-
readNgrokMeta,
|
|
23
|
-
writeNgrokMeta,
|
|
24
|
-
clearNgrokMeta,
|
|
25
|
-
isLikelyNgrok,
|
|
26
|
-
isProcessAlive,
|
|
27
|
-
parseStrictPidLine,
|
|
28
|
-
resolvePortOwnerPid,
|
|
29
|
-
handleWebhookPortInUse,
|
|
30
|
-
checkNgrokHealth,
|
|
31
|
-
} from "./webhook/ngrok.mjs";
|
|
15
|
+
import { resolveHookRelayUrl, startHookTunnel } from "./webhook/relay-tunnel.mjs";
|
|
32
16
|
|
|
33
17
|
class WebhookServer {
|
|
34
18
|
config;
|
|
@@ -38,7 +22,7 @@ class WebhookServer {
|
|
|
38
22
|
boundPort = 0;
|
|
39
23
|
listenInFlight = false;
|
|
40
24
|
noSecretWarned = false;
|
|
41
|
-
|
|
25
|
+
hookTunnel = null;
|
|
42
26
|
constructor(config) {
|
|
43
27
|
this.config = config;
|
|
44
28
|
}
|
|
@@ -311,42 +295,19 @@ class WebhookServer {
|
|
|
311
295
|
const basePort = this.config.port || 3333;
|
|
312
296
|
const maxPort = basePort + 7;
|
|
313
297
|
let currentPort = basePort;
|
|
314
|
-
let baseReclaimAttempted = false;
|
|
315
298
|
const tryListen = () => {
|
|
316
299
|
this.server.listen(currentPort, () => {
|
|
317
300
|
this.listenInFlight = false;
|
|
318
301
|
this.boundPort = currentPort;
|
|
319
302
|
logWebhook(`listening on port ${currentPort}`);
|
|
320
|
-
this.
|
|
303
|
+
this._startHookTunnel();
|
|
321
304
|
});
|
|
322
305
|
};
|
|
323
306
|
this.server.on("error", (err) => {
|
|
324
|
-
if (err.code === "EADDRINUSE" && currentPort === basePort && !baseReclaimAttempted) {
|
|
325
|
-
baseReclaimAttempted = true;
|
|
326
|
-
void handleWebhookPortInUse(basePort, this.config.ngrokDomain || this.config.domain).then((result) => {
|
|
327
|
-
if (result.ok) {
|
|
328
|
-
currentPort = basePort;
|
|
329
|
-
logWebhook(`reclaimed base port ${basePort}, retrying bind`);
|
|
330
|
-
tryListen();
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
if (result.bump && currentPort < maxPort) {
|
|
334
|
-
logWebhook(
|
|
335
|
-
`port ${basePort} not reclaimable (live non-ngrok PID ${result.ownerPid ?? "unknown"}), trying ${currentPort + 1}`,
|
|
336
|
-
);
|
|
337
|
-
currentPort++;
|
|
338
|
-
tryListen();
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
if (err.code === "EADDRINUSE") {
|
|
342
|
-
logWebhook(`all ports ${basePort}-${maxPort} in use \u2014 webhook server disabled`);
|
|
343
|
-
this.listenInFlight = false;
|
|
344
|
-
this.server = null;
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
307
|
if (err.code === "EADDRINUSE" && currentPort < maxPort) {
|
|
308
|
+
// The relay tunnel forwards to whatever port this process binds, so
|
|
309
|
+
// port identity no longer matters (the ngrok-era domain↔port coupling
|
|
310
|
+
// is gone) — walk up the range.
|
|
350
311
|
logWebhook(`port ${currentPort} already in use, trying ${currentPort + 1}`);
|
|
351
312
|
currentPort++;
|
|
352
313
|
tryListen();
|
|
@@ -364,166 +325,27 @@ class WebhookServer {
|
|
|
364
325
|
});
|
|
365
326
|
tryListen();
|
|
366
327
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (!meta || !(meta.pid > 0)) {
|
|
376
|
-
clearNgrokMeta();
|
|
377
|
-
return false;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const { pid } = meta;
|
|
381
|
-
|
|
382
|
-
// Metadata domain mismatch — different config. Do not kill another terminal's tunnel.
|
|
383
|
-
if (meta.domain && normalizeDomain(meta.domain) !== normalizeDomain(domain)) {
|
|
384
|
-
logWebhook(`ngrok meta domain mismatch (${meta.domain} vs ${domain}), ignoring PID ${pid}`);
|
|
385
|
-
clearNgrokMeta();
|
|
386
|
-
return false;
|
|
387
|
-
}
|
|
388
|
-
if (expectedPort && meta.port && Number(meta.port) !== Number(expectedPort)) {
|
|
389
|
-
// A tunnel forwarding to the OLD local port cannot serve this server.
|
|
390
|
-
// Ignore the metadata and let this process try its own tunnel without
|
|
391
|
-
// touching the existing process.
|
|
392
|
-
logWebhook(`ngrok meta port mismatch (${meta.port} vs ${expectedPort}) — ignoring PID ${pid}`);
|
|
393
|
-
clearNgrokMeta();
|
|
394
|
-
return false;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Stale check — older than 24 hours (ngrok session realistic lifetime;
|
|
398
|
-
// ngrok free-tier tunnels expire after ~2h but paid/reserved-domain
|
|
399
|
-
// tunnels survive much longer; 24h is a safe conservative ceiling).
|
|
400
|
-
if (meta.startedAt && (Date.now() - new Date(meta.startedAt).getTime()) > NGROK_MAX_AGE_MS) {
|
|
401
|
-
logWebhook(`ngrok meta stale (started ${meta.startedAt}), ignoring PID ${pid}`);
|
|
402
|
-
clearNgrokMeta();
|
|
403
|
-
return false;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Check if process is alive
|
|
407
|
-
let alive = false;
|
|
408
|
-
try { process.kill(pid, 0); alive = true } catch {}
|
|
409
|
-
|
|
410
|
-
if (!alive) {
|
|
411
|
-
logWebhook(`ngrok PID ${pid} is dead, cleaning up`);
|
|
412
|
-
clearNgrokMeta();
|
|
413
|
-
return false;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Process alive + domain matches — verify tunnel via 4040 API
|
|
417
|
-
const healthy = await checkNgrokHealth(domain, expectedPort);
|
|
418
|
-
if (healthy) {
|
|
419
|
-
logWebhook(`reusing ngrok (PID ${pid}, domain ${domain}, port ${meta.port})`);
|
|
420
|
-
return true;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Alive but tunnel unhealthy. Leave it alone; it may belong to another terminal.
|
|
424
|
-
logWebhook(`ngrok PID ${pid} alive but tunnel unhealthy, ignoring`);
|
|
425
|
-
clearNgrokMeta();
|
|
426
|
-
return false;
|
|
427
|
-
}
|
|
428
|
-
async startNgrok() {
|
|
429
|
-
// Mutex: skip only when THIS process still owns a live ngrok child. Fresh
|
|
430
|
-
// daemon restarts always have ngrokProcess=null and must proceed; stale
|
|
431
|
-
// in-memory refs after exit must not block respawn.
|
|
432
|
-
if (this.ngrokProcess && this.ngrokProcess.exitCode == null && !this.ngrokProcess.killed) return;
|
|
433
|
-
if (this._ngrokStartPromise) return this._ngrokStartPromise;
|
|
434
|
-
this._ngrokStartPromise = this._doStartNgrok();
|
|
435
|
-
try { await this._ngrokStartPromise; } finally { this._ngrokStartPromise = null; }
|
|
436
|
-
}
|
|
437
|
-
async _doStartNgrok() {
|
|
438
|
-
const authtoken = getWebhookAuthtoken();
|
|
439
|
-
const domain = this.config.ngrokDomain || this.config.domain;
|
|
440
|
-
if (!authtoken || !domain) return;
|
|
441
|
-
let attempts = 0;
|
|
442
|
-
while (!this.boundPort) {
|
|
443
|
-
if (++attempts > 30) {
|
|
444
|
-
logWebhook("ngrok: gave up waiting for port");
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Try to reuse an existing ngrok process
|
|
451
|
-
const reused = await this.reuseNgrokIfHealthy(domain, this.boundPort);
|
|
452
|
-
if (reused) {
|
|
328
|
+
// Relay-backed public tunnel (replaces the ngrok child): one outbound
|
|
329
|
+
// WebSocket to the Mixdog relay serves every inbound webhook — no binary,
|
|
330
|
+
// no authtoken, no reserved domain.
|
|
331
|
+
_startHookTunnel() {
|
|
332
|
+
if (this.hookTunnel) return;
|
|
333
|
+
const relayUrl = resolveHookRelayUrl();
|
|
334
|
+
if (!relayUrl) {
|
|
335
|
+
logWebhook("hook tunnel disabled (MIXDOG_RELAY_URL=off)");
|
|
453
336
|
return;
|
|
454
337
|
}
|
|
455
|
-
|
|
456
|
-
let ngrokBin;
|
|
457
338
|
try {
|
|
458
|
-
|
|
339
|
+
this.hookTunnel = startHookTunnel({ relayUrl, getLocalPort: () => this.boundPort });
|
|
340
|
+
logWebhook(`public hook base: ${this.hookTunnel.publicBase}`);
|
|
459
341
|
} catch (err) {
|
|
460
|
-
|
|
461
|
-
logWebhook(`ngrok disabled — ${err.message}`);
|
|
462
|
-
this._ngrokDisabledLogged = true;
|
|
463
|
-
}
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
spawnSync(ngrokBin, ["config", "add-authtoken", authtoken], { stdio: "ignore", timeout: 1e4, windowsHide: true });
|
|
467
|
-
attempts = 0;
|
|
468
|
-
const waitAndStart = () => {
|
|
469
|
-
if (!this.boundPort) {
|
|
470
|
-
if (++attempts > 30) {
|
|
471
|
-
logWebhook("ngrok: gave up waiting for port");
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
setTimeout(waitAndStart, 500);
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
try {
|
|
478
|
-
// stdio fully ignored so Node does not pass inheritable stdio handles
|
|
479
|
-
// (bInheritHandles stays false on Windows). There is no portable Node API
|
|
480
|
-
// to mark the http.Server listen socket non-inheritable; detached ngrok
|
|
481
|
-
// can still inherit stale handles in edge cases — layer-1 port reclaim
|
|
482
|
-
// on EADDRINUSE is the guaranteed safety net.
|
|
483
|
-
this.ngrokProcess = spawn(ngrokBin, ["http", String(this.boundPort), "--url=" + domain], {
|
|
484
|
-
stdio: ["ignore", "ignore", "ignore"],
|
|
485
|
-
...detachedSpawnOpts,
|
|
486
|
-
});
|
|
487
|
-
this.ngrokProcess.unref();
|
|
488
|
-
if (this.ngrokProcess.pid) {
|
|
489
|
-
writeNgrokMeta({
|
|
490
|
-
pid: this.ngrokProcess.pid,
|
|
491
|
-
domain,
|
|
492
|
-
port: this.boundPort,
|
|
493
|
-
startedAt: new Date().toISOString(),
|
|
494
|
-
binaryPath: ngrokBin,
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
this.ngrokProcess.on("exit", () => {
|
|
498
|
-
this.ngrokProcess = null;
|
|
499
|
-
clearNgrokMeta();
|
|
500
|
-
});
|
|
501
|
-
this.ngrokProcess.on("error", () => {
|
|
502
|
-
this.ngrokProcess = null;
|
|
503
|
-
clearNgrokMeta();
|
|
504
|
-
});
|
|
505
|
-
logWebhook(`ngrok tunnel started: ${domain} \u2192 localhost:${this.boundPort} (PID ${this.ngrokProcess.pid})`);
|
|
506
|
-
} catch (e) {
|
|
507
|
-
logWebhook(`ngrok start failed: ${e}`);
|
|
508
|
-
}
|
|
509
|
-
};
|
|
510
|
-
setTimeout(waitAndStart, 1e3);
|
|
511
|
-
// Hold the outer startNgrok() mutex (`_ngrokStartPromise`) until
|
|
512
|
-
// waitAndStart actually spawns ngrok OR exhausts its 30-attempt
|
|
513
|
-
// budget. Pre-fix the mutex released as soon as the setTimeout was
|
|
514
|
-
// scheduled, letting a duplicate startNgrok() call within the wait
|
|
515
|
-
// window arm a second timer and spawn a second ngrok process.
|
|
516
|
-
// Deadline: 1s initial + 30 × 500ms attempts = 16s, +1.5s slack.
|
|
517
|
-
const _deadline = Date.now() + 17500;
|
|
518
|
-
while (!this.ngrokProcess && Date.now() < _deadline) {
|
|
519
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
342
|
+
logWebhook(`hook tunnel start failed: ${err?.message || err}`);
|
|
520
343
|
}
|
|
521
344
|
}
|
|
522
345
|
stop() {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
this.ngrokProcess = null;
|
|
346
|
+
if (this.hookTunnel) {
|
|
347
|
+
try { this.hookTunnel.close(); } catch { /* already closed */ }
|
|
348
|
+
this.hookTunnel = null;
|
|
527
349
|
}
|
|
528
350
|
let closed = Promise.resolve();
|
|
529
351
|
if (this.server) {
|
|
@@ -538,7 +360,7 @@ class WebhookServer {
|
|
|
538
360
|
}
|
|
539
361
|
});
|
|
540
362
|
}
|
|
541
|
-
logWebhook("stopped
|
|
363
|
+
logWebhook("stopped");
|
|
542
364
|
return closed;
|
|
543
365
|
}
|
|
544
366
|
// reloadConfig(webhookCfg, options?)
|
|
@@ -673,8 +495,8 @@ ${payload}
|
|
|
673
495
|
}
|
|
674
496
|
/** Get the webhook URL for an endpoint name */
|
|
675
497
|
getUrl(name) {
|
|
676
|
-
if (this.
|
|
677
|
-
return
|
|
498
|
+
if (this.hookTunnel) {
|
|
499
|
+
return `${this.hookTunnel.publicBase}/webhook/${name}`;
|
|
678
500
|
}
|
|
679
501
|
return `http://localhost:${this.boundPort || this.config.port}/webhook/${name}`;
|
|
680
502
|
}
|
|
@@ -437,7 +437,6 @@ function getCapabilities() {
|
|
|
437
437
|
export const SECRET_ACCOUNTS = Object.freeze({
|
|
438
438
|
discordToken: 'discord.token',
|
|
439
439
|
telegramToken: 'telegram.token',
|
|
440
|
-
webhookAuth: 'webhook.authtoken',
|
|
441
440
|
agentApiKey: (provider) => `agent.${provider}.apiKey`,
|
|
442
441
|
openaiUsageSessionKey: 'agent.openai.usageSessionKey',
|
|
443
442
|
opencodeGoAuthCookie: 'agent.opencode-go.authCookie',
|
|
@@ -497,14 +496,6 @@ export function getTelegramToken() {
|
|
|
497
496
|
return _readSecret(SECRET_ACCOUNTS.telegramToken)
|
|
498
497
|
}
|
|
499
498
|
|
|
500
|
-
/**
|
|
501
|
-
* Returns the ngrok/webhook authtoken.
|
|
502
|
-
* Priority: MIXDOG_WEBHOOK_AUTHTOKEN → keychain('webhook.authtoken') → null
|
|
503
|
-
*/
|
|
504
|
-
export function getWebhookAuthtoken() {
|
|
505
|
-
return _readSecret(SECRET_ACCOUNTS.webhookAuth)
|
|
506
|
-
}
|
|
507
|
-
|
|
508
499
|
export function getOpenAIUsageSessionKey() {
|
|
509
500
|
return process.env.OPENAI_USAGE_SESSION_KEY
|
|
510
501
|
|| process.env.OPENAI_DASHBOARD_SESSION_KEY
|