mixdog 0.9.65 → 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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/app.mjs +2 -2
  3. package/src/cli.mjs +1 -1
  4. package/src/repl.mjs +72 -6
  5. package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
  6. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
  7. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
  8. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
  9. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +83 -18
  10. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +14 -0
  11. package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
  12. package/src/runtime/channels/lib/config.mjs +7 -0
  13. package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
  14. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
  15. package/src/runtime/channels/lib/webhook.mjs +23 -201
  16. package/src/runtime/shared/config.mjs +0 -9
  17. package/src/runtime/shared/time-format.mjs +56 -0
  18. package/src/runtime/shared/tool-card-model.mjs +740 -0
  19. package/src/session-runtime/channel-config-api.mjs +5 -0
  20. package/src/session-runtime/context-status.mjs +2 -1
  21. package/src/session-runtime/lifecycle-api.mjs +5 -0
  22. package/src/session-runtime/prewarm.mjs +13 -7
  23. package/src/session-runtime/provider-request-tools.mjs +6 -0
  24. package/src/session-runtime/runtime-core.mjs +28 -9
  25. package/src/session-runtime/session-text.mjs +6 -0
  26. package/src/session-runtime/settings-api.mjs +0 -12
  27. package/src/session-runtime/tool-catalog-schema.mjs +5 -1
  28. package/src/standalone/channel-admin.mjs +35 -21
  29. package/src/tui/App.jsx +0 -15
  30. package/src/tui/app/channel-pickers.mjs +18 -42
  31. package/src/tui/app/doctor.mjs +0 -1
  32. package/src/tui/app/transcript-window.mjs +16 -0
  33. package/src/tui/app/use-transcript-window.mjs +36 -1
  34. package/src/tui/components/Markdown.jsx +15 -1
  35. package/src/tui/components/Message.jsx +1 -1
  36. package/src/tui/components/PromptInput.jsx +7 -0
  37. package/src/tui/components/ToolExecution.jsx +47 -196
  38. package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
  39. package/src/tui/components/tool-execution/text-format.mjs +27 -104
  40. package/src/tui/dist/index.mjs +613 -264
  41. package/src/tui/engine/frame-batched-store.mjs +5 -3
  42. package/src/tui/engine/live-share.mjs +9 -0
  43. package/src/tui/engine/render-timing.mjs +28 -0
  44. package/src/tui/engine/session-api-ext.mjs +7 -10
  45. package/src/tui/engine/tui-steering-persist.mjs +25 -4
  46. package/src/tui/engine.mjs +9 -1
  47. package/src/tui/index.jsx +11 -3
  48. package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
  49. package/src/tui/markdown/render-ansi.mjs +34 -1
  50. package/src/tui/markdown/stream-fence.mjs +44 -7
  51. package/src/tui/markdown/streaming-markdown.mjs +95 -27
  52. package/src/tui/time-format.mjs +4 -51
  53. package/src/ui/stream-finalize.mjs +45 -0
  54. package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
@@ -36,25 +36,67 @@ function _isPreviewNoise(text) {
36
36
  return isSessionPreviewNoise(text);
37
37
  }
38
38
 
