mixdog 0.9.90 → 0.9.92

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 (56) hide show
  1. package/package.json +5 -1
  2. package/scripts/tool-overhead-microbench.mjs +60 -0
  3. package/src/agents/debugger/agent.json +1 -1
  4. package/src/agents/explore/agent.json +1 -1
  5. package/src/agents/heavy-worker/agent.json +1 -1
  6. package/src/agents/maintainer/agent.json +1 -1
  7. package/src/agents/reviewer/agent.json +1 -1
  8. package/src/agents/worker/agent.json +1 -1
  9. package/src/lib/rules-builder.cjs +5 -5
  10. package/src/output-styles/detailed.md +27 -0
  11. package/src/output-styles/extreme-minimal.md +8 -8
  12. package/src/output-styles/minimal.md +7 -10
  13. package/src/output-styles/simple.md +12 -20
  14. package/src/rules/lead/01-general.md +9 -1
  15. package/src/rules/lead/lead-brief.md +15 -14
  16. package/src/rules/shared/01-tool.md +32 -37
  17. package/src/runtime/agent/orchestrator/agent-runtime/commit-message-completion.mjs +67 -0
  18. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +10 -0
  19. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +10 -0
  21. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +35 -0
  22. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +47 -1
  23. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -0
  24. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +28 -1
  25. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +20 -2
  26. package/src/runtime/agent/orchestrator/session/result-classification.mjs +28 -0
  27. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +176 -3
  28. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -11
  29. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +6 -6
  30. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +15 -3
  31. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +16 -2
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +176 -16
  34. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +5 -0
  35. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +30 -1
  36. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +96 -5
  38. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +72 -1
  39. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +48 -9
  41. package/src/runtime/channels/backends/discord.mjs +21 -1
  42. package/src/runtime/channels/tool-defs.mjs +1 -1
  43. package/src/runtime/memory/lib/trace-store.mjs +25 -3
  44. package/src/runtime/memory/tool-defs.mjs +1 -3
  45. package/src/runtime/search/tool-defs.mjs +2 -18
  46. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  47. package/src/session-runtime/output-styles.mjs +4 -6
  48. package/src/session-runtime/tool-defs.mjs +0 -1
  49. package/src/session-runtime/tool-surface.mjs +9 -0
  50. package/src/session-runtime/workflow.mjs +25 -8
  51. package/src/tui/dist/index.mjs +32 -1
  52. package/src/tui/engine/session-api.mjs +17 -0
  53. package/src/tui/engine/tui-steering-persist.mjs +24 -1
  54. package/src/workflows/default/WORKFLOW.md +7 -17
  55. package/src/workflows/solo/WORKFLOW.md +7 -5
  56. package/src/output-styles/default.md +0 -40
@@ -27,6 +27,7 @@ import {
27
27
  import {
28
28
  findLineSequence,
29
29
  findLineSequenceEscapeEquiv,
30
+ longestCommonSubstringLen,
30
31
  splitTextLinesForPatch,
31
32
  firstMeaningfulPatchLine,
32
33
  nearestPatchLineHint,
@@ -42,6 +43,7 @@ function v4AHunkLineStats(hunk) {
42
43
  let newCount = 0;
43
44
  const oldLines = [];
44
45
  const newLines = [];
46
+ const oldTags = [];
45
47
  for (const raw of hunk.lines || []) {
46
48
  if (!raw) continue;
47
49
  const tag = raw[0];
@@ -51,15 +53,17 @@ function v4AHunkLineStats(hunk) {
51
53
  newCount++;
52
54
  oldLines.push(body);
53
55
  newLines.push(body);
56
+ oldTags.push(' ');
54
57
  } else if (tag === '-') {
55
58
  oldCount++;
56
59
  oldLines.push(body);
60
+ oldTags.push('-');
57
61
  } else if (tag === '+') {
58
62
  newCount++;
59
63
  newLines.push(body);
60
64
  }
61
65
  }
62
- return { oldCount, newCount, oldLines, newLines };
66
+ return { oldCount, newCount, oldLines, newLines, oldTags };
63
67
  }
64
68
 
65
69
  function findAnchorLine(lines, anchors, fromLine) {
@@ -164,6 +168,46 @@ function eofInsertionIndex(sourceLines) {
164
168
  return len > 0 && sourceLines[len - 1] === '' ? len - 1 : len;
165
169
  }
166
170
 
171
+ // Bounded context-tolerance tier (fuzzy, non-EOF, last resort before the
172
+ // context-miss error). Recovers the measured top remaining failure class —
173
+ // 1-2 nearby context lines drifted since the model last saw the file — under
174
+ // guards that make mis-application practically impossible:
175
+ // - every '-' (deletion) line must match the file byte-exactly;
176
+ // - only ' ' (context) lines may mismatch, at most 2, and each drifted
177
+ // line must still resemble the file line (shared substring >= half);
178
+ // - at least 2 exact non-blank matches must anchor the window;
179
+ // - the qualifying window must be UNIQUE across the ENTIRE file;
180
+ // - the caller REMAPS tolerated context to the file's on-disk lines, so a
181
+ // drifted patch line never overwrites file content it did not target.
182
+ function findContextTolerantWindow(sourceLines, oldLines, oldTags) {
183
+ const n = oldLines.length;
184
+ if (n < 3 || !Array.isArray(oldTags) || oldTags.length !== n) return null;
185
+ const collapse = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
186
+ const similar = (a, b) => {
187
+ const ca = collapse(a);
188
+ const cb = collapse(b);
189
+ if (!ca || !cb) return false;
190
+ return longestCommonSubstringLen(ca, cb) >= Math.max(4, Math.ceil(Math.max(ca.length, cb.length) / 2));
191
+ };
192
+ const windows = [];
193
+ for (let i = 0; i + n <= sourceLines.length && windows.length < 2; i++) {
194
+ let mismatches = 0;
195
+ let exactNonBlank = 0;
196
+ let ok = true;
197
+ for (let k = 0; k < n; k++) {
198
+ const pat = oldLines[k];
199
+ const src = sourceLines[i + k];
200
+ if (src === pat) {
201
+ if (collapse(pat)) exactNonBlank++;
202
+ continue;
203
+ }
204
+ if (oldTags[k] !== ' ' || ++mismatches > 2 || !similar(pat, src)) { ok = false; break; }
205
+ }
206
+ if (ok && mismatches > 0 && exactNonBlank >= 2) windows.push(i);
207
+ }
208
+ return windows.length === 1 ? { start: windows[0] } : null;
209
+ }
210
+
167
211
  function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {}) {
168
212
  const stats = v4AHunkLineStats(hunk);
169
213
  if (stats.oldCount === 0 && stats.newCount === 0) return { skip: true };
@@ -242,6 +286,33 @@ function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {})
242
286
  }
243
287
  }
