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.
Files changed (35) hide show
  1. package/README.md +14 -12
  2. package/package.json +1 -1
  3. package/src/defaults/skills/setup/SKILL.md +327 -0
  4. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  6. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  7. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  8. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  9. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  10. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +10 -10
  11. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -6
  12. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +14 -1
  13. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +23 -13
  14. package/src/runtime/channels/lib/owned-runtime.mjs +95 -15
  15. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  16. package/src/runtime/channels/lib/worker-main.mjs +7 -1
  17. package/src/runtime/memory/index.mjs +90 -0
  18. package/src/runtime/memory/lib/http-router.mjs +39 -0
  19. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  20. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  21. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  22. package/src/runtime/memory/lib/memory.mjs +39 -0
  23. package/src/runtime/shared/atomic-file.mjs +161 -85
  24. package/src/runtime/shared/child-guardian.mjs +61 -3
  25. package/src/session-runtime/runtime-core.mjs +75 -17
  26. package/src/standalone/channel-worker.mjs +63 -7
  27. package/src/standalone/folder-dialog.mjs +4 -1
  28. package/src/standalone/memory-runtime-proxy.mjs +98 -11
  29. package/src/standalone/seeds.mjs +28 -1
  30. package/src/tui/App.jsx +1 -0
  31. package/src/tui/app/doctor.mjs +175 -0
  32. package/src/tui/app/slash-commands.mjs +1 -0
  33. package/src/tui/app/slash-dispatch.mjs +9 -0
  34. package/src/tui/dist/index.mjs +314 -74
  35. 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
- if (targetPid) ownedChildPids.delete(targetPid);
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 finish = (ok) => {
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
- const sendTimer = setTimeout(() => finish(false), 250);
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
- finish(true);
529
+ settle(true);
478
530
  });
479
531
  } catch {
480
532
  try { target.disconnect?.(); } catch {}
481
- finish(false);
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
- 'Add-Type -TypeDefinition $code -ReferencedAssemblies System.Windows.Forms | Out-Null',
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
- const claim = claimOwner();
350
+ let claim = claimOwner();
293
351
  if (!claim.owned) {
294
- const owner = readSingletonOwner(ownerPath);
295
- if (owner.alive) {
296
- const live = await waitForPort(30_000);
297
- return live;
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
- const port = portCache || await findLivePort({ allowStarting: true });
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
- const port = portCache || await findLivePort({ allowStarting: true });
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
- // Detach only. The daemon owns its own idle lifetime; CLI shutdown must not
450
- // tear it down out from under another tab.
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;
@@ -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;