mixdog 0.7.11 → 0.7.13

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 (67) hide show
  1. package/.claude-plugin/marketplace.json +5 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +28 -74
  4. package/README.md +193 -249
  5. package/bin/statusline-launcher.mjs +5 -1
  6. package/bin/statusline-lib.mjs +14 -6
  7. package/bin/statusline.mjs +14 -6
  8. package/bun.lock +128 -3
  9. package/defaults/hidden-roles.json +3 -0
  10. package/defaults/user-workflow.json +1 -2
  11. package/defaults/user-workflow.md +5 -1
  12. package/hooks/lib/settings-loader.cjs +4 -3
  13. package/hooks/pre-tool-subagent.cjs +7 -2
  14. package/hooks/session-start.cjs +52 -24
  15. package/lib/mixdog-debug.cjs +163 -0
  16. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  17. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  18. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  19. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  20. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  21. package/package.json +9 -2
  22. package/scripts/builtin-utils-smoke.mjs +14 -8
  23. package/scripts/bump.mjs +80 -0
  24. package/scripts/doctor.mjs +8 -3
  25. package/scripts/ensure-deps.mjs +2 -2
  26. package/scripts/mutation-io-smoke.mjs +17 -1
  27. package/scripts/permission-eval-smoke.mjs +18 -1
  28. package/scripts/run-mcp.mjs +65 -9
  29. package/scripts/statusline-launcher-smoke.mjs +2 -2
  30. package/scripts/webhook-selfheal-smoke.mjs +1 -3
  31. package/server-main.mjs +57 -3
  32. package/setup/install.mjs +574 -574
  33. package/setup/launch-core.mjs +0 -1
  34. package/setup/setup-server.mjs +90 -35
  35. package/setup/setup.html +44 -11
  36. package/skills/setup/SKILL.md +12 -2
  37. package/src/agent/index.mjs +1 -1
  38. package/src/agent/orchestrator/config.mjs +58 -6
  39. package/src/agent/orchestrator/providers/model-catalog.mjs +1 -1
  40. package/src/agent/orchestrator/providers/openai-oauth.mjs +9 -2
  41. package/src/agent/orchestrator/providers/openai-ws.mjs +23 -0
  42. package/src/agent/orchestrator/session/loop.mjs +3 -3
  43. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +6 -2
  44. package/src/agent/orchestrator/tools/bash-session.mjs +1 -0
  45. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  46. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +29 -6
  47. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +8 -4
  48. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +29 -8
  49. package/src/agent/orchestrator/tools/builtin.mjs +5 -2
  50. package/src/agent/orchestrator/tools/cwd-tool.mjs +17 -17
  51. package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
  52. package/src/agent/orchestrator/tools/patch-manifest.json +11 -11
  53. package/src/agent/tool-defs.mjs +1 -1
  54. package/src/channels/index.mjs +39 -9
  55. package/src/channels/lib/event-queue.mjs +24 -1
  56. package/src/channels/lib/hook-pipe-server.mjs +21 -8
  57. package/src/channels/lib/webhook.mjs +159 -20
  58. package/src/memory/index.mjs +5 -1
  59. package/src/memory/lib/core-memory-store.mjs +1 -1
  60. package/src/memory/lib/memory-cycle1.mjs +8 -4
  61. package/src/memory/lib/memory-cycle2.mjs +1 -1
  62. package/src/memory/lib/memory-cycle3.mjs +1 -1
  63. package/src/memory/lib/memory-recall-store.mjs +27 -10
  64. package/src/search/lib/backends/openai-oauth.mjs +6 -2
  65. package/src/search/lib/cache.mjs +55 -7
  66. package/tools.json +2 -2
  67. package/scripts/test-config-rmw-restore.mjs +0 -122
@@ -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);
@@ -599,9 +599,17 @@ function _modelFromConfiguredId(id, provider) {
599
599
  }
600
600
 
601
601
  function _familyFromModelId(id) {
602
- const claude = String(id || '').match(/^claude-(opus|sonnet|haiku)/i);
602
+ const s = String(id || '').toLowerCase();
603
+ const claude = s.match(/^claude-(opus|sonnet|haiku)/i);
603
604
  if (claude) return claude[1].toLowerCase();
604
- const gpt = String(id || '').match(/^(gpt-\d+)/i);
605
+ if (s.includes('nano')) return 'gpt-nano';
606
+ if (s.includes('mini')) return 'gpt-mini';
607
+ if (s.includes('codex')) return 'gpt-codex';
608
+ if (s.startsWith('gpt-5.5')) return 'gpt-5.5';
609
+ if (s.startsWith('gpt-5.4')) return 'gpt-5.4';
610
+ if (s.startsWith('gpt-5.2')) return 'gpt-5.2';
611
+ if (s.startsWith('gpt-5')) return 'gpt-5';
612
+ const gpt = s.match(/^(gpt-\d+(?:\.\d+)?)/i);
605
613
  if (gpt) return gpt[1].toLowerCase();
606
614
  return undefined;
607
615
  }
