dsh-task-ledger 0.0.46 → 0.0.48

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.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 读缓存(方案 B 的落地件,2026-10-18 拍板的三条口径,无黑魔法):
3
+ *
4
+ * ① 进程内 `Map`,key = `op:workspace:filter`(filter 已序列化),值 = 结果 + 时间戳;
5
+ * ② 命中且 1.5s 内直接返回、**不起进程**;同 key 并发**单飞**(只跑一次脚本,其余等同一个 Promise);
6
+ * ③ `fresh=1` 绕过(路由层先 `invalidate()` 再算);写路径成功后 `invalidate(workspaceId)` 钩子。
7
+ *
8
+ * 边界:只缓存 skill 脚本的两读(在办 list / 历史 history)。worklog 正文不缓存——它会被
9
+ * 会话里的 Agent 实时改写,缓存 1.5s 也会让用户看到上一版。**失败不入缓存**(错就是错,重试要能重新跑)。
10
+ * 命中返回的是**同一个引用**:调用方只许读(上层 service 全是纯计算,不原地改数组)。
11
+ */
12
+ /** 缓存有效期(2026-10-18 拍板 1.5s:够挡同一屏的重复读,短到不会让人看到旧数据)。 */
13
+ export declare const CACHE_TTL_MS = 1500;
14
+ /** 缓存的操作种类(= skill 脚本子命令;worklog 不进缓存故不在此列)。 */
15
+ export type CacheOp = 'list' | 'history';
16
+ /** 缓存键:`op:workspace:filter` 三条信息都要,否则不同筛选 / 不同工作区会串味。 */
17
+ export declare function cacheKey(workspaceId: string, op: CacheOp, detail: string): string;
18
+ /**
19
+ * 取数(命中即返回,未命中跑 `load` 并落缓存)。
20
+ * `now` 仅供冒烟注入;业务代码不传,默认取当前时刻。
21
+ */
22
+ export declare function cached<T>(key: string, load: () => Promise<T>, now?: number): Promise<T>;
23
+ /**
24
+ * 让缓存失效:不传 = 全清(`fresh=1` 手动刷新用);传工作区 id = 只清该工作区。
25
+ * 写路径(任务 4,未拍板)落地后在其成功后调本函数;现在只有手动刷新这一处用。
26
+ */
27
+ export declare function invalidate(workspaceId?: string): void;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 读缓存(方案 B 的落地件,2026-10-18 拍板的三条口径,无黑魔法):
3
+ *
4
+ * ① 进程内 `Map`,key = `op:workspace:filter`(filter 已序列化),值 = 结果 + 时间戳;
5
+ * ② 命中且 1.5s 内直接返回、**不起进程**;同 key 并发**单飞**(只跑一次脚本,其余等同一个 Promise);
6
+ * ③ `fresh=1` 绕过(路由层先 `invalidate()` 再算);写路径成功后 `invalidate(workspaceId)` 钩子。
7
+ *
8
+ * 边界:只缓存 skill 脚本的两读(在办 list / 历史 history)。worklog 正文不缓存——它会被
9
+ * 会话里的 Agent 实时改写,缓存 1.5s 也会让用户看到上一版。**失败不入缓存**(错就是错,重试要能重新跑)。
10
+ * 命中返回的是**同一个引用**:调用方只许读(上层 service 全是纯计算,不原地改数组)。
11
+ */
12
+ /** 缓存有效期(2026-10-18 拍板 1.5s:够挡同一屏的重复读,短到不会让人看到旧数据)。 */
13
+ export const CACHE_TTL_MS = 1_500;
14
+ const SEP = '\u0000';
15
+ /** 已完成的缓存:key → 值与时间戳。 */
16
+ const settled = new Map();
17
+ /** 在途单飞:key → 正在跑的 Promise(同 key 并发只跑一次)。 */
18
+ const inflight = new Map();
19
+ /** 缓存键:`op:workspace:filter` 三条信息都要,否则不同筛选 / 不同工作区会串味。 */
20
+ export function cacheKey(workspaceId, op, detail) {
21
+ return `${op}${SEP}${workspaceId}${SEP}${detail}`;
22
+ }
23
+ /**
24
+ * 取数(命中即返回,未命中跑 `load` 并落缓存)。
25
+ * `now` 仅供冒烟注入;业务代码不传,默认取当前时刻。
26
+ */
27
+ export function cached(key, load, now = Date.now()) {
28
+ const hit = settled.get(key);
29
+ if (hit !== undefined && now - hit.at < CACHE_TTL_MS)
30
+ return Promise.resolve(hit.value);
31
+ const pending = inflight.get(key);
32
+ if (pending !== undefined)
33
+ return pending;
34
+ const task = Promise.resolve()
35
+ .then(load)
36
+ .then((value) => {
37
+ settled.set(key, { at: Date.now(), value });
38
+ return value;
39
+ })
40
+ .finally(() => { inflight.delete(key); });
41
+ inflight.set(key, task);
42
+ return task;
43
+ }
44
+ /**
45
+ * 让缓存失效:不传 = 全清(`fresh=1` 手动刷新用);传工作区 id = 只清该工作区。
46
+ * 写路径(任务 4,未拍板)落地后在其成功后调本函数;现在只有手动刷新这一处用。
47
+ */
48
+ export function invalidate(workspaceId) {
49
+ if (workspaceId === undefined) {
50
+ settled.clear();
51
+ return;
52
+ }
53
+ for (const key of settled.keys()) {
54
+ if (key.split(SEP)[1] === workspaceId)
55
+ settled.delete(key);
56
+ }
57
+ }
@@ -12,10 +12,10 @@ export declare function hasLedger(workspacePath: string): boolean;
12
12
  /**
13
13
  * 在办任务(包 `list --json`;结果过 records 入口校验后打上 workspace)。
14
14
  * 脚本失败 / 形状不符抛错——由 service 层捕获进 degraded,路由层转 500 + 人话,
15
- * 不静默返空(空 = 撒谎)。
15
+ * 不静默返空(空 = 撒谎)。**过 1.5s 读缓存**(同屏多端点会反复读同一份在办)。
16
16
  */
