mixdog 0.9.4 → 0.9.5

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 (121) hide show
  1. package/README.md +112 -38
  2. package/package.json +4 -1
  3. package/scripts/bench/r5-orchestrated-task.json +7 -0
  4. package/scripts/bench/round-r13-clientmeta.json +464 -0
  5. package/scripts/bench/round-r14-betafeatures.json +466 -0
  6. package/scripts/bench/round-r15-fulldefault.json +462 -0
  7. package/scripts/bench/round-r16-sessionid.json +466 -0
  8. package/scripts/bench/round-r17-wirebytes.json +456 -0
  9. package/scripts/bench/round-r18-prewarm.json +468 -0
  10. package/scripts/bench/round-r19-clean.json +472 -0
  11. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  12. package/scripts/bench/round-r21-delta-retry.json +473 -0
  13. package/scripts/bench/round-r22-full-probe.json +693 -0
  14. package/scripts/bench/round-r23-itemprobe.json +701 -0
  15. package/scripts/bench/round-r24-shapefix.json +677 -0
  16. package/scripts/bench/round-r25-serial.json +464 -0
  17. package/scripts/bench/round-r26-parallel3.json +671 -0
  18. package/scripts/bench/round-r27-parallel10.json +894 -0
  19. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  20. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  21. package/scripts/bench/round-r30-instid.json +253 -0
  22. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  23. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  24. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  25. package/scripts/bench/round-r34-orchestrated.json +120 -0
  26. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  27. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  28. package/scripts/bench-run.mjs +40 -7
  29. package/scripts/internal-comms-bench.mjs +2 -4
  30. package/scripts/model-catalog-audit.mjs +209 -0
  31. package/scripts/model-list-sanitize-test.mjs +37 -0
  32. package/scripts/recall-bench.mjs +76 -13
  33. package/scripts/recall-quality-cases.json +12 -0
  34. package/scripts/tool-smoke.mjs +2 -2
  35. package/src/agents/heavy-worker/AGENT.md +7 -7
  36. package/src/agents/scheduler-task/AGENT.md +2 -3
  37. package/src/agents/webhook-handler/AGENT.md +2 -3
  38. package/src/agents/worker/AGENT.md +9 -10
  39. package/src/app.mjs +12 -1
  40. package/src/headless-role.mjs +7 -1
  41. package/src/mixdog-session-runtime.mjs +15 -14
  42. package/src/output-styles/default.md +14 -18
  43. package/src/output-styles/minimal.md +4 -5
  44. package/src/output-styles/simple.md +7 -8
  45. package/src/rules/agent/00-common.md +6 -5
  46. package/src/rules/agent/30-explorer.md +16 -5
  47. package/src/rules/lead/01-general.md +5 -5
  48. package/src/rules/lead/lead-brief.md +6 -3
  49. package/src/rules/lead/lead-tool.md +6 -4
  50. package/src/rules/shared/01-tool.md +17 -21
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  52. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +19 -0
  53. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +9 -1
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +53 -9
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +23 -10
  57. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +33 -4
  59. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  60. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +93 -0
  61. package/src/runtime/agent/orchestrator/session/loop.mjs +27 -5
  62. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  65. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  66. package/src/runtime/channels/backends/discord-gateway.mjs +2 -2
  67. package/src/runtime/channels/backends/discord.mjs +15 -26
  68. package/src/runtime/channels/backends/telegram.mjs +8 -12
  69. package/src/runtime/channels/index.mjs +23 -43
  70. package/src/runtime/channels/lib/backend-dispatch.mjs +5 -3
  71. package/src/runtime/channels/lib/config.mjs +37 -149
  72. package/src/runtime/channels/lib/event-pipeline.mjs +4 -4
  73. package/src/runtime/channels/lib/event-queue.mjs +15 -9
  74. package/src/runtime/channels/lib/inbound-routing.mjs +2 -39
  75. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  76. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  77. package/src/runtime/channels/lib/webhook/deliveries.mjs +3 -2
  78. package/src/runtime/channels/lib/webhook.mjs +8 -47
  79. package/src/runtime/memory/index.mjs +9 -0
  80. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  81. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  82. package/src/runtime/memory/lib/memory-recall-store.mjs +47 -10
  83. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  84. package/src/runtime/memory/lib/query-handlers.mjs +27 -5
  85. package/src/runtime/memory/tool-defs.mjs +2 -2
  86. package/src/runtime/shared/config.mjs +14 -4
  87. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  88. package/src/runtime/shared/schedules-store.mjs +13 -3
  89. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  90. package/src/session-runtime/config-helpers.mjs +45 -10
  91. package/src/session-runtime/quick-model-rows.mjs +46 -11
  92. package/src/session-runtime/quick-search-models.mjs +28 -27
  93. package/src/session-runtime/settings-api.mjs +34 -1
  94. package/src/session-runtime/tool-defs.mjs +1 -1
  95. package/src/session-runtime/workflow.mjs +1 -1
  96. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  97. package/src/standalone/agent-tool.mjs +30 -0
  98. package/src/standalone/channel-admin.mjs +102 -90
  99. package/src/standalone/explore-tool.mjs +39 -10
  100. package/src/tui/App.jsx +126 -28
  101. package/src/tui/app/app-format.mjs +11 -4
  102. package/src/tui/app/channel-pickers.mjs +2 -4
  103. package/src/tui/app/maintenance-pickers.mjs +60 -28
  104. package/src/tui/app/model-options.mjs +5 -1
  105. package/src/tui/app/settings-picker.mjs +0 -1
  106. package/src/tui/app/transcript-window.mjs +8 -2
  107. package/src/tui/app/use-transcript-scroll.mjs +3 -1
  108. package/src/tui/app/use-transcript-window.mjs +19 -1
  109. package/src/tui/components/ConfirmBar.jsx +10 -7
  110. package/src/tui/components/Picker.jsx +32 -11
  111. package/src/tui/components/PromptInput.jsx +10 -1
  112. package/src/tui/components/StatusLine.jsx +6 -3
  113. package/src/tui/dist/index.mjs +440 -293
  114. package/src/tui/engine.mjs +48 -12
  115. package/src/tui/index.jsx +11 -52
  116. package/src/ui/statusline.mjs +10 -0
  117. package/src/workflows/default/WORKFLOW.md +15 -17
  118. package/src/workflows/sequential/WORKFLOW.md +15 -17
  119. package/vendor/ink/build/ink.js +9 -1
  120. package/scripts/recall-usecase-probe.json +0 -6
  121. package/src/runtime/channels/lib/holidays.mjs +0 -138
