mixdog 0.9.20 → 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 (36) hide show
  1. package/README.md +14 -12
  2. package/package.json +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/recall-usecase-cases.json +1 -1
  5. package/scripts/smoke-runtime-negative.ps1 +106 -106
  6. package/scripts/tool-efficiency-diag.mjs +1 -1
  7. package/src/defaults/skills/setup/SKILL.md +327 -0
  8. package/src/rules/lead/02-channels.md +3 -3
  9. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  10. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  11. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  15. package/src/runtime/channels/lib/owned-runtime.mjs +95 -15
  16. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  17. package/src/runtime/channels/lib/worker-main.mjs +7 -1
  18. package/src/runtime/memory/index.mjs +90 -0
  19. package/src/runtime/memory/lib/http-router.mjs +39 -0
  20. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  21. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  22. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  23. package/src/runtime/memory/lib/memory.mjs +39 -0
  24. package/src/runtime/shared/atomic-file.mjs +138 -80
  25. package/src/runtime/shared/child-guardian.mjs +61 -3
  26. package/src/session-runtime/runtime-core.mjs +75 -17
  27. package/src/standalone/channel-worker.mjs +63 -7
  28. package/src/standalone/folder-dialog.mjs +4 -1
  29. package/src/standalone/memory-runtime-proxy.mjs +98 -11
  30. package/src/standalone/seeds.mjs +28 -1
  31. package/src/tui/App.jsx +1 -0
  32. package/src/tui/app/doctor.mjs +175 -0
  33. package/src/tui/app/slash-commands.mjs +1 -0
  34. package/src/tui/app/slash-dispatch.mjs +9 -0
  35. package/src/tui/dist/index.mjs +297 -71
  36. package/src/tui/engine/session-api.mjs +19 -0
@@ -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;