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.
- package/package.json +5 -1
- package/scripts/tool-overhead-microbench.mjs +60 -0
- package/src/agents/debugger/agent.json +1 -1
- package/src/agents/explore/agent.json +1 -1
- package/src/agents/heavy-worker/agent.json +1 -1
- package/src/agents/maintainer/agent.json +1 -1
- package/src/agents/reviewer/agent.json +1 -1
- package/src/agents/worker/agent.json +1 -1
- package/src/lib/rules-builder.cjs +5 -5
- package/src/output-styles/detailed.md +27 -0
- package/src/output-styles/extreme-minimal.md +8 -8
- package/src/output-styles/minimal.md +7 -10
- package/src/output-styles/simple.md +12 -20
- package/src/rules/lead/01-general.md +9 -1
- package/src/rules/lead/lead-brief.md +15 -14
- package/src/rules/shared/01-tool.md +32 -37
- package/src/runtime/agent/orchestrator/agent-runtime/commit-message-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +10 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +10 -0
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +47 -1
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +28 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +20 -2
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +176 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -11
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +9 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +16 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +176 -16
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +30 -1
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +96 -5
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +72 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +48 -9
- package/src/runtime/channels/backends/discord.mjs +21 -1
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/lib/trace-store.mjs +25 -3
- package/src/runtime/memory/tool-defs.mjs +1 -3
- package/src/runtime/search/tool-defs.mjs +2 -18
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/session-runtime/output-styles.mjs +4 -6
- package/src/session-runtime/tool-defs.mjs +0 -1
- package/src/session-runtime/tool-surface.mjs +9 -0
- package/src/session-runtime/workflow.mjs +25 -8
- package/src/tui/dist/index.mjs +32 -1
- package/src/tui/engine/session-api.mjs +17 -0
- package/src/tui/engine/tui-steering-persist.mjs +24 -1
- package/src/workflows/default/WORKFLOW.md +7 -17
- package/src/workflows/solo/WORKFLOW.md +7 -5
- 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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
477
|
+
if (raw == null || raw === '') return 1
|
|
473
478
|
const n = Number(raw)
|
|
474
|
-
return Number.isFinite(n) && n >= 0 ? Math.floor(n) :
|
|
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)
|
|
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
|
|
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
|
|
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 = ['
|
|
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) || '
|
|
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('
|
|
154
|
+
|| findOutputStyle('simple', styles)
|
|
157
155
|
|| styles[0]
|
|
158
|
-
|| { id: '
|
|
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: '
|
|
20
|
-
{ id: 'heavy-worker', label: 'Heavy Worker', description: '
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
}
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|