@@ -24,6 +24,7 @@ export function createSettingsApi({
24
24
  normalizeCompactTypeSetting,
25
25
  normalizeSystemShellConfig,
26
26
  normalizeSystemShellCommand,
27
+ autoClearProviderDefaults,
27
28
  setConfiguredShell,
28
29
  setRecapEnabledInConfig,
29
30
  setModuleEnabledInConfig,
@@ -88,14 +89,29 @@ export function createSettingsApi({
88
89
  const route = getRoute();
89
90
  const normalized = normalizeAutoClearConfig(config.autoClear);
90
91
  const provider = route?.provider || null;
91
- const providerDefault = autoClearIdleMsForProvider(provider);
92
+ const providerDefault = autoClearIdleMsForProvider(provider, normalized.providerIdleMs);
92
93
  const idleMs = normalized.custom ? normalized.idleMs : providerDefault;
94
+ // Advanced picker shows only providers the user actually has enabled
95
+ // (config.providers[*].enabled), plus the active route provider, any
96
+ // provider with a custom override, and the 'default' fallback row —
97
+ // not the full built-in table.
98
+ const enabledProviders = new Set(
99
+ Object.entries(config?.providers || {})
100
+ .filter(([, v]) => v && typeof v === 'object' && v.enabled !== false)
101
+ .map(([k]) => String(k).toLowerCase()),
102
+ );
103
+ if (provider) enabledProviders.add(String(provider).toLowerCase());
104
+ const providerDefaults = autoClearProviderDefaults(normalized.providerIdleMs)
105
+ .filter((entry) => entry.provider === 'default'
106
+ || entry.custom === true
107
+ || enabledProviders.has(entry.provider));
93
108
  return {
94
109
  enabled: normalized.enabled,
95
110
  idleMs,
96
111
  custom: normalized.custom,
97
112
  providerDefault,
98
113
  provider,
114
+ providerDefaults,
99
115
  };
100
116
  },
101
117
  // --- User profile (/profile statusline command) ---------------------
@@ -225,6 +241,23 @@ export function createSettingsApi({
225
241
  const current = normalizeAutoClearConfig(config.autoClear);
226
242
  const next = { ...current };
227
243
  if (hasOwn(input, 'enabled')) next.enabled = input.enabled !== false;
244
+ const providerKey = String(input.provider || '').trim().toLowerCase();
245
+ const editsProviderDefault = providerKey
246
+ && (input.resetProvider === true || hasOwn(input, 'duration') || hasOwn(input, 'idleMs'));
247
+ if (editsProviderDefault) {
248
+ const providerIdleMs = { ...(next.providerIdleMs || {}) };
249
+ if (input.resetProvider === true || (hasOwn(input, 'idleMs') && input.idleMs == null)) {
250
+ delete providerIdleMs[providerKey];
251
+ } else {
252
+ const idleMs = hasOwn(input, 'duration') ? parseDurationMs(input.duration) : Number(input.idleMs);
253
+ if (!idleMs || !Number.isFinite(idleMs) || idleMs <= 0) throw new Error('usage: duration like 10m, 1h, or 24h');
254
+ providerIdleMs[providerKey] = Math.max(60_000, Math.round(idleMs));
255
+ }
256
+ next.providerIdleMs = providerIdleMs;
257
+ saveConfigAndAdopt({ ...config, autoClear: next });
258
+ const resolved = this.getAutoClear();
259
+ return { ...resolved, label: formatDurationMs(resolved.idleMs) };
260
+ }
228
261
  if (input.reset === true || (hasOwn(input, 'idleMs') && input.idleMs == null)) {
229
262
  next.idleMs = null;
230
263
  } else if (hasOwn(input, 'idleMs')) {
@@ -36,7 +36,7 @@ export const CWD_TOOL = {
36
36
  openWorldHint: false,
37
37
  agentHidden: true,
38
38
  },
39
- description: 'Show or set the session work project for tool execution.',
39
+ description: 'Show or set the session work project for tool execution. Use only for explicit project path changes.',
40
40
  inputSchema: {
41
41
  type: 'object',
42
42
  properties: {
@@ -18,7 +18,7 @@ export const FIXED_AGENT_SLOTS = Object.freeze([
18
18
  { id: 'debugger', label: 'Debugger', description: 'Root-cause analysis and failure tracing' },
19
19
  ]);
20
20
  const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
21
- const DEFAULT_WORKFLOW_ID = 'default';
21
+ export const DEFAULT_WORKFLOW_ID = 'default';
22
22
 
23
23
  const SEARCH_CAPABLE_PROVIDERS = new Set([
24
24
  'openai-oauth', 'openai', 'grok-oauth', 'xai', 'gemini', 'anthropic', 'anthropic-oauth',
@@ -35,7 +35,7 @@ export const AGENT_TOOL = {
35
35
  openWorldHint: true,
36
36
  agentHidden: true,
37
37
  },
38
- description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn independent scopes in parallel with distinct tags; for the same scope/tag, use send or spawn with the same tag to reuse the live session. Wait for the completion notification before dependent work. Do not call status/read after spawn; status/read are manual recovery only.',
38
+ description: 'Delegate scoped work. Agent handoffs always start background tasks and return task IDs immediately. Spawn independent scopes in parallel with distinct tags; for the same scope, use send or spawn with the same tag to reuse the live session. Wait for the completion notification before dependent work. Do not call status/read after spawn; they are manual recovery only.',
39
39
  inputSchema: {
40
40
  type: 'object',
41
41
  properties: {
@@ -92,6 +92,31 @@ function reapMsForProvider(provider) {
92
92
  // cap and restore strictly-unbounded prep.
93
93
  const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS', 120_000);
94
94
 
95
+ // Global spawn-start stagger: unlimited-N parallel fan-out otherwise fires all
96
+ // first provider calls in the same instant, racing the server-side prompt-
97
+ // cache write/propagation window. Bench (parallel-10, identical-ms starts):
98
+ // 12.5% cache miss; 3000ms lane stagger: 4.3%; 166ms: 6.0%. 2000ms default
99
+ // picked as a low-cost middle ground. Chain (not a fixed lane count) so it
100
+ // scales to any N: each new spawn's start is pushed to at least STAGGER_MS
101
+ // after the previous spawn's start; sequential/non-overlapping spawns (now
102
+ // already past the window) pay zero added latency. MIXDOG_SPAWN_STAGGER_MS=0
103
+ // disables. Applied inside the deferred job body (see startDeferredSpawnJob)
104
+ // so the agent tool call itself still returns task_id immediately.
105
+ const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 2000);
106
+ let lastSpawnStartAt = 0;
107
+ async function waitForSpawnStagger() {
108
+ if (SPAWN_STAGGER_MS <= 0) return;
109
+ const now = Date.now();
110
+ const myStart = Math.max(now, lastSpawnStartAt + SPAWN_STAGGER_MS);
111
+ lastSpawnStartAt = myStart;
112
+ const delay = myStart - now;
113
+ if (delay <= 0) return;
114
+ await new Promise((resolve) => {
115
+ const t = setTimeout(resolve, delay);
116
+ t.unref?.();
117
+ });
118
+ }
119
+
95
120
  export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultCwd, onSubagentEvent }) {
96
121
  // Optional bridge to the standard hook bus for SubagentStart / SubagentStop.
97
122
  // Best-effort: a hook error must never affect worker spawn/finish.
@@ -881,6 +906,11 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
881
906
 
882
907
  function startDeferredSpawnJob(args, callerCwd, context, notifyContext, extras = {}) {
883
908
  return startJob('spawn', pendingSpawnMeta(args, extras), async (job) => {
909
+ // Stagger concurrent spawn starts before prep/model-call begins (see
910
+ // SPAWN_STAGGER_MS above). Runs inside the job body, so the tool call
911
+ // that queued this job already returned task_id before this awaits.
912
+ await waitForSpawnStagger();
913
+ if (job?.status === 'cancelled') return null;
884
914
  // prepareSpawn (ensureProvider/prepareAgentSession) runs before runSpawn
885
915
  // installs its progress watchdog, so guard prep with an internal env-
886
916
  // backed cap rather than exposing per-call timeout knobs on the agent
@@ -22,20 +22,15 @@ import {
22
22
  updateSection,
23
23
  } from '../runtime/shared/config.mjs';
24
24
  import { resolvePluginData } from '../runtime/shared/plugin-paths.mjs';
25
- import { readMarkdownDocument } from '../runtime/shared/markdown-frontmatter.mjs';
25
+ import { readMarkdownDocument, serializeFrontmatterDoc } from '../runtime/shared/markdown-frontmatter.mjs';
26
26
 
27
27
  const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
28
28
  const DEFAULT_CHANNELS = Object.freeze({
29
29
  backend: 'discord',
30
30
  discord: {},
31
31
  access: { dmPolicy: 'allowlist', allowFrom: [], channels: {} },
32
- mainChannel: 'main',
33
- channelsConfig: {
34
- main: { channelId: '', mode: 'interactive' },
35
- },
36
- quiet: { schedule: '23:00-09:00', holidays: false },
37
- schedules: { respectQuiet: true },
38
- webhook: { enabled: true, respectQuiet: false, port: 3333 },
32
+ channel: { channelId: '', discordChannelId: '', telegramChatId: '' },
33
+ webhook: { enabled: true, port: 3333 },
39
34
  });
40
35
 
41
36
  function dataDir() {
@@ -61,24 +56,6 @@ function writeTextAtomic(path, text) {
61
56
  renameSync(tmp, path);
62
57
  }
63
58
 
64
- // Serialize a single-file frontmatter markdown document:
65
- // ---
66
- // key: value
67
- // ---
68
- //
69
- // <body>
70
- // Values are stringified as-is (enabled:false -> "false"); the reader casts
71
- // types back. Mirrors the WORKFLOW.md / SKILL.md single-file convention.
72
- function serializeFrontmatterDoc(meta = {}, body = '') {
73
- const lines = ['---'];
74
- for (const [key, value] of Object.entries(meta)) {
75
- if (value === undefined || value === null) continue;
76
- lines.push(`${key}: ${value}`);
77
- }
78
- lines.push('---', '');
79
- return `${lines.join('\n')}\n${String(body || '').trim()}\n`;
80
- }
81
-
82
59
  function assertName(name, kind = 'name') {
83
60
  const value = String(name || '').trim();
84
61
  if (!NAME_RE.test(value) || value !== basename(value)) {
@@ -93,9 +70,7 @@ function normalizeChannelsConfig(raw = {}) {
93
70
  ...(raw && typeof raw === 'object' ? raw : {}),
94
71
  discord: { ...DEFAULT_CHANNELS.discord, ...(raw?.discord || {}) },
95
72
  access: { ...DEFAULT_CHANNELS.access, ...(raw?.access || {}) },
96
- channelsConfig: { ...DEFAULT_CHANNELS.channelsConfig, ...(raw?.channelsConfig || {}) },
97
- quiet: { ...DEFAULT_CHANNELS.quiet, ...(raw?.quiet || {}) },
98
- schedules: { ...DEFAULT_CHANNELS.schedules, ...(raw?.schedules || {}) },
73
+ channel: { ...DEFAULT_CHANNELS.channel, ...(raw?.channel || {}) },
99
74
  webhook: { ...DEFAULT_CHANNELS.webhook, ...(raw?.webhook || {}) },
100
75
  };
101
76
  }
@@ -116,11 +91,42 @@ function seedBackendChannelIds(entry = {}, backend = 'discord') {
116
91
  return next;
117
92
  }
118
93
 
94
+ // Resolve the single-channel entry from the config: prefer the new `channel`
95
+ // object; fall back (read-side only, no on-disk migration) to the legacy
96
+ // channelsConfig[mainChannel ?? 'main'] entry, then the first entry with an id.
97
+ function resolveChannelEntry(cfg = {}) {
98
+ if (cfg.channel && typeof cfg.channel === 'object'
99
+ && (cfg.channel.channelId || cfg.channel.discordChannelId || cfg.channel.telegramChatId)) {
100
+ return cfg.channel;
101
+ }
102
+ const legacy = cfg.channelsConfig && typeof cfg.channelsConfig === 'object' ? cfg.channelsConfig : null;
103
+ if (legacy) {
104
+ const mainName = cfg.mainChannel ?? 'main';
105
+ const preferred = legacy[mainName];
106
+ if (preferred && typeof preferred === 'object'
107
+ && (preferred.channelId || preferred.discordChannelId || preferred.telegramChatId)) {
108
+ return preferred;
109
+ }
110
+ for (const entry of Object.values(legacy)) {
111
+ if (entry && typeof entry === 'object'
112
+ && (entry.channelId || entry.discordChannelId || entry.telegramChatId)) {
113
+ return entry;
114
+ }
115
+ }
116
+ }
117
+ return cfg.channel && typeof cfg.channel === 'object' ? cfg.channel : {};
118
+ }
119
+
119
120
  function updateChannelsSection(build) {
120
121
  let next;
121
122
  updateSection('channels', (current) => {
122
- next = build(normalizeChannelsConfig(current));
123
- return normalizeChannelsConfig(next);
123
+ // Preserve any legacy channelsConfig/mainChannel that already live on disk
124
+ // for read-side compat, but never re-emit them from our writers: strip them
125
+ // out of the returned shape so writes converge on the single `channel`.
126
+ const normalized = normalizeChannelsConfig(current);
127
+ next = build(normalized);
128
+ const { channelsConfig: _lc, mainChannel: _lm, ...clean } = normalizeChannelsConfig(next);
129
+ return clean;
124
130
  });
125
131
  return next;
126
132
  }
@@ -136,19 +142,17 @@ function listEntryDirs(dir) {
136
142
  }
137
143
  }
138
144
 
139
- export function listChannels(config = {}) {
145
+ // Single-channel read: resolves `cfg.channel` (legacy channelsConfig fallback)
146
+ // into the flat shape the settings/TUI layer consumes.
147
+ export function getChannel(config = {}) {
140
148
  const cfg = normalizeChannelsConfig(config);
141
149
  const backend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
142
- return Object.entries(cfg.channelsConfig || {})
143
- .sort(([a], [b]) => a.localeCompare(b))
144
- .map(([name, value]) => ({
145
- name,
146
- channelId: channelIdForBackend(value, backend),
147
- discordChannelId: String(value?.discordChannelId || ''),
148
- telegramChatId: String(value?.telegramChatId || ''),
149
- mode: value?.mode || 'interactive',
150
- main: cfg.mainChannel === name,
151
- }));
150
+ const entry = resolveChannelEntry(cfg);
151
+ return {
152
+ channelId: channelIdForBackend(entry, backend),
153
+ discordChannelId: String(entry?.discordChannelId || ''),
154
+ telegramChatId: String(entry?.telegramChatId || ''),
155
+ };
152
156
  }
153
157
 
154
158
  export function saveDiscordToken(token) {
@@ -187,40 +191,24 @@ export function forgetWebhookAuthtoken() {
187
191
  return { ok: true };
188
192
  }
189
193
 
190
- export function saveChannel({ name, channelId, mode = 'interactive', main = false, backend = null } = {}) {
191
- const label = assertName(name, 'channel name');
194
+ // Single-channel write: persists `cfg.channel`, keeping the per-backend id
195
+ // fields (discordChannelId/telegramChatId) so a backend switch retains both
196
+ // ids. `backend` selects which per-backend field the id updates; when omitted
197
+ // the id lands on the active backend's field.
198
+ export function setChannel({ channelId, backend = null } = {}) {
192
199
  const id = String(channelId || '').trim();
193
200
  if (!id) throw new Error('channelId is required');
194
- const nextMode = mode === 'broadcast' ? 'broadcast' : 'interactive';
195
201
  const targetBackend = backend === 'telegram' || backend === 'discord' ? backend : null;
196
202
  return updateChannelsSection((cfg) => {
197
203
  const activeBackend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
198
- const current = seedBackendChannelIds(cfg.channelsConfig?.[label] || {}, activeBackend);
199
- const nextEntry = { ...current, mode: nextMode };
200
- if (targetBackend === 'telegram') nextEntry.telegramChatId = id;
201
- else if (targetBackend === 'discord') nextEntry.discordChannelId = id;
202
- else nextEntry.channelId = id;
203
- nextEntry.channelId = targetBackend
204
- ? (channelIdForBackend(nextEntry, activeBackend) || (targetBackend === activeBackend ? id : ''))
205
- : id;
206
- return {
207
- ...cfg,
208
- mainChannel: main || !cfg.mainChannel ? label : cfg.mainChannel,
209
- channelsConfig: {
210
- ...(cfg.channelsConfig || {}),
211
- [label]: nextEntry,
212
- },
213
- };
214
- });
215
- }
216
-
217
- export function deleteChannel(name) {
218
- const label = assertName(name, 'channel name');
219
- return updateChannelsSection((cfg) => {
220
- const channelsConfig = { ...(cfg.channelsConfig || {}) };
221
- delete channelsConfig[label];
222
- const nextMain = cfg.mainChannel === label ? Object.keys(channelsConfig)[0] || 'main' : cfg.mainChannel;
223
- return { ...cfg, channelsConfig, mainChannel: nextMain };
204
+ const writeBackend = targetBackend || activeBackend;
205
+ const current = seedBackendChannelIds(resolveChannelEntry(cfg), activeBackend);
206
+ const nextEntry = { ...current };
207
+ if (writeBackend === 'telegram') nextEntry.telegramChatId = id;
208
+ else nextEntry.discordChannelId = id;
209
+ // `channelId` mirrors the active backend's id for legacy readers.
210
+ nextEntry.channelId = channelIdForBackend(nextEntry, activeBackend);
211
+ return { ...cfg, channel: nextEntry };
224
212
  });
225
213
  }
226
214
 
@@ -230,7 +218,6 @@ export function setWebhookConfig(patch = {}) {
230
218
  webhook: {
231
219
  ...(cfg.webhook || {}),
232
220
  ...(Object.prototype.hasOwnProperty.call(patch, 'enabled') ? { enabled: patch.enabled === true } : {}),
233
- ...(Object.prototype.hasOwnProperty.call(patch, 'respectQuiet') ? { respectQuiet: patch.respectQuiet === true } : {}),
234
221
  ...(patch.port ? { port: Number(patch.port) || 3333 } : {}),
235
222
  ...(patch.domain ? { domain: String(patch.domain).trim() } : {}),
236
223
  ...(patch.ngrokDomain ? { ngrokDomain: String(patch.ngrokDomain).trim() } : {}),
@@ -245,14 +232,11 @@ export function setBackend(name) {
245
232
  }
246
233
  updateChannelsSection((cfg) => {
247
234
  const activeBackend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
248
- const channelsConfig = Object.fromEntries(Object.entries(cfg.channelsConfig || {}).map(([key, entry]) => [
249
- key,
250
- (() => {
251
- const seeded = seedBackendChannelIds(entry, activeBackend);
252
- return { ...seeded, channelId: channelIdForBackend(seeded, value) };
253
- })(),
254
- ]));
255
- return { ...cfg, channelsConfig, backend: value };
235
+ const seeded = seedBackendChannelIds(resolveChannelEntry(cfg), activeBackend);
236
+ // `channelId` now mirrors the newly-selected backend's id; both per-backend
237
+ // id fields are retained so switching back keeps the other id.
238
+ const channel = { ...seeded, channelId: channelIdForBackend(seeded, value) };
239
+ return { ...cfg, channel, backend: value };
256
240
  });
257
241
  return { ok: true, backend: value };
258
242
  }
@@ -274,8 +258,15 @@ export function listSchedules() {
274
258
  if (Object.prototype.hasOwnProperty.call(config, 'enabled')) {
275
259
  config.enabled = config.enabled !== 'false' && config.enabled !== false;
276
260
  }
261
+ // Dir name is canonical identity; frontmatter `name` is display-only and
262
+ // must never override the slug used for routing/deletion. `description`
263
+ // defaults to '' for old files with no such key (backward compat).
264
+ const description = String(config.description || '');
265
+ delete config.name;
266
+ delete config.description;
277
267
  return {
278
268
  name,
269
+ description,
279
270
  ...config,
280
271
  instructions: body,
281
272
  route: config.channel ? `channel:${config.channel}` : 'session',
@@ -285,6 +276,7 @@ export function listSchedules() {
285
276
 
286
277
  export function saveSchedule({
287
278
  name,
279
+ description = '',
288
280
  time,
289
281
  timezone,
290
282
  days,
@@ -302,7 +294,9 @@ export function saveSchedule({
302
294
  const dir = join(schedulesDir(), id);
303
295
  if (existsSync(dir) && overwrite !== true) throw new Error(`schedule "${id}" already exists`);
304
296
  mkdirSync(dir, { recursive: true });
305
- const config = { time: cron };
297
+ // name (== dir slug) and description lead the frontmatter per the SKILL.md
298
+ // convention; operational keys follow.
299
+ const config = { name: id, description: String(description || '').trim(), time: cron };
306
300
  if (timezone) config.timezone = String(timezone).trim();
307
301
  if (days && days !== 'daily') config.days = String(days).trim();
308
302
  if (channel) config.channel = String(channel).trim();
@@ -324,7 +318,13 @@ export function setScheduleEnabled(name, enabled) {
324
318
  const mdPath = join(dir, 'SCHEDULE.md');
325
319
  if (!existsSync(mdPath)) throw new Error(`schedule "${id}" does not exist`);
326
320
  const { frontmatter, body } = readMarkdownDocument(readText(mdPath, ''));
327
- writeTextAtomic(mdPath, serializeFrontmatterDoc({ ...frontmatter, enabled: enabled !== false }, body));
321
+ // Re-spread with name/description first so the round-trip keeps SKILL.md
322
+ // key order; name stays the dir slug regardless of stale frontmatter.
323
+ const { name: _staleName, description = '', ...rest } = frontmatter;
324
+ writeTextAtomic(mdPath, serializeFrontmatterDoc(
325
+ { name: id, description, ...rest, enabled: enabled !== false },
326
+ body,
327
+ ));
328
328
  return { name: id, enabled: enabled !== false };
329
329
  }
330
330
 
@@ -336,12 +336,19 @@ export function listWebhooks() {
336
336
  if (Object.prototype.hasOwnProperty.call(config, 'enabled')) {
337
337
  config.enabled = config.enabled !== 'false' && config.enabled !== false;
338
338
  }
339
+ // Dir name is canonical identity/URL routing key; frontmatter `name` is
340
+ // display-only and must not override the slug. `description` defaults to
341
+ // '' for old files with no such key (backward compat).
342
+ const description = String(config.description || '');
343
+ delete config.name;
344
+ delete config.description;
339
345
  // Secret lives in a side file (<name>/secret), never in frontmatter, so
340
346
  // an arbitrary user-supplied secret (quotes/colon/newline) round-trips
341
347
  // losslessly and setWebhookEnabled cannot corrupt it.
342
348
  const hasSecret = Boolean(readText(join(dir, 'secret'), '').trim());
343
349
  return {
344
350
  name,
351
+ description,
345
352
  ...config,
346
353
  secretSet: hasSecret,
347
354
  secret: undefined,
@@ -353,6 +360,7 @@ export function listWebhooks() {
353
360
 
354
361
  export function saveWebhook({
355
362
  name,
363
+ description = '',
356
364
  parser = 'github',
357
365
  secret,
358
366
  channel,
@@ -373,7 +381,9 @@ export function saveWebhook({
373
381
  if (existsSync(dir) && overwrite !== true) throw new Error(`webhook "${id}" already exists`);
374
382
  mkdirSync(dir, { recursive: true });
375
383
  const secretValue = String(secret || randomBytes(24).toString('hex')).trim();
376
- const config = { parser: nextParser };
384
+ // name (== dir slug) and description lead the frontmatter per the SKILL.md
385
+ // convention; operational keys (parser/channel/model/enabled) follow.
386
+ const config = { name: id, description: String(description || '').trim(), parser: nextParser };
377
387
  if (channel) config.channel = String(channel).trim();
378
388
  if (model) config.model = String(model).trim();
379
389
  if (enabled === false) config.enabled = false;
@@ -397,7 +407,13 @@ export function setWebhookEnabled(name, enabled) {
397
407
  const mdPath = join(dir, 'WEBHOOK.md');
398
408
  if (!existsSync(mdPath)) throw new Error(`webhook "${id}" does not exist`);
399
409
  const { frontmatter, body } = readMarkdownDocument(readText(mdPath, ''));
400
- writeTextAtomic(mdPath, serializeFrontmatterDoc({ ...frontmatter, enabled: enabled !== false }, body));
410
+ // Re-spread with name/description first so the round-trip keeps SKILL.md
411
+ // key order; name stays the dir slug regardless of stale frontmatter.
412
+ const { name: _staleName, description = '', ...rest } = frontmatter;
413
+ writeTextAtomic(mdPath, serializeFrontmatterDoc(
414
+ { name: id, description, ...rest, enabled: enabled !== false },
415
+ body,
416
+ ));
401
417
  return { name: id, enabled: enabled !== false };
402
418
  }
403
419
 
@@ -429,7 +445,7 @@ export function channelSetup(config = null) {
429
445
  stored: hasStoredSecret(SECRET_ACCOUNTS.webhookAuth),
430
446
  status: webhookAuth ? 'Set' : 'Off',
431
447
  },
432
- channels: listChannels(cfg),
448
+ channel: getChannel(cfg),
433
449
  schedules: listSchedules(),
434
450
  webhooks: listWebhooks(),
435
451
  };
@@ -440,11 +456,7 @@ export function renderChannelStatus(config = null) {
440
456
  const lines = [];
441
457
  lines.push(`discord ${setup.discord.status}${setup.discord.problem ? ` (${setup.discord.problem})` : ''}`);
442
458
  lines.push(`webhook ${setup.webhook.enabled === false ? 'disabled' : 'enabled'} · auth ${setup.webhook.status} · port ${setup.webhook.port || 3333}`);
443
- lines.push('channels');
444
- if (setup.channels.length === 0) lines.push(' (none)');
445
- for (const ch of setup.channels) {
446
- lines.push(` ${ch.name}${ch.main ? ' *' : ''} ${ch.channelId || '(unset)'} ${ch.mode}`);
447
- }
459
+ lines.push(`channel ${setup.channel.channelId || '(unset)'}`);
448
460
  lines.push('schedules');
449
461
  if (setup.schedules.length === 0) lines.push(' (none)');
450
462
  for (const item of setup.schedules) {
@@ -39,8 +39,28 @@ const EXPLORE_RESULT_CACHE_TTL_MS = (() => {
39
39
  const raw = Number(process.env.MIXDOG_EXPLORE_RESULT_CACHE_TTL_MS);
40
40
  return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5 * 60_000;
41
41
  })();
42
+ // Hard ceiling for one explorer compute. The dispatch-level watchdog is the
43
+ // primary abort; this race is the last line of defence so a wedged compute can
44
+ // never hang the awaiting tool call (and its cache key) forever.
45
+ export const EXPLORE_COMPUTE_HARD_TIMEOUT_MS = (() => {
46
+ const raw = Number(process.env.MIXDOG_EXPLORE_HARD_TIMEOUT_MS);
47
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 10 * 60_000;
48
+ })();
42
49
  const EXPLORE_RESULT_CACHE = new Map(); // key -> { ts, value?, promise? }
43
50
 
51
+ function withExploreHardTimeout(promise) {
52
+ if (!(EXPLORE_COMPUTE_HARD_TIMEOUT_MS > 0)) return promise;
53
+ let timer = null;
54
+ const timeout = new Promise((_resolve, reject) => {
55
+ timer = setTimeout(
56
+ () => reject(new Error(`explorer timed out after ${EXPLORE_COMPUTE_HARD_TIMEOUT_MS}ms`)),
57
+ EXPLORE_COMPUTE_HARD_TIMEOUT_MS,
58
+ );
59
+ if (typeof timer.unref === 'function') timer.unref();
60
+ });
61
+ return Promise.race([promise, timeout]).finally(() => { if (timer) clearTimeout(timer); });
62
+ }
63
+
44
64
  function escapeXml(str) {
45
65
  return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
46
66
  }
@@ -50,7 +70,7 @@ function escapeXml(str) {
50
70
  // reminder. The full no-verdict contract lives at system level
51
71
  // (rules/agent/30-explorer.md).
52
72
  export function buildExplorerPrompt(query) {
53
- return `<query>${escapeXml(query)}</query>\nReminder: output only anchor lines formatted as path:line — symbol/name — short reason, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
73
+ return `<query>${escapeXml(query)}</query>\nReminder: budget 4 tool turns — batch every candidate lookup per turn, never one tool at a time. The moment you have credible anchors, STOP and answer; spend remaining turns only while you have zero anchors. Output only anchor lines formatted as path:line — symbol/name — short reason, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
54
74
  }
55
75
 
56
76
  export function normalizeExploreQueries(rawQuery) {
@@ -209,11 +229,19 @@ function getCachedExploreResult(key) {
209
229
  async function runExploreCached(key, compute) {
210
230
  const cached = getCachedExploreResult(key);
211
231
  if (cached !== null) return cached;
212
- if (!exploreResultCacheEnabled()) return await compute();
213
- const pending = EXPLORE_RESULT_CACHE.get(key)?.promise;
214
- if (pending) return await pending;
232
+ if (!exploreResultCacheEnabled()) return await withExploreHardTimeout(compute());
233
+ const entry = EXPLORE_RESULT_CACHE.get(key);
234
+ if (entry?.promise) {
235
+ // Pending in-flight dedup — but never trust a pending entry forever: a
236
+ // compute that outlived the hard timeout is wedged (its own race should
237
+ // have rejected); drop the poisoned key and recompute fresh.
238
+ if (!(EXPLORE_COMPUTE_HARD_TIMEOUT_MS > 0) || Date.now() - entry.ts <= EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
239
+ return await entry.promise;
240
+ }
241
+ EXPLORE_RESULT_CACHE.delete(key);
242
+ }
215
243
  const promise = Promise.resolve()
216
- .then(() => compute())
244
+ .then(() => withExploreHardTimeout(compute()))
217
245
  .then((value) => {
218
246
  const text = typeof value === 'string' ? value : responseText(value);
219
247
  const cleaned = cleanExplorerText(text);
@@ -327,15 +355,16 @@ async function runExploreSync(args = {}, ctx = {}) {
327
355
  const presetName = (route && typeof route === 'object')
328
356
  ? `${clean(route.provider)}/${clean(route.model)}`
329
357
  : (route || '');
330
- // Explorer is a one-shot locator (one grep+code_graph turn, answer, stop
331
- // rules/agent/30-explorer.md). Pin a low hard cap so a drifting explorer
332
- // cannot run an 11-call research loop; 3 leaves room for one lookup turn,
333
- // one recovery turn, and the answer.
358
+ // Explorer self-manages its turn budget: the 4-turn budget lives ONLY in
359
+ // explorer-scoped surfaces (rules/agent/30-explorer.md + per-query prompt),
360
+ // never in shared loop policy. The cap here is a runaway guard, unchanged
361
+ // from the original value; B-round live tests showed hard targets
362
+ // legitimately need ~10.
334
363
  const llm = makeAgentDispatch({
335
364
  agent: 'explorer',
336
365
  cwd: resolvedCwd,
337
366
  brief: true,
338
- maxLoopIterations: 3,
367
+ maxLoopIterations: 12,
339
368
  parentSessionId: ctx.callerSessionId || null,
340
369
  clientHostPid: ctx.clientHostPid || null,
341
370
  config,