mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -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);
@@ -845,34 +846,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
845
846
  }, 2200);
846
847
  }, []);
847
848
 
848
- // Copy the currently-highlighted selection to the OS clipboard. ink's fork
849
- // refreshed store.getRenderSelectionText() on the synchronous render that the
850
- // final setSelection() triggered, so the selected text is ready to read.
851
- const copySelection = useCallback((attempt = 0) => {
852
- const renderText = store.getRenderSelectionText?.();
853
- const remembered = selectionTextRef.current || '';
854
- // A selection that has partially scrolled out of the viewport renders —
855
- // and therefore harvests — only its visible rows. The remembered text
856
- // (captured while the selection was last fully painted) is the fuller
857
- // copy; prefer whichever is longer so scrolling never shrinks a copy.
858
- const text = renderText == null
859
- ? remembered
860
- : (remembered.length > renderText.length ? remembered : renderText);
861
- if ((!text || !text.trim()) && attempt < 4) {
862
- setTimeout(() => copySelection(attempt + 1), attempt === 0 ? 0 : 24);
863
- return;
864
- }
865
- if (!text || !text.trim()) return;
866
- selectionTextRef.current = text;
867
- copyToClipboard(text)
868
- .then(() => {
869
- const lines = text.split('\n').length;
870
- const chars = text.length;
871
- showSelectionCopyHint(`copied ${chars} char${chars === 1 ? '' : 's'}${lines > 1 ? ` · ${lines} lines` : ''}`, 'plain');
872
- })
873
- .catch((e) => showSelectionCopyHint(`copy failed: ${e?.message || e}`, 'error'));
874
- }, [store, showSelectionCopyHint]);
875
-
876
849
  // ── Post-mount input gate ──────────────────────────────────────────────
877
850
  // Let one event-loop poll pass so Ink processes (and discards, because
878
851
  // PromptInput is still disabled) any keystrokes queued during boot/first
@@ -1047,6 +1020,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1047
1020
  scrollTranscriptRows,
1048
1021
  queueScrollCoalesced,
1049
1022
  moveSelectionFocus,
1023
+ getStitchedSelectionText,
1024
+ clearStitchBuffer,
1050
1025
  } = useTranscriptScroll({
1051
1026
  store,
1052
1027
  frameColumns,
@@ -1067,6 +1042,42 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1067
1042
  selectionTextRef,
1068
1043
  });
1069
1044
 
1045
+ // Copy the currently-highlighted selection to the OS clipboard. ink's fork
1046
+ // refreshed store.getRenderSelectionText() on the synchronous render that the
1047
+ // final setSelection() triggered, so the selected text is ready to read.
1048
+ // NOTE: declared after useTranscriptScroll — the dependency array below
1049
+ // evaluates getStitchedSelectionText at render time, so referencing it
1050
+ // before the destructuring above is a TDZ ReferenceError.
1051
+ const copySelection = useCallback((attempt = 0) => {
1052
+ const renderText = store.getRenderSelectionText?.();
1053
+ const remembered = selectionTextRef.current || '';
1054
+ // A selection that has partially scrolled out of the viewport renders —
1055
+ // and therefore harvests — only its visible rows. The remembered text
1056
+ // (captured while the selection was last fully painted) is the fuller
1057
+ // copy; prefer whichever is longer so scrolling never shrinks a copy.
1058
+ let text = renderText == null
1059
+ ? remembered
1060
+ : (remembered.length > renderText.length ? remembered : renderText);
1061
+ // The stitch buffer accumulates rows harvested across every scroll position
1062
+ // during a transcript drag, so it can reconstruct rows that scrolled out of
1063
+ // view entirely (neither renderText nor the last-full-paint remembered text
1064
+ // ever saw them). Prefer it only when it is strictly longer than both.
1065
+ const stitched = getStitchedSelectionText?.() || '';
1066
+ if (stitched.length > text.length) text = stitched;
1067
+ if ((!text || !text.trim()) && attempt < 4) {
1068
+ setTimeout(() => copySelection(attempt + 1), attempt === 0 ? 0 : 24);
1069
+ return;
1070
+ }
1071
+ if (!text || !text.trim()) return;
1072
+ selectionTextRef.current = text;
1073
+ copyToClipboard(text)
1074
+ .then(() => {
1075
+ const lines = text.split('\n').length;
1076
+ const chars = text.length;
1077
+ showSelectionCopyHint(`copied ${chars} char${chars === 1 ? '' : 's'}${lines > 1 ? ` · ${lines} lines` : ''}`, 'plain');
1078
+ })
1079
+ .catch((e) => showSelectionCopyHint(`copy failed: ${e?.message || e}`, 'error'));
1080
+ }, [store, showSelectionCopyHint, getStitchedSelectionText]);
1070
1081
 
