mixdog 0.9.66 → 0.9.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -0
- package/src/runtime/channels/lib/config.mjs +7 -0
- package/src/runtime/channels/lib/status-snapshot.mjs +6 -30
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +203 -0
- package/src/runtime/channels/lib/webhook.mjs +23 -201
- package/src/runtime/shared/config.mjs +0 -9
- package/src/runtime/shared/time-format.mjs +56 -0
- package/src/runtime/shared/tool-card-model.mjs +740 -0
- package/src/session-runtime/channel-config-api.mjs +5 -0
- package/src/session-runtime/lifecycle-api.mjs +5 -0
- package/src/session-runtime/prewarm.mjs +13 -7
- package/src/session-runtime/runtime-core.mjs +28 -9
- package/src/session-runtime/session-text.mjs +6 -0
- package/src/session-runtime/settings-api.mjs +0 -12
- package/src/standalone/channel-admin.mjs +35 -21
- package/src/tui/App.jsx +0 -15
- package/src/tui/app/channel-pickers.mjs +18 -42
- package/src/tui/app/doctor.mjs +0 -1
- package/src/tui/components/ToolExecution.jsx +47 -196
- package/src/tui/components/tool-execution/surface-detail.mjs +33 -394
- package/src/tui/components/tool-execution/text-format.mjs +27 -104
- package/src/tui/dist/index.mjs +306 -185
- package/src/tui/engine/live-share.mjs +9 -0
- package/src/tui/engine/session-api-ext.mjs +0 -10
- package/src/tui/time-format.mjs +4 -51
- package/src/runtime/channels/lib/webhook/ngrok.mjs +0 -181
|
@@ -402,6 +402,7 @@ export function createLiveShare({
|
|
|
402
402
|
const stopClient = () => {
|
|
403
403
|
const closing = client;
|
|
404
404
|
const closingId = clientId;
|
|
405
|
+
const wasUp = clientUp;
|
|
405
406
|
client = null;
|
|
406
407
|
clientId = '';
|
|
407
408
|
clientUp = false;
|
|
@@ -410,6 +411,14 @@ export function createLiveShare({
|
|
|
410
411
|
if (closing) {
|
|
411
412
|
try { closing.destroy(); } catch { /* already gone */ }
|
|
412
413
|
}
|
|
414
|
+
// Deliberate teardown (session switch / role change / dispose) destroys
|
|
415
|
+
// the socket AFTER clientUp is already false, so the socket's close
|
|
416
|
+
// handler sees wasUp=false and never clears the mirror. Clear it here:
|
|
417
|
+
// otherwise the owner's mirrored busy/spinner/queue leaks into the next
|
|
418
|
+
// resumed session as a frozen working indicator (user report: finished
|
|
419
|
+
// session shows a spinner + stop button after switching away from a
|
|
420
|
+
// busy live-attached session).
|
|
421
|
+
if (wasUp) clearMirroredLiveState();
|
|
413
422
|
};
|
|
414
423
|
|
|
415
424
|
const startClient = (id) => {
|
|
@@ -633,16 +633,6 @@ export function createEngineApiB(bag) {
|
|
|
633
633
|
pushNotice('telegram token forgotten', 'info');
|
|
634
634
|
return result;
|
|
635
635
|
},
|
|
636
|
-
saveWebhookAuthtoken: (token) => {
|
|
637
|
-
const result = runtime.saveWebhookAuthtoken(token);
|
|
638
|
-
pushNotice('webhook/ngrok authtoken saved', 'info');
|
|
639
|
-
return result;
|
|
640
|
-
},
|
|
641
|
-
forgetWebhookAuthtoken: () => {
|
|
642
|
-
const result = runtime.forgetWebhookAuthtoken();
|
|
643
|
-
pushNotice('webhook/ngrok authtoken forgotten', 'info');
|
|
644
|
-
return result;
|
|
645
|
-
},
|
|
646
636
|
setChannel: async (entry) => {
|
|
647
637
|
const result = await runtime.setChannel(entry);
|
|
648
638
|
pushNotice('channel saved', 'info');
|
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';
|
|
@@ -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
|
-
};
|