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.
@@ -45,7 +45,25 @@ window.__ModuleLoader__.load({
45
45
  .ag-error{padding:8px 10px;border:1px solid rgba(159,18,57,.16);border-radius:9px;background:rgba(159,18,57,.06);color:#9f1239;font-size:12px;line-height:1.4}
46
46
  .ag-loading{color:var(--ag-muted);font-size:13px;padding:18px 0}
47
47
  .ag-note{margin:0;color:var(--ag-muted);font-size:11px;line-height:1.45}
48
- @media(max-width:860px){.ag-grid{grid-template-columns:1fr}.ag-stats{grid-template-columns:1fr 1fr}}
48
+ .ag-btn{font:inherit;font-size:11px;font-weight:650;border:1px solid var(--ag-line);background:var(--ag-panel);color:var(--ag-text);border-radius:8px;padding:6px 9px;cursor:pointer;min-height:30px}
49
+ .ag-btn:hover{border-color:rgba(15,118,110,.35);background:rgba(15,118,110,.05)}
50
+ .ag-btn:focus-visible{outline:2px solid var(--ag-accent);outline-offset:2px}
51
+ .ag-section{display:flex;flex-direction:column;gap:8px;min-width:0}
52
+ .ag-section h3{margin:0;font-size:15px;letter-spacing:-.01em}
53
+ .ag-hint{margin:0;color:var(--ag-muted);font-size:11.5px;line-height:1.45}
54
+ .ag-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;min-width:0}
55
+ .ag-item{border:1px solid var(--ag-line);border-radius:12px;background:var(--ag-panel);padding:11px 12px;min-width:0;display:flex;flex-direction:column;gap:5px}
56
+ .ag-item-top{display:flex;justify-content:space-between;gap:8px;align-items:baseline;min-width:0}
57
+ .ag-item-top code{font:inherit;font-weight:650;font-size:12px;color:var(--ag-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
58
+ .ag-item-top span{flex:none;font-size:9.5px;font-weight:750;letter-spacing:.06em;text-transform:uppercase;color:var(--ag-muted)}
59
+ .ag-item p{margin:0;color:var(--ag-muted);font-size:11px;line-height:1.4;overflow-wrap:anywhere}
60
+ .ag-item p.ag-task{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
61
+ .ag-status-running{color:#9a3412;border-color:rgba(154,52,18,.22);background:rgba(154,52,18,.06)}
62
+ .ag-status-completed{color:#0f766e;border-color:rgba(15,118,110,.22);background:rgba(15,118,110,.06)}
63
+ .ag-status-failed,.ag-status-canceled{color:#9f1239;border-color:rgba(159,18,57,.18);background:rgba(159,18,57,.05)}
64
+ .ag-mini-empty{padding:12px;border:1px dashed var(--ag-line);border-radius:10px;color:var(--ag-muted);font-size:11.5px;line-height:1.45;background:linear-gradient(160deg,var(--ag-soft),transparent 80%)}
65
+ .ag-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:2px}
66
+ @media(max-width:860px){.ag-grid{grid-template-columns:1fr}.ag-stats{grid-template-columns:1fr 1fr}.ag-items{grid-template-columns:1fr}}
49
67
  @media(max-width:520px){.ag-stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.ag-stat{padding:10px 12px}.ag-stat b{font-size:20px}.ag-stat span{font-size:9px;letter-spacing:.04em}.ag-root{gap:16px}}
50
68
  @media(prefers-reduced-motion:reduce){.ag-card{transition:none}}
51
69
  `;
@@ -93,6 +111,7 @@ window.__ModuleLoader__.load({
93
111
 
94
112
  function AgentsSection(props) {
95
113
  const [state, setState] = useState({ loading: true, error: null, data: null });
114
+ const [canceling, setCanceling] = useState("");
96
115
  const useSessions = props && props.useSessions;
97
116
  const useWorkspaces = props && props.useWorkspaces;
98
117
  const sessionId = useSessions ? useSessions(currentSessionSelector) : undefined;
@@ -102,6 +121,16 @@ window.__ModuleLoader__.load({
102
121
  ?? workspaces.find((item) => Array.isArray(item.sessionIds) && item.sessionIds.length > 0)
103
122
  ?? workspaces[0];
104
123
  const cwd = typeof workspace?.path === "string" ? workspace.path : undefined;
124
+
125
+ const load = async () => {
126
+ try {
127
+ const data = await call(window.__DSH_AGENTS_CTX__, "list", { sessionId, cwd });
128
+ setState({ loading: false, error: null, data });
129
+ } catch (error) {
130
+ setState({ loading: false, error: fallbackError(error), data: null });
131
+ }
132
+ };
133
+
105
134
  useEffect(() => {
106
135
  let cancelled = false;
107
136
  (async () => {
@@ -114,7 +143,22 @@ window.__ModuleLoader__.load({
114
143
  })();
115
144
  return () => { cancelled = true; };
116
145
  }, [sessionId, cwd]);
146
+
147
+ const cancel = async (id) => {
148
+ setCanceling(id);
149
+ try {
150
+ await call(window.__DSH_AGENTS_CTX__, "cancel", { sessionId, cwd, id });
151
+ await load();
152
+ } catch (error) {
153
+ setState((current) => ({ ...current, error: fallbackError(error) }));
154
+ } finally {
155
+ setCanceling("");
156
+ }
157
+ };
158
+
117
159
  const agents = state.data?.agents ?? [];
160
+ const worktrees = state.data?.worktrees ?? [];
161
+ const runs = state.data?.runs ?? [];
118
162
  const readOnly = agents.filter((agent) => agent.permission === "read-only").length;
119
163
  const models = new Set(agents.map((agent) => agent.model).filter(Boolean)).size;
120
164
  return h("section", { className: "ag-root", "aria-label": "Агенты проекта" },
@@ -122,8 +166,9 @@ window.__ModuleLoader__.load({
122
166
  h("div", null,
123
167
  h("p", { className: "ag-eyebrow" }, "Agents · project roles"),
124
168
  h("h2", { className: "ag-title" }, "Агенты"),
125
- h("p", { className: "ag-sub" }, "Проектные роли из `.dsh/agents/*.md`. Их `tools`, `permission` и `model` применяются к дочернему агенту при запуске `project_agent`."),
169
+ h("p", { className: "ag-sub" }, "Проектные роли из `.dsh/agents/*.md`. Их `tools`, `permission` и `model` применяются к дочернему агенту при запуске `project_agent`. Фоновые запуски и worktrees — здесь же."),
126
170
  ),
171
+ h("button", { className: "ag-btn", type: "button", onClick: () => void load() }, "Обновить"),
127
172
  ),
128
173
  state.error ? h("div", { className: "ag-error", role: "alert" }, state.error) : null,
129
174
  h("div", { className: "ag-stats" },
@@ -138,6 +183,38 @@ window.__ModuleLoader__.load({
138
183
  h("pre", { className: "ag-code" }, "---\nname: reviewer\ndescription: Ревью диффа\nmodel: deepseek-flash\ntools: read, grep\npermission: read-only\n---\n\nТы ревьюер. Верни только findings."),
139
184
  ) : null,
140
185
  agents.length > 0 ? h("div", { className: "ag-grid" }, agents.map((agent) => h(AgentCard, { key: agent.name, agent }))) : null,
186
+ h("div", { className: "ag-section" },
187
+ h("h3", null, "Worktrees"),
188
+ h("p", { className: "ag-hint" }, "Изолированные рабочие копии: `project_worktree create` или `project_agent` с `worktree=auto`."),
189
+ state.data?.worktreeError ? h("div", { className: "ag-error", role: "alert" }, state.data.worktreeError) : null,
190
+ worktrees.length > 0 ? h("div", { className: "ag-items" }, worktrees.map((tree) => h("article", { className: "ag-item", key: tree.id },
191
+ h("div", { className: "ag-item-top" },
192
+ h("code", { title: tree.branch || tree.id }, tree.branch || tree.id),
193
+ h("span", { className: tree.dirty ? "ag-status-running" : "ag-status-completed" }, tree.dirty ? "dirty" : "clean"),
194
+ ),
195
+ h("p", { title: tree.path }, tree.path),
196
+ ))) : h("div", { className: "ag-mini-empty" }, "Worktree пока нет. Создайте через инструмент `project_worktree`."),
197
+ ),
198
+ h("div", { className: "ag-section" },
199
+ h("h3", null, "Фоновые запуски"),
200
+ h("p", { className: "ag-hint" }, "`project_agent` с `background=true` возвращает id сразу; статус сохраняется в `.dsh/agents/runs.json`."),
201
+ runs.length > 0 ? h("div", { className: "ag-items" }, runs.slice().reverse().map((run) => h("article", { className: "ag-item", key: run.id },
202
+ h("div", { className: "ag-item-top" },
203
+ h("code", { title: run.id }, run.name),
204
+ h("span", { className: `ag-status-${run.status}` }, run.status),
205
+ ),
206
+ run.task ? h("p", { className: "ag-task", title: run.task }, run.task) : null,
207
+ h("div", { className: "ag-actions" },
208
+ run.worktree?.branch ? h("p", null, run.worktree.branch) : null,
209
+ run.status === "running" ? h("button", {
210
+ className: "ag-btn",
211
+ type: "button",
212
+ disabled: canceling === run.id,
213
+ onClick: () => void cancel(run.id),
214
+ }, canceling === run.id ? "Останавливаем…" : "Остановить") : null,
215
+ ),
216
+ ))) : h("div", { className: "ag-mini-empty" }, "Фоновых запусков нет."),
217
+ ),
141
218
  h("p", { className: "ag-note" }, state.data?.project ? `Проект: ${state.data.project}` : "Проект определяется по текущей рабочей папке."),
142
219
  );
143
220
  }
@@ -12,6 +12,8 @@ import path from "node:path";
12
12
  import { registerProjectHooks } from "./lib/hooks.js";
13
13
  import { createCheckpoint } from "./lib/checkpoints.js";
14
14
  import { runProjectAgent } from "./lib/agents.js";
15
+ import { cancelRun, listRuns } from "./lib/agents.js";
16
+ import { createWorktree, removeWorktree, worktreeSummary } from "./lib/worktrees.js";
15
17
  import { registerChildPolicyHook } from "./lib/policy.js";
16
18
  import { registerCommands } from "./lib/commands.js";
17
19
  import { registerAgentApi } from "./lib/api.js";
@@ -21,8 +23,13 @@ import { findDshDir, loadAgents, projectFor } from "./lib/project.js";
21
23
  export const inject = ["tools", "systemPrompt"];
22
24
 
23
25
  const TOOL = "project_agent";
26
+ const WORKTREE_TOOL = "project_worktree";
24
27
  const AUTO_CHECKPOINT_MS = 10 * 60_000;
25
28
 
29
+ function safeSlug(name) {
30
+ return String(name ?? "agent").toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 30) || "agent";
31
+ }
32
+
26
33
  function projectFromAgent(agent) {
27
34
  const cwd = agent?.session?.header?.cwd;
28
35
  return projectFor(cwd);
@@ -32,14 +39,17 @@ function toolDefinition(ctx) {
32
39
  return {
33
40
  name: TOOL,
34
41
  description:
35
- "Run or list project-local custom agents from `.dsh/agents/*.md`. `list` shows names/descriptions/models; `run` starts a child agent with its own context and returns only its final text. Use for repeated specialised work (reviewer, researcher, SEO, migration).",
42
+ "Run or list project-local custom agents from `.dsh/agents/*.md`. `run` starts a child agent; `background=true` returns immediately with a run id; `worktree=true|slug` runs the child in an isolated git worktree. `status` lists runs, `cancel` stops one. Use for repeated specialised work (reviewer, researcher, SEO, migration).",
36
43
  parameters: {
37
44
  type: "object",
38
45
  additionalProperties: false,
39
46
  properties: {
40
- action: { type: "string", enum: ["list", "run"], description: "list project agents or run one by name" },
47
+ action: { type: "string", enum: ["list", "run", "status", "cancel"], description: "list agents, run one, list background runs, or cancel a run" },
41
48
  name: { type: "string", description: "Agent name from `.dsh/agents/<name>.md`" },
42
49
  task: { type: "string", description: "Task/prompt for the child agent (required for run)" },
50
+ background: { type: "boolean", description: "Return immediately with a durable run id instead of waiting for the child" },
51
+ worktree: { type: "string", description: "Run in a git worktree: `auto` picks a name, or pass a lowercase slug" },
52
+ id: { type: "string", description: "Run id for status/cancel" },
43
53
  },
44
54
  required: ["action"],
45
55
  },
@@ -52,17 +62,92 @@ function toolDefinition(ctx) {
52
62
  : "Нет проектных агентов. Создайте `.dsh/agents/<name>.md`.";
53
63
  return { ok: true, text: `project_agent list:\n${text}` };
54
64
  }
55
- if (input?.action !== "run") return { ok: false, text: "project_agent: action must be list or run" };
65
+ if (input?.action === "status") {
66
+ const runs = listRuns(project.root);
67
+ const text = runs.length
68
+ ? runs.map((run) => `- ${run.id} · ${run.name} · ${run.status}${run.worktree ? ` · ${run.worktree.branch}` : ""}${run.error ? ` — ${run.error}` : ""}`).join("\n")
69
+ : "Фоновых запусков нет.";
70
+ return { ok: true, text: `project_agent status:\n${text}` };
71
+ }
72
+ if (input?.action === "cancel") {
73
+ const id = String(input?.id ?? "").trim();
74
+ if (!id) return { ok: false, text: "project_agent cancel: id is required" };
75
+ try {
76
+ const result = await cancelRun(ctx, exec, id);
77
+ return { ok: true, text: `project_agent cancel: ${id} (${result.live ? "live" : "already finished"})` };
78
+ } catch (error) {
79
+ return { ok: false, text: `project_agent cancel ${id}: ${error instanceof Error ? error.message : String(error)}` };
80
+ }
81
+ }
82
+ if (input?.action !== "run") return { ok: false, text: "project_agent: action must be list, run, status or cancel" };
56
83
  const name = String(input?.name ?? "").trim();
57
84
  const task = String(input?.task ?? "").trim();
58
85
  if (!name || !task) return { ok: false, text: "project_agent: name and task are required for run" };
59
86
  const definition = agents.find((agent) => agent.name === name);
60
87
  if (!definition) return { ok: false, text: `project_agent: unknown agent "${name}"` };
88
+ let worktree = null;
89
+ try {
90
+ const requested = input?.worktree;
91
+ if (requested) {
92
+ const auto = requested === true || String(requested).toLowerCase() === "auto";
93
+ const slug = auto ? `${safeSlug(name)}-${Date.now().toString(36).slice(-4)}` : String(requested);
94
+ worktree = createWorktree({ cwd: project.root, slug });
95
+ }
96
+ const result = await runProjectAgent(ctx, exec, definition, task, {
97
+ background: input?.background === true,
98
+ worktree,
99
+ cwd: worktree?.path,
100
+ });
101
+ if (result.background) return { ok: true, text: result.text, background: true, id: result.id, worktree };
102
+ const suffix = worktree ? `\nworktree: ${worktree.branch} → ${worktree.path}` : "";
103
+ return { ok: true, text: `project_agent ${name} (${result.stopReason}):\n${result.text}${suffix}`, worktree };
104
+ } catch (error) {
105
+ const suffix = worktree ? ` (worktree ${worktree.branch} сохранён для разбора)` : "";
106
+ return { ok: false, text: `project_agent ${name}: ${error instanceof Error ? error.message : String(error)}${suffix}` };
107
+ }
108
+ },
109
+ };
110
+ }
111
+
112
+ function worktreeToolDefinition() {
113
+ return {
114
+ name: WORKTREE_TOOL,
115
+ description:
116
+ "Manage isolated git worktrees for project agents. `list` shows branches, paths, dirty state; `create` adds `.dsh/worktrees/<slug>` on branch `dsh/<slug>`; `remove` refuses a dirty worktree unless force=true. The branch is kept after removal.",
117
+ parameters: {
118
+ type: "object",
119
+ additionalProperties: false,
120
+ properties: {
121
+ action: { type: "string", enum: ["list", "create", "remove"], description: "list, create or remove a worktree" },
122
+ slug: { type: "string", description: "Worktree id: lowercase letters, digits, hyphen" },
123
+ base: { type: "string", description: "Base ref for create; default HEAD" },
124
+ force: { type: "boolean", description: "Remove a dirty/locked worktree (data loss risk)" },
125
+ },
126
+ required: ["action"],
127
+ },
128
+ async execute(input, exec) {
129
+ const project = projectFromAgent(exec?.agent);
61
130
  try {
62
- const result = await runProjectAgent(ctx, exec, definition, task);
63
- return { ok: true, text: `project_agent ${name} (${result.stopReason}):\n${result.text}` };
131
+ if (input?.action === "list") {
132
+ const summary = worktreeSummary({ cwd: project.root });
133
+ const lines = summary.worktrees.map((item) => `- ${item.id} · ${item.branch ?? "detached"} · ${item.path}${item.dirty ? " · dirty" : ""}${item.locked ? " · locked" : ""}`);
134
+ return { ok: true, text: `project_worktree list (${summary.repo}):\n${lines.length ? lines.join("\n") : "Рабочих worktree нет."}` };
135
+ }
136
+ if (input?.action === "create") {
137
+ const slug = String(input?.slug ?? "").trim();
138
+ if (!slug) return { ok: false, text: "project_worktree create: slug is required" };
139
+ const created = createWorktree({ cwd: project.root, slug, base: input?.base });
140
+ return { ok: true, text: `project_worktree create: ${created.branch} → ${created.path}`, worktree: created };
141
+ }
142
+ if (input?.action === "remove") {
143
+ const slug = String(input?.slug ?? "").trim();
144
+ if (!slug) return { ok: false, text: "project_worktree remove: slug is required" };
145
+ const removed = removeWorktree({ cwd: project.root, id: slug, force: input?.force === true });
146
+ return { ok: true, text: `project_worktree remove: ${slug} (branch ${removed.branchKept ?? "kept"})`, worktree: removed };
147
+ }
148
+ return { ok: false, text: "project_worktree: action must be list, create or remove" };
64
149
  } catch (error) {
65
- return { ok: false, text: `project_agent ${name}: ${error instanceof Error ? error.message : String(error)}` };
150
+ return { ok: false, text: `project_worktree ${input?.action}: ${error instanceof Error ? error.message : String(error)}` };
66
151
  }
67
152
  },
68
153
  };
@@ -102,6 +187,7 @@ export function apply(ctx) {
102
187
 
103
188
  try {
104
189
  ctx.effect(() => ctx.tools.register(toolDefinition(ctx)), "agent-extensions: project_agent tool");
190
+ ctx.effect(() => ctx.tools.register(worktreeToolDefinition()), "agent-extensions: project_worktree tool");
105
191
  } catch (error) {
106
192
  ctx.logger?.warn?.(`[agent-extensions] tool registration failed: ${error instanceof Error ? error.message : String(error)}`);
107
193
  }
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Project-local custom agents: `.dsh/agents/<name>.md`.
3
3
  *
4
- * The file frontmatter describes when to use the agent; the body becomes the
5
- * child prompt. A child runs through the DSH subagents service with its own
6
- * context and returns only its final text.
4
+ * A child runs through the DSH subagents service with its own context. It can
5
+ * run in the foreground (returns final text), in a git worktree, or in the
6
+ * background with a durable run record under `.dsh/agents/runs.json`.
7
7
  */
8
8
  import crypto from "node:crypto";
9
- import { loadAgents } from "./project.js";
9
+ import { loadAgents, projectFor } from "./project.js";
10
10
  import { clearChildPolicy, setChildPolicy } from "./policy.js";
11
+ import { loadRuns, runPreview, updateRun, upsertRun } from "./runs.js";
12
+
13
+ const background = new Map();
11
14
 
12
15
  function textFromOutput(output) {
13
16
  if (!Array.isArray(output)) return "";
@@ -18,7 +21,15 @@ function textFromOutput(output) {
18
21
  .trim();
19
22
  }
20
23
 
21
- export async function runProjectAgent(ctx, exec, definition, task) {
24
+ function finishRun(projectRoot, id, patch) {
25
+ try { updateRun(projectRoot, id, patch); } catch { /* registry is best effort */ }
26
+ }
27
+
28
+ function remember(record, activation, projectRoot, signal, onAbort) {
29
+ background.set(record.id, { record, activation, projectRoot, signal, onAbort });
30
+ }
31
+
32
+ export async function runProjectAgent(ctx, exec, definition, task, options = {}) {
22
33
  const subagents = ctx.get("subagents");
23
34
  if (!subagents || typeof subagents.startActivation !== "function") {
24
35
  throw new Error("subagents service is unavailable");
@@ -26,17 +37,21 @@ export async function runProjectAgent(ctx, exec, definition, task) {
26
37
  const providers = typeof subagents.list === "function" ? subagents.list() : [];
27
38
  const provider = providers.includes("spawn") ? "spawn" : providers[0];
28
39
  if (!provider) throw new Error("no subagent provider is registered");
40
+ const project = projectFor(exec?.agent?.session?.header?.cwd);
41
+ const cwd = options.cwd ?? exec?.agent?.session?.header?.cwd;
42
+ const isBackground = options.background === true;
29
43
  const controller = new AbortController();
30
- const signal = exec?.signal;
44
+ const signal = isBackground ? undefined : exec?.signal;
31
45
  const abort = () => controller.abort(signal?.reason ?? new Error("parent aborted"));
32
46
  if (signal?.aborted) abort();
33
47
  else signal?.addEventListener?.("abort", abort, { once: true });
34
48
  const reservedId = crypto.randomUUID();
35
49
  let activeId = reservedId;
36
50
  setChildPolicy(reservedId, definition);
51
+ let activation;
37
52
  try {
38
53
  const prompt = [`<project-agent name="${definition.name}">`, definition.body, "</project-agent>", "", "Task:", String(task ?? "")].join("\n");
39
- const activation = await subagents.startActivation({
54
+ activation = await subagents.startActivation({
40
55
  provider,
41
56
  label: `agent:${definition.name}`,
42
57
  childId: reservedId,
@@ -44,7 +59,7 @@ export async function runProjectAgent(ctx, exec, definition, task) {
44
59
  prompt: [{ type: "text", text: prompt }],
45
60
  parent: exec.agent,
46
61
  signal: controller.signal,
47
- cwd: exec?.agent?.session?.header?.cwd,
62
+ cwd,
48
63
  ...(definition.model ? { agentOptions: { model: definition.model } } : {}),
49
64
  },
50
65
  signal: controller.signal,
@@ -55,18 +70,132 @@ export async function runProjectAgent(ctx, exec, definition, task) {
55
70
  setChildPolicy(activeId, definition);
56
71
  clearChildPolicy(reservedId);
57
72
  }
73
+ if (isBackground) {
74
+ const startedAt = new Date().toISOString();
75
+ const record = {
76
+ id: reservedId,
77
+ childId: activeId,
78
+ name: definition.name,
79
+ task: runPreview(task),
80
+ cwd,
81
+ worktree: options.worktree ?? null,
82
+ parentId: exec?.agent?.session?.id ?? null,
83
+ startedAt,
84
+ finishedAt: null,
85
+ status: "running",
86
+ stopReason: null,
87
+ result: "",
88
+ error: "",
89
+ };
90
+ upsertRun(project.root, record);
91
+ remember(record, activation, project.root, signal, abort);
92
+ activation.result.then(async (result) => {
93
+ const currentRecord = loadRuns(project.root).find((item) => item.id === record.id);
94
+ if (currentRecord?.status !== "canceled") {
95
+ const text = textFromOutput(result?.output);
96
+ finishRun(project.root, record.id, {
97
+ status: result?.stopReason === "completed" ? "completed" : "failed",
98
+ stopReason: result?.stopReason ?? "completed",
99
+ finishedAt: new Date().toISOString(),
100
+ result: runPreview(text),
101
+ error: result?.stopReason === "completed" ? "" : (result?.diagnostic ?? "agent failed"),
102
+ });
103
+ }
104
+ try { await activation.dispose?.(); } catch { /* best effort */ }
105
+ clearChildPolicy(activeId);
106
+ background.delete(record.id);
107
+ signal?.removeEventListener?.("abort", abort);
108
+ }).catch((error) => {
109
+ const currentRecord = loadRuns(project.root).find((item) => item.id === record.id);
110
+ if (currentRecord?.status !== "canceled") {
111
+ finishRun(project.root, record.id, {
112
+ status: "failed",
113
+ finishedAt: new Date().toISOString(),
114
+ error: error instanceof Error ? error.message : String(error),
115
+ });
116
+ }
117
+ clearChildPolicy(activeId);
118
+ background.delete(record.id);
119
+ signal?.removeEventListener?.("abort", abort);
120
+ });
121
+ return {
122
+ background: true,
123
+ id: record.id,
124
+ childId: activeId,
125
+ name: definition.name,
126
+ worktree: record.worktree,
127
+ text: `Фоновый агент ${definition.name} запущен: ${record.id}${record.worktree ? ` · ${record.worktree.branch}` : ""}`,
128
+ };
129
+ }
58
130
  const result = await activation.result;
59
131
  const text = textFromOutput(result?.output);
60
132
  await activation.dispose?.();
61
133
  if (!text) throw new Error(`project agent ${definition.name} returned no text`);
62
- return { text, stopReason: result?.stopReason ?? "completed" };
63
- } finally {
64
- clearChildPolicy(reservedId);
134
+ return { text, stopReason: result?.stopReason ?? "completed", worktree: options.worktree ?? null };
135
+ } catch (error) {
65
136
  clearChildPolicy(activeId);
66
- signal?.removeEventListener?.("abort", abort);
137
+ if (isBackground) {
138
+ const message = error instanceof Error ? error.message : String(error);
139
+ const record = {
140
+ id: reservedId,
141
+ childId: activeId,
142
+ name: definition.name,
143
+ task: runPreview(task),
144
+ cwd,
145
+ worktree: options.worktree ?? null,
146
+ parentId: exec?.agent?.session?.id ?? null,
147
+ startedAt: new Date().toISOString(),
148
+ finishedAt: new Date().toISOString(),
149
+ status: "failed",
150
+ stopReason: null,
151
+ result: "",
152
+ error: message,
153
+ };
154
+ try { upsertRun(project.root, record); } catch { /* best effort */ }
155
+ return { background: true, id: record.id, name: definition.name, error: message, text: `Фоновый агент ${definition.name} не запустился: ${message}` };
156
+ }
157
+ throw error;
158
+ } finally {
159
+ if (!isBackground) signal?.removeEventListener?.("abort", abort);
67
160
  }
68
161
  }
69
162
 
163
+ export function listRuns(projectRoot) {
164
+ const persisted = loadRuns(projectRoot);
165
+ return persisted.map((run) => {
166
+ const live = background.get(run.id);
167
+ return {
168
+ ...run,
169
+ status: live ? run.status : (run.status === "running" ? "unknown" : run.status),
170
+ live: Boolean(live),
171
+ };
172
+ });
173
+ }
174
+
175
+ export async function cancelRun(ctx, exec, id) {
176
+ const entry = background.get(String(id ?? ""));
177
+ const project = projectFor(exec?.agent?.session?.header?.cwd);
178
+ if (!entry) {
179
+ const run = loadRuns(project.root).find((item) => item.id === id);
180
+ if (!run) throw new Error(`run not found: ${id}`);
181
+ updateRun(project.root, run.id, { status: "canceled", finishedAt: new Date().toISOString(), error: "cancel requested (not live)" });
182
+ return { id: run.id, canceled: true, live: false };
183
+ }
184
+ const subagents = ctx.get("subagents");
185
+ try {
186
+ subagents?.interrupt?.(entry.record.childId, {
187
+ kind: "user",
188
+ parentSessionId: entry.record.parentId ?? exec?.agent?.session?.id,
189
+ });
190
+ } catch { /* disposal below still stops the local activation */ }
191
+ try { await entry.activation.dispose?.(); } catch { /* best effort */ }
192
+ clearChildPolicy(entry.record.childId);
193
+ background.delete(entry.record.id);
194
+ entry.signal?.removeEventListener?.("abort", entry.onAbort);
195
+ updateRun(project.root, entry.record.id, { status: "canceled", finishedAt: new Date().toISOString(), error: "canceled by user" });
196
+ return { id: entry.record.id, canceled: true, live: true };
197
+ }
198
+
70
199
  export function projectAgentSummary(projectDir, dshDir) {
71
200
  return loadAgents(projectDir, dshDir).map((agent) => ({
72
201
  name: agent.name,
@@ -2,6 +2,8 @@
2
2
  * Authenticated RPC for the Agents settings page.
3
3
  */
4
4
  import { loadAgents, projectFor } from "./project.js";
5
+ import { cancelRun, listRuns } from "./agents.js";
6
+ import { worktreeSummary } from "./worktrees.js";
5
7
 
6
8
  export const RPC_NAMESPACE = "dsh-agent-extensions";
7
9
  const ROUTE_BASE = "/api/dsh-agent-extensions";
@@ -21,7 +23,17 @@ async function envelope(request) {
21
23
  }
22
24
 
23
25
  export function registerAgentApi(ctx) {
24
- const route = {
26
+ const sessionFrom = (payload) => {
27
+ const sessions = ctx.get?.("sessions");
28
+ let session = payload?.sessionId ? sessions?.get?.(payload.sessionId) : undefined;
29
+ if (!session && typeof sessions?.list === "function") {
30
+ const live = sessions.list();
31
+ session = live.length > 0 ? live[live.length - 1] : undefined;
32
+ }
33
+ return session;
34
+ };
35
+
36
+ const listRoute = {
25
37
  path: `${ROUTE_BASE}/list`,
26
38
  methods: ["POST"],
27
39
  requestBody: "buffered",
@@ -31,12 +43,7 @@ export function registerAgentApi(ctx) {
31
43
  const value = await envelope(request);
32
44
  rpcId = value.rpcId;
33
45
  if (value.method !== `${RPC_NAMESPACE}/list`) throw new Error("endpoint/method mismatch");
34
- const sessions = ctx.get?.("sessions");
35
- let session = value.payload?.sessionId ? sessions?.get?.(value.payload.sessionId) : undefined;
36
- if (!session && typeof sessions?.list === "function") {
37
- const live = sessions.list();
38
- session = live.length > 0 ? live[live.length - 1] : undefined;
39
- }
46
+ const session = sessionFrom(value.payload);
40
47
  const project = projectFor(value.payload?.cwd || session?.header?.cwd || process.cwd());
41
48
  const agents = loadAgents(project.root, project.dsh).map((agent) => ({
42
49
  name: agent.name,
@@ -45,14 +52,50 @@ export function registerAgentApi(ctx) {
45
52
  tools: agent.tools,
46
53
  permission: agent.permission,
47
54
  }));
48
- return ok(rpcId, { project: project.root, agents });
55
+ let worktrees = [];
56
+ let worktreeError = "";
57
+ try {
58
+ worktrees = worktreeSummary({ cwd: project.root }).worktrees;
59
+ } catch (error) {
60
+ worktreeError = error instanceof Error ? error.message : String(error);
61
+ }
62
+ return ok(rpcId, {
63
+ project: project.root,
64
+ agents,
65
+ worktrees,
66
+ worktreeError,
67
+ runs: listRuns(project.root),
68
+ });
49
69
  } catch (error) {
50
70
  return fail(rpcId, error instanceof Error ? error.message : String(error));
51
71
  }
52
72
  },
53
73
  };
54
- ctx.effect(() => {
55
- const dispose = ctx.connection.fetch.register(route);
56
- return () => { try { void Promise.resolve(dispose()).catch(() => {}); } catch { /* unloading */ } };
57
- }, "agent-extensions: agents route");
74
+
75
+ const cancelRoute = {
76
+ path: `${ROUTE_BASE}/cancel`,
77
+ methods: ["POST"],
78
+ requestBody: "buffered",
79
+ fetch: async (request) => {
80
+ let rpcId = "invalid-request";
81
+ try {
82
+ const value = await envelope(request);
83
+ rpcId = value.rpcId;
84
+ if (value.method !== `${RPC_NAMESPACE}/cancel`) throw new Error("endpoint/method mismatch");
85
+ const session = sessionFrom(value.payload);
86
+ const project = projectFor(value.payload?.cwd || session?.header?.cwd || process.cwd());
87
+ const result = await cancelRun(ctx, { agent: { session } }, String(value.payload?.id ?? ""));
88
+ return ok(rpcId, { ...result, project: project.root });
89
+ } catch (error) {
90
+ return fail(rpcId, error instanceof Error ? error.message : String(error));
91
+ }
92
+ },
93
+ };
94
+
95
+ for (const route of [listRoute, cancelRoute]) {
96
+ ctx.effect(() => {
97
+ const dispose = ctx.connection.fetch.register(route);
98
+ return () => { try { void Promise.resolve(dispose()).catch(() => {}); } catch { /* unloading */ } };
99
+ }, `agent-extensions: ${route.path}`);
100
+ }
58
101
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Durable registry for background project-agent runs.
3
+ *
4
+ * A record is intentionally small: ids, worktree/branch, status and a short
5
+ * preview. Full child transcripts remain in DSH session storage.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const LIMIT = 100;
11
+ const PREVIEW = 2_000;
12
+
13
+ export function runsPath(projectRoot) {
14
+ return path.join(projectRoot, ".dsh", "agents", "runs.json");
15
+ }
16
+
17
+ function clip(value, max = PREVIEW) {
18
+ const text = String(value ?? "");
19
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
20
+ }
21
+
22
+ export function loadRuns(projectRoot) {
23
+ try {
24
+ const parsed = JSON.parse(fs.readFileSync(runsPath(projectRoot), "utf8"));
25
+ return Array.isArray(parsed) ? parsed : [];
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ export function saveRuns(projectRoot, runs) {
32
+ const file = runsPath(projectRoot);
33
+ fs.mkdirSync(path.dirname(file), { recursive: true });
34
+ const temp = `${file}.${process.pid}.tmp`;
35
+ fs.writeFileSync(temp, `${JSON.stringify(runs.slice(-LIMIT), null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
36
+ fs.renameSync(temp, file);
37
+ }
38
+
39
+ export function upsertRun(projectRoot, record) {
40
+ const runs = loadRuns(projectRoot);
41
+ const index = runs.findIndex((run) => run.id === record.id);
42
+ const next = { ...(index >= 0 ? runs[index] : {}), ...record };
43
+ if (index >= 0) runs[index] = next;
44
+ else runs.push(next);
45
+ saveRuns(projectRoot, runs);
46
+ return next;
47
+ }
48
+
49
+ export function updateRun(projectRoot, id, patch) {
50
+ const runs = loadRuns(projectRoot);
51
+ const index = runs.findIndex((run) => run.id === id);
52
+ if (index < 0) return null;
53
+ runs[index] = { ...runs[index], ...patch };
54
+ saveRuns(projectRoot, runs);
55
+ return runs[index];
56
+ }
57
+
58
+ export function runPreview(value) {
59
+ return clip(value);
60
+ }