mixdog 0.7.12 → 0.7.14

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 (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +28 -74
  4. package/bun.lock +128 -3
  5. package/defaults/hidden-roles.json +3 -0
  6. package/defaults/user-workflow.json +3 -3
  7. package/defaults/user-workflow.md +16 -11
  8. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  9. package/package.json +9 -2
  10. package/scripts/ensure-deps.mjs +2 -2
  11. package/scripts/run-mcp.mjs +65 -9
  12. package/setup/launch-core.mjs +0 -1
  13. package/setup/setup-server.mjs +80 -33
  14. package/setup/setup.html +1 -3
  15. package/skills/setup/SKILL.md +12 -2
  16. package/src/agent/index.mjs +1 -1
  17. package/src/agent/orchestrator/config.mjs +58 -6
  18. package/src/agent/orchestrator/providers/model-catalog.mjs +1 -1
  19. package/src/agent/orchestrator/session/loop.mjs +3 -3
  20. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +6 -2
  21. package/src/agent/orchestrator/tools/bash-session.mjs +1 -0
  22. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  23. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +29 -6
  24. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +8 -4
  25. package/src/agent/orchestrator/tools/builtin.mjs +5 -2
  26. package/src/agent/orchestrator/tools/cwd-tool.mjs +17 -17
  27. package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
  28. package/src/agent/orchestrator/tools/patch-manifest.json +7 -7
  29. package/src/agent/tool-defs.mjs +1 -1
  30. package/src/channels/index.mjs +12 -1
  31. package/src/channels/lib/webhook.mjs +35 -18
  32. package/src/memory/index.mjs +5 -1
  33. package/src/memory/lib/core-memory-store.mjs +1 -1
  34. package/src/memory/lib/memory-cycle1.mjs +1 -1
  35. package/src/memory/lib/memory-cycle2.mjs +1 -1
  36. package/src/memory/lib/memory-cycle3.mjs +1 -1
  37. package/tools.json +2 -2
@@ -8,10 +8,10 @@ import * as os from 'os';
8
8
  import { dirname, join } from 'path';
9
9
  import { assertSafeOwnedDir } from '../src/shared/user-data-guard.mjs';
10
10
 
11
- const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
11
+ export const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
12
12
  const RENAME_BACKOFFS_MS = Object.freeze([25, 50, 100, 200, 400, 800, 1200, 1600]);
13
13
 
14
- function sleepSync(ms) {
14
+ export function sleepSync(ms) {
15
15
  try {
16
16
  const buf = new SharedArrayBuffer(4);
17
17
  Atomics.wait(new Int32Array(buf), 0, 0, Math.max(1, Number(ms) || 1));
@@ -26,6 +26,8 @@ import {
26
26
  ensureRuntimeDeps,
27
27
  hasRequiredDeps,
28
28
  renameWithRetrySync,
29
+ RENAME_RETRY_CODES,
30
+ sleepSync,
29
31
  } from './ensure-deps.mjs';
30
32
 
31
33
  // Stable per-terminal session id for this proxy supervisor's lifetime. The
@@ -453,6 +455,12 @@ let childHasResponded = false;
453
455
  let announceListChangedOnReady = false;
454
456
  let cachedInitRequest = null; // { id, params } from client's first initialize
455
457
  let cachedInitDone = false; // initialized notification observed from client
458
+ // One-shot latch: flips true the instant the client's initialize response is
459
+ // forwarded and never resets. It selects replayInitToChild's mode (real-id vs
460
+ // swallow) and tells handleChildGone whether the pending initialize may be
461
+ // flushed. Until it is true the client has NOT seen its initialize result, so
462
+ // that request must survive a child death and be re-driven, not errored.
463
+ let clientInitAnswered = false;
456
464
  let internalIdSeq = -1; // negative ids reserved for supervisor-internal requests
457
465
  const pendingFromClient = new Map(); // request id (from client) → { method }
458
466
  const pendingInternal = new Set(); // internal ids (init replay) — drop responses
@@ -670,14 +678,33 @@ function flushPendingClientErrors(tag) {
670
678
 
671
679
  function replayInitToChild() {
672
680
  if (!cachedInitRequest) return;
673
- const internalId = internalIdSeq--;
674
- pendingInternal.add(internalId);
675
- writeToChild(JSON.stringify({
676
- jsonrpc: '2.0',
677
- id: internalId,
678
- method: 'initialize',
679
- params: cachedInitRequest.params,
680
- }));
681
+ // Mode select by invariant — has the client's initialize been answered yet?
682
+ // • Already answered (steady-state respawn): the client is fully
683
+ // initialized and must NOT see a second result. Replay under an internal
684
+ // negative id and swallow the response.
685
+ // • Not yet (first-boot / handshake-time crash): replay under the client's
686
+ // OWN id. Its request is still pending (preserved across the child-gone
687
+ // flush in handleChildGone), so the new child's initialize response flows
688
+ // back through the normal forward path — the client sees one clean result
689
+ // instead of the -32603 that previously killed the connection and forced
690
+ // a manual /mcp reconnect.
691
+ if (clientInitAnswered) {
692
+ const internalId = internalIdSeq--;
693
+ pendingInternal.add(internalId);
694
+ writeToChild(JSON.stringify({
695
+ jsonrpc: '2.0',
696
+ id: internalId,
697
+ method: 'initialize',
698
+ params: cachedInitRequest.params,
699
+ }));
700
+ } else {
701
+ writeToChild(JSON.stringify({
702
+ jsonrpc: '2.0',
703
+ id: cachedInitRequest.id,
704
+ method: 'initialize',
705
+ params: cachedInitRequest.params,
706
+ }));
707
+ }
681
708
  if (cachedInitDone) {
682
709
  // Notification — no id, no response expected.
683
710
  writeToChild(JSON.stringify({
@@ -816,7 +843,13 @@ function handleChildLine(line) {
816
843
  for (const item of scanned) {
817
844
  if (item && item.id !== undefined) {
818
845
  if (pendingInternal.has(item.id)) { internalIds.add(item.id); pendingInternal.delete(item.id); _maybeResolveLivenessPong(item.id); }
819
- else { pendingFromClient.delete(item.id); }
846
+ else {
847
+ pendingFromClient.delete(item.id);
848
+ // Same latch as the scalar path below — keep the invariant consistent
849
+ // even if the client's initialize ever returns inside a batch, so a
850
+ // later respawn swallows its replay instead of re-driving the real id.
851
+ if (cachedInitRequest && item.id === cachedInitRequest.id) clientInitAnswered = true;
852
+ }
820
853
  }
821
854
  }
822
855
  if (internalIds.size) {
@@ -846,6 +879,10 @@ function handleChildLine(line) {
846
879
  return;
847
880
  }
848
881
  pendingFromClient.delete(scanned.id);
882
+ // The client's initialize is satisfied the instant its response is
883
+ // forwarded. Latch it so the next respawn swallows its replay (steady
884
+ // state) and so handleChildGone is free to error this id on a later death.
885
+ if (cachedInitRequest && scanned.id === cachedInitRequest.id) clientInitAnswered = true;
849
886
  }
850
887
  writeToClient(line);
851
888
  }
@@ -896,10 +933,29 @@ function handleChildGone(why) {
896
933
  }
897
934
  const _pendingClientAtGone = pendingFromClient.size;
898
935
  const _pendingInternalAtGone = pendingInternal.size;
936
+ // First-boot recovery invariant: the client's initialize must receive exactly
937
+ // one success response, from whichever child completes the handshake. If it
938
+ // has not been answered yet (clientInitAnswered=false), erroring it here makes
939
+ // the client mark the MCP server failed — it never re-issues initialize on its
940
+ // own, and the replay (internal id) never reaches it. That is the "startup
941
+ // fails, /mcp fixes it" symptom. Keep that single id pending across the flush;
942
+ // replayInitToChild re-drives it under the client's own id against the fresh
943
+ // child so the success response flows straight back. shuttingDown never
944
+ // preserves (the supervisor is exiting; nothing will replay).
945
+ const _preserveInitId = (!shuttingDown && !clientInitAnswered && cachedInitRequest)
946
+ ? cachedInitRequest.id
947
+ : undefined;
948
+ const _preservedInit = _preserveInitId !== undefined
949
+ ? pendingFromClient.get(_preserveInitId)
950
+ : undefined;
899
951
  for (const [id] of pendingFromClient) {
952
+ if (id === _preserveInitId) continue;
900
953
  sendErrorToClient(id, -32603, `[run-mcp] mcp child ${why.tag}; retry`);
901
954
  }
902
955
  pendingFromClient.clear();
956
+ if (_preserveInitId !== undefined && _preservedInit !== undefined) {
957
+ pendingFromClient.set(_preserveInitId, _preservedInit);
958
+ }
903
959
  pendingInternal.clear();
904
960
  // Fresh child = fresh response path; discard any in-flight liveness probe.
905
961
  _livenessPingId = null;
@@ -285,7 +285,6 @@ async function spawnServerWithLog(pluginRoot, pluginData, { openOnStart = true }
285
285
  MIXDOG_SETUP_OPEN_ON_START: openOnStart ? '1' : '0',
286
286
  MIXDOG_SETUP_PARENT_PID: String(findAncestorPid() || ''),
287
287
  },
288
- windowsHide: true,
289
288
  });
290
289
  } catch (error) {
291
290
  closeLog(launchLog.fd);
@@ -1236,28 +1236,18 @@ async function openAppWindowSequence() {
1236
1236
  // inherit so they do not allocate their own conhost instances.
1237
1237
  const escVbs = s => String(s).replace(/"/g, '""');
1238
1238
  const argsStr = args.join(' ');
1239
- // Warm open + cold-open invariant. The profile dir is stable per
1240
- // install (CHROME_PROFILE_DIR) and chrome enforces ONE singleton per
1241
- // --user-data-dir, so two facts decide the action:
1242
- //
1243
- // 1. A live mixdog chrome already owns the profile (FindChromePid by
1244
- // --app + --user-data-dir, ignoring --type= helpers) just focus
1245
- // it. Killing+respawning a live window is wasteful and races the
1246
- // /generation self-close poll. (warm open)
1247
- //
1248
- // 2. No live owner the profile may still carry a STALE singleton
1249
- // lock (SingletonLock/Socket/Cookie) left by a prior chrome that
1250
- // was force-killed (takeover taskkill /T /F) or lost to sleep/
1251
- // crash. A fresh `--app` launch then rendezvouses with that dead
1252
- // instance over the singleton socket, forwards its URL, and exits
1253
- // WITHOUT opening a window — the reported cold-open bug (URL
1254
- // printed, no window, a later /open works once the lock is reaped).
1255
- // Deleting the stale Singleton* files first guarantees chrome
1256
- // boots a real window instead of IPC-forwarding to a ghost. This
1257
- // is the invariant ("spawn into a clean singleton when no live
1258
- // owner"), not a retry. The title-scoped taskkill is kept only as
1259
- // a defensive belt for a same-title window with a non-matching
1260
- // command line; it is a no-op in the common case.
1239
+ // Hybrid warm-focus / ghost-respawn. A chrome process can outlive its
1240
+ // window (closed / crashed / IPC-forwarded over the singleton socket),
1241
+ // so "a process exists" never proves "a window is visible" — the old
1242
+ // focus-if-found path then opened nothing (the "URL printed, no window"
1243
+ // cold-open bug), while killing+respawning on EVERY open cold-boots
1244
+ // chrome each time (slow). Balance: TryFocus the existing MAIN with a
1245
+ // short bound. If it activates → healthy window, just focus it (fast,
1246
+ // no respawn). If it can't (ghost) or none exists → KillMixdogChromes
1247
+ // for this profile (--user-data-dir match, MAIN + helpers), clear the
1248
+ // stale Singleton* locks, spawn one fresh --app window. The VBS finally
1249
+ // re-checks FindChromePid and exits 0 only if a MAIN is live (else 2 →
1250
+ // default-browser fallback in JS), so /open's ok reflects a real window.
1261
1251
  //
1262
1252
  // The taskkill → chrome chain runs under one hidden cmd.exe (one
1263
1253
  // cmd.exe per /open). `&` runs both regardless of exit code (taskkill
@@ -1275,7 +1265,7 @@ async function openAppWindowSequence() {
1275
1265
  const vbsLines = [
1276
1266
  'Option Explicit',
1277
1267
  'Const HIDDEN_WINDOW = 0',
1278
- 'Dim Wmi, Startup, Wsh, cmdLine, cmdPid, rc, appNeedle, profileNeedle, existingPid, profileDir, procName',
1268
+ 'Dim Wmi, Startup, Wsh, cmdLine, cmdPid, rc, appNeedle, profileNeedle, existingPid, profileDir, procName, focused',
1279
1269
  'Set Wmi = GetObject("winmgmts:{impersonationLevel=impersonate}!\\\\.\\root\\cimv2")',
1280
1270
  'Set Wsh = CreateObject("WScript.Shell")',
1281
1271
  `appNeedle = "${escVbs(appNeedle)}"`,
@@ -1283,15 +1273,22 @@ async function openAppWindowSequence() {
1283
1273
  `profileDir = "${escVbs(chromeProfile)}"`,
1284
1274
  `procName = "${escVbs(procName)}"`,
1285
1275
  'existingPid = FindChromePid(Wmi, appNeedle, profileNeedle)',
1286
- 'If existingPid = 0 Then',
1276
+ 'focused = False',
1277
+ 'If existingPid <> 0 Then focused = TryFocus(Wsh, existingPid, 8)',
1278
+ 'If Not focused Then',
1279
+ ' Call KillMixdogChromes(Wmi, profileNeedle)',
1280
+ ' WScript.Sleep 200',
1287
1281
  ' Call ClearSingletonLocks(profileDir)',
1288
1282
  ' Set Startup = Wmi.Get("Win32_ProcessStartup").SpawnInstance_',
1289
1283
  ' Startup.ShowWindow = HIDDEN_WINDOW',
1290
1284
  ` cmdLine = "cmd.exe ${escVbs(cmdArg)}"`,
1291
1285
  ' rc = Wmi.Get("Win32_Process").Create(cmdLine, Null, Startup, cmdPid)',
1292
1286
  ' If rc <> 0 Then WScript.Quit rc',
1287
+ ' Call FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
1293
1288
  'End If',
1294
- 'Call FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
1289
+ 'existingPid = FindChromePid(Wmi, appNeedle, profileNeedle)',
1290
+ 'If existingPid = 0 Then WScript.Quit 2',
1291
+ 'WScript.Quit 0',
1295
1292
  '',
1296
1293
  'Sub ClearSingletonLocks(profileDir)',
1297
1294
  ' Dim Fso, names, i, p',
@@ -1305,6 +1302,32 @@ async function openAppWindowSequence() {
1305
1302
  ' On Error GoTo 0',
1306
1303
  'End Sub',
1307
1304
  '',
1305
+ 'Sub KillMixdogChromes(Wmi, profileNeedle)',
1306
+ ' Dim proc, commandLine',
1307
+ ' On Error Resume Next',
1308
+ ' For Each proc In Wmi.ExecQuery("SELECT ProcessId,CommandLine FROM Win32_Process WHERE Name = \'" & procName & "\'")',
1309
+ ' commandLine = ""',
1310
+ ' If Not IsNull(proc.CommandLine) Then commandLine = CStr(proc.CommandLine)',
1311
+ ' If InStr(1, commandLine, profileNeedle, vbTextCompare) > 0 Then proc.Terminate',
1312
+ ' Next',
1313
+ ' On Error GoTo 0',
1314
+ 'End Sub',
1315
+ '',
1316
+ 'Function TryFocus(Wsh, pid, maxTicks)',
1317
+ ' Dim i, ok',
1318
+ ' ok = False',
1319
+ ' On Error Resume Next',
1320
+ ' For i = 1 To maxTicks',
1321
+ ' Wsh.SendKeys "%"',
1322
+ ' WScript.Sleep 25',
1323
+ ' ok = Wsh.AppActivate(CLng(pid))',
1324
+ ' If ok Then Exit For',
1325
+ ' WScript.Sleep 120',
1326
+ ' Next',
1327
+ ' On Error GoTo 0',
1328
+ ' TryFocus = ok',
1329
+ 'End Function',
1330
+ '',
1308
1331
  'Sub FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
1309
1332
  ' Dim i, pid, activated',
1310
1333
  ' On Error Resume Next',
@@ -1370,29 +1393,37 @@ async function openAppWindowSequence() {
1370
1393
  // parented to wscript, so a tree-kill of wscript reaps only the focus
1371
1394
  // loop.
1372
1395
  const WSCRIPT_OPEN_DEADLINE_MS = 12000;
1373
- const timedOut = await new Promise(resolve => {
1396
+ const outcome = await new Promise(resolve => {
1374
1397
  const wscriptChild = spawn('wscript.exe', ['//B', '//NoLogo', vbsPath], {
1375
1398
  stdio: 'ignore', windowsHide: true,
1376
1399
  });
1377
1400
  let settled = false;
1378
- const finish = via => { if (settled) return; settled = true; clearTimeout(timer); resolve(via); };
1401
+ const finish = v => { if (settled) return; settled = true; clearTimeout(timer); resolve(v); };
1379
1402
  const timer = setTimeout(() => {
1380
1403
  // Tree-kill the wscript launcher only; the detached browser lives on.
1381
1404
  try { spawnSync('taskkill', ['/F', '/T', '/PID', String(wscriptChild.pid)], { windowsHide: true, stdio: 'ignore', timeout: 4000 }); } catch {}
1382
1405
  try { wscriptChild.kill(); } catch {}
1383
- finish(true);
1406
+ finish({ timedOut: true });
1384
1407
  }, WSCRIPT_OPEN_DEADLINE_MS);
1385
1408
  if (typeof timer.unref === 'function') timer.unref();
1386
- wscriptChild.once('error', () => finish(false));
1387
- wscriptChild.once('exit', () => finish(false));
1409
+ wscriptChild.once('error', () => finish({ timedOut: false, code: -1 }));
1410
+ wscriptChild.once('exit', code => finish({ timedOut: false, code }));
1388
1411
  });
1389
- if (timedOut) {
1412
+ if (outcome.timedOut) {
1390
1413
  const err = `wscript launcher did not exit within ${WSCRIPT_OPEN_DEADLINE_MS}ms; killed launcher (browser left running)`;
1391
1414
  attempts.push({ method: 'browser app mode (wscript)', ok: false, error: err });
1392
1415
  logOpenFailure('browser app mode (wscript)', err);
1393
- } else {
1416
+ } else if (outcome.code === 0) {
1417
+ // VBS exits 0 only after it re-checks FindChromePid post-spawn and a
1418
+ // MAIN (non-helper) chrome for this profile is live — an honest
1419
+ // "window materialized" signal, not just "the launcher exited". Any
1420
+ // other code (esp. 2 = no window) falls through to the default browser.
1394
1421
  chromeSpawnOk = true;
1395
1422
  attempts.push({ method: 'browser app mode (wscript)', ok: true });
1423
+ } else {
1424
+ const err = `wscript exited ${outcome.code}; config window did not materialize`;
1425
+ attempts.push({ method: 'browser app mode (wscript)', ok: false, error: err });
1426
+ logOpenFailure('browser app mode (wscript)', err);
1396
1427
  }
1397
1428
  } catch (error) {
1398
1429
  attempts.push({ method: 'browser app mode (wscript)', ok: false, error: formatOpenError(error) });
@@ -2218,12 +2249,19 @@ async function handleRequest(req, res) {
2218
2249
  const rawMaint = cfg.maintenance || {};
2219
2250
  // Strip legacy keys that no longer belong in maintenance
2220
2251
  // (classification/recap were retired with the cycle1 split;
2221
- // scheduler/webhook keep their model per-entry).
2252
+ // scheduler/webhook keep their model per-entry; the three memory-cycle
2253
+ // MODEL presets collapsed into a single `memory` key — fold the first
2254
+ // present legacy cycle value into `memory` before stripping).
2222
2255
  // Persist back when the stored config carried any of them so the Setup
2223
2256
  // panel and the runtime resolver stop having to dual-match name vs id.
2224
2257
  const allowedKeys = new Set([...Object.keys(DEFAULT_MAINTENANCE), ...MAINTENANCE_SLOTS]);
2225
2258
  const cleanMaint = {};
2226
2259
  let changed = false;
2260
+ const legacyCycleKeys = ['cycle1', 'cycle2', 'cycle3'];
2261
+ if (!('memory' in rawMaint) && legacyCycleKeys.some(k => k in rawMaint)) {
2262
+ cleanMaint.memory = rawMaint.cycle1 ?? rawMaint.cycle2 ?? rawMaint.cycle3 ?? DEFAULT_MAINTENANCE.memory;
2263
+ changed = true;
2264
+ }
2227
2265
  for (const [k, v] of Object.entries(rawMaint)) {
2228
2266
  if (allowedKeys.has(k)) cleanMaint[k] = v;
2229
2267
  else changed = true;
@@ -2267,6 +2305,15 @@ async function handleRequest(req, res) {
2267
2305
  return;
2268
2306
  }
2269
2307
  const nextMaint = { ...(cfg.maintenance || {}) };
2308
+ // Migrate any stored legacy cycle1/2/3 model keys into `memory` (one-time
2309
+ // schema collapse) so the persisted config never carries them forward.
2310
+ {
2311
+ const legacyCycleKeys = ['cycle1', 'cycle2', 'cycle3'];
2312
+ if (!('memory' in nextMaint) && legacyCycleKeys.some(k => k in nextMaint)) {
2313
+ nextMaint.memory = nextMaint.cycle1 ?? nextMaint.cycle2 ?? nextMaint.cycle3 ?? DEFAULT_MAINTENANCE.memory;
2314
+ }
2315
+ for (const k of legacyCycleKeys) delete nextMaint[k];
2316
+ }
2270
2317
  for (const [k, v] of Object.entries(data)) {
2271
2318
  if (v == null || v === '') delete nextMaint[k]; // inherit → remove override
2272
2319
  else nextMaint[k] = v;
package/setup/setup.html CHANGED
@@ -3642,9 +3642,7 @@ async function srSavePanel() {
3642
3642
  // -- Agent Maintenance --
3643
3643
  const AG_MAINT_TASKS = [
3644
3644
  { id: 'explore', label: 'Explore', desc: 'Filesystem exploration agent (explore tool)' },
3645
- { id: 'cycle1', label: 'Memory Cycle 1', desc: 'Chunker / classifier (memory ingestion)' },
3646
- { id: 'cycle2', label: 'Memory Cycle 2', desc: 'Root re-scorer (core memory promotion)' },
3647
- { id: 'cycle3', label: 'Memory Cycle 3', desc: 'Core memory reviewer' },
3645
+ { id: 'memory', label: 'Memory Cycles', desc: 'Chunker / re-scorer / core reviewer (cycles 1-3)' },
3648
3646
  ];
3649
3647
  let agMaintenance = {};
3650
3648
  let agMaintenanceDefaults = {};
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: setup
3
- description: Invoke for ANY mixdog setup or config task — onboarding a fresh install AND editing existing config. Covers agent providers/models, bridge role→preset mapping (changing worker/reviewer/tester/debugger models), agent.presets, channels/Discord, memory, search, webhook/ngrok, quiet hours, DM access, address form (user title), launch flag, and secrets. Trigger on intents like "mixdog config", "edit/change settings", "set up", "세팅"/"설정 수정", "호칭 변경" (change how the assistant addresses the user), "조용시간"/quiet hours, DM access, "change worker (or any role) model", "add a preset", "switch provider", or first-time "what goes where". Also handles uninstall/restore intents ("remove mixdog", "restore pre-install state", "원상복구") — route those to `node scripts/uninstall.mjs` and UNINSTALL.md.
3
+ description: Invoke for ANY mixdog setup or config task — onboarding a fresh install AND editing existing config. Covers agent providers/models, bridge role→preset mapping (changing worker/reviewer/tester/debugger models), agent.presets, the cross-verification workflow text & role roster (add/remove a role, tune the debugger/reviewer loop prose), channels/Discord, memory, search, webhook/ngrok, quiet hours, DM access, address form (user title), launch flag, and secrets. Trigger on intents like "mixdog config", "edit/change settings", "set up", "세팅"/"설정 수정", "호칭 변경" (change how the assistant addresses the user), "조용시간"/quiet hours, DM access, "change worker (or any role) model", "add a preset", "switch provider", "remove the tester role", "tune the debugger loop", "edit the workflow prose", or first-time "what goes where". Also handles uninstall/restore intents ("remove mixdog", "restore pre-install state", "원상복구") — route those to `node scripts/uninstall.mjs` and UNINSTALL.md.
4
4
  version: 0.1.0
5
5
  ---
6
6
 
@@ -14,6 +14,7 @@ Guided onboarding for a fresh mixdog install **and** a reference for editing exi
14
14
  |---|---|---|
15
15
  | Any setting value (channels, memory, agent presets, search, capabilities, cwd) | `${DATA}/mixdog-config.json` | `defaults/*.template.json`, anything under `<plugin>/cache/` |
16
16
  | Which model a role uses | `${DATA}/user-workflow.json` (`roles[].preset`) | `defaults/user-workflow.json` |
17
+ | Add/remove a role (e.g. drop `tester`), or tune the workflow loop prose (cross-verification / debugger fan-out) | `${DATA}/user-workflow.json` (`roles[]`) **and** `${DATA}/user-workflow.md` (loop text) | the `defaults/` + `cache/` copies — those are the shipped template, edited only to change fresh-install defaults |
17
18
  | A secret (bot token, API key, authtoken) | OS keychain / `MIXDOG_*` env | any JSON file |
18
19
  | Skill text / prompts / plugin code | the marketplace **source** (`<plugin>/marketplaces/trib-plugin/`; dev installs: the source repo, then sync) | the `cache/` copy — it is overwritten on update/sync |
19
20
 
@@ -51,10 +52,19 @@ Guided onboarding for a fresh mixdog install **and** a reference for editing exi
51
52
  ## Config structure & editing
52
53
 
53
54
  - Config lives under the plugin data dir `~/.claude/plugins/data/mixdog-trib-plugin/` (the `CLAUDE_PLUGIN_DATA` env var overrides it). Two files matter:
54
- - **user-workflow.json** — `roles[] {name, preset, permission}` maps each bridge role to a preset (the active role set is whatever this file defines — e.g. worker / reviewer / debugger / tester). This sets which model each role uses. To change a role's model, set its `preset` to one of the preset names below (takes effect LIVE — bridge dispatch re-reads this file on every spawn; only the Lead's injected `# Roles` list waits for the next SessionStart).
55
+ - **user-workflow.json** — `roles[] {name, preset, permission}` maps each bridge role to a preset (the active role set is whatever this file defines — e.g. worker / reviewer / debugger). This sets which model each role uses. To change a role's model, set its `preset` to one of the preset names below (takes effect LIVE — bridge dispatch re-reads this file on every spawn; only the Lead's injected `# Roles` list waits for the next SessionStart).
55
56
  - **mixdog-config.json → `agent.presets[] {id, name, type, provider, model, tools, effort?, fast?, xaiCacheMaxInFlight?}`** defines each preset, e.g. opus-high → claude-opus-4-8 / high, composer-2.5 → grok-composer-2.5-fast, gpt-5.5-xhigh → gpt-5.5 / xhigh. `type` is `"bridge"` and `tools` is `"full"` for normal worker presets; `xaiCacheMaxInFlight` is a grok-oauth-only tuning knob. `agent.default` is the fallback preset for any role without its own mapping. To offer a new model, add a preset here first, then point a role at it in user-workflow.json.
56
57
  - Edit via the Setup UI (Custom Workflow for role→preset, Agent presets for presets) or edit the JSON files directly (reloads next session). These are user data — no rebuild needed.
57
58
 
59
+ ### Workflow files — quick-edit map (NO exploration needed)
60
+
61
+ Two sibling files under `${DATA}` (`~/.claude/plugins/data/mixdog-trib-plugin/`) define the active bridge workflow — edit them directly, do not go hunting:
62
+ - **`${DATA}/user-workflow.json`** — `roles[] {name, preset, permission}`. The active role set IS exactly this list. Add or remove a role here (e.g. drop `tester`); removal takes effect on the next bridge spawn (live re-read). Keep each remaining role's `preset` untouched.
63
+ - **`${DATA}/user-workflow.md`** — the role-assignment table + cross-verification loop prose, injected as the Lead's `# User Workflow` rule. Tune the debugger fan-out / reviewer pairing text here. The Lead's injected copy refreshes on the next SessionStart.
64
+ - To change the **shipped fresh-install defaults** (not just this user), edit the repo's `defaults/user-workflow.{json,md}` to match. `cache/` copies are never the edit target.
65
+
66
+ After editing `${DATA}` files, call `reload_config`; after editing repo `defaults/`, run dev-sync to propagate.
67
+
58
68
  ## Agent providers — supported list & onboarding
59
69
 
60
70
  A preset's `provider` field selects the backend. Authoritative allow-list:
@@ -190,7 +190,7 @@ function scheduleAgentConfigReload(reason = 'change') {
190
190
  _agentConfigReloadTimer.unref?.();
191
191
  }
192
192
 
193
- async function reloadAgentConfig(reason = 'change') {
193
+ export async function reloadAgentConfig(reason = 'change') {
194
194
  if (_agentConfigReloadRunning) {
195
195
  _agentConfigReloadQueued = true;
196
196
  return;
@@ -1,5 +1,6 @@
1
1
  import { resolvePluginData } from '../../shared/plugin-paths.mjs';
2
2
  import { readSection, updateSection, getAgentApiKey } from '../../shared/config.mjs';
3
+ import { OPENAI_COMPAT_PRESETS } from './providers/openai-compat.mjs';
3
4
  import { hasAnthropicOAuthCredentials } from './providers/anthropic-oauth.mjs';
4
5
  import { hasOpenAIOAuthCredentials } from './providers/openai-oauth.mjs';
5
6
  import { hasGrokOAuthCredentials } from './providers/grok-oauth.mjs';
@@ -23,15 +24,17 @@ const ENV_KEY_MAP = {
23
24
  // resolvePresetName() (bridge-llm) always resolves a model directly from
24
25
  // `maint[slot]` — no shared `defaultPreset` fallback is needed or used.
25
26
  // Memory cycles + Lead helper fan-out (explore/cycle1/cycle2/cycle3) and
26
- // entry-driven dispatch (scheduler/webhook) all default to `haiku`.
27
+ // entry-driven dispatch (scheduler/webhook) all default to `haiku`. The three
28
+ // memory cycles (chunker / re-scorer / core reviewer) share ONE `memory`
29
+ // preset knob — the cycle agents stay separate (cycle1/2/3-agent, distinct
30
+ // slots and invokedBy) but resolve their model from `maint.memory` via the
31
+ // `maintKey` override on their hidden-role entries.
27
32
  // scheduler/webhook still let a per-entry config.json model win first (the
28
33
  // caller passes it explicitly via opts.preset); the haiku default below only
29
34
  // applies when an entry omits its own model.
30
35
  export const DEFAULT_MAINTENANCE = Object.freeze({
31
36
  explore: 'haiku',
32
- cycle1: 'haiku',
33
- cycle2: 'haiku',
34
- cycle3: 'haiku',
37
+ memory: 'haiku',
35
38
  scheduler: 'haiku',
36
39
  webhook: 'haiku',
37
40
  });
@@ -41,7 +44,7 @@ export const DEFAULT_MAINTENANCE = Object.freeze({
41
44
  // SUBSET of DEFAULT_MAINTENANCE: scheduler/webhook carry a per-entry model and
42
45
  // are not shown as shared rows, but still inherit the haiku default above when
43
46
  // an entry omits its own model.
44
- export const MAINTENANCE_SLOTS = Object.freeze(['explore', 'cycle1', 'cycle2', 'cycle3']);
47
+ export const MAINTENANCE_SLOTS = Object.freeze(['explore', 'memory']);
45
48
 
46
49
  // Map short Anthropic family labels to the full model ids used by the API.
47
50
  // Honors ANTHROPIC_DEFAULT_{OPUS|SONNET|HAIKU}_MODEL env overrides.
@@ -135,7 +138,11 @@ export function loadConfig() {
135
138
  // Provider API keys live in the OS keychain (std env / MIXDOG_AGENT_*
136
139
  // -> keychain), never plaintext in config. Overlay them so the
137
140
  // provider clients see config.apiKey populated.
138
- for (const name of Object.keys(ENV_KEY_MAP)) {
141
+ // ENV_KEY_MAP covers first-class key providers; OPENAI_COMPAT_PRESETS
142
+ // covers compat providers (opencode-go, …) whose key also lives in
143
+ // the keychain. Without the union, a compat provider with a valid
144
+ // stored key still ships 'no-key' → 401.
145
+ for (const name of new Set([...Object.keys(ENV_KEY_MAP), ...Object.keys(OPENAI_COMPAT_PRESETS)])) {
139
146
  const kc = getAgentApiKey(name);
140
147
  if (kc) mergedProviders[name] = { ...(mergedProviders[name] || {}), apiKey: kc, enabled: true };
141
148
  }
@@ -148,6 +155,19 @@ export function loadConfig() {
148
155
  for (const [k, v] of Object.entries(raw.maintenance || {})) {
149
156
  if (allowedMaintKeys.has(k)) rawMaint[k] = v;
150
157
  }
158
+ // One-time schema migration: the three memory-cycle MODEL presets
159
+ // (cycle1/cycle2/cycle3) collapsed into a single `memory` key. If the
160
+ // stored config still carries any old cycle key and no `memory`, fold
161
+ // the first present value into `memory` (preserving the user's
162
+ // choice), then the old keys drop via the allow-list above. This is a
163
+ // schema migration, NOT a runtime fallback — the persisted config is
164
+ // cleaned once so runtime never has to re-migrate.
165
+ const legacyCycleKeys = ['cycle1', 'cycle2', 'cycle3'];
166
+ let migratedMaintenance = false;
167
+ if (!('memory' in rawMaint) && legacyCycleKeys.some(k => k in (raw.maintenance || {}))) {
168
+ rawMaint.memory = raw.maintenance.cycle1 ?? raw.maintenance.cycle2 ?? raw.maintenance.cycle3 ?? DEFAULT_MAINTENANCE.memory;
169
+ migratedMaintenance = true;
170
+ }
151
171
  // Self-ref guard: mcpServers.mixdog / mcpServers["trib-plugin"]
152
172
  // would self-spawn through the in-process tool bridge. Strip on
153
173
  // ingress so user-edited configs cannot brick the agent boot.
@@ -180,6 +200,38 @@ export function loadConfig() {
180
200
  process.stderr.write(`[config] persist sanitized config failed: ${err?.message}\n`);
181
201
  }
182
202
  }
203
+ // Persist the memory-cycle schema migration once. rawMaint already
204
+ // carries the folded `memory` key and excludes the dropped cycle1/2/3
205
+ // keys (not in the allow-list); rebase onto the in-lock current so a
206
+ // concurrent writer's unrelated edits survive, mirroring the
207
+ // mcpServers self-ref strip above.
208
+ if (migratedMaintenance) {
209
+ try {
210
+ persistAgentConfig((current) => {
211
+ const cur = { ...current };
212
+ const target = (cur.agent && cur.agent.providers)
213
+ ? (cur.agent = { ...cur.agent })
214
+ : cur;
215
+ const curMaint = (target.maintenance && typeof target.maintenance === 'object') ? { ...target.maintenance } : {};
216
+ // Derive `memory` from the IN-LOCK current, not the
217
+ // pre-lock rawMaint snapshot — a concurrent writer may
218
+ // have set maintenance.memory or changed a legacy cycle
219
+ // value between this loadConfig()'s read and the lock.
220
+ // If `memory` is already present in-lock, preserve it
221
+ // (lost-update guard); otherwise fold the in-lock legacy
222
+ // cycle value first, with the pre-lock snapshot as the
223
+ // last-resort seed.
224
+ if (!('memory' in curMaint)) {
225
+ curMaint.memory = curMaint.cycle1 ?? curMaint.cycle2 ?? curMaint.cycle3 ?? rawMaint.memory;
226
+ }
227
+ for (const k of legacyCycleKeys) delete curMaint[k];
228
+ target.maintenance = curMaint;
229
+ return cur;
230
+ });
231
+ } catch (err) {
232
+ process.stderr.write(`[config] persist maintenance migration failed: ${err?.message}\n`);
233
+ }
234
+ }
183
235
  const rawPresets = Array.isArray(raw.presets) ? raw.presets : [];
184
236
  const normalizedPresets = rawPresets.map(p => normalizePreset(p)).filter(Boolean);
185
237
  return {
@@ -32,7 +32,7 @@ const MODELSDEV_URL = 'https://models.dev/api.json';
32
32
  const MODELSDEV_CACHE_FILE = 'modelsdev-catalog.json';
33
33
 
34
34
  // mixdog provider id → models.dev provider id. Identity for ids that already
35
- // match (opencode-go / deepseek / xai / nvidia / openai / anthropic / groq /
35
+ // match (opencode-go / deepseek / xai / openai / anthropic / groq /
36
36
  // mistral); only the OAuth aliases and gemini→google need remapping.
37
37
  const _MODELSDEV_PROVIDER_ALIAS = {
38
38
  'anthropic-oauth': 'anthropic',
@@ -628,7 +628,7 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
628
628
  if (isBuiltinTool(name)) {
629
629
  // clientHostPid threaded for the same per-terminal job-scope reason as
630
630
  // the bash branch above (see resolveJobOwnerHostPid).
631
- return executeBuiltinTool(name, args, cwd, { sessionId: callerSessionId, clientHostPid: sessionRef?.clientHostPid, ...toolOpts });
631
+ return executeBuiltinTool(name, args, cwd, { sessionId: callerSessionId, clientHostPid: sessionRef?.clientHostPid, signal: executeOpts.signal, ...toolOpts });
632
632
  }
633
633
  return formatUnknownBuiltinToolMessage(name, args, 'tool');
634
634
  }
@@ -807,7 +807,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
807
807
  try {
808
808
  const permBlocked = _checkWorkerPermission(call.name, call.arguments, sessionRef);
809
809
  if (permBlocked !== null) return { ok: true, value: permBlocked };
810
- return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id }) };
810
+ return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal }) };
811
811
  } catch (error) {
812
812
  return { ok: false, error };
813
813
  }
@@ -1185,7 +1185,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1185
1185
  toolEndedAt = Date.now();
1186
1186
  _resultKind = 'error';
1187
1187
  } else {
1188
- result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id });
1188
+ result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal });
1189
1189
  toolEndedAt = Date.now();
1190
1190
  // Boundary: tool-return string convention → structural kind.
1191
1191
  // The only prefix check in this codebase; downstream layers
@@ -117,13 +117,17 @@ export function resolvePresetName({ preset, optsPreset, role, config: cfgIn = nu
117
117
  // Hidden roles resolve their maintenance preset by SLOT. Every slot carries
118
118
  // a concrete default in DEFAULT_MAINTENANCE, so `maint[slot]` resolves
119
119
  // directly; the Setup panel can still tune each slot independently.
120
- // (explorer.slot = 'explore', cycle1-agent.slot = 'cycle1', …)
120
+ // (explorer.slot = 'explore', cycle1-agent.slot = 'cycle1', …). A hidden
121
+ // role may override which maintenance key it reads via `maintKey`
122
+ // (e.g. the cycle1/2/3 agents all read `maint.memory` instead of their
123
+ // own slot) so several agents can share one model knob while keeping
124
+ // distinct slots/identity.
121
125
  const hidden = getHiddenRole(role);
122
126
  if (hidden) {
123
127
  try {
124
128
  const config = cfgIn || loadConfig();
125
129
  const maint = config?.maintenance || {};
126
- return maint[hidden.slot] || null;
130
+ return maint[hidden.maintKey || hidden.slot] || null;
127
131
  } catch { return null; }
128
132
  }
129
133
  try {
@@ -47,6 +47,7 @@ import { stripQuotedAndHeredoc, extractShellCInner } from './destructive-warning
47
47
  import { _maybeEncodePowerShellCommand } from './shell-command.mjs';
48
48
  import { _captureTrackedMtimes, _trackedDriftNoteAfter, _injectionBlockTargets, getDedupedDestructiveWarnings } from './builtin/bash-tool.mjs';
49
49
  import { scrubLoaderVars, scrubProviderSecrets } from './env-scrub.mjs';
50
+ import { checkExecPolicyMessage } from './bash-policy-scan.mjs';
50
51
 
51
52
  // Default 600 s (10 min), max 1800 s. Aligned with the one-shot bash tool's
52
53
  // 600 s default (builtin/bash-tool.mjs); the persistent shell carries
@@ -125,7 +125,7 @@ export const BUILTIN_TOOLS = [
125
125
  name: 'bash',
126
126
  title: 'Mixdog Shell',
127
127
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
128
- description: "Shell for git/build/test/run. Use current-OS syntax: Windows default = PowerShell; POSIX default = /bin/sh. Always pass shell matching your syntax: 'bash' = POSIX via Git Bash, 'powershell' = PS cmdlets; omitting uses the OS default and mis-parses the other. run_in_background works for both shells, including Windows shell:'bash' (Git Bash). Single shell entry point; not for inline code you were asked to return.",
128
+ description: "Shell for git/build/test/run. ALWAYS set `shell` explicitly ('bash' = POSIX via Git Bash, 'powershell' = PS cmdlets); omitting defaults to the OS shell (Windows = PowerShell, POSIX = /bin/sh) and mis-parses the other syntax. run_in_background works for both shells, including Windows shell:'bash' (Git Bash). Single shell entry point; not for inline code you were asked to return.",
129
129
  inputSchema: {
130
130
  type: 'object',
131
131
  properties: {