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 +7 -0
- package/README.md +18 -17
- package/client.js +80 -3
- package/docs/TEST-2.7.9.md +28 -0
- package/package.json +2 -2
- package/plugins/dsh-agent-extensions/README.md +24 -1
- package/plugins/dsh-agent-extensions/client.js +79 -2
- package/plugins/dsh-agent-extensions/index.js +92 -6
- package/plugins/dsh-agent-extensions/lib/agents.js +141 -12
- package/plugins/dsh-agent-extensions/lib/api.js +55 -12
- package/plugins/dsh-agent-extensions/lib/runs.js +60 -0
- package/plugins/dsh-agent-extensions/lib/worktrees.js +143 -0
- package/plugins/dsh-agent-extensions/screenshots/agents-1440.png +0 -0
- package/plugins/dsh-agent-extensions/screenshots/agents-390.png +0 -0
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
|
-

|
|
8
8
|
|
|
9
9
|
## MCP: подключения без JSON
|
|
10
10
|
|
|
11
11
|
Настройки → **MCP**: пресеты GitHub, PostgreSQL и Filesystem, живые статусы, health, список tools и безопасное хранение токенов в `~/.dsh/mcp.secrets.json`. Промпты сервера открываются кнопкой **Prompts**: аргументы, запуск и копирование результата.
|
|
12
12
|
|
|
13
|
-

|
|
14
14
|
|
|
15
|
-

|
|
16
16
|
|
|
17
|
-

|
|
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
|
-

|
|
27
28
|
|
|
28
|
-

|
|
29
30
|
|
|
30
|
-
**v2.7.
|
|
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
|
-

|
|
119
120
|
|
|
120
|
-

|
|
121
122
|
|
|
122
123
|
## Скриншоты
|
|
123
124
|
|
|
124
125
|
### Skills Studio: каталог, стоимость и редактор
|
|
125
126
|
|
|
126
|
-

|
|
127
128
|
|
|
128
|
-

|
|
129
130
|
|
|
130
131
|
### Token Lab: куда уходит контекст
|
|
131
132
|
|
|
132
|
-

|
|
133
134
|
|
|
134
135
|
### Тёмная тема и мобильный
|
|
135
136
|
|
|
136
|
-

|
|
137
138
|
|
|
138
|
-

|
|
139
140
|
|
|
140
141
|
## Окно-напоминание: пиковый тариф
|
|
141
142
|
|
|
@@ -145,11 +146,11 @@ DeepSeek включает двойной тариф в пиковые часы (
|
|
|
145
146
|
- **в окне** — красная карточка: причина, границы окна в местном времени и UTC, живой отсчёт до конца и рекомендация, когда вернуться;
|
|
146
147
|
- **после окна** — карточка исчезает; состояние всегда доступно через `window.__DSH_PEAK_GUARD__.state()`.
|
|
147
148
|
|
|
148
|
-

|
|
149
150
|
|
|
150
|
-

|
|
151
152
|
|
|
152
|
-

|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|

|
|
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
|
-
|
|
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`. `
|
|
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
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
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: `
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
64
|
-
clearChildPolicy(reservedId);
|
|
134
|
+
return { text, stopReason: result?.stopReason ?? "completed", worktree: options.worktree ?? null };
|
|
135
|
+
} catch (error) {
|
|
65
136
|
clearChildPolicy(activeId);
|
|
66
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
Binary file
|
|
Binary file
|