dsh-edt-projects 0.11.0 → 0.12.0

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/lib/index.js CHANGED
@@ -18,6 +18,11 @@
18
18
  // включения на лету), поэтому правки применяются через edt_reload без
19
19
  // перезапуска DSH.
20
20
  //
21
+ // Git worktree: включённость и настройки наследуются от основного checkout, а
22
+ // окружение EDT поднимается своё — workspace в <worktree>/build/ws, свой порт и
23
+ // фоновый процесс (lib/workspace-run.mjs). Так параллельные worktree не делят
24
+ // один workspace EDT; основной checkout продолжает работать с ручным сервером.
25
+ //
21
26
  // `shared: true` больше не работает: сервер всегда живёт в скоупе сессии, иначе
22
27
  // его инструменты протекали бы в чужие проекты.
23
28
  export const name = 'mcp-hub';
@@ -25,17 +30,40 @@ export const name = 'mcp-hub';
25
30
  export const inject = ['tools', 'workingDirectory'];
26
31
 
27
32
  /** Собственные инструменты плагина; префикс edt_ отделяет их от `mcp__edt__*`. */
28
- export const TOOL_NAMES = ['edt_servers', 'edt_pin', 'edt_unpin', 'edt_reload', 'edt_config'];
33
+ export const TOOL_NAMES = ['edt_servers', 'edt_pin', 'edt_unpin', 'edt_reload', 'edt_config', 'edt_workspace'];
29
34
 
35
+ import { spawn } from 'node:child_process';
36
+ import { join, resolve as resolvePath, isAbsolute } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
30
38
  import { watch } from 'node:fs';
31
39
  import { bslAvailable, bslSettings, ensureJar, resolveBsl, startBslSupervisor } from './bsl.mjs';
32
40
  import { projectConfigLoaded, projectConfigPath, resolveConfig, userConfigPath } from './config.mjs';
33
- import { activationFor } from './enable.mjs';
41
+ import { activationForPaths } from './enable.mjs';
34
42
  import { normalizeWiki, targetPathOf, writeDenialReason } from './wiki.mjs';
35
43
  import { normalizeTestTooling } from './tests.mjs';
36
44
  import { initFile, upgradeFile } from './config-file.mjs';
37
45
  import { stringifyConfig, validateConfig } from './schema.mjs';
38
46
  import { createSkillsProvider } from './skills.mjs';
47
+ import { repositoriesUnder } from './repo.mjs';
48
+ import {
49
+ BUILD_DIR,
50
+ detectEdtHome,
51
+ formatElapsed,
52
+ isPortOpen,
53
+ normalizeWorkspaceSettings,
54
+ pickFreePort,
55
+ pidAlive,
56
+ prepareWorkspaceSnapshot,
57
+ probeHealth,
58
+ readPort,
59
+ removeStamp,
60
+ resolveWorkspaceTarget,
61
+ stateOf as workspaceState,
62
+ writePort,
63
+ writeStamp,
64
+ writeWorkspaceSnapshot,
65
+ } from './workspace.mjs';
66
+ import { cmdStop as stopWorkspaceNow } from './workspace-run.mjs';
39
67
 
40
68
  const HEADER = `## Работа с 1С через MCP
41
69
 
@@ -108,6 +136,20 @@ v8std — внешняя база стандартов 1С и диагности
108
136
  - Найденный текст — справка, а не приговор: проверку даёт EDT или BSL, v8std объясняет, что она означает.
109
137
  - v8std только читает; правки исходников — через EDT-MCP, по правилам выше.`;
110
138
 
139
+ /** Блок про отдельное EDT-окружение worktree. */
140
+ function workspaceRules(workspace) {
141
+ return `
142
+ ### Окружение EDT в worktree
143
+
144
+ Сессия открыта в отдельном git worktree, поэтому у неё свой workspace EDT: ${workspace.wsDir}. Его поднимает плагин, отдельно от основного checkout.
145
+
146
+ - Первый запуск занимает 1–3 минуты, с импортом проектов — дольше. Готовность проверяй через \`edt_workspace\` (action=status): состояние \`ready\` и порт.
147
+ - Инструменты \`mcp__edt__*\` появляются после готовности сервера. Если вызов не удался из-за соединения — проверь статус, не перезапускай окружение самовольно.
148
+ - Правки исходников — через EDT по правилам выше; workspace EDT знает проекты этого worktree.
149
+ - Не удаляй \`${workspace.root}/build\` при живом EDT. Остановка — \`edt_workspace\` action=stop, перезапуск — action=restart.
150
+ - Логи: ${workspace.root}/build/logs (edt.log, edt-import.log, init.log).`;
151
+ }
152
+
111
153
  /**
112
154
  * Собирает блок правил для вики.
113
155
  * @param roots результат normalizeWiki().roots
@@ -170,13 +212,15 @@ function vanessaRules(settings) {
170
212
  }
171
213
 
172
214
  /**
173
- * Текст правил под фактически настроенные серверы, их состояние, вики и тесты.
215
+ * Текст правил под фактически настроенные серверы, их состояние, вики, тесты
216
+ * и управляемое окружение worktree.
174
217
  * @param env окружение процесса (для тестов передаётся явно).
175
218
  * @param servers список записей { name, state }.
176
219
  * @param wikiRoots корни вики из normalizeWiki().
177
220
  * @param tests секция tests из настроек (флаги YAxUnit / Vanessa).
221
+ * @param workspace описание окружения worktree (describeWorkspace) или undefined.
178
222
  */
