mixdog 0.7.11 → 0.7.13
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/.claude-plugin/marketplace.json +5 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +28 -74
- package/README.md +193 -249
- package/bin/statusline-launcher.mjs +5 -1
- package/bin/statusline-lib.mjs +14 -6
- package/bin/statusline.mjs +14 -6
- package/bun.lock +128 -3
- package/defaults/hidden-roles.json +3 -0
- package/defaults/user-workflow.json +1 -2
- package/defaults/user-workflow.md +5 -1
- package/hooks/lib/settings-loader.cjs +4 -3
- package/hooks/pre-tool-subagent.cjs +7 -2
- package/hooks/session-start.cjs +52 -24
- package/lib/mixdog-debug.cjs +163 -0
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +9 -2
- package/scripts/builtin-utils-smoke.mjs +14 -8
- package/scripts/bump.mjs +80 -0
- package/scripts/doctor.mjs +8 -3
- package/scripts/ensure-deps.mjs +2 -2
- package/scripts/mutation-io-smoke.mjs +17 -1
- package/scripts/permission-eval-smoke.mjs +18 -1
- package/scripts/run-mcp.mjs +65 -9
- package/scripts/statusline-launcher-smoke.mjs +2 -2
- package/scripts/webhook-selfheal-smoke.mjs +1 -3
- package/server-main.mjs +57 -3
- package/setup/install.mjs +574 -574
- package/setup/launch-core.mjs +0 -1
- package/setup/setup-server.mjs +90 -35
- package/setup/setup.html +44 -11
- package/skills/setup/SKILL.md +12 -2
- package/src/agent/index.mjs +1 -1
- package/src/agent/orchestrator/config.mjs +58 -6
- package/src/agent/orchestrator/providers/model-catalog.mjs +1 -1
- package/src/agent/orchestrator/providers/openai-oauth.mjs +9 -2
- package/src/agent/orchestrator/providers/openai-ws.mjs +23 -0
- package/src/agent/orchestrator/session/loop.mjs +3 -3
- package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +6 -2
- package/src/agent/orchestrator/tools/bash-session.mjs +1 -0
- package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +29 -6
- package/src/agent/orchestrator/tools/builtin/list-tool.mjs +8 -4
- package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +29 -8
- package/src/agent/orchestrator/tools/builtin.mjs +5 -2
- package/src/agent/orchestrator/tools/cwd-tool.mjs +17 -17
- package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/agent/tool-defs.mjs +1 -1
- package/src/channels/index.mjs +39 -9
- package/src/channels/lib/event-queue.mjs +24 -1
- package/src/channels/lib/hook-pipe-server.mjs +21 -8
- package/src/channels/lib/webhook.mjs +159 -20
- package/src/memory/index.mjs +5 -1
- package/src/memory/lib/core-memory-store.mjs +1 -1
- package/src/memory/lib/memory-cycle1.mjs +8 -4
- package/src/memory/lib/memory-cycle2.mjs +1 -1
- package/src/memory/lib/memory-cycle3.mjs +1 -1
- package/src/memory/lib/memory-recall-store.mjs +27 -10
- package/src/search/lib/backends/openai-oauth.mjs +6 -2
- package/src/search/lib/cache.mjs +55 -7
- package/tools.json +2 -2
- package/scripts/test-config-rmw-restore.mjs +0 -122
package/hooks/session-start.cjs
CHANGED
|
@@ -8,34 +8,51 @@ const net = require('net');
|
|
|
8
8
|
const { spawn } = require('child_process');
|
|
9
9
|
const { resolvePluginData } = require(path.join(__dirname, '..', 'lib', 'plugin-paths.cjs'));
|
|
10
10
|
const { readSection } = require(path.join(__dirname, '..', 'lib', 'config-cjs.cjs'));
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const {
|
|
12
|
+
isMixdogDebugEnabled,
|
|
13
|
+
pruneStalePluginDataLogSiblings,
|
|
14
|
+
appendSessionStartCriticalLog,
|
|
15
|
+
sessionStartCriticalFallback,
|
|
16
|
+
DEFAULT_STALE_LOG_SIBLING_MAX,
|
|
17
|
+
} = require(path.join(__dirname, '..', 'lib', 'mixdog-debug.cjs'));
|
|
18
|
+
|
|
19
|
+
// Verbose tracing → session-start.log (MIXDOG_DEBUG). Ship mode → critical
|
|
20
|
+
// fail-open lines only in bounded session-start-critical.log.
|
|
15
21
|
let _SESSION_START_LOG_PATH = null;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
let _SESSION_START_DATA_DIR = null;
|
|
23
|
+
const MIXDOG_DEBUG_ENABLED = isMixdogDebugEnabled();
|
|
24
|
+
let _sessionStartLogsPruned = false;
|
|
25
|
+
|
|
26
|
+
function sessionStartDataDir() {
|
|
27
|
+
if (_SESSION_START_DATA_DIR) return _SESSION_START_DATA_DIR;
|
|
28
|
+
try {
|
|
29
|
+
if (typeof DATA_DIR === 'string' && DATA_DIR) {
|
|
30
|
+
_SESSION_START_DATA_DIR = DATA_DIR;
|
|
31
|
+
return _SESSION_START_DATA_DIR;
|
|
32
|
+
}
|
|
33
|
+
} catch { /* safe before DATA_DIR const init */ }
|
|
34
|
+
_SESSION_START_DATA_DIR = process.env.CLAUDE_PLUGIN_DATA
|
|
35
|
+
|| path.join(os.homedir(), '.claude', 'plugins', 'data', 'mixdog-trib-plugin');
|
|
36
|
+
return _SESSION_START_DATA_DIR;
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
function sessionStartLogPath() {
|
|
20
40
|
if (_SESSION_START_LOG_PATH) return _SESSION_START_LOG_PATH;
|
|
21
|
-
|
|
22
|
-
? DATA_DIR
|
|
23
|
-
: path.join(os.homedir(), '.claude', 'plugins', 'data', 'mixdog-trib-plugin');
|
|
24
|
-
_SESSION_START_LOG_PATH = path.join(base, 'session-start.log');
|
|
41
|
+
_SESSION_START_LOG_PATH = path.join(sessionStartDataDir(), 'session-start.log');
|
|
25
42
|
return _SESSION_START_LOG_PATH;
|
|
26
43
|
}
|
|
27
|
-
// Always append to session-start.log so fail-open reasons (skip / cycle1
|
|
28
|
-
// failure / missing-dirs / null dispatch) stay diagnosable without requiring
|
|
29
|
-
// MIXDOG_DEBUG_SESSION_START to be pre-set. Stderr output remains gated by
|
|
30
|
-
// the trace flag — log file is the durable record, stderr is the live tail.
|
|
31
|
-
// Size-based rotation: when the log exceeds LOG_MAX_BYTES, head-trim down to
|
|
32
|
-
// LOG_KEEP_BYTES so a long-running daemon doesn't grow it unbounded.
|
|
33
44
|
const SESSION_START_LOG_MAX_BYTES = 256 * 1024;
|
|
34
45
|
const SESSION_START_LOG_KEEP_BYTES = 64 * 1024;
|
|
35
|
-
|
|
46
|
+
|
|
47
|
+
function appendVerboseSessionStartLog(line) {
|
|
36
48
|
try {
|
|
49
|
+
const dir = sessionStartDataDir();
|
|
37
50
|
const p = sessionStartLogPath();
|
|
38
|
-
fs.mkdirSync(
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
if (!_sessionStartLogsPruned) {
|
|
53
|
+
_sessionStartLogsPruned = true;
|
|
54
|
+
pruneStalePluginDataLogSiblings(dir, DEFAULT_STALE_LOG_SIBLING_MAX);
|
|
55
|
+
}
|
|
39
56
|
try {
|
|
40
57
|
const st = fs.statSync(p);
|
|
41
58
|
if (st.size > SESSION_START_LOG_MAX_BYTES) {
|
|
@@ -45,9 +62,19 @@ function teeStderr(line) {
|
|
|
45
62
|
} catch {}
|
|
46
63
|
fs.appendFileSync(p, line);
|
|
47
64
|
} catch {}
|
|
48
|
-
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function teeStderr(line, opts = {}) {
|
|
68
|
+
const critical = opts.critical === true
|
|
69
|
+
|| (opts.critical !== false && sessionStartCriticalFallback(line));
|
|
70
|
+
if (!MIXDOG_DEBUG_ENABLED) {
|
|
71
|
+
if (!critical) return;
|
|
72
|
+
appendSessionStartCriticalLog(sessionStartDataDir(), line);
|
|
49
73
|
try { process.stderr.write(line); } catch {}
|
|
74
|
+
return;
|
|
50
75
|
}
|
|
76
|
+
appendVerboseSessionStartLog(line);
|
|
77
|
+
try { process.stderr.write(line); } catch {}
|
|
51
78
|
}
|
|
52
79
|
|
|
53
80
|
// ---------------------------------------------------------------------------
|
|
@@ -82,6 +109,9 @@ function parseArgs(argv) {
|
|
|
82
109
|
const ARGS = parseArgs(process.argv);
|
|
83
110
|
let PART = ARGS.part;
|
|
84
111
|
|
|
112
|
+
const DATA_DIR = resolvePluginData();
|
|
113
|
+
const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT;
|
|
114
|
+
|
|
85
115
|
let _event = {};
|
|
86
116
|
const IS_DAEMON_REQUIRE = !!process.env.MIXDOG_SKIP_TOP_STDIN;
|
|
87
117
|
// In-daemon `require()` would otherwise read fd 0 (the daemon's MCP stdio
|
|
@@ -115,8 +145,6 @@ if (_event.kind && _event.kind !== 'interactive') {
|
|
|
115
145
|
process.exit(0);
|
|
116
146
|
}
|
|
117
147
|
|
|
118
|
-
const DATA_DIR = resolvePluginData();
|
|
119
|
-
const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT;
|
|
120
148
|
const SESSION_START_CYCLE1_TIMEOUT_MS = Number.parseInt(process.env.MIXDOG_SESSION_START_CYCLE1_TIMEOUT_MS || '110000', 10);
|
|
121
149
|
const MIXDOG_RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
|
|
122
150
|
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
@@ -709,7 +737,7 @@ async function pollActiveInstance(graceMs) {
|
|
|
709
737
|
if (result) {
|
|
710
738
|
teeStderr(`[session-start] pollActiveInstance done elapsed=${Date.now() - _pollStart}ms port=${result.httpPort} via=${via}\n`);
|
|
711
739
|
} else {
|
|
712
|
-
teeStderr(`[session-start] pollActiveInstance end elapsed=${Date.now() - _pollStart}ms result=null\n
|
|
740
|
+
teeStderr(`[session-start] pollActiveInstance end elapsed=${Date.now() - _pollStart}ms result=null\n`, { critical: true });
|
|
713
741
|
}
|
|
714
742
|
resolve(result);
|
|
715
743
|
};
|
|
@@ -785,7 +813,7 @@ async function awaitMemoryPort(graceMs) {
|
|
|
785
813
|
await sleepMs(Math.min(200, Math.max(0, deadline - Date.now())));
|
|
786
814
|
}
|
|
787
815
|
|
|
788
|
-
teeStderr(`[session-start] awaitMemoryPort end elapsed=${Date.now() - _t0}ms result=null\n
|
|
816
|
+
teeStderr(`[session-start] awaitMemoryPort end elapsed=${Date.now() - _t0}ms result=null\n`, { critical: true });
|
|
789
817
|
return null;
|
|
790
818
|
}
|
|
791
819
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/** Truthy env: 1, true, yes, on (case-insensitive). */
|
|
7
|
+
function isTruthyEnv(value) {
|
|
8
|
+
if (value == null || value === '') return false;
|
|
9
|
+
const s = String(value).trim().toLowerCase();
|
|
10
|
+
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ship-mode gate: verbose hook/daemon tracing. Default OFF.
|
|
15
|
+
* MIXDOG_DEBUG_SESSION_START remains a legacy alias.
|
|
16
|
+
*/
|
|
17
|
+
function isMixdogDebugEnabled() {
|
|
18
|
+
return (
|
|
19
|
+
isTruthyEnv(process.env.MIXDOG_DEBUG) ||
|
|
20
|
+
isTruthyEnv(process.env.MIXDOG_DEBUG_SESSION_START)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Canonical / live logs — never subject to sibling GC. */
|
|
25
|
+
const CANONICAL_PLUGIN_LOG_NAMES = new Set([
|
|
26
|
+
'boot.log',
|
|
27
|
+
'crash.log',
|
|
28
|
+
'drop-trace.log',
|
|
29
|
+
'event.log',
|
|
30
|
+
'channels-worker.log',
|
|
31
|
+
'memory-worker.log',
|
|
32
|
+
'mcp-debug.log',
|
|
33
|
+
'pg.log',
|
|
34
|
+
'schedule.log',
|
|
35
|
+
'session-start.log',
|
|
36
|
+
'session-start-critical.log',
|
|
37
|
+
'webhook.log',
|
|
38
|
+
'perf.log',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per-process sibling logs (stale accumulators). Matches channels worker GC.
|
|
43
|
+
* Only these may be count-pruned.
|
|
44
|
+
*/
|
|
45
|
+
const STALE_PLUGIN_LOG_SIBLING_RE = [
|
|
46
|
+
/^(channels|memory)-worker\.\d+\.\d+\.log$/,
|
|
47
|
+
/^mcp-debug\.\d+\.\d+\.log$/,
|
|
48
|
+
/^supervisor\.\d+\.log$/,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const DEFAULT_STALE_LOG_SIBLING_MAX = 50;
|
|
52
|
+
const DEFAULT_STALE_LOG_MIN_AGE_MS = 5 * 60 * 1000;
|
|
53
|
+
|
|
54
|
+
function isStalePluginLogSibling(name) {
|
|
55
|
+
if (!name || !name.endsWith('.log')) return false;
|
|
56
|
+
if (CANONICAL_PLUGIN_LOG_NAMES.has(name)) return false;
|
|
57
|
+
return STALE_PLUGIN_LOG_SIBLING_RE.some((re) => re.test(name));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Drop oldest stale per-worker / per-PID log siblings only. Skips canonical logs
|
|
62
|
+
* and files touched within minAgeMs (likely live writers). Best-effort; no throw.
|
|
63
|
+
*/
|
|
64
|
+
function pruneStalePluginDataLogSiblings(
|
|
65
|
+
dataDir,
|
|
66
|
+
maxSiblings = DEFAULT_STALE_LOG_SIBLING_MAX,
|
|
67
|
+
minAgeMs = DEFAULT_STALE_LOG_MIN_AGE_MS,
|
|
68
|
+
) {
|
|
69
|
+
if (!dataDir || maxSiblings < 1) return { removed: 0, kept: 0 };
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = fs.readdirSync(dataDir, { withFileTypes: true })
|
|
74
|
+
.filter((e) => e.isFile() && isStalePluginLogSibling(e.name));
|
|
75
|
+
} catch {
|
|
76
|
+
return { removed: 0, kept: 0 };
|
|
77
|
+
}
|
|
78
|
+
const candidates = [];
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
const p = path.join(dataDir, e.name);
|
|
81
|
+
try {
|
|
82
|
+
const st = fs.statSync(p);
|
|
83
|
+
if (now - st.mtimeMs < minAgeMs) continue;
|
|
84
|
+
candidates.push({ path: p, mtimeMs: st.mtimeMs });
|
|
85
|
+
} catch { /* skip */ }
|
|
86
|
+
}
|
|
87
|
+
if (candidates.length <= maxSiblings) {
|
|
88
|
+
return { removed: 0, kept: candidates.length };
|
|
89
|
+
}
|
|
90
|
+
candidates.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
91
|
+
const toRemove = candidates.length - maxSiblings;
|
|
92
|
+
let removed = 0;
|
|
93
|
+
for (let i = 0; i < toRemove; i++) {
|
|
94
|
+
try {
|
|
95
|
+
fs.unlinkSync(candidates[i].path);
|
|
96
|
+
removed++;
|
|
97
|
+
} catch { /* skip */ }
|
|
98
|
+
}
|
|
99
|
+
return { removed, kept: candidates.length - removed };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @deprecated use pruneStalePluginDataLogSiblings */
|
|
103
|
+
function prunePluginDataLogFiles(dataDir, maxFiles) {
|
|
104
|
+
return pruneStalePluginDataLogSiblings(dataDir, maxFiles);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const SESSION_START_CRITICAL_LOG = 'session-start-critical.log';
|
|
108
|
+
const SESSION_START_CRITICAL_MAX_BYTES = 64 * 1024;
|
|
109
|
+
const SESSION_START_CRITICAL_KEEP_BYTES = 64 * 1024;
|
|
110
|
+
|
|
111
|
+
function rotateBoundedLog(filePath, maxBytes, keepBytes) {
|
|
112
|
+
try {
|
|
113
|
+
const st = fs.statSync(filePath);
|
|
114
|
+
if (st.size > maxBytes) {
|
|
115
|
+
const buf = fs.readFileSync(filePath);
|
|
116
|
+
fs.writeFileSync(filePath, buf.subarray(Math.max(0, buf.length - keepBytes)));
|
|
117
|
+
}
|
|
118
|
+
} catch { /* missing file ok */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ship-mode durable fail-open record (size-capped). No-op when line empty.
|
|
123
|
+
*/
|
|
124
|
+
function appendSessionStartCriticalLog(dataDir, line) {
|
|
125
|
+
if (!dataDir || !line) return;
|
|
126
|
+
try {
|
|
127
|
+
const p = path.join(dataDir, SESSION_START_CRITICAL_LOG);
|
|
128
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
129
|
+
rotateBoundedLog(p, SESSION_START_CRITICAL_MAX_BYTES, SESSION_START_CRITICAL_KEEP_BYTES);
|
|
130
|
+
fs.appendFileSync(p, line.endsWith('\n') ? line : `${line}\n`);
|
|
131
|
+
} catch { /* best-effort */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fallback when call sites omit `{ critical: true }`.
|
|
136
|
+
*/
|
|
137
|
+
function sessionStartCriticalFallback(line) {
|
|
138
|
+
const s = String(line || '');
|
|
139
|
+
if (/reason=ok\b/.test(s)) return false;
|
|
140
|
+
if (/\[session-start\] skip\b/.test(s) && /reason=/.test(s)) return true;
|
|
141
|
+
if (/result=null/.test(s)) return true;
|
|
142
|
+
if (/owner route unavailable/.test(s)) return true;
|
|
143
|
+
if (/memory_port unavailable/.test(s)) return true;
|
|
144
|
+
if (/\baborted\b/i.test(s)) return true;
|
|
145
|
+
if (/\bcycle1\b/.test(s) && /reason=/.test(s) && !/reason=ok\b/.test(s)) return true;
|
|
146
|
+
if (/\b(exception|failed|abort|err=|non-200|missing-dirs|catch endpoint)\b/i.test(s)) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
isTruthyEnv,
|
|
154
|
+
isMixdogDebugEnabled,
|
|
155
|
+
isStalePluginLogSibling,
|
|
156
|
+
pruneStalePluginDataLogSiblings,
|
|
157
|
+
prunePluginDataLogFiles,
|
|
158
|
+
appendSessionStartCriticalLog,
|
|
159
|
+
sessionStartCriticalFallback,
|
|
160
|
+
DEFAULT_STALE_LOG_SIBLING_MAX,
|
|
161
|
+
DEFAULT_PLUGIN_LOG_MAX_FILES: DEFAULT_STALE_LOG_SIBLING_MAX,
|
|
162
|
+
SESSION_START_CRITICAL_LOG,
|
|
163
|
+
};
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.13",
|
|
4
4
|
"description": "Claude Code all-in-one bridge plugin: role-based bridge workers, continuous memory, and syntax-aware code editing.",
|
|
5
5
|
"author": "mixdog contributors <dev@tribgames.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -67,6 +67,8 @@
|
|
|
67
67
|
"test:all": "bun run test:fault-inject && bun run test:large-file && bun run test:stress",
|
|
68
68
|
"ci:core": "bun run check:syntax && bun run check:json && bun scripts/doctor.mjs && bun scripts/guard-smoke.mjs && node scripts/hidden-role-schema-smoke.mjs && bun scripts/permission-eval-smoke.mjs && bun scripts/perf-hook-smoke.mjs && bun scripts/edit-normalize-smoke.mjs && bun scripts/edit-normalize-fuzz.mjs && bun scripts/builtin-utils-smoke.mjs && bun scripts/mutation-io-smoke.mjs && node scripts/io-complex-smoke.mjs && bun run smoke:io-routes && bun scripts/webhook-selfheal-smoke.mjs && node scripts/config-preserve-smoke.mjs && node scripts/statusline-launcher-smoke.mjs && bun run check:tools-sync",
|
|
69
69
|
"ci": "bun run ci:core && bun audit",
|
|
70
|
+
"lint": "eslint .",
|
|
71
|
+
"lint:fix": "eslint . --fix",
|
|
70
72
|
"prepublishOnly": "bun run build:tools"
|
|
71
73
|
},
|
|
72
74
|
"overrides": {
|
|
@@ -104,5 +106,10 @@
|
|
|
104
106
|
"keytar",
|
|
105
107
|
"onnxruntime-node",
|
|
106
108
|
"protobufjs"
|
|
107
|
-
]
|
|
109
|
+
],
|
|
110
|
+
"devDependencies": {
|
|
111
|
+
"@eslint/js": "^10.0.1",
|
|
112
|
+
"eslint": "^10.5.0",
|
|
113
|
+
"globals": "^17.6.0"
|
|
114
|
+
}
|
|
108
115
|
}
|
|
@@ -11,7 +11,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
11
11
|
|
|
12
12
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
13
|
process.env.CLAUDE_PLUGIN_ROOT ||= ROOT;
|
|
14
|
-
process.env.CLAUDE_PLUGIN_DATA
|
|
14
|
+
process.env.CLAUDE_PLUGIN_DATA = join(tmpdir(), `mixdog-builtin-utils-smoke-data-${process.pid}`);
|
|
15
15
|
process.env.MIXDOG_HINT_LEVEL ||= 'normal';
|
|
16
16
|
|
|
17
17
|
const {
|
|
@@ -86,9 +86,10 @@ if (!unknownToolHint.includes('Did you mean "grep"') || unknownToolHint.includes
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// ── normalizeInputPath / normalizeOutputPath ───────────────────────────────
|
|
89
|
-
// Path normalization round-trips
|
|
90
|
-
// representations of the same path collapse to one form
|
|
91
|
-
// `/c/Users/...` drive paths decode back to Windows drive form.
|
|
89
|
+
// Path normalization round-trips. On Windows, forward-slash and backslash
|
|
90
|
+
// representations of the same path collapse to one form, and POSIX-style
|
|
91
|
+
// `/c/Users/...` drive paths decode back to Windows drive form. On POSIX,
|
|
92
|
+
// those spellings are ordinary strings and must stay byte-preserved.
|
|
92
93
|
// Output normalization is the inverse — the user-facing form mixdog
|
|
93
94
|
// always emits regardless of how it received the path.
|
|
94
95
|
const pathCases = [
|
|
@@ -114,13 +115,18 @@ if (typeof out1 !== 'string') fail('path-out:windows', `expected string, got ${t
|
|
|
114
115
|
const out2 = normalizeOutputPath('src/foo.js');
|
|
115
116
|
if (typeof out2 !== 'string') fail('path-out:relative', `expected string, got ${typeof out2}`);
|
|
116
117
|
|
|
117
|
-
// posixPathToWindowsPath
|
|
118
|
-
//
|
|
118
|
+
// posixPathToWindowsPath is intentionally Windows-only. On POSIX hosts, `/c/...`
|
|
119
|
+
// is a legal absolute path, not a Windows drive alias, so assert identity there.
|
|
119
120
|
const winFromPosix = posixPathToWindowsPath('/c/Users/foo');
|
|
120
121
|
if (typeof winFromPosix !== 'string') {
|
|
121
122
|
fail('posix-to-win:basic', `expected string, got ${typeof winFromPosix}`);
|
|
122
|
-
} else if (
|
|
123
|
-
|
|
123
|
+
} else if (process.platform === 'win32') {
|
|
124
|
+
// Accept either case for the drive letter (POSIX `/c/` doesn't carry case).
|
|
125
|
+
if (!/^[a-zA-Z]:/.test(winFromPosix)) {
|
|
126
|
+
fail('posix-to-win:basic', `expected drive-letter prefix, got ${JSON.stringify(winFromPosix)}`);
|
|
127
|
+
}
|
|
128
|
+
} else if (winFromPosix !== '/c/Users/foo') {
|
|
129
|
+
fail('posix-to-win:basic', `expected POSIX identity, got ${JSON.stringify(winFromPosix)}`);
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
// ── read cache key shape ───────────────────────────────────────────────────
|
package/scripts/bump.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bump.mjs — single source of truth for the mixdog release version.
|
|
3
|
+
//
|
|
4
|
+
// Bumps the THREE version surfaces in lockstep so the npm and marketplace
|
|
5
|
+
// channels can never drift again:
|
|
6
|
+
// 1. package.json version (npm channel)
|
|
7
|
+
// 2. .claude-plugin/plugin.json version (Claude Code plugin / marketplace update trigger)
|
|
8
|
+
// 3. .claude-plugin/marketplace.json plugin source `ref` -> `v<new>` (pins marketplace to the tagged release)
|
|
9
|
+
//
|
|
10
|
+
// Run by the gated release.yml pipeline, or locally:
|
|
11
|
+
// node scripts/bump.mjs <patch|minor|major|X.Y.Z>
|
|
12
|
+
//
|
|
13
|
+
// Invariant-based (no heuristic fallback): any inconsistent on-disk state
|
|
14
|
+
// throws and aborts the release rather than guessing.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const PKG = join(ROOT, 'package.json');
|
|
22
|
+
const PLUGIN = join(ROOT, '.claude-plugin', 'plugin.json');
|
|
23
|
+
const MARKET = join(ROOT, '.claude-plugin', 'marketplace.json');
|
|
24
|
+
|
|
25
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
26
|
+
|
|
27
|
+
function readJson(p) {
|
|
28
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
function writeJson(p, obj) {
|
|
31
|
+
writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function nextVersion(current, arg) {
|
|
35
|
+
const m = SEMVER.exec(current);
|
|
36
|
+
if (!m) throw new Error(`package.json version is not X.Y.Z: "${current}"`);
|
|
37
|
+
const [major, minor, patch] = m.slice(1).map(Number);
|
|
38
|
+
if (arg === 'major') return `${major + 1}.0.0`;
|
|
39
|
+
if (arg === 'minor') return `${major}.${minor + 1}.0`;
|
|
40
|
+
if (arg === 'patch') return `${major}.${minor}.${patch + 1}`;
|
|
41
|
+
if (SEMVER.test(arg)) return arg;
|
|
42
|
+
throw new Error(`bump arg must be patch|minor|major|X.Y.Z, got: "${arg}"`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const arg = process.argv[2];
|
|
46
|
+
if (!arg) throw new Error('usage: node scripts/bump.mjs <patch|minor|major|X.Y.Z>');
|
|
47
|
+
|
|
48
|
+
const pkg = readJson(PKG);
|
|
49
|
+
const plugin = readJson(PLUGIN);
|
|
50
|
+
const market = readJson(MARKET);
|
|
51
|
+
|
|
52
|
+
if (typeof pkg.version !== 'string') throw new Error('package.json has no version string');
|
|
53
|
+
if (typeof plugin.version !== 'string') throw new Error('plugin.json has no version string');
|
|
54
|
+
|
|
55
|
+
const current = pkg.version;
|
|
56
|
+
const next = nextVersion(current, arg);
|
|
57
|
+
const tag = `v${next}`;
|
|
58
|
+
|
|
59
|
+
const entry = (market.plugins || []).find((p) => p.name === plugin.name);
|
|
60
|
+
if (!entry) throw new Error(`marketplace.json has no plugin entry named "${plugin.name}"`);
|
|
61
|
+
if (!entry.source || typeof entry.source !== 'object') {
|
|
62
|
+
throw new Error('marketplace plugin entry is missing a source object');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// All three surfaces move together or not at all.
|
|
66
|
+
pkg.version = next;
|
|
67
|
+
plugin.version = next;
|
|
68
|
+
entry.source.ref = tag;
|
|
69
|
+
|
|
70
|
+
writeJson(PKG, pkg);
|
|
71
|
+
writeJson(PLUGIN, plugin);
|
|
72
|
+
writeJson(MARKET, market);
|
|
73
|
+
|
|
74
|
+
console.log(`bump: ${current} -> ${next} (tag ${tag})`);
|
|
75
|
+
console.log(' updated package.json, .claude-plugin/plugin.json, marketplace source ref');
|
|
76
|
+
|
|
77
|
+
// Expose results to GitHub Actions when running in CI.
|
|
78
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
79
|
+
writeFileSync(process.env.GITHUB_OUTPUT, `version=${next}\ntag=${tag}\n`, { flag: 'a' });
|
|
80
|
+
}
|
package/scripts/doctor.mjs
CHANGED
|
@@ -24,6 +24,11 @@ const require = createRequire(import.meta.url);
|
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || dirname(__dirname);
|
|
26
26
|
|
|
27
|
+
// Claude config base — honours CLAUDE_CONFIG_DIR (matches settings-loader / statusline).
|
|
28
|
+
function claudeConfigBase() {
|
|
29
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
function resolvePluginData() {
|
|
28
33
|
if (process.env.CLAUDE_PLUGIN_DATA) return process.env.CLAUDE_PLUGIN_DATA;
|
|
29
34
|
const dirName = basename(PLUGIN_ROOT);
|
|
@@ -31,7 +36,7 @@ function resolvePluginData() {
|
|
|
31
36
|
if (/^\d+\.\d+\.\d+/.test(dirName)) {
|
|
32
37
|
const pluginName = basename(join(PLUGIN_ROOT, '..'));
|
|
33
38
|
const marketplace = basename(join(PLUGIN_ROOT, '..', '..'));
|
|
34
|
-
return join(
|
|
39
|
+
return join(claudeConfigBase(), 'plugins', 'data', `${pluginName}-${marketplace}`);
|
|
35
40
|
}
|
|
36
41
|
// Marketplace layout: root IS the plugin dir
|
|
37
42
|
const marketplace = dirName;
|
|
@@ -40,7 +45,7 @@ function resolvePluginData() {
|
|
|
40
45
|
const manifest = JSON.parse(readFileSync(join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
41
46
|
if (typeof manifest?.name === 'string' && manifest.name.trim()) pluginName = manifest.name.trim();
|
|
42
47
|
} catch { /* fall through */ }
|
|
43
|
-
return join(
|
|
48
|
+
return join(claudeConfigBase(), 'plugins', 'data', `${pluginName}-${marketplace}`);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
const PLUGIN_DATA = resolvePluginData();
|
|
@@ -271,7 +276,7 @@ await (async function checkHookPipe() {
|
|
|
271
276
|
// Check 6: Cache layout - ~/.claude/plugins/cache/trib-plugin/mixdog/<ver>/
|
|
272
277
|
// ---------------------------------------------------------------------------
|
|
273
278
|
(function checkCacheLayout() {
|
|
274
|
-
const cacheBase = join(
|
|
279
|
+
const cacheBase = join(claudeConfigBase(), 'plugins', 'cache', 'trib-plugin', 'mixdog');
|
|
275
280
|
if (!existsSync(cacheBase)) {
|
|
276
281
|
warn('Cache layout', `cache dir not found: ${cacheBase}`);
|
|
277
282
|
return;
|
package/scripts/ensure-deps.mjs
CHANGED
|
@@ -8,10 +8,10 @@ import * as os from 'os';
|
|
|
8
8
|
import { dirname, join } from 'path';
|
|
9
9
|
import { assertSafeOwnedDir } from '../src/shared/user-data-guard.mjs';
|
|
10
10
|
|
|
11
|
-
const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
|
|
11
|
+
export const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
|
|
12
12
|
const RENAME_BACKOFFS_MS = Object.freeze([25, 50, 100, 200, 400, 800, 1200, 1600]);
|
|
13
13
|
|
|
14
|
-
function sleepSync(ms) {
|
|
14
|
+
export function sleepSync(ms) {
|
|
15
15
|
try {
|
|
16
16
|
const buf = new SharedArrayBuffer(4);
|
|
17
17
|
Atomics.wait(new Int32Array(buf), 0, 0, Math.max(1, Number(ms) || 1));
|
|
@@ -70,7 +70,23 @@ async function warmRead(path, scope, offset = 0, limit = 20) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
async function rmTree(path) {
|
|
73
|
-
|
|
73
|
+
// Windows CI occasionally keeps a just-used temp file locked briefly after the
|
|
74
|
+
// owning JS/native subprocess exits (EACCES/EBUSY/EPERM). Cleanup is not part
|
|
75
|
+
// of the smoke invariant, so retry with backoff and make final teardown
|
|
76
|
+
// best-effort instead of turning a passed mutation suite red.
|
|
77
|
+
const delays = [50, 100, 200, 400, 800];
|
|
78
|
+
let lastErr = null;
|
|
79
|
+
for (let i = 0; i <= delays.length; i++) {
|
|
80
|
+
try {
|
|
81
|
+
await rm(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
82
|
+
return;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
lastErr = err;
|
|
85
|
+
if (!['EACCES', 'EBUSY', 'EPERM', 'ENOTEMPTY'].includes(err?.code)) break;
|
|
86
|
+
if (i < delays.length) await new Promise((resolve) => setTimeout(resolve, delays[i]));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
console.warn(`mutation-io smoke cleanup warning: failed to remove ${path}: ${lastErr?.code || ''} ${lastErr?.message || lastErr}`);
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
try {
|
|
@@ -10,6 +10,23 @@ import path from 'path';
|
|
|
10
10
|
import { createRequire } from 'module';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
12
|
|
|
13
|
+
// The evaluator intentionally merges user-global Claude settings. Local dev
|
|
14
|
+
// machines commonly have defaultMode=bypassPermissions, which made this smoke
|
|
15
|
+
// pass locally while CI's empty user config exercised stricter defaults. Pin
|
|
16
|
+
// the user tier to a throwaway Claude config dir so the smoke is hermetic and
|
|
17
|
+
// still verifies the intended invariant: hard-deny and explicit list rules win
|
|
18
|
+
// before bypass mode.
|
|
19
|
+
const smokeHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'perm-smoke-home-')));
|
|
20
|
+
const smokeConfigDir = path.join(smokeHome, '.claude');
|
|
21
|
+
fs.mkdirSync(smokeConfigDir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(
|
|
23
|
+
path.join(smokeConfigDir, 'settings.json'),
|
|
24
|
+
JSON.stringify({ permissions: { defaultMode: 'bypassPermissions' } }),
|
|
25
|
+
);
|
|
26
|
+
process.env.HOME = smokeHome;
|
|
27
|
+
process.env.USERPROFILE = smokeHome;
|
|
28
|
+
process.env.CLAUDE_CONFIG_DIR = smokeConfigDir;
|
|
29
|
+
|
|
13
30
|
const _require = createRequire(import.meta.url);
|
|
14
31
|
const evalPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../hooks/lib/permission-evaluator.cjs');
|
|
15
32
|
const { evaluatePermission } = _require(evalPath);
|
|
@@ -21,7 +38,7 @@ const { loadPermissions, clearSettingsCache } = _require(loaderPath);
|
|
|
21
38
|
let pass = 0, fail = 0;
|
|
22
39
|
|
|
23
40
|
function makeProject(settings = {}) {
|
|
24
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'perm-smoke-'));
|
|
41
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'perm-smoke-')));
|
|
25
42
|
fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
|
|
26
43
|
if (Object.keys(settings).length) {
|
|
27
44
|
fs.writeFileSync(
|