mixdog 0.9.34 → 0.9.35

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 (69) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -2
  3. package/scripts/_devtools-stub.mjs +1 -0
  4. package/scripts/_jitter-fuzz.jsx +42 -0
  5. package/scripts/_jitter-fuzz.mjs +44410 -0
  6. package/scripts/_jitter-fuzz2.jsx +30 -0
  7. package/scripts/_jitter-fuzz2.mjs +44400 -0
  8. package/scripts/_jitter-probe.jsx +35 -0
  9. package/scripts/_jitter-probe.mjs +44397 -0
  10. package/scripts/_jp2.jsx +16 -0
  11. package/scripts/_jp2.mjs +45614 -0
  12. package/scripts/agent-live-arg-guard-smoke.mjs +20 -0
  13. package/scripts/agent-live-path-suffix-smoke.mjs +34 -0
  14. package/scripts/agent-live-toolcall-args-smoke.mjs +45 -0
  15. package/scripts/apply-patch-edit-smoke.mjs +71 -0
  16. package/scripts/async-notify-settlement-test.mjs +99 -0
  17. package/scripts/forwarder-rebind-tail-test.mjs +67 -0
  18. package/scripts/live-worker-smoke.mjs +1 -1
  19. package/scripts/pending-completion-drop-test.mjs +46 -27
  20. package/scripts/provider-toolcall-test.mjs +1 -0
  21. package/scripts/recall-bench-cases.json +1 -0
  22. package/scripts/session-bench.mjs +44 -0
  23. package/scripts/shell-hardening-test.mjs +209 -0
  24. package/scripts/ship-mode-test.mjs +98 -0
  25. package/scripts/steering-drain-buckets-test.mjs +59 -0
  26. package/scripts/tool-smoke.mjs +46 -10
  27. package/scripts/worker-notify-rejection-test.mjs +51 -0
  28. package/src/lib/mixdog-debug.cjs +69 -0
  29. package/src/rules/agent/00-common.md +4 -5
  30. package/src/rules/agent/00-core.md +13 -20
  31. package/src/rules/agent/20-skip-protocol.md +3 -8
  32. package/src/rules/agent/30-explorer.md +31 -54
  33. package/src/rules/lead/01-general.md +3 -6
  34. package/src/rules/lead/lead-brief.md +10 -16
  35. package/src/rules/lead/lead-tool.md +3 -5
  36. package/src/rules/shared/01-tool.md +7 -9
  37. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +13 -2
  38. package/src/runtime/agent/orchestrator/providers/gemini.mjs +19 -23
  39. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +21 -5
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +15 -0
  41. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -13
  42. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +21 -7
  43. package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +1 -5
  44. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +71 -1
  45. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +17 -9
  46. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +127 -4
  47. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +105 -28
  48. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  49. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  50. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +7 -3
  51. package/src/runtime/memory/lib/memory-embed.mjs +1 -1
  52. package/src/runtime/memory/lib/query-handlers.mjs +25 -10
  53. package/src/runtime/memory/tool-defs.mjs +3 -3
  54. package/src/runtime/shared/background-tasks.mjs +21 -1
  55. package/src/runtime/shared/tool-execution-contract.mjs +29 -16
  56. package/src/session-runtime/model-route-api.mjs +3 -3
  57. package/src/session-runtime/provider-auth-api.mjs +13 -0
  58. package/src/session-runtime/runtime-core.mjs +40 -6
  59. package/src/session-runtime/tool-catalog.mjs +3 -2
  60. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  61. package/src/standalone/agent-tool/notify.mjs +14 -1
  62. package/src/tui/dist/index.mjs +63 -25
  63. package/src/tui/engine/session-flow.mjs +26 -13
  64. package/src/tui/engine/turn.mjs +7 -8
  65. package/src/tui/engine.mjs +4 -2
  66. package/src/vendor/statusline/bin/statusline-route.mjs +60 -4
  67. package/src/vendor/statusline/src/gateway/route-meta.mjs +37 -8
  68. package/src/workflows/default/WORKFLOW.md +26 -32
  69. package/src/workflows/solo/WORKFLOW.md +12 -13