39
- function _sessionPreview(session) {
40
- const messages = Array.isArray(session?.messages) ? session.messages : [];
41
- for (const m of messages) {
42
- if (m?.role !== 'user') continue;
43
- const raw = _messageText(m.content);
44
- if (_isPreviewNoise(raw)) continue;
45
- const text = _cleanPreview(raw);
46
- if (text) return text;
47
- }
48
- return '';
39
+ const sessionMessageProjectionMemo = new WeakMap();
40
+
41
+ function _previewFromMessage(message) {
42
+ if (message?.role !== 'user') return '';
43
+ const raw = _messageText(message.content);
44
+ if (_isPreviewNoise(raw)) return '';
45
+ return _cleanPreview(raw);
49
46
  }
50
47
 
51
- function _sessionMessageCount(session) {
48
+ function _sessionMessageProjection(session) {
52
49
  const messages = Array.isArray(session?.messages) ? session.messages : [];
50
+ const cached = session && typeof session === 'object'
51
+ ? sessionMessageProjectionMemo.get(session)
52
+ : null;
53
+ let start = 0;
53
54
  let count = 0;
54
- for (const m of messages) {
55
- if (m && (m.role === 'user' || m.role === 'assistant')) count++;
55
+ let preview = '';
56
+ let previewMessage = null;
57
+ const canAppend = cached
58
+ && messages.length >= cached.length
59
+ && (cached.length === 0 || messages[cached.length - 1] === cached.lastMessage);
60
+ if (canAppend) {
61
+ start = cached.length;
62
+ count = cached.count;
63
+ preview = cached.preview;
64
+ previewMessage = cached.previewMessage;
65
+ // The first real user message is stable in normal append-only session
66
+ // flow, but refresh it cheaply so an in-place content scrub still
67
+ // invalidates the cached title source without rescanning the transcript.
68
+ if (previewMessage) {
69
+ const refreshed = _previewFromMessage(previewMessage);
70
+ if (refreshed) preview = refreshed;
71
+ else {
72
+ start = 0;
73
+ count = 0;
74
+ preview = '';
75
+ previewMessage = null;
76
+ }
77
+ }
56
78
  }
57
- return count;
79
+ for (let index = start; index < messages.length; index += 1) {
80
+ const message = messages[index];
81
+ if (message && (message.role === 'user' || message.role === 'assistant')) count += 1;
82
+ if (!preview) {
83
+ const candidate = _previewFromMessage(message);
84
+ if (candidate) {
85
+ preview = candidate;
86
+ previewMessage = message;
87
+ }
88
+ }
89
+ }
90
+ if (session && typeof session === 'object') {
91
+ sessionMessageProjectionMemo.set(session, {
92
+ length: messages.length,
93
+ lastMessage: messages[messages.length - 1] || null,
94
+ count,
95
+ preview,
96
+ previewMessage,
97
+ });
98
+ }
99
+ return { count, preview };
58
100
  }
59
101
 
60
102
  function _positiveNumber(value, fallback = 0) {
@@ -79,9 +121,14 @@ function _desktopSessionSummary(value, cwd = null) {
79
121
 
80
122
  export function _sessionSummary(session) {
81
123
  if (!session?.id) return null;
124
+ const messageProjection = _sessionMessageProjection(session);
82
125
  return {
83
126
  id: String(session.id),
84
127
  updatedAt: _positiveNumber(session.updatedAt, Date.now()),
128
+ // Conversation activity is intentionally separate from lifecycle
129
+ // bookkeeping. Resume/detach saves can advance updatedAt without a
130
+ // user-visible turn and must not reshuffle Recent session lists.
131
+ lastUsedAt: _positiveNumber(session.lastUsedAt, 0),
85
132
  createdAt: _positiveNumber(session.createdAt, 0),
86
133
  lastHeartbeatAt: _positiveNumber(session.lastHeartbeatAt, 0),
87
134
  closed: session.closed === true,
@@ -101,10 +148,13 @@ export function _sessionSummary(session) {
101
148
  task_id: session.task_id || session.taskId || null,
102
149
  permission: session.permission || null,
103
150
  toolPermission: session.toolPermission || null,
104
- messageCount: _sessionMessageCount(session),
105
- preview: _sessionPreview(session),
151
+ messageCount: messageProjection.count,
152
+ preview: messageProjection.preview,
106
153
  generation: typeof session.generation === 'number' ? session.generation : 0,
107
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,
108
158
  };
109
159
  }
110
160
 
@@ -113,6 +163,7 @@ function _normalizeSummaryRow(row) {
113
163
  return {
114
164
  id: row.id,
115
165
  updatedAt: _positiveNumber(row.updatedAt, 0),
166
+ lastUsedAt: _positiveNumber(row.lastUsedAt, 0),
116
167
  createdAt: _positiveNumber(row.createdAt, 0),
117
168
  lastHeartbeatAt: _positiveNumber(row.lastHeartbeatAt, 0),
118
169
  closed: row.closed === true,
@@ -136,6 +187,7 @@ function _normalizeSummaryRow(row) {
136
187
  preview: _cleanPreview(row.preview || ''),
137
188
  generation: typeof row.generation === 'number' ? row.generation : 0,
138
189
  implicitBashSessionId: row.implicitBashSessionId || null,
190
+ detachedReason: row.detachedReason || null,
139
191
  };
140
192
  }
141
193
 
@@ -173,6 +225,12 @@ const _pendingUpserts = new Map(); // id → summary row (latest wins)
173
225
  const _pendingRemovals = new Set(); // ids to drop (upsert/removal are mutually exclusive per id)
174
226
  let _summaryRetryTimer = null;
175
227
  let _summaryFlushScheduled = false;
228
+ let _summaryFlushInflight = 0;
229
+
230
+ export function _hasUnsettledSummaryOps() {
231
+ return _pendingUpserts.size > 0 || _pendingRemovals.size > 0
232
+ || _summaryFlushScheduled || _summaryFlushInflight > 0;
233
+ }
176
234
 
177
235
  function _scheduleSummaryRetry() {
178
236
  if (_summaryRetryTimer) return;
@@ -206,6 +264,7 @@ export function _flushPendingSummaryOps({ sync = false } = {}) {
206
264
  _pendingRemovals.clear();
207
265
  const mutate = (cur) => {
208
266
  const index = _normalizeSummaryIndex(cur);
267
+ const existingById = new Map(index.rows.map((row) => [row.id, row]));
209
268
  let changed = false;
210
269
  const rows = index.rows.filter((r) => {
211
270
  if (removals.has(r.id) || upserts.has(r.id)) { changed = true; return false; }
@@ -214,7 +273,7 @@ export function _flushPendingSummaryOps({ sync = false } = {}) {
214
273
  for (const row of upserts.values()) {
215
274
  const cleanRow = _normalizeSummaryRow(row);
216
275
  if (!cleanRow) continue;
217
- const existing = index.rows.find((r) => r.id === cleanRow.id) || null;
276
+ const existing = existingById.get(cleanRow.id) || null;
218
277
  if (existing && JSON.stringify(existing) === JSON.stringify(cleanRow)) {
219
278
  rows.push(existing);
220
279
  continue;
@@ -248,12 +307,18 @@ export function _flushPendingSummaryOps({ sync = false } = {}) {
248
307
  } catch { requeue(); }
249
308
  return;
250
309
  }
310
+ _summaryFlushInflight++;
251
311
  updateJsonAtomic(summaryIndexPath(), mutate, { compact: true, lock: true, timeoutMs: SUMMARY_LOCK_TIMEOUT_MS })
252
- .catch(() => { requeue(); });
312
+ .catch(() => { requeue(); })
313
+ .finally(() => { _summaryFlushInflight--; });
253
314
  }
254
315
 
255
316
  export function _upsertSessionSummary(session) {
256
317
  const row = _sessionSummary(session);
318
+ _upsertSessionSummaryRow(row);
319
+ }
320
+
321
+ export function _upsertSessionSummaryRow(row) {
257
322
  if (!row) return;
258
323
  _pendingRemovals.delete(row.id);
259
324
  _pendingUpserts.set(row.id, row);
@@ -20,6 +20,10 @@ const LEAD_OWNERS = new Set(['cli', 'user', 'mixdog', 'legacy']);
20
20
  function isLeadVisibleRow(row) {
21
21
  const owner = String(row.owner || 'user').trim().toLowerCase();
22
22
  if (owner && !LEAD_OWNERS.has(owner)) return false;
23
+ // Mirror listLeadSessions: a previewless zero-message row is an unusable
24
+ // scratch (desktop boot leftovers, crashed first turns) — resuming it
25
+ // shows an empty conversation, so the catalog hides it.
26
+ if (!row.preview && row.messageCount === 0) return false;
23
27
  const sourceType = String(row.sourceType || '').trim().toLowerCase();
24
28
  const sourceName = String(row.sourceName || '').trim().toLowerCase();
25
29
  const agent = String(row.agent || '').trim().toLowerCase();
@@ -45,6 +49,11 @@ function cleanText(value, maximum = 240) {
45
49
  return String(value || '')
46
50
  .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
47
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, ' ')
48
57
  .replace(/\s+/g, ' ')
49
58
  .trim()
50
59
  .slice(0, maximum);
@@ -101,6 +110,7 @@ function normalizedRow(row, heartbeatAt = 0) {
101
110
  preview: cleanText(row.preview),
102
111
  generation: typeof row.generation === 'number' ? row.generation : 0,
103
112
  implicitBashSessionId: row.implicitBashSessionId || null,
113
+ detachedReason: row.detachedReason || null,
104
114
  };
105
115
  }
106
116
 
@@ -108,6 +118,10 @@ function rowFromSession(session, heartbeatAt = 0) {
108
118
  const messages = Array.isArray(session?.messages) ? session.messages : [];
109
119
  const preview = messages
110
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)))
111
125
  .map((message) => cleanText(messageText(message.content)))
112
126
  .find(Boolean) || '';
113
127
  return normalizedRow({
@@ -34,6 +34,7 @@ import {
34
34
  _removeSessionSummary,
35
35
  _pruneSummaryIndexIds,
36
36
  _flushPendingSummaryOps,
37
+ _hasUnsettledSummaryOps,
37
38
  } from './store-summary-index.mjs';
38
39
  // Facade re-export: summary-index API moved to store-summary-index.mjs; keep
39
40
  // prior importers of store.mjs unchanged.
@@ -68,6 +69,9 @@ export { saveSessionAsync, saveSessionAsyncDeferred } from './store/save-worker.
68
69
 
69
70
  /** Module-level map tracking in-flight saves per session ID to prevent concurrent write corruption. */
70
71
  const _savePending = new Map();
72
+ // Disk mtime of the summary index at the last time it was read into (or
73
+ // refreshed as) the in-memory cache base — cross-process staleness detector.
74
+ let _summaryIndexMtimeSeen = 0;
71
75
 
72
76
 
73
77
  /**
@@ -836,7 +840,12 @@ function _overlayUnpersistedSummaryRows(rows, invalidStorageIds = new Set()) {
836
840
  function rebuildSessionSummaryIndex() {
837
841
  _ensureSummaryCacheDataDir();
838
842
  const { rows } = _scanStoredSessionSummaryRows();
839
- return _setSummaryRowsCache(_writeSummaryIndex(rows));
843
+ const indexedRows = _writeSummaryIndex(rows);
844
+ // This process just authored the new cache base. Remember its mtime so the
845
+ // next local save cannot mistake the rebuild for a cross-process update
846
+ // and replace a newer in-memory row with the just-obsoleted sidecar.
847
+ try { _summaryIndexMtimeSeen = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* stat only */ }
848
+ return _setSummaryRowsCache(indexedRows);
840
849
  }
841
850
 
842
851
  export function listStoredSessionSummaries(options = {}) {
@@ -867,7 +876,30 @@ export function listStoredSessionSummaries(options = {}) {
867
876
  return [];
868
877
  }
869
878
  }
870
- if (_summaryRowsCache !== null) return _cachedSummaryRows().slice();
879
+ if (_summaryRowsCache !== null) {
880
+ // A local session save has already updated the in-memory cache but its
881
+ // non-blocking sidecar merge may still be queued/in flight. Re-reading
882
+ // the older sidecar in that window would temporarily erase the new row.
883
+ if (_hasUnsettledSummaryOps()) return _cachedSummaryRows().slice();
884
+ // Cross-process freshness: another live process (terminal CLI owning a
885
+ // session this surface only views) advances messageCount/updatedAt by
886
+ // rewriting the summary index FILE — an in-memory cache that never
887
+ // looks back at disk serves frozen rows forever (user: the unread dot
888
+ // never fired for terminal-owned growth). One stat per call; when the
889
+ // index advanced, re-read the cheap index JSON as the new cache base
890
+ // (local optimistic overlays stay applied on top).
891
+ let diskMtime = 0;
892
+ try { diskMtime = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* no index yet */ }
893
+ if (diskMtime <= _summaryIndexMtimeSeen) return _cachedSummaryRows().slice();
894
+ try {
895
+ const raw = JSON.parse(readFileSync(summaryIndexPath(), 'utf-8'));
896
+ if (Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION) {
897
+ _summaryIndexMtimeSeen = diskMtime;
898
+ return _setSummaryRowsCache(_normalizeSummaryIndex(raw).rows).slice();
899
+ }
900
+ } catch { /* torn concurrent write — keep serving the cache; retry next call */ }
901
+ return _cachedSummaryRows().slice();
902
+ }
871
903
 
872
904
  let indexedRows = [];
873
905
  let p;
@@ -879,6 +911,9 @@ export function listStoredSessionSummaries(options = {}) {
879
911
  const raw = JSON.parse(readFileSync(p, 'utf-8'));
880
912
  hasIndex = Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION;
881
913
  if (hasIndex) indexedRows = _normalizeSummaryIndex(raw).rows;
914
+ if (hasIndex) {
915
+ try { _summaryIndexMtimeSeen = statSync(p).mtimeMs || 0; } catch { /* stat only */ }
916
+ }
882
917
  }
883
918
  } catch { /* unreadable/malformed sidecar falls through to rebuild */ }
884
919
 
@@ -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
+ }