mixdog 0.9.4 → 0.9.6

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 +131 -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 +441 -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
@@ -245,6 +245,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
245
245
  let _softCapActive = false; // tools disabled + wrap-up injected
246
246
  let _softCapGraceTurns = 0; // text-only grace turns consumed (max 2)
247
247
  let _terminatedBySoftCap = false;
248
+ // Hard-cap final-answer turn: one tool-less wrap-up turn granted when the
249
+ // hard iteration cap fires, so the session ends with text, not empty.
250
+ let _capFinalTurnUsed = false;
248
251
  // Completion-first steering ladder controller. Owns the (cumulative) level-1
249
252
  // fire count, the all-read-only / serial-single / same-file-grep streaks,
250
253
  // and the level-2 latch. Threaded via live getters so it reads the loop's
@@ -280,9 +283,28 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
280
283
  while (true) {
281
284
  throwIfAborted();
282
285
  if (iterations >= maxLoopIterations) {
283
- process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
284
- terminatedByCap = true;
285
- break;
286
+ // Final-answer turn: instead of breaking mid-transcript (which
287
+ // yields an empty final for locator-style agents that never got to
288
+ // answer), give the model ONE tool-less text turn to wrap up, then
289
+ // stop. Same mechanism as the worker soft cap (empty sendTools).
290
+ if (_capFinalTurnUsed) {
291
+ process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
292
+ terminatedByCap = true;
293
+ // The granted final turn produced no text (model kept emitting
294
+ // tool calls into refusal stubs, or thinking-only). Synthesize a
295
+ // non-empty final so callers never see an empty response.
296
+ if (response && !String(response.content || '').trim()) {
297
+ response.content = sessionAgent === 'explorer'
298
+ ? 'EXPLORATION_FAILED'
299
+ : '[iteration cap reached before final text]';
300
+ if (Array.isArray(response.toolCalls)) response.toolCalls = [];
301
+ }
302
+ break;
303
+ }
304
+ _capFinalTurnUsed = true;
305
+ _softCapActive = true; // reuse soft-cap plumbing: no tool defs, refusal stubs
306
+ messages.push({ role: 'user', content: '<system-reminder>\nIteration cap reached. Tools are disabled. Answer NOW with your best result from what you already found.\n</system-reminder>', meta: 'hook' });
307
+ process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); forcing final text turn.\n`);
286
308
  }
287
309
  if (_iterWarnStage < _iterWarnAt.length && iterations >= _iterWarnAt[_iterWarnStage]) {
288
310
  _iterWarnStage += 1;
@@ -370,7 +392,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
370
392
  pressureTokens,
371
393
  });
372
394
  } else {
373
- try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
395
+ try { await opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
374
396
  const compactStartedAt = Date.now();
375
397
  // Clear the one-shot reactive-overflow flag now that this
376
398
  // compact pass is consuming it (compactTrigger already
@@ -626,7 +648,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
626
648
  }, compactErr);
627
649
  }
628
650
  }
629
- try { opts.onStageChange?.('requesting'); } catch { /* best-effort */ }
651
+ try { await opts.onStageChange?.('requesting'); } catch { /* best-effort */ }
630
652
  const compactChanged = messagesArrayChanged(messages, compacted);
631
653
  if (compactChanged) {
632
654
  messages.length = 0;
@@ -196,7 +196,7 @@ export async function runSessionCompaction(session, opts = {}) {
196
196
  const budgetSourceTokens = force ? Math.max(pressureTokens, triggerTokens) : pressureTokens;
197
197
  const compactBudget = compactTargetBudget(boundary, reserveTokens, budgetSourceTokens);
198
198
  const budget = compactBudget || boundary;
199
- try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
199
+ try { await opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
200
200
  const provider = opts.provider || getProvider(session.provider) || null;
201
201
  let compacted;
202
202
  let compactError = null;
@@ -206,9 +206,28 @@ export const PROVIDER_RETRY_JITTER_RATIO = (() => {
206
206
  return 0.2;
207
207
  })();
208
208
 
209
+ // Public workflow agents are not in agents.json (that file only defines hidden
210
+ // roles), so they historically always fell back to the 600s default. A wedged
211
+ // anthropic SSE stream (HTTP 200 then zero deltas — observed 2026-07-03 on a
212
+ // sonnet-5 worker, twice in a row) then holds the owner for the full 10 min.
213
+ // Scoped workers/reviewers should be reaped faster; lead keeps the generous
214
+ // default because interactive xhigh turns legitimately think for minutes.
215
+ const WORKFLOW_AGENT_STALL_ABORT_S = {
216
+ 'worker': 300,
217
+ 'reviewer': 300,
218
+ 'debugger': 420,
219
+ 'heavy-worker': 420,
220
+ 'maintainer': 300,
221
+ 'explore': 240,
222
+ 'web-researcher': 300,
223
+ };
224
+
209
225
  export function resolveAgentStallThresholds(agent, env = process.env) {
210
226
  const cfg = agent ? getHiddenAgent(agent) : null;
211
- const cfgAbort = cfg?.stallCap?.idleSeconds > 0 ? cfg.stallCap.idleSeconds : STALL_ABORT_S;
227
+ const roleAbort = WORKFLOW_AGENT_STALL_ABORT_S[String(agent || '')] || null;
228
+ const cfgAbort = cfg?.stallCap?.idleSeconds > 0
229
+ ? cfg.stallCap.idleSeconds
230
+ : (roleAbort || STALL_ABORT_S);
212
231
  const envOverride = envThresholdSeconds(env);
213
232
  const abort = envOverride != null ? envOverride : cfgAbort;
214
233
  // Mid-stream "slow" warning disabled — an agent stall now notifies ONLY at
@@ -17,7 +17,7 @@ export const BUILTIN_TOOLS = [
17
17
  name: 'read',
18
18
  title: 'Mixdog Read',
19
19
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
20
- description: 'Read known file path(s). Prefer grep content_with_context or code_graph anchors first. Window with numeric offset+limit only. Batch paths/regions as real arrays; adjacent spans in one file = one window, not repeated calls. Dirs use list.',
20
+ description: 'Read known file path(s). Prefer grep content_with_context or code_graph anchors first. Window with numeric offset+limit. Batch paths/regions as real arrays; adjacent spans in one file = one window, not repeated calls. Dirs use list.',
21
21
  inputSchema: {
22
22
  type: 'object',
23
23
  properties: {
@@ -42,7 +42,7 @@ export const BUILTIN_TOOLS = [
42
42
  minItems: 1,
43
43
  },
44
44
  ],
45
- description: 'File path. To batch spans: path[] or {path,offset,limit}[] region objects. Pass arrays directly; JSON strings are legacy recovery only.',
45
+ description: 'File path. Batch spans via path[] or {path,offset,limit}[] regions. Pass arrays directly; JSON strings are legacy recovery only.',
46
46
  },
47
47
  offset: { type: 'number', minimum: 0, description: 'Numeric lines to skip before reading; 0 starts at line 1. Continue with offset:N.' },
48
48
  limit: { type: 'number', minimum: 1, description: 'Numeric max lines to return after offset.' },
@@ -53,16 +53,16 @@ export const BUILTIN_TOOLS = [
53
53
  name: 'shell',
54
54
  title: 'Mixdog Shell',
55
55
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
56
- description: `Run shell to CHANGE state or RUN programs (git/build/test/run). Never to inspect the filesystem — reading, listing, searching, or checking existence go through the dedicated tools, never a shell command. Set shell: powershell or bash. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
56
+ description: `Run shell to CHANGE state or RUN programs (git/build/test/run). Never to inspect the filesystem — reading, listing, searching, existence checks use the dedicated tools, never shell. Set shell: powershell or bash. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
57
57
  inputSchema: {
58
58
  type: 'object',
59
59
  properties: {
60
60
  command: { type: 'string', description: 'Command.' },
61
- cwd: { type: 'string', description: 'Working directory. Persists across shell calls within a session — omit to reuse the previous command\'s directory (e.g. after cd); pass an absolute path to change it.' },
61
+ cwd: { type: 'string', description: 'Working directory. Persists across shell calls in a session — omit to reuse the previous command\'s directory (e.g. after cd); pass an absolute path to change it.' },
62
62
  timeout: { type: 'number', description: 'Timeout ms.' },
63
63
  merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
64
64
  mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
65
- shell: { type: 'string', enum: ['bash', 'powershell'], description: 'Force shell. Windows default is PowerShell; bash means Git Bash/POSIX.' },
65
+ shell: { type: 'string', enum: ['bash', 'powershell'], description: 'Force shell. Windows defaults to PowerShell; bash = Git Bash/POSIX.' },
66
66
  },
67
67
  required: ['command'],
68
68
  },
@@ -87,7 +87,7 @@ export const BUILTIN_TOOLS = [
87
87
  name: 'grep',
88
88
  title: 'Mixdog Grep',
89
89
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
90
- description: 'Search file contents by text/regex in a known scope. Use files_with_matches/count for broad anchors, content_with_context for narrow code answers. One concept → one grep; multiple scopes = ONE call with path[].',
90
+ description: 'Search file contents by text/regex in a known scope. files_with_matches/count for broad anchors, content_with_context for narrow answers. One concept → one grep; multiple scopes = ONE call with path[].',
91
91
  inputSchema: {
92
92
  type: 'object',
93
93
  properties: {
@@ -163,7 +163,7 @@ export const BUILTIN_TOOLS = [
163
163
  { type: 'string' },
164
164
  { type: 'array', items: { type: 'string' }, minItems: 1 },
165
165
  ],
166
- description: 'Partial path/name words (not file contents), or query[] to batch several lookups in one call.',
166
+ description: 'Partial path/name words (not file contents), or query[] to batch lookups in one call.',
167
167
  },
168
168
  path: { type: 'string', description: 'Base directory.' },
169
169
  head_limit: { type: 'number', description: 'Max paths.' },
@@ -9,7 +9,7 @@ export const CODE_GRAPH_TOOL_DEFS = [
9
9
  properties: {
10
10
  mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers'], description: 'Repo-local operation; code flow before text search.' },
11
11
  files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file(s): string or array; one call fans out per file (imports/dependents/related/impact/symbols/overview). `file` singular alias also accepted.' },
12
- symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Identifier(s)/keyword(s): string or array; batch in one call. One anchor is enough — no repeated lookups. `symbol` singular alias also accepted.' },
12
+ symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Identifier(s)/keyword(s): string or array; batch in one call. One anchor is enough. `symbol` singular alias also accepted.' },
13
13
  symbol: { type: 'string', description: 'Singular alias for symbols.' },
14
14
  file: { type: 'string', description: 'Singular alias for files.' },
15
15
  body: { type: 'boolean', description: 'Include body.' },
@@ -31,7 +31,7 @@ async function connectInner(self) {
31
31
  self.client = null;
32
32
  throw err;
33
33
  }
34
- self.persistAccessFromChannelsConfig();
34
+ self.persistAccessFromMainChannel();
35
35
  }
36
36
 
37
37
  async function buildClient(self) {
@@ -120,7 +120,7 @@ function registerEventListeners(self) {
120
120
  return;
121
121
  }
122
122
  if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isRoleSelectMenu() || interaction.isUserSelectMenu() || interaction.isChannelSelectMenu()) {
123
- const needsModal = interaction.isButton() && (interaction.customId === "sched_add_next" || interaction.customId === "sched_edit_next" || interaction.customId === "quiet_set_next" || interaction.customId === "activity_add_next" || interaction.customId === "profile_edit");
123
+ const needsModal = interaction.isButton() && (interaction.customId === "sched_add_next" || interaction.customId === "sched_edit_next" || interaction.customId === "profile_edit");
124
124
  if (needsModal) {
125
125
  if (self.onModalRequest) {
126
126
  await Promise.resolve(self.onModalRequest(interaction)).catch((err) => {
@@ -326,47 +326,37 @@ class DiscordBackend {
326
326
  try {
327
327
  const parsed = readSection("channels");
328
328
  const access = normalizeAccess(parsed.access ?? this.initialAccess);
329
- if (parsed.channelsConfig) {
330
- for (const entry of Object.values(parsed.channelsConfig)) {
331
- if (typeof entry === "object" && entry !== null) {
332
- const id = entry.channelId;
333
- if (id && !(id in access.channels)) {
334
- access.channels[id] = { requireMention: false, allowFrom: [] };
335
- }
336
- }
337
- }
329
+ // Single main channel: auto-allow the configured channel id so channel
330
+ // adds apply without a restart (mirrors loadAccess()).
331
+ const id = this.mainChannelId;
332
+ if (id && !(id in access.channels)) {
333
+ access.channels[id] = { requireMention: false, allowFrom: [] };
338
334
  }
339
335
  return access;
340
336
  } catch {
341
337
  return this.initialAccess;
342
338
  }
343
339
  }
344
- persistAccessFromChannelsConfig() {
340
+ persistAccessFromMainChannel() {
345
341
  if (this.isStatic || !this.configFile) return;
346
342
  try {
343
+ const id = this.mainChannelId;
344
+ if (!id) return;
347
345
  const parsed = readSection("channels");
348
- if (!parsed.channelsConfig) return;
349
346
  const access = normalizeAccess(parsed.access);
350
- let changed = false;
351
- for (const entry of Object.values(parsed.channelsConfig)) {
352
- if (typeof entry === "object" && entry !== null) {
353
- const id = entry.channelId;
354
- if (id && !(id in access.channels)) {
355
- access.channels[id] = { requireMention: false, allowFrom: [] };
356
- changed = true;
357
- }
358
- }
347
+ if (!(id in access.channels)) {
348
+ access.channels[id] = { requireMention: false, allowFrom: [] };
349
+ this.saveAccess(access);
359
350
  }
360
- if (changed) this.saveAccess(access);
361
351
  } catch (err) {
362
- process.stderr.write(`mixdog discord: persistAccessFromChannelsConfig failed: ${err}\n`);
352
+ process.stderr.write(`mixdog discord: persistAccessFromMainChannel failed: ${err}\n`);
363
353
  }
364
354
  }
365
355
  loadAccess() {
366
356
  const a = this.bootAccess ?? this.readConfigAccess();
367
357
  // Single-source channel setup: auto-allow the configured main channel so
368
358
  // both inbound (gate) and outbound (fetchAllowedChannel) accept it from a
369
- // single channelsConfig.main.channelId — no separate access.channels entry.
359
+ // single channel.channelId — no separate access.channels entry.
370
360
  // An explicit entry for the same channel is preserved (not overwritten).
371
361
  if (this.mainChannelId && a && !(this.mainChannelId in (a.channels ?? {}))) {
372
362
  return { ...a, channels: { ...(a.channels ?? {}), [this.mainChannelId]: { allowFrom: [], requireMention: false } } };
@@ -516,9 +506,8 @@ class DiscordBackend {
516
506
  }
517
507
  if (this.onMessage) {
518
508
  // Thread messages were gated against the parent channel's policy (see
519
- // gate() above), but routing also needs the parent id to find labels/
520
- // modes in channelsConfig. Surface parentChatId so resolveInboundRoute
521
- // can fall back to the parent when the thread id has no entry.
509
+ // gate() above). Surface parentChatId so downstream routing can fall
510
+ // back to the parent when the thread id needs it.
522
511
  const isThread = (() => { try { return !!msg.channel?.isThread?.(); } catch { return false; } })();
523
512
  const parentChatId = isThread ? (msg.channel.parentId ?? null) : null;
524
513
  this.onMessage({
@@ -112,22 +112,18 @@ class TelegramBackend {
112
112
  formatOutgoing(text) {
113
113
  return text;
114
114
  }
115
- // Fold every channelsConfig entry's channelId into access.channels, mirroring
116
- // DiscordBackend.readConfigAccess(). Read live so same-backend channel adds
117
- // apply without a restart. Falls back to initialAccess if the read throws.
115
+ // Fold the single configured main channel id into access.channels, mirroring
116
+ // DiscordBackend.readConfigAccess(). Read live so a channel change applies
117
+ // without a restart. Falls back to initialAccess if the read throws.
118
118
  readConfigAccess() {
119
119
  try {
120
120
  const parsed = readSection("channels");
121
121
  const access = normalizeAccess(parsed.access ?? this.initialAccess);
122
- if (parsed.channelsConfig) {
123
- for (const entry of Object.values(parsed.channelsConfig)) {
124
- if (typeof entry === "object" && entry !== null) {
125
- const id = entry.channelId;
126
- if (id && !(id in access.channels)) {
127
- access.channels[id] = { requireMention: false, allowFrom: [] };
128
- }
129
- }
130
- }
122
+ // Single main channel: auto-allow the configured channel id so a channel
123
+ // change applies without a restart (mirrors loadAccess()).
124
+ const id = this.mainChannelId;
125
+ if (id && !(id in access.channels)) {
126
+ access.channels[id] = { requireMention: false, allowFrom: [] };
131
127
  }
132
128
  return access;
133
129
  } catch {
@@ -208,12 +208,6 @@ const {
208
208
  callMemoryAction,
209
209
  handleMemoryCallResponse,
210
210
  } = createParentBridge({ getInstanceId: () => INSTANCE_ID });
211
- function resolveChannelLabel(channelsConfig, label) {
212
- if (!label || !channelsConfig) return label;
213
- const entry = channelsConfig[label];
214
- if (entry?.channelId) return entry.channelId;
215
- return label;
216
- }
217
211
  let channelBridgeActive = false;
218
212
  function writeBridgeState(active) {
219
213
  try {
@@ -518,11 +512,8 @@ async function rebindTranscriptContext(channelId, options = {}) {
518
512
  const scheduler = new Scheduler(
519
513
  config.nonInteractive ?? [],
520
514
  config.interactive ?? [],
521
- // channelsConfig kept for channel-label resolution (resolveChannel)
522
- // only — quiet/schedules now come from the top-level config.
523
- config.channelsConfig,
524
- // 0.1.62: top-level normalized config carries quiet/schedules.
525
- config
515
+ // Single resolved main-channel id used for the schedule `channel` flag.
516
+ config.channelId
526
517
  );
527
518
  // Register the pending-dispatch probe so the scheduler treats an in-flight
528
519
  // bridge dispatch as "active" regardless of user-inbound silence.
@@ -608,10 +599,7 @@ async function bindPersistedTranscriptIfAny() {
608
599
  }
609
600
  }
610
601
  if (!currentStatus.channelId && channelBridgeActive) {
611
- const chCfg = config.channelsConfig;
612
- const mainLabel = config.mainChannel ?? "main";
613
- const mainEntry = chCfg?.[mainLabel];
614
- const mainId = mainEntry?.channelId;
602
+ const mainId = config.channelId;
615
603
  if (mainId) {
616
604
  statusState.update((state) => {
617
605
  state.channelId = mainId;
@@ -637,17 +625,14 @@ function shouldStartEventPipelineRuntime() {
637
625
  }
638
626
  function ensureEventPipelineRuntime() {
639
627
  if (!eventPipeline) {
640
- eventPipeline = new EventPipeline(config.events, config.channelsConfig);
628
+ eventPipeline = new EventPipeline(config.events, config.channelId);
641
629
  wireEventQueueHandlers(eventPipeline.getQueue());
642
630
  }
643
631
  return eventPipeline;
644
632
  }
645
633
  function ensureWebhookServerRuntime() {
646
634
  if (!webhookServer) {
647
- // Pass top-level normalized config so the webhook gate reads the new
648
- // top-level `quiet` subtree (and `webhook.respectQuiet`) introduced in
649
- // 0.1.62. See applyDefaults() in lib/config.mjs.
650
- webhookServer = new WebhookServer(config.webhook, { quiet: config.quiet ?? null });
635
+ webhookServer = new WebhookServer(config.webhook);
651
636
  }
652
637
  wireWebhookHandlers();
653
638
  return webhookServer;
@@ -666,7 +651,7 @@ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
666
651
  if (shouldStartEventPipelineRuntime()) {
667
652
  const pipeline = ensureEventPipelineRuntime();
668
653
  if (reload) {
669
- pipeline.reloadConfig(config.events, config.channelsConfig);
654
+ pipeline.reloadConfig(config.events, config.channelId);
670
655
  wireEventQueueHandlers(pipeline.getQueue());
671
656
  }
672
657
  pipeline.start();
@@ -683,7 +668,7 @@ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
683
668
  // so we don't race the bound port — calling start() synchronously
684
669
  // here would re-listen before close() finishes and surface
685
670
  // EADDRINUSE on the same port.
686
- server.reloadConfig(config.webhook, { quiet: config.quiet ?? null }, {
671
+ server.reloadConfig(config.webhook, {
687
672
  autoStart: false
688
673
  }).then(() => {
689
674
  // A stopWebhookAndEventRuntime() / deactivate landing during the async
@@ -940,10 +925,8 @@ async function reloadRuntimeConfig() {
940
925
  scheduler.reloadConfig(
941
926
  config.nonInteractive ?? [],
942
927
  config.interactive ?? [],
943
- // channelsConfig: channel-label resolution only.
944
- config.channelsConfig,
945
- // 0.1.62: top-level normalized config (quiet/schedules).
946
- config,
928
+ // Single resolved main-channel id used for the schedule `channel` flag.
929
+ config.channelId,
947
930
  { restart: bridgeRuntimeConnected }
948
931
  );
949
932
  const nextBackend = createBackend(config);
@@ -1128,15 +1111,17 @@ function wireWebhookHandlers() {
1128
1111
  );
1129
1112
  });
1130
1113
  }
1131
- function resolveWebhookChannelId(channelLabel) {
1132
- // Fail closed: route only to channels explicitly present in config —
1133
- // the endpoint's owner-configured `channel`, else the `main` channel.
1134
- // Runtime / persisted-status fallbacks are never used (they could route
1135
- // a delivery to an arbitrary or stale channel). The endpoint channel is
1136
- // owner-authored config, not attacker payload, so honoring it is safe.
1137
- const channels = config?.channelsConfig || {};
1138
- if (channelLabel && channels[channelLabel]?.channelId) return channels[channelLabel].channelId;
1139
- return channels.main?.channelId || "";
1114
+ function resolveWebhookChannelId(channelFlag) {
1115
+ // Single main channel: the endpoint's `channel` frontmatter is a boolean-ish
1116
+ // flag (any non-empty value except "false" → post to the main channel). A raw
1117
+ // channel id is still honored verbatim for owner-authored overrides.
1118
+ const main = config?.channelId || "";
1119
+ if (channelFlag == null) return main;
1120
+ const v = String(channelFlag).trim();
1121
+ if (v === "" || v.toLowerCase() === "false") return "";
1122
+ // A pure-digit / snowflake-looking value is treated as an explicit id;
1123
+ // anything else (legacy labels like "main") maps to the main channel.
1124
+ return /^-?\d+$/.test(v) ? v : main;
1140
1125
  }
1141
1126
  function wireEventQueueHandlers(eventQueue) {
1142
1127
  if (!eventQueue) return;
@@ -1390,13 +1375,10 @@ import { TOOL_DEFS } from './tool-defs.mjs';
1390
1375
  const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch"]);
1391
1376
  // ── Inbound routing / dedup / ownership helpers ─────────────────────────────
1392
1377
  // Extracted → lib/inbound-routing.mjs. Bound to live config/identity getters.
1393
- // Created here (ahead of backend-dispatch) so labelForChannelId is initialised
1394
- // before it is passed into createBackendDispatch below.
1395
1378
  const {
1396
1379
  writeChannelOwner,
1397
1380
  shouldDropDuplicateInbound,
1398
1381
  resolveInboundRoute,
1399
- labelForChannelId,
1400
1382
  } = createInboundRouting({
1401
1383
  getConfig: () => config,
1402
1384
  getInstanceId: () => INSTANCE_ID,
@@ -1414,8 +1396,6 @@ const {
1414
1396
  getConfig: () => config,
1415
1397
  getBackend: () => backend,
1416
1398
  scheduler,
1417
- resolveChannelLabel,
1418
- labelForChannelId,
1419
1399
  });
1420
1400
  // ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
1421
1401
  // handleToolCall switch + bridge auto-connect retry wrapper. Extracted →
@@ -1472,7 +1452,8 @@ backend.onMessage = (msg) => {
1472
1452
  }
1473
1453
  if (!channelBridgeActive) return;
1474
1454
  if (shouldDropDuplicateInbound(msg)) return;
1475
- recordFetchedMessages(msg.chatId, labelForChannelId(msg.chatId), [{ id: msg.messageId }], { markRead: true });
1455
+ // No label concept anymore: the channel id IS the identifier.
1456
+ recordFetchedMessages(msg.chatId, msg.chatId, [{ id: msg.messageId }], { markRead: true });
1476
1457
  if (!writeChannelOwner(msg.chatId)) return;
1477
1458
  const route = resolveInboundRoute(msg.chatId, msg.parentChatId);
1478
1459
  scheduler.noteActivity();
@@ -1877,9 +1858,8 @@ if (_isWorkerMode && process.send) {
1877
1858
  }
1878
1859
  refreshToolExecConsumerMarker();
1879
1860
  }
1880
- const mainLabel = config?.mainChannel || 'main';
1881
1861
  const target = (statusState?.read?.().channelId)
1882
- || resolveChannelLabel(config?.channelsConfig, mainLabel)
1862
+ || config?.channelId
1883
1863
  || null;
1884
1864
  if (!target || !backend?.sendMessage) {
1885
1865
  process.stderr.write(`mixdog channels: permission_request dropped, no target channel (request_id=${request_id})\n`);
@@ -3,7 +3,7 @@ import { recordFetchedMessages } from "./status-snapshot.mjs";
3
3
  // Backend-tool dispatch helpers. Extracted verbatim from channels/index.mjs
4
4
  // (behavior-preserving). Bound to live getters so runtime config/backend
5
5
  // reloads keep the original file-level reference semantics.
6
- function createBackendDispatch({ getConfig, getBackend, scheduler, resolveChannelLabel, labelForChannelId }) {
6
+ function createBackendDispatch({ getConfig, getBackend, scheduler }) {
7
7
  async function dispatchReply(args) {
8
8
  const sendOpts = {
9
9
  replyTo: args.reply_to,
@@ -22,12 +22,14 @@ function createBackendDispatch({ getConfig, getBackend, scheduler, resolveChanne
22
22
  }
23
23
 
24
24
  async function dispatchFetch(args) {
25
+ // `args.channel` is a raw channel id (no label resolution anymore); when
26
+ // omitted, fall back to the single configured main channel id.
25
27
  const config = getConfig();
26
- const channelId = resolveChannelLabel(config.channelsConfig, args.channel);
28
+ const channelId = args.channel || config.channelId || "";
27
29
  const limit = args.limit ?? 20;
28
30
  let msgs;
29
31
  msgs = await getBackend().fetchMessages(channelId, limit);
30
- recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
32
+ recordFetchedMessages(channelId, channelId, msgs);
31
33
  const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
32
34
  const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
33
35
  return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;