@@ -477,13 +477,14 @@ export function createQueryHandlers({
477
477
  const applyFreshness = !isCalendarPeriod
478
478
 
479
479
  // period='last': no time window and no session exclusion — 'last' is a
480
- // plain "walk back through ALL prior conversation, newest first" browse.
480
+ // recent-session browse; with a query, filter those recent sessions by
481
+ // topic instead of falling through to the unbounded semantic search path.
481
482
  // No boot-timestamp cap (the old cap hid every session that ran while a
482
483
  // long-lived daemon stayed up), no gap-bounded burst, no current-session
483
484
  // filter: limit/offset page through history and the grouped renderer
484
485
  // separates sessions. temporal stays unbounded (mode marker only).
485
486
 
486
- if (query) {
487
+ if (query && temporal?.mode !== 'last') {
487
488
  const _t0 = Date.now()
488
489
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
489
490
  let queryVector = null
@@ -673,6 +674,20 @@ export function createQueryHandlers({
673
674
  if (temporal?.mode === 'last') {
674
675
  const sessionCount = Number.isFinite(requestedLimit) ? limit : 5
675
676
  const PER_SESSION_ROW_CAP = 10
677
+ const PER_SESSION_SEARCH_CAP = 50
678
+ const queryTerms = sessionRecallTerms(query)
679
+ const matchesQueryTerms = (row) => {
680
+ if (queryTerms.length === 0) return true
681
+ const rowText = `${row?.content ?? ''} ${row?.element ?? ''} ${row?.summary ?? ''}`.toLowerCase()
682
+ if (queryTerms.some((term) => rowText.includes(term))) return true
683
+ if (Array.isArray(row?.members)) {
684
+ return row.members.some((member) => {
685
+ const memberText = `${member?.content ?? ''} ${member?.element ?? ''} ${member?.summary ?? ''}`.toLowerCase()
686
+ return queryTerms.some((term) => memberText.includes(term))
687
+ })
688
+ }
689
+ return false
690
+ }
676
691
  const excludeStatuses = includeArchived ? [] : ['archived']
677
692
  const VALID_LAST_CATS = new Set(['rule', 'constraint', 'decision', 'fact', 'goal', 'preference', 'task', 'issue'])
678
693
  const catList = category == null
@@ -684,7 +699,7 @@ export function createQueryHandlers({
684
699
  // the fill draws from — roots (status-filtered) + unchunked raw
685
700
  // leaves, member rows excluded — so a session ranked here can never
686
701
  // fill empty (e.g. an archived-root session whose only surviving rows
687
- // are members). projectScope + excludeStatuses + category + promoted
702
+ // are members). projectScope + excludeStatuses + promoted
688
703
  // exclusion all match the fill filters below.
689
704
  const selWhere = [
690
705
  'session_id IS NOT NULL',
@@ -702,10 +717,6 @@ export function createQueryHandlers({
702
717
  const ph = excludeStatuses.map((s) => { selParams.push(s); return `$${selParams.length}` }).join(',')
703
718
  selWhere.push(`(status IS NULL OR status NOT IN (${ph}))`)
704
719
  }
705
- if (catList.length > 0) {
706
- const ph = catList.map((c) => { selParams.push(c); return `$${selParams.length}` }).join(',')
707
- selWhere.push(`category IN (${ph})`)
708
- }
709
720
  for (const c of buildPromotedExclusionClauses()) selWhere.push(c)
710
721
  selParams.push(sessionCount, offset)
711
722
  // Deterministic tie-breaker: equal MAX(ts) across sessions would let the
@@ -724,14 +735,15 @@ export function createQueryHandlers({
724
735
  for (const s of sessRows) {
725
736
  const sid = String(s?.session_id || '').trim()
726
737
  if (!sid) continue
727
- const sf = { limit: PER_SESSION_ROW_CAP, session_id: sid, projectScope, sort: 'date' }
738
+ const perSessionFetchCap = queryTerms.length > 0 ? PER_SESSION_SEARCH_CAP : PER_SESSION_ROW_CAP
739
+ const sf = { limit: perSessionFetchCap, session_id: sid, projectScope, sort: 'date' }
728
740
  if (includeMembers) sf.includeMembers = true
729
741
  if (excludeStatuses.length > 0) sf.excludeStatuses = excludeStatuses
730
742
  if (catList.length > 0) sf.category = catList
731
743
  const sRows = await retrieveEntries(db, sf)
732
744
  let merged = sRows
733
745
  if (includeRaw) {
734
- const rawRows = await readRawRowsInWindow(db, null, Date.now(), PER_SESSION_ROW_CAP, { projectScope, sessionId: sid })
746
+ const rawRows = await readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: queryTerms })
735
747
  const seenIds = new Set(sRows.map((r) => Number(r.id)))
736
748
  for (const r of sRows) if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
737
749
  // readRawRowsInWindow carries no category filter, so a category-
@@ -754,9 +766,12 @@ export function createQueryHandlers({
754
766
  if (newRaw.length > 0) {
755
767
  merged = [...sRows, ...newRaw]
756
768
  merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
757
- merged = merged.slice(0, PER_SESSION_ROW_CAP)
758
769
  }
759
770
  }
771
+ if (queryTerms.length > 0) {
772
+ merged = merged.filter(matchesQueryTerms)
773
+ }
774
+ merged = merged.slice(0, PER_SESSION_ROW_CAP)
760
775
  for (const r of merged) allRows.push(r)
761
776
  }
762
777
  const _currentSessionHint = String(args?.currentSessionId || '').trim()
@@ -30,13 +30,13 @@ export const TOOL_DEFS = [
30
30
  name: 'recall',
31
31
  title: 'Recall',
32
32
  annotations: { title: 'Recall', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
33
- description: 'Retrieve stored memory/session history. Call when a task ties to prior work — resumes, continuations, or references to earlier decisions/messages. Do not call as a reflexive pre-step, to verify a just-made decision, or before storing memory. Query is topic/semantic search, not regex. Patterns: period:"last" for previous conversation; sessionId without query for current session; period:"3h"/"30m"/"24h" for recent; YYYY-MM-DD, date ranges, or HH:MM~HH:MM for time windows; id for exact follow-up.',
33
+ description: 'Retrieve stored memory/session history. Call when a task ties to prior work — resumes, continuations, or references to earlier decisions/messages. Do not call as a reflexive pre-step, to verify a just-made decision, or before storing memory. Query is topic/semantic search, not regex. Patterns: period:"last" browses recent sessions; +query narrows topic; sessionId without query for current session; period:"3h"/"30m"/"24h" for recent; YYYY-MM-DD, date ranges, or HH:MM~HH:MM for time windows; id for exact follow-up.',
34
34
  inputSchema: {
35
35
  type: 'object',
36
36
  properties: {
37
37
  query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Search text, or array for independent fan-out queries.' },
38
38
  id: { anyOf: [{ type: 'number' }, { type: 'array', items: { type: 'number' }, minItems: 1 }], description: 'Exact #id(s) from recall. Do not invent ids.' },
39
- period: { type: 'string', description: "last (recent sessions grouped, newest-active first; limit=session count [default 5], offset=session paging), Nm/Nh/Nd (rolling), today/yesterday/this_week/last_week, all, YYYY-MM-DD, YYYY-MM-DD~YYYY-MM-DD, HH:MM~HH:MM (today), or 'YYYY-MM-DD HH:MM~HH:MM'." },
39
+ period: { type: 'string', description: "last (recent sessions; +query topic-filter; limit=session count [default 5], offset=session paging), Nm/Nh/Nd (rolling), today/yesterday/this_week/last_week, all, YYYY-MM-DD, YYYY-MM-DD~YYYY-MM-DD, HH:MM~HH:MM (today), or 'YYYY-MM-DD HH:MM~HH:MM'." },
40
40
  limit: { type: 'number', description: 'Max entries.' },
41
41
  offset: { type: 'number', description: 'Skip entries.' },
42
42
  sort: { type: 'string', enum: ['importance', 'date'], description: 'importance or date.' },
@@ -63,7 +63,7 @@ export const TOOL_DEFS = [
63
63
  type: 'object',
64
64
  properties: {
65
65
  query: { type: 'string', description: 'Search text.' },
66
- period: { type: 'string', description: "last, Nm/Nh/Nd, today/yesterday/this_week/last_week, all, YYYY-MM-DD, date range, HH:MM~HH:MM (today), or 'YYYY-MM-DD HH:MM~HH:MM'." },
66
+ period: { type: 'string', description: "last (recent sessions; +query topic-filter), Nm/Nh/Nd, today/yesterday/this_week/last_week, all, YYYY-MM-DD, date range, HH:MM~HH:MM (today), or 'YYYY-MM-DD HH:MM~HH:MM'." },
67
67
  sort: { type: 'string', enum: ['date', 'importance'], description: 'date or importance.' },
68
68
  category: { anyOf: [{ type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'] }, { type: 'array', items: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'] }, minItems: 1 }], description: 'Category filter.' },
69
69
  limit: { type: 'number', default: 30, description: 'Max entries.' },
@@ -358,6 +358,17 @@ export function notifyTaskCompletion(task, instruction) {
358
358
  context: task.notifyContext,
359
359
  enqueueFallback: _enqueueFallback || undefined,
360
360
  logPrefix: `background-${task.surface}`,
361
+ // Async notifyFn: `sent` is reported optimistically while the delivery
362
+ // promise is still in flight, so mark delivered now but let settlement
363
+ // confirm it. If the notifyFn rejects/declines AND the fallback rescue also
364
+ // fails, un-mark so a later reconcile can retry rather than leaving the
365
+ // completion silently unsent. A successful/rescued async delivery keeps the
366
+ // marks, preserving exact-once.
367
+ onSettled: (delivered) => {
368
+ if (delivered) return;
369
+ task.notified = false;
370
+ task.notifiedWithBody = false;
371
+ },
361
372
  });
362
373
  if (sent) {
363
374
  task.notified = true;
@@ -382,7 +393,16 @@ export function reconcileBackgroundTask(taskId, {
382
393
  } = {}) {
383
394
  const task = getBackgroundTask(taskId);
384
395
  if (!task) return null;
385
- if (TERMINAL_STATUSES.has(task.status)) return task;
396
+ if (TERMINAL_STATUSES.has(task.status)) {
397
+ // Already terminal, but a prior async completion notify may have un-marked
398
+ // itself after a reject/decline whose fallback rescue also missed
399
+ // (notifyTaskCompletion's onSettled clears notified/notifiedWithBody). The
400
+ // task never leaves its terminal status, so without retrying here the
401
+ // body-carrying completion stays silently unsent. Re-fire the notification;
402
+ // it is idempotent once notifiedWithBody is set, preserving exact-once.
403
+ if (task.notifiedWithBody !== true) notifyTaskCompletion(task, instruction);
404
+ return task;
405
+ }
386
406
  return completeBackgroundTask(task.taskId, {
387
407
  status,
388
408
  result,
@@ -253,6 +253,7 @@ export function notifyToolCompletion({
253
253
  context,
254
254
  enqueueFallback,
255
255
  logPrefix = 'tool-execution',
256
+ onSettled,
256
257
  } = {}) {
257
258
  const ctx = normalizeToolNotifyContext(context);
258
259
  const message = String(text || '');
@@ -275,22 +276,34 @@ export function notifyToolCompletion({
275
276
  try {
276
277
  const notifyResult = ctx.notifyFn(message, meta);
277
278
  if (notifyResult !== false) {
278
- // Optimistically report delivered (keeps this fn synchronous). But a
279
- // notifyFn that returns a Promise can still reject — or resolve to an
280
- // explicit false/0 (declined/failed) *after* we returned. In that
281
- // case rescue the completion via enqueueFallback so the owner isn't
282
- // left without a notification. The normal success path (truthy
283
- // resolve) never enqueues, preserving exact-once delivery.
284
- Promise.resolve(notifyResult).then((settled) => {
285
- if (settled === false || settled === 0) {
286
- tryEnqueueFallback(ctx, message, meta, enqueueFallback, logPrefix, id);
287
- }
288
- }).catch((err) => {
289
- try {
290
- process.stderr.write(`[${logPrefix}] async completion notify failed: id=${id || 'unknown'} err=${err?.message || err}\n`);
291
- } catch {}
292
- tryEnqueueFallback(ctx, message, meta, enqueueFallback, logPrefix, id);
293
- });
279
+ const isThenable = notifyResult && typeof notifyResult.then === 'function';
280
+ if (isThenable) {
281
+ // A Promise notifyFn has NOT delivered yet settlement decides the
282
+ // real outcome. Return `true` synchronously so the caller does not
283
+ // double-deliver through the sync fallback, but signal the FINAL
284
+ // delivered state via onSettled so the caller only *marks* the
285
+ // completion delivered after settlement. On a reject or explicit
286
+ // false/0 resolve, rescue via enqueueFallback; onSettled then reports
287
+ // whether that rescue (or the notifyFn itself) actually delivered, so
288
+ // a caller can un-mark and retry when nothing landed. The truthy
289
+ // resolve path never enqueues, preserving exact-once delivery.
290
+ Promise.resolve(notifyResult).then((settled) => {
291
+ if (settled === false || settled === 0) {
292
+ const rescued = tryEnqueueFallback(ctx, message, meta, enqueueFallback, logPrefix, id);
293
+ if (typeof onSettled === 'function') onSettled(rescued);
294
+ } else if (typeof onSettled === 'function') {
295
+ onSettled(true);
296
+ }
297
+ }).catch((err) => {
298
+ try {
299
+ process.stderr.write(`[${logPrefix}] async completion notify failed: id=${id || 'unknown'} err=${err?.message || err}\n`);
300
+ } catch {}
301
+ const rescued = tryEnqueueFallback(ctx, message, meta, enqueueFallback, logPrefix, id);
302
+ if (typeof onSettled === 'function') onSettled(rescued);
303
+ });
304
+ return true;
305
+ }
306
+ // Synchronous non-false result → confirmed delivered now.
294
307
  return true;
295
308
  }
296
309
  } catch (err) {
@@ -29,7 +29,7 @@ export function createModelRouteApi(deps) {
29
29
  getConfigHasSecrets, getSearchRouteState, setSearchRouteState,
30
30
  cfgMod, reg, mgr, statusRoutes,
31
31
  resolveRoute, searchCapableFor, lookupModelMeta,
32
- adoptConfig, saveConfigAndAdopt, ensureFullConfig,
32
+ adoptConfig, saveConfigAndAdopt, ensureFullConfig, ensureProvidersReady,
33
33
  persistLeadRoute, refreshRouteEffort,
34
34
  refreshStatuslineUsageSnapshot, scheduleStatuslineUsageRefresh,
35
35
  invalidateContextStatusCache, invalidateProviderCaches,
@@ -70,7 +70,7 @@ export function createModelRouteApi(deps) {
70
70
  throw new Error(`provider "${selectedRoute.provider}" does not support Mixdog native search`);
71
71
  }
72
72
  ensureFullConfig();
73
- await reg.initProviders(ensureProviderEnabled(getConfig(), selectedRoute.provider));
73
+ await ensureProvidersReady(ensureProviderEnabled(getConfig(), selectedRoute.provider));
74
74
  const modelMeta = await lookupModelMeta(selectedRoute.provider, selectedRoute.model);
75
75
  if (!searchCapableFor(selectedRoute.provider, modelMeta)) {
76
76
  throw new Error(`model "${selectedRoute.model}" is not marked as web-search capable`);
@@ -107,7 +107,7 @@ export function createModelRouteApi(deps) {
107
107
  requested.provider = getRoute().provider;
108
108
  }
109
109
  let selectedRoute = resolveRoute(getConfig(), requested);
110
- await reg.initProviders(ensureProviderEnabled(getConfig(), selectedRoute.provider));
110
+ await ensureProvidersReady(ensureProviderEnabled(getConfig(), selectedRoute.provider));
111
111
  const modelMeta = await lookupModelMeta(selectedRoute.provider, selectedRoute.model);
112
112
  if (!providerExplicitlyRequested
113
113
  && !selectedRoute.preset
@@ -24,10 +24,16 @@ export function createProviderAuthApi({
24
24
  reloadFullConfig,
25
25
  invalidateProviderCaches,
26
26
  warmProviderModelCache,
27
+ refreshProviderCatalogs,
27
28
  cachedProviderSetup,
28
29
  getUsageDashboard,
29
30
  collectProviderModels,
30
31
  }) {
32
+ function refreshProviderCatalogsSoon() {
33
+ if (typeof refreshProviderCatalogs !== 'function') return;
34
+ try { void Promise.resolve(refreshProviderCatalogs()).catch(() => {}); } catch { /* best-effort */ }
35
+ }
36
+
31
37
  return {
32
38
  listProviders() {
33
39
  return renderProviderStatus(displayConfig());
@@ -44,6 +50,7 @@ export function createProviderAuthApi({
44
50
  : await loginOAuthProvider(cfgMod, providerId);
45
51
  reloadFullConfig();
46
52
  invalidateProviderCaches();
53
+ refreshProviderCatalogsSoon();
47
54
  warmProviderModelCache();
48
55
  return result;
49
56
  },
@@ -51,6 +58,7 @@ export function createProviderAuthApi({
51
58
  const result = await loginOAuthProvider(cfgMod, providerId);
52
59
  reloadFullConfig();
53
60
  invalidateProviderCaches();
61
+ refreshProviderCatalogsSoon();
54
62
  warmProviderModelCache();
55
63
  return result;
56
64
  },
@@ -63,6 +71,7 @@ export function createProviderAuthApi({
63
71
  reloadFullConfig();
64
72
  if (completed) {
65
73
  invalidateProviderCaches();
74
+ refreshProviderCatalogsSoon();
66
75
  warmProviderModelCache();
67
76
  }
68
77
  return completed;
@@ -71,6 +80,7 @@ export function createProviderAuthApi({
71
80
  const completed = await result.completeCode(code);
72
81
  reloadFullConfig();
73
82
  invalidateProviderCaches();
83
+ refreshProviderCatalogsSoon();
74
84
  warmProviderModelCache();
75
85
  return completed;
76
86
  },
@@ -80,6 +90,7 @@ export function createProviderAuthApi({
80
90
  const result = saveProviderApiKey(cfgMod, providerId, secret);
81
91
  reloadFullConfig();
82
92
  invalidateProviderCaches();
93
+ refreshProviderCatalogsSoon();
83
94
  warmProviderModelCache();
84
95
  return result;
85
96
  },
@@ -105,6 +116,7 @@ export function createProviderAuthApi({
105
116
  const result = setLocalProvider(cfgMod, providerId, opts);
106
117
  reloadFullConfig();
107
118
  invalidateProviderCaches();
119
+ refreshProviderCatalogsSoon();
108
120
  warmProviderModelCache();
109
121
  return result;
110
122
  },
@@ -112,6 +124,7 @@ export function createProviderAuthApi({
112
124
  const result = forgetProviderAuth(cfgMod, providerId);
113
125
  reloadFullConfig();
114
126
  invalidateProviderCaches();
127
+ refreshProviderCatalogsSoon();
115
128
  warmProviderModelCache();
116
129
  return result;
117
130
  },
@@ -581,7 +581,8 @@ export async function createMixdogSessionRuntime({
581
581
  providerSetupQuickCache: { setup: null, at: 0 },
582
582
  providerSetupPromise: null,
583
583
  };
584
- let providerInitPromise = null;
584
+ const providerInitPromises = new Map();
585
+ let startupProviderCatalogRefreshStarted = false;
585
586
  let lastProjectMcpKey = null;
586
587
  // MCP connect state, owned here so teardown/reconnect paths still observe it;
587
588
  // the mcp-glue factory mutates this object in place (see createMcpGlue).
@@ -1150,7 +1151,7 @@ export async function createMixdogSessionRuntime({
1150
1151
  providerUsageCaches.providerSetupCache = { setup: null, at: 0 };
1151
1152
  providerUsageCaches.providerSetupQuickCache = { setup: null, at: 0 };
1152
1153
  providerUsageCaches.providerSetupPromise = null;
1153
- providerInitPromise = null;
1154
+ providerInitPromises.clear();
1154
1155
  modelMetaByRoute.clear();
1155
1156
  }
1156
1157
 
@@ -1197,13 +1198,43 @@ export async function createMixdogSessionRuntime({
1197
1198
  STANDALONE_DATA_DIR,
1198
1199
  });
1199
1200
 
1201
+ function providerInitCacheKey(value) {
1202
+ function sortKeysDeep(v) {
1203
+ if (Array.isArray(v)) return v.map(sortKeysDeep);
1204
+ if (v && typeof v === 'object') {
1205
+ const out = {};
1206
+ for (const key of Object.keys(v).sort()) out[key] = sortKeysDeep(v[key]);
1207
+ return out;
1208
+ }
1209
+ return v;
1210
+ }
1211
+ try {
1212
+ return JSON.stringify(sortKeysDeep(value));
1213
+ } catch {
1214
+ return `uncacheable:${Date.now()}:${Math.random()}`;
1215
+ }
1216
+ }
1217
+
1200
1218
  async function ensureProvidersReady(providerConfig = config.providers || {}) {
1201
- if (providerInitPromise) return await providerInitPromise;
1202
- providerInitPromise = reg.initProviders(providerConfig)
1219
+ const initKey = providerInitCacheKey(providerConfig);
1220
+ const existing = providerInitPromises.get(initKey);
1221
+ if (existing) return await existing;
1222
+ const providerInitPromise = reg.initProviders(providerConfig)
1203
1223
  .finally(() => {
1204
- providerInitPromise = null;
1224
+ if (providerInitPromises.get(initKey) === providerInitPromise) providerInitPromises.delete(initKey);
1205
1225
  });
1206
- return await providerInitPromise;
1226
+ providerInitPromises.set(initKey, providerInitPromise);
1227
+ const result = await providerInitPromise;
1228
+ if (!startupProviderCatalogRefreshStarted && !closeRequested) {
1229
+ startupProviderCatalogRefreshStarted = true;
1230
+ try {
1231
+ reg.refreshProviderCatalogsOnStartup();
1232
+ bootProfile('provider-catalogs:refresh-started');
1233
+ } catch (error) {
1234
+ bootProfile('provider-catalogs:refresh-failed', { error: error?.message || String(error) });
1235
+ }
1236
+ }
1237
+ return result;
1207
1238
  }
1208
1239
 
1209
1240
  const {
@@ -1982,6 +2013,7 @@ export async function createMixdogSessionRuntime({
1982
2013
  reloadFullConfig,
1983
2014
  invalidateProviderCaches,
1984
2015
  warmProviderModelCache,
2016
+ refreshProviderCatalogs: () => ensureProvidersReady(config.providers || {}).then(() => reg.refreshCatalogs()),
1985
2017
  cachedProviderSetup,
1986
2018
  getUsageDashboard,
1987
2019
  collectProviderModels,
@@ -2063,6 +2095,7 @@ export async function createMixdogSessionRuntime({
2063
2095
  adoptConfig,
2064
2096
  saveConfigAndAdopt,
2065
2097
  ensureFullConfig,
2098
+ ensureProvidersReady,
2066
2099
  persistLeadRoute,
2067
2100
  refreshRouteEffort,
2068
2101
  refreshStatuslineUsageSnapshot,
@@ -2086,6 +2119,7 @@ export async function createMixdogSessionRuntime({
2086
2119
  lookupModelMeta,
2087
2120
  adoptConfig,
2088
2121
  saveConfigAndAdopt,
2122
+ ensureProvidersReady,
2089
2123
  displayConfig,
2090
2124
  agentRouteFromConfig,
2091
2125
  loadAgentDefinition,
@@ -757,8 +757,9 @@ function pendingAndFailedMcpServers(mcpStatus) {
757
757
  // tools PLUS pending/failed MCP servers. No listing, no ranking, no substring
758
758
  // filter. `options.mcpStatus` is the runtime getter for per-server status.
759
759
  export function renderToolSearch(args = {}, session, mode = 'full', options = {}) {
760
- const catalog = Array.isArray(session?.deferredToolCatalog)
761
- ? session.deferredToolCatalog
760
+ const unionCatalog = deferredCatalogUnion(session);
761
+ const catalog = unionCatalog.length
762
+ ? unionCatalog
762
763
  : (Array.isArray(session?.tools) ? session.tools : []);
763
764
  const requestedNames = parseLoadToolNames(args);
764
765
  const { pending: pendingMcpServers, failed: failedMcpServers } = pendingAndFailedMcpServers(options?.mcpStatus);
@@ -25,7 +25,7 @@ export function createWorkflowAgentsApi(deps) {
25
25
  const {
26
26
  getConfig, getRoute, setRouteState, getSession, setSession, getConfigHasSecrets,
27
27
  cfgMod, reg, mgr, STANDALONE_DATA_DIR,
28
- resolveRoute, lookupModelMeta, adoptConfig, saveConfigAndAdopt, displayConfig,
28
+ resolveRoute, lookupModelMeta, adoptConfig, saveConfigAndAdopt, displayConfig, ensureProvidersReady,
29
29
  agentRouteFromConfig, loadAgentDefinition, activeWorkflowId, listWorkflowPacks,
30
30
  loadWorkflowPack, workflowSummary,
31
31
  getOutputStyleStatusCached, seedOutputStyleStatusCache, scheduleOutputStyleSave,
@@ -204,7 +204,7 @@ export function createWorkflowAgentsApi(deps) {
204
204
  const id = normalizeAgentId(agentId);
205
205
  if (!id) throw new Error(`unknown agent "${agentId}"`);
206
206
  let selectedRoute = resolveRoute(getConfig(), { ...(next || {}) });
207
- await reg.initProviders(ensureProviderEnabled(getConfig(), selectedRoute.provider));
207
+ await ensureProvidersReady(ensureProviderEnabled(getConfig(), selectedRoute.provider));
208
208
  const modelMeta = await lookupModelMeta(selectedRoute.provider, selectedRoute.model);
209
209
  const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
210
210
  selectedRoute = { ...selectedRoute, fast: fastCapable ? selectedRoute.fast === true : false };
@@ -37,7 +37,20 @@ export function createNotify(mgr) {
37
37
  try {
38
38
  const result = upstream(text, meta);
39
39
  ownerDelivered = result !== false;
40
- if (ownerDelivered) Promise.resolve(result).catch(() => {});
40
+ if (ownerDelivered && result && typeof result.then === 'function') {
41
+ // Async notifyFn: `ownerDelivered` is optimistic while the delivery
42
+ // promise is in flight — settlement decides the real outcome. On a
43
+ // reject or explicit false/0 resolve, rescue via the owner-session
44
+ // enqueue fallback so the completion is not silently swallowed. A
45
+ // truthy resolve never enqueues, preserving exact-once delivery.
46
+ Promise.resolve(result).then((settled) => {
47
+ if ((settled === false || settled === 0) && ownerSessionId) {
48
+ enqueueCompletionMessage(ownerSessionId, text, meta);
49
+ }
50
+ }).catch(() => {
51
+ if (ownerSessionId) enqueueCompletionMessage(ownerSessionId, text, meta);
52
+ });
53
+ }
41
54
  } catch {
42
55
  ownerDelivered = false;
43
56
  }
@@ -50,6 +50,39 @@ var require_mixdog_debug = __commonJS({
50
50
  function isMixdogDebugEnabled() {
51
51
  return isTruthyEnv(process.env.MIXDOG_DEBUG) || isTruthyEnv(process.env.MIXDOG_DEBUG_SESSION_START);
52
52
  }
53
+ var _cachedFromSource = null;
54
+ function _detectFromSourceCheckout() {
55
+ if (_cachedFromSource !== null) return _cachedFromSource;
56
+ try {
57
+ const moduleDir = module && module.filename ? path2.dirname(module.filename) : process.cwd();
58
+ const roots = [
59
+ path2.resolve(moduleDir, "..", ".."),
60
+ process.cwd()
61
+ ];
62
+ _cachedFromSource = roots.some((root) => fs.existsSync(path2.join(root, ".git")));
63
+ } catch {
64
+ _cachedFromSource = false;
65
+ }
66
+ return _cachedFromSource;
67
+ }
68
+ function resolveMixdogMode() {
69
+ const raw = String(process.env.MIXDOG_MODE || "").trim().toLowerCase();
70
+ if (raw === "dev" || raw === "development" || raw === "debug") return "dev";
71
+ if (raw === "ship" || raw === "shipping" || raw === "prod" || raw === "production") return "ship";
72
+ if (isTruthyEnv(process.env.MIXDOG_SHIP)) return "ship";
73
+ if (isMixdogDebugEnabled()) return "dev";
74
+ return _detectFromSourceCheckout() ? "dev" : "ship";
75
+ }
76
+ function isShippingMode() {
77
+ return resolveMixdogMode() === "ship";
78
+ }
79
+ function isDevMode() {
80
+ return resolveMixdogMode() === "dev";
81
+ }
82
+ function isDiagnosticIOEnabled() {
83
+ if (isTruthyEnv(process.env.MIXDOG_DIAGNOSTICS)) return true;
84
+ return !isShippingMode();
85
+ }
53
86
  var CANONICAL_PLUGIN_LOG_NAMES = /* @__PURE__ */ new Set([
54
87
  "boot.log",
55
88
  "crash.log",
@@ -143,6 +176,10 @@ var require_mixdog_debug = __commonJS({
143
176
  }
144
177
  module.exports = {
145
178
  isMixdogDebugEnabled,
179
+ resolveMixdogMode,
180
+ isShippingMode,
181
+ isDevMode,
182
+ isDiagnosticIOEnabled,
146
183
  pruneStalePluginDataLogSiblings,
147
184
  appendSessionStartCriticalLog,
148
185
  DEFAULT_STALE_LOG_SIBLING_MAX,
@@ -23702,18 +23739,24 @@ function createSessionFlow(bag) {
23702
23739
  return true;
23703
23740
  }
23704
23741
  function drainPendingSteering() {
23705
- const batch = dequeueQueueBatch("next", { predicate: (entry) => !isSlashQueuedEntry(entry) });
23706
- if (batch.length === 0) return [];
23707
- const out = batch.map((entry) => {
23708
- const content = entry.content;
23709
- if (typeof content === "string") return content.trim();
23710
- return { text: String(entry.text || "").trim(), content };
23711
- }).filter((entry) => {
23712
- if (typeof entry === "string") return entry.length > 0;
23713
- if (Array.isArray(entry?.content)) return entry.content.length > 0;
23714
- return String(entry?.content ?? "").trim().length > 0;
23715
- });
23716
- commitSteeringQueueEntries(batch);
23742
+ const predicate = (entry) => !isSlashQueuedEntry(entry);
23743
+ const out = [];
23744
+ for (; ; ) {
23745
+ const batch = dequeueQueueBatch("later", { predicate });
23746
+ if (batch.length === 0) break;
23747
+ for (const entry of batch) {
23748
+ const content = entry.content;
23749
+ const value = typeof content === "string" ? content.trim() : { text: String(entry.text || "").trim(), content };
23750
+ if (typeof value === "string") {
23751
+ if (value.length > 0) out.push(value);
23752
+ } else if (Array.isArray(value.content)) {
23753
+ if (value.content.length > 0) out.push(value);
23754
+ } else if (String(value.content ?? "").trim().length > 0) {
23755
+ out.push(value);
23756
+ }
23757
+ }
23758
+ commitSteeringQueueEntries(batch);
23759
+ }
23717
23760
  return out;
23718
23761
  }
23719
23762
  async function restoreLeadSteeringFromDisk() {
@@ -24055,18 +24098,13 @@ function createRunTurn(bag) {
24055
24098
  const deferredEntries = [];
24056
24099
  const flushDeferredUpTo = (entry) => {
24057
24100
  if (!entry) return;
24058
- for (const e of deferredEntries) {
24059
- if (e.seq > entry.seq) break;
24060
- if (e.pushed) continue;
24061
- e.pushed = true;
24062
- if (e.timer) {
24063
- clearTimeout(e.timer);
24064
- e.timer = null;
24065
- }
24066
- try {
24067
- e.push();
24068
- } catch {
24069
- }
24101
+ const specs = collectDeferredUpTo(entry);
24102
+ if (!specs.length) return;
24103
+ flags.pushingFromDeferredEntry = true;
24104
+ try {
24105
+ appendItemsBatch(specs);
24106
+ } finally {
24107
+ flags.pushingFromDeferredEntry = false;
24070
24108
  }
24071
24109
  };
24072
24110
  flags.flushDeferredBeforeImmediatePush = () => {
@@ -25988,7 +26026,7 @@ var TOOL_APPROVAL_TIMEOUT_MS = (() => {
25988
26026
  })();
25989
26027
  var LEAD_TURN_TIMEOUT_MS = (() => {
25990
26028
  const value = Number(process.env.MIXDOG_LEAD_TURN_TIMEOUT_MS);
25991
- return Number.isFinite(value) && value > 0 ? Math.max(1e4, Math.round(value)) : 30 * 6e4;
26029
+ return Number.isFinite(value) && value > 0 ? Math.max(1e4, Math.round(value)) : 5 * 6e4;
25992
26030
  })();
25993
26031
  var TUI_DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_TUI_DEBUG || ""));
25994
26032
  var tuiDebug = (msg) => {
@@ -210,20 +210,33 @@ export function createSessionFlow(bag) {
210
210
  // through the normal command processor after the turn, not be sent as plain
211
211
  // text. Consumed entries are spliced out of `pending` here, so the post-turn
212
212
  // drain() loop will not re-execute them.
213
- const batch = dequeueQueueBatch('next', { predicate: (entry) => !isSlashQueuedEntry(entry) });
214
- if (batch.length === 0) return [];
215
- const out = batch
216
- .map((entry) => {
213
+ //
214
+ // dequeueQueueBatch drains ONE priority/mode bucket per call, capped at a
215
+ // max priority. A concurrent user steering prompt (`next`) and a task
216
+ // notification (`later`) sit in different buckets, so a single `next`-capped
217
+ // dequeue would leave the notification pending and the post-turn drain()
218
+ // loop would spawn an unintended follow-up turn/reply. Loop up to `later`
219
+ // so EVERY non-slash bucket is consumed by the current turn.
220
+ const predicate = (entry) => !isSlashQueuedEntry(entry);
221
+ const out = [];
222
+ for (;;) {
223
+ const batch = dequeueQueueBatch('later', { predicate });
224
+ if (batch.length === 0) break;
225
+ for (const entry of batch) {
217
226
  const content = entry.content;
218
- if (typeof content === 'string') return content.trim();
219
- return { text: String(entry.text || '').trim(), content };
220
- })
221
- .filter((entry) => {
222
- if (typeof entry === 'string') return entry.length > 0;
223
- if (Array.isArray(entry?.content)) return entry.content.length > 0;
224
- return String(entry?.content ?? '').trim().length > 0;
225
- });
226
- commitSteeringQueueEntries(batch);
227
+ const value = typeof content === 'string'
228
+ ? content.trim()
229
+ : { text: String(entry.text || '').trim(), content };
230
+ if (typeof value === 'string') {
231
+ if (value.length > 0) out.push(value);
232
+ } else if (Array.isArray(value.content)) {
233
+ if (value.content.length > 0) out.push(value);
234
+ } else if (String(value.content ?? '').trim().length > 0) {
235
+ out.push(value);
236
+ }
237
+ }
238
+ commitSteeringQueueEntries(batch);
239
+ }
227
240
  return out;
228
241
  }
229
242