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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.66",
3
+ "version": "0.9.67",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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, ngrok tunnel URL) without IPC.
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
- // ── Ngrok tunnel URL ───────────────────────────────────────────────────────
176
- const tunnelUrl = await probeNgrokUrl();
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
- ngrok: {
192
- tunnelUrl,
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
- ngrokProcess = null;
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.startNgrok();
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
- * Check if a previous ngrok process can be reused.
369
- * Returns true if the existing ngrok is alive, healthy, and serving the right domain.
370
- * Returns false otherwise. External processes are never killed; stale or
371
- * incompatible metadata is ignored so another terminal's tunnel stays alive.
372
- */
373
- async reuseNgrokIfHealthy(domain, expectedPort = null) {
374
- const meta = readNgrokMeta();
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
- ngrokBin = resolveNgrokBin();
339
+ this.hookTunnel = startHookTunnel({ relayUrl, getLocalPort: () => this.boundPort });
340
+ logWebhook(`public hook base: ${this.hookTunnel.publicBase}`);
459
341
  } catch (err) {
460
- if (!this._ngrokDisabledLogged) {
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
- // Intentionally do NOT kill ngrok — let it survive across MCP restarts.
524
- // The next start() can reuse it if reuseNgrokIfHealthy() validates it.
525
- if (this.ngrokProcess) {
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 (ngrok left running for reuse)");
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.config.ngrokDomain) {
677
- return `https://${this.config.ngrokDomain}/webhook/${name}`;
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