244
288
  }
289
+ // Context-tolerance tier: see findContextTolerantWindow. Tolerated context
290
+ // lines are remapped to the file's actual lines on BOTH the old and new
291
+ // side (same shape as the escape-equiv remap above); remapping bails on any
292
+ // string ambiguity so an unrelated occurrence can never be rewritten.
293
+ if (oldStartIdx < 0 && fuzzy && !eof && oldLinesPattern.length >= 3) {
294
+ const tolTags = trimmedTrailing ? stats.oldTags.slice(0, -1) : stats.oldTags;
295
+ const tol = findContextTolerantWindow(sourceLines, oldLinesPattern, tolTags);
296
+ if (tol) {
297
+ const remapped = new Map();
298
+ let ambiguous = false;
299
+ for (let k = 0; k < oldLinesPattern.length; k++) {
300
+ const pat = oldLinesPattern[k];
301
+ const src = sourceLines[tol.start + k];
302
+ if (src === pat) continue;
303
+ if (
304
+ oldLinesPattern.filter((l) => l === pat).length > 1
305
+ || (remapped.has(pat) && remapped.get(pat) !== src)
306
+ ) { ambiguous = true; break; }
307
+ remapped.set(pat, src);
308
+ }
309
+ if (!ambiguous) {
310
+ newLinesPattern = newLinesPattern.map((l) => remapped.get(l) ?? l);
311
+ oldLinesPattern = oldLinesPattern.map((_, k) => sourceLines[tol.start + k]);
312
+ oldStartIdx = tol.start;
313
+ }
314
+ }
315
+ }
245
316
  if (oldStartIdx < 0) {
246
317
  const msg = `V4A hunk context not found: ${formatV4AHunkLocator(hunk)};${formatV4AContextMissHint(sourceLines, stats, anchorLine)}`;
247
318
  return { error: msg };
@@ -48,7 +48,7 @@ const APPLY_PATCH_JSON_DESCRIPTION = [
48
48
  '- *** Delete File: <path> with nothing after it.',
49
49
  '- *** Update File: <path>, optionally followed by *** Move to: <new path>.',
50
50
  'Updates contain hunks introduced by @@ or @@ <enclosing class/function>. Prefix every hunk line with space (context), - (remove), or + (add); a hunk at end of file may close with *** End of File.',
51
- 'Copy 3 context lines above and below verbatim from the file. Do not duplicate overlapping context. If context is not unique, add one or more enclosing @@ headers.',
51
+ 'Copy 3 context lines above and below verbatim from the newest tool output of the file (never retype from memory; after your own patch, use post-patch content). Do not duplicate overlapping context. If context is not unique, add one or more enclosing @@ headers.',
52
52
  'Use project-relative paths. Every added file line needs +. Never submit a compacted-history marker; re-read and create a fresh patch.',
53
53
  ].join('\n');
54
54
 
@@ -115,9 +115,39 @@ function _admissionSaturationError(admission, waitMs) {
115
115
  return error;
116
116
  }
117
117
 
118
- async function _acquireShellLeaseBounded(admission, { abortSignal, label }) {
118
+ // Memory-pressure rejections from resource-admission are TRANSIENT: they clear
119
+ // when concurrent work finishes releasing RSS/host memory. Measured 2026-08:
120
+ // ~25 shell calls/14d failed instantly on `[resource pressure]` during
121
+ // parallel bench/build waves. Instead of failing the tool call, retry the
122
+ // acquire on a short backoff inside the existing admission deadline so most
123
+ // of those become slightly-delayed successes. Non-memory errors (queue full,
124
+ // detached-dependency, saturation deadline, aborts) still throw immediately.
125
+ function _isMemoryPressureError(err) {
126
+ if (!err || err.code !== 'ERESOURCEPRESSURE') return false;
127
+ if (err.metric === 'rss' || err.metric === 'free-memory') return true;
128
+ return /memory metrics unavailable/i.test(String(err.message || ''));
129
+ }
130
+
131
+ function _abortableDelay(ms, signal) {
132
+ return new Promise((resolveDelay) => {
133
+ let onAbort = null;
134
+ // NOT unref'd: this delay is awaited foreground work — an unref'd timer
135
+ // lets the event loop drain and strands the retry loop forever.
136
+ const timer = setTimeout(() => {
137
+ if (onAbort && signal) { try { signal.removeEventListener('abort', onAbort); } catch {} }
138
+ resolveDelay();
139
+ }, ms);
140
+ if (signal) {
141
+ onAbort = () => { clearTimeout(timer); resolveDelay(); };
142
+ if (signal.aborted) onAbort();
143
+ else signal.addEventListener('abort', onAbort, { once: true });
144
+ }
145
+ });
146
+ }
147
+
148
+ export async function acquireShellLeaseBounded(admission, { abortSignal, label, dependency = 'scoped' } = {}) {
119
149
  if (!(SHELL_ADMISSION_WAIT_MS > 0)) {
120
- return admission.acquire('shell', { signal: abortSignal || null, label });
150
+ return admission.acquire('shell', { signal: abortSignal || null, label, dependency });
121
151
  }
122
152
  const ctl = new AbortController();
123
153
  const onAbort = () => {
@@ -127,17 +157,26 @@ async function _acquireShellLeaseBounded(admission, { abortSignal, label }) {
127
157
  if (abortSignal.aborted) onAbort();
128
158
  else abortSignal.addEventListener('abort', onAbort, { once: true });
129
159
  }
160
+ const deadlineAt = Date.now() + SHELL_ADMISSION_WAIT_MS;
130
161
  const deadline = setTimeout(() => {
131
162
  try { ctl.abort(_admissionSaturationError(admission, SHELL_ADMISSION_WAIT_MS)); } catch {}
132
163
  }, SHELL_ADMISSION_WAIT_MS);
133
164
  if (deadline.unref) deadline.unref();
134
165
  try {
135
- const lease = await admission.acquire('shell', { signal: ctl.signal, label });
136
- // Hand governance back to the caller's signal: the internal deadline
137
- // controller may still fire in a lost race after grant, and a stale
138
- // aborted signal on the lease would poison later parent-restore paths.
139
- lease.signal = abortSignal || null;
140
- return lease;
166
+ for (;;) {
167
+ try {
168
+ const lease = await admission.acquire('shell', { signal: ctl.signal, label, dependency });
169
+ // Hand governance back to the caller's signal: the internal deadline
170
+ // controller may still fire in a lost race after grant, and a stale
171
+ // aborted signal on the lease would poison later parent-restore paths.
172
+ lease.signal = abortSignal || null;
173
+ return lease;
174
+ } catch (err) {
175
+ const remainingMs = deadlineAt - Date.now();
176
+ if (!_isMemoryPressureError(err) || ctl.signal.aborted || remainingMs <= 1_200) throw err;
177
+ await _abortableDelay(Math.min(1_000, remainingMs), ctl.signal);
178
+ }
179
+ }
141
180
  } finally {
142
181
  clearTimeout(deadline);
143
182
  if (abortSignal) { try { abortSignal.removeEventListener('abort', onAbort); } catch {} }
@@ -320,7 +359,7 @@ export function execShellCommand({
320
359
  let _markerFilters = null;
321
360
  let _onChildErrorRef = null;
322
361
  try {
323
- resourceLease = await _acquireShellLeaseBounded(admission, {
362
+ resourceLease = await acquireShellLeaseBounded(admission, {
324
363
  abortSignal,
325
364
  label: String(command || '').slice(0, 120),
326
365
  });
@@ -12,7 +12,9 @@ async function ensureDiscord() {
12
12
  import {
13
13
  mkdirSync,
14
14
  statSync,
15
- realpathSync
15
+ realpathSync,
16
+ readdirSync,
17
+ unlinkSync
16
18
  } from "fs";
17
19
  import { join, sep } from "path";
18
20
  import { createHash } from "crypto";
@@ -83,8 +85,26 @@ class DiscordBackend {
83
85
  }
84
86
  // ── Lifecycle ──────────────────────────────────────────────────────
85
87
  async connect() {
88
+ this._sweepInboxOnce();
86
89
  return gateway.connect(this);
87
90
  }
91
+ // Voice/attachment inbox retention: downloaded .ogg/.wav payloads are
92
+ // consumed at transcription time and were never cleaned up (audit: 477
93
+ // files / 55 MB accumulated since July). Best-effort, once per process.
94
+ _sweepInboxOnce() {
95
+ if (this._inboxSweepDone) return;
96
+ this._inboxSweepDone = true;
97
+ try {
98
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
99
+ for (const name of readdirSync(this.inboxDir)) {
100
+ const p = join(this.inboxDir, name);
101
+ try {
102
+ const st = statSync(p);
103
+ if (st.isFile() && st.mtimeMs < cutoff) unlinkSync(p);
104
+ } catch { /* per-file best-effort */ }
105
+ }
106
+ } catch { /* inbox may not exist yet */ }
107
+ }
88
108
  async disconnect() {
89
109
  for (const interval of this.typingIntervals.values()) {
90
110
  clearInterval(interval);
@@ -32,7 +32,7 @@ export const TOOL_DEFS = [
32
32
  name: "fetch",
33
33
  title: "Fetch",
34
34
  annotations: { title: "Fetch", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
35
- description: "Discord-only recent messages. NOT for URLs; use web_fetch.",
35
+ description: "Discord-only: read recent messages from a channel.",
36
36
  inputSchema: {
37
37
  type: "object",
38
38
  properties: {
@@ -465,13 +465,18 @@ async function ensureCurrentAndNextMonthPartitions(client) {
465
465
  // Trace retention: drop named monthly partitions older than the configured
466
466
  // window. Trace is regenerable observability data, so aging out old months is
467
467
  // non-destructive to durable state. Config via MIXDOG_TRACE_RETENTION_MONTHS
468
- // (default 6); set to 0 to disable. Only DROPs partitions strictly older than
468
+ // (default 1); set to 0 to disable. Only DROPs partitions strictly older than
469
469
  // the cutoff month; the default catch-all partition is never dropped.
470
+ // Default rationale: a personal-machine audit found trace partitions at 76%
471
+ // of the 1.5 GB memory DB (July alone 787 MB) while durable entries were
472
+ // 207 MB. Monthly partitions mean retention 1 always keeps the current +
473
+ // previous month (30-60 days of full-resolution diagnostics) while bounding
474
+ // the DB to a rolling ceiling instead of unbounded growth.
470
475
  const TRACE_RETENTION_MONTHS = (() => {
471
476
  const raw = process.env.MIXDOG_TRACE_RETENTION_MONTHS
472
- if (raw == null || raw === '') return 6
477
+ if (raw == null || raw === '') return 1
473
478
  const n = Number(raw)
474
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 6
479
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 1
475
480
  })()
476
481
 
477
482
  async function dropAgedTracePartitions(client) {
@@ -499,6 +504,22 @@ async function dropAgedTracePartitions(client) {
499
504
  `)
500
505
  }
501
506
 
507
+ // Row-level ageing for the non-partitioned trace side tables. agent_calls and
508
+ // agent_llm grew unbounded (284 MB observed) because only trace_events had a
509
+ // retention path. Same window, same regenerable-diagnostics rationale; space
510
+ // is reclaimed by autovacuum row reuse rather than immediate disk shrink.
511
+ async function deleteAgedTraceRows(client) {
512
+ if (TRACE_RETENTION_MONTHS <= 0) return
513
+ const months = TRACE_RETENTION_MONTHS
514
+ for (const table of ['agent_calls', 'agent_llm', 'bridge_calls', 'bridge_llm']) {
515
+ try {
516
+ await client.query(`DELETE FROM ${table} WHERE ts < now() - interval '${months} months'`)
517
+ } catch (err) {
518
+ __mixdogMemoryLog(`[trace-store] aged-row delete skipped for ${table}: ${err?.message ?? err}\n`)
519
+ }
520
+ }
521
+ }
522
+
502
523
  async function isBootstrapComplete(client) {
503
524
  try {
504
525
  // Harden the check: require (a) root is partitioned, (b) schema-version
@@ -624,6 +645,7 @@ export async function openTraceDatabase(dataDir) {
624
645
  await c.query(`SET search_path = trace, public`)
625
646
  await ensureCurrentAndNextMonthPartitions(c)
626
647
  await dropAgedTracePartitions(c)
648
+ await deleteAgedTraceRows(c)
627
649
  } catch (err) {
628
650
  __mixdogMemoryLog(`[trace-store] periodic partition ensure failed: ${err?.message ?? err}\n`)
629
651
  } finally {
@@ -48,7 +48,7 @@ export const TOOL_DEFS = [
48
48
  name: 'recall',
49
49
  title: 'Recall',
50
50
  annotations: { title: 'Recall', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
51
- description: 'Retrieve stored memory/session history for prior-work context (resumes, earlier decisions); not a reflexive pre-step. Query is topic/semantic, not regex; period selects the time window; id for exact follow-up.',
51
+ description: 'Retrieve stored memory/session history for prior-work context (resumes, earlier decisions). Query is topic/semantic, not regex; period selects the time window; id for exact follow-up.',
52
52
  inputSchema: {
53
53
  type: 'object',
54
54
  properties: {
@@ -60,12 +60,10 @@ export const TOOL_DEFS = [
60
60
  sort: { type: 'string', enum: ['importance', 'date'], description: 'importance or date.' },
61
61
  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.' },
62
62
  includeArchived: { type: 'boolean', description: 'Include archived entries.' },
63
- sessionId: { type: 'string', description: 'Scoped session id.' },
64
63
  includeMembers: { type: 'boolean', description: 'Include chunk members in output; does not widen the search pool.' },
65
64
  includeRaw: { type: 'boolean', description: 'Include unchunked raw/episode rows.' },
66
65
  sessionOnly: { type: 'boolean', description: 'Search this session only.' },
67
66
  projectScope: { type: 'string', description: 'Project pool selector: inferred from cwd, common, all, or slug.' },
68
- cwd: { type: 'string', description: 'Infer projectScope.' },
69
67
  },
70
68
  additionalProperties: false,
71
69
  },
@@ -8,7 +8,7 @@ export const TOOL_DEFS = [
8
8
  {
9
9
  name: 'search',
10
10
  title: 'Mixdog Web Search',
11
- description: `Web/docs/current-info search; not repo-local. ${TOOL_SYNC_EXECUTION_CONTRACT} Cached; query supports array fan-out.`,
11
+ description: `Web/docs/current-info search. ${TOOL_SYNC_EXECUTION_CONTRACT} Cached; query supports array fan-out.`,
12
12
  inputSchema: {
13
13
  type: 'object',
14
14
  properties: {
@@ -16,23 +16,7 @@ export const TOOL_DEFS = [
16
16
  site: { type: 'string', description: 'Site/domain filter.' },
17
17
  type: { type: 'string', enum: ['web', 'news', 'images'], description: 'Default web.' },
18
18
  maxResults: { type: 'number', minimum: 1, maximum: 20, description: 'Result count, 1-20.' },
19
- locale: {
20
- description: 'Optional locale hint.',
21
- anyOf: [
22
- { type: 'string' },
23
- {
24
- type: 'object',
25
- properties: {
26
- country: { type: 'string', description: 'Country.' },
27
- language: { type: 'string', description: 'Language.' },
28
- region: { type: 'string', description: 'Region.' },
29
- city: { type: 'string', description: 'City.' },
30
- timezone: { type: 'string', description: 'Timezone.' },
31
- },
32
- additionalProperties: false,
33
- },
34
- ],
35
- },
19
+ locale: { type: 'string', description: 'Optional locale hint, e.g. ko-KR or a city/region name.' },
36
20
  contextSize: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Default low.' },
37
21
  },
38
22
  required: [],
@@ -2,7 +2,7 @@ export const TOOL_SYNC_EXECUTION_CONTRACT =
2
2
  'Runs synchronously in this tool call.';
3
3
 
4
4
  export const TOOL_ASYNC_EXECUTION_CONTRACT =
5
- 'Runs sync inline; no default auto-background. async forces a background task_id + completion notification. status/read/wait are recovery/blocking only.';
5
+ 'Runs sync inline by default; async returns a background task_id and delivers a completion notification.';
6
6
 
7
7
  export const TOOL_MANUAL_CONTROL_CONTRACT =
8
8
  'wait/read/status/cancel are for explicit blocking or recovery only.';
@@ -5,10 +5,8 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
5
5
  import { clean } from './session-text.mjs';
6
6
  import { readJsonSafe } from './fs-utils.mjs';
7
7
 
8
- const OUTPUT_STYLE_ORDER = ['default', 'simple', 'minimal', 'extreme-minimal'];
8
+ const OUTPUT_STYLE_ORDER = ['detailed', 'simple', 'minimal', 'extreme-minimal'];
9
9
  const OUTPUT_STYLE_ALIASES = new Map([
10
- ['compact', 'default'],
11
- ['normal', 'default'],
12
10
  ['extreme', 'extreme-minimal'],
13
11
  ['extremesimple', 'extreme-minimal'],
14
12
  ['extreme-simple', 'extreme-minimal'],
@@ -146,15 +144,15 @@ export function findOutputStyle(value, styles) {
146
144
 
147
145
  function configuredOutputStyleValue(dataDir) {
148
146
  const unified = readJsonSafe(join(dataDir, 'mixdog-config.json')) || {};
149
- return clean(unified.outputStyle || (unified.agent && unified.agent.outputStyle) || 'default') || 'default';
147
+ return clean(unified.outputStyle || (unified.agent && unified.agent.outputStyle) || 'simple') || 'simple';
150
148
  }
151
149
 
152
150
  export function outputStyleStatus(rootDir, dataDir, { fresh = false } = {}) {
153
151
  const styles = listOutputStyleCatalog(rootDir, dataDir, { fresh });
154
152
  const configured = configuredOutputStyleValue(dataDir);
155
153
  const current = findOutputStyle(configured, styles)
156
- || findOutputStyle('default', styles)
154
+ || findOutputStyle('simple', styles)
157
155
  || styles[0]
158
- || { id: 'default', label: 'Default', description: '', aliases: [], source: 'builtin' };
156
+ || { id: 'simple', label: 'Simple', description: '', aliases: [], source: 'builtin' };
159
157
  return { configured, current, styles };
160
158
  }
@@ -18,7 +18,6 @@ export const TOOL_SEARCH_TOOL = {
18
18
  type: 'object',
19
19
  properties: {
20
20
  names: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: 'Exact deferred tool names/aliases to load.' },
21
- select: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: 'Legacy alias for names; accepts exact names/aliases and select:name syntax.' },
22
21
  },
23
22
  additionalProperties: false,
24
23
  },
@@ -26,8 +26,17 @@ export function createToolSurface({
26
26
 
27
27
  // A workflow that delegates to NOBODY must not advertise the agent tool: a
28
28
  // schema-visible tool policy always rejects is a guaranteed error turn.
29
+ // The SESSION's effective workflow wins over the config-active one: headless
30
+ // and per-session overrides (e.g. bench `workflow=solo`) never touch the
31
+ // config default, so keying off config alone left the agent tool visible in
32
+ // Solo sessions (observed across a full TB2.1 run: advertised, never used).
29
33
  function workflowAllowsAgents() {
30
34
  try {
35
+ const sessionWorkflow = getSession()?.workflow;
36
+ if (sessionWorkflow && typeof sessionWorkflow === 'object'
37
+ && sessionWorkflow.agentsConfigured === true) {
38
+ return Array.isArray(sessionWorkflow.agents) && sessionWorkflow.agents.length > 0;
39
+ }
31
40
  const pack = loadWorkflowPack(cfgMod.getPluginData?.() || dataDir, activeWorkflowId(getConfig()));
32
41
  if (!pack || pack.agentsConfigured !== true) return true;
33
42
  return Array.isArray(pack.agents) && pack.agents.length > 0;
@@ -16,12 +16,18 @@ export const FIXED_AGENT_SLOTS = Object.freeze([
16
16
  // (user: 창이 크지 않으니 설명은 짧게).
17
17
  { id: 'explore', label: 'Explore', description: 'Repository exploration', workflowSlot: 'explorer' },
18
18
  { id: 'maintainer', label: 'Maintainer', description: 'Memory and upkeep', workflowSlot: 'memory' },
19
- { id: 'worker', label: 'Worker', description: 'Scoped implementation' },
20
- { id: 'heavy-worker', label: 'Heavy Worker', description: 'Multi-file implementation' },
19
+ { id: 'worker', label: 'Worker', description: 'Simple tasks' },
20
+ { id: 'heavy-worker', label: 'Heavy Worker', description: 'Complex tasks' },
21
21
  { id: 'reviewer', label: 'Reviewer', description: 'Diff and risk review' },
22
22
  { id: 'debugger', label: 'Debugger', description: 'Root-cause debugging' },
23
23
  ]);
24
24
  const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
25
+ // Slot-backed built-ins (explore/maintainer) run through their own dedicated
26
+ // channels — the explore tool and the memory cycle — so they are never
27
+ // Lead-delegation targets and stay out of the Available Agents catalog.
28
+ const BUILTIN_SLOT_AGENT_IDS = new Set(
29
+ FIXED_AGENT_SLOTS.filter((agent) => agent.workflowSlot).map((agent) => agent.id),
30
+ );
25
31
  export const DEFAULT_WORKFLOW_ID = 'default';
26
32
 
27
33
  const SEARCH_CAPABLE_PROVIDERS = new Set([
@@ -229,6 +235,13 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
229
235
  name: clean(pack?.name) || (id === DEFAULT_WORKFLOW_ID ? 'Default' : id),
230
236
  description: clean(pack?.description),
231
237
  source: clean(pack?.source),
238
+ // Delegation surface fields: the session stores this summary as
239
+ // session.workflow, and the agent-tool gate (tool-surface.mjs
240
+ // workflowAllowsAgents) needs them to filter the agent tool for
241
+ // packs that delegate to nobody — including headless/bench sessions
242
+ // whose workflow never touches the config-active pack.
243
+ agentsConfigured: pack?.agentsConfigured === true,
244
+ agents: Array.isArray(pack?.agents) ? pack.agents.slice() : [],
232
245
  };
233
246
  }
234
247
 
@@ -275,16 +288,20 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
275
288
  lines.push(pack.body);
276
289
  // A hand-edited pack may name a hidden role in its `agents:` frontmatter;
277
290
  // internal roles are never delegatable, so they never enter the catalog.
291
+ // Slot-backed built-ins are equally non-delegatable (they ride the explore
292
+ // tool / memory cycle), so they are filtered even when a pack names them.
278
293
  const agentIds = (pack.agentsConfigured ? pack.agents : FIXED_AGENT_SLOTS.map((agent) => agent.id))
279
- .filter((id) => !isHiddenAgent(id));
294
+ .filter((id) => !isHiddenAgent(id) && !BUILTIN_SLOT_AGENT_IDS.has(id));
280
295
  const agentBlocks = agentIds.map((id) => loadAgentDefinition(dir, id)).filter(Boolean);
281
296
  if (agentBlocks.length) {
282
297
  lines.push('# Available Agents');
283
- for (const agent of agentBlocks) {
284
- lines.push(`## ${agent.name} (${agent.id})`);
285
- if (agent.description) lines.push(agent.description);
286
- lines.push(agent.body);
287
- }
298
+ // Name + description only: the AGENT.md body is the worker's own system
299
+ // prompt and rides in the worker session at spawn time — repeating it in
300
+ // the Lead prompt only bloats context. Lead picks agents by description
301
+ // (Claude Code whenToUse pattern); the workflow body carries the rules.
302
+ lines.push(agentBlocks
303
+ .map((agent) => `- ${agent.name} (${agent.id})${agent.description ? `: ${agent.description}` : ''}`)
304
+ .join('\n'));
288
305
  }
289
306
  return lines.join('\n\n');
290
307
  }
@@ -26573,6 +26573,7 @@ function drainTuiSteeringPersist(leadSessionId) {
26573
26573
  return _serialize(async () => {
26574
26574
  let drained = [];
26575
26575
  let droppedStale = 0;
26576
+ let prunedOrphans = 0;
26576
26577
  try {
26577
26578
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
26578
26579
  const next = normalizePendingStore(raw);
@@ -26586,7 +26587,21 @@ function drainTuiSteeringPersist(leadSessionId) {
26586
26587
  return !stale;
26587
26588
  });
26588
26589
  drained = fresh.map(drainedRowToRestore).filter(Boolean);
26589
- if (drained.length === 0 && droppedStale === 0) return void 0;
26590
+ for (const otherKey of Object.keys(next.sessions)) {
26591
+ if (otherKey === key) continue;
26592
+ const rows = Array.isArray(next.sessions[otherKey]) ? next.sessions[otherKey] : [];
26593
+ const otherTouched = Number(next.sessionTouchedAt?.[otherKey]) || 0;
26594
+ const allStale = rows.every((row) => {
26595
+ const at = Number(row?.at) || otherTouched;
26596
+ return at > 0 && now - at > STALE_STEERING_RESTORE_TTL_MS;
26597
+ });
26598
+ if (rows.length === 0 || allStale) {
26599
+ delete next.sessions[otherKey];
26600
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[otherKey];
26601
+ prunedOrphans += rows.length;
26602
+ }
26603
+ }
26604
+ if (drained.length === 0 && droppedStale === 0 && prunedOrphans === 0) return void 0;
26590
26605
  delete next.sessions[key];
26591
26606
  if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
26592
26607
  next.updatedAt = Date.now();
@@ -26602,6 +26617,13 @@ function drainTuiSteeringPersist(leadSessionId) {
26602
26617
  if (droppedStale > 0) {
26603
26618
  try {
26604
26619
  process.stderr.write(`[tui] dropped ${droppedStale} stale steering row(s) sessionId=${leadSessionId}
26620
+ `);
26621
+ } catch {
26622
+ }
26623
+ }
26624
+ if (prunedOrphans > 0) {
26625
+ try {
26626
+ process.stderr.write(`[tui] pruned ${prunedOrphans} orphaned steering row(s) from stale sessions
26605
26627
  `);
26606
26628
  } catch {
26607
26629
  }
@@ -30212,6 +30234,15 @@ function createEngineApiA(bag) {
30212
30234
  restoreState.requeueEntries = [];
30213
30235
  restoreState.discardExecutionPendingResumeKeys = [];
30214
30236
  }
30237
+ const pendingAfterAbortKick = setTimeout(() => {
30238
+ try {
30239
+ if (flags.disposed) return;
30240
+ if (getState().busy) return;
30241
+ if (pending.length > 0 && typeof drain === "function") void drain();
30242
+ } catch {
30243
+ }
30244
+ }, 150);
30245
+ pendingAfterAbortKick.unref?.();
30215
30246
  const abortEpoch = flags.leadTurnEpoch;
30216
30247
  const recoveryMs = Number(flags.manualAbortRecoveryMs) > 0 ? Number(flags.manualAbortRecoveryMs) : MANUAL_ABORT_RECOVERY_MS;
30217
30248
  const recoveryTimer = setTimeout(() => {
@@ -616,6 +616,23 @@ export function createEngineApiA(bag) {
616
616
  // ── Bounded manual-abort recovery ───────────────────────────────────
617
617
  // runtime.abort() above normally rejects the in-flight runtime.ask() so
618
618
  // the turn's own finally clears busy within a tick. If that unwind is
619
+ // starved the recovery timer below hard-releases busy. Separately from
620
+ // recovery: queued work must never strand behind a cancelled turn
621
+ // (Codex parity — abort preserves pending input for the next turn; CC
622
+ // parity — the command queue survives cancel and fires when idle). The
623
+ // drain loop that owns a normal turn continues on its own; this bounded
624
+ // kick covers unwinds where no drain owner re-checks pending after busy
625
+ // clears. drain() self-guards (busy/draining/commandBusy), so the
626
+ // drain-owned path is unchanged and a duplicate kick is a no-op.
627
+ const pendingAfterAbortKick = setTimeout(() => {
628
+ try {
629
+ if (flags.disposed) return;
630
+ if (getState().busy) return;
631
+ if (pending.length > 0 && typeof drain === 'function') void drain();
632
+ } catch { /* best-effort */ }
633
+ }, 150);
634
+ pendingAfterAbortKick.unref?.();
635
+ // If that unwind is
619
636
  // starved — e.g. a provider abort that never settles after a post-tool
620
637
  // fetch stall — busy would stay true until the far-larger turn watchdog
621
638
  // trips, wedging the TUI with dead input. Arm a short grace timer that
@@ -195,6 +195,7 @@ export function drainTuiSteeringPersist(leadSessionId) {
195
195
  return _serialize(async () => {
196
196
  let drained = [];
197
197
  let droppedStale = 0;
198
+ let prunedOrphans = 0;
198
199
  try {
199
200
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
200
201
  const next = normalizePendingStore(raw);
@@ -210,7 +211,26 @@ export function drainTuiSteeringPersist(leadSessionId) {
210
211
  return !stale;
211
212
  });
212
213
  drained = fresh.map(drainedRowToRestore).filter(Boolean);
213
- if (drained.length === 0 && droppedStale === 0) return undefined;
214
+ // Orphan cleanup: buckets of OTHER sessions whose every row already
215
+ // aged past the restore TTL can never be restored (restore is keyed
216
+ // by the live session id), so they only grow the file forever
217
+ // (observed live: queued rows from sessions closed days ago). Prune
218
+ // them under the same lock/write.
219
+ for (const otherKey of Object.keys(next.sessions)) {
220
+ if (otherKey === key) continue;
221
+ const rows = Array.isArray(next.sessions[otherKey]) ? next.sessions[otherKey] : [];
222
+ const otherTouched = Number(next.sessionTouchedAt?.[otherKey]) || 0;
223
+ const allStale = rows.every((row) => {
224
+ const at = Number(row?.at) || otherTouched;
225
+ return at > 0 && (now - at) > STALE_STEERING_RESTORE_TTL_MS;
226
+ });
227
+ if (rows.length === 0 || allStale) {
228
+ delete next.sessions[otherKey];
229
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[otherKey];
230
+ prunedOrphans += rows.length;
231
+ }
232
+ }
233
+ if (drained.length === 0 && droppedStale === 0 && prunedOrphans === 0) return undefined;
214
234
  delete next.sessions[key];
215
235
  if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
216
236
  next.updatedAt = Date.now();
@@ -222,6 +242,9 @@ export function drainTuiSteeringPersist(leadSessionId) {
222
242
  if (droppedStale > 0) {
223
243
  try { process.stderr.write(`[tui] dropped ${droppedStale} stale steering row(s) sessionId=${leadSessionId}\n`); } catch {}
224
244
  }
245
+ if (prunedOrphans > 0) {
246
+ try { process.stderr.write(`[tui] pruned ${prunedOrphans} orphaned steering row(s) from stale sessions\n`); } catch {}
247
+ }
225
248
  return drained;
226
249
  });
227
250
  }