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.
- package/package.json +1 -1
- package/src/app.mjs +2 -2
- package/src/cli.mjs +1 -1
- package/src/repl.mjs +72 -6
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +83 -18
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
- 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/context-status.mjs +2 -1
- package/src/session-runtime/lifecycle-api.mjs +5 -0
- package/src/session-runtime/prewarm.mjs +13 -7
- package/src/session-runtime/provider-request-tools.mjs +6 -0
- 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/session-runtime/tool-catalog-schema.mjs +5 -1
- 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/app/transcript-window.mjs +16 -0
- package/src/tui/app/use-transcript-window.mjs +36 -1
- package/src/tui/components/Markdown.jsx +15 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +7 -0
- 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 +613 -264
- package/src/tui/engine/frame-batched-store.mjs +5 -3
- package/src/tui/engine/live-share.mjs +9 -0
- package/src/tui/engine/render-timing.mjs +28 -0
- package/src/tui/engine/session-api-ext.mjs +7 -10
- package/src/tui/engine/tui-steering-persist.mjs +25 -4
- package/src/tui/engine.mjs +9 -1
- package/src/tui/index.jsx +11 -3
- package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
- package/src/tui/markdown/render-ansi.mjs +34 -1
- package/src/tui/markdown/stream-fence.mjs +44 -7
- package/src/tui/markdown/streaming-markdown.mjs +95 -27
- package/src/tui/time-format.mjs +4 -51
- package/src/ui/stream-finalize.mjs +45 -0
- 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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:
|
|
105
|
-
preview:
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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
|
+
}
|