1071
1082
  useEffect(() => () => {
1072
1083
  stopSmoothScroll();
@@ -1102,6 +1113,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1102
1113
  queueScrollCoalesced,
1103
1114
  setSlashIndex,
1104
1115
  setMeasuredRowsVersion,
1116
+ clearStitchBuffer,
1105
1117
  });
1106
1118
 
1107
1119
  // Enable extended keyboard reporting (kitty + xterm modifyOtherKeys)
@@ -47,7 +47,7 @@ export function createCoreMemoryPicker({
47
47
  const rows = [
48
48
  {
49
49
  value: 'memory-toggle',
50
- label: 'Memory on/off',
50
+ label: 'Memory',
51
51
  meta: memoryOn ? 'On' : 'Off',
52
52
  description: memoryOn
53
53
  ? 'Background memory cycles on'
@@ -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;
@@ -54,6 +54,7 @@ export function useMouseInput({
54
54
  queueScrollCoalesced,
55
55
  setSlashIndex,
56
56
  setMeasuredRowsVersion,
57
+ clearStitchBuffer,
57
58
  }) {
58
59
  const mouseZoomPassthroughTimerRef = useRef(null);
59
60
 
@@ -353,6 +354,8 @@ export function useMouseInput({
353
354
  const hi = { x: wr.x2, y: wr.y2 };
354
355
  const rect = linearSelection(lo, hi);
355
356
  stopSmoothScroll();
357
+ // Fresh word/line anchor: reset the stitch buffer (see char-drag).
358
+ clearStitchBuffer?.();
356
359
  dragRef.current = {
357
360
  anchor: { x, y },
358
361
  anchorScroll: region === 'transcript' ? scrollTargetRef.current : 0,
@@ -376,6 +379,9 @@ export function useMouseInput({
376
379
  // there; keep the transcript scroll anchor only for the transcript.
377
380
  // Plain single press clears any word/line anchorSpan (char-drag mode).
378
381
  stopSmoothScroll();
382
+ // Fresh char-drag anchor: drop any rows stitched from a prior
383
+ // selection so the new drag reconstructs only its own content.
384
+ clearStitchBuffer?.();
379
385
  dragRef.current = {
380
386
  anchor: { x, y },
381
387
  anchorScroll: region === 'transcript' ? scrollTargetRef.current : 0,
@@ -44,6 +44,71 @@ export function useTranscriptScroll({
44
44
  // SCROLL_COALESCE_MS). Both call sites accumulate into pendingRows.
45
45
  const scrollCoalesceRef = useRef({ pendingRows: 0, timer: null });
46
46
  const selectionTextTimerRef = useRef(null);
47
+ // Stitch buffer: accumulates harvested transcript selection rows across scroll
48
+ // positions so Ctrl+C copies the FULL drag even after it auto-scrolled past the
49
+ // viewport. Keyed by scroll-invariant content row = screenY - scrollTarget at
50
+ // harvest time; value = row text. Only transcript-region drags accumulate.
51
+ const stitchBufferRef = useRef(new Map());
52
+ const stitchHarvestTimerRef = useRef(null);
53
+ // Scroll offset captured at the SCHEDULE (paint) time of the pending harvest —
54
+ // the deferred timer must key rows by the frame's scroll, not by whatever
55
+ // scrollTargetRef holds when the timer eventually fires (a scroll in between
56
+ // would mis-key the rows). Latest schedule wins (latest paint = latest frame).
57
+ const stitchHarvestScrollRef = useRef(0);
58
+
59
+ const clearStitchBuffer = useCallback(() => {
60
+ stitchBufferRef.current.clear();
61
+ if (stitchHarvestTimerRef.current) {
62
+ clearTimeout(stitchHarvestTimerRef.current);
63
+ stitchHarvestTimerRef.current = null;
64
+ }
65
+ }, []);
66
+
67
+ // Deferred (like rememberSelectionTextSoon) harvest of the currently visible
68
+ // selection rows into the stitch buffer. Runs on EVERY transcript selection
69
+ // paint AND on scroll-shift repaints (the rememberText:false path) so rows
70
+ // revealed only mid-scroll are captured. Later harvest of a key overwrites,
71
+ // handling partial↔full endpoint rows on retraction.
72
+ const harvestStitchRowsSoon = useCallback(() => {
73
+ if (dragRef.current.region !== 'transcript') return;
74
+ // Capture the scroll offset for THIS paint (schedule time). If a timer is
75
+ // already pending, only refresh the captured offset to the latest frame and
76
+ // reuse the existing timer.
77
+ stitchHarvestScrollRef.current = Number(scrollTargetRef.current) || 0;
78
+ if (stitchHarvestTimerRef.current) return;
79
+ stitchHarvestTimerRef.current = setTimeout(() => {
80
+ stitchHarvestTimerRef.current = null;
81
+ if (dragRef.current.region !== 'transcript') return;
82
+ const rows = store.getRenderSelectionRows?.();
83
+ if (!Array.isArray(rows)) return;
84
+ const scroll = stitchHarvestScrollRef.current;
85
+ for (const row of rows) {
86
+ if (!row || typeof row.y !== 'number') continue;
87
+ stitchBufferRef.current.set(row.y - scroll, typeof row.text === 'string' ? row.text : '');
88
+ }
89
+ }, 0);
90
+ }, [store]);
91
+
92
+ // Map the CURRENT rect + current scrollTarget onto the content-key range and
93
+ // join buffered rows sorted by key with '\n' (skipping missing keys). Returns
94
+ // '' when unusable so callers can fall back to render/remembered text.
95
+ const getStitchedSelectionText = useCallback(() => {
96
+ const buf = stitchBufferRef.current;
97
+ if (!buf.size) return '';
98
+ if (dragRef.current.region !== 'transcript') return '';
99
+ const rect = dragRef.current.rect;
100
+ if (!rect) return '';
101
+ const y1 = Number(rect.y1);
102
+ const y2 = Number(rect.y2);
103
+ if (!Number.isFinite(y1) || !Number.isFinite(y2)) return '';
104
+ const scroll = Number(scrollTargetRef.current) || 0;
105
+ const lo = Math.min(y1, y2) - scroll;
106
+ const hi = Math.max(y1, y2) - scroll;
107
+ const keys = [...buf.keys()].filter((k) => k >= lo && k <= hi).sort((a, b) => a - b);
108
+ if (!keys.length) return '';
109
+ const text = keys.map((k) => buf.get(k)).filter((t) => t != null).join('\n');
110
+ return text.trim() ? text : '';
111
+ }, []);
47
112
 
48
113
  const stopSmoothScroll = useCallback(() => {
49
114
  if (!scrollAnimationRef.current) return;
@@ -145,19 +210,24 @@ export function useTranscriptScroll({
145
210
  store.setRenderSelection?.(nextRect, { immediate: true });
146
211
  }
147
212
  if (needsCapture) rememberSelectionTextSoon();
213
+ if (nextRect) harvestStitchRowsSoon();
148
214
  return true;
149
215
  }
150
216
  state.rect = nextRect;
151
217
  state.t = Date.now();
152
218
  store.setRenderSelection?.(nextRect, immediate ? { immediate: true } : undefined);
153
219
  if (nextRect && rememberText && nextRect.captureText !== false) rememberSelectionTextSoon();
220
+ if (nextRect) harvestStitchRowsSoon();
154
221
  return true;
155
- }, [store, rememberSelectionTextSoon]);
222
+ }, [store, rememberSelectionTextSoon, harvestStitchRowsSoon]);
156
223
 
157
224
  const applySelectionRect = useCallback((rect) => {
158
225
  const clippedRect = withSelectionClip(rect);
159
226
  dragRef.current.rect = clippedRect || null;
160
- if (!clippedRect) selectionTextRef.current = '';
227
+ if (!clippedRect) {
228
+ selectionTextRef.current = '';
229
+ clearStitchBuffer();
230
+ }
161
231
  const state = selectionPaintRef.current;
162
232
  if (state.timer) {
163
233
  clearTimeout(state.timer);
@@ -165,7 +235,7 @@ export function useTranscriptScroll({
165
235
  state.pending = null;
166
236
  }
167
237
  paintSelectionRect(clippedRect, { rememberText: true, immediate: true });
168
- }, [paintSelectionRect, withSelectionClip]);
238
+ }, [paintSelectionRect, withSelectionClip, clearStitchBuffer]);
169
239
 
170
240
  const applySelectionRectThrottled = useCallback((rect) => {
171
241
  const clippedRect = withSelectionClip(rect, { captureText: false });
@@ -274,6 +344,8 @@ export function useTranscriptScroll({
274
344
  paintState.pending = null;
275
345
  if (selectionTextTimerRef.current) clearTimeout(selectionTextTimerRef.current);
276
346
  selectionTextTimerRef.current = null;
347
+ if (stitchHarvestTimerRef.current) clearTimeout(stitchHarvestTimerRef.current);
348
+ stitchHarvestTimerRef.current = null;
277
349
  const coalesceState = scrollCoalesceRef.current;
278
350
  if (coalesceState.timer) clearTimeout(coalesceState.timer);
279
351
  coalesceState.timer = null;
@@ -512,5 +584,7 @@ export function useTranscriptScroll({
512
584
  scrollTranscriptRows,
513
585
  queueScrollCoalesced,
514
586
  moveSelectionFocus,
587
+ getStitchedSelectionText,
588
+ clearStitchBuffer,
515
589
  };
516
590
  }