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
|
@@ -4,10 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { marked } from 'marked';
|
|
6
6
|
import { configureMarked, hasMarkdownSyntax } from './render-ansi.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
trimPartialClosingFences,
|
|
9
|
+
findOpenFenceStart,
|
|
10
|
+
resetAllOpenFenceScans,
|
|
11
|
+
resetOpenFenceScan,
|
|
12
|
+
} from './stream-fence.mjs';
|
|
8
13
|
import { displayWidth } from '../display-width.mjs';
|
|
9
14
|
|
|
10
15
|
const stablePrefixByStreamKey = new Map();
|
|
16
|
+
const markdownSyntaxByStreamKey = new Map();
|
|
17
|
+
const plainWindowSyntaxByStreamKey = new Map();
|
|
11
18
|
// Reuse the current normalized-text split across measure → render → harvest.
|
|
12
19
|
const resolvedPartsByStreamKey = new Map();
|
|
13
20
|
const STABLE_PREFIX_LRU_MAX = 32;
|
|
@@ -17,48 +24,92 @@ export function streamingLayoutText(text) {
|
|
|
17
24
|
return String(text ?? '').replace(/^\n+|\n+$/g, '');
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
export function windowPlainStreamingText(text, columns, maxRows) {
|
|
27
|
+
export function windowPlainStreamingText(text, columns, maxRows, streamKey = null) {
|
|
21
28
|
const value = streamingLayoutText(text);
|
|
22
29
|
const rowBudget = Math.max(0, Math.floor(Number(maxRows) || 0));
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
const key = streamKey == null || streamKey === '' ? null : String(streamKey);
|
|
31
|
+
if (!value || rowBudget <= 0 || cachedStreamingHasMarkdownSyntax(
|
|
32
|
+
plainWindowSyntaxByStreamKey,
|
|
33
|
+
value,
|
|
34
|
+
key,
|
|
35
|
+
)) return value;
|
|
25
36
|
const width = Math.max(1, Math.floor(Number(columns) || 80));
|
|
26
37
|
let rows = 0;
|
|
27
|
-
let
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
let end = value.length;
|
|
39
|
+
let start = end;
|
|
40
|
+
while (end >= 0) {
|
|
41
|
+
const newline = value.lastIndexOf('\n', end - 1);
|
|
42
|
+
const lineStart = newline + 1;
|
|
43
|
+
const line = value.slice(lineStart, end);
|
|
30
44
|
const lineRows = Math.max(1, Math.ceil(displayWidth(line) / width));
|
|
31
45
|
if (rows > 0 && rows + lineRows > rowBudget) break;
|
|
32
46
|
rows += lineRows;
|
|
33
|
-
start
|
|
34
|
-
if (rows >= rowBudget) break;
|
|
47
|
+
start = lineStart;
|
|
48
|
+
if (rows >= rowBudget || newline < 0) break;
|
|
49
|
+
end = newline;
|
|
35
50
|
}
|
|
36
|
-
return start > 0 ?
|
|
51
|
+
return start > 0 ? value.slice(start) : value;
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
function isWhitespaceOnlyText(text) {
|
|
40
55
|
return !String(text ?? '').trim();
|
|
41
56
|
}
|
|
42
57
|
|
|
43
|
-
function
|
|
58
|
+
function touchLruKey(cache, key, value) {
|
|
44
59
|
if (!key) return;
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
while (
|
|
48
|
-
const oldest =
|
|
60
|
+
if (cache.has(key)) cache.delete(key);
|
|
61
|
+
cache.set(key, value);
|
|
62
|
+
while (cache.size > STABLE_PREFIX_LRU_MAX) {
|
|
63
|
+
const oldest = cache.keys().next().value;
|
|
49
64
|
if (oldest === undefined) break;
|
|
50
|
-
|
|
65
|
+
cache.delete(oldest);
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
|
|
69
|
+
function touchStablePrefixKey(key, value) {
|
|
70
|
+
touchLruKey(stablePrefixByStreamKey, key, value);
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
function getStablePrefixKey(key) {
|
|
55
|
-
if (!key || !stablePrefixByStreamKey.has(key)) return '';
|
|
74
|
+
if (!key || !stablePrefixByStreamKey.has(key)) return { text: '', chunks: [] };
|
|
56
75
|
const value = stablePrefixByStreamKey.get(key);
|
|
57
|
-
|
|
58
|
-
stablePrefixByStreamKey.set(key, value);
|
|
76
|
+
touchStablePrefixKey(key, value);
|
|
59
77
|
return value;
|
|
60
78
|
}
|
|
61
79
|
|
|
80
|
+
function stableStateForText(text, previous) {
|
|
81
|
+
if (!text) return { text: '', chunks: [] };
|
|
82
|
+
if (text.startsWith(previous.text)) {
|
|
83
|
+
const appended = text.substring(previous.text.length);
|
|
84
|
+
return appended
|
|
85
|
+
? { text, chunks: [...previous.chunks, appended] }
|
|
86
|
+
: previous;
|
|
87
|
+
}
|
|
88
|
+
return { text, chunks: [text] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cachedStreamingHasMarkdownSyntax(cache, text, key) {
|
|
92
|
+
if (!key) return hasMarkdownSyntax(text);
|
|
93
|
+
const previous = cache.get(key);
|
|
94
|
+
let value;
|
|
95
|
+
if (previous && text.startsWith(previous.text)) {
|
|
96
|
+
if (previous.value) {
|
|
97
|
+
value = true;
|
|
98
|
+
} else {
|
|
99
|
+
const lineStart = previous.text.lastIndexOf('\n') + 1;
|
|
100
|
+
value = hasMarkdownSyntax(text.substring(Math.max(0, lineStart - 1)));
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
value = hasMarkdownSyntax(text);
|
|
104
|
+
}
|
|
105
|
+
touchLruKey(cache, key, { text, value });
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function streamingHasMarkdownSyntax(text, key) {
|
|
110
|
+
return cachedStreamingHasMarkdownSyntax(markdownSyntaxByStreamKey, text, key);
|
|
111
|
+
}
|
|
112
|
+
|
|
62
113
|
function getResolvedPartsKey(key, text) {
|
|
63
114
|
if (!key) return null;
|
|
64
115
|
const entry = resolvedPartsByStreamKey.get(key);
|
|
@@ -136,12 +187,18 @@ export function resetStreamingMarkdownStablePrefix(streamKey) {
|
|
|
136
187
|
if (streamKey == null || streamKey === '') return;
|
|
137
188
|
const key = String(streamKey);
|
|
138
189
|
stablePrefixByStreamKey.delete(key);
|
|
190
|
+
markdownSyntaxByStreamKey.delete(key);
|
|
191
|
+
plainWindowSyntaxByStreamKey.delete(key);
|
|
139
192
|
resolvedPartsByStreamKey.delete(key);
|
|
193
|
+
resetOpenFenceScan(key);
|
|
140
194
|
}
|
|
141
195
|
|
|
142
196
|
export function resetAllStreamingMarkdownStablePrefixes() {
|
|
143
197
|
stablePrefixByStreamKey.clear();
|
|
198
|
+
markdownSyntaxByStreamKey.clear();
|
|
199
|
+
plainWindowSyntaxByStreamKey.clear();
|
|
144
200
|
resolvedPartsByStreamKey.clear();
|
|
201
|
+
resetAllOpenFenceScans();
|
|
145
202
|
}
|
|
146
203
|
|
|
147
204
|
export function resolveStreamingMarkdownParts(text, streamKey) {
|
|
@@ -155,42 +212,47 @@ export function resolveStreamingMarkdownParts(text, streamKey) {
|
|
|
155
212
|
return cacheResolvedPartsKey(key, t, {
|
|
156
213
|
plain: true,
|
|
157
214
|
stablePrefix: '',
|
|
215
|
+
stableChunks: [],
|
|
158
216
|
unstableSuffix: '',
|
|
159
217
|
unstableForRender: '',
|
|
160
218
|
});
|
|
161
219
|
}
|
|
162
220
|
|
|
163
|
-
if (!
|
|
221
|
+
if (!streamingHasMarkdownSyntax(t, key)) {
|
|
164
222
|
if (key) stablePrefixByStreamKey.delete(key);
|
|
165
223
|
return cacheResolvedPartsKey(key, t, {
|
|
166
224
|
plain: true,
|
|
167
225
|
stablePrefix: '',
|
|
226
|
+
stableChunks: [],
|
|
168
227
|
unstableSuffix: t,
|
|
169
228
|
unstableForRender: t,
|
|
170
229
|
});
|
|
171
230
|
}
|
|
172
231
|
|
|
173
|
-
let
|
|
174
|
-
if (!t.startsWith(
|
|
175
|
-
|
|
232
|
+
let stableState = key ? getStablePrefixKey(key) : { text: '', chunks: [] };
|
|
233
|
+
if (!t.startsWith(stableState.text)) {
|
|
234
|
+
stableState = { text: '', chunks: [] };
|
|
176
235
|
}
|
|
236
|
+
let stablePrefix = stableState.text;
|
|
177
237
|
|
|
178
238
|
// Open-fence fast path: never run marked.lexer on a growing unclosed code
|
|
179
239
|
// block (its closing-fence regex never matches and backtracks over the whole
|
|
180
240
|
// body every delta — the ~56ms/frame cost). Split cheaply at the fence line:
|
|
181
241
|
// everything before it is settled markdown (lexed + cached once by the stable
|
|
182
242
|
// <Markdown>), the open block is rendered flat until the closing fence lands.
|
|
183
|
-
const open = findOpenFenceStart(t);
|
|
243
|
+
const open = findOpenFenceStart(t, key);
|
|
184
244
|
if (open) {
|
|
185
245
|
let openPrefix = t.substring(0, open.index);
|
|
186
246
|
if (isWhitespaceOnlyText(openPrefix)) openPrefix = '';
|
|
187
|
-
|
|
247
|
+
stableState = stableStateForText(openPrefix, stableState);
|
|
248
|
+
if (key && openPrefix) touchStablePrefixKey(key, stableState);
|
|
188
249
|
else if (key) stablePrefixByStreamKey.delete(key);
|
|
189
250
|
const unstableSuffix = t.substring(openPrefix.length);
|
|
190
251
|
return cacheResolvedPartsKey(key, t, {
|
|
191
252
|
plain: false,
|
|
192
253
|
openFence: true,
|
|
193
254
|
stablePrefix: openPrefix,
|
|
255
|
+
stableChunks: stableState.chunks,
|
|
194
256
|
unstableSuffix,
|
|
195
257
|
unstableForRender: unstableSuffix,
|
|
196
258
|
});
|
|
@@ -214,20 +276,26 @@ export function resolveStreamingMarkdownParts(text, streamKey) {
|
|
|
214
276
|
if (advance > 0) {
|
|
215
277
|
stablePrefix = t.substring(0, boundary + advance);
|
|
216
278
|
if (isWhitespaceOnlyText(stablePrefix)) stablePrefix = '';
|
|
217
|
-
|
|
279
|
+
stableState = stableStateForText(stablePrefix, stableState);
|
|
280
|
+
if (key && stablePrefix) touchStablePrefixKey(key, stableState);
|
|
218
281
|
else if (key && !stablePrefix) stablePrefixByStreamKey.delete(key);
|
|
219
282
|
}
|
|
220
283
|
} catch {
|
|
221
284
|
stablePrefix = '';
|
|
285
|
+
stableState = { text: '', chunks: [] };
|
|
222
286
|
if (key) stablePrefixByStreamKey.delete(key);
|
|
223
287
|
}
|
|
224
288
|
|
|
225
|
-
if (isWhitespaceOnlyText(stablePrefix))
|
|
289
|
+
if (isWhitespaceOnlyText(stablePrefix)) {
|
|
290
|
+
stablePrefix = '';
|
|
291
|
+
stableState = { text: '', chunks: [] };
|
|
292
|
+
}
|
|
226
293
|
|
|
227
294
|
const unstableSuffix = t.substring(stablePrefix.length);
|
|
228
295
|
return cacheResolvedPartsKey(key, t, {
|
|
229
296
|
plain: false,
|
|
230
297
|
stablePrefix,
|
|
298
|
+
stableChunks: stableState.chunks,
|
|
231
299
|
unstableSuffix,
|
|
232
300
|
unstableForRender: balanceStreamingMarkdown(unstableSuffix),
|
|
233
301
|
});
|
package/src/tui/time-format.mjs
CHANGED
|
@@ -1,53 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TUI duration formatting
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* 42s
|
|
6
|
-
* 9m 23s
|
|
7
|
-
* 1h 2m 3s
|
|
8
|
-
* 1d 3h 20m
|
|
2
|
+
* TUI duration formatting — moved to runtime/shared/time-format.mjs so the
|
|
3
|
+
* desktop tool cards share the exact same elapsed labels. This module remains
|
|
4
|
+
* as the TUI-facing import path.
|
|
9
5
|
*/
|
|
10
|
-
export
|
|
11
|
-
if (!Number.isFinite(Number(ms))) return '';
|
|
12
|
-
const value = Math.max(0, Number(ms) || 0);
|
|
13
|
-
if (value < 60_000) {
|
|
14
|
-
if (value < 1_000) return '';
|
|
15
|
-
return `${Math.floor(value / 1000)}s`;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
let days = Math.floor(value / 86_400_000);
|
|
19
|
-
let hours = Math.floor((value % 86_400_000) / 3_600_000);
|
|
20
|
-
let minutes = Math.floor((value % 3_600_000) / 60_000);
|
|
21
|
-
const seconds = Math.floor((value % 60_000) / 1000);
|
|
22
|
-
|
|
23
|
-
if (options.mostSignificantOnly) {
|
|
24
|
-
if (days > 0) return `${days}d`;
|
|
25
|
-
if (hours > 0) return `${hours}h`;
|
|
26
|
-
if (minutes > 0) return `${minutes}m`;
|
|
27
|
-
return `${seconds}s`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const hide = options.hideTrailingZeros;
|
|
31
|
-
if (days > 0) {
|
|
32
|
-
if (hide && hours === 0 && minutes === 0) return `${days}d`;
|
|
33
|
-
if (hide && minutes === 0) return `${days}d ${hours}h`;
|
|
34
|
-
return `${days}d ${hours}h ${minutes}m`;
|
|
35
|
-
}
|
|
36
|
-
if (hours > 0) {
|
|
37
|
-
if (hide && minutes === 0 && seconds === 0) return `${hours}h`;
|
|
38
|
-
if (hide && seconds === 0) return `${hours}h ${minutes}m`;
|
|
39
|
-
return `${hours}h ${minutes}m ${seconds}s`;
|
|
40
|
-
}
|
|
41
|
-
if (minutes > 0) {
|
|
42
|
-
if (hide && seconds === 0) return `${minutes}m`;
|
|
43
|
-
return `${minutes}m ${seconds}s`;
|
|
44
|
-
}
|
|
45
|
-
return `${seconds}s`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function formatElapsed(ms) {
|
|
49
|
-
const n = Math.max(0, Number(ms || 0));
|
|
50
|
-
if (!Number.isFinite(n) || n <= 0) return '';
|
|
51
|
-
if (n < 1000) return '';
|
|
52
|
-
return formatDuration(n);
|
|
53
|
-
}
|
|
6
|
+
export { formatDuration, formatElapsed } from '../runtime/shared/time-format.mjs';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { visibleWidth } from './ansi.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a conservative in-place final-format patch for a streamed terminal
|
|
5
|
+
* block. Logical rows must map 1:1 to physical rows; wrapped or structurally
|
|
6
|
+
* changed output returns null so the caller can use the full-redraw fallback.
|
|
7
|
+
*/
|
|
8
|
+
export function buildStreamFinalPatch(rawText, renderedText, { columns = 80 } = {}) {
|
|
9
|
+
const raw = String(rawText ?? '');
|
|
10
|
+
const rendered = String(renderedText ?? '');
|
|
11
|
+
if (raw.includes('\r') || rendered.includes('\r')) return null;
|
|
12
|
+
const rawLines = raw.split('\n');
|
|
13
|
+
const renderedLines = rendered.split('\n');
|
|
14
|
+
if (rawLines.length !== renderedLines.length) return null;
|
|
15
|
+
|
|
16
|
+
const width = Math.max(1, Math.floor(Number(columns) || 80));
|
|
17
|
+
for (let index = 0; index < rawLines.length; index += 1) {
|
|
18
|
+
if (visibleWidth(rawLines[index]) >= width || visibleWidth(renderedLines[index]) >= width) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const changedRows = [];
|
|
24
|
+
for (let index = 0; index < rawLines.length; index += 1) {
|
|
25
|
+
if (rawLines[index] !== renderedLines[index]) changedRows.push(index);
|
|
26
|
+
}
|
|
27
|
+
if (changedRows.length === 0) {
|
|
28
|
+
return { output: '', changedRows: 0, totalRows: rawLines.length };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let output = '\r';
|
|
32
|
+
for (let index = rawLines.length - 1; index >= 0; index -= 1) {
|
|
33
|
+
if (rawLines[index] !== renderedLines[index]) {
|
|
34
|
+
output += `\x1b[2K${renderedLines[index]}`;
|
|
35
|
+
}
|
|
36
|
+
if (index > 0) output += '\r\x1b[1A';
|
|
37
|
+
}
|
|
38
|
+
if (rawLines.length > 1) {
|
|
39
|
+
output += `\r\x1b[${rawLines.length - 1}B`;
|
|
40
|
+
const lastWidth = visibleWidth(renderedLines.at(-1) || '');
|
|
41
|
+
if (lastWidth > 0) output += `\x1b[${lastWidth}C`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { output, changedRows: changedRows.length, totalRows: rawLines.length };
|
|
45
|
+
}
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import * as http from "http";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import { spawnSync } from "child_process";
|
|
4
|
-
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "fs";
|
|
5
|
-
import { DATA_DIR } from "../config.mjs";
|
|
6
|
-
import { logWebhook } from "./log.mjs";
|
|
7
|
-
|
|
8
|
-
function _readNgrokBinFromRegistry() {
|
|
9
|
-
if (process.platform !== "win32") return null;
|
|
10
|
-
try {
|
|
11
|
-
const r = spawnSync("reg", ["query", "HKCU\\Environment", "/v", "NGROK_BIN"], {
|
|
12
|
-
encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"],
|
|
13
|
-
});
|
|
14
|
-
if (r.status === 0 && r.stdout) {
|
|
15
|
-
const m = r.stdout.match(/NGROK_BIN\s+REG_(?:EXPAND_)?SZ\s+(.+?)\r?\n/);
|
|
16
|
-
if (m && m[1]) return m[1].trim();
|
|
17
|
-
}
|
|
18
|
-
} catch { /* missing reg.exe is non-fatal */ }
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
function resolveNgrokBin() {
|
|
22
|
-
// Invariant on Windows: BOTH process.env.NGROK_BIN AND HKCU\Environment\NGROK_BIN
|
|
23
|
-
// are candidate sources. process.env is the shell-start snapshot; registry
|
|
24
|
-
// is the live user definition. Each candidate is tried in order and the
|
|
25
|
-
// first that resolves to an existing file wins. This recovers two distinct
|
|
26
|
-
// post-setx cases without a host-agent restart:
|
|
27
|
-
// (a) env unset, registry set — fresh install + setx after process start
|
|
28
|
-
// (b) env set to stale old path, registry set to new — user moved or
|
|
29
|
-
// re-installed ngrok and setx'd the new path; the old env value would
|
|
30
|
-
// otherwise dead-end at existsSync=false.
|
|
31
|
-
// POSIX has no registry; process.env is the sole candidate.
|
|
32
|
-
const candidates = [];
|
|
33
|
-
if (process.env.NGROK_BIN) candidates.push(process.env.NGROK_BIN);
|
|
34
|
-
const fromReg = _readNgrokBinFromRegistry();
|
|
35
|
-
if (fromReg && !candidates.includes(fromReg)) candidates.push(fromReg);
|
|
36
|
-
for (const p of candidates) {
|
|
37
|
-
if (existsSync(p)) return p;
|
|
38
|
-
}
|
|
39
|
-
if (candidates.length > 0) {
|
|
40
|
-
throw new Error(`NGROK_BIN candidates (${candidates.join(", ")}) do not exist on disk. Set NGROK_BIN to the correct ngrok binary path.`);
|
|
41
|
-
}
|
|
42
|
-
throw new Error('NGROK_BIN env var is not set. Set NGROK_BIN to the path of the ngrok binary (e.g. NGROK_BIN=/usr/local/bin/ngrok).');
|
|
43
|
-
}
|
|
44
|
-
const NGROK_META_FILE = join(DATA_DIR, "ngrok-meta.json");
|
|
45
|
-
const NGROK_OLD_PID_FILE = join(DATA_DIR, "ngrok.pid");
|
|
46
|
-
const NGROK_MAX_AGE_MS = 24 * 60 * 60 * 1e3; // 24 hours
|
|
47
|
-
|
|
48
|
-
function normalizeDomain(d) {
|
|
49
|
-
if (!d) return '';
|
|
50
|
-
const url = new URL(d.includes('://') ? d : 'https://' + d);
|
|
51
|
-
if (!url.hostname) throw new Error(`[webhook] invalid host: ${d}`);
|
|
52
|
-
return url.hostname.toLowerCase();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function readNgrokMeta() {
|
|
56
|
-
try { return JSON.parse(readFileSync(NGROK_META_FILE, 'utf8')) } catch {}
|
|
57
|
-
// Migration: read old pid file if meta doesn't exist
|
|
58
|
-
try {
|
|
59
|
-
const pid = parseInt(readFileSync(NGROK_OLD_PID_FILE, 'utf8').trim());
|
|
60
|
-
if (pid > 0) {
|
|
61
|
-
logWebhook(`migrating ngrok.pid (PID ${pid}) to ngrok-meta.json`);
|
|
62
|
-
const meta = { pid, domain: '', port: 0, startedAt: new Date().toISOString() };
|
|
63
|
-
writeNgrokMeta(meta);
|
|
64
|
-
try { unlinkSync(NGROK_OLD_PID_FILE) } catch {}
|
|
65
|
-
return meta;
|
|
66
|
-
}
|
|
67
|
-
} catch {}
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
function writeNgrokMeta(meta) {
|
|
71
|
-
try { writeFileSync(NGROK_META_FILE, JSON.stringify(meta, null, 2)) } catch {}
|
|
72
|
-
}
|
|
73
|
-
function clearNgrokMeta() {
|
|
74
|
-
try { unlinkSync(NGROK_META_FILE) } catch {}
|
|
75
|
-
}
|
|
76
|
-
// Recycled-PID guard: a stale ngrok-meta.json may name a PID that ngrok
|
|
77
|
-
// long ago freed and the OS reassigned to an unrelated process (commonly
|
|
78
|
-
// another mixdog server). Verify the PID is actually an ngrok process
|
|
79
|
-
// before sending a kill signal, so a live peer's server is never taken
|
|
80
|
-
// down. Returns false (skip kill) when the check is inconclusive.
|
|
81
|
-
function isLikelyNgrok(pid) {
|
|
82
|
-
if (!pid || pid <= 0) return false;
|
|
83
|
-
try {
|
|
84
|
-
if (process.platform === "win32") {
|
|
85
|
-
const r = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
86
|
-
return /ngrok/i.test(r.stdout || "");
|
|
87
|
-
}
|
|
88
|
-
const r = spawnSync("ps", ["-p", String(pid), "-o", "comm="], { encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
89
|
-
return /ngrok/i.test(r.stdout || "");
|
|
90
|
-
} catch { return false; }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function isProcessAlive(pid) {
|
|
94
|
-
if (!pid || pid <= 0) return false;
|
|
95
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Strict PID extraction: the first non-empty output line must be decimal-only.
|
|
99
|
-
// `123junk` / any non-numeric noise → null, so a malformed shell result can
|
|
100
|
-
// never be coerced into a real PID we might later signal or kill.
|
|
101
|
-
function parseStrictPidLine(out) {
|
|
102
|
-
const line = String(out || "").split(/\r?\n/).map((s) => s.trim()).find((s) => s.length > 0);
|
|
103
|
-
if (!line || !/^\d+$/.test(line)) return null;
|
|
104
|
-
const n = parseInt(line, 10);
|
|
105
|
-
return Number.isFinite(n) && n > 0 ? n : null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function resolvePortOwnerPid(port) {
|
|
109
|
-
// Coerce + range-validate the port BEFORE any spawn so it can never inject
|
|
110
|
-
// into a command, and use spawnSync argv (no shell) for defense in depth.
|
|
111
|
-
// Invalid port → null (treated as "no owner", never an exec).
|
|
112
|
-
const p = Number(port);
|
|
113
|
-
if (!Number.isInteger(p) || p < 1 || p > 65535) return null;
|
|
114
|
-
try {
|
|
115
|
-
if (process.platform === "win32") {
|
|
116
|
-
const r = spawnSync(
|
|
117
|
-
"powershell",
|
|
118
|
-
["-NoProfile", "-Command", `(Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess`],
|
|
119
|
-
{ encoding: "utf8", timeout: 3000, windowsHide: true },
|
|
120
|
-
);
|
|
121
|
-
return r.status === 0 ? parseStrictPidLine(r.stdout) : null;
|
|
122
|
-
}
|
|
123
|
-
const r = spawnSync("lsof", ["-ti", `:${p}`, "-sTCP:LISTEN"], { encoding: "utf8", timeout: 3000, windowsHide: true });
|
|
124
|
-
return r.status === 0 ? parseStrictPidLine(r.stdout) : null;
|
|
125
|
-
} catch {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function handleWebhookPortInUse(basePort, expectedDomain) {
|
|
131
|
-
const ownerPid = resolvePortOwnerPid(basePort);
|
|
132
|
-
const ownerAlive = ownerPid != null && isProcessAlive(ownerPid);
|
|
133
|
-
const ownerIsNgrok = ownerAlive && isLikelyNgrok(ownerPid);
|
|
134
|
-
logWebhook(
|
|
135
|
-
`port ${basePort} EADDRINUSE — not reclaiming external PID ${ownerPid ?? "unknown"} (alive=${ownerAlive}, ngrok=${ownerIsNgrok}); trying next port`,
|
|
136
|
-
);
|
|
137
|
-
return { ok: false, bump: true, ownerPid };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function checkNgrokHealth(expectedDomain, expectedPort = null) {
|
|
141
|
-
try {
|
|
142
|
-
return new Promise((resolve) => {
|
|
143
|
-
const req = http.get("http://localhost:4040/api/tunnels", { timeout: 2000 }, (res) => {
|
|
144
|
-
let data = "";
|
|
145
|
-
res.on("data", chunk => data += chunk);
|
|
146
|
-
res.on("end", () => {
|
|
147
|
-
try {
|
|
148
|
-
const tunnels = JSON.parse(data).tunnels || [];
|
|
149
|
-
const expected = normalizeDomain(expectedDomain);
|
|
150
|
-
const match = tunnels.some(t => {
|
|
151
|
-
if (normalizeDomain(t.public_url) !== expected) return false;
|
|
152
|
-
if (!expectedPort) return true;
|
|
153
|
-
const addr = String(t.config?.addr || '');
|
|
154
|
-
return addr === `http://localhost:${expectedPort}`
|
|
155
|
-
|| addr === `https://localhost:${expectedPort}`
|
|
156
|
-
|| addr.endsWith(`:${expectedPort}`);
|
|
157
|
-
});
|
|
158
|
-
resolve(match);
|
|
159
|
-
} catch { resolve(false); }
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
req.on("error", () => resolve(false));
|
|
163
|
-
req.on("timeout", () => { req.destroy(); resolve(false); });
|
|
164
|
-
});
|
|
165
|
-
} catch { return Promise.resolve(false); }
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export {
|
|
169
|
-
NGROK_MAX_AGE_MS,
|
|
170
|
-
resolveNgrokBin,
|
|
171
|
-
normalizeDomain,
|
|
172
|
-
readNgrokMeta,
|
|
173
|
-
writeNgrokMeta,
|
|
174
|
-
clearNgrokMeta,
|
|
175
|
-
isLikelyNgrok,
|
|
176
|
-
isProcessAlive,
|
|
177
|
-
parseStrictPidLine,
|
|
178
|
-
resolvePortOwnerPid,
|
|
179
|
-
handleWebhookPortInUse,
|
|
180
|
-
checkNgrokHealth,
|
|
181
|
-
};
|