mixdog 0.9.18 → 0.9.19
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 +3 -2
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/recall-bench-cases.json +11 -0
- package/scripts/recall-bench.mjs +91 -2
- package/scripts/tool-efficiency-diag.mjs +4 -1
- package/scripts/tool-smoke.mjs +101 -27
- package/src/agents/debugger/AGENT.md +3 -3
- package/src/agents/heavy-worker/AGENT.md +7 -10
- package/src/agents/maintainer/AGENT.md +1 -2
- package/src/agents/reviewer/AGENT.md +1 -2
- package/src/agents/worker/AGENT.md +5 -8
- package/src/defaults/agents.json +3 -0
- package/src/mixdog-session-runtime.mjs +23 -6
- package/src/rules/agent/00-core.md +4 -3
- package/src/rules/agent/30-explorer.md +53 -22
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +24 -29
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
- package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
- package/src/runtime/channels/backends/discord.mjs +43 -1
- package/src/runtime/channels/index.mjs +145 -58
- package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
- package/src/runtime/channels/lib/memory-client.mjs +32 -14
- package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
- package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
- package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
- package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
- package/src/runtime/memory/lib/query-handlers.mjs +36 -4
- package/src/runtime/memory/lib/recall-format.mjs +106 -6
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +10 -4
- package/src/runtime/shared/background-tasks.mjs +4 -2
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +30 -1
- package/src/session-runtime/config-lifecycle.mjs +1 -1
- package/src/session-runtime/cwd-plugins.mjs +46 -3
- package/src/session-runtime/mcp-glue.mjs +24 -3
- package/src/session-runtime/output-styles.mjs +44 -10
- package/src/session-runtime/workflow.mjs +16 -1
- package/src/standalone/channel-worker.mjs +74 -5
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/App.jsx +57 -77
- package/src/tui/app/channel-pickers.mjs +45 -0
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/transcript-window.mjs +44 -1
- package/src/tui/app/use-mouse-input.mjs +9 -2
- package/src/tui/app/use-prompt-handlers.mjs +7 -94
- package/src/tui/app/use-transcript-scroll.mjs +5 -1
- package/src/tui/app/use-transcript-window.mjs +65 -5
- package/src/tui/components/PromptInput.jsx +33 -64
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +5330 -5443
- package/src/tui/engine.mjs +49 -10
- package/src/tui/lib/voice-setup.mjs +166 -0
- package/src/tui/paste-attachments.mjs +12 -5
- package/src/tui/prompt-history-store.mjs +125 -12
- package/scripts/bench/cache-probe-tasks.json +0 -8
- package/scripts/bench/lead-review-tasks-r3.json +0 -20
- package/scripts/bench/lead-review-tasks.json +0 -20
- package/scripts/bench/r4-mixed-tasks.json +0 -20
- package/scripts/bench/r5-orchestrated-task.json +0 -7
- package/scripts/bench/review-tasks.json +0 -20
- package/scripts/bench/round-codex.json +0 -114
- package/scripts/bench/round-mixdog-lead-r3.json +0 -269
- package/scripts/bench/round-mixdog-lead.json +0 -269
- package/scripts/bench/round-mixdog.json +0 -126
- package/scripts/bench/round-r10-bigsample.json +0 -679
- package/scripts/bench/round-r11-codexalign.json +0 -257
- package/scripts/bench/round-r13-clientmeta.json +0 -464
- package/scripts/bench/round-r14-betafeatures.json +0 -466
- package/scripts/bench/round-r15-fulldefault.json +0 -462
- package/scripts/bench/round-r16-sessionid.json +0 -466
- package/scripts/bench/round-r17-wirebytes.json +0 -456
- package/scripts/bench/round-r18-prewarm.json +0 -468
- package/scripts/bench/round-r19-clean.json +0 -472
- package/scripts/bench/round-r20-prewarm-clean.json +0 -475
- package/scripts/bench/round-r21-delta-retry.json +0 -473
- package/scripts/bench/round-r22-full-probe.json +0 -693
- package/scripts/bench/round-r23-itemprobe.json +0 -701
- package/scripts/bench/round-r24-shapefix.json +0 -677
- package/scripts/bench/round-r25-serial.json +0 -464
- package/scripts/bench/round-r26-parallel3.json +0 -671
- package/scripts/bench/round-r27-parallel10.json +0 -894
- package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
- package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
- package/scripts/bench/round-r30-instid.json +0 -253
- package/scripts/bench/round-r31-upgradeprobe.json +0 -256
- package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
- package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
- package/scripts/bench/round-r34-orchestrated.json +0 -120
- package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
- package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
- package/scripts/bench/round-r4-codex.json +0 -114
- package/scripts/bench/round-r4-mixed.json +0 -225
- package/scripts/bench/round-r5-gpt-lead.json +0 -259
- package/scripts/bench/round-r6-codex.json +0 -114
- package/scripts/bench/round-r6-solo.json +0 -257
- package/scripts/bench/round-r7-full.json +0 -254
- package/scripts/bench/round-r8-fulldefault.json +0 -255
- package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
- package/src/tui/lib/voice-recorder.mjs +0 -469
|
@@ -74,7 +74,7 @@ export function formatLineDelta(totals) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
export function parseUpdateSummary(text) {
|
|
77
|
-
const match = /^(Updated|Created|Deleted)\s+(.+?)(?:\s+·\s+|$)/i.exec(String(text || '').trim());
|
|
77
|
+
const match = /^(Updated|Created|Deleted|Checked)\s+(.+?)(?:\s+·\s+|$)/i.exec(String(text || '').trim());
|
|
78
78
|
if (!match) return null;
|
|
79
79
|
const action = titleWord(match[1]);
|
|
80
80
|
const target = match[2].trim();
|
|
@@ -703,7 +703,36 @@ export function formatAggregateDetail(summaries) {
|
|
|
703
703
|
}
|
|
704
704
|
|
|
705
705
|
const update = parseUpdateSummary(text);
|
|
706
|
-
|
|
706
|
+
// Dry-run patch checks ("Checked foo.js · +7 -5") are validations, not
|
|
707
|
+
// edits: their line delta must NEVER be summed into the real edit total.
|
|
708
|
+
// They get their own metric so repeated checks still merge; the preview
|
|
709
|
+
// delta is shown only when the card has no real edit delta it could be
|
|
710
|
+
// confused with. Delta-less "Checked ..." texts (task/memory summaries)
|
|
711
|
+
// fall through to extras unchanged.
|
|
712
|
+
if (update && update.action === 'Checked') {
|
|
713
|
+
if (update.seen) {
|
|
714
|
+
const metric = addMetric('checked_files', {
|
|
715
|
+
files: new Set(),
|
|
716
|
+
fileCount: 0,
|
|
717
|
+
added: 0,
|
|
718
|
+
removed: 0,
|
|
719
|
+
seen: false,
|
|
720
|
+
render: (m) => {
|
|
721
|
+
const count = m.fileCount + m.files.size;
|
|
722
|
+
const target = count === 1 && m.fileCount === 0 ? [...m.files][0] : `${count} ${pluralize(count, 'file')}`;
|
|
723
|
+
const editDelta = formatLineDelta(metrics.get('updated_files'));
|
|
724
|
+
const delta = editDelta ? '' : formatLineDelta(m);
|
|
725
|
+
return delta ? `Checked ${target} · ${delta}` : `Checked ${target}`;
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
if (update.file) metric.files.add(update.file);
|
|
729
|
+
metric.fileCount += update.fileCount;
|
|
730
|
+
metric.added += update.added;
|
|
731
|
+
metric.removed += update.removed;
|
|
732
|
+
metric.seen = metric.seen || update.seen;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
} else if (update) {
|
|
707
736
|
const metric = addMetric('updated_files', {
|
|
708
737
|
files: new Set(),
|
|
709
738
|
fileCount: 0,
|
|
@@ -58,7 +58,7 @@ export function createConfigLifecycle({
|
|
|
58
58
|
) {
|
|
59
59
|
return outputStyleStatusCache;
|
|
60
60
|
}
|
|
61
|
-
outputStyleStatusCache = outputStyleStatus(dataDir);
|
|
61
|
+
outputStyleStatusCache = outputStyleStatus(dataDir, { fresh });
|
|
62
62
|
outputStyleStatusCacheAt = now;
|
|
63
63
|
outputStyleStatusCacheDir = cacheDir;
|
|
64
64
|
return outputStyleStatusCache;
|
|
@@ -45,6 +45,48 @@ export function createCwdPlugins({
|
|
|
45
45
|
cfgMod,
|
|
46
46
|
STANDALONE_DATA_DIR,
|
|
47
47
|
}) {
|
|
48
|
+
// Per-plugin-root caches for pluginsStatus(): manifest + MCP discovery keyed
|
|
49
|
+
// by root path + manifest mtime (invalidated on manifest edit); recursive
|
|
50
|
+
// skill-file count keyed by root with a ~5s TTL fallback (the walk has no
|
|
51
|
+
// single mtime to key on).
|
|
52
|
+
const pluginRootCache = new Map();
|
|
53
|
+
const skillCountCache = new Map();
|
|
54
|
+
const mcpDiscoveryCache = new Map();
|
|
55
|
+
function manifestMtimeKey(root) {
|
|
56
|
+
let key = '';
|
|
57
|
+
for (const rel of ['.codex-plugin/plugin.json', 'plugin.json']) {
|
|
58
|
+
try { key += `${statSync(resolve(root, rel)).mtimeMs}:`; } catch { key += '0:'; }
|
|
59
|
+
}
|
|
60
|
+
return key;
|
|
61
|
+
}
|
|
62
|
+
function cachedPluginData(root) {
|
|
63
|
+
const key = manifestMtimeKey(root);
|
|
64
|
+
const hit = pluginRootCache.get(root);
|
|
65
|
+
if (hit && hit.key === key) return hit;
|
|
66
|
+
const entry = { key, manifest: pluginManifest(root) };
|
|
67
|
+
pluginRootCache.set(root, entry);
|
|
68
|
+
return entry;
|
|
69
|
+
}
|
|
70
|
+
// MCP discovery probes candidate script files (.mcp.json, scripts/run-mcp.mjs,
|
|
71
|
+
// ...) whose add/remove is NOT reflected in the manifest mtime, so key it on a
|
|
72
|
+
// short TTL instead (~5s, like the skill-file count).
|
|
73
|
+
function cachedMcpDiscovery(root) {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const hit = mcpDiscoveryCache.get(root);
|
|
76
|
+
if (hit && (now - hit.at) < 5000) return hit.mcp;
|
|
77
|
+
const mcp = discoverPluginMcp(root);
|
|
78
|
+
mcpDiscoveryCache.set(root, { at: now, mcp });
|
|
79
|
+
return mcp;
|
|
80
|
+
}
|
|
81
|
+
function cachedSkillCount(root) {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const hit = skillCountCache.get(root);
|
|
84
|
+
if (hit && (now - hit.at) < 5000) return hit.count;
|
|
85
|
+
const count = countSkillFiles(root);
|
|
86
|
+
skillCountCache.set(root, { at: now, count });
|
|
87
|
+
return count;
|
|
88
|
+
}
|
|
89
|
+
|
|
48
90
|
function resolveCwdPath(value) {
|
|
49
91
|
const raw = clean(value);
|
|
50
92
|
if (!raw) throw new Error('cwd: path is required for action=set');
|
|
@@ -125,7 +167,8 @@ export function createCwdPlugins({
|
|
|
125
167
|
const addRegisteredPlugin = (entry) => {
|
|
126
168
|
const root = clean(entry.root);
|
|
127
169
|
if (!root || !existsSync(root)) return;
|
|
128
|
-
const
|
|
170
|
+
const cached = cachedPluginData(root);
|
|
171
|
+
const manifest = cached.manifest;
|
|
129
172
|
const name = clean(manifest.name) || clean(manifest.id) || clean(entry.name) || root.split(/[\\/]/).pop() || root;
|
|
130
173
|
const plugin = {
|
|
131
174
|
id: clean(entry.id) || name,
|
|
@@ -141,9 +184,9 @@ export function createCwdPlugins({
|
|
|
141
184
|
root,
|
|
142
185
|
installedAt: entry.installedAt || null,
|
|
143
186
|
updatedAt: entry.updatedAt || null,
|
|
144
|
-
skillCount:
|
|
187
|
+
skillCount: cachedSkillCount(root),
|
|
145
188
|
...(() => {
|
|
146
|
-
const { mcpScript, mcpInline } =
|
|
189
|
+
const { mcpScript, mcpInline } = cachedMcpDiscovery(root);
|
|
147
190
|
return { mcpScript, mcpInline };
|
|
148
191
|
})(),
|
|
149
192
|
};
|
|
@@ -4,9 +4,30 @@
|
|
|
4
4
|
// `state` object so the facade's teardown/reconnect paths still observe it.
|
|
5
5
|
// Method behavior is byte-for-byte identical; only grouping changes.
|
|
6
6
|
import { resolve } from 'node:path';
|
|
7
|
+
import { statSync } from 'node:fs';
|
|
7
8
|
import { clean } from './session-text.mjs';
|
|
8
9
|
import { readProjectMcpServers } from './plugin-mcp.mjs';
|
|
9
10
|
|
|
11
|
+
// Cache project-local `.mcp.json` reads by path + mtime so repeated mcpStatus()
|
|
12
|
+
// calls skip existsSync+readFileSync+JSON.parse when the file is unchanged.
|
|
13
|
+
// Invalidated automatically on any mtime change (or create/delete via mtime=0).
|
|
14
|
+
const projectMcpCache = new Map();
|
|
15
|
+
const PROJECT_MCP_CACHE_MAX = 32;
|
|
16
|
+
function cachedProjectMcpServers(cwd) {
|
|
17
|
+
const path = resolve(cwd || '.', '.mcp.json');
|
|
18
|
+
let mtimeMs = 0;
|
|
19
|
+
try { mtimeMs = statSync(path).mtimeMs; } catch { mtimeMs = 0; }
|
|
20
|
+
const hit = projectMcpCache.get(path);
|
|
21
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.value;
|
|
22
|
+
const value = readProjectMcpServers(cwd);
|
|
23
|
+
// Bound the cache: Map preserves insertion order, so drop the oldest entry.
|
|
24
|
+
if (!projectMcpCache.has(path) && projectMcpCache.size >= PROJECT_MCP_CACHE_MAX) {
|
|
25
|
+
projectMcpCache.delete(projectMcpCache.keys().next().value);
|
|
26
|
+
}
|
|
27
|
+
projectMcpCache.set(path, { mtimeMs, value });
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
export function createMcpGlue({
|
|
11
32
|
mcpClient,
|
|
12
33
|
getConfig,
|
|
@@ -23,15 +44,15 @@ export function createMcpGlue({
|
|
|
23
44
|
}
|
|
24
45
|
|
|
25
46
|
// Merge mixdog-config `agent.mcpServers` with project-local `.mcp.json`.
|
|
26
|
-
// On name collision the project-local `.mcp.json` entry WINS
|
|
27
|
-
// precedence: project > user config). `sources[name]` records each server's
|
|
47
|
+
// On name collision the project-local `.mcp.json` entry WINS
|
|
48
|
+
// (precedence: project > user config). `sources[name]` records each server's
|
|
28
49
|
// origin ('config' | 'project') for status reporting.
|
|
29
50
|
function resolveEffectiveMcpServers() {
|
|
30
51
|
const config = getConfig();
|
|
31
52
|
const configured = config?.mcpServers && typeof config.mcpServers === 'object'
|
|
32
53
|
? config.mcpServers
|
|
33
54
|
: {};
|
|
34
|
-
const project =
|
|
55
|
+
const project = cachedProjectMcpServers(getCurrentCwd());
|
|
35
56
|
const servers = { ...configured, ...project };
|
|
36
57
|
const sources = {};
|
|
37
58
|
for (const name of Object.keys(configured)) sources[name] = 'config';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Output-style catalog + metadata parsing. Roots are injected so this module
|
|
2
2
|
// stays free of the runtime's path constants.
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
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
|
|
|
@@ -55,7 +55,7 @@ function parseOutputStyleFrontmatter(markdown) {
|
|
|
55
55
|
return meta;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function
|
|
58
|
+
function computeOutputStyleMetadata(filePath, source) {
|
|
59
59
|
let raw = '';
|
|
60
60
|
try { raw = readFileSync(filePath, 'utf8'); } catch { return null; }
|
|
61
61
|
const meta = parseOutputStyleFrontmatter(raw);
|
|
@@ -76,18 +76,52 @@ function readOutputStyleMetadata(filePath, source) {
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// Per-file metadata cache keyed by path + mtime, and a per-directory listing
|
|
80
|
+
// cache keyed by dir mtime — repeated catalog reads skip readdir/readFile when
|
|
81
|
+
// nothing changed. File content edits (unchanged dir mtime) are still caught by
|
|
82
|
+
// the per-file mtime check.
|
|
83
|
+
const styleFileCache = new Map();
|
|
84
|
+
function readOutputStyleMetadata(filePath, source) {
|
|
85
|
+
let mtimeMs = 0;
|
|
86
|
+
try { mtimeMs = statSync(filePath).mtimeMs; } catch { mtimeMs = 0; }
|
|
87
|
+
const hit = styleFileCache.get(filePath);
|
|
88
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.value;
|
|
89
|
+
const value = computeOutputStyleMetadata(filePath, source);
|
|
90
|
+
styleFileCache.set(filePath, { mtimeMs, value });
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const styleDirCache = new Map();
|
|
95
|
+
function listStyleDirFiles(dir, fresh = false) {
|
|
96
|
+
let mtimeMs = 0;
|
|
97
|
+
try { mtimeMs = statSync(dir).mtimeMs; } catch { return null; }
|
|
98
|
+
if (!fresh) {
|
|
99
|
+
const hit = styleDirCache.get(dir);
|
|
100
|
+
if (hit && hit.mtimeMs === mtimeMs) return hit.files;
|
|
101
|
+
}
|
|
102
|
+
let entries;
|
|
103
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return null; }
|
|
104
|
+
const files = entries
|
|
105
|
+
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
|
|
106
|
+
.map((entry) => entry.name);
|
|
107
|
+
styleDirCache.set(dir, { mtimeMs, files });
|
|
108
|
+
return files;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function listOutputStyleCatalog(rootDir, dataDir, { fresh = false } = {}) {
|
|
80
112
|
const byId = new Map();
|
|
81
113
|
const dirs = [
|
|
82
114
|
{ dir: join(rootDir, 'output-styles'), source: 'builtin' },
|
|
83
115
|
{ dir: join(dataDir, 'output-styles'), source: 'user' },
|
|
84
116
|
];
|
|
85
117
|
for (const { dir, source } of dirs) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
118
|
+
// Forced-fresh (e.g. setOutputStyle) bypasses the dir-listing cache so a
|
|
119
|
+
// just-added/removed .md is always seen; per-file metadata still reuses the
|
|
120
|
+
// path+mtime cache below.
|
|
121
|
+
const files = listStyleDirFiles(dir, fresh);
|
|
122
|
+
if (!files) continue;
|
|
123
|
+
for (const name of files) {
|
|
124
|
+
const style = readOutputStyleMetadata(join(dir, name), source);
|
|
91
125
|
if (style) byId.set(style.id, style);
|
|
92
126
|
}
|
|
93
127
|
}
|
|
@@ -115,8 +149,8 @@ function configuredOutputStyleValue(dataDir) {
|
|
|
115
149
|
return clean(unified.outputStyle || (unified.agent && unified.agent.outputStyle) || 'default') || 'default';
|
|
116
150
|
}
|
|
117
151
|
|
|
118
|
-
export function outputStyleStatus(rootDir, dataDir) {
|
|
119
|
-
const styles = listOutputStyleCatalog(rootDir, dataDir);
|
|
152
|
+
export function outputStyleStatus(rootDir, dataDir, { fresh = false } = {}) {
|
|
153
|
+
const styles = listOutputStyleCatalog(rootDir, dataDir, { fresh });
|
|
120
154
|
const configured = configuredOutputStyleValue(dataDir);
|
|
121
155
|
const current = findOutputStyle(configured, styles)
|
|
122
156
|
|| findOutputStyle('default', styles)
|
|
@@ -201,7 +201,10 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
function workflowContextBlock(config, dir) {
|
|
204
|
-
|
|
204
|
+
return workflowContextBlockFromPack(loadWorkflowPack(dir, activeWorkflowId(config)), dir);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function workflowContextBlockFromPack(pack, dir) {
|
|
205
208
|
if (!pack) return '';
|
|
206
209
|
const lines = [`# Active Workflow: ${pack.name}`];
|
|
207
210
|
if (pack.description) lines.push(pack.description);
|
|
@@ -219,6 +222,17 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
|
|
|
219
222
|
return lines.join('\n\n');
|
|
220
223
|
}
|
|
221
224
|
|
|
225
|
+
// Single-pass variant: loads the active WORKFLOW.md pack once and derives both
|
|
226
|
+
// the summary and the context block from it, so session-create does not re-read
|
|
227
|
+
// and re-parse WORKFLOW.md twice on the hot boot path.
|
|
228
|
+
function activeWorkflowContext(config, dir) {
|
|
229
|
+
const pack = loadWorkflowPack(dir, activeWorkflowId(config));
|
|
230
|
+
return {
|
|
231
|
+
summary: workflowSummary(pack),
|
|
232
|
+
context: workflowContextBlockFromPack(pack, dir),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
222
236
|
return {
|
|
223
237
|
listWorkflowPacks,
|
|
224
238
|
activeWorkflowId,
|
|
@@ -227,6 +241,7 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
|
|
|
227
241
|
activeWorkflowSummary,
|
|
228
242
|
loadAgentDefinition,
|
|
229
243
|
workflowContextBlock,
|
|
244
|
+
activeWorkflowContext,
|
|
230
245
|
};
|
|
231
246
|
}
|
|
232
247
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile, fork, spawnSync } from 'node:child_process';
|
|
2
|
-
import { mkdirSync, rmSync,
|
|
2
|
+
import { mkdirSync, rmSync, writeFile } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -25,6 +25,9 @@ const WORKER_PRELOAD = fileURLToPath(new URL('./channel-worker-preload.cjs', imp
|
|
|
25
25
|
// surfacing the failure to callers.
|
|
26
26
|
const WORKER_BOOT_MAX_ATTEMPTS = 2;
|
|
27
27
|
const WORKER_BOOT_RETRY_DELAY_MS = 1000;
|
|
28
|
+
// Fail an in-flight IPC send whose flush callback never fires (wedged channel)
|
|
29
|
+
// so the FIFO send queue can't stall permanently on one stuck message.
|
|
30
|
+
const SEND_FLUSH_TIMEOUT_MS = 10_000;
|
|
28
31
|
|
|
29
32
|
function logLine(path, line) {
|
|
30
33
|
try {
|
|
@@ -83,21 +86,39 @@ export function createStandaloneChannelWorker({
|
|
|
83
86
|
let parentExitCleanup = null;
|
|
84
87
|
const pending = new Map();
|
|
85
88
|
const ownedChildPids = new Set();
|
|
89
|
+
// Strict-FIFO backpressure queue for child IPC. child.send returns false
|
|
90
|
+
// when the channel is over its backpressure threshold; we keep at most one
|
|
91
|
+
// send in flight and only dispatch the next once the prior send's flush
|
|
92
|
+
// callback fires, so bursts don't unboundedly buffer and order is preserved.
|
|
93
|
+
const sendQueue = [];
|
|
94
|
+
let sendInFlight = false;
|
|
95
|
+
// Guards the single in-flight send: the token invalidates a stale settler
|
|
96
|
+
// (flush callback / watchdog / rejectPending) so only the first one to fire
|
|
97
|
+
// releases the slot; the watchdog handle lets rejectPending cancel it.
|
|
98
|
+
let inFlightToken = 0;
|
|
99
|
+
let inFlightWatchdog = null;
|
|
86
100
|
const logPath = join(dataDir, 'channels-worker-standalone.log');
|
|
87
101
|
const useProcessWorker = process.env.MIXDOG_CHANNEL_WORKER_PROCESS !== '0';
|
|
88
102
|
const clientDir = join(runtimeRoot(), 'channel-clients');
|
|
89
103
|
const clientPath = join(clientDir, `${process.pid}.json`);
|
|
90
104
|
let clientHeartbeatTimer = null;
|
|
91
105
|
let clientHeartbeatExitCleanup = null;
|
|
106
|
+
let clientDirReady = false;
|
|
92
107
|
|
|
93
108
|
function writeClientHeartbeat() {
|
|
109
|
+
// Must write every tick: liveness readers key off the file's rolling
|
|
110
|
+
// mtime/updatedAt, so an unchanged-skip would make the client look dead.
|
|
111
|
+
// mkdir once and write async to keep the 5s interval off the sync-fs path.
|
|
94
112
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
113
|
+
if (!clientDirReady) {
|
|
114
|
+
mkdirSync(clientDir, { recursive: true });
|
|
115
|
+
clientDirReady = true;
|
|
116
|
+
}
|
|
117
|
+
writeFile(clientPath, JSON.stringify({
|
|
97
118
|
pid: process.pid,
|
|
98
119
|
cwd,
|
|
99
120
|
updatedAt: Date.now(),
|
|
100
|
-
}));
|
|
121
|
+
}), () => {});
|
|
101
122
|
} catch {}
|
|
102
123
|
}
|
|
103
124
|
|
|
@@ -147,6 +168,54 @@ export function createStandaloneChannelWorker({
|
|
|
147
168
|
item.reject(error);
|
|
148
169
|
}
|
|
149
170
|
pending.clear();
|
|
171
|
+
// Fail any sends still queued/in-flight for the now-dead channel so their
|
|
172
|
+
// callers unblock instead of waiting on a flush callback that never fires.
|
|
173
|
+
// Invalidate + disarm the in-flight watchdog so it can't later flip
|
|
174
|
+
// sendInFlight under a freshly-started send (its owning call was already
|
|
175
|
+
// rejected via the pending map above).
|
|
176
|
+
inFlightToken++;
|
|
177
|
+
if (inFlightWatchdog) { clearTimeout(inFlightWatchdog); inFlightWatchdog = null; }
|
|
178
|
+
sendInFlight = false;
|
|
179
|
+
while (sendQueue.length) {
|
|
180
|
+
const item = sendQueue.shift();
|
|
181
|
+
try { item.cb?.(error); } catch {}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Dispatch queued IPC sends one at a time, gating the next on the current
|
|
186
|
+
// send's flush callback so child.send backpressure (false return) is honored.
|
|
187
|
+
function drainSendQueue() {
|
|
188
|
+
if (sendInFlight) return;
|
|
189
|
+
const item = sendQueue.shift();
|
|
190
|
+
if (!item) return;
|
|
191
|
+
if (!child || !child.send) {
|
|
192
|
+
try { item.cb?.(new Error('channels worker is not running')); } catch {}
|
|
193
|
+
drainSendQueue();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
sendInFlight = true;
|
|
197
|
+
const token = ++inFlightToken;
|
|
198
|
+
// Settle exactly once, whichever of {flush callback, watchdog} fires first;
|
|
199
|
+
// a stale token (superseded by rejectPending or the sibling) is ignored.
|
|
200
|
+
const finish = (error) => {
|
|
201
|
+
if (token !== inFlightToken) return;
|
|
202
|
+
inFlightToken++;
|
|
203
|
+
if (inFlightWatchdog) { clearTimeout(inFlightWatchdog); inFlightWatchdog = null; }
|
|
204
|
+
sendInFlight = false;
|
|
205
|
+
try { item.cb?.(error); } catch {}
|
|
206
|
+
drainSendQueue();
|
|
207
|
+
};
|
|
208
|
+
// Watchdog: if the flush callback never fires (wedged IPC channel), fail
|
|
209
|
+
// this request via its normal rejection path and keep draining so a single
|
|
210
|
+
// stuck send can't wedge the queue forever.
|
|
211
|
+
inFlightWatchdog = setTimeout(() => finish(new Error('channels worker send timed out')), SEND_FLUSH_TIMEOUT_MS);
|
|
212
|
+
inFlightWatchdog.unref?.();
|
|
213
|
+
child.send(item.msg, (error) => finish(error));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sendToChild(msg, cb) {
|
|
217
|
+
sendQueue.push({ msg, cb });
|
|
218
|
+
drainSendQueue();
|
|
150
219
|
}
|
|
151
220
|
|
|
152
221
|
function start() {
|
|
@@ -298,7 +367,7 @@ export function createStandaloneChannelWorker({
|
|
|
298
367
|
reject(new Error(`channels tool timed out: ${name}`));
|
|
299
368
|
}, timeoutMs);
|
|
300
369
|
pending.set(callId, { resolve, reject, timer });
|
|
301
|
-
|
|
370
|
+
sendToChild({ type: 'call', callId, name, args: args || {} }, (error) => {
|
|
302
371
|
if (!error) return;
|
|
303
372
|
pending.delete(callId);
|
|
304
373
|
clearTimeout(timer);
|
|
@@ -70,7 +70,7 @@ function escapeXml(str) {
|
|
|
70
70
|
// reminder. The full no-verdict contract lives at system level
|
|
71
71
|
// (rules/agent/30-explorer.md).
|
|
72
72
|
export function buildExplorerPrompt(query) {
|
|
73
|
-
return `<query>${escapeXml(query)}</query>\nReminder: RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> answer NOW with those exact coordinates; zero -> one more batch if budget remains. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — never trace the chain, one anchor per concept. You locate WHERE, never WHY. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens
|
|
73
|
+
return `<query>${escapeXml(query)}</query>\nReminder: RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> answer NOW with those exact coordinates; zero -> one more batch if budget remains. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — never trace the chain, one anchor per concept. You locate WHERE, never WHY. Scope is ALWAYS the session working directory: omit path or pass only a path seen in an earlier result; inventing a directory (/workspace/..., another repo's layout) is a defect — on zero hits change TOKENS, never guess paths. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. Before emitting EXPLORATION_FAILED re-scan ALL earlier results: any line matching a specific query token -> answer with that anchor (? if weak) — a weak anchor beats a false miss; EXPLORATION_FAILED only when all 3 turns found zero token-matching lines. Output only anchor lines formatted as path:line — symbol/name — short reason, max 3 lines, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
export function normalizeExploreQueries(rawQuery) {
|