dsh-desktop-kit 2.7.8 → 2.7.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.9 (2026-11-12)
4
+
5
+ - **Worktrees.** `project_worktree` (`list|create|remove`) создаёт `.dsh/worktrees/<slug>` на ветке `dsh/<slug>`, пишет путь в `.git/info/exclude`, отказывает в удалении грязного worktree без `force`.
6
+ - **Фоновые агенты.** `project_agent` получил `background=true`, `worktree=auto|<slug>`, `status` и `cancel`; реестр `.dsh/agents/runs.json` хранит id, ветку, статус и короткий результат.
7
+ - **Agents UI.** Секции Worktrees и «Фоновые запуски», кнопки «Обновить» и «Остановить»; адаптив 390/768/1440 без overflow/markup.
8
+ - **Тесты.** `worktrees-tests`, `background-agents-tests`, полный `npm test`.
9
+
3
10
  ## 2.7.8 (2026-11-12)
4
11
 
5
12
  - **Permissions UI.** Настройки → **Права**: эффективный preset DSH (`current`), sandbox/approval, каталог пресетов, жёсткие правила kit и защищённые пути. Страница read-only, без второго picker и без хранения approvals.
package/README.md CHANGED
@@ -4,17 +4,17 @@
4
4
 
5
5
  Набор из **16 плагинов и 30 навыков** для DeepSeek Harness 0.2.x: русский интерфейс, автопилот навыков, токен-лаборатория, студия навыков, память проектов, обмен памятью между машинами (GitHub и USB), guardrails, карта репозитория, чекпоинты, plan mode, browser QA, GitHub review, Claude Code-подобные hooks/agents/commands, перенос чатов и резервные копии профиля.
6
6
 
