dsh-task-ledger 0.0.126 → 0.0.128
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/dist/client.js +38864 -27205
- package/dist/client.js.map +1 -1
- package/dist/host/errors.d.ts +7 -1
- package/dist/host/errors.js +10 -0
- package/dist/host/repositories/dirs.repository.d.ts +37 -0
- package/dist/host/repositories/dirs.repository.js +97 -0
- package/dist/host/repositories/history.repository.d.ts +20 -0
- package/dist/host/repositories/history.repository.js +63 -0
- package/dist/host/repositories/identity.repository.d.ts +53 -3
- package/dist/host/repositories/identity.repository.js +105 -10
- package/dist/host/repositories/ledger.repository.d.ts +0 -8
- package/dist/host/repositories/ledger.repository.js +3 -20
- package/dist/host/repositories/runner.d.ts +29 -0
- package/dist/host/repositories/runner.js +66 -4
- package/dist/host/repositories/stats.repository.js +2 -2
- package/dist/host/repositories/task.repository.d.ts +12 -0
- package/dist/host/repositories/task.repository.js +63 -0
- package/dist/host/repositories/trend.repository.d.ts +3 -3
- package/dist/host/repositories/trend.repository.js +16 -8
- package/dist/host/routes.d.ts +13 -1
- package/dist/host/routes.js +369 -16
- package/dist/host/services/continue.service.d.ts +126 -0
- package/dist/host/services/continue.service.js +138 -0
- package/dist/host/services/dirs.service.d.ts +34 -0
- package/dist/host/services/dirs.service.js +66 -0
- package/dist/host/services/history.service.d.ts +13 -1
- package/dist/host/services/history.service.js +42 -0
- package/dist/host/services/identity.service.d.ts +53 -0
- package/dist/host/services/identity.service.js +89 -0
- package/dist/host/services/stats.service.d.ts +2 -2
- package/dist/host/services/stats.service.js +23 -6
- package/dist/host/services/task.service.d.ts +15 -0
- package/dist/host/services/task.service.js +15 -0
- package/dist/host/services/trend.service.d.ts +2 -2
- package/dist/host/services/trend.service.js +3 -3
- package/dist/host/services/workspaces.service.d.ts +10 -0
- package/dist/host/services/workspaces.service.js +28 -0
- package/dist/host/types.d.ts +53 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +31 -3
- package/package.json +2 -2
- package/skill/SKILL.md +14 -14
- package/skill/package.json +1 -1
- package/skill/references/init-identity.md +1 -1
- package/skill/references/troubleshooting.md +1 -1
- package/skill/scripts/bin/ledger-darwin-arm64 +0 -0
- package/skill/scripts/bin/ledger-linux-amd64 +0 -0
- package/skill/scripts/bin/ledger-windows-amd64.exe +0 -0
- package/dist/host/workspace.d.ts +0 -20
- package/dist/host/workspace.js +0 -17
package/dist/host/routes.js
CHANGED
|
@@ -1,16 +1,101 @@
|
|
|
1
|
+
import { codeOf, messageOf } from "./errors.js";
|
|
1
2
|
import { invalidate } from "./repositories/cache.js";
|
|
3
|
+
import { scriptPath } from "./repositories/runner.js";
|
|
4
|
+
import { getIdentity, getRoster, forgetOwner, switchIdentity, } from "./services/identity.service.js";
|
|
2
5
|
import { getBoardHome } from "./services/stats.service.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
6
|
+
import { listDirs, writeDirs } from "./services/dirs.service.js";
|
|
7
|
+
import { getHistoryList, getHistoryTrend } from "./services/history.service.js";
|
|
8
|
+
import { actOnTask } from "./services/task.service.js";
|
|
9
|
+
import { continueTask, resolveWorkspace, } from "./services/continue.service.js";
|
|
10
|
+
import { resolveWorkspaces } from "./services/workspaces.service.js";
|
|
5
11
|
/** API 前缀(唯一真源就在这一处)。 */
|
|
6
12
|
const API_PREFIX = "/api/task-ledger";
|
|
13
|
+
/** 核心 explorer 的只读文件路由(`serveFsRoute` 挂这里,client 的 `connectFs` 用同一个值)。 */
|
|
14
|
+
export const FS_ROUTE = `${API_PREFIX}/fs`;
|
|
7
15
|
function sendJson(res, status, body) {
|
|
8
16
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
9
17
|
res.end(JSON.stringify(body));
|
|
10
18
|
}
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
/**
|
|
20
|
+
* 失败统一出口:500 + 人话 + **机器可读码**。
|
|
21
|
+
* 界面靠 `code` 分流(`LEDGER_IDENTITY_MISSING` ⇒ 整页切身份页),不去匹配中文。
|
|
22
|
+
*/
|
|
23
|
+
function sendFailure(res, error) {
|
|
24
|
+
sendJson(res, 500, { error: messageOf(error), code: codeOf(error) });
|
|
25
|
+
}
|
|
26
|
+
/** 请求体上限:身份写入只带一个名字 / 一个 uuid,8 KB 富余。 */
|
|
27
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
28
|
+
/** 读原始请求体(宿主给的 handler 是裸 Node req,没有解析过的 body)。 */
|
|
29
|
+
function readBody(req) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
let text = "";
|
|
32
|
+
req.on("data", (chunk) => {
|
|
33
|
+
text += chunk.toString();
|
|
34
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
35
|
+
reject(new Error("参数:请求体过大"));
|
|
36
|
+
req.destroy();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
req.on("end", () => resolve(text));
|
|
40
|
+
req.on("error", reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** 400 与 500 靠这个前缀区分:参数类错误一律在文案前加「参数:」。 */
|
|
44
|
+
const BAD_REQUEST = "参数:";
|
|
45
|
+
/** 写端点的失败出口:参数类错误 400(去掉前缀),其余照 500 + 机器可读码。 */
|
|
46
|
+
function sendWriteFailure(res, error) {
|
|
47
|
+
const message = messageOf(error);
|
|
48
|
+
if (message.startsWith(BAD_REQUEST)) {
|
|
49
|
+
sendJson(res, 400, { error: message.slice(BAD_REQUEST.length) });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
sendFailure(res, error);
|
|
53
|
+
}
|
|
54
|
+
/** 界面上给用户复制到终端的 init 命令(有工作区就带上 --repo,否则脚本找不到台账)。 */
|
|
55
|
+
function initCommandOf(repoPath) {
|
|
56
|
+
const head = `sh "${scriptPath()}" init`;
|
|
57
|
+
return repoPath === "" ? head : `${head} --repo "${repoPath}"`;
|
|
58
|
+
}
|
|
59
|
+
/** 换身份请求体 → 动作参数(名字 / uuid 的合法性由 skill 再判一次,这里只挡形状)。 */
|
|
60
|
+
async function switchInputOf(req, deps) {
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = JSON.parse(await readBody(req));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error(`${BAD_REQUEST}请求体不是合法 JSON`);
|
|
67
|
+
}
|
|
68
|
+
const body = (raw ?? {});
|
|
69
|
+
if (body.action === "new") {
|
|
70
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
71
|
+
if (name === "")
|
|
72
|
+
throw new Error(`${BAD_REQUEST}新建身份必须填名字`);
|
|
73
|
+
return { action: "new", name };
|
|
74
|
+
}
|
|
75
|
+
if (body.action === "claim") {
|
|
76
|
+
const uuid = typeof body.uuid === "string" ? body.uuid.trim() : "";
|
|
77
|
+
if (uuid === "")
|
|
78
|
+
throw new Error(`${BAD_REQUEST}认领身份必须给 uuid`);
|
|
79
|
+
const workspaceId = typeof body.workspace === "string" ? body.workspace : "";
|
|
80
|
+
const workspace = (await workspacesOf(deps)).find((item) => item.id === workspaceId);
|
|
81
|
+
if (workspace === undefined)
|
|
82
|
+
throw new Error(`${BAD_REQUEST}找不到这个工作区,读不了名册`);
|
|
83
|
+
return { action: "claim", uuid, repo: workspace.path };
|
|
84
|
+
}
|
|
85
|
+
if (body.action === "rename") {
|
|
86
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
87
|
+
if (name === "")
|
|
88
|
+
throw new Error(`${BAD_REQUEST}改昵称必须填新昵称`);
|
|
89
|
+
return { action: "rename", name };
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`${BAD_REQUEST}action 只能是 new / claim / rename`);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 解析工作区。`sync` 只在「进看板 / 手动刷新」时为 true —— 只有这一次才去宿主工作区补登记,
|
|
95
|
+
* 其余请求(名册、趋势、名册写动作…)一律只读清单,不起进程、不写盘。
|
|
96
|
+
*/
|
|
97
|
+
function workspacesOf(deps, sync = false) {
|
|
98
|
+
return resolveWorkspaces(deps.registry.list(), sync);
|
|
14
99
|
}
|
|
15
100
|
export function registerRoutes(webServer, deps) {
|
|
16
101
|
/**
|
|
@@ -28,25 +113,275 @@ export function registerRoutes(webServer, deps) {
|
|
|
28
113
|
},
|
|
29
114
|
});
|
|
30
115
|
};
|
|
116
|
+
// 本机身份:`{uuid, name}`,未初始化双 null(退 0,不是错误)。界面进页面的第一件事就是它 ——
|
|
117
|
+
// 没身份时后续的快照请求根本不会发(身份闸门在 skill 侧挡着,快照会 500)。
|
|
118
|
+
registerRead("/identity", async (_req, res) => {
|
|
119
|
+
try {
|
|
120
|
+
const identity = await getIdentity();
|
|
121
|
+
sendJson(res, 200, {
|
|
122
|
+
...identity,
|
|
123
|
+
// 界面上要直接给用户复制到终端的命令(绝对路径 —— PATH 里没有 progress)
|
|
124
|
+
initCommand: initCommandOf((await workspacesOf(deps))[0]?.path ?? ""),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
sendFailure(res, error);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
// 可以认领的人:各工作区名册(没身份也能读;读挂的工作区直接跳过)。
|
|
132
|
+
registerRead("/identity/roster", async (_req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
sendJson(res, 200, await getRoster(await workspacesOf(deps)));
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
sendFailure(res, error);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
// 换身份(建新的 / 认领名册里的)。宿主路由不区分 method ⇒ 自己判;写操作必须 POST。
|
|
141
|
+
webServer.register({
|
|
142
|
+
kind: "exact",
|
|
143
|
+
path: `${API_PREFIX}/identity/switch`,
|
|
144
|
+
handler: async (req, res) => {
|
|
145
|
+
if (req.method !== "POST") {
|
|
146
|
+
sendJson(res, 405, { error: "身份写入只接受 POST" });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
sendJson(res, 200, await switchIdentity(await switchInputOf(req, deps)));
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
sendWriteFailure(res, error);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
// 从名册摘人(设置页每行的小叉):逐本台账各删各的,分片还有任务的那本不删。
|
|
158
|
+
webServer.register({
|
|
159
|
+
kind: "exact",
|
|
160
|
+
path: `${API_PREFIX}/identity/forget`,
|
|
161
|
+
handler: async (req, res) => {
|
|
162
|
+
if (req.method !== "POST") {
|
|
163
|
+
sendJson(res, 405, { error: "身份写入只接受 POST" });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
let raw;
|
|
168
|
+
try {
|
|
169
|
+
raw = JSON.parse(await readBody(req));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
throw new Error(`${BAD_REQUEST}请求体不是合法 JSON`);
|
|
173
|
+
}
|
|
174
|
+
const body = (raw ?? {});
|
|
175
|
+
const uuid = typeof body.uuid === "string" ? body.uuid.trim() : "";
|
|
176
|
+
if (uuid === "")
|
|
177
|
+
throw new Error(`${BAD_REQUEST}摘人必须给 uuid`);
|
|
178
|
+
sendJson(res, 200, await forgetOwner(await workspacesOf(deps), uuid));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
sendWriteFailure(res, error);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
});
|
|
31
185
|
registerRead("/workspaces", async (_req, res) => {
|
|
32
186
|
try {
|
|
33
187
|
sendJson(res, 200, { workspaces: await workspacesOf(deps) });
|
|
34
188
|
}
|
|
35
189
|
catch (error) {
|
|
36
|
-
|
|
37
|
-
error: error instanceof Error ? error.message : String(error),
|
|
38
|
-
});
|
|
190
|
+
sendFailure(res, error);
|
|
39
191
|
}
|
|
40
192
|
});
|
|
193
|
+
// 台账目录清单(设置页「台账目录」块):GET 读全量(含隐藏项),POST 是写动作(显示 / 不显示 /
|
|
194
|
+
// 改名 / 从看板移除,全部转给 skill,插件不写那个文件)。
|
|
195
|
+
// ⚠️ 同一个 path 只能注册一次(宿主对重复 path 直接拒绝)⇒ 读写合成这一条,按 method 分流。
|
|
196
|
+
webServer.register({
|
|
197
|
+
kind: "exact",
|
|
198
|
+
path: `${API_PREFIX}/dirs`,
|
|
199
|
+
handler: async (req, res) => {
|
|
200
|
+
if (req.method !== "POST") {
|
|
201
|
+
try {
|
|
202
|
+
// 与 registerRead 同一口径:manual refresh 先清读缓存再算
|
|
203
|
+
if (new URL(req.url ?? "/", "http://localhost").searchParams.get("fresh") === "1")
|
|
204
|
+
invalidate();
|
|
205
|
+
sendJson(res, 200, await listDirs());
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
sendFailure(res, error);
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
let raw;
|
|
214
|
+
try {
|
|
215
|
+
raw = JSON.parse(await readBody(req));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new Error(`${BAD_REQUEST}请求体不是合法 JSON`);
|
|
219
|
+
}
|
|
220
|
+
const body = (raw ?? {});
|
|
221
|
+
const path = typeof body.path === "string" ? body.path.trim() : "";
|
|
222
|
+
if (path === "")
|
|
223
|
+
throw new Error(`${BAD_REQUEST}必须给台账目录路径`);
|
|
224
|
+
if (body.action === "add") {
|
|
225
|
+
sendJson(res, 200, await writeDirs({ action: "add", path }));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (body.action === "hide" || body.action === "unhide" || body.action === "remove") {
|
|
229
|
+
sendJson(res, 200, await writeDirs({ action: body.action, path }));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (body.action === "rename") {
|
|
233
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
234
|
+
sendJson(res, 200, await writeDirs({ action: "rename", path, name }));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`${BAD_REQUEST}action 只能是 add / hide / unhide / rename / remove`);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
sendWriteFailure(res, error);
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
// 任务动作(在办行内的按钮):认领 / 释放 / 取消 / 结案。前端只给工作区 id + 任务号,
|
|
245
|
+
// 台账根由 host 解析,改台账一律交给 skill(claim / set --release / close),插件不碰文件。
|
|
246
|
+
//
|
|
247
|
+
// **响应是 NDJSON 流**(2026-11-04):一条写命令要跑「检查 → 拉取 → 写 → 提交 → 推送」好几秒,
|
|
248
|
+
// 攒完再回只能给界面一句固定提示。skill 每进一个阶段在 stderr 打 `#step <key>`,
|
|
249
|
+
// host 原样转成一行 JSON 推出去,界面据此翻进度文案:
|
|
250
|
+
// {"type":"step","step":"sync"} / {"type":"done"} / {"type":"error","error":"…","code":"…"}
|
|
251
|
+
// ⚠️ 头一旦发出去,后面的失败只能进流(状态码改不了)⇒ 参数类错误必须在发头之前挡掉。
|
|
252
|
+
webServer.register({
|
|
253
|
+
kind: "exact",
|
|
254
|
+
path: `${API_PREFIX}/task/action`,
|
|
255
|
+
handler: async (req, res) => {
|
|
256
|
+
if (req.method !== "POST") {
|
|
257
|
+
sendJson(res, 405, { error: "任务动作只接受 POST" });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
let input;
|
|
261
|
+
try {
|
|
262
|
+
let raw;
|
|
263
|
+
try {
|
|
264
|
+
raw = JSON.parse(await readBody(req));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new Error(`${BAD_REQUEST}请求体不是合法 JSON`);
|
|
268
|
+
}
|
|
269
|
+
const body = (raw ?? {});
|
|
270
|
+
const action = body.action;
|
|
271
|
+
if (action !== "claim" &&
|
|
272
|
+
action !== "release" &&
|
|
273
|
+
action !== "cancel" &&
|
|
274
|
+
action !== "close")
|
|
275
|
+
throw new Error(`${BAD_REQUEST}action 只能是 claim / release / cancel / close`);
|
|
276
|
+
// 摘要 / 理由:一句话就够,超长的截掉(历史行不该被一段小作文撑开)
|
|
277
|
+
const summary = typeof body.summary === "string" ? body.summary.trim().slice(0, 200) : "";
|
|
278
|
+
// 正常结案必须有摘要(取消可以没有):空着也要在界面先挡住,这里兜一层
|
|
279
|
+
if (action === "close" && summary === "")
|
|
280
|
+
throw new Error(`${BAD_REQUEST}结案必须填一句摘要`);
|
|
281
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
282
|
+
if (id === "")
|
|
283
|
+
throw new Error(`${BAD_REQUEST}必须给任务号`);
|
|
284
|
+
const workspaceId = typeof body.workspace === "string" ? body.workspace : "";
|
|
285
|
+
const workspace = (await workspacesOf(deps)).find((item) => item.id === workspaceId);
|
|
286
|
+
if (workspace === undefined)
|
|
287
|
+
throw new Error(`${BAD_REQUEST}找不到这个工作区,改不了任务`);
|
|
288
|
+
input = { action, repo: workspace.path, id, summary };
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
sendWriteFailure(res, error);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
res.writeHead(200, {
|
|
295
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
296
|
+
"cache-control": "no-store",
|
|
297
|
+
});
|
|
298
|
+
const send = (event) => {
|
|
299
|
+
res.write(`${JSON.stringify(event)}\n`);
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
await actOnTask(input, (step) => send({ type: "step", step }));
|
|
303
|
+
send({ type: "done" });
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
send({ type: "error", error: messageOf(error), code: codeOf(error) });
|
|
307
|
+
}
|
|
308
|
+
res.end();
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
// 工作区归属探测(2026-11-03):非 DSH 工作区**起不了会话**,「继续」与「完成」都先问这一下,
|
|
312
|
+
// 判据与「继续」完全同一条(`resolveWorkspace`,含 realpath 归一),不各写一份。
|
|
313
|
+
webServer.register({
|
|
314
|
+
kind: "exact",
|
|
315
|
+
path: `${API_PREFIX}/workspace/kind`,
|
|
316
|
+
handler: (req, res) => {
|
|
317
|
+
try {
|
|
318
|
+
const path = new URL(req.url ?? "/", "http://localhost").searchParams.get("path") ?? "";
|
|
319
|
+
if (path.trim() === "")
|
|
320
|
+
throw new Error(`${BAD_REQUEST}必须给台账目录路径`);
|
|
321
|
+
const resolved = resolveWorkspace(deps.registry, path);
|
|
322
|
+
sendJson(res, 200, resolved.kind === "dsh"
|
|
323
|
+
? { kind: "dsh", workspaceId: resolved.workspace.id }
|
|
324
|
+
: { kind: "not-dsh" });
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
sendFailure(res, error);
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
// 「继续」:在该台账所属的 DSH 工作区里起会话 + 注入只读提示词(2026-11-03)。
|
|
332
|
+
// 非 DSH 工作区**不是错误** ⇒ 200 + `kind: "not-dsh"`,界面据此先复制任务号再弹警告。
|
|
333
|
+
webServer.register({
|
|
334
|
+
kind: "exact",
|
|
335
|
+
path: `${API_PREFIX}/task/continue`,
|
|
336
|
+
handler: async (req, res) => {
|
|
337
|
+
if (req.method !== "POST") {
|
|
338
|
+
sendJson(res, 405, { error: "继续任务只接受 POST" });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
let raw;
|
|
343
|
+
try {
|
|
344
|
+
raw = JSON.parse(await readBody(req));
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
throw new Error(`${BAD_REQUEST}请求体不是合法 JSON`);
|
|
348
|
+
}
|
|
349
|
+
const body = (raw ?? {});
|
|
350
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
351
|
+
if (id === "")
|
|
352
|
+
throw new Error(`${BAD_REQUEST}必须给任务号`);
|
|
353
|
+
const workspaceId = typeof body.workspace === "string" ? body.workspace : "";
|
|
354
|
+
if (workspaceId === "")
|
|
355
|
+
throw new Error(`${BAD_REQUEST}必须给工作区`);
|
|
356
|
+
// 提示词 / 标题都由客户端拼(要跟界面语言走),host 只负责注入原文
|
|
357
|
+
const prompt = typeof body.prompt === "string" ? body.prompt : "";
|
|
358
|
+
if (prompt.trim() === "")
|
|
359
|
+
throw new Error(`${BAD_REQUEST}必须给提示词`);
|
|
360
|
+
const title = typeof body.title === "string" && body.title.trim() !== ""
|
|
361
|
+
? body.title
|
|
362
|
+
: `继续任务 ${id}`;
|
|
363
|
+
sendJson(res, 200, await continueTask({
|
|
364
|
+
registry: deps.registry,
|
|
365
|
+
agents: deps.agents?.(),
|
|
366
|
+
presets: deps.presets?.(),
|
|
367
|
+
defaultModel: deps.defaultModel?.(),
|
|
368
|
+
sessionTitle: deps.sessionTitle?.(),
|
|
369
|
+
}, { workspaceId, id, title, prompt }));
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
sendWriteFailure(res, error);
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
});
|
|
41
376
|
// 首屏一次拿全:8 卡(skill `stats`;本周/上周完成取趋势最后两个桶)+ 在办列表(`list --json`)+ 趋势。
|
|
42
|
-
|
|
377
|
+
// `?sync=1` 才去宿主工作区补登记(用户 2026-11-03:只在进看板与手动刷新时同步,别的时候一次都不跑)。
|
|
378
|
+
registerRead("/board/snapshot", async (req, res) => {
|
|
43
379
|
try {
|
|
44
|
-
|
|
380
|
+
const sync = new URL(req.url ?? "/", "http://localhost").searchParams.get("sync") === "1";
|
|
381
|
+
sendJson(res, 200, await getBoardHome(await workspacesOf(deps, sync)));
|
|
45
382
|
}
|
|
46
383
|
catch (error) {
|
|
47
|
-
|
|
48
|
-
error: error instanceof Error ? error.message : String(error),
|
|
49
|
-
});
|
|
384
|
+
sendFailure(res, error);
|
|
50
385
|
}
|
|
51
386
|
});
|
|
52
387
|
// 历史页顶部趋势(粒度 day/week/month,桶全在 skill):切粒度 = 重新请求,host 有读缓存。
|
|
@@ -57,9 +392,27 @@ export function registerRoutes(webServer, deps) {
|
|
|
57
392
|
sendJson(res, 200, await getHistoryTrend(await workspacesOf(deps), gran));
|
|
58
393
|
}
|
|
59
394
|
catch (error) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
395
|
+
sendFailure(res, error);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
// 历史页列表:**只取选中的那个月**(工作区 / 执行人 / 年月筛都在 host)。
|
|
399
|
+
registerRead("/history/list", async (req, res) => {
|
|
400
|
+
try {
|
|
401
|
+
const params = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
402
|
+
const all = await workspacesOf(deps);
|
|
403
|
+
const workspaceId = params.get("workspace") ?? "";
|
|
404
|
+
const scoped = workspaceId === ""
|
|
405
|
+
? all
|
|
406
|
+
: all.filter((workspace) => workspace.id === workspaceId);
|
|
407
|
+
const owner = params.get("owner") ?? "";
|
|
408
|
+
const ym = params.get("ym") ?? "";
|
|
409
|
+
sendJson(res, 200, await getHistoryList(scoped, {
|
|
410
|
+
ym,
|
|
411
|
+
...(owner === "" ? {} : { owner }),
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
sendFailure(res, error);
|
|
63
416
|
}
|
|
64
417
|
});
|
|
65
418
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「继续」:在**该台账所属的 DSH 工作区**里起一个新会话,把只读提示词作为首条 user message 注入。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不塞进现有会话的输入框(2026-11-03 核实):写草稿的 `setDraft` 只挂在会话作用域的门面上
|
|
5
|
+
* (`dsh-client-ui-conversation` `contract/input.d.ts:165`,经 `SessionInputResolver.for(actx)` 取),
|
|
6
|
+
* 根级 client 插件拿不到某个会话的 `actx`;按 id 取的 `input.shell()` 是包内部件。
|
|
7
|
+
* 且任务↔会话之间没有绑定字段 ⇒「往哪个会话塞」本身没有答案。链路事实见
|
|
8
|
+
* `docs/design/external/dsh-session-handoff.md`,兄弟插件实证 `dsh-task-dispatch-table/src/dispatch.ts:519/562/627`。
|
|
9
|
+
*
|
|
10
|
+
* 三条硬约束(宿主 rc 线的坑,缺一条就是"会话建了但没法干活"):
|
|
11
|
+
* 1. `setup` 里必须 `agentPresets.mount()` —— 不挂 preset 的 agent 落到空的全局层,fs / bash 全无;
|
|
12
|
+
* 2. `agentOptions.provider/model` 必须成对显式给 —— 缺省**不等于**走宿主默认;
|
|
13
|
+
* 3. 建完必须 `attachSession(sessionId)` —— 只设 `meta.cwd` 不会把会话归进工作区。
|
|
14
|
+
*/
|
|
15
|
+
import type { RegistryWorkspace } from "../types.js";
|
|
16
|
+
/** 宿主 `agent.send` 收的消息(`dsh-llm` 的 UserMessage 结构面,只声明实际用到的字段)。 */
|
|
17
|
+
interface UserMessageLike {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly role: "user";
|
|
20
|
+
readonly content: readonly {
|
|
21
|
+
readonly type: "text";
|
|
22
|
+
readonly text: string;
|
|
23
|
+
}[];
|
|
24
|
+
readonly source: {
|
|
25
|
+
readonly kind: string;
|
|
26
|
+
readonly form: string;
|
|
27
|
+
readonly summary: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
interface AgentHandleLike {
|
|
31
|
+
readonly agent: {
|
|
32
|
+
/** 宿主 `Session`:命名的入参(`sessionTitle.rename(agent.session, …)`)。 */
|
|
33
|
+
readonly session: unknown;
|
|
34
|
+
send(message: UserMessageLike, target: "next-turn" | "next-step", wakeup: boolean): void;
|
|
35
|
+
};
|
|
36
|
+
dispose(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
/** 宿主 `ctx.get('agents')` 的最小面。 */
|
|
39
|
+
export interface AgentsLike {
|
|
40
|
+
create(options: {
|
|
41
|
+
readonly sessionId: string;
|
|
42
|
+
readonly meta: {
|
|
43
|
+
readonly cwd: string;
|
|
44
|
+
readonly agentPreset?: string;
|
|
45
|
+
};
|
|
46
|
+
readonly agentOptions: {
|
|
47
|
+
readonly provider: string;
|
|
48
|
+
readonly model: string;
|
|
49
|
+
};
|
|
50
|
+
readonly setup?: (agentCtx: unknown) => Promise<void>;
|
|
51
|
+
}): Promise<AgentHandleLike>;
|
|
52
|
+
}
|
|
53
|
+
/** 宿主 `sessionTitle` 服务的最小面(给会话起名;缺席就保持「未命名」,不影响继续)。 */
|
|
54
|
+
export interface SessionTitleLike {
|
|
55
|
+
rename(session: unknown, title: string): unknown;
|
|
56
|
+
}
|
|
57
|
+
/** 宿主 `ctx.get('agentPresets')` 的最小面。 */
|
|
58
|
+
export interface AgentPresetsLike {
|
|
59
|
+
/** 省略参数 = 部署默认 preset(settings 的 selectionPolicy().defaultId,热读)。 */
|
|
60
|
+
resolve(): Promise<{
|
|
61
|
+
readonly id: string;
|
|
62
|
+
}>;
|
|
63
|
+
mount(agentCtx: unknown, presetId: string): Promise<void> | void;
|
|
64
|
+
}
|
|
65
|
+
/** 宿主 `ctx.get('agentDefaultModel')` 的最小面。 */
|
|
66
|
+
export interface AgentDefaultModelLike {
|
|
67
|
+
currentSelection(): {
|
|
68
|
+
readonly provider?: unknown;
|
|
69
|
+
readonly model?: unknown;
|
|
70
|
+
} | undefined;
|
|
71
|
+
}
|
|
72
|
+
export interface ContinueDeps {
|
|
73
|
+
readonly registry: {
|
|
74
|
+
list(): readonly RegistryWorkspace[];
|
|
75
|
+
};
|
|
76
|
+
/** 以下四项**按需现取**:服务可能晚于路由就位,取不到由本文件报人话,不让路由整块失效。 */
|
|
77
|
+
readonly agents: AgentsLike | undefined;
|
|
78
|
+
readonly presets: AgentPresetsLike | undefined;
|
|
79
|
+
readonly defaultModel: AgentDefaultModelLike | undefined;
|
|
80
|
+
/** 命名服务;缺席会话就叫「未命名」,继续照跑。 */
|
|
81
|
+
readonly sessionTitle: SessionTitleLike | undefined;
|
|
82
|
+
}
|
|
83
|
+
/** 宿主工作区解析结果:`dsh` 带上 registry 实体(建会话要靠它归组、取名)。 */
|
|
84
|
+
export type WorkspaceResolution = {
|
|
85
|
+
readonly kind: "dsh";
|
|
86
|
+
readonly workspace: RegistryWorkspace;
|
|
87
|
+
} | {
|
|
88
|
+
readonly kind: "not-dsh";
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* 判据:台账目录的 path 能不能在宿主工作区里找到实体。
|
|
92
|
+
*
|
|
93
|
+
* 「继续」和「完成」两条链都先过这一关 —— 非 DSH 工作区**起不了会话**,
|
|
94
|
+
* 只能退化成「把提示词复制给用户,让他去对应的 Agent 粘」。
|
|
95
|
+
*/
|
|
96
|
+
export declare function resolveWorkspace(registry: {
|
|
97
|
+
list(): readonly RegistryWorkspace[];
|
|
98
|
+
}, path: string): WorkspaceResolution;
|
|
99
|
+
export interface ContinueInput {
|
|
100
|
+
/** 台账工作区 id(= 台账目录绝对路径)。 */
|
|
101
|
+
readonly workspaceId: string;
|
|
102
|
+
/** 任务号(含任务名的那一串,skill 精确匹配用)。 */
|
|
103
|
+
readonly id: string;
|
|
104
|
+
/** 会话标题(客户端按界面语言拼好,会话列表里显示的名字)。 */
|
|
105
|
+
readonly title: string;
|
|
106
|
+
/**
|
|
107
|
+
* 注入的首条消息正文。**由客户端拼好传进来** —— 提示词要跟界面语言(中/英)走,
|
|
108
|
+
* 而语言只有浏览器半知道;host 不再自带中文模板(否则英文环境收到的仍是中文)。
|
|
109
|
+
*/
|
|
110
|
+
readonly prompt: string;
|
|
111
|
+
}
|
|
112
|
+
/** `not-dsh` = 该台账目录不是宿主登记过的工作区(预期分支,不是错误)。 */
|
|
113
|
+
export type ContinueResult = {
|
|
114
|
+
readonly kind: "started";
|
|
115
|
+
readonly sessionId: string;
|
|
116
|
+
} | {
|
|
117
|
+
readonly kind: "not-dsh";
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* 起会话并注入只读提示词。
|
|
121
|
+
*
|
|
122
|
+
* @throws Error(人话直上界面):宿主缺 agents 服务 / 没有可用默认模型 / 会话建不起来 /
|
|
123
|
+
* 建好了但归不进工作区(此时会话会被 dispose 掉,不留孤儿)。
|
|
124
|
+
*/
|
|
125
|
+
export declare function continueTask(deps: ContinueDeps, input: ContinueInput): Promise<ContinueResult>;
|
|
126
|
+
export {};
|