mixdog 0.9.20 → 0.9.22
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/README.md +14 -12
- package/package.json +1 -1
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +10 -10
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +23 -13
- package/src/runtime/channels/lib/owned-runtime.mjs +95 -15
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/worker-main.mjs +7 -1
- package/src/runtime/memory/index.mjs +90 -0
- package/src/runtime/memory/lib/http-router.mjs +39 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/shared/atomic-file.mjs +161 -85
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/runtime-core.mjs +75 -17
- package/src/standalone/channel-worker.mjs +63 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +98 -11
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +1 -0
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/dist/index.mjs +314 -74
- package/src/tui/engine/session-api.mjs +19 -0
|
@@ -252,6 +252,13 @@ export function createStandaloneChannelWorker({
|
|
|
252
252
|
MIXDOG_STANDALONE: '1',
|
|
253
253
|
MIXDOG_WORKER_MODE: '1',
|
|
254
254
|
MIXDOG_CLI_OWNED: '0',
|
|
255
|
+
// Preserve the real terminal-lead PID (host TUI, or an outer run-mcp
|
|
256
|
+
// supervisor if one injected MIXDOG_SUPERVISOR_PID) through the fork.
|
|
257
|
+
// Without this the worker resolves getTerminalLeadPid() to its OWN pid,
|
|
258
|
+
// so the seat's ownerLeadPid tracks the headless worker instead of the
|
|
259
|
+
// terminal that owns it — and the seat can never be evicted when the
|
|
260
|
+
// owning terminal/TUI dies while the worker stays alive.
|
|
261
|
+
MIXDOG_SUPERVISOR_PID: process.env.MIXDOG_SUPERVISOR_PID || String(process.pid),
|
|
255
262
|
MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
|
|
256
263
|
},
|
|
257
264
|
windowsHide: true,
|
|
@@ -416,6 +423,12 @@ export function createStandaloneChannelWorker({
|
|
|
416
423
|
|
|
417
424
|
function uninstallParentExitHook() {
|
|
418
425
|
if (!parentExitCleanup) return;
|
|
426
|
+
// Refcount-aware: never strip the shared parent-exit protection while any
|
|
427
|
+
// owned child PID is still tracked. A newer worker may have been spawned
|
|
428
|
+
// (new PID added, hook install is a no-op because it is already present)
|
|
429
|
+
// before an older worker finishes its async teardown; letting that old
|
|
430
|
+
// teardown uninstall here would leave the live newer PID unprotected.
|
|
431
|
+
if (ownedChildPids.size > 0) return;
|
|
419
432
|
const unregister = parentExitCleanup;
|
|
420
433
|
parentExitCleanup = null;
|
|
421
434
|
unregister();
|
|
@@ -458,27 +471,70 @@ export function createStandaloneChannelWorker({
|
|
|
458
471
|
child = null;
|
|
459
472
|
if (!waitForExit) {
|
|
460
473
|
rejectPending(new Error(`channels runtime shutdown requested (${reason})`));
|
|
461
|
-
|
|
474
|
+
// Fast/detached path is still TERMINAL: the caller does not block on the
|
|
475
|
+
// full teardown, but a background escalation ladder guarantees the worker
|
|
476
|
+
// dies so no zombie survives. Exit-hook + PID tracking stay installed
|
|
477
|
+
// until the process ACTUALLY exits (not on IPC ack), so a parent that
|
|
478
|
+
// force-exits mid-grace still force-kills the tree via the exit cleanup.
|
|
479
|
+
let torn = false;
|
|
480
|
+
const teardown = () => {
|
|
481
|
+
if (torn) return;
|
|
482
|
+
torn = true;
|
|
483
|
+
clearTimeout(termTimer);
|
|
484
|
+
clearTimeout(killTimer);
|
|
485
|
+
if (targetPid) ownedChildPids.delete(targetPid);
|
|
486
|
+
unrefChildHandles(target);
|
|
487
|
+
uninstallParentExitHook();
|
|
488
|
+
};
|
|
489
|
+
// Bounded grace after IPC shutdown, then SIGTERM.
|
|
490
|
+
const termTimer = setTimeout(() => {
|
|
491
|
+
try {
|
|
492
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
|
|
493
|
+
} catch {}
|
|
494
|
+
}, 1500);
|
|
495
|
+
// Hard fallback: taskkill /T /F the whole tree, then SIGKILL the handle.
|
|
496
|
+
const killTimer = setTimeout(() => {
|
|
497
|
+
try {
|
|
498
|
+
if (target.exitCode == null) forceKillTree(targetPid);
|
|
499
|
+
} catch {}
|
|
500
|
+
try {
|
|
501
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGKILL');
|
|
502
|
+
} catch {}
|
|
503
|
+
teardown();
|
|
504
|
+
}, 3000);
|
|
505
|
+
// Unref so these background escalation timers never keep the event loop
|
|
506
|
+
// (or a pending /exit) alive waiting out the grace/hard-kill window.
|
|
507
|
+
termTimer.unref?.();
|
|
508
|
+
killTimer.unref?.();
|
|
509
|
+
// Actual exit (or spawn error) is the only thing that tears down tracking.
|
|
510
|
+
target.once('exit', teardown);
|
|
511
|
+
target.once('error', teardown);
|
|
462
512
|
stopPromise = new Promise((resolve) => {
|
|
463
513
|
let settled = false;
|
|
464
|
-
const
|
|
514
|
+
const settle = (ok) => {
|
|
465
515
|
if (settled) return;
|
|
466
516
|
settled = true;
|
|
467
517
|
clearTimeout(sendTimer);
|
|
468
518
|
stopPromise = null;
|
|
469
|
-
unrefChildHandles(target);
|
|
470
|
-
uninstallParentExitHook();
|
|
471
519
|
resolve(ok);
|
|
472
520
|
};
|
|
473
|
-
|
|
521
|
+
// Resolve the caller quickly once the IPC shutdown is acked/timed out;
|
|
522
|
+
// the escalation ladder above keeps running in the background.
|
|
523
|
+
const sendTimer = setTimeout(() => settle(true), 250);
|
|
524
|
+
sendTimer.unref?.();
|
|
525
|
+
target.once('exit', () => settle(true));
|
|
474
526
|
try {
|
|
475
527
|
target.send?.({ type: 'shutdown', reason }, () => {
|
|
476
528
|
try { target.disconnect?.(); } catch {}
|
|
477
|
-
|
|
529
|
+
settle(true);
|
|
478
530
|
});
|
|
479
531
|
} catch {
|
|
480
532
|
try { target.disconnect?.(); } catch {}
|
|
481
|
-
|
|
533
|
+
// IPC unavailable: skip the grace window and escalate now.
|
|
534
|
+
try {
|
|
535
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
|
|
536
|
+
} catch {}
|
|
537
|
+
settle(false);
|
|
482
538
|
}
|
|
483
539
|
});
|
|
484
540
|
return stopPromise;
|
|
@@ -232,7 +232,10 @@ namespace Mixdog {
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
'@`,
|
|
235
|
-
|
|
235
|
+
// System.Drawing is required too: GetDialogOwnerCenter uses
|
|
236
|
+
// Screen.PrimaryScreen.WorkingArea (System.Drawing.Rectangle) — without it
|
|
237
|
+
// the Add-Type compile fails and the dialog silently falls back to typing.
|
|
238
|
+
'Add-Type -TypeDefinition $code -ReferencedAssemblies System.Windows.Forms,System.Drawing | Out-Null',
|
|
236
239
|
// Invisible TopMost owner anchored to the TUI console (or foreground window)
|
|
237
240
|
// so IFileOpenDialog is modal, centered, and not detached on another monitor.
|
|
238
241
|
'$owner = New-Object System.Windows.Forms.Form',
|
|
@@ -81,7 +81,7 @@ function isConnResetLikeError(err) {
|
|
|
81
81
|
|
|
82
82
|
function isMemoryWorkerNotReadyError(err) {
|
|
83
83
|
const msg = String(err?.message || err || '');
|
|
84
|
-
return /memory worker exited before ready|memory worker ready timeout|memory runtime did not become ready|memory worker degraded/i.test(msg);
|
|
84
|
+
return /memory worker exited before ready|memory worker ready timeout|memory runtime did not become ready|memory worker degraded|memory worker draining/i.test(msg);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function isTransientMemoryRpcError(err) {
|
|
@@ -166,9 +166,67 @@ export function createStandaloneMemoryRuntime({
|
|
|
166
166
|
let child = null;
|
|
167
167
|
let nextCallId = 1;
|
|
168
168
|
let crashState = null; // { reason, at } — cached deterministic spawn crash
|
|
169
|
+
// Port we have registered this proxy pid with (so the shared daemon can
|
|
170
|
+
// reap itself promptly once every client deregisters). Re-registers when
|
|
171
|
+
// the daemon respawns on a new port.
|
|
172
|
+
let registeredWithPort = null;
|
|
173
|
+
|
|
174
|
+
async function ensureClientRegistered(port) {
|
|
175
|
+
// Returns the port the pending RPC should target. When an internal respawn
|
|
176
|
+
// happens the daemon moves to a fresh port, so the caller MUST use the
|
|
177
|
+
// returned value rather than the port it captured before registering.
|
|
178
|
+
if (!port || registeredWithPort === port) return port;
|
|
179
|
+
// The register RPC is provably side-effect-free from the caller's point of
|
|
180
|
+
// view: a refused/reset connection means the daemon never received it, and
|
|
181
|
+
// a draining 503 means it refused it. So a register-phase transient is ALWAYS
|
|
182
|
+
// safe to recover from by respawning a fresh daemon and retrying — even for a
|
|
183
|
+
// pending WRITE RPC. We do that respawn-and-retry HERE, before the write RPC
|
|
184
|
+
// is ever attempted, rather than leaning on the outer retry (which must not
|
|
185
|
+
// blanket-retry refused/reset on the write RPC itself).
|
|
186
|
+
let curPort = port;
|
|
187
|
+
for (let attempt = 0; ; attempt++) {
|
|
188
|
+
try {
|
|
189
|
+
await requestJson({
|
|
190
|
+
port: curPort,
|
|
191
|
+
method: 'POST',
|
|
192
|
+
path: '/client/register',
|
|
193
|
+
body: { clientPid: process.pid },
|
|
194
|
+
timeoutMs: 2000,
|
|
195
|
+
});
|
|
196
|
+
registeredWithPort = curPort;
|
|
197
|
+
return curPort;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// Benign failures (e.g. request timeout against a busy-but-live daemon)
|
|
200
|
+
// are not fatal: registration is best-effort and the idle TTL backstops
|
|
201
|
+
// a missed register. Only recover from genuine "daemon is gone/dying".
|
|
202
|
+
if (!isTransientMemoryRpcError(err)) return curPort;
|
|
203
|
+
if (attempt >= 3) throw err;
|
|
204
|
+
invalidateMemoryRuntimeAfterTransient(err);
|
|
205
|
+
await delay(TRANSIENT_MEMORY_RPC_BACKOFF_MS);
|
|
206
|
+
const started = await start();
|
|
207
|
+
curPort = started.port;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function deregisterClient() {
|
|
213
|
+
const port = registeredWithPort || portCache;
|
|
214
|
+
registeredWithPort = null;
|
|
215
|
+
if (!port) return;
|
|
216
|
+
try {
|
|
217
|
+
await requestJson({
|
|
218
|
+
port,
|
|
219
|
+
method: 'POST',
|
|
220
|
+
path: '/client/deregister',
|
|
221
|
+
body: { clientPid: process.pid },
|
|
222
|
+
timeoutMs: 1500,
|
|
223
|
+
});
|
|
224
|
+
} catch { /* best-effort; sweep + idle TTL reap us anyway */ }
|
|
225
|
+
}
|
|
169
226
|
|
|
170
227
|
function invalidateMemoryRuntimeAfterTransient(err) {
|
|
171
228
|
portCache = null;
|
|
229
|
+
registeredWithPort = null;
|
|
172
230
|
if (!isMemoryWorkerNotReadyError(err)) return;
|
|
173
231
|
startPromise = null;
|
|
174
232
|
const proc = child;
|
|
@@ -289,14 +347,34 @@ export function createStandaloneMemoryRuntime({
|
|
|
289
347
|
}
|
|
290
348
|
|
|
291
349
|
startPromise = (async () => {
|
|
292
|
-
|
|
350
|
+
let claim = claimOwner();
|
|
293
351
|
if (!claim.owned) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
352
|
+
// Another owner holds the singleton. If it is live AND serving a
|
|
353
|
+
// healthy port, use it. If it is live but NOT healthy (a daemon that is
|
|
354
|
+
// draining/shutting down never revives — see getDraining/health in
|
|
355
|
+
// lib/http-router.mjs), poll: reclaim the instant the old owner exits so
|
|
356
|
+
// a quick restart still ends with a fresh, working daemon instead of
|
|
357
|
+
// binding to the dying one and failing the pending RPC.
|
|
358
|
+
const deadline = Date.now() + 30_000;
|
|
359
|
+
while (Date.now() < deadline) {
|
|
360
|
+
const livePort = await findLivePort({ allowStarting: true });
|
|
361
|
+
if (livePort) {
|
|
362
|
+
try {
|
|
363
|
+
const health = await requestJson({ port: livePort, path: '/health', timeoutMs: 1500 });
|
|
364
|
+
if (health?.status === 'ok') return livePort;
|
|
365
|
+
} catch { /* dying/unreachable — fall through to reclaim */ }
|
|
366
|
+
}
|
|
367
|
+
const reclaim = claimOwner();
|
|
368
|
+
if (reclaim.owned) { claim = reclaim; break; }
|
|
369
|
+
await delay(150);
|
|
370
|
+
}
|
|
371
|
+
if (!claim.owned) {
|
|
372
|
+
const owner = readSingletonOwner(ownerPath);
|
|
373
|
+
if (owner.alive) throw new Error('memory runtime did not become ready');
|
|
374
|
+
releaseOwnerIfSelf();
|
|
375
|
+
claim = claimOwner();
|
|
376
|
+
if (!claim.owned) throw new Error('memory runtime did not become ready');
|
|
298
377
|
}
|
|
299
|
-
releaseOwnerIfSelf();
|
|
300
378
|
}
|
|
301
379
|
|
|
302
380
|
const daemonEnv = { ...process.env };
|
|
@@ -354,6 +432,7 @@ export function createStandaloneMemoryRuntime({
|
|
|
354
432
|
if (singletonEnabled && childPid) releaseSingletonOwner(ownerPath, childPid);
|
|
355
433
|
if (child?.pid === childPid) child = null;
|
|
356
434
|
portCache = null;
|
|
435
|
+
registeredWithPort = null;
|
|
357
436
|
});
|
|
358
437
|
|
|
359
438
|
const ready = new Promise((resolveReady, rejectReady) => {
|
|
@@ -417,7 +496,11 @@ export function createStandaloneMemoryRuntime({
|
|
|
417
496
|
const callId = `mem_${process.pid}_${nextCallId++}`;
|
|
418
497
|
return await withTransientMemoryRpcRetry(async () => {
|
|
419
498
|
await start();
|
|
420
|
-
|
|
499
|
+
let port = portCache || await findLivePort({ allowStarting: true });
|
|
500
|
+
if (!port) throw new Error('memory runtime is not available');
|
|
501
|
+
// ensureClientRegistered may respawn onto a fresh daemon/port; target the
|
|
502
|
+
// port it hands back so the RPC and registration always hit the same one.
|
|
503
|
+
port = await ensureClientRegistered(port);
|
|
421
504
|
if (!port) throw new Error('memory runtime is not available');
|
|
422
505
|
return await requestJson({
|
|
423
506
|
port,
|
|
@@ -433,7 +516,9 @@ export function createStandaloneMemoryRuntime({
|
|
|
433
516
|
async function buildSessionCoreMemoryPayload(sessionCwd) {
|
|
434
517
|
return await withTransientMemoryRpcRetry(async () => {
|
|
435
518
|
await start();
|
|
436
|
-
|
|
519
|
+
let port = portCache || await findLivePort({ allowStarting: true });
|
|
520
|
+
if (!port) throw new Error('memory runtime is not available');
|
|
521
|
+
port = await ensureClientRegistered(port);
|
|
437
522
|
if (!port) throw new Error('memory runtime is not available');
|
|
438
523
|
return await requestJson({
|
|
439
524
|
port,
|
|
@@ -446,8 +531,10 @@ export function createStandaloneMemoryRuntime({
|
|
|
446
531
|
}
|
|
447
532
|
|
|
448
533
|
async function stop() {
|
|
449
|
-
//
|
|
450
|
-
//
|
|
534
|
+
// Deregister this client so a shared daemon can reap itself within the
|
|
535
|
+
// seconds-scale client grace once no clients remain, then detach. We never
|
|
536
|
+
// hard-kill the daemon here — another tab/session may still be using it.
|
|
537
|
+
await deregisterClient();
|
|
451
538
|
try { child?.disconnect?.(); } catch {}
|
|
452
539
|
try { child?.unref?.(); } catch {}
|
|
453
540
|
child = null;
|
package/src/standalone/seeds.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync } from 'node:fs';
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
|
|
3
4
|
export function ensureStandaloneEnvironment({ rootDir, dataDir }) {
|
|
4
5
|
if (!rootDir) throw new Error('standalone rootDir is required');
|
|
@@ -14,4 +15,30 @@ export function ensureStandaloneEnvironment({ rootDir, dataDir }) {
|
|
|
14
15
|
process.env.MIXDOG_PATCH_NATIVE_PREWARM ??= '0';
|
|
15
16
|
|
|
16
17
|
mkdirSync(dataDir, { recursive: true });
|
|
18
|
+
seedBundledSkills({ rootDir, dataDir });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Copy skills bundled in the package (src/defaults/skills/<name>/) into the
|
|
22
|
+
// user data skills dir, but only when the target dir does not already exist —
|
|
23
|
+
// never overwrite user-owned skill dirs.
|
|
24
|
+
export function seedBundledSkills({ rootDir, dataDir }) {
|
|
25
|
+
const bundledDir = join(rootDir, 'defaults', 'skills');
|
|
26
|
+
if (!existsSync(bundledDir)) return;
|
|
27
|
+
const targetRoot = join(dataDir, 'skills');
|
|
28
|
+
let names;
|
|
29
|
+
try {
|
|
30
|
+
names = readdirSync(bundledDir, { withFileTypes: true });
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const entry of names) {
|
|
35
|
+
if (!entry.isDirectory()) continue;
|
|
36
|
+
const dest = join(targetRoot, entry.name);
|
|
37
|
+
if (existsSync(dest)) continue;
|
|
38
|
+
try {
|
|
39
|
+
cpSync(join(bundledDir, entry.name), dest, { recursive: true });
|
|
40
|
+
} catch {
|
|
41
|
+
// best-effort seeding; ignore individual copy failures
|
|
42
|
+
}
|
|
43
|
+
}
|
|
17
44
|
}
|
package/src/tui/App.jsx
CHANGED
|
@@ -720,6 +720,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
720
720
|
openContextPicker: (...a) => openContextPicker(...a),
|
|
721
721
|
openProfilePicker: (...a) => openProfilePicker(...a),
|
|
722
722
|
openUpdatePicker: (...a) => openUpdatePicker(...a),
|
|
723
|
+
runDoctor: (...a) => store.runDoctor?.(...a),
|
|
723
724
|
requestExit: (...a) => requestExit(...a),
|
|
724
725
|
});
|
|
725
726
|
const promptHintTimerRef = useRef(null);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor.mjs — /doctor installation health report builder.
|
|
3
|
+
*
|
|
4
|
+
* buildDoctorReport(runtime, getState) runs a fixed set of best-effort
|
|
5
|
+
* diagnostics against the live runtime accessors and returns a single
|
|
6
|
+
* multi-line string (one glyph-prefixed row per check). Every check is
|
|
7
|
+
* individually try/caught so one failure degrades to a single FAIL row
|
|
8
|
+
* instead of killing the whole report. No secrets are ever printed — only
|
|
9
|
+
* whether a token/auth is configured. The only network touch is the
|
|
10
|
+
* best-effort npm update check already used by /update; an unreachable
|
|
11
|
+
* registry downgrades to a WARN "check skipped" row, never a throw.
|
|
12
|
+
*/
|
|
13
|
+
import { compareSemver } from '../../runtime/shared/update-checker.mjs';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const GLYPH = { ok: '✓', warn: '⚠', fail: '✗' };
|
|
19
|
+
|
|
20
|
+
function readPackageJson() {
|
|
21
|
+
try {
|
|
22
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const raw = JSON.parse(readFileSync(join(dir, '..', '..', '..', 'package.json'), 'utf8'));
|
|
24
|
+
return raw && typeof raw === 'object' ? raw : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function buildDoctorReport(runtime = {}, getState = () => ({})) {
|
|
31
|
+
const rows = [];
|
|
32
|
+
const check = async (label, fn) => {
|
|
33
|
+
const row = (level, detail) => {
|
|
34
|
+
rows.push(`${GLYPH[level] || GLYPH.warn} ${label}: ${detail}`);
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
await fn(row);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
row('fail', `check failed: ${e?.message || e}`);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const pkg = readPackageJson();
|
|
43
|
+
|
|
44
|
+
// 1. mixdog version + update availability (best-effort registry check).
|
|
45
|
+
await check('mixdog', async (row) => {
|
|
46
|
+
const upd = (await runtime.checkForUpdate?.({})) || {};
|
|
47
|
+
const current = upd.currentVersion || pkg?.version || 'unknown';
|
|
48
|
+
const latest = upd.latestVersion;
|
|
49
|
+
if (latest == null) {
|
|
50
|
+
row('warn', `v${current} · update check skipped (registry unreachable)`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (upd.updateAvailable) row('warn', `v${current} · update available → v${latest}`);
|
|
54
|
+
else row('ok', `v${current} · up to date`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// 2. node version vs package.json engines (only when engines present).
|
|
58
|
+
await check('node', async (row) => {
|
|
59
|
+
const nodeVer = process.versions?.node || '0.0.0';
|
|
60
|
+
const engines = pkg?.engines?.node;
|
|
61
|
+
if (!engines) {
|
|
62
|
+
row('ok', `v${nodeVer}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const m = String(engines).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
66
|
+
if (!m) {
|
|
67
|
+
row('warn', `v${nodeVer} · engines "${engines}" unparsed`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const required = `${m[1]}.${m[2] || 0}.${m[3] || 0}`;
|
|
71
|
+
const ok = compareSemver(nodeVer, required) >= 0;
|
|
72
|
+
row(ok ? 'ok' : 'fail', `v${nodeVer} · requires node ${engines}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 3. providers: configured auth count; FAIL if the active route provider
|
|
76
|
+
// has no auth.
|
|
77
|
+
await check('providers', async (row) => {
|
|
78
|
+
const setup = (await runtime.getProviderSetup?.()) || {};
|
|
79
|
+
const lists = [...(setup.api || []), ...(setup.oauth || []), ...(setup.local || [])];
|
|
80
|
+
const isAuthed = (p) => Boolean(p && (p.authenticated || p.enabled || p.detected));
|
|
81
|
+
const authed = lists.filter(isAuthed);
|
|
82
|
+
const active = getState()?.provider || '';
|
|
83
|
+
const activeEntry = active ? lists.find((p) => p.id === active) : null;
|
|
84
|
+
if (activeEntry && !isAuthed(activeEntry)) {
|
|
85
|
+
row('fail', `route ${active} has no auth · ${authed.length} configured`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (active && !activeEntry) {
|
|
89
|
+
row('warn', `${authed.length} authed · route ${active} (not listed)`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
row('ok', `${authed.length} authed · route ${active || 'unknown'}`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 4. MCP: connected/configured, failed servers named.
|
|
96
|
+
await check('mcp', async (row) => {
|
|
97
|
+
const m = runtime.mcpStatus?.() || {};
|
|
98
|
+
const servers = Array.isArray(m.servers) ? m.servers : [];
|
|
99
|
+
const conn = Number(m.connectedCount || 0);
|
|
100
|
+
const conf = Number(m.configuredCount || 0);
|
|
101
|
+
if (conf === 0) {
|
|
102
|
+
row('ok', 'no servers configured');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const failed = servers
|
|
106
|
+
.filter((s) => s && (s.error || s.status === 'failed'))
|
|
107
|
+
.map((s) => s.name || s.id)
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
const detail = `${conn}/${conf} connected${failed.length ? ` · failed: ${failed.join(', ')}` : ''}`;
|
|
110
|
+
row(failed.length || conn < conf ? 'warn' : 'ok', detail);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// 5. memory: enabled/disabled (+ backend health only if the accessor
|
|
114
|
+
// already exposes it; no new probing).
|
|
115
|
+
await check('memory', async (row) => {
|
|
116
|
+
const mem = runtime.getMemorySettings?.() || {};
|
|
117
|
+
const enabled = mem.enabled !== false;
|
|
118
|
+
let detail = enabled ? 'enabled' : 'disabled';
|
|
119
|
+
if (mem.backend) detail += ` · backend ${mem.backend}`;
|
|
120
|
+
const health = mem.backendHealth || mem.health;
|
|
121
|
+
if (health != null) {
|
|
122
|
+
detail += ` · ${typeof health === 'string' ? health : (health.ok ? 'healthy' : 'unhealthy')}`;
|
|
123
|
+
}
|
|
124
|
+
row(enabled ? 'ok' : 'warn', detail);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// 6. channels: enabled + worker status + configured tokens (names only).
|
|
128
|
+
await check('channels', async (row) => {
|
|
129
|
+
const settings = runtime.getChannelSettings?.({ includeStatus: true }) || {};
|
|
130
|
+
const enabled = settings.enabled !== false;
|
|
131
|
+
if (!enabled) {
|
|
132
|
+
row('ok', 'disabled');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const worker = settings.status || runtime.getChannelWorkerStatus?.() || {};
|
|
136
|
+
const setup = runtime.getChannelSetup?.() || {};
|
|
137
|
+
const tokens = [];
|
|
138
|
+
if (setup.discord?.authenticated) tokens.push('discord');
|
|
139
|
+
if (setup.telegram?.authenticated) tokens.push('telegram');
|
|
140
|
+
if (setup.webhook?.authenticated) tokens.push('webhook');
|
|
141
|
+
const running = worker.running === true;
|
|
142
|
+
const detail = `enabled · worker ${running ? 'running' : 'stopped'} · tokens: ${tokens.length ? tokens.join(', ') : 'none'}`;
|
|
143
|
+
row(running ? 'ok' : 'warn', detail);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 7. skills / plugins / hooks: counts + broken/disabled entries.
|
|
147
|
+
await check('skills', async (row) => {
|
|
148
|
+
const s = runtime.skillsStatus?.() || {};
|
|
149
|
+
const skills = Array.isArray(s.skills) ? s.skills : [];
|
|
150
|
+
const broken = skills.filter((x) => x && (x.broken || x.error || x.invalid));
|
|
151
|
+
const disabled = skills.filter((x) => x && x.disabled);
|
|
152
|
+
let detail = `${s.count ?? skills.length} available`;
|
|
153
|
+
if (disabled.length) detail += ` · ${disabled.length} disabled`;
|
|
154
|
+
if (broken.length) detail += ` · broken: ${broken.map((x) => x.name || x.id).filter(Boolean).join(', ')}`;
|
|
155
|
+
row(broken.length ? 'warn' : 'ok', detail);
|
|
156
|
+
});
|
|
157
|
+
await check('plugins', async (row) => {
|
|
158
|
+
const p = runtime.pluginsStatus?.() || {};
|
|
159
|
+
const plugins = Array.isArray(p.plugins) ? p.plugins : [];
|
|
160
|
+
const broken = plugins.filter((x) => x && (x.broken || x.error));
|
|
161
|
+
const disabled = plugins.filter((x) => x && x.disabled);
|
|
162
|
+
let detail = `${p.count ?? plugins.length} detected`;
|
|
163
|
+
if (disabled.length) detail += ` · ${disabled.length} disabled`;
|
|
164
|
+
if (broken.length) detail += ` · broken: ${broken.map((x) => x.title || x.name || x.id).filter(Boolean).join(', ')}`;
|
|
165
|
+
row(broken.length ? 'warn' : 'ok', detail);
|
|
166
|
+
});
|
|
167
|
+
await check('hooks', async (row) => {
|
|
168
|
+
const h = runtime.hooksStatus?.() || {};
|
|
169
|
+
const events = Array.isArray(h.events) ? h.events : [];
|
|
170
|
+
const enabled = h.enabled === true;
|
|
171
|
+
row('ok', `${enabled ? 'enabled' : 'disabled'} · ${events.length} event${events.length === 1 ? '' : 's'}`);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return ['mixdog doctor — installation health', ...rows].join('\n');
|
|
175
|
+
}
|
|
@@ -32,6 +32,7 @@ export const SLASH_COMMANDS = [
|
|
|
32
32
|
{ name: 'settings', usage: '/setting', aliases: ['setting', 'config'], aliasUsage: ['settings', 'config'], showAliasUsage: false, description: 'Open runtime settings' },
|
|
33
33
|
{ name: 'profile', usage: '/profile', description: 'Set your title and response language' },
|
|
34
34
|
{ name: 'update', usage: '/update', description: 'Check version and update mixdog' },
|
|
35
|
+
{ name: 'doctor', usage: '/doctor', description: 'Diagnose installation health' },
|
|
35
36
|
{ name: 'quit', usage: '/quit', aliases: ['exit', 'q'], aliasUsage: ['exit', 'q'], description: 'Quit the TUI' },
|
|
36
37
|
];
|
|
37
38
|
|
|
@@ -46,6 +46,7 @@ export function createSlashDispatch({
|
|
|
46
46
|
openContextPicker,
|
|
47
47
|
openProfilePicker,
|
|
48
48
|
openUpdatePicker,
|
|
49
|
+
runDoctor,
|
|
49
50
|
requestExit,
|
|
50
51
|
}) {
|
|
51
52
|
const runSlashCommand = (cmd, arg = '') => {
|
|
@@ -383,6 +384,14 @@ export function createSlashDispatch({
|
|
|
383
384
|
case 'update':
|
|
384
385
|
openUpdatePicker();
|
|
385
386
|
return true;
|
|
387
|
+
case 'doctor':
|
|
388
|
+
if (state.commandBusy) {
|
|
389
|
+
store.pushNotice('wait for the current command to finish before /doctor', 'warn');
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
void Promise.resolve(runDoctor?.())
|
|
393
|
+
.catch((e) => store.pushNotice(`doctor failed: ${e?.message || e}`, 'error'));
|
|
394
|
+
return true;
|
|
386
395
|
case 'quit':
|
|
387
396
|
requestExit();
|
|
388
397
|
return true;
|