17
17
  export declare function readTasks(workspace: LedgerWorkspace, filter?: LedgerFilter): Promise<TaskRow[]>;
18
- /** 已结案记录(包 `history --json`;`records` 必须存在且为数组,缺了即坏数据)。 */
18
+ /** 已结案记录(包 `history --json`;`records` 必须存在且为数组,缺了即坏数据)。同样过读缓存。 */
19
19
  export declare function readHistory(workspace: LedgerWorkspace, filter?: LedgerFilter): Promise<HistoryRecord[]>;
20
20
  /** 单条在办(展开行 / 详情用)。取不到回 null,不抛。 */
21
21
  export declare function getTask(workspace: LedgerWorkspace, taskId: string): Promise<TaskRow | null>;
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { runLedgerScript } from './runner.js';
5
5
  import { parseHistoryRecords, parseTaskList } from './records.js';
6
+ import { cacheKey, cached } from './cache.js';
6
7
  /** 台账目录名(各工作区根下的这一个目录;布局知识只在本文件出现一次)。 */
7
8
  const LEDGER_DIR = 'ledger';
8
9
  /**
@@ -39,20 +40,28 @@ export function hasLedger(workspacePath) {
39
40
  /**
40
41
  * 在办任务(包 `list --json`;结果过 records 入口校验后打上 workspace)。
41
42
  * 脚本失败 / 形状不符抛错——由 service 层捕获进 degraded,路由层转 500 + 人话,
42
- * 不静默返空(空 = 撒谎)。
43
+ * 不静默返空(空 = 撒谎)。**过 1.5s 读缓存**(同屏多端点会反复读同一份在办)。
43
44
  */