179
- export function buildRulesText(env = process.env, servers = [], wikiRoots = [], tests = {}) {
223
+ export function buildRulesText(env = process.env, servers = [], wikiRoots = [], tests = {}, workspace = undefined) {
180
224
  const usable = new Set(servers.filter((s) => s.state !== 'unavailable').map((s) => s.name));
181
225
  const byEnv = servers.length === 0;
182
226
  const blocks = [HEADER];
@@ -188,6 +232,7 @@ export function buildRulesText(env = process.env, servers = [], wikiRoots = [],
188
232
  if (usable.has('bsl') || (byEnv && (env.BSL_LS_JAR || env.BSL_MCP_URL))) blocks.push(BSL_RULES);
189
233
  if (usable.has('tasks') || (byEnv && env.TASKS_MCP_URL)) blocks.push(TASKS_RULES);
190
234
  if (usable.has('v8std') || (byEnv && env.V8STD_MCP_URL)) blocks.push(V8STD_RULES);
235
+ if (workspace?.state === 'worktree') blocks.push(workspaceRules(workspace));
191
236
 
192
237
  const lazy = servers.filter((s) => s.state === 'lazy').map((s) => s.name);
193
238
  const unavailable = servers.filter((s) => s.state === 'unavailable').map((s) => s.name);
@@ -248,11 +293,23 @@ export function mountConfig(entry) {
248
293
  return config;
249
294
  }
250
295
 
251
- /** Настройки BSL LS из окружения, перекрытые файлами настроек. */
252
- function bslSettingsFor(effective) {
296
+ /**
297
+ * Настройки BSL LS из окружения, перекрытые файлами настроек.
298
+ * @param fallbackRoot корень проекта: работе в worktree нужен его каталог,
299
+ * а не каталог процесса DSH. Относительный `servers.bsl.root` считается
300
+ * от него же.
301
+ */
302
+ function bslSettingsFor(effective, fallbackRoot = process.cwd()) {
253
303
  const base = bslSettings(process.env);
254
304
  const fromFile = effective.config?.servers?.bsl ?? {};
255
305
  const pick = (key, fallback) => (fromFile[key] === undefined ? fallback : fromFile[key]);
306
+ const rawRoot = pick('root', base.root);
307
+ const root =
308
+ typeof rawRoot !== 'string' || rawRoot.trim() === ''
309
+ ? fallbackRoot
310
+ : isAbsolute(rawRoot)
311
+ ? rawRoot
312
+ : resolvePath(fallbackRoot, rawRoot);
256
313
  return {
257
314
  ...base,
258
315
  jar: pick('jar', base.jar),
@@ -260,8 +317,8 @@ function bslSettingsFor(effective) {
260
317
  version: pick('version', base.version),
261
318
  cacheDir: pick('cacheDir', base.cacheDir),
262
319
  java: pick('java', base.java),
263
- cwd: pick('cwd', base.cwd),
264
- root: pick('root', base.root),
320
+ cwd: pick('cwd', base.cwd) || fallbackRoot,
321
+ root,
265
322
  port: pick('port', base.port),
266
323
  mode: pick('mode', base.mode),
267
324
  mcpUrl: pick('mcpUrl', base.mcpUrl),
@@ -299,7 +356,9 @@ const activationReason = (activation) => {
299
356
  case 'project-file':
300
357
  return 'есть .dsh-1c.json проекта';
301
358
  case 'allowlist':
302
- return `путь в projects (${activation.entry})`;
359
+ return activation.inherited === true
360
+ ? `путь в projects (${activation.entry}), наследовано из ${activation.path}`
361
+ : `путь в projects (${activation.entry})`;
303
362
  default:
304
363
  return 'проект не выбран: нет .dsh-1c.json и его нет в projects';
305
364
  }
@@ -336,26 +395,106 @@ export async function apply(ctx, config) {
336
395
 
337
396
  const cwdOf = (agent) => sessionCwd(agent, ctx.workingDirectory);
338
397
 
339
- const effectiveForCwd = (cwd) => {
340
- const effective = resolveConfig({ cwd, env: process.env, base });
398
+ /** Уникальные пути в порядке появления (для слоёв настроек и allowlist). */
399
+ const uniquePaths = (values) => {
400
+ const seen = new Set();
401
+ const out = [];
402
+ for (const value of values) {
403
+ if (typeof value !== 'string' || value === '') continue;
404
+ const key = value.toLowerCase();
405
+ if (seen.has(key)) continue;
406
+ seen.add(key);
407
+ out.push(value);
408
+ }
409
+ return out;
410
+ };
411
+
412
+ /**
413
+ * Контекст проекта для каталога сессии: репозитории, слои настроек,
414
+ * активация и план EDT-окружения. Для worktree настройки берутся ещё и из
415
+ * основного checkout, а активация наследуется от него же.
416
+ */
417
+ const projectContextForCwd = (cwd) => {
418
+ const repositories = repositoriesUnder(cwd);
419
+ // Порядок = порядок слияния: основной checkout слабее worktree, cwd сильнее всех.
420
+ const paths = uniquePaths([...repositories.flatMap((repo) => [repo.mainRoot, repo.root]), cwd]);
421
+ const effective = resolveConfig({ cwd, projectCwds: paths, env: process.env, base });
341
422
  for (const error of effective.errors) {
342
423
  if (loggedErrors.has(error)) continue;
343
424
  loggedErrors.add(error);
344
425
  ctx.logger?.warn?.(`mcp-hub: ${error}`);
345
426
  }
346
- return effective;
427
+ const hasProjectFile = paths.some((dir) => projectConfigLoaded(dir));
428
+ const activation = activationForPaths({ cwd, config: effective.config, hasProjectFile, paths });
429
+ const enabledRepositories = repositories.filter(
430
+ (repo) =>
431
+ activationForPaths({
432
+ cwd: repo.root,
433
+ config: effective.config,
434
+ hasProjectFile: projectConfigLoaded(repo.root) || projectConfigLoaded(repo.mainRoot),
435
+ paths: [repo.root, repo.mainRoot],
436
+ }).enabled,
437
+ );
438
+ // Если проект включён самим каталогом сессии (файл в task space), явного
439
+ // списка включённых репозиториев может не быть — берём репозитории сессии.
440
+ const workspaceRepositories =
441
+ enabledRepositories.length > 0 ? enabledRepositories : activation.enabled ? repositories : [];
442
+ const workspace = resolveWorkspaceTarget({
443
+ cwd,
444
+ config: effective.config,
445
+ repositories: workspaceRepositories,
446
+ env: process.env,
447
+ });
448
+ return { cwd, repositories, enabledRepositories, paths, effective, activation, workspace };
347
449
  };
348
450
 
349
- const effectiveFor = (agent) => effectiveForCwd(cwdOf(agent));
451
+ const contextFor = (agent) => projectContextForCwd(cwdOf(agent));
452
+ const effectiveFor = (agent) => contextFor(agent).effective;
350
453
 
351
- const activationOfCwd = (cwd) =>
352
- activationFor({ cwd, config: effectiveForCwd(cwd).config, hasProjectFile: projectConfigLoaded(cwd) });
454
+ /** Каталог, от которого считается корень BSL и относительный workspace.root. */
455
+ const fallbackRootFor = (context) => {
456
+ if (context.workspace.state === 'ok') return context.workspace.root;
457
+ if (context.enabledRepositories.length > 0) return context.enabledRepositories[0].root;
458
+ return context.cwd;
459
+ };
353
460
 
354
- const activationOf = (agent) => activationOfCwd(cwdOf(agent));
461
+ /** Дешёвая проверка живости: без tasklist, чтобы не звать её на каждом шаге. */
462
+ const cheapAlive = (pid) => pidAlive(pid);
355
463
 
356
- const entriesFor = (effective) => {
357
- const bsl = bslSettingsFor(effective);
358
- return normalizeServers(effective.config?.servers).map((entry) => {
464
+ /**
465
+ * Описание окружения для правил, снапшота и инструмента edt_workspace.
466
+ * `state` — включено ли управление ('worktree'/'off'/'error'),
467
+ * `status` — фактическая фаза (absent/stopped/starting/ready/stale/failed).
468
+ */
469
+ const describeWorkspace = (plan, deps = {}) => {
470
+ if (plan === undefined || plan.state === 'off') return { state: 'off', reason: plan?.reason };
471
+ if (plan.state === 'error') {
472
+ return { state: 'error', reason: plan.reason, message: plan.message, settings: plan.settings };
473
+ }
474
+ const current = workspaceState(plan.buildDir, deps);
475
+ const phase = current.progress?.edt;
476
+ return {
477
+ state: 'worktree',
478
+ status: current.label,
479
+ root: plan.root,
480
+ buildDir: plan.buildDir,
481
+ wsDir: plan.wsDir,
482
+ mainRoot: plan.mainRoot,
483
+ port: current.port ?? undefined,
484
+ pid: current.pid?.pid ?? undefined,
485
+ phase: phase?.phase,
486
+ since: phase?.since,
487
+ detail: phase?.detail,
488
+ imported: current.imported?.projects?.length,
489
+ settings: plan.settings,
490
+ warnings: plan.warnings ?? [],
491
+ rootSource: plan.rootSource,
492
+ };
493
+ };
494
+
495
+ const entriesFor = (context) => {
496
+ const bsl = bslSettingsFor(context.effective, fallbackRootFor(context));
497
+ const entries = normalizeServers(context.effective.config?.servers).map((entry) => {
359
498
  if (entry.raw.provision === true) {
360
499
  entry.provisioned = true;
361
500
  entry.transport = bsl.mcpUrl === undefined ? 'stdio' : 'streamable-http';
@@ -363,6 +502,16 @@ export async function apply(ctx, config) {
363
502
  }
364
503
  return entry;
365
504
  });
505
+
506
+ // В worktree EDT монтируется на порт поднятого для него окружения.
507
+ if (context.workspace.state === 'ok') {
508
+ const port = readPort(context.workspace.buildDir) ?? context.workspace.port;
509
+ const edt = entries.find((entry) => entry.name === 'edt');
510
+ if (edt !== undefined && Number.isInteger(port)) {
511
+ edt.raw = { ...edt.raw, url: `http://127.0.0.1:${port}/mcp` };
512
+ }
513
+ }
514
+ return entries;
366
515
  };
367
516
 
368
517
  /** Состояние активации одного агента (сессии): инструменты и смонтированные серверы. */
@@ -401,9 +550,10 @@ export async function apply(ctx, config) {
401
550
  }
402
551
  };
403
552
 
404
- const snapshot = (agent) => {
405
- const effective = effectiveFor(agent);
406
- return entriesFor(effective).map((entry) => {
553
+ const snapshot = (agent, deps = {}) => {
554
+ const context = contextFor(agent);
555
+ const workspace = describeWorkspace(context.workspace, deps);
556
+ return entriesFor(context).map((entry) => {
407
557
  const item = mapFor(agent).get(entry.name);
408
558
  let value = 'lazy';
409
559
  if (entry.unavailable) value = 'unavailable';
@@ -418,6 +568,7 @@ export async function apply(ctx, config) {
418
568
  endpoint: item?.supervisor?.url ?? entry.raw?.url,
419
569
  tools: toolNamesOf(agent, entry.name).length,
420
570
  error: item?.error,
571
+ workspace: entry.name === 'edt' ? workspace : undefined,
421
572
  };
422
573
  });
423
574
  };
@@ -434,8 +585,8 @@ export async function apply(ctx, config) {
434
585
  };
435
586
 
436
587
  const mount = async (agent, serverName, options = {}) => {
437
- const effective = effectiveFor(agent);
438
- const entries = entriesFor(effective);
588
+ const context = contextFor(agent);
589
+ const entries = entriesFor(context);
439
590
  const entry = entries.find((candidate) => candidate.name === serverName);
440
591
  if (entry === undefined) {
441
592
  throw new Error(`unknown MCP server "${serverName}"; configured: ${entries.map((item) => item.name).join(', ') || 'none'}`);
@@ -445,7 +596,7 @@ export async function apply(ctx, config) {
445
596
  }
446
597
 
447
598
  const item = itemFor(agent, entry);
448
- const bsl = bslSettingsFor(effective);
599
+ const bsl = bslSettingsFor(context.effective, fallbackRootFor(context));
449
600
  const root = options.root ?? entry.raw.root ?? bsl.root;
450
601
 
451
602
  if (item.fiber && !item.error) {
@@ -515,8 +666,8 @@ export async function apply(ctx, config) {
515
666
  };
516
667
 
517
668
  const unmount = async (agent, serverName) => {
518
- const effective = effectiveFor(agent);
519
- const entry = entriesFor(effective).find((candidate) => candidate.name === serverName);
669
+ const context = contextFor(agent);
670
+ const entry = entriesFor(context).find((candidate) => candidate.name === serverName);
520
671
  const map = mapFor(agent);
521
672
  const item = map.get(serverName);
522
673
  if (item === undefined || !item.fiber) {
@@ -531,8 +682,8 @@ export async function apply(ctx, config) {
531
682
 
532
683
  /** Перечитывает настройки и перезапускает серверы, у которых изменился конфиг. */
533
684
  const reload = async (agent, serverName) => {
534
- const effective = effectiveFor(agent);
535
- const entries = entriesFor(effective);
685
+ const context = contextFor(agent);
686
+ const entries = entriesFor(context);
536
687
  const targets = serverName === undefined ? entries : entries.filter((entry) => entry.name === serverName);
537
688
  if (serverName !== undefined && targets.length === 0) {
538
689
  throw new Error(`unknown MCP server "${serverName}"; configured: ${entries.map((item) => item.name).join(', ') || 'none'}`);
@@ -542,9 +693,8 @@ export async function apply(ctx, config) {
542
693
  for (const entry of targets) {
543
694
  const item = mapFor(agent).get(entry.name);
544
695
  if (item === undefined || !item.fiber) continue;
545
- const bsl = bslSettingsFor(effective);
546
- const root = entry.raw.root ?? bsl.root;
547
- const fresh = signatureOf(entry, root);
696
+ const freshRoot = entry.raw.root ?? bslSettingsFor(context.effective, fallbackRootFor(context)).root;
697
+ const fresh = signatureOf(entry, freshRoot);
548
698
  if (fresh === item.signature) continue;
549
699
  await disposeItem(item);
550
700
  const result = await mount(agent, entry.name, {});
@@ -582,17 +732,155 @@ export async function apply(ctx, config) {
582
732
  }
583
733
  };
584
734
 
735
+ // --- окружение EDT в worktree ----------------------------------------------
736
+ const initializerPath = fileURLToPath(new URL('./workspace-run.mjs', import.meta.url));
737
+ /** buildDir -> Promise подъёма (одна на процесс, чтобы не поднять два EDT). */
738
+ const ensuring = new Map();
739
+ /** buildDir -> { agents, settings, startedByUs } для остановки после последней сессии. */
740
+ const workspaceSessions = new Map();
741
+
742
+ const spawnInitializer = (root, action = 'init') => {
743
+ try {
744
+ const child = spawn(process.execPath, [initializerPath, action, '--root', root], {
745
+ cwd: root,
746
+ detached: true,
747
+ stdio: 'ignore',
748
+ windowsHide: true,
749
+ });
750
+ child.unref();
751
+ return true;
752
+ } catch (error) {
753
+ ctx.logger?.warn?.(`mcp-hub: не удалось запустить ${action} окружения EDT: ${error instanceof Error ? error.message : String(error)}`);
754
+ return false;
755
+ }
756
+ };
757
+
758
+ const registerWorkspaceSession = (buildDir, agent, settings) => {
759
+ let record = workspaceSessions.get(buildDir);
760
+ if (record === undefined) {
761
+ record = { agents: new Set(), settings, startedByUs: false };
762
+ workspaceSessions.set(buildDir, record);
763
+ }
764
+ if (agent !== undefined) record.agents.add(agent);
765
+ if (settings !== undefined) record.settings = settings;
766
+ return record;
767
+ };
768
+
769
+ /** Фаза подъёма без pid (идёт импорт или старт GUI) тоже считается запуском. */
770
+ const busyLaunching = (current, settings = {}) => {
771
+ if (current.label === 'starting') return true;
772
+ const phase = current.progress?.edt;
773
+ if (phase?.phase === undefined || ['failed', 'ready'].includes(phase.phase)) return false;
774
+ const updated = Date.parse(phase.updated ?? phase.since ?? '');
775
+ if (Number.isNaN(updated)) return true;
776
+ const budget = Math.max(settings.startTimeout ?? 1800, settings.importTimeout ?? 3600) * 1000 + 5 * 60_000;
777
+ return Date.now() - updated < budget;
778
+ };
779
+
780
+ /**
781
+ * Гарантирует подъём окружения: занимает порт, пишет снимок и запускает
782
+ * фоновый init. Готовое/стартующее окружение только принимается.
783
+ */
784
+ const ensureWorkspace = async (agent, context = contextFor(agent), options = {}) => {
785
+ const plan = context.workspace;
786
+ if (plan?.state !== 'ok') return undefined;
787
+ const key = plan.buildDir;
788
+ const record = registerWorkspaceSession(key, agent, plan.settings);
789
+ // После edt_workspace stop не поднимаем заново, пока не попросят init.
790
+ if (record.suppressed === true && options.force !== true) {
791
+ return workspaceState(key, { alive: cheapAlive });
792
+ }
793
+ const pending = ensuring.get(key);
794
+ if (pending !== undefined) return pending;
795
+
796
+ const promise = (async () => {
797
+ let current = workspaceState(key, { alive: cheapAlive });
798
+ if (current.label === 'failed' && options.force === true) {
799
+ removeStamp(key, 'failed');
800
+ current = workspaceState(key, { alive: cheapAlive });
801
+ }
802
+ if (current.label === 'ready') return current;
803
+ if (current.label === 'failed') {
804
+ // EDT мог подняться позже таймаута health: проверяем и принимаем.
805
+ if (await probeHealth(current.port)) {
806
+ removeStamp(key, 'failed');
807
+ writeStamp(key, 'ready');
808
+ return workspaceState(key, { alive: cheapAlive });
809
+ }
810
+ return current;
811
+ }
812
+ if (busyLaunching(current, plan.settings)) return current;
813
+ if (current.label === 'stale') removeStamp(key, 'ready');
814
+
815
+ try {
816
+ let port = readPort(key);
817
+ if (port !== null && (await isPortOpen(port))) {
818
+ // Кто-то уже слушает порт: если это наш MCP-сервер (DSH перезапустили,
819
+ // маркер потерялся), принимаем его и не поднимаем второй EDT на тот же
820
+ // workspace.
821
+ if (await probeHealth(port)) {
822
+ removeStamp(key, 'failed');
823
+ writeStamp(key, 'ready');
824
+ return workspaceState(key, { alive: cheapAlive });
825
+ }
826
+ port = null;
827
+ }
828
+ if (port === null) {
829
+ port = await pickFreePort(plan.settings.ports);
830
+ writePort(key, port);
831
+ }
832
+ const workspaceSnapshot = prepareWorkspaceSnapshot({
833
+ root: plan.root,
834
+ mainRoot: plan.mainRoot,
835
+ settings: plan.settings,
836
+ });
837
+ if (options.import === true) workspaceSnapshot.forceImport = true;
838
+ writeWorkspaceSnapshot(key, workspaceSnapshot);
839
+ record.startedByUs = true;
840
+ spawnInitializer(plan.root, 'init');
841
+ return workspaceState(key, { alive: cheapAlive });
842
+ } catch (error) {
843
+ ctx.logger?.warn?.(`mcp-hub: окружение EDT не поднялось: ${error instanceof Error ? error.message : String(error)}`);
844
+ return undefined;
845
+ }
846
+ })().finally(() => ensuring.delete(key));
847
+
848
+ ensuring.set(key, promise);
849
+ return promise;
850
+ };
851
+
852
+ /** Снимает сессию с окружения; после последней — останавливает поднятое нами EDT. */
853
+ const releaseWorkspaceSession = (agent) => {
854
+ for (const [buildDir, record] of workspaceSessions) {
855
+ if (!record.agents.delete(agent)) continue;
856
+ if (record.agents.size > 0) continue;
857
+ workspaceSessions.delete(buildDir);
858
+ if (record.startedByUs !== true) continue;
859
+ if (record.settings?.stopOnSessionEnd === false) continue;
860
+ spawnInitializer(resolvePath(buildDir, '..'), 'stop');
861
+ }
862
+ };
863
+
585
864
  const activate = async (agent) => {
586
865
  const state = stateOf(agent);
587
866
  if (state.active) return;
588
867
  state.active = true;
589
- for (const definition of [serversTool(), pinTool(), unpinTool(), reloadTool()]) {
868
+ const context = contextFor(agent);
869
+ for (const definition of [serversTool(), pinTool(), unpinTool(), reloadTool(), workspaceTool()]) {
590
870
  state.disposers.push(agent.ctx.tools.register(definition));
591
871
  }
592
- watchFile(projectConfigPath(cwdOf(agent)));
872
+ for (const dir of context.paths) watchFile(projectConfigPath(dir));
593
873
 
594
- const effective = effectiveFor(agent);
595
- for (const entry of entriesFor(effective)) {
874
+ // Окружение поднимается до монтирования edt: нужен порт для адреса.
875
+ if (context.workspace.state === 'ok') {
876
+ try {
877
+ await ensureWorkspace(agent, context);
878
+ } catch (error) {
879
+ ctx.logger?.warn?.(`mcp-hub: окружение EDT не поднялось: ${error instanceof Error ? error.message : String(error)}`);
880
+ }
881
+ }
882
+
883
+ for (const entry of entriesFor(context)) {
596
884
  if (entry.lazy || entry.unavailable) continue;
597
885
  try {
598
886
  await mount(agent, entry.name);
@@ -615,6 +903,7 @@ export async function apply(ctx, config) {
615
903
  }
616
904
  for (const item of state.items.values()) await disposeItem(item);
617
905
  state.items.clear();
906
+ releaseWorkspaceSession(agent);
618
907
  };
619
908
 
620
909
  /**
@@ -623,13 +912,26 @@ export async function apply(ctx, config) {
623
912
  */
624
913
  const reconcile = async (agent) => {
625
914
  if (agent === undefined) return;
626
- const { enabled } = activationOf(agent);
915
+ const context = contextFor(agent);
627
916
  const state = agentStates.get(agent);
628
- if (enabled) {
629
- if (state === undefined || !state.active) await activate(agent);
917
+ if (!context.activation.enabled) {
918
+ await deactivate(agent);
919
+ return;
920
+ }
921
+ if (state === undefined || !state.active) {
922
+ await activate(agent);
630
923
  return;
631
924
  }
632
- await deactivate(agent);
925
+
926
+ if (context.workspace.state === 'ok') {
927
+ await ensureWorkspace(agent, context);
928
+ // Порт появился после init — адрес edt изменился, перемонтируем.
929
+ try {
930
+ await reload(agent, 'edt');
931
+ } catch (error) {
932
+ ctx.logger?.warn?.(`mcp-hub: не удалось обновить адрес EDT: ${error instanceof Error ? error.message : String(error)}`);
933
+ }
934
+ }
633
935
  };
634
936
 
635
937
  ctx.on('agent/created', async ({ agent }) => {
@@ -649,6 +951,15 @@ export async function apply(ctx, config) {
649
951
  return next();
650
952
  });
651
953
 
954
+ ctx.on('agent/disposed', ({ agent }) => {
955
+ releaseWorkspaceSession(agent);
956
+ });
957
+
958
+ ctx.effect(() => () => {
959
+ ensuring.clear();
960
+ workspaceSessions.clear();
961
+ });
962
+
652
963
  // --- вики: чтение разрешено, запись отклоняется ---
653
964
  ctx.tools.guard((exec) => {
654
965
  const target = targetPathOf(exec);
@@ -662,13 +973,16 @@ export async function apply(ctx, config) {
662
973
  const serversTool = () =>
663
974
  textTool(
664
975
  'edt_servers',
665
- 'List MCP servers of the 1C plugin: state (mounted, lazy, unavailable, failed), scope, transport, tool count, workspace root and endpoint. Settings are read from $DSH_HOME/dsh-1c.json and <cwd>/.dsh-1c.json.',
976
+ 'List MCP servers of the 1C plugin: state (mounted, lazy, unavailable, failed), scope, transport, tool count, workspace root and endpoint, plus the worktree EDT environment when one is managed. Settings are read from $DSH_HOME/dsh-1c.json and <cwd>/.dsh-1c.json.',
666
977
  {},
667
978
  async (_args, exec) => {
668
- const rows = snapshot(exec?.agent).map((item) => {
979
+ const rows = snapshot(exec?.agent, { alive: cheapAlive }).map((item) => {
980
+ const workspace = item.workspace;
669
981
  const extra = [
670
982
  item.root === undefined ? null : `root: ${item.root}`,
671
983
  item.endpoint === undefined ? null : `endpoint: ${item.endpoint}`,
984
+ workspace?.state === 'worktree' ? `worktree: ${workspace.status}${workspace.port === undefined ? '' : ` port ${workspace.port}`}` : null,
985
+ workspace?.state === 'error' ? `worktree: ${workspace.message}` : null,
672
986
  item.error === undefined ? null : `error: ${item.error}`,
673
987
  ]
674
988
  .filter(Boolean)
@@ -727,6 +1041,121 @@ export async function apply(ctx, config) {
727
1041
  },
728
1042
  );
729
1043
 
1044
+ /** План окружения с необязательным переопределением корня (параметр root). */
1045
+ const workspacePlanFor = (exec, rootOverride) => {
1046
+ const context = contextFor(exec?.agent);
1047
+ if (rootOverride === undefined || rootOverride === '') return { context, plan: context.workspace };
1048
+ const settings = context.workspace?.settings ?? normalizeWorkspaceSettings(context.effective.config?.workspace, process.env);
1049
+ const root = isAbsolute(rootOverride) ? rootOverride : resolvePath(context.cwd, rootOverride);
1050
+ const buildDir = join(root, BUILD_DIR);
1051
+ return {
1052
+ context,
1053
+ plan: {
1054
+ state: 'ok',
1055
+ settings,
1056
+ root,
1057
+ buildDir,
1058
+ wsDir: join(buildDir, 'ws'),
1059
+ mainRoot: context.workspace?.mainRoot,
1060
+ warnings: context.workspace?.warnings ?? [],
1061
+ },
1062
+ };
1063
+ };
1064
+
1065
+ const workspaceText = (plan, deps = {}) => {
1066
+ const info = describeWorkspace(plan, deps);
1067
+ if (info.state === 'off') {
1068
+ const why =
1069
+ info.reason === 'disabled'
1070
+ ? 'workspace.enabled=false'
1071
+ : 'основной checkout: окружение поднимается только в git worktree';
1072
+ return `Автоподъём EDT-окружения выключен — ${why}.`;
1073
+ }
1074
+ if (info.state === 'error') return `EDT-окружение недоступно: ${info.message}`;
1075
+
1076
+ const lines = [
1077
+ `Worktree: ${info.root}`,
1078
+ `Workspace EDT: ${info.wsDir}`,
1079
+ `Состояние: ${info.status}${info.port === undefined ? '' : `, порт ${info.port}`}${info.pid === undefined ? '' : `, pid ${info.pid}`}`,
1080
+ ];
1081
+ if (info.phase !== undefined) {
1082
+ const elapsed = info.since === undefined ? '' : ` (${formatElapsed(info.since)})`;
1083
+ lines.push(`Фаза: ${info.phase}${elapsed}${info.detail === undefined ? '' : ` — ${info.detail}`}`);
1084
+ }
1085
+ if (info.imported !== undefined) lines.push(`Импортировано проектов: ${info.imported}`);
1086
+ const home = detectEdtHome(info.settings, { platform: process.platform, env: process.env });
1087
+ lines.push(
1088
+ `EDT: ${home.home ?? 'не найден'}${home.error === undefined ? '' : ` — ${home.error}`}${home.mcpBundle === false ? ' (бандл EDT-MCP не найден в plugins/features)' : ''}`,
1089
+ );
1090
+ const importMode = info.settings.import;
1091
+ lines.push(`Импорт проектов: ${importMode === true ? 'да' : importMode === false ? 'нет' : 'авто (если workspace пуст)'}`);
1092
+ for (const warning of info.warnings) lines.push(`! ${warning}`);
1093
+ lines.push(`Логи: ${join(info.buildDir, 'logs')}`);
1094
+ return lines.join('\n');
1095
+ };
1096
+
1097
+ const workspaceTool = () =>
1098
+ textTool(
1099
+ 'edt_workspace',
1100
+ 'Manage the EDT workspace of this git worktree: action=status reports where build/ws is, whether EDT is ready, its port, phase and warnings; init starts it (or retries a failed start); stop stops the EDT process; restart stops and starts again, optionally re-importing projects; import imports EDT projects while EDT is stopped (it runs in the background).',
1101
+ {
1102
+ action: {
1103
+ type: 'string',
1104
+ description: 'status | init | stop | restart | import',
1105
+ enum: ['status', 'init', 'stop', 'restart', 'import'],
1106
+ },
1107
+ root: { type: 'string', description: 'Worktree root to manage; defaults to the project worktree of this session.' },
1108
+ import: { type: 'boolean', description: 'For init/restart: import EDT projects even if the workspace already has some.' },
1109
+ },
1110
+ async (args, exec) => {
1111
+ const action = args.action ?? 'status';
1112
+ const { context, plan } = workspacePlanFor(exec, args.root);
1113
+ if (action === 'status') return { text: workspaceText(plan, { alive: (pid) => pidAlive(pid, ['1cedt']) }) };
1114
+
1115
+ if (plan === undefined || plan.state !== 'ok') {
1116
+ throw new Error(`EDT-окружение недоступно: ${plan?.message ?? workspaceText(plan)}`);
1117
+ }
1118
+ const record = registerWorkspaceSession(plan.buildDir, exec?.agent, plan.settings);
1119
+
1120
+ if (action === 'stop') {
1121
+ const result = await stopWorkspaceNow(plan.root);
1122
+ record.suppressed = true;
1123
+ return { text: result.stopped ? `EDT остановлен (pid ${result.pid}).` : `EDT не остановлен: ${result.reason}.` };
1124
+ }
1125
+
1126
+ record.suppressed = false;
1127
+ if (action === 'restart' || action === 'import') {
1128
+ if (action === 'import') {
1129
+ const current = workspaceState(plan.buildDir, { alive: cheapAlive });
1130
+ if (current.label === 'ready' || busyLaunching(current)) {
1131
+ throw new Error('EDT запущен: импорт возможен только при остановленном EDT. Используйте action=restart с import: true.');
1132
+ }
1133
+ }
1134
+ if (action === 'restart') await stopWorkspaceNow(plan.root);
1135
+ }
1136
+
1137
+ const forceImport = args.import === true || action === 'import';
1138
+ const result = await ensureWorkspace(exec?.agent, { ...context, workspace: plan }, { force: true, import: forceImport });
1139
+ if (result === undefined) throw new Error('Не удалось запустить подъём окружения: смотрите логи build/logs.');
1140
+ if (result.label === 'ready') {
1141
+ return { text: `EDT уже готов (порт ${result.port ?? '-'}).\n\n${workspaceText(plan)}` };
1142
+ }
1143
+ const headline =
1144
+ action === 'import'
1145
+ ? 'Импорт проектов запущен в фоне.'
1146
+ : action === 'restart'
1147
+ ? 'EDT перезапускается в фоне.'
1148
+ : 'Подъём EDT-окружения запущен в фоне.';
1149
+ return {
1150
+ text: [
1151
+ headline,
1152
+ 'Первый запуск занимает 1–3 минуты (с импортом дольше). Проверяйте готовность через action=status.',
1153
+ workspaceText(plan),
1154
+ ].join('\n'),
1155
+ };
1156
+ },
1157
+ );
1158
+
730
1159
  const configTool = () =>
731
1160
  textTool(
732
1161
  'edt_config',
@@ -747,12 +1176,21 @@ export async function apply(ctx, config) {
747
1176
  if (path === undefined) throw new Error('edt_config: cannot resolve the settings path');
748
1177
 
749
1178
  if (args.action === 'show') {
750
- const effective = effectiveFor(exec?.agent);
1179
+ const context = contextFor(exec?.agent);
1180
+ const effective = context.effective;
751
1181
  const { errors, warnings } = validateConfig(effective.config);
752
- const activation = activationOf(exec?.agent);
1182
+ const activation = context.activation;
1183
+ const workspace = describeWorkspace(context.workspace, { alive: cheapAlive });
1184
+ const workspaceLine =
1185
+ workspace.state === 'worktree'
1186
+ ? `EDT-окружение worktree: ${workspace.status}, порт ${workspace.port ?? '-'}, ${workspace.root}`
1187
+ : workspace.state === 'error'
1188
+ ? `EDT-окружение worktree: ошибка — ${workspace.message}`
1189
+ : 'EDT-окружение worktree: выключено (поднимается только в git worktree)';
753
1190
  const head = [
754
1191
  `Проект: ${activation.enabled ? 'включён' : 'выключен'} — ${activationReason(activation)}`,
755
1192
  `Источники: ${effective.sources.length > 0 ? effective.sources.join(', ') : 'только cordis.patch.yml'}`,
1193
+ workspaceLine,
756
1194
  `Действующие настройки:\n${stringifyConfig(effective.config)}`,
757
1195
  ];
758
1196
  if (errors.length > 0) head.push(`Ошибки:\n${errors.map((line) => ` ✗ ${line}`).join('\n')}`);
@@ -808,8 +1246,10 @@ export async function apply(ctx, config) {
808
1246
  inner.skills.registerProvider((control) => {
809
1247
  skillsControl = control;
810
1248
  return createSkillsProvider({
811
- resolveConfig: (cwd) => resolveConfig({ cwd: cwd ?? process.cwd(), env: process.env, base }).config,
812
- isEnabled: (cwd, config) => activationFor({ cwd, config, hasProjectFile: projectConfigLoaded(cwd) }).enabled,
1249
+ // Навыки гейтятся тем же контекстом проекта, что и инструменты: в
1250
+ // worktree включённость наследуется от основного checkout.
1251
+ resolveConfig: (cwd) => projectContextForCwd(cwd ?? process.cwd()).effective.config,
1252
+ isEnabled: (cwd) => projectContextForCwd(cwd ?? process.cwd()).activation.enabled,
813
1253
  logger: ctx.logger,
814
1254
  });
815
1255
  });
@@ -827,10 +1267,17 @@ export async function apply(ctx, config) {
827
1267
  const agent = context?.agent;
828
1268
  // В выключенном проекте секция пустая: renderPrompt выбрасывает её,
829
1269
  // поэтому в промпте не остаётся ни строчки про 1С.
830
- if (agent === undefined || !activationOf(agent).enabled) return '';
831
- const effective = effectiveFor(agent);
832
- const { roots } = normalizeWiki(effective.config?.wiki, { cwd: cwdOf(agent) });
833
- return buildRulesText(process.env, snapshot(agent), roots, effective.config?.tests);
1270
+ if (agent === undefined) return '';
1271
+ const projectContext = contextFor(agent);
1272
+ if (!projectContext.activation.enabled) return '';
1273
+ const { roots } = normalizeWiki(projectContext.effective.config?.wiki, { cwd: projectContext.cwd });
1274
+ return buildRulesText(
1275
+ process.env,
1276
+ snapshot(agent, { alive: cheapAlive }),
1277
+ roots,
1278
+ projectContext.effective.config?.tests,
1279
+ describeWorkspace(projectContext.workspace, { alive: cheapAlive }),
1280
+ );
834
1281
  },
835
1282
  });
836
1283
  });