mixdog 0.7.5 → 0.7.7
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/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +18 -0
- package/hooks/hooks.json +6 -6
- package/hooks/session-start.cjs +73 -2
- package/hooks/shim-launcher.cjs +51 -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 +2 -2
- package/scripts/bootstrap.mjs +5 -59
- package/scripts/ensure-deps.mjs +259 -0
- package/scripts/resolve-bun.mjs +60 -0
- package/scripts/run-mcp.mjs +13 -168
- package/setup/install.mjs +220 -22
- package/setup/launch.mjs +0 -0
- package/setup/locate-claude.mjs +38 -0
- package/setup/mixdog-cli.mjs +95 -0
- package/setup/setup-server.mjs +50 -2
- package/setup/setup.html +26 -12
- package/setup/tui.mjs +606 -0
- package/setup/wizard.mjs +220 -151
- package/src/agent/bridge-stall-watchdog.mjs +2 -2
- package/src/agent/index.mjs +3 -3
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +139 -0
- package/src/agent/orchestrator/providers/openai-oauth.mjs +96 -0
- package/src/agent/orchestrator/session/manager.mjs +5 -3
- package/src/agent/orchestrator/session/store.mjs +9 -1
- package/src/channels/lib/runtime-paths.mjs +112 -74
- package/src/memory/index.mjs +30 -7
- package/src/memory/lib/pg/supervisor.mjs +12 -12
- package/src/shared/atomic-file.mjs +16 -0
- package/src/status/aggregator.mjs +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes are tracked per release, starting at 0.7.1.
|
|
4
4
|
|
|
5
|
+
## 0.7.7 — 2026-06-18
|
|
6
|
+
|
|
7
|
+
- Add in-app OAuth sign-in for the Codex (`openai-oauth`) and Claude
|
|
8
|
+
(`anthropic-oauth`) providers, mirroring the existing Grok login: the
|
|
9
|
+
Setup UI can sign in directly when no CLI credentials are detected.
|
|
10
|
+
- `npx mixdog` (no args) now runs the installer directly; `mixdog install`
|
|
11
|
+
and `mixdog install --dry-run` (a no-side-effect install preview) are the
|
|
12
|
+
install/test entry points. Any other args launch Claude Code.
|
|
13
|
+
- Add a Claude Code CLI preflight to setup: if `claude` is missing, offer to
|
|
14
|
+
run the official native installer (consent-prompted; never in CI).
|
|
15
|
+
- Polished terminal setup wizard: arrow-key menus, checkboxes, progress bars,
|
|
16
|
+
and spinners (zero-dependency, grapheme/CJK-aware) replace the plain prompts.
|
|
17
|
+
- Bridge workers stay visible in the statusline correctly: completed workers
|
|
18
|
+
persist as idle for 1h (was swept at 5 min), and worker scoping survives a
|
|
19
|
+
daemon restart (clientHostPid instead of the volatile owner-session id).
|
|
20
|
+
- Fix `bridge list includeClosed:true` so closed-session tombstones are
|
|
21
|
+
actually returned.
|
|
22
|
+
|
|
5
23
|
## 0.7.1 — 2026-06-12
|
|
6
24
|
|
|
7
25
|
Initial versioned release.
|
package/README.md
CHANGED
|
@@ -48,6 +48,24 @@ as JSON you can diff.
|
|
|
48
48
|
|
|
49
49
|
## Install
|
|
50
50
|
|
|
51
|
+
**From npm (terminal):**
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx mixdog # register plugin + run the setup wizard
|
|
55
|
+
mixdog install # same install + wizard (explicit subcommand)
|
|
56
|
+
mixdog install --dry-run # preview install steps (no writes, prompts, or installs)
|
|
57
|
+
npm i -g mixdog # then launch Claude Code with mixdog pre-loaded:
|
|
58
|
+
mixdog # no args → same setup wizard as `npx mixdog`
|
|
59
|
+
mixdog --version # other args (e.g. `setup`) → claude --dangerously-load-development-channels plugin:mixdog@trib-plugin …
|
|
60
|
+
mixdog --dangerously-skip-permissions # extra Claude flags pass through
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
With no arguments, or with `install` (optional `--dry-run`), `mixdog` runs the
|
|
64
|
+
install flow. Any other arguments start Claude Code with mixdog pre-loaded; the
|
|
65
|
+
`claude` CLI must be on your PATH.
|
|
66
|
+
|
|
67
|
+
**Inside Claude Code (slash commands):**
|
|
68
|
+
|
|
51
69
|
```
|
|
52
70
|
/plugin marketplace add trib-plugin/mixdog
|
|
53
71
|
/plugin install mixdog@trib-plugin
|
package/hooks/hooks.json
CHANGED
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/
|
|
10
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\" --part=rules",
|
|
11
11
|
"timeout": 30
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"type": "command",
|
|
15
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/
|
|
15
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\" --part=core",
|
|
16
16
|
"timeout": 130
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"type": "command",
|
|
20
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/
|
|
20
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\" --part=recap",
|
|
21
21
|
"timeout": 130
|
|
22
22
|
}
|
|
23
23
|
]
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"hooks": [
|
|
30
30
|
{
|
|
31
31
|
"type": "command",
|
|
32
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/
|
|
32
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\"",
|
|
33
33
|
"timeout": 10
|
|
34
34
|
}
|
|
35
35
|
]
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"hooks": [
|
|
42
42
|
{
|
|
43
43
|
"type": "command",
|
|
44
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/
|
|
44
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shim-launcher.cjs\" --kind=post-tool",
|
|
45
45
|
"timeout": 5
|
|
46
46
|
}
|
|
47
47
|
]
|
|
@@ -70,4 +70,4 @@
|
|
|
70
70
|
}
|
|
71
71
|
]
|
|
72
72
|
}
|
|
73
|
-
}
|
|
73
|
+
}
|
package/hooks/session-start.cjs
CHANGED
|
@@ -508,6 +508,77 @@ function resolveTranscriptPath() {
|
|
|
508
508
|
return '';
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
// Prior-session transcript for the ingest-watermark barrier on `/clear`.
|
|
512
|
+
// SessionStart carries the NEW session_id; rules-part rebind records the
|
|
513
|
+
// outgoing path in active-instance.json `priorTranscriptPath` before updating
|
|
514
|
+
// `transcriptPath`. Resolution is deterministic (no mtime scan).
|
|
515
|
+
function resolvePriorTranscriptPath() {
|
|
516
|
+
const sessionId = String(_event.session_id || _event.sessionId || '').trim();
|
|
517
|
+
const active = readJson(ACTIVE_INSTANCE_FILE);
|
|
518
|
+
const tp = active && active.transcriptPath;
|
|
519
|
+
if (typeof tp === 'string' && tp && fs.existsSync(tp)) {
|
|
520
|
+
const base = path.basename(tp, '.jsonl');
|
|
521
|
+
if (sessionId && base !== sessionId) return tp;
|
|
522
|
+
}
|
|
523
|
+
const prior = active && active.priorTranscriptPath;
|
|
524
|
+
if (typeof prior === 'string' && prior && fs.existsSync(prior)) {
|
|
525
|
+
const base = path.basename(prior, '.jsonl');
|
|
526
|
+
if (sessionId && base !== sessionId) return prior;
|
|
527
|
+
}
|
|
528
|
+
return '';
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Poll memory-service until offsetBytes >= fileSize for the prior transcript,
|
|
532
|
+
// bounded by a dedicated sub-budget so cycle1 keeps a floor of the grace window.
|
|
533
|
+
async function awaitPriorTranscriptIngested(deadline, opts = {}) {
|
|
534
|
+
const slot = opts.slot || 'unknown';
|
|
535
|
+
const start = Date.now();
|
|
536
|
+
const windowMs = Math.max(0, deadline - start);
|
|
537
|
+
// ~4s cap on the 8s graceMs case; smaller windows get half — cycle1 retains the rest.
|
|
538
|
+
const BARRIER_BUDGET_MS = Math.min(4000, windowMs / 2);
|
|
539
|
+
const barrierDeadline = Math.min(deadline, start + BARRIER_BUDGET_MS);
|
|
540
|
+
const priorPath = resolvePriorTranscriptPath();
|
|
541
|
+
if (!priorPath) {
|
|
542
|
+
teeStderr(`[session-start] ingest-barrier slot=${slot} skip reason=no-prior-transcript source=${_event.source || ''}\n`);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
teeStderr(`[session-start] ingest-barrier slot=${slot} priorPath=${priorPath}\n`);
|
|
546
|
+
const pollMs = 200;
|
|
547
|
+
while (Date.now() < barrierDeadline) {
|
|
548
|
+
const remaining = barrierDeadline - Date.now();
|
|
549
|
+
if (remaining <= 0) break;
|
|
550
|
+
const port = await getLiveMemoryServicePort(Math.min(200, remaining));
|
|
551
|
+
if (!port) {
|
|
552
|
+
await sleepMs(Math.min(pollMs, remaining));
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const res = await httpPostJson({
|
|
557
|
+
hostname: '127.0.0.1',
|
|
558
|
+
port,
|
|
559
|
+
path: '/transcript/ingest-sync',
|
|
560
|
+
timeoutMs: Math.min(5000, remaining),
|
|
561
|
+
body: { path: priorPath, cwd: _event.cwd || process.cwd() },
|
|
562
|
+
});
|
|
563
|
+
if (res.statusCode === 200) {
|
|
564
|
+
let parsed;
|
|
565
|
+
try { parsed = JSON.parse(res.body); } catch { parsed = null; }
|
|
566
|
+
if (parsed && parsed.ok === true && parsed.complete === true) {
|
|
567
|
+
teeStderr(`[session-start] ingest-barrier slot=${slot} complete offsetBytes=${parsed.offsetBytes} fileSize=${parsed.fileSize} elapsed=${Date.now() - start}ms\n`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (parsed && parsed.ok === true) {
|
|
571
|
+
teeStderr(`[session-start] ingest-barrier slot=${slot} pending offsetBytes=${parsed.offsetBytes} fileSize=${parsed.fileSize}\n`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
} catch (e) {
|
|
575
|
+
teeStderr(`[session-start] ingest-barrier slot=${slot} err=${(e && e.message) || e}\n`);
|
|
576
|
+
}
|
|
577
|
+
await sleepMs(Math.min(pollMs, Math.max(0, barrierDeadline - Date.now())));
|
|
578
|
+
}
|
|
579
|
+
teeStderr(`[session-start] ingest-barrier slot=${slot} deadline-reached proceed elapsed=${Date.now() - start}ms\n`);
|
|
580
|
+
}
|
|
581
|
+
|
|
511
582
|
function rebindActiveInstance() {
|
|
512
583
|
try {
|
|
513
584
|
const activePath = ACTIVE_INSTANCE_FILE;
|
|
@@ -1071,6 +1142,7 @@ async function requestCycle1(timeoutMs, opts = {}) {
|
|
|
1071
1142
|
const transientDeadline = start + TRANSIENT_RETRY_BUDGET_MS;
|
|
1072
1143
|
|
|
1073
1144
|
try {
|
|
1145
|
+
await awaitPriorTranscriptIngested(deadline, opts);
|
|
1074
1146
|
let r1 = await requestCycle1Once(deadline, opts);
|
|
1075
1147
|
let transientAttempt = 0;
|
|
1076
1148
|
while (
|
|
@@ -1085,8 +1157,7 @@ async function requestCycle1(timeoutMs, opts = {}) {
|
|
|
1085
1157
|
}
|
|
1086
1158
|
if (!r1.ok) return r1;
|
|
1087
1159
|
if (r1.processed != null && r1.processed > 0) return r1;
|
|
1088
|
-
//
|
|
1089
|
-
// would do nothing useful, skip the second pass.
|
|
1160
|
+
// After ingest-barrier, pendingRows===0 with no in-flight dedup is terminal.
|
|
1090
1161
|
if (r1.pendingRows === 0 && r1.skippedInFlight === false) return r1;
|
|
1091
1162
|
const RETRY_DELAY_MS = 800;
|
|
1092
1163
|
const remaining = deadline - Date.now();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const SHIM_ARCH_ALIAS = Object.freeze({
|
|
8
|
+
x64: 'x86_64',
|
|
9
|
+
arm64: 'aarch64',
|
|
10
|
+
ia32: 'i686',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function shimBinExt() {
|
|
14
|
+
return process.platform === 'win32' ? '.exe' : '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function shimArchTag() {
|
|
18
|
+
const arch = SHIM_ARCH_ALIAS[process.arch] || process.arch;
|
|
19
|
+
const os = process.platform === 'win32' ? 'windows'
|
|
20
|
+
: process.platform === 'darwin' ? 'macos'
|
|
21
|
+
: 'linux';
|
|
22
|
+
return `${os}-${arch}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveShim(pluginRoot) {
|
|
26
|
+
const ext = shimBinExt();
|
|
27
|
+
const name = 'mixdog-shim' + ext;
|
|
28
|
+
const candidates = [
|
|
29
|
+
path.join(pluginRoot, 'native', 'mixdog-shim', 'target', 'release', name),
|
|
30
|
+
path.join(pluginRoot, 'native', 'prebuilt', shimArchTag(), name),
|
|
31
|
+
];
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
try {
|
|
34
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
35
|
+
} catch { /* ignore */ }
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
41
|
+
if (!pluginRoot) process.exit(0);
|
|
42
|
+
|
|
43
|
+
const shimPath = resolveShim(pluginRoot);
|
|
44
|
+
if (!shimPath) process.exit(0);
|
|
45
|
+
|
|
46
|
+
const child = spawnSync(shimPath, process.argv.slice(2), {
|
|
47
|
+
stdio: 'inherit',
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
process.exit(child.status ?? 0);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
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",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"bin": {
|
|
10
10
|
"mcfg": "./setup/launch.mjs",
|
|
11
11
|
"mixdog-install": "./setup/install.mjs",
|
|
12
|
-
"mixdog": "./setup/
|
|
12
|
+
"mixdog": "./setup/mixdog-cli.mjs"
|
|
13
13
|
},
|
|
14
14
|
"engines": {
|
|
15
15
|
"bun": ">=1.1.0"
|
package/scripts/bootstrap.mjs
CHANGED
|
@@ -8,80 +8,26 @@
|
|
|
8
8
|
* Deps: node built-ins only (node:child_process, node:fs, node:path, node:url, node:os).
|
|
9
9
|
*/
|
|
10
10
|
import { spawnSync, spawn } from 'node:child_process';
|
|
11
|
-
import {
|
|
11
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { join, dirname } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { prepShim } from './prep-shim.mjs';
|
|
16
16
|
import { prepPatch } from './prep-patch.mjs';
|
|
17
|
+
import { resolveBun, installBunViaNpm } from './resolve-bun.mjs';
|
|
17
18
|
|
|
18
19
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const pluginRoot = dirname(scriptDir);
|
|
20
21
|
const isWin = process.platform === 'win32';
|
|
21
22
|
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// Locate bun — returns absolute path or null.
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
function findSystemBun() {
|
|
26
|
-
const cmd = isWin ? 'where.exe' : 'which';
|
|
27
|
-
const r = spawnSync(cmd, ['bun'], { encoding: 'utf8', windowsHide: true });
|
|
28
|
-
if (r.status !== 0 || !r.stdout) return null;
|
|
29
|
-
const lines = r.stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
30
|
-
// On Windows `where bun` can list a .cmd/.ps1 shim before the real .exe;
|
|
31
|
-
// the downstream spawn is no-shell, so prefer an actual .exe when present.
|
|
32
|
-
const pick = isWin ? (lines.find(l => l.toLowerCase().endsWith('.exe')) ?? lines[0]) : lines[0];
|
|
33
|
-
if (!pick) return null;
|
|
34
|
-
return existsSync(pick) ? pick : null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function findLocalBun() {
|
|
38
|
-
// After `npm install bun`, the .bin entry on Windows is a .cmd/.sh shim
|
|
39
|
-
// (not bun.exe), while the real binary lands in node_modules/bun/bin/. Check
|
|
40
|
-
// the package's own bin dir too — otherwise a clean-machine npm fallback can
|
|
41
|
-
// succeed yet resolveBun() still returns null and we exit "still not found".
|
|
42
|
-
const candidates = [
|
|
43
|
-
join(pluginRoot, 'node_modules', '.bin', isWin ? 'bun.exe' : 'bun'),
|
|
44
|
-
join(pluginRoot, 'node_modules', 'bun', 'bin', isWin ? 'bun.exe' : 'bun'),
|
|
45
|
-
];
|
|
46
|
-
for (const p of candidates) if (existsSync(p)) return p;
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function resolveBun() {
|
|
51
|
-
return findSystemBun() ?? findLocalBun();
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
// npm-install bun locally if nothing found.
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
function installBunViaNpm() {
|
|
58
|
-
process.stderr.write(
|
|
59
|
-
'[bootstrap] bun not found on PATH — installing via npm (npm install --no-save --silent bun)...\n'
|
|
60
|
-
);
|
|
61
|
-
const r = spawnSync('npm', ['install', '--no-save', '--silent', 'bun'], {
|
|
62
|
-
cwd: pluginRoot,
|
|
63
|
-
stdio: 'inherit',
|
|
64
|
-
shell: false,
|
|
65
|
-
windowsHide: true,
|
|
66
|
-
});
|
|
67
|
-
if (r.status !== 0 || r.error) {
|
|
68
|
-
const hint = r.error ? ` (${r.error.message})` : '';
|
|
69
|
-
process.stderr.write(
|
|
70
|
-
`[bootstrap] npm install failed${hint}.\n` +
|
|
71
|
-
'[bootstrap] Please install bun manually: https://bun.sh\n'
|
|
72
|
-
);
|
|
73
|
-
process.exit(1);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
23
|
// ---------------------------------------------------------------------------
|
|
78
24
|
// Main
|
|
79
25
|
// ---------------------------------------------------------------------------
|
|
80
|
-
let bunPath = resolveBun();
|
|
26
|
+
let bunPath = resolveBun(pluginRoot);
|
|
81
27
|
|
|
82
28
|
if (!bunPath) {
|
|
83
|
-
installBunViaNpm();
|
|
84
|
-
bunPath = resolveBun();
|
|
29
|
+
installBunViaNpm(pluginRoot);
|
|
30
|
+
bunPath = resolveBun(pluginRoot);
|
|
85
31
|
if (!bunPath) {
|
|
86
32
|
process.stderr.write(
|
|
87
33
|
'[bootstrap] bun still not found after npm install.\n' +
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ensure-deps.mjs — install or seed runtime deps into <dataDir>/.deps and stamp.
|
|
3
|
+
*/
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import { dirname, join } from 'path';
|
|
9
|
+
import { assertSafeOwnedDir } from '../src/shared/user-data-guard.mjs';
|
|
10
|
+
|
|
11
|
+
const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
|
|
12
|
+
const RENAME_BACKOFFS_MS = Object.freeze([25, 50, 100, 200, 400, 800, 1200, 1600]);
|
|
13
|
+
|
|
14
|
+
function sleepSync(ms) {
|
|
15
|
+
try {
|
|
16
|
+
const buf = new SharedArrayBuffer(4);
|
|
17
|
+
Atomics.wait(new Int32Array(buf), 0, 0, Math.max(1, Number(ms) || 1));
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renameWithRetrySync(src, dst) {
|
|
22
|
+
let lastErr = null;
|
|
23
|
+
for (let attempt = 0; attempt <= RENAME_BACKOFFS_MS.length; attempt++) {
|
|
24
|
+
try {
|
|
25
|
+
fs.renameSync(src, dst);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
lastErr = err;
|
|
29
|
+
if (!RENAME_RETRY_CODES.has(err?.code) || attempt >= RENAME_BACKOFFS_MS.length) break;
|
|
30
|
+
sleepSync(RENAME_BACKOFFS_MS[attempt] + Math.floor(Math.random() * 50));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
throw lastErr;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const requiredDepNames = [
|
|
37
|
+
['@modelcontextprotocol', 'sdk', 'package.json'],
|
|
38
|
+
['zod', 'package.json'],
|
|
39
|
+
['zod-to-json-schema', 'package.json'],
|
|
40
|
+
['openai', 'package.json'],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function hasRequiredDeps(nmDir) {
|
|
44
|
+
return requiredDepNames.every((parts) => fs.existsSync(join(nmDir, ...parts)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Find hoisted node_modules (npx) that satisfies hasRequiredDeps. */
|
|
48
|
+
export function resolveNmWithRequiredDeps(pluginRoot) {
|
|
49
|
+
let dir = pluginRoot;
|
|
50
|
+
for (let depth = 0; depth < 16; depth++) {
|
|
51
|
+
const nm = join(dir, 'node_modules');
|
|
52
|
+
if (hasRequiredDeps(nm)) return nm;
|
|
53
|
+
const parent = dirname(dir);
|
|
54
|
+
if (parent === dir) break;
|
|
55
|
+
dir = parent;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const LOCK_POLL_MS = 250;
|
|
61
|
+
const LOCK_MAX_MS = 15 * 60 * 1000;
|
|
62
|
+
const LOCK_XHOST_MS = 10 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
function acquireLock(lockFile) {
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
while (Date.now() - start < LOCK_MAX_MS) {
|
|
67
|
+
try {
|
|
68
|
+
const body = JSON.stringify({
|
|
69
|
+
pid: process.pid,
|
|
70
|
+
hostname: os.hostname(),
|
|
71
|
+
startedAt: Date.now(),
|
|
72
|
+
});
|
|
73
|
+
fs.writeFileSync(lockFile, body, { flag: 'wx' });
|
|
74
|
+
return;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e.code !== 'EEXIST') throw e;
|
|
77
|
+
try {
|
|
78
|
+
const raw = fs.readFileSync(lockFile, 'utf8');
|
|
79
|
+
const body = JSON.parse(raw);
|
|
80
|
+
const st = fs.statSync(lockFile);
|
|
81
|
+
const sameHost = body.hostname === os.hostname();
|
|
82
|
+
let dead = false;
|
|
83
|
+
if (sameHost) {
|
|
84
|
+
try { process.kill(body.pid, 0); }
|
|
85
|
+
catch (ke) { if (ke.code === 'ESRCH') dead = true; }
|
|
86
|
+
} else if (Date.now() - st.mtimeMs > LOCK_XHOST_MS) {
|
|
87
|
+
dead = true;
|
|
88
|
+
}
|
|
89
|
+
if (dead) fs.unlinkSync(lockFile);
|
|
90
|
+
} catch { /* lock may have been released — retry */ }
|
|
91
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_POLL_MS);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw new Error(
|
|
95
|
+
`timed out waiting for dependency install lock after ${LOCK_MAX_MS / 60000} minutes`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function releaseLock(lockFile) {
|
|
100
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sha256(buf) {
|
|
104
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function computeDepHash(pkgJsonPath, pkgLockPath) {
|
|
108
|
+
if (fs.existsSync(pkgLockPath)) {
|
|
109
|
+
return sha256(fs.readFileSync(pkgLockPath));
|
|
110
|
+
}
|
|
111
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
112
|
+
const depKeys = ['dependencies', 'optionalDependencies', 'peerDependencies'];
|
|
113
|
+
const depObj = {};
|
|
114
|
+
for (const k of depKeys) {
|
|
115
|
+
if (pkg[k]) {
|
|
116
|
+
depObj[k] = Object.fromEntries(
|
|
117
|
+
Object.entries(pkg[k]).sort(([a], [b]) => a.localeCompare(b)),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return sha256(Buffer.from(JSON.stringify(depObj)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const INSTALL_TIMEOUT_MS = 180_000;
|
|
125
|
+
|
|
126
|
+
function resolveBunExec(bunPath) {
|
|
127
|
+
for (const p of [bunPath, process.env.BUN_EXEC_PATH]) {
|
|
128
|
+
if (p && typeof p === 'string' && fs.existsSync(p)) return p;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function depsAlreadySatisfied(stampPath, sharedNm, currentHash) {
|
|
134
|
+
let storedHash = '';
|
|
135
|
+
try { storedHash = fs.readFileSync(stampPath, 'utf8').trim(); } catch {}
|
|
136
|
+
return currentHash === storedHash && hasRequiredDeps(sharedNm);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function runFrozenLockfileInstall(bunExec, depsDir, stdio = 'inherit') {
|
|
140
|
+
return spawnSync(bunExec, ['install', '--frozen-lockfile'], {
|
|
141
|
+
cwd: depsDir,
|
|
142
|
+
stdio,
|
|
143
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
144
|
+
windowsHide: true,
|
|
145
|
+
encoding: stdio === 'pipe' ? 'utf8' : undefined,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @returns {{ satisfied: boolean, skipped?: boolean, method?: 'seed'|'install', reason?: string }}
|
|
151
|
+
*/
|
|
152
|
+
export function ensureRuntimeDeps({
|
|
153
|
+
dataDir,
|
|
154
|
+
pluginRoot,
|
|
155
|
+
bunPath,
|
|
156
|
+
seedNm,
|
|
157
|
+
logPrefix = '[run-mcp]',
|
|
158
|
+
/** @type {'inherit' | 'pipe' | 'ignore'} */
|
|
159
|
+
installStdio = 'inherit',
|
|
160
|
+
}) {
|
|
161
|
+
const pluginPkg = join(pluginRoot, 'package.json');
|
|
162
|
+
const pluginLock = join(pluginRoot, 'bun.lock');
|
|
163
|
+
|
|
164
|
+
const depsDir = join(dataDir, '.deps');
|
|
165
|
+
const sharedPkg = join(depsDir, 'package.json');
|
|
166
|
+
const sharedLock = join(depsDir, 'bun.lock');
|
|
167
|
+
const sharedNm = join(depsDir, 'node_modules');
|
|
168
|
+
const stamp = join(depsDir, '.deps-stamp');
|
|
169
|
+
const stampTmp = join(depsDir, '.deps-stamp.tmp');
|
|
170
|
+
const lockFile = join(depsDir, '.install.lock');
|
|
171
|
+
|
|
172
|
+
const currentHash = computeDepHash(pluginPkg, pluginLock);
|
|
173
|
+
if (depsAlreadySatisfied(stamp, sharedNm, currentHash)) {
|
|
174
|
+
return { satisfied: true, skipped: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
assertSafeOwnedDir(depsDir, dataDir, 'bun install');
|
|
178
|
+
fs.mkdirSync(depsDir, { recursive: true });
|
|
179
|
+
acquireLock(lockFile);
|
|
180
|
+
try {
|
|
181
|
+
if (depsAlreadySatisfied(stamp, sharedNm, currentHash)) {
|
|
182
|
+
return { satisfied: true, skipped: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fs.copyFileSync(pluginPkg, sharedPkg);
|
|
186
|
+
if (fs.existsSync(pluginLock)) fs.copyFileSync(pluginLock, sharedLock);
|
|
187
|
+
|
|
188
|
+
const bunExec = resolveBunExec(bunPath);
|
|
189
|
+
|
|
190
|
+
const canSeed = seedNm && hasRequiredDeps(seedNm);
|
|
191
|
+
if (canSeed) {
|
|
192
|
+
process.stderr.write(
|
|
193
|
+
`${logPrefix} seeding shared deps from ${seedNm} (skipping bun install)\n`,
|
|
194
|
+
);
|
|
195
|
+
fs.cpSync(seedNm, sharedNm, { recursive: true, force: true });
|
|
196
|
+
if (!bunExec) {
|
|
197
|
+
process.stderr.write(
|
|
198
|
+
`${logPrefix} bun not available — cannot validate seeded tree against lock\n`,
|
|
199
|
+
);
|
|
200
|
+
return { satisfied: false, reason: 'bun unavailable' };
|
|
201
|
+
}
|
|
202
|
+
process.stderr.write(
|
|
203
|
+
`${logPrefix} validating seeded deps: bun install --frozen-lockfile\n`,
|
|
204
|
+
);
|
|
205
|
+
const validate = runFrozenLockfileInstall(bunExec, depsDir, installStdio);
|
|
206
|
+
if (validate.status !== 0) {
|
|
207
|
+
const detail = validate.status ?? validate.signal ?? 'unknown';
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
`${logPrefix} WARN: seeded tree failed frozen-lockfile (${detail}) — ` +
|
|
210
|
+
'not stamping (runtime will install normally)\n',
|
|
211
|
+
);
|
|
212
|
+
return { satisfied: false, reason: 'frozen-lockfile validation failed' };
|
|
213
|
+
}
|
|
214
|
+
fs.writeFileSync(stampTmp, currentHash);
|
|
215
|
+
renameWithRetrySync(stampTmp, stamp);
|
|
216
|
+
return { satisfied: true, method: 'seed' };
|
|
217
|
+
} else {
|
|
218
|
+
if (!bunExec) {
|
|
219
|
+
process.stderr.write(
|
|
220
|
+
`${logPrefix} bun not available — skipping install (will retry on launch)\n`,
|
|
221
|
+
);
|
|
222
|
+
return { satisfied: false, reason: 'bun unavailable' };
|
|
223
|
+
}
|
|
224
|
+
const args = fs.existsSync(sharedLock)
|
|
225
|
+
? ['install', '--frozen-lockfile']
|
|
226
|
+
: ['install'];
|
|
227
|
+
process.stderr.write(`${logPrefix} installing shared deps: bun ${args.join(' ')}\n`);
|
|
228
|
+
|
|
229
|
+
const result = spawnSync(bunExec, args, {
|
|
230
|
+
cwd: depsDir,
|
|
231
|
+
stdio: installStdio,
|
|
232
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
233
|
+
windowsHide: true,
|
|
234
|
+
encoding: installStdio === 'pipe' ? 'utf8' : undefined,
|
|
235
|
+
});
|
|
236
|
+
if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') {
|
|
237
|
+
process.stderr.write(
|
|
238
|
+
`${logPrefix} WARN: bun install timed out after ${INSTALL_TIMEOUT_MS}ms — ` +
|
|
239
|
+
'continuing with existing node_modules (stale lock removed)\n',
|
|
240
|
+
);
|
|
241
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
242
|
+
return { satisfied: false, reason: 'install timed out' };
|
|
243
|
+
} else if (result.status !== 0) {
|
|
244
|
+
const detail = result.status ?? result.signal ?? 'unknown';
|
|
245
|
+
process.stderr.write(
|
|
246
|
+
`${logPrefix} WARN: bun install exited with status ${detail} — ` +
|
|
247
|
+
'continuing with existing node_modules if available\n',
|
|
248
|
+
);
|
|
249
|
+
return { satisfied: false, reason: 'install failed' };
|
|
250
|
+
} else {
|
|
251
|
+
fs.writeFileSync(stampTmp, currentHash);
|
|
252
|
+
renameWithRetrySync(stampTmp, stamp);
|
|
253
|
+
return { satisfied: true, method: 'install' };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
releaseLock(lockFile);
|
|
258
|
+
}
|
|
259
|
+
}
|