dsh-desktop-kit 2.7.7 → 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 +14 -0
- package/README.md +19 -16
- package/client.js +294 -4
- package/docs/TEST-2.7.8.md +29 -0
- 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/plugins/dsh-guardrails/README.md +13 -0
- package/plugins/dsh-guardrails/client.js +220 -0
- package/plugins/dsh-guardrails/index.js +6 -0
- package/plugins/dsh-guardrails/lib/api.js +103 -0
- package/plugins/dsh-guardrails/package.json +2 -0
- package/plugins/dsh-guardrails/screenshots/permissions-1440.png +0 -0
- package/plugins/dsh-guardrails/screenshots/permissions-390.png +0 -0
|
@@ -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
|
+
}
|