@@ -1228,28 +1236,18 @@ async function openAppWindowSequence() {
1228
1236
  // inherit so they do not allocate their own conhost instances.
1229
1237
  const escVbs = s => String(s).replace(/"/g, '""');
1230
1238
  const argsStr = args.join(' ');
1231
- // Warm open + cold-open invariant. The profile dir is stable per
1232
- // install (CHROME_PROFILE_DIR) and chrome enforces ONE singleton per
1233
- // --user-data-dir, so two facts decide the action:
1234
- //
1235
- // 1. A live mixdog chrome already owns the profile (FindChromePid by
1236
- // --app + --user-data-dir, ignoring --type= helpers) just focus
1237
- // it. Killing+respawning a live window is wasteful and races the
1238
- // /generation self-close poll. (warm open)
1239
- //
1240
- // 2. No live owner the profile may still carry a STALE singleton
1241
- // lock (SingletonLock/Socket/Cookie) left by a prior chrome that
1242
- // was force-killed (takeover taskkill /T /F) or lost to sleep/
1243
- // crash. A fresh `--app` launch then rendezvouses with that dead
1244
- // instance over the singleton socket, forwards its URL, and exits
1245
- // WITHOUT opening a window — the reported cold-open bug (URL
1246
- // printed, no window, a later /open works once the lock is reaped).
1247
- // Deleting the stale Singleton* files first guarantees chrome
1248
- // boots a real window instead of IPC-forwarding to a ghost. This
1249
- // is the invariant ("spawn into a clean singleton when no live
1250
- // owner"), not a retry. The title-scoped taskkill is kept only as
1251
- // a defensive belt for a same-title window with a non-matching
1252
- // 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.
1253
1251
  //
1254
1252
  // The taskkill → chrome chain runs under one hidden cmd.exe (one
1255
1253
  // cmd.exe per /open). `&` runs both regardless of exit code (taskkill
@@ -1267,7 +1265,7 @@ async function openAppWindowSequence() {
1267
1265
  const vbsLines = [
1268
1266
  'Option Explicit',
1269
1267
  'Const HIDDEN_WINDOW = 0',
1270
- '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',
1271
1269
  'Set Wmi = GetObject("winmgmts:{impersonationLevel=impersonate}!\\\\.\\root\\cimv2")',
1272
1270
  'Set Wsh = CreateObject("WScript.Shell")',
1273
1271
  `appNeedle = "${escVbs(appNeedle)}"`,
@@ -1275,15 +1273,22 @@ async function openAppWindowSequence() {
1275
1273
  `profileDir = "${escVbs(chromeProfile)}"`,
1276
1274
  `procName = "${escVbs(procName)}"`,
1277
1275
  'existingPid = FindChromePid(Wmi, appNeedle, profileNeedle)',
1278
- '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',
1279
1281
  ' Call ClearSingletonLocks(profileDir)',
1280
1282
  ' Set Startup = Wmi.Get("Win32_ProcessStartup").SpawnInstance_',
1281
1283
  ' Startup.ShowWindow = HIDDEN_WINDOW',
1282
1284
  ` cmdLine = "cmd.exe ${escVbs(cmdArg)}"`,
1283
1285
  ' rc = Wmi.Get("Win32_Process").Create(cmdLine, Null, Startup, cmdPid)',
1284
1286
  ' If rc <> 0 Then WScript.Quit rc',
1287
+ ' Call FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
1285
1288
  'End If',
1286
- 'Call FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
1289
+ 'existingPid = FindChromePid(Wmi, appNeedle, profileNeedle)',
1290
+ 'If existingPid = 0 Then WScript.Quit 2',
1291
+ 'WScript.Quit 0',
1287
1292
  '',
1288
1293
  'Sub ClearSingletonLocks(profileDir)',
1289
1294
  ' Dim Fso, names, i, p',
@@ -1297,6 +1302,32 @@ async function openAppWindowSequence() {
1297
1302
  ' On Error GoTo 0',
1298
1303
  'End Sub',
1299
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
+ '',
1300
1331
  'Sub FocusMixdogWindow(Wmi, Wsh, appNeedle, profileNeedle)',
1301
1332
  ' Dim i, pid, activated',
1302
1333
  ' On Error Resume Next',
@@ -1362,29 +1393,37 @@ async function openAppWindowSequence() {
1362
1393
  // parented to wscript, so a tree-kill of wscript reaps only the focus
1363
1394
  // loop.
1364
1395
  const WSCRIPT_OPEN_DEADLINE_MS = 12000;
1365
- const timedOut = await new Promise(resolve => {
1396
+ const outcome = await new Promise(resolve => {
1366
1397
  const wscriptChild = spawn('wscript.exe', ['//B', '//NoLogo', vbsPath], {
1367
1398
  stdio: 'ignore', windowsHide: true,
1368
1399
  });
1369
1400
  let settled = false;
1370
- 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); };
1371
1402
  const timer = setTimeout(() => {
1372
1403
  // Tree-kill the wscript launcher only; the detached browser lives on.
1373
1404
  try { spawnSync('taskkill', ['/F', '/T', '/PID', String(wscriptChild.pid)], { windowsHide: true, stdio: 'ignore', timeout: 4000 }); } catch {}
1374
1405
  try { wscriptChild.kill(); } catch {}
1375
- finish(true);
1406
+ finish({ timedOut: true });
1376
1407
  }, WSCRIPT_OPEN_DEADLINE_MS);
1377
1408
  if (typeof timer.unref === 'function') timer.unref();
1378
- wscriptChild.once('error', () => finish(false));
1379
- wscriptChild.once('exit', () => finish(false));
1409
+ wscriptChild.once('error', () => finish({ timedOut: false, code: -1 }));
1410
+ wscriptChild.once('exit', code => finish({ timedOut: false, code }));
1380
1411
  });
1381
- if (timedOut) {
1412
+ if (outcome.timedOut) {
1382
1413
  const err = `wscript launcher did not exit within ${WSCRIPT_OPEN_DEADLINE_MS}ms; killed launcher (browser left running)`;
1383
1414
  attempts.push({ method: 'browser app mode (wscript)', ok: false, error: err });
1384
1415
  logOpenFailure('browser app mode (wscript)', err);
1385
- } 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.
1386
1421
  chromeSpawnOk = true;
1387
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);
1388
1427
  }
1389
1428
  } catch (error) {
1390
1429
  attempts.push({ method: 'browser app mode (wscript)', ok: false, error: formatOpenError(error) });
@@ -2210,12 +2249,19 @@ async function handleRequest(req, res) {
2210
2249
  const rawMaint = cfg.maintenance || {};
2211
2250
  // Strip legacy keys that no longer belong in maintenance
2212
2251
  // (classification/recap were retired with the cycle1 split;
2213
- // 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).
2214
2255
  // Persist back when the stored config carried any of them so the Setup
2215
2256
  // panel and the runtime resolver stop having to dual-match name vs id.
2216
2257
  const allowedKeys = new Set([...Object.keys(DEFAULT_MAINTENANCE), ...MAINTENANCE_SLOTS]);
2217
2258
  const cleanMaint = {};
2218
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
+ }
2219
2265
  for (const [k, v] of Object.entries(rawMaint)) {
2220
2266
  if (allowedKeys.has(k)) cleanMaint[k] = v;
2221
2267
  else changed = true;
@@ -2259,6 +2305,15 @@ async function handleRequest(req, res) {
2259
2305
  return;
2260
2306
  }
2261
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
+ }
2262
2317
  for (const [k, v] of Object.entries(data)) {
2263
2318
  if (v == null || v === '') delete nextMaint[k]; // inherit → remove override
2264
2319
  else nextMaint[k] = v;
package/setup/setup.html CHANGED
@@ -2640,9 +2640,48 @@ const AG_EFFORT_LABEL = { none: 'None', low: 'Low', medium: 'Medium', high: 'Hig
2640
2640
  // Families that don't support fast mode even when the provider does.
2641
2641
  const AG_FAMILY_NO_FAST = new Set(['haiku', 'gpt-nano', 'gpt-codex']);
2642
2642
  const AG_FAST_PROVIDERS = new Set(['anthropic', 'anthropic-oauth', 'openai', 'openai-oauth']);
2643
+ const AG_OPENAI_DIRECT_FAST_MODEL_RE = [
2644
+ /^gpt-5\.5(?:-\d{4}|$)/,
2645
+ /^gpt-5\.4(?:-\d{4}|$)/,
2646
+ /^gpt-5\.4-mini(?:-\d{4}|$)/,
2647
+ ];
2643
2648
  let agModelList = [];
2644
2649
  const AG_ACCESS_LABELS = { full: 'Read & Write', readonly: 'Read Only', mcp: 'None' };
2645
2650
 
2651
+ function agNormalizeFastProvider(provider) {
2652
+ return provider === 'openai-api' ? 'openai' : provider;
2653
+ }
2654
+
2655
+ function agOpenAIDirectSupportsFast(modelId) {
2656
+ const id = String(modelId || '').trim();
2657
+ return AG_OPENAI_DIRECT_FAST_MODEL_RE.some(re => re.test(id));
2658
+ }
2659
+
2660
+ function agExplicitFastSupport(model) {
2661
+ const hasServiceTiers = Array.isArray(model?.serviceTiers);
2662
+ const hasSpeedTiers = Array.isArray(model?.additionalSpeedTiers);
2663
+ if (!hasServiceTiers && !hasSpeedTiers) return null;
2664
+ const serviceTiers = hasServiceTiers ? model.serviceTiers : [];
2665
+ const speedTiers = hasSpeedTiers ? model.additionalSpeedTiers : [];
2666
+ const serviceFast = serviceTiers.some(t => {
2667
+ const id = typeof t === 'string' ? t : t?.id;
2668
+ return id === 'priority' || id === 'fast';
2669
+ });
2670
+ const speedFast = speedTiers.some(t => t === 'fast' || t === 'priority');
2671
+ return serviceFast || speedFast;
2672
+ }
2673
+
2674
+ function agModelSupportsFast(provider, model) {
2675
+ const normalizedProvider = agNormalizeFastProvider(provider);
2676
+ if (!model) return false;
2677
+ const explicit = agExplicitFastSupport(model);
2678
+ if (explicit !== null) return explicit;
2679
+ if (normalizedProvider === 'openai') return agOpenAIDirectSupportsFast(model.id);
2680
+ const providerFast = AG_FAST_PROVIDERS.has(normalizedProvider);
2681
+ const familyNoFast = model?.family && AG_FAMILY_NO_FAST.has(model.family);
2682
+ return providerFast && !familyNoFast;
2683
+ }
2684
+
2646
2685
  async function loadAgentData() {
2647
2686
  const r = await fetch('/agent/config').then(r => r.json()).catch(() => ({}));
2648
2687
  agConfig = r.config || {};
@@ -2974,9 +3013,7 @@ function agUpdateEffortAndFast(provider) {
2974
3013
  effortSel.innerHTML = allowed.map(v => `<option value="${v}">${AG_EFFORT_LABEL[v] || v}</option>`).join('');
2975
3014
  }
2976
3015
 
2977
- const providerFast = AG_FAST_PROVIDERS.has(provider);
2978
- const familyNoFast = model?.family && AG_FAMILY_NO_FAST.has(model.family);
2979
- const fastAllowed = modelResolved && providerFast && !familyNoFast;
3016
+ const fastAllowed = modelResolved && agModelSupportsFast(provider, model);
2980
3017
  fastRow.style.display = fastAllowed ? 'flex' : 'none';
2981
3018
  if (!fastAllowed) document.getElementById('ag-pf-fast').classList.remove('on');
2982
3019
  }
@@ -3479,8 +3516,8 @@ async function srRenderModelPresets() {
3479
3516
  }
3480
3517
 
3481
3518
  // Reveal/hide effort + fast based on the currently selected openai model.
3482
- // Mirrors agUpdateEffortAndFast() so the rules are identical: model family
3483
- // drives effort options, AG_FAST_PROVIDERS / AG_FAMILY_NO_FAST drive fast.
3519
+ // Mirrors agUpdateEffortAndFast() so the rules are identical: provider/model
3520
+ // metadata drives effort options and Fast Mode availability.
3484
3521
  function srUpdateOpenAIEffortFast() {
3485
3522
  const body = document.getElementById('sr-model-presets-body');
3486
3523
  if (!body) return;
@@ -3505,9 +3542,7 @@ function srUpdateOpenAIEffortFast() {
3505
3542
  effortSel.style.display = '';
3506
3543
  effortSel.innerHTML = allowed.map(v => '<option value="' + v + '"' + (v === storedEffort ? ' selected' : '') + '>' + (AG_EFFORT_LABEL[v] || v) + '</option>').join('');
3507
3544
  }
3508
- const providerFast = AG_FAST_PROVIDERS.has(normalizedProvider);
3509
- const familyNoFast = model?.family && AG_FAMILY_NO_FAST.has(model.family);
3510
- const fastAllowed = !!model && providerFast && !familyNoFast;
3545
+ const fastAllowed = !!model && agModelSupportsFast(normalizedProvider, model);
3511
3546
  const fastLabel = body.querySelector('[data-fam-fast-label="openai"]');
3512
3547
  fastEl.style.display = fastAllowed ? '' : 'none';
3513
3548
  if (fastLabel) fastLabel.style.display = fastAllowed ? '' : 'none';
@@ -3607,9 +3642,7 @@ async function srSavePanel() {
3607
3642
  // -- Agent Maintenance --
3608
3643
  const AG_MAINT_TASKS = [
3609
3644
  { id: 'explore', label: 'Explore', desc: 'Filesystem exploration agent (explore tool)' },
3610
- { id: 'cycle1', label: 'Memory Cycle 1', desc: 'Chunker / classifier (memory ingestion)' },
3611
- { id: 'cycle2', label: 'Memory Cycle 2', desc: 'Root re-scorer (core memory promotion)' },
3612
- { 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)' },
3613
3646
  ];
3614
3647
  let agMaintenance = {};
3615
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',
@@ -133,7 +133,14 @@ function _codexServiceTiers(modelInfo) {
133
133
  return Array.isArray(modelInfo?.serviceTiers) ? modelInfo.serviceTiers : [];
134
134
  }
135
135
 
136
- function _codexModelSupportsServiceTier(id, serviceTier) {
136
+ function _codexModelBlocksServiceTier(id, serviceTier) {
137
+ if (serviceTier !== 'priority') return false;
138
+ const family = _codexFamily(id);
139
+ return family === 'gpt-mini' || family === 'gpt-nano' || family === 'gpt-codex';
140
+ }
141
+
142
+ export function codexModelSupportsServiceTier(id, serviceTier) {
143
+ if (_codexModelBlocksServiceTier(id, serviceTier)) return false;
137
144
  const info = _findCachedCodexModel(id);
138
145
  if (!info) return true;
139
146
  const tiers = _codexServiceTiers(info);
@@ -530,7 +537,7 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
530
537
  // accepts on the wire: 'fast' is hard-rejected ("Unsupported
531
538
  // service_tier: fast", probed 2026-06-11). Match official Codex:
532
539
  // only send the request value when the model catalog advertises it.
533
- if (_codexModelSupportsServiceTier(model, 'priority')) {
540
+ if (codexModelSupportsServiceTier(model, 'priority')) {
534
541
  body.service_tier = 'priority';
535
542
  }
536
543
  }
@@ -16,6 +16,24 @@ import { sendViaWebSocket } from './openai-oauth-ws.mjs';
16
16
  import { buildRequestBody } from './openai-oauth.mjs';
17
17
  import { resolveProviderCacheKey } from '../smart-bridge/cache-strategy.mjs';
18
18
 
19
+ const OPENAI_DIRECT_PRIORITY_MODEL_PATTERNS = Object.freeze([
20
+ /^gpt-5\.5(?:-\d{4}|$)/,
21
+ /^gpt-5\.4(?:-\d{4}|$)/,
22
+ /^gpt-5\.4-mini(?:-\d{4}|$)/,
23
+ ]);
24
+
25
+ export function openAiDirectSupportsPriority(model) {
26
+ const id = String(model || '').trim();
27
+ return OPENAI_DIRECT_PRIORITY_MODEL_PATTERNS.some(re => re.test(id));
28
+ }
29
+
30
+ export function applyOpenAIDirectFastTier(body, model, opts) {
31
+ if (opts?.fast === true && openAiDirectSupportsPriority(model)) {
32
+ body.service_tier = 'priority';
33
+ }
34
+ return body;
35
+ }
36
+
19
37
  export class OpenAIDirectProvider {
20
38
  // input_tokens INCLUDES cached tokens (OpenAI convention). See registry.mjs.
21
39
  static inputExcludesCache = false;
@@ -38,6 +56,11 @@ export class OpenAIDirectProvider {
38
56
  const apiKey = this._ensureKey();
39
57
  const useModel = model || 'gpt-5.5';
40
58
  const body = buildRequestBody(messages, useModel, tools, sendOpts);
59
+ // Public OpenAI API priority support is documented separately from the
60
+ // Codex OAuth catalog. Keep this provider's service-tier decision local
61
+ // so gpt-5.4-mini can opt into Priority even when the Codex catalog does
62
+ // not advertise a Fast tier for its OAuth endpoint.
63
+ applyOpenAIDirectFastTier(body, useModel, opts);
41
64
  // Public Responses API supports prompt_cache_retention='24h' at no
42
65
  // extra cost (same cached_input_tokens billing as the default 5–10
43
66
  // min in-memory cache). Codex/oauth rejects the parameter, so it's
@@ -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: {