44
45
  export async function readTasks(workspace, filter = {}) {
45
- const result = await runLedgerScript('list', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
46
- if (!result.ok)
47
- throw new Error(`${workspace.title}: ${result.error}`);
48
- return parseTaskList(result.data, workspace);
46
+ return await cached(cacheKey(workspace.id, 'list', argsKey(filter)), async () => {
47
+ const result = await runLedgerScript('list', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
48
+ if (!result.ok)
49
+ throw new Error(`${workspace.title}: ${result.error}`);
50
+ return parseTaskList(result.data, workspace);
51
+ });
49
52
  }
50
- /** 已结案记录(包 `history --json`;`records` 必须存在且为数组,缺了即坏数据)。 */
53
+ /** 已结案记录(包 `history --json`;`records` 必须存在且为数组,缺了即坏数据)。同样过读缓存。 */
51
54
  export async function readHistory(workspace, filter = {}) {
52
- const result = await runLedgerScript('history', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
53
- if (!result.ok)
54
- throw new Error(`${workspace.title}: ${result.error}`);
55
- return parseHistoryRecords(result.data, workspace);
55
+ return await cached(cacheKey(workspace.id, 'history', argsKey(filter)), async () => {
56
+ const result = await runLedgerScript('history', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
57
+ if (!result.ok)
58
+ throw new Error(`${workspace.title}: ${result.error}`);
59
+ return parseHistoryRecords(result.data, workspace);
60
+ });
61
+ }
62
+ /** 缓存键的 filter 段:脚本参数原样序列化(分隔符取不可见字符,值里带空格也不会串)。 */
63
+ function argsKey(filter) {
64
+ return filterArgs(filter).join('\u0001');
56
65
  }
57
66
  /** 单条在办(展开行 / 详情用)。取不到回 null,不抛。 */
58
67
  export async function getTask(workspace, taskId) {
@@ -5,6 +5,7 @@
5
5
  * 见 external/host-webserver.md。
6
6
  */
7
7
  import type { IncomingMessage, ServerResponse } from 'node:http';
8
+ import { type AgentsLike } from './services/session.service.js';
8
9
  import { type RegistryWorkspace } from './workspace.js';
9
10
  /** 宿主 webServer.register 的最小面(WebRoute 形状见 external/host-webserver.md)。 */
10
11
  export interface WebServerLike {
@@ -22,3 +23,19 @@ export interface RouteDeps {
22
23
  readonly registry: WorkspaceRegistryLike;
23
24
  }
24
25
  export declare function registerRoutes(webServer: WebServerLike, deps: RouteDeps): void;
26
+ export interface SessionRouteDeps {
27
+ readonly registry: WorkspaceRegistryLike;
28
+ /** 起会话的服务面;宿主缺就回 503 人话(端点照常注册,前端不用按版本分支)。 */
29
+ readonly agents: AgentsLike | undefined;
30
+ /** 惰性服务读取:`agentPresets` / `agentDefaultModel` 可能整条不存在,或被 ctx 的 inject 门挡住。 */
31
+ readonly getService: (name: string) => unknown;
32
+ }
33
+ /**
34
+ * 注册「结束任务」端点:`POST /close`。
35
+ *
36
+ * 为什么只有这一条走会话(2026-10-18 用户拍板):认领 / 释放没有判断量,host 直写即可;
37
+ * **结束**要带用户留言、要模型读过程文档后写真摘要 —— 只能交给会话里的 Agent 做。
38
+ *
39
+ * 请求体:`{ ws, id, message?, provider?, model? }`;响应:`{ sessionId, warning? }`。
40
+ */
41
+ export declare function registerSessionRoutes(webServer: WebServerLike, deps: SessionRouteDeps): void;
@@ -1,5 +1,9 @@
1
1
  import { getHomeAggregate, getFilterOptions, queryClosed } from './board.js';
2
+ import { getSnapshot } from './services/home.service.js';
3
+ import { getTrend } from './services/trend.service.js';
2
4
  import { getTask, readTasks, readWorklog } from './repositories/ledger.repository.js';
5
+ import { invalidate } from './repositories/cache.js';
6
+ import { HandoffError, startCloseSession, } from './services/session.service.js';
3
7
  import { resolveWorkspaces } from './workspace.js';
4
8
  /** API 前缀(唯一真源就在这一处)。 */
5
9
  const API_PREFIX = '/api/task-ledger';
@@ -52,90 +56,235 @@ function workspacesOf(deps) {
52
56
  return resolveWorkspaces(deps.registry.list());
53
57
  }
54
58
  export function registerRoutes(webServer, deps) {
55
- webServer.register({
56
- kind: 'exact',
57
- path: `${API_PREFIX}/workspaces`,
58
- handler: (_req, res) => { sendJson(res, 200, { workspaces: workspacesOf(deps) }); },
59
- });
59
+ /**
60
+ * 注册一个**读**端点:统一先看 `?fresh=1`(手动刷新 = 清缓存再算,别让用户按了刷新还看旧数)。
61
+ * 缓存 TTL 只有 1.5s,正常浏览照样吃缓存;写端点将来自己走写路径 + `invalidate()`,不走这里。
62
+ */
63
+ const registerRead = (path, handler) => {
64
+ webServer.register({
65
+ kind: 'exact',
66
+ path: `${API_PREFIX}${path}`,
67
+ handler: async (req, res) => {
68
+ if (new URL(req.url ?? '/', 'http://localhost').searchParams.get('fresh') === '1')
69
+ invalidate();
70
+ await handler(req, res);
71
+ },
72
+ });
73
+ };
74
+ registerRead('/workspaces', (_req, res) => { sendJson(res, 200, { workspaces: workspacesOf(deps) }); });
60
75
  // 首页一次拿全:在办快照 + 近 8 周趋势 + 四个自然周期计数(host 每个工作区只读一次)。
61
- webServer.register({
62
- kind: 'exact',
63
- path: `${API_PREFIX}/board`,
64
- handler: async (_req, res) => {
65
- try {
66
- const home = await getHomeAggregate(workspacesOf(deps));
67
- sendJson(res, 200, { ...home.snapshot, closed: home.closed, trend: home.trend, degraded: home.degraded });
76
+ registerRead('/board', async (_req, res) => {
77
+ try {
78
+ const home = await getHomeAggregate(workspacesOf(deps));
79
+ sendJson(res, 200, { ...home.snapshot, closed: home.closed, trend: home.trend, degraded: home.degraded });
80
+ }
81
+ catch (error) {
82
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
83
+ }
84
+ });
85
+ // ── 拆端点(2026-10-18 步骤 4a:纯加法,旧 /board 全量原样保留)──
86
+ // 快路径:首屏。只读 progress.json + git 身份,**不碰 history** ⇒ 耗时与历史总量解耦
87
+ // (20 万条量级实测 527ms vs 合并端点 4.4s)。
88
+ registerRead('/board/snapshot', async (_req, res) => {
89
+ try {
90
+ const result = await getSnapshot(workspacesOf(deps));
91
+ sendJson(res, 200, { ...result.snapshot, degraded: result.degraded });
92
+ }
93
+ catch (error) {
94
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
95
+ }
96
+ });
97
+ // 慢路径:趋势(近 8 周 + 四个自然周期)。读 history ⇒ 异步/按需拉,首屏不等它。
98
+ registerRead('/board/trend', async (_req, res) => {
99
+ try {
100
+ const result = await getTrend(workspacesOf(deps));
101
+ sendJson(res, 200, { closed: result.closed, trend: result.trend, degraded: result.degraded });
102
+ }
103
+ catch (error) {
104
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
105
+ }
106
+ });
107
+ registerRead('/tasks', async (req, res) => {
108
+ try {
109
+ const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
110
+ const perWorkspace = await Promise.all(workspacesOf(deps).map((workspace) => readTasks(workspace, filter)));
111
+ sendJson(res, 200, { tasks: perWorkspace.flat() });
112
+ }
113
+ catch (error) {
114
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
115
+ }
116
+ });
117
+ // 任务详情 + 过程文档正文(query 传 ws/id,不做路径段解析——任务号含中文和连字符)。
118
+ registerRead('/task', async (req, res) => {
119
+ try {
120
+ const url = new URL(req.url ?? '/', 'http://localhost');
121
+ const workspace = workspacesOf(deps).find((item) => item.id === url.searchParams.get('ws'));
122
+ const taskId = url.searchParams.get('id');
123
+ if (workspace === undefined || taskId === null) {
124
+ sendJson(res, 400, { error: '缺少 ws / id 参数' });
125
+ return;
68
126
  }
69
- catch (error) {
70
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
127
+ const task = await getTask(workspace, taskId);
128
+ if (task === null) {
129
+ sendJson(res, 404, { error: '任务不存在' });
130
+ return;
71
131
  }
72
- },
132
+ sendJson(res, 200, { task, worklog: await readWorklog(workspace, task) });
133
+ }
134
+ catch (error) {
135
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
136
+ }
137
+ });
138
+ registerRead('/history', async (req, res) => {
139
+ try {
140
+ const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
141
+ const result = await queryClosed(workspacesOf(deps), filter);
142
+ sendJson(res, 200, { records: result.records, degraded: result.degraded });
143
+ }
144
+ catch (error) {
145
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
146
+ }
73
147
  });
148
+ registerRead('/filters', async (_req, res) => {
149
+ try {
150
+ const result = await getFilterOptions(workspacesOf(deps));
151
+ sendJson(res, 200, { ...result.options, degraded: result.degraded });
152
+ }
153
+ catch (error) {
154
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
155
+ }
156
+ });
157
+ }
158
+ // ───────────────────────── 会话交接(按钮侧 · 写类端点) ─────────────────────────
159
+ /** 写端点请求体上限:只收一小段用户留言,别让人往这塞文件。 */
160
+ const BODY_LIMIT = 64 * 1024;
161
+ /** 读请求体(宿主 handler 收原始 req,没有 body parser;超上限直接拒,不缓冲垃圾)。 */
162
+ async function readJsonBody(req) {
163
+ const chunks = [];
164
+ let size = 0;
165
+ for await (const chunk of req) {
166
+ const buffer = chunk;
167
+ size += buffer.length;
168
+ if (size > BODY_LIMIT)
169
+ throw new Error('body-too-large');
170
+ chunks.push(buffer);
171
+ }
172
+ const text = Buffer.concat(chunks).toString('utf8');
173
+ if (text.trim() === '')
174
+ return {};
175
+ return JSON.parse(text);
176
+ }
177
+ /**
178
+ * 同源守卫(architecture 不变量 6 的落地):浏览器 fetch 必带与 Host 同源的 `Origin`;
179
+ * 没有 `Origin` 的(本机脚本 / Desktop IPC)只放行回环地址。
180
+ * 读端点保持现状(只读无副作用);**会起会话的写端点必须过这道**。
181
+ */
182
+ function isTrustedRequest(req) {
183
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
184
+ const host = typeof req.headers.host === 'string' ? req.headers.host : undefined;
185
+ if (origin === undefined) {
186
+ const address = req.socket.remoteAddress;
187
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
188
+ }
189
+ if (host === undefined)
190
+ return false;
191
+ try {
192
+ return new URL(origin).host === host;
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
198
+ /**
199
+ * 注册「结束任务」端点:`POST /close`。
200
+ *
201
+ * 为什么只有这一条走会话(2026-10-18 用户拍板):认领 / 释放没有判断量,host 直写即可;
202
+ * **结束**要带用户留言、要模型读过程文档后写真摘要 —— 只能交给会话里的 Agent 做。
203
+ *
204
+ * 请求体:`{ ws, id, message?, provider?, model? }`;响应:`{ sessionId, warning? }`。
205
+ */
206
+ export function registerSessionRoutes(webServer, deps) {
74
207
  webServer.register({
75
208
  kind: 'exact',
76
- path: `${API_PREFIX}/tasks`,
209
+ path: `${API_PREFIX}/close`,
77
210
  handler: async (req, res) => {
78
- try {
79
- const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
80
- const perWorkspace = await Promise.all(workspacesOf(deps).map((workspace) => readTasks(workspace, filter)));
81
- sendJson(res, 200, { tasks: perWorkspace.flat() });
211
+ if (req.method !== 'POST') {
212
+ sendJson(res, 405, { error: '「结束任务」只支持 POST' });
213
+ return;
82
214
  }
83
- catch (error) {
84
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
215
+ if (!isTrustedRequest(req)) {
216
+ sendJson(res, 403, { error: '只接受同源请求' });
217
+ return;
85
218
  }
86
- },
87
- });
88
- // 任务详情 + 过程文档正文(query 传 ws/id,不做路径段解析——任务号含中文和连字符)。
89
- webServer.register({
90
- kind: 'exact',
91
- path: `${API_PREFIX}/task`,
92
- handler: async (req, res) => {
219
+ let body;
93
220
  try {
94
- const url = new URL(req.url ?? '/', 'http://localhost');
95
- const workspace = workspacesOf(deps).find((item) => item.id === url.searchParams.get('ws'));
96
- const taskId = url.searchParams.get('id');
97
- if (workspace === undefined || taskId === null) {
98
- sendJson(res, 400, { error: '缺少 ws / id 参数' });
99
- return;
100
- }
101
- const task = await getTask(workspace, taskId);
102
- if (task === null) {
103
- sendJson(res, 404, { error: '任务不存在' });
104
- return;
105
- }
106
- sendJson(res, 200, { task, worklog: await readWorklog(workspace, task) });
221
+ body = await readJsonBody(req);
107
222
  }
108
223
  catch (error) {
109
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
224
+ const tooLarge = error instanceof Error && error.message === 'body-too-large';
225
+ sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? '请求体过大' : '请求体不是合法 JSON' });
226
+ return;
110
227
  }
111
- },
112
- });
113
- webServer.register({
114
- kind: 'exact',
115
- path: `${API_PREFIX}/history`,
116
- handler: async (req, res) => {
117
- try {
118
- const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
119
- const result = await queryClosed(workspacesOf(deps), filter);
120
- sendJson(res, 200, { records: result.records, degraded: result.degraded });
228
+ const fields = typeof body === 'object' && body !== null ? body : {};
229
+ const str = (key) => (typeof fields[key] === 'string' ? fields[key] : '');
230
+ const workspaceId = str('ws');
231
+ const taskId = str('id');
232
+ if (workspaceId === '' || taskId === '') {
233
+ sendJson(res, 400, { error: '缺少 ws / id 参数' });
234
+ return;
121
235
  }
122
- catch (error) {
123
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
236
+ const provider = str('provider');
237
+ const model = str('model');
238
+ if ((provider === '') !== (model === '')) {
239
+ sendJson(res, 400, { error: 'provider 与 model 必须成对给出' });
240
+ return;
241
+ }
242
+ const workspace = workspacesOf(deps).find((item) => item.id === workspaceId);
243
+ if (workspace === undefined) {
244
+ sendJson(res, 404, { error: '工作区不存在或不含台账' });
245
+ return;
124
246
  }
125
- },
126
- });
127
- webServer.register({
128
- kind: 'exact',
129
- path: `${API_PREFIX}/filters`,
130
- handler: async (_req, res) => {
131
247
  try {
132
- const result = await getFilterOptions(workspacesOf(deps));
133
- sendJson(res, 200, { ...result.options, degraded: result.degraded });
248
+ const result = await startCloseSession({
249
+ agents: deps.agents,
250
+ presets: deps.getService('agentPresets'),
251
+ defaultModel: deps.getService('agentDefaultModel'),
252
+ }, {
253
+ workspace,
254
+ taskId,
255
+ message: str('message'),
256
+ model: provider === '' ? undefined : { provider, model },
257
+ });
258
+ const warning = await attachSession(deps, workspaceId, result.sessionId) ?? result.warning;
259
+ sendJson(res, 200, warning === undefined ? { sessionId: result.sessionId } : { sessionId: result.sessionId, warning });
134
260
  }
135
261
  catch (error) {
262
+ if (error instanceof HandoffError) {
263
+ const status = error.code === 'task-missing'
264
+ ? 404
265
+ : error.code === 'no-agents' || error.code === 'no-preset' || error.code === 'no-model' ? 503 : 500;
266
+ sendJson(res, status, { error: error.message, code: error.code });
267
+ return;
268
+ }
136
269
  sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
137
270
  }
138
271
  },
139
272
  });
140
- // POST /sessions(按钮会话交接)归「任务按钮与会话交接」任务——写入路径 A/B 拍板后再接。
273
+ }
274
+ /**
275
+ * 会话归组(best effort):登记进工作区 `sessionIds`,否则侧栏里找不到这个会话。
276
+ * 缺失 / 失败都不算致命——会话本身已经建好、跳转可用,只如实回一条 warning。
277
+ */
278
+ async function attachSession(deps, workspaceId, sessionId) {
279
+ const entity = deps.registry.list().find((item) => item.id === workspaceId);
280
+ if (entity === undefined || typeof entity.attachSession !== 'function') {
281
+ return '会话已建,但宿主该工作区没有 attachSession 面:会话不会出现在工作区列表里';
282
+ }
283
+ try {
284
+ await entity.attachSession(sessionId);
285
+ return undefined;
286
+ }
287
+ catch (error) {
288
+ return `会话已建,但没能挂进工作区列表:${error instanceof Error ? error.message : String(error)}`;
289
+ }
141
290
  }
@@ -0,0 +1,110 @@
1
+ import type { LedgerWorkspace } from '../types.js';
2
+ /** 模型路由:provider 与 model 必须成对。 */
3
+ export interface ModelRoute {
4
+ readonly provider: string;
5
+ readonly model: string;
6
+ }
7
+ /** 会话首条 user 消息(`@deepseek-ai/dsh-llm` UserMessage 的最小同形面)。 */
8
+ export interface UserMessageLike {
9
+ readonly id: string;
10
+ readonly role: 'user';
11
+ readonly content: readonly {
12
+ readonly type: 'text';
13
+ readonly text: string;
14
+ }[];
15
+ /**
16
+ * 生产者自有 kind(宿主 v4 格式要求;`kind: 'plugin'` 已废弃会被拒)。
17
+ * form/summary 给官方界面渲染成通知卡用。
18
+ */
19
+ readonly source: {
20
+ readonly kind: string;
21
+ readonly form: 'notice';
22
+ readonly summary: string;
23
+ };
24
+ }
25
+ /** `ctx.agents` 最小面(`CreateAgentOptions` 见 session-handoff 文档 §二)。 */
26
+ export interface AgentsLike {
27
+ create(options: {
28
+ readonly sessionId: string;
29
+ readonly meta?: {
30
+ readonly cwd?: string;
31
+ readonly agentPreset?: string;
32
+ };
33
+ readonly agentOptions?: {
34
+ readonly provider?: string;
35
+ readonly model?: string;
36
+ };
37
+ readonly setup?: (agentCtx: unknown) => void | Promise<void>;
38
+ }): Promise<{
39
+ readonly agent: {
40
+ send(message: UserMessageLike, target: 'next-turn' | 'next-step', wakeup: boolean): void;
41
+ };
42
+ }>;
43
+ }
44
+ /** `ctx.agentPresets` 最小面:resolve 省略 id = 部署默认;mount 只在 setup 里调得到。 */
45
+ export interface AgentPresetsLike {
46
+ resolve(id?: string): Promise<{
47
+ readonly id: string;
48
+ }>;
49
+ mount(agentCtx: unknown, id?: string): Promise<unknown>;
50
+ }
51
+ /** `ctx.agentDefaultModel` 最小面(部署默认模型)。 */
52
+ export interface DefaultModelLike {
53
+ currentSelection(): ModelRoute;
54
+ }
55
+ /** 交接失败的原因码(路由层据此给状态码,文案在这里写成能直接上界面的)。 */
56
+ export type HandoffErrorCode = 'no-agents' | 'no-preset' | 'no-model' | 'task-missing' | 'create-failed' | 'send-failed';
57
+ export declare class HandoffError extends Error {
58
+ readonly code: HandoffErrorCode;
59
+ constructor(code: HandoffErrorCode, message: string);
60
+ }
61
+ /** 结束任务的预置提示词入参(纯数据,便于冒烟直接断言文案)。 */
62
+ export interface ClosePromptInput {
63
+ readonly title: string;
64
+ readonly taskId: string;
65
+ /** 任务所在工作区绝对路径(`--repo` 就是它)。 */
66
+ readonly workspacePath: string;
67
+ /** 过程文档相对 `ledger/` 的路径;缺失传 null,提示词如实说「没有」。 */
68
+ readonly worklogPath: string | null;
69
+ /** 用户在弹框里留的话(可为空)。 */
70
+ readonly message: string;
71
+ }
72
+ /**
73
+ * 「结束任务」提示词(2026-10-18 用户口述定稿)。
74
+ *
75
+ * 结构照用户原话:**预置说明在前,用户留言放最末**(越靠后、越只讲一件事的段落遵守率越高 ——
76
+ * 参考插件真机结论)。三道硬约束缺一不可:
77
+ * ① 不要开新会话(用户:我要结束这个流程了,不能让模型再开一堆框);
78
+ * ② 读过程文档后直接收尾(别再回头问,用户已经留言了);
79
+ * ③ 写台账只经 skill 脚本(插件不手改文件,close 由模型执行)。
80
+ */
81
+ export declare function buildClosePrompt(input: ClosePromptInput): string;
82
+ /** 交接入参(路由层校验完协议字段后给)。 */
83
+ export interface CloseHandoffRequest {
84
+ readonly workspace: LedgerWorkspace;
85
+ readonly taskId: string;
86
+ readonly message: string;
87
+ /** 用户选的模型;不给 = 用宿主默认。 */
88
+ readonly model?: ModelRoute;
89
+ }
90
+ export interface CloseHandoffResult {
91
+ readonly sessionId: string;
92
+ /** 非致命问题(如会话没能挂进工作区列表),如实回给调用方显示,不吞。 */
93
+ readonly warning?: string;
94
+ }
95
+ /** 依赖面(都是惰性服务,缺了给 503 人话,不硬崩)。 */
96
+ export interface CloseHandoffDeps {
97
+ readonly agents: AgentsLike | undefined;
98
+ readonly presets: AgentPresetsLike | undefined;
99
+ readonly defaultModel: DefaultModelLike | undefined;
100
+ }
101
+ /**
102
+ * 起一个「结束任务」会话并发出首条提示词。
103
+ * 失败抛 {@link HandoffError}(带原因码);成功返回 sessionId 供客户端 `openSession` 跳转。
104
+ */
105
+ export declare function startCloseSession(deps: CloseHandoffDeps, request: CloseHandoffRequest): Promise<CloseHandoffResult>;
106
+ /**
107
+ * 模型路由:用户选了就必须给全一对(只有一半 = 配错,直接人话拒绝);
108
+ * 没选就走宿主部署默认。**没有兜底猜测**——猜一个模型去结案比报错更糟。
109
+ */
110
+ export declare function resolveModelRoute(defaultModel: DefaultModelLike | undefined, explicit?: ModelRoute): ModelRoute;