7
- ![DSH Desktop Kit 2.7](https://unpkg.com/dsh-desktop-kit@2.7.8/docs/images/hero.png)
7
+ ![DSH Desktop Kit 2.7](https://unpkg.com/dsh-desktop-kit@2.7.9/docs/images/hero.png)
8
8
 
9
9
  ## MCP: подключения без JSON
10
10
 
11
11
  Настройки → **MCP**: пресеты GitHub, PostgreSQL и Filesystem, живые статусы, health, список tools и безопасное хранение токенов в `~/.dsh/mcp.secrets.json`. Промпты сервера открываются кнопкой **Prompts**: аргументы, запуск и копирование результата.
12
12
 
13
- ![MCP Settings](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-mcp-manager/screenshots/settings-1440.png)
13
+ ![MCP Settings](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-mcp-manager/screenshots/settings-1440.png)
14
14
 
15
- ![MCP на 390px](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-mcp-manager/screenshots/settings-390.png)
15
+ ![MCP на 390px](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-mcp-manager/screenshots/settings-390.png)
16
16
 
17
- ![Добавление MCP-сервера](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-mcp-manager/screenshots/modal-390.png)
17
+ ![Добавление MCP-сервера](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-mcp-manager/screenshots/modal-390.png)
18
18
 
19
19
  ## Права и субагенты
20
20
 
@@ -22,12 +22,13 @@
22
22
  - **`.dsh/agents/*.md`** реально ограничивает `tools`, `permission` и `model` ребёнка; reviewer не может править, researcher — писать.
23
23
  - **Approval** — штатная карточка DSH: Allow once / Always allow / Reject.
24
24
  - **`.dsh/`** создаётся автоматически: agents, commands, rules, hooks, verify, mcp, PROJECT.md.
25
+ - **Worktrees и фоновые агенты.** `project_worktree` создаёт ветку `dsh/<slug>` и отдельную папку `.dsh/worktrees/<slug>`; `project_agent ... worktree=auto background=true` запускает агента в изоляции, а страница **Агенты** показывает worktrees и очередь запусков с кнопкой «Остановить».
25
26
 
26
- ![Агенты проекта](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-agent-extensions/screenshots/agents-1440.png)
27
+ ![Агенты проекта](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-agent-extensions/screenshots/agents-1440.png)
27
28
 
28
- ![Права и ограничения](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-guardrails/screenshots/permissions-1440.png)
29
+ ![Права и ограничения](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-guardrails/screenshots/permissions-1440.png)
29
30
 
30
- **v2.7.8** · MIT · проверено на DSH `0.2.1-rc.5` и `0.2.2-rc.1` (Windows 11) · без внешних зависимостей и телеметрии
31
+ **v2.7.9** · MIT · проверено на DSH `0.2.1-rc.5` и `0.2.2-rc.1` (Windows 11) · без внешних зависимостей и телеметрии
31
32
 
32
33
  **План развития:** [docs/ROADMAP-3.0.md](docs/ROADMAP-3.0.md) — Memory Fabric, семантическое
33
34
  слияние, локальный индекс памяти, шифрованные переносы, Memory Hub и npm trusted publishing.
@@ -115,27 +116,27 @@
115
116
  - перед каждой синхронизацией проверяется, что репозиторий всё ещё приватный;
116
117
  - секреты и PII вычищаются, домашние пути заменяются токеном `%DSH_HOME%`; LLM не вызывается — **0 токенов**.
117
118
 
118
- ![Настройки памяти проектов](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-memory-bridge/docs/images/settings-light.png)
119
+ ![Настройки памяти проектов](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-memory-bridge/docs/images/settings-light.png)
119
120
 
120
- ![Конфликты досье](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-memory-bridge/docs/images/conflicts.png)
121
+ ![Конфликты досье](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-memory-bridge/docs/images/conflicts.png)
121
122
 
122
123
  ## Скриншоты
123
124
 
124
125
  ### Skills Studio: каталог, стоимость и редактор
125
126
 
126
- ![Каталог навыков](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-gkb-ru-ui/screenshots/02-skills-1440-light.png)
127
+ ![Каталог навыков](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-gkb-ru-ui/screenshots/02-skills-1440-light.png)
127
128
 
128
- ![Редактор навыка](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-gkb-ru-ui/screenshots/03-skills-editor-1440-light.png)
129
+ ![Редактор навыка](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-gkb-ru-ui/screenshots/03-skills-editor-1440-light.png)
129
130
 
130
131
  ### Token Lab: куда уходит контекст
131
132
 
132
- ![Токен-лаборатория](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-token-lab/screenshots/01-token-lab-1440-light.png)
133
+ ![Токен-лаборатория](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-token-lab/screenshots/01-token-lab-1440-light.png)
133
134
 
134
135
  ### Тёмная тема и мобильный
135
136
 
136
- ![Тёмная тема](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-gkb-ru-ui/screenshots/04-skills-1440-dark.png)
137
+ ![Тёмная тема](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-gkb-ru-ui/screenshots/04-skills-1440-dark.png)
137
138
 
138
- ![Каталог навыков на 390 px](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-gkb-ru-ui/screenshots/05-skills-390-light.png)
139
+ ![Каталог навыков на 390 px](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-gkb-ru-ui/screenshots/05-skills-390-light.png)
139
140
 
140
141
  ## Окно-напоминание: пиковый тариф
141
142
 
@@ -145,11 +146,11 @@ DeepSeek включает двойной тариф в пиковые часы (
145
146
  - **в окне** — красная карточка: причина, границы окна в местном времени и UTC, живой отсчёт до конца и рекомендация, когда вернуться;
146
147
  - **после окна** — карточка исчезает; состояние всегда доступно через `window.__DSH_PEAK_GUARD__.state()`.
147
148
 
148
- ![Окно-напоминание: светлая тема](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-peak-guard/screenshots/03-peak-card-1440-light.png)
149
+ ![Окно-напоминание: светлая тема](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-peak-guard/screenshots/03-peak-card-1440-light.png)
149
150
 
150
- ![Окно-напоминание: тёмная тема](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-peak-guard/screenshots/04-peak-card-1440-dark.png)
151
+ ![Окно-напоминание: тёмная тема](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-peak-guard/screenshots/04-peak-card-1440-dark.png)
151
152
 
152
- ![Окно-напоминание на 390 px](https://unpkg.com/dsh-desktop-kit@2.7.8/plugins/dsh-peak-guard/screenshots/05-peak-card-390-light.png)
153
+ ![Окно-напоминание на 390 px](https://unpkg.com/dsh-desktop-kit@2.7.9/plugins/dsh-peak-guard/screenshots/05-peak-card-390-light.png)
153
154
 
154
155
  Подробности и настройки — [plugins/dsh-peak-guard/README.md](plugins/dsh-peak-guard/README.md).
155
156
 
package/client.js CHANGED
@@ -7411,7 +7411,25 @@ var(--dsw-alias-bg-layer-1,#fff);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff
7411
7411
  .ag-error{padding:8px 10px;border:1px solid rgba(159,18,57,.16);border-radius:9px;background:rgba(159,18,57,.06);color:#9f1239;font-size:12px;line-height:1.4}
7412
7412
  .ag-loading{color:var(--ag-muted);font-size:13px;padding:18px 0}
7413
7413
  .ag-note{margin:0;color:var(--ag-muted);font-size:11px;line-height:1.45}
7414
- @media(max-width:860px){.ag-grid{grid-template-columns:1fr}.ag-stats{grid-template-columns:1fr 1fr}}
7414
+ .ag-btn{font:inherit;font-size:11px;font-weight:650;border:1px solid var(--ag-line);background:var(--ag-panel);color:var(--ag-text);border-radius:8px;padding:6px 9px;cursor:pointer;min-height:30px}
7415
+ .ag-btn:hover{border-color:rgba(15,118,110,.35);background:rgba(15,118,110,.05)}
7416
+ .ag-btn:focus-visible{outline:2px solid var(--ag-accent);outline-offset:2px}
7417
+ .ag-section{display:flex;flex-direction:column;gap:8px;min-width:0}
7418
+ .ag-section h3{margin:0;font-size:15px;letter-spacing:-.01em}
7419
+ .ag-hint{margin:0;color:var(--ag-muted);font-size:11.5px;line-height:1.45}
7420
+ .ag-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;min-width:0}
7421
+ .ag-item{border:1px solid var(--ag-line);border-radius:12px;background:var(--ag-panel);padding:11px 12px;min-width:0;display:flex;flex-direction:column;gap:5px}
7422
+ .ag-item-top{display:flex;justify-content:space-between;gap:8px;align-items:baseline;min-width:0}
7423
+ .ag-item-top code{font:inherit;font-weight:650;font-size:12px;color:var(--ag-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
7424
+ .ag-item-top span{flex:none;font-size:9.5px;font-weight:750;letter-spacing:.06em;text-transform:uppercase;color:var(--ag-muted)}
7425
+ .ag-item p{margin:0;color:var(--ag-muted);font-size:11px;line-height:1.4;overflow-wrap:anywhere}
7426
+ .ag-item p.ag-task{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
7427
+ .ag-status-running{color:#9a3412;border-color:rgba(154,52,18,.22);background:rgba(154,52,18,.06)}
7428
+ .ag-status-completed{color:#0f766e;border-color:rgba(15,118,110,.22);background:rgba(15,118,110,.06)}
7429
+ .ag-status-failed,.ag-status-canceled{color:#9f1239;border-color:rgba(159,18,57,.18);background:rgba(159,18,57,.05)}
7430
+ .ag-mini-empty{padding:12px;border:1px dashed var(--ag-line);border-radius:10px;color:var(--ag-muted);font-size:11.5px;line-height:1.45;background:linear-gradient(160deg,var(--ag-soft),transparent 80%)}
7431
+ .ag-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:2px}
7432
+ @media(max-width:860px){.ag-grid{grid-template-columns:1fr}.ag-stats{grid-template-columns:1fr 1fr}.ag-items{grid-template-columns:1fr}}
7415
7433
  @media(max-width:520px){.ag-stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.ag-stat{padding:10px 12px}.ag-stat b{font-size:20px}.ag-stat span{font-size:9px;letter-spacing:.04em}.ag-root{gap:16px}}
7416
7434
  @media(prefers-reduced-motion:reduce){.ag-card{transition:none}}
7417
7435
  `;
@@ -7459,6 +7477,7 @@ var(--dsw-alias-bg-layer-1,#fff);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff
7459
7477
 
7460
7478
  function AgentsSection(props) {
7461
7479
  const [state, setState] = useState({ loading: true, error: null, data: null });
7480
+ const [canceling, setCanceling] = useState("");
7462
7481
  const useSessions = props && props.useSessions;
7463
7482
  const useWorkspaces = props && props.useWorkspaces;
7464
7483
  const sessionId = useSessions ? useSessions(currentSessionSelector) : undefined;
@@ -7468,6 +7487,16 @@ var(--dsw-alias-bg-layer-1,#fff);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff
7468
7487
  ?? workspaces.find((item) => Array.isArray(item.sessionIds) && item.sessionIds.length > 0)
7469
7488
  ?? workspaces[0];
7470
7489
  const cwd = typeof workspace?.path === "string" ? workspace.path : undefined;
7490
+
7491
+ const load = async () => {
7492
+ try {
7493
+ const data = await call(window.__DSH_AGENTS_CTX__, "list", { sessionId, cwd });
7494
+ setState({ loading: false, error: null, data });
7495
+ } catch (error) {
7496
+ setState({ loading: false, error: fallbackError(error), data: null });
7497
+ }
7498
+ };
7499
+
7471
7500
  useEffect(() => {
7472
7501
  let cancelled = false;
7473
7502
  (async () => {
@@ -7480,7 +7509,22 @@ var(--dsw-alias-bg-layer-1,#fff);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff
7480
7509
  })();
7481
7510
  return () => { cancelled = true; };
7482
7511
  }, [sessionId, cwd]);
7512
+
7513
+ const cancel = async (id) => {
7514
+ setCanceling(id);
7515
+ try {
7516
+ await call(window.__DSH_AGENTS_CTX__, "cancel", { sessionId, cwd, id });
7517
+ await load();
7518
+ } catch (error) {
7519
+ setState((current) => ({ ...current, error: fallbackError(error) }));
7520
+ } finally {
7521
+ setCanceling("");
7522
+ }
7523
+ };
7524
+
7483
7525
  const agents = state.data?.agents ?? [];
7526
+ const worktrees = state.data?.worktrees ?? [];
7527
+ const runs = state.data?.runs ?? [];
7484
7528
  const readOnly = agents.filter((agent) => agent.permission === "read-only").length;
7485
7529
  const models = new Set(agents.map((agent) => agent.model).filter(Boolean)).size;
7486
7530
  return h("section", { className: "ag-root", "aria-label": "Агенты проекта" },
@@ -7488,8 +7532,9 @@ var(--dsw-alias-bg-layer-1,#fff);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff
7488
7532
  h("div", null,
7489
7533
  h("p", { className: "ag-eyebrow" }, "Agents · project roles"),
7490
7534
  h("h2", { className: "ag-title" }, "Агенты"),
7491
- h("p", { className: "ag-sub" }, "Проектные роли из `.dsh/agents/*.md`. Их `tools`, `permission` и `model` применяются к дочернему агенту при запуске `project_agent`."),
7535
+ h("p", { className: "ag-sub" }, "Проектные роли из `.dsh/agents/*.md`. Их `tools`, `permission` и `model` применяются к дочернему агенту при запуске `project_agent`. Фоновые запуски и worktrees — здесь же."),
7492
7536
  ),
7537
+ h("button", { className: "ag-btn", type: "button", onClick: () => void load() }, "Обновить"),
7493
7538
  ),
7494
7539
  state.error ? h("div", { className: "ag-error", role: "alert" }, state.error) : null,
7495
7540
  h("div", { className: "ag-stats" },
@@ -7504,6 +7549,38 @@ var(--dsw-alias-bg-layer-1,#fff);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff
7504
7549
  h("pre", { className: "ag-code" }, "---\nname: reviewer\ndescription: Ревью диффа\nmodel: deepseek-flash\ntools: read, grep\npermission: read-only\n---\n\nТы ревьюер. Верни только findings."),
7505
7550
  ) : null,
7506
7551
  agents.length > 0 ? h("div", { className: "ag-grid" }, agents.map((agent) => h(AgentCard, { key: agent.name, agent }))) : null,
7552
+ h("div", { className: "ag-section" },
7553
+ h("h3", null, "Worktrees"),
7554
+ h("p", { className: "ag-hint" }, "Изолированные рабочие копии: `project_worktree create` или `project_agent` с `worktree=auto`."),
7555
+ state.data?.worktreeError ? h("div", { className: "ag-error", role: "alert" }, state.data.worktreeError) : null,
7556
+ worktrees.length > 0 ? h("div", { className: "ag-items" }, worktrees.map((tree) => h("article", { className: "ag-item", key: tree.id },
7557
+ h("div", { className: "ag-item-top" },
7558
+ h("code", { title: tree.branch || tree.id }, tree.branch || tree.id),
7559
+ h("span", { className: tree.dirty ? "ag-status-running" : "ag-status-completed" }, tree.dirty ? "dirty" : "clean"),
7560
+ ),
7561
+ h("p", { title: tree.path }, tree.path),
7562
+ ))) : h("div", { className: "ag-mini-empty" }, "Worktree пока нет. Создайте через инструмент `project_worktree`."),
7563
+ ),
7564
+ h("div", { className: "ag-section" },
7565
+ h("h3", null, "Фоновые запуски"),
7566
+ h("p", { className: "ag-hint" }, "`project_agent` с `background=true` возвращает id сразу; статус сохраняется в `.dsh/agents/runs.json`."),
7567
+ runs.length > 0 ? h("div", { className: "ag-items" }, runs.slice().reverse().map((run) => h("article", { className: "ag-item", key: run.id },
7568
+ h("div", { className: "ag-item-top" },
7569
+ h("code", { title: run.id }, run.name),
7570
+ h("span", { className: `ag-status-${run.status}` }, run.status),
7571
+ ),
7572
+ run.task ? h("p", { className: "ag-task", title: run.task }, run.task) : null,
7573
+ h("div", { className: "ag-actions" },
7574
+ run.worktree?.branch ? h("p", null, run.worktree.branch) : null,
7575
+ run.status === "running" ? h("button", {
7576
+ className: "ag-btn",
7577
+ type: "button",
7578
+ disabled: canceling === run.id,
7579
+ onClick: () => void cancel(run.id),
7580
+ }, canceling === run.id ? "Останавливаем…" : "Остановить") : null,
7581
+ ),
7582
+ ))) : h("div", { className: "ag-mini-empty" }, "Фоновых запусков нет."),
7583
+ ),
7507
7584
  h("p", { className: "ag-note" }, state.data?.project ? `Проект: ${state.data.project}` : "Проект определяется по текущей рабочей папке."),
7508
7585
  );
7509
7586
  }
@@ -7751,7 +7828,7 @@ var(--dsw-alias-bg-layer-1,#fff);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff
7751
7828
  if (__kitErrors.length <= 5) console.error("[dsh-desktop-kit] registration failed:", label, message);
7752
7829
  };
7753
7830
  try {
7754
- window.__DSH_DESKTOP_KIT__ = { version: "2.7.8", parts: __partNames, errors: __kitErrors, ready: false };
7831
+ window.__DSH_DESKTOP_KIT__ = { version: "2.7.9", parts: __partNames, errors: __kitErrors, ready: false };
7755
7832
  } catch {}
7756
7833
  const __inject = [];
7757
7834
  for (const __part of __parts) {
@@ -0,0 +1,28 @@
1
+ # DSH Desktop Kit 2.7.9 — тест
2
+
3
+ Дата: 2026-11-12.
4
+
5
+ ## Worktrees
6
+
7
+ - `project_worktree action=list|create|remove`.
8
+ - `.dsh/worktrees/<slug>` + branch `dsh/<slug>`; путь исключён через `.git/info/exclude`.
9
+ - Грязный worktree не удаляется без `force`; ветка остаётся.
10
+ - Тест: `tools/worktrees-tests.mjs` (создание, dirty, force remove, ветка).
11
+
12
+ ## Фоновые агенты
13
+
14
+ - `project_agent background=true` возвращает id сразу.
15
+ - `.dsh/agents/runs.json` — durable-реестр (id, childId, branch, status, preview).
16
+ - `status` и `cancel`; отмена через `subagents.interrupt` + dispose.
17
+ - Тест: `tools/background-agents-tests.mjs` (running → completed, cancel).
18
+
19
+ ## UI
20
+
21
+ - Настройки → Агенты: Worktrees и Фоновые запуски.
22
+ - browser_qa audit 390/768/1440 — overflow 0, console 0, markup 0.
23
+ - Скриншоты обновлены.
24
+
25
+ ## Полный прогон
26
+
27
+ - `npm test` — exit 0.
28
+ - `build:client` — 9 частей; `check:client` — OK.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-desktop-kit",
3
- "version": "2.7.8",
3
+ "version": "2.7.9",
4
4
  "description": "DeepSeek Harness desktop kit 2.7: 16 plugins and 30 skills — self-applying skills, token lab, Skills Studio, project memory, GitHub and USB memory transfer, guardrails, repo map, checkpoints, plan mode, browser QA, GitHub review, Claude Code-like hooks/agents/commands, Russian UI, peak guard, chat move and profile transfer.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -74,7 +74,7 @@
74
74
  "build:client": "node tools/build-client.mjs",
75
75
  "build:skills": "node tools/build-skill-manifest.mjs",
76
76
  "check:client": "node tools/check-client.mjs client.js",
77
- "test": "node tools/smoke-tests.mjs && node tools/memory-tests.mjs && node tools/skills-sync-tests.mjs && node tools/kit-upgrade-tests.mjs && node plugins/dsh-profile-transfer/test/transfer.test.mjs && node --test plugins/dsh-memory-bridge/test/bridge.test.mjs plugins/dsh-memory-bridge/test/github.test.mjs plugins/dsh-memory-bridge/test/merge.test.mjs plugins/dsh-memory-bridge/test/user-specific.test.mjs && node tools/repo-map-tests.mjs && node tools/checkpoints-tests.mjs && node tools/plan-mode-tests.mjs && node tools/browser-qa-tests.mjs && node tools/ui-gate-tests.mjs && node tools/agent-extensions-tests.mjs && node tools/mcp-manager-tests.mjs && node tools/github-review-tests.mjs && node tools/permissions-ui-tests.mjs && node plugins/dsh-guardrails/tools/guardrails-tests.mjs",
77
+ "test": "node tools/smoke-tests.mjs && node tools/memory-tests.mjs && node tools/skills-sync-tests.mjs && node tools/kit-upgrade-tests.mjs && node plugins/dsh-profile-transfer/test/transfer.test.mjs && node --test plugins/dsh-memory-bridge/test/bridge.test.mjs plugins/dsh-memory-bridge/test/github.test.mjs plugins/dsh-memory-bridge/test/merge.test.mjs plugins/dsh-memory-bridge/test/user-specific.test.mjs && node tools/repo-map-tests.mjs && node tools/checkpoints-tests.mjs && node tools/plan-mode-tests.mjs && node tools/browser-qa-tests.mjs && node tools/ui-gate-tests.mjs && node tools/agent-extensions-tests.mjs && node tools/mcp-manager-tests.mjs && node tools/github-review-tests.mjs && node tools/worktrees-tests.mjs && node tools/background-agents-tests.mjs && node tools/permissions-ui-tests.mjs && node plugins/dsh-guardrails/tools/guardrails-tests.mjs",
78
78
  "skills:cost": "node tools/skill-cost.mjs",
79
79
  "skills:check": "node tools/skill-cost.mjs --check",
80
80
  "release": "node tools/release.mjs"
@@ -4,7 +4,7 @@ Claude Code-like project extensions for DSH, all opt-in and project-local.
4
4
 
5
5
  ## Settings → Агенты
6
6
 
7
- Экран показывает все `.dsh/agents/*.md`: описание, enforced permission, tools и модель.
7
+ Экран показывает все `.dsh/agents/*.md`: описание, enforced permission, tools и модель. Ниже — активные **worktrees** и **фоновые запуски** с кнопкой «Остановить».
8
8
 
9
9
  ![Агенты проекта](screenshots/agents-1440.png)
10
10
 
@@ -68,6 +68,29 @@ project_agent action=run name=reviewer task="Проверь текущий ди
68
68
 
69
69
  The child runs in its own context through the DSH `subagents` service and returns only its final text.
70
70
 
71
+ Background and isolated runs:
72
+
73
+ ```text
74
+ project_agent action=run name=reviewer task="..." background=true
75
+ project_agent action=status
76
+ project_agent action=cancel id=<run-id>
77
+
78
+ project_agent action=run name=reviewer task="..." worktree=auto
79
+ project_worktree action=list
80
+ project_worktree action=create slug=feature-x base=HEAD
81
+ project_worktree action=remove slug=feature-x
82
+ ```
83
+
84
+ A background run returns immediately with a durable id and writes a small record
85
+ to `.dsh/agents/runs.json` (status, worktree/branch, short result preview; no
86
+ transcript). The child keeps running after the parent turn. `cancel` interrupts
87
+ the live child and marks the record canceled.
88
+
89
+ Worktrees live in `.dsh/worktrees/<slug>` on branch `dsh/<slug>`. The path is
90
+ added to `.git/info/exclude`, not to the tracked `.gitignore`, so the main
91
+ worktree stays clean. `remove` refuses a dirty worktree unless `force=true`;
92
+ the branch is kept for review.
93
+
71
94
  `tools`, `permission` and `model` are enforced, not just documentation:
72
95
 
73
96
  - `tools: read, grep` — the child receives a deny for any other tool;
@@ -45,7 +45,25 @@ window.__ModuleLoader__.load({
45
45
  .ag-error{padding:8px 10px;border:1px solid rgba(159,18,57,.16);border-radius:9px;background:rgba(159,18,57,.06);color:#9f1239;font-size:12px;line-height:1.4}
46
46
  .ag-loading{color:var(--ag-muted);font-size:13px;padding:18px 0}
47
47
  .ag-note{margin:0;color:var(--ag-muted);font-size:11px;line-height:1.45}
48
- @media(max-width:860px){.ag-grid{grid-template-columns:1fr}.ag-stats{grid-template-columns:1fr 1fr}}
48
+ .ag-btn{font:inherit;font-size:11px;font-weight:650;border:1px solid var(--ag-line);background:var(--ag-panel);color:var(--ag-text);border-radius:8px;padding:6px 9px;cursor:pointer;min-height:30px}
49
+ .ag-btn:hover{border-color:rgba(15,118,110,.35);background:rgba(15,118,110,.05)}
50
+ .ag-btn:focus-visible{outline:2px solid var(--ag-accent);outline-offset:2px}
51
+ .ag-section{display:flex;flex-direction:column;gap:8px;min-width:0}
52
+ .ag-section h3{margin:0;font-size:15px;letter-spacing:-.01em}
53
+ .ag-hint{margin:0;color:var(--ag-muted);font-size:11.5px;line-height:1.45}
54
+ .ag-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;min-width:0}
55
+ .ag-item{border:1px solid var(--ag-line);border-radius:12px;background:var(--ag-panel);padding:11px 12px;min-width:0;display:flex;flex-direction:column;gap:5px}
56
+ .ag-item-top{display:flex;justify-content:space-between;gap:8px;align-items:baseline;min-width:0}
57
+ .ag-item-top code{font:inherit;font-weight:650;font-size:12px;color:var(--ag-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
58
+ .ag-item-top span{flex:none;font-size:9.5px;font-weight:750;letter-spacing:.06em;text-transform:uppercase;color:var(--ag-muted)}
59
+ .ag-item p{margin:0;color:var(--ag-muted);font-size:11px;line-height:1.4;overflow-wrap:anywhere}
60
+ .ag-item p.ag-task{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
61
+ .ag-status-running{color:#9a3412;border-color:rgba(154,52,18,.22);background:rgba(154,52,18,.06)}
62
+ .ag-status-completed{color:#0f766e;border-color:rgba(15,118,110,.22);background:rgba(15,118,110,.06)}
63
+ .ag-status-failed,.ag-status-canceled{color:#9f1239;border-color:rgba(159,18,57,.18);background:rgba(159,18,57,.05)}
64
+ .ag-mini-empty{padding:12px;border:1px dashed var(--ag-line);border-radius:10px;color:var(--ag-muted);font-size:11.5px;line-height:1.45;background:linear-gradient(160deg,var(--ag-soft),transparent 80%)}
65
+ .ag-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:2px}
66
+ @media(max-width:860px){.ag-grid{grid-template-columns:1fr}.ag-stats{grid-template-columns:1fr 1fr}.ag-items{grid-template-columns:1fr}}
49
67
  @media(max-width:520px){.ag-stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.ag-stat{padding:10px 12px}.ag-stat b{font-size:20px}.ag-stat span{font-size:9px;letter-spacing:.04em}.ag-root{gap:16px}}
50
68
  @media(prefers-reduced-motion:reduce){.ag-card{transition:none}}
51
69
  `;
@@ -93,6 +111,7 @@ window.__ModuleLoader__.load({
93
111
 
94
112
  function AgentsSection(props) {
95
113
  const [state, setState] = useState({ loading: true, error: null, data: null });
114
+ const [canceling, setCanceling] = useState("");
96
115
  const useSessions = props && props.useSessions;
97
116
  const useWorkspaces = props && props.useWorkspaces;
98
117
  const sessionId = useSessions ? useSessions(currentSessionSelector) : undefined;
@@ -102,6 +121,16 @@ window.__ModuleLoader__.load({
102
121
  ?? workspaces.find((item) => Array.isArray(item.sessionIds) && item.sessionIds.length > 0)
103
122
  ?? workspaces[0];
104
123
  const cwd = typeof workspace?.path === "string" ? workspace.path : undefined;
124
+
125
+ const load = async () => {
126
+ try {
127
+ const data = await call(window.__DSH_AGENTS_CTX__, "list", { sessionId, cwd });
128
+ setState({ loading: false, error: null, data });
129
+ } catch (error) {
130
+ setState({ loading: false, error: fallbackError(error), data: null });
131
+ }
132
+ };
133
+
105
134
  useEffect(() => {
106
135
  let cancelled = false;
107
136
  (async () => {
@@ -114,7 +143,22 @@ window.__ModuleLoader__.load({
114
143
  })();
115
144
  return () => { cancelled = true; };
116
145
  }, [sessionId, cwd]);
146
+
147
+ const cancel = async (id) => {
148
+ setCanceling(id);
149
+ try {
150
+ await call(window.__DSH_AGENTS_CTX__, "cancel", { sessionId, cwd, id });
151
+ await load();
152
+ } catch (error) {
153
+ setState((current) => ({ ...current, error: fallbackError(error) }));
154
+ } finally {
155
+ setCanceling("");
156
+ }
157
+ };
158
+
117
159
  const agents = state.data?.agents ?? [];
160
+ const worktrees = state.data?.worktrees ?? [];
161
+ const runs = state.data?.runs ?? [];
118
162
  const readOnly = agents.filter((agent) => agent.permission === "read-only").length;
119
163
  const models = new Set(agents.map((agent) => agent.model).filter(Boolean)).size;
120
164
  return h("section", { className: "ag-root", "aria-label": "Агенты проекта" },
@@ -122,8 +166,9 @@ window.__ModuleLoader__.load({
122
166
  h("div", null,
123
167
  h("p", { className: "ag-eyebrow" }, "Agents · project roles"),
124
168
  h("h2", { className: "ag-title" }, "Агенты"),
125
- h("p", { className: "ag-sub" }, "Проектные роли из `.dsh/agents/*.md`. Их `tools`, `permission` и `model` применяются к дочернему агенту при запуске `project_agent`."),
169
+ h("p", { className: "ag-sub" }, "Проектные роли из `.dsh/agents/*.md`. Их `tools`, `permission` и `model` применяются к дочернему агенту при запуске `project_agent`. Фоновые запуски и worktrees — здесь же."),
126
170
  ),
171
+ h("button", { className: "ag-btn", type: "button", onClick: () => void load() }, "Обновить"),
127
172
  ),
128
173
  state.error ? h("div", { className: "ag-error", role: "alert" }, state.error) : null,
129
174
  h("div", { className: "ag-stats" },
@@ -138,6 +183,38 @@ window.__ModuleLoader__.load({
138
183
  h("pre", { className: "ag-code" }, "---\nname: reviewer\ndescription: Ревью диффа\nmodel: deepseek-flash\ntools: read, grep\npermission: read-only\n---\n\nТы ревьюер. Верни только findings."),
139
184
  ) : null,
140
185
  agents.length > 0 ? h("div", { className: "ag-grid" }, agents.map((agent) => h(AgentCard, { key: agent.name, agent }))) : null,
186
+ h("div", { className: "ag-section" },
187
+ h("h3", null, "Worktrees"),
188
+ h("p", { className: "ag-hint" }, "Изолированные рабочие копии: `project_worktree create` или `project_agent` с `worktree=auto`."),
189
+ state.data?.worktreeError ? h("div", { className: "ag-error", role: "alert" }, state.data.worktreeError) : null,
190
+ worktrees.length > 0 ? h("div", { className: "ag-items" }, worktrees.map((tree) => h("article", { className: "ag-item", key: tree.id },
191
+ h("div", { className: "ag-item-top" },
192
+ h("code", { title: tree.branch || tree.id }, tree.branch || tree.id),
193
+ h("span", { className: tree.dirty ? "ag-status-running" : "ag-status-completed" }, tree.dirty ? "dirty" : "clean"),
194
+ ),
195
+ h("p", { title: tree.path }, tree.path),
196
+ ))) : h("div", { className: "ag-mini-empty" }, "Worktree пока нет. Создайте через инструмент `project_worktree`."),
197
+ ),
198
+ h("div", { className: "ag-section" },
199
+ h("h3", null, "Фоновые запуски"),
200
+ h("p", { className: "ag-hint" }, "`project_agent` с `background=true` возвращает id сразу; статус сохраняется в `.dsh/agents/runs.json`."),
201
+ runs.length > 0 ? h("div", { className: "ag-items" }, runs.slice().reverse().map((run) => h("article", { className: "ag-item", key: run.id },
202
+ h("div", { className: "ag-item-top" },
203
+ h("code", { title: run.id }, run.name),
204
+ h("span", { className: `ag-status-${run.status}` }, run.status),
205
+ ),
206
+ run.task ? h("p", { className: "ag-task", title: run.task }, run.task) : null,
207
+ h("div", { className: "ag-actions" },
208
+ run.worktree?.branch ? h("p", null, run.worktree.branch) : null,
209
+ run.status === "running" ? h("button", {
210
+ className: "ag-btn",
211
+ type: "button",
212
+ disabled: canceling === run.id,
213
+ onClick: () => void cancel(run.id),
214
+ }, canceling === run.id ? "Останавливаем…" : "Остановить") : null,
215
+ ),
216
+ ))) : h("div", { className: "ag-mini-empty" }, "Фоновых запусков нет."),
217
+ ),
141
218
  h("p", { className: "ag-note" }, state.data?.project ? `Проект: ${state.data.project}` : "Проект определяется по текущей рабочей папке."),
142
219
  );
143
220
  }
@@ -12,6 +12,8 @@ import path from "node:path";
12
12
  import { registerProjectHooks } from "./lib/hooks.js";
13
13
  import { createCheckpoint } from "./lib/checkpoints.js";
14
14
  import { runProjectAgent } from "./lib/agents.js";
15
+ import { cancelRun, listRuns } from "./lib/agents.js";
16
+ import { createWorktree, removeWorktree, worktreeSummary } from "./lib/worktrees.js";
15
17
  import { registerChildPolicyHook } from "./lib/policy.js";
16
18
  import { registerCommands } from "./lib/commands.js";
17
19
  import { registerAgentApi } from "./lib/api.js";
@@ -21,8 +23,13 @@ import { findDshDir, loadAgents, projectFor } from "./lib/project.js";
21
23
  export const inject = ["tools", "systemPrompt"];
22
24
 
23
25
  const TOOL = "project_agent";
26
+ const WORKTREE_TOOL = "project_worktree";
24
27
  const AUTO_CHECKPOINT_MS = 10 * 60_000;
25
28
 
29
+ function safeSlug(name) {
30
+ return String(name ?? "agent").toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 30) || "agent";
31
+ }
32
+
26
33
  function projectFromAgent(agent) {
27
34
  const cwd = agent?.session?.header?.cwd;
28
35
  return projectFor(cwd);
@@ -32,14 +39,17 @@ function toolDefinition(ctx) {
32
39
  return {
33
40
  name: TOOL,
34
41
  description:
35
- "Run or list project-local custom agents from `.dsh/agents/*.md`. `list` shows names/descriptions/models; `run` starts a child agent with its own context and returns only its final text. Use for repeated specialised work (reviewer, researcher, SEO, migration).",
42
+ "Run or list project-local custom agents from `.dsh/agents/*.md`. `run` starts a child agent; `background=true` returns immediately with a run id; `worktree=true|slug` runs the child in an isolated git worktree. `status` lists runs, `cancel` stops one. Use for repeated specialised work (reviewer, researcher, SEO, migration).",
36
43
  parameters: {
37
44
  type: "object",
38
45
  additionalProperties: false,
39
46
  properties: {
40
- action: { type: "string", enum: ["list", "run"], description: "list project agents or run one by name" },
47
+ action: { type: "string", enum: ["list", "run", "status", "cancel"], description: "list agents, run one, list background runs, or cancel a run" },
41
48
  name: { type: "string", description: "Agent name from `.dsh/agents/<name>.md`" },
42
49
  task: { type: "string", description: "Task/prompt for the child agent (required for run)" },
50
+ background: { type: "boolean", description: "Return immediately with a durable run id instead of waiting for the child" },
51
+ worktree: { type: "string", description: "Run in a git worktree: `auto` picks a name, or pass a lowercase slug" },
52
+ id: { type: "string", description: "Run id for status/cancel" },
43
53
  },
44
54
  required: ["action"],
45
55
  },
@@ -52,17 +62,92 @@ function toolDefinition(ctx) {
52
62
  : "Нет проектных агентов. Создайте `.dsh/agents/<name>.md`.";
53
63
  return { ok: true, text: `project_agent list:\n${text}` };
54
64
  }
55
- if (input?.action !== "run") return { ok: false, text: "project_agent: action must be list or run" };
65
+ if (input?.action === "status") {
66
+ const runs = listRuns(project.root);
67
+ const text = runs.length
68
+ ? runs.map((run) => `- ${run.id} · ${run.name} · ${run.status}${run.worktree ? ` · ${run.worktree.branch}` : ""}${run.error ? ` — ${run.error}` : ""}`).join("\n")
69
+ : "Фоновых запусков нет.";
70
+ return { ok: true, text: `project_agent status:\n${text}` };
71
+ }
72
+ if (input?.action === "cancel") {
73
+ const id = String(input?.id ?? "").trim();
74
+ if (!id) return { ok: false, text: "project_agent cancel: id is required" };
75
+ try {
76
+ const result = await cancelRun(ctx, exec, id);
77
+ return { ok: true, text: `project_agent cancel: ${id} (${result.live ? "live" : "already finished"})` };
78
+ } catch (error) {
79
+ return { ok: false, text: `project_agent cancel ${id}: ${error instanceof Error ? error.message : String(error)}` };
80
+ }
81
+ }
82
+ if (input?.action !== "run") return { ok: false, text: "project_agent: action must be list, run, status or cancel" };
56
83
  const name = String(input?.name ?? "").trim();
57
84
  const task = String(input?.task ?? "").trim();
58
85
  if (!name || !task) return { ok: false, text: "project_agent: name and task are required for run" };
59
86
  const definition = agents.find((agent) => agent.name === name);
60
87
  if (!definition) return { ok: false, text: `project_agent: unknown agent "${name}"` };
88
+ let worktree = null;
89
+ try {
90
+ const requested = input?.worktree;
91
+ if (requested) {
92
+ const auto = requested === true || String(requested).toLowerCase() === "auto";
93
+ const slug = auto ? `${safeSlug(name)}-${Date.now().toString(36).slice(-4)}` : String(requested);
94
+ worktree = createWorktree({ cwd: project.root, slug });
95
+ }
96
+ const result = await runProjectAgent(ctx, exec, definition, task, {
97
+ background: input?.background === true,
98
+ worktree,
99
+ cwd: worktree?.path,
100
+ });
101
+ if (result.background) return { ok: true, text: result.text, background: true, id: result.id, worktree };
102
+ const suffix = worktree ? `\nworktree: ${worktree.branch} → ${worktree.path}` : "";
103
+ return { ok: true, text: `project_agent ${name} (${result.stopReason}):\n${result.text}${suffix}`, worktree };
104
+ } catch (error) {
105
+ const suffix = worktree ? ` (worktree ${worktree.branch} сохранён для разбора)` : "";
106
+ return { ok: false, text: `project_agent ${name}: ${error instanceof Error ? error.message : String(error)}${suffix}` };
107
+ }
108
+ },
109
+ };
110
+ }
111
+
112
+ function worktreeToolDefinition() {
113
+ return {
114
+ name: WORKTREE_TOOL,
115
+ description:
116
+ "Manage isolated git worktrees for project agents. `list` shows branches, paths, dirty state; `create` adds `.dsh/worktrees/<slug>` on branch `dsh/<slug>`; `remove` refuses a dirty worktree unless force=true. The branch is kept after removal.",
117
+ parameters: {
118
+ type: "object",
119
+ additionalProperties: false,
120
+ properties: {
121
+ action: { type: "string", enum: ["list", "create", "remove"], description: "list, create or remove a worktree" },
122
+ slug: { type: "string", description: "Worktree id: lowercase letters, digits, hyphen" },
123
+ base: { type: "string", description: "Base ref for create; default HEAD" },
124
+ force: { type: "boolean", description: "Remove a dirty/locked worktree (data loss risk)" },
125
+ },
126
+ required: ["action"],
127
+ },
128
+ async execute(input, exec) {
129
+ const project = projectFromAgent(exec?.agent);
61
130
  try {
62
- const result = await runProjectAgent(ctx, exec, definition, task);
63
- return { ok: true, text: `project_agent ${name} (${result.stopReason}):\n${result.text}` };
131
+ if (input?.action === "list") {
132
+ const summary = worktreeSummary({ cwd: project.root });
133
+ const lines = summary.worktrees.map((item) => `- ${item.id} · ${item.branch ?? "detached"} · ${item.path}${item.dirty ? " · dirty" : ""}${item.locked ? " · locked" : ""}`);
134
+ return { ok: true, text: `project_worktree list (${summary.repo}):\n${lines.length ? lines.join("\n") : "Рабочих worktree нет."}` };
135
+ }
136
+ if (input?.action === "create") {
137
+ const slug = String(input?.slug ?? "").trim();
138
+ if (!slug) return { ok: false, text: "project_worktree create: slug is required" };
139
+ const created = createWorktree({ cwd: project.root, slug, base: input?.base });
140
+ return { ok: true, text: `project_worktree create: ${created.branch} → ${created.path}`, worktree: created };
141
+ }
142
+ if (input?.action === "remove") {
143
+ const slug = String(input?.slug ?? "").trim();
144
+ if (!slug) return { ok: false, text: "project_worktree remove: slug is required" };
145
+ const removed = removeWorktree({ cwd: project.root, id: slug, force: input?.force === true });
146
+ return { ok: true, text: `project_worktree remove: ${slug} (branch ${removed.branchKept ?? "kept"})`, worktree: removed };
147
+ }
148
+ return { ok: false, text: "project_worktree: action must be list, create or remove" };
64
149
  } catch (error) {
65
- return { ok: false, text: `project_agent ${name}: ${error instanceof Error ? error.message : String(error)}` };
150
+ return { ok: false, text: `project_worktree ${input?.action}: ${error instanceof Error ? error.message : String(error)}` };
66
151
  }
67
152
  },
68
153
  };
@@ -102,6 +187,7 @@ export function apply(ctx) {
102
187
 
103
188
  try {
104
189
  ctx.effect(() => ctx.tools.register(toolDefinition(ctx)), "agent-extensions: project_agent tool");
190
+ ctx.effect(() => ctx.tools.register(worktreeToolDefinition()), "agent-extensions: project_worktree tool");
105
191
  } catch (error) {
106
192
  ctx.logger?.warn?.(`[agent-extensions] tool registration failed: ${error instanceof Error ? error.message : String(error)}`);
107
193
  }
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Project-local custom agents: `.dsh/agents/<name>.md`.
3
3
  *
4
- * The file frontmatter describes when to use the agent; the body becomes the
5
- * child prompt. A child runs through the DSH subagents service with its own
6
- * context and returns only its final text.
4
+ * A child runs through the DSH subagents service with its own context. It can
5
+ * run in the foreground (returns final text), in a git worktree, or in the
6
+ * background with a durable run record under `.dsh/agents/runs.json`.
7
7
  */
8
8
  import crypto from "node:crypto";
9
- import { loadAgents } from "./project.js";
9
+ import { loadAgents, projectFor } from "./project.js";
10
10
  import { clearChildPolicy, setChildPolicy } from "./policy.js";
11
+ import { loadRuns, runPreview, updateRun, upsertRun } from "./runs.js";
12
+
13
+ const background = new Map();
11
14
 
12
15
  function textFromOutput(output) {
13
16
  if (!Array.isArray(output)) return "";
@@ -18,7 +21,15 @@ function textFromOutput(output) {
18
21
  .trim();
19
22
  }
20
23
 
21
- export async function runProjectAgent(ctx, exec, definition, task) {
24
+ function finishRun(projectRoot, id, patch) {
25
+ try { updateRun(projectRoot, id, patch); } catch { /* registry is best effort */ }
26
+ }
27
+
28
+ function remember(record, activation, projectRoot, signal, onAbort) {
29
+ background.set(record.id, { record, activation, projectRoot, signal, onAbort });
30
+ }
31
+
32
+ export async function runProjectAgent(ctx, exec, definition, task, options = {}) {
22
33
  const subagents = ctx.get("subagents");
23
34
  if (!subagents || typeof subagents.startActivation !== "function") {
24
35
  throw new Error("subagents service is unavailable");
@@ -26,17 +37,21 @@ export async function runProjectAgent(ctx, exec, definition, task) {
26
37
  const providers = typeof subagents.list === "function" ? subagents.list() : [];
27
38
  const provider = providers.includes("spawn") ? "spawn" : providers[0];
28
39
  if (!provider) throw new Error("no subagent provider is registered");
40
+ const project = projectFor(exec?.agent?.session?.header?.cwd);
41
+ const cwd = options.cwd ?? exec?.agent?.session?.header?.cwd;
42
+ const isBackground = options.background === true;
29
43
  const controller = new AbortController();
30
- const signal = exec?.signal;
44
+ const signal = isBackground ? undefined : exec?.signal;
31
45
  const abort = () => controller.abort(signal?.reason ?? new Error("parent aborted"));
32
46
  if (signal?.aborted) abort();
33
47
  else signal?.addEventListener?.("abort", abort, { once: true });
34
48
  const reservedId = crypto.randomUUID();
35
49
  let activeId = reservedId;
36
50
  setChildPolicy(reservedId, definition);
51
+ let activation;
37
52
  try {
38
53
  const prompt = [`<project-agent name="${definition.name}">`, definition.body, "</project-agent>", "", "Task:", String(task ?? "")].join("\n");
39
- const activation = await subagents.startActivation({
54
+ activation = await subagents.startActivation({
40
55
  provider,
41
56
  label: `agent:${definition.name}`,
42
57
  childId: reservedId,
@@ -44,7 +59,7 @@ export async function runProjectAgent(ctx, exec, definition, task) {
44
59
  prompt: [{ type: "text", text: prompt }],
45
60
  parent: exec.agent,
46
61
  signal: controller.signal,
47
- cwd: exec?.agent?.session?.header?.cwd,
62
+ cwd,
48
63
  ...(definition.model ? { agentOptions: { model: definition.model } } : {}),
49
64
  },
50
65
  signal: controller.signal,
@@ -55,18 +70,132 @@ export async function runProjectAgent(ctx, exec, definition, task) {
55
70
  setChildPolicy(activeId, definition);
56
71
  clearChildPolicy(reservedId);
57
72
  }
73
+ if (isBackground) {
74
+ const startedAt = new Date().toISOString();
75
+ const record = {
76
+ id: reservedId,
77
+ childId: activeId,
78
+ name: definition.name,
79
+ task: runPreview(task),
80
+ cwd,
81
+ worktree: options.worktree ?? null,
82
+ parentId: exec?.agent?.session?.id ?? null,
83
+ startedAt,
84
+ finishedAt: null,
85
+ status: "running",
86
+ stopReason: null,
87
+ result: "",
88
+ error: "",
89
+ };
90
+ upsertRun(project.root, record);
91
+ remember(record, activation, project.root, signal, abort);
92
+ activation.result.then(async (result) => {
93
+ const currentRecord = loadRuns(project.root).find((item) => item.id === record.id);
94
+ if (currentRecord?.status !== "canceled") {
95
+ const text = textFromOutput(result?.output);
96
+ finishRun(project.root, record.id, {
97
+ status: result?.stopReason === "completed" ? "completed" : "failed",
98
+ stopReason: result?.stopReason ?? "completed",
99
+ finishedAt: new Date().toISOString(),
100
+ result: runPreview(text),
101
+ error: result?.stopReason === "completed" ? "" : (result?.diagnostic ?? "agent failed"),
102
+ });
103
+ }
104
+ try { await activation.dispose?.(); } catch { /* best effort */ }
105
+ clearChildPolicy(activeId);
106
+ background.delete(record.id);
107
+ signal?.removeEventListener?.("abort", abort);
108
+ }).catch((error) => {
109
+ const currentRecord = loadRuns(project.root).find((item) => item.id === record.id);
110
+ if (currentRecord?.status !== "canceled") {
111
+ finishRun(project.root, record.id, {
112
+ status: "failed",
113
+ finishedAt: new Date().toISOString(),
114
+ error: error instanceof Error ? error.message : String(error),
115
+ });
116
+ }
117
+ clearChildPolicy(activeId);
118
+ background.delete(record.id);
119
+ signal?.removeEventListener?.("abort", abort);
120
+ });
121
+ return {
122
+ background: true,
123
+ id: record.id,
124
+ childId: activeId,
125
+ name: definition.name,
126
+ worktree: record.worktree,
127
+ text: `Фоновый агент ${definition.name} запущен: ${record.id}${record.worktree ? ` · ${record.worktree.branch}` : ""}`,
128
+ };
129
+ }
58
130
  const result = await activation.result;
59
131
  const text = textFromOutput(result?.output);
60
132
  await activation.dispose?.();
61
133
  if (!text) throw new Error(`project agent ${definition.name} returned no text`);
62
- return { text, stopReason: result?.stopReason ?? "completed" };
63
- } finally {
64
- clearChildPolicy(reservedId);
134
+ return { text, stopReason: result?.stopReason ?? "completed", worktree: options.worktree ?? null };
135
+ } catch (error) {
65
136
  clearChildPolicy(activeId);
66
- signal?.removeEventListener?.("abort", abort);
137
+ if (isBackground) {
138
+ const message = error instanceof Error ? error.message : String(error);
139
+ const record = {
140
+ id: reservedId,
141
+ childId: activeId,
142
+ name: definition.name,
143
+ task: runPreview(task),
144
+ cwd,
145
+ worktree: options.worktree ?? null,
146
+ parentId: exec?.agent?.session?.id ?? null,
147
+ startedAt: new Date().toISOString(),
148
+ finishedAt: new Date().toISOString(),
149
+ status: "failed",
150
+ stopReason: null,
151
+ result: "",
152
+ error: message,
153
+ };
154
+ try { upsertRun(project.root, record); } catch { /* best effort */ }
155
+ return { background: true, id: record.id, name: definition.name, error: message, text: `Фоновый агент ${definition.name} не запустился: ${message}` };
156
+ }
157
+ throw error;
158
+ } finally {
159
+ if (!isBackground) signal?.removeEventListener?.("abort", abort);
67
160
  }
68
161
  }
69
162
 
163
+ export function listRuns(projectRoot) {
164
+ const persisted = loadRuns(projectRoot);
165
+ return persisted.map((run) => {
166
+ const live = background.get(run.id);
167
+ return {
168
+ ...run,
169
+ status: live ? run.status : (run.status === "running" ? "unknown" : run.status),
170
+ live: Boolean(live),
171
+ };
172
+ });
173
+ }
174
+
175
+ export async function cancelRun(ctx, exec, id) {
176
+ const entry = background.get(String(id ?? ""));
177
+ const project = projectFor(exec?.agent?.session?.header?.cwd);
178
+ if (!entry) {
179
+ const run = loadRuns(project.root).find((item) => item.id === id);
180
+ if (!run) throw new Error(`run not found: ${id}`);
181
+ updateRun(project.root, run.id, { status: "canceled", finishedAt: new Date().toISOString(), error: "cancel requested (not live)" });
182
+ return { id: run.id, canceled: true, live: false };
183
+ }
184
+ const subagents = ctx.get("subagents");
185
+ try {
186
+ subagents?.interrupt?.(entry.record.childId, {
187
+ kind: "user",
188
+ parentSessionId: entry.record.parentId ?? exec?.agent?.session?.id,
189
+ });
190
+ } catch { /* disposal below still stops the local activation */ }
191
+ try { await entry.activation.dispose?.(); } catch { /* best effort */ }
192
+ clearChildPolicy(entry.record.childId);
193
+ background.delete(entry.record.id);
194
+ entry.signal?.removeEventListener?.("abort", entry.onAbort);
195
+ updateRun(project.root, entry.record.id, { status: "canceled", finishedAt: new Date().toISOString(), error: "canceled by user" });
196
+ return { id: entry.record.id, canceled: true, live: true };
197
+ }
198
+
70
199
  export function projectAgentSummary(projectDir, dshDir) {
71
200
  return loadAgents(projectDir, dshDir).map((agent) => ({
72
201
  name: agent.name,
@@ -2,6 +2,8 @@
2
2
  * Authenticated RPC for the Agents settings page.
3
3
  */
4
4
  import { loadAgents, projectFor } from "./project.js";
5
+ import { cancelRun, listRuns } from "./agents.js";
6
+ import { worktreeSummary } from "./worktrees.js";
5
7
 
6
8
  export const RPC_NAMESPACE = "dsh-agent-extensions";
7
9
  const ROUTE_BASE = "/api/dsh-agent-extensions";
@@ -21,7 +23,17 @@ async function envelope(request) {
21
23
  }
22
24
 
23
25
  export function registerAgentApi(ctx) {
24
- const route = {
26
+ const sessionFrom = (payload) => {
27
+ const sessions = ctx.get?.("sessions");
28
+ let session = payload?.sessionId ? sessions?.get?.(payload.sessionId) : undefined;
29
+ if (!session && typeof sessions?.list === "function") {
30
+ const live = sessions.list();
31
+ session = live.length > 0 ? live[live.length - 1] : undefined;
32
+ }
33
+ return session;
34
+ };
35
+
36
+ const listRoute = {
25
37
  path: `${ROUTE_BASE}/list`,
26
38
  methods: ["POST"],
27
39
  requestBody: "buffered",
@@ -31,12 +43,7 @@ export function registerAgentApi(ctx) {
31
43
  const value = await envelope(request);
32
44
  rpcId = value.rpcId;
33
45
  if (value.method !== `${RPC_NAMESPACE}/list`) throw new Error("endpoint/method mismatch");
34
- const sessions = ctx.get?.("sessions");
35
- let session = value.payload?.sessionId ? sessions?.get?.(value.payload.sessionId) : undefined;
36
- if (!session && typeof sessions?.list === "function") {
37
- const live = sessions.list();
38
- session = live.length > 0 ? live[live.length - 1] : undefined;
39
- }
46
+ const session = sessionFrom(value.payload);
40
47
  const project = projectFor(value.payload?.cwd || session?.header?.cwd || process.cwd());
41
48
  const agents = loadAgents(project.root, project.dsh).map((agent) => ({
42
49
  name: agent.name,
@@ -45,14 +52,50 @@ export function registerAgentApi(ctx) {
45
52
  tools: agent.tools,
46
53
  permission: agent.permission,
47
54
  }));
48
- return ok(rpcId, { project: project.root, agents });
55
+ let worktrees = [];
56
+ let worktreeError = "";
57
+ try {
58
+ worktrees = worktreeSummary({ cwd: project.root }).worktrees;
59
+ } catch (error) {
60
+ worktreeError = error instanceof Error ? error.message : String(error);
61
+ }
62
+ return ok(rpcId, {
63
+ project: project.root,
64
+ agents,
65
+ worktrees,
66
+ worktreeError,
67
+ runs: listRuns(project.root),
68
+ });
49
69
  } catch (error) {
50
70
  return fail(rpcId, error instanceof Error ? error.message : String(error));
51
71
  }
52
72
  },
53
73
  };
54
- ctx.effect(() => {
55
- const dispose = ctx.connection.fetch.register(route);
56
- return () => { try { void Promise.resolve(dispose()).catch(() => {}); } catch { /* unloading */ } };
57
- }, "agent-extensions: agents route");
74
+
75
+ const cancelRoute = {
76
+ path: `${ROUTE_BASE}/cancel`,
77
+ methods: ["POST"],
78
+ requestBody: "buffered",
79
+ fetch: async (request) => {
80
+ let rpcId = "invalid-request";
81
+ try {
82
+ const value = await envelope(request);
83
+ rpcId = value.rpcId;
84
+ if (value.method !== `${RPC_NAMESPACE}/cancel`) throw new Error("endpoint/method mismatch");
85
+ const session = sessionFrom(value.payload);
86
+ const project = projectFor(value.payload?.cwd || session?.header?.cwd || process.cwd());
87
+ const result = await cancelRun(ctx, { agent: { session } }, String(value.payload?.id ?? ""));
88
+ return ok(rpcId, { ...result, project: project.root });
89
+ } catch (error) {
90
+ return fail(rpcId, error instanceof Error ? error.message : String(error));
91
+ }
92
+ },
93
+ };
94
+
95
+ for (const route of [listRoute, cancelRoute]) {
96
+ ctx.effect(() => {
97
+ const dispose = ctx.connection.fetch.register(route);
98
+ return () => { try { void Promise.resolve(dispose()).catch(() => {}); } catch { /* unloading */ } };
99
+ }, `agent-extensions: ${route.path}`);
100
+ }
58
101
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Durable registry for background project-agent runs.
3
+ *
4
+ * A record is intentionally small: ids, worktree/branch, status and a short
5
+ * preview. Full child transcripts remain in DSH session storage.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const LIMIT = 100;
11
+ const PREVIEW = 2_000;
12
+
13
+ export function runsPath(projectRoot) {
14
+ return path.join(projectRoot, ".dsh", "agents", "runs.json");
15
+ }
16
+
17
+ function clip(value, max = PREVIEW) {
18
+ const text = String(value ?? "");
19
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
20
+ }
21
+
22
+ export function loadRuns(projectRoot) {
23
+ try {
24
+ const parsed = JSON.parse(fs.readFileSync(runsPath(projectRoot), "utf8"));
25
+ return Array.isArray(parsed) ? parsed : [];
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ export function saveRuns(projectRoot, runs) {
32
+ const file = runsPath(projectRoot);
33
+ fs.mkdirSync(path.dirname(file), { recursive: true });
34
+ const temp = `${file}.${process.pid}.tmp`;
35
+ fs.writeFileSync(temp, `${JSON.stringify(runs.slice(-LIMIT), null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
36
+ fs.renameSync(temp, file);
37
+ }
38
+
39
+ export function upsertRun(projectRoot, record) {
40
+ const runs = loadRuns(projectRoot);
41
+ const index = runs.findIndex((run) => run.id === record.id);
42
+ const next = { ...(index >= 0 ? runs[index] : {}), ...record };
43
+ if (index >= 0) runs[index] = next;
44
+ else runs.push(next);
45
+ saveRuns(projectRoot, runs);
46
+ return next;
47
+ }
48
+
49
+ export function updateRun(projectRoot, id, patch) {
50
+ const runs = loadRuns(projectRoot);
51
+ const index = runs.findIndex((run) => run.id === id);
52
+ if (index < 0) return null;
53
+ runs[index] = { ...runs[index], ...patch };
54
+ saveRuns(projectRoot, runs);
55
+ return runs[index];
56
+ }
57
+
58
+ export function runPreview(value) {
59
+ return clip(value);
60
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Safe git worktrees for project agents.
3
+ *
4
+ * One task = one branch + one working directory under `.dsh/worktrees/`.
5
+ * `.dsh/worktrees/` is excluded through `.git/info/exclude` (never the user's
6
+ * tracked .gitignore), so the main worktree stays clean.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,40}$/u;
13
+
14
+ function run(cwd, args, options = {}) {
15
+ return execFileSync("git", args, {
16
+ cwd,
17
+ encoding: "utf8",
18
+ stdio: options.allowFailure ? ["ignore", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
19
+ timeout: options.timeout ?? 30_000,
20
+ windowsHide: true,
21
+ }).trim();
22
+ }
23
+
24
+ function tryRun(cwd, args) {
25
+ try { return run(cwd, args); } catch { return null; }
26
+ }
27
+
28
+ export function gitRoot(cwd) {
29
+ if (!cwd) throw new Error("worktree: cwd is required");
30
+ const root = tryRun(cwd, ["rev-parse", "--show-toplevel"]);
31
+ if (!root) throw new Error(`worktree: not a git repository (${cwd})`);
32
+ return root;
33
+ }
34
+
35
+ export function validateSlug(value) {
36
+ const slug = String(value ?? "").trim();
37
+ if (!SLUG.test(slug)) throw new Error("worktree: slug must be 1–41 chars, lowercase letters/digits/hyphen");
38
+ return slug;
39
+ }
40
+
41
+ export function worktreesRoot(repo) {
42
+ return path.join(repo, ".dsh", "worktrees");
43
+ }
44
+
45
+ function ensureExcluded(repo) {
46
+ const exclude = path.join(repo, ".git", "info", "exclude");
47
+ try {
48
+ fs.mkdirSync(path.dirname(exclude), { recursive: true });
49
+ const current = fs.existsSync(exclude) ? fs.readFileSync(exclude, "utf8") : "";
50
+ if (!/^\.dsh\/worktrees\/?$/mu.test(current)) {
51
+ fs.appendFileSync(exclude, `${current.endsWith("\n") || current.length === 0 ? "" : "\n"}.dsh/worktrees/\n`, "utf8");
52
+ }
53
+ } catch {
54
+ /* best effort: a worktree inside .dsh is still usable */
55
+ }
56
+ }
57
+
58
+ export function listWorktrees({ cwd }) {
59
+ const repo = gitRoot(cwd);
60
+ const output = run(repo, ["worktree", "list", "--porcelain"]);
61
+ const records = [];
62
+ let current = {};
63
+ for (const line of output.split(/\r?\n/u)) {
64
+ if (line === "") {
65
+ if (current.path) records.push(current);
66
+ current = {};
67
+ continue;
68
+ }
69
+ const [key, ...rest] = line.split(" ");
70
+ if (key === "worktree") current.path = rest.join(" ");
71
+ else if (key === "HEAD") current.head = rest.join(" ");
72
+ else if (key === "branch") current.branch = rest.join(" ").replace(/^refs\/heads\//u, "");
73
+ else if (key === "detached") current.detached = true;
74
+ else if (key === "locked") current.locked = true;
75
+ else if (key === "prunable") current.prunable = true;
76
+ }
77
+ if (current.path) records.push(current);
78
+ const root = worktreesRoot(repo);
79
+ return records
80
+ .filter((record) => record.path === repo || record.path.startsWith(`${root}${path.sep}`) || record.branch?.startsWith("dsh/"))
81
+ .map((record) => {
82
+ const isMain = path.resolve(record.path) === path.resolve(repo);
83
+ const dirty = isMain ? null : (tryRun(record.path, ["status", "--porcelain"]) ?? "").length > 0;
84
+ return {
85
+ id: path.basename(record.path),
86
+ path: record.path,
87
+ branch: record.branch ?? null,
88
+ head: record.head ?? null,
89
+ detached: record.detached === true,
90
+ locked: record.locked === true,
91
+ prunable: record.prunable === true,
92
+ main: isMain,
93
+ dirty,
94
+ exists: fs.existsSync(record.path),
95
+ };
96
+ });
97
+ }
98
+
99
+ export function createWorktree({ cwd, slug, base }) {
100
+ const repo = gitRoot(cwd);
101
+ const name = validateSlug(slug);
102
+ ensureExcluded(repo);
103
+ const target = path.join(worktreesRoot(repo), name);
104
+ if (fs.existsSync(target)) throw new Error(`worktree: path already exists: ${target}`);
105
+ const baseRef = String(base ?? "HEAD").trim() || "HEAD";
106
+ if (!tryRun(repo, ["rev-parse", "--verify", `${baseRef}^{commit}`])) throw new Error(`worktree: base ref not found: ${baseRef}`);
107
+ const branch = `dsh/${name}`;
108
+ const branchExists = tryRun(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
109
+ fs.mkdirSync(path.dirname(target), { recursive: true });
110
+ if (branchExists) run(repo, ["worktree", "add", target, branch]);
111
+ else run(repo, ["worktree", "add", "-b", branch, target, baseRef]);
112
+ return {
113
+ id: name,
114
+ path: target,
115
+ branch,
116
+ base: baseRef,
117
+ repo,
118
+ createdAt: new Date().toISOString(),
119
+ };
120
+ }
121
+
122
+ export function removeWorktree({ cwd, id, force = false }) {
123
+ const repo = gitRoot(cwd);
124
+ const name = validateSlug(id);
125
+ const target = path.join(worktreesRoot(repo), name);
126
+ const record = listWorktrees({ cwd: repo }).find((item) => path.resolve(item.path) === path.resolve(target));
127
+ if (!record) throw new Error(`worktree not found: ${name}`);
128
+ if (record.dirty && !force) throw new Error(`worktree is dirty: ${name}; commit/stash or pass force=true`);
129
+ if (record.locked && !force) throw new Error(`worktree is locked: ${name}`);
130
+ if (fs.existsSync(target)) run(repo, ["worktree", "remove", ...(force ? ["--force"] : []), target]);
131
+ run(repo, ["worktree", "prune"]);
132
+ return { id: name, path: target, removed: true, branchKept: record.branch };
133
+ }
134
+
135
+ export function worktreeSummary({ cwd }) {
136
+ const list = listWorktrees({ cwd });
137
+ return {
138
+ repo: gitRoot(cwd),
139
+ root: worktreesRoot(gitRoot(cwd)),
140
+ worktrees: list.filter((item) => !item.main),
141
+ main: list.find((item) => item.main) ?? null,
142
+ };
143
+ }