dsh-agent-tasks 0.1.1 → 0.1.2

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.
Files changed (2) hide show
  1. package/index.js +2031 -1977
  2. package/package.json +19 -6
package/index.js CHANGED
@@ -1,1977 +1,2031 @@
1
- /**
2
- * dsh-agent-tasks — AI-Agent-Hub 的任务线 / agent 通信工具。
3
- *
4
- * 2026-11-10 起从这里起步:先把 **agent_message**(146 行、只依赖 hub.get、完全自包含)
5
- * 从 hub-desktop-ui/index.js 的 register 块(原 L6191-6337)搬过来,作为拆包的样板。
6
- * 其余 11 个工具按同样方法逐个搬入(各自依赖已在拆包前查清:sessions / hub.get /
7
- * core 的 projectOfSessionTitle+hostRoot / clone-handoff 的 collectEvents)。
8
- *
9
- * 用法(hub-desktop-ui 里):
10
- * import { registerAgentTools } from 'dsh-agent-tasks';
11
- * const disposers = registerAgentTools({ hub, register, deps });
12
- */
13
- export const PACKAGE_VERSION = '0.1.0';
14
-
15
- /**
16
- * 注册本包负责的工具。
17
- * @param {{ hub: object, register: (definition: object) => unknown, deps?: object }} opts
18
- * @returns {unknown[]} disposers(与 tools.register 的返回值一致)
19
- */
20
- import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21
- import { randomUUID } from 'node:crypto';
22
- import { homedir, tmpdir } from 'node:os';
23
- import { dirname, join, resolve } from 'node:path';
24
- import { collectEvents, writeCloneHandoff } from 'dsh-clone-handoff';
25
- import {
26
- agentNameOfTitle,
27
- cleanCloneRegistry,
28
- dedupeActiveTitles,
29
- deliveryConclusion,
30
- ensureLiveSession,
31
- ensureMemberAvatar,
32
- goalDirective,
33
- guardRoutingFile,
34
- hostRoot,
35
- INTERRUPT_IDLE_WAIT_MS,
36
- isQueueTriageTitle,
37
- looksTaskSpecificName,
38
- noticeMessage,
39
- planDelivery,
40
- projectOfSessionTitle,
41
- pruneArchivedSessions,
42
- readJsonForWrite,
43
- readRegisteredSessionIds,
44
- readTaskFile,
45
- renderTasklinePrompt,
46
- resumeInterruptedSessions,
47
- setTaskGoal,
48
- writeJsonAtomic,
49
- resumeLedgerPath,
50
- RESUME_PROMPT,
51
- RESUME_MAX_PER_BOOT,
52
- titleOf,
53
- readGlobalAvatars,
54
- writeGlobalAvatar,
55
- AVATAR_POOL,
56
- recordResumeSweep,
57
- shouldSkipResumeByLedger,
58
- projectsDir,
59
- boardPath,
60
- tasksPath,
61
- routingPath,
62
- handoffsDir,
63
- triageDecisionOf,
64
- readSessionEvents,
65
- compactSessionEvents,
66
- } from 'dsh-hub-core';
67
-
68
- export function registerAgentTools({ hub, register, deps = {} }) {
69
- // defaultPreset(2026-11-10 修):12 个工具迁出 hub-desktop-ui 时漏注入了这个。
70
- // 因为 taskline_create 里写的是 `args.preset || defaultPreset`(短路求值),
71
- // 只要调用方传了 preset 就永远不会求值 → 一直没暴露;
72
- // 一旦不传 preset 就直接 ReferenceError: defaultPreset is not defined。
73
- // 兜底 'cordis' 与 hub-desktop-ui 那边 `config.defaultPreset || 'cordis'` 保持一致。
74
- const { callerTitleOf, isRunningNow, pendingDispatchesOf, recordDispatch, resolveSession, snapshotTargetRunning, spawnQueueTriage, titleOfSession, waitForAgentIdle, wakeAgentMessageTarget, liveRunning, queueTriageEnabled, defaultPreset = 'cordis' } = deps;
75
- const disposers = [];
76
-
77
- // —— agent_message(2026-10-23 用户令;原 hub-desktop-ui L6191-6337)——
78
- disposers.push(register({
79
- name: 'agent_message',
80
- description: '给任意 agent(含其它项目)直接发一条消息:讨论、要资料、同步状态、协作求解。'
81
- + '交流放行(跨项目可用);正式任务分派仍走本项目管理者的 taskline_prompt。'
82
- + 'mode 默认 steer=**中途插队**(目标在跑时在下一个 step 边界插入当前轮,不打断正在执行的工具;目标空闲自动退化为排队),'
83
- + 'mode=queue 才是排队等回合结束。'
84
- + '别用它给忙碌的 agent 反复轰炸转派任务——接活请走管理者。',
85
- parameters: {
86
- type: 'object',
87
- additionalProperties: false,
88
- required: ['target', 'text'],
89
- properties: {
90
- target: { type: 'string', description: '目标:完整 sessionId 或「项目 · 成员」标题(可部分匹配)' },
91
- text: { type: 'string', description: '要说的话(会自动加【来自 <你>】前缀)' },
92
- replyExpected: { type: 'boolean', description: 'true 时文末提示对方回一句(默认不加)' },
93
- mode: {
94
- type: 'string',
95
- enum: ['steer', 'queue'],
96
- description: '投递模式:steer(默认)= 目标在跑时插进当前轮的下一个 step 边界(不打断当前工具调用;目标空闲退化为排队);'
97
- + 'queue = 排队等它当前回合结束',
98
- },
99
- },
100
- },
101
- output: {
102
- schema: {
103
- type: 'object',
104
- additionalProperties: false,
105
- required: ['ok'],
106
- properties: {
107
- ok: { type: 'boolean' },
108
- sessionId: { type: 'string' },
109
- targetTitle: { type: 'string' },
110
- crossProject: { type: 'boolean' },
111
- callerTitle: { type: 'string' },
112
- ambiguous: { type: 'boolean' },
113
- candidates: { type: 'array', items: { type: 'string' } },
114
- mode: { type: 'string' },
115
- woke: { type: 'boolean' },
116
- wakeNote: { type: 'string' },
117
- error: { type: 'string' },
118
- },
119
- },
120
- render: (_args, value) => [{
121
- type: 'text',
122
- text: value.ok
123
- ? `已发给「${value.targetTitle}」${value.crossProject ? '(跨项目)' : ''}${value.woke ? ',并已唤醒它开始回复' : (value.wakeNote ? `(未唤醒:${value.wakeNote})` : '')}`
124
- : (value.ambiguous
125
- ? `命中多个会话,请用完整 sessionId 重发:\n${(value.candidates || []).join('\n')}`
126
- : `发送失败:${value.error || 'unknown'}`),
127
- }],
128
- },
129
- async execute(args, exec) {
130
- const controller = hub.get('sessionController');
131
- if (!controller || typeof controller.prompt !== 'function') {
132
- return { ok: false, error: 'sessionController 不可用' };
133
- }
134
- // P8-①(2026-10-29):text 缺失/非字符串时拒绝投递(同 taskline_prompt 防 undefined)
135
- if (typeof args.text !== 'string' || !args.text.trim()) {
136
- return { ok: false, error: '缺少 text(收到 undefined/空/非字符串)。请检查 agent_message 调用参数后重发;本条未投递。' };
137
- }
138
- const ref = String(args.target || '').trim();
139
- const isExactId = /^session-[0-9a-f-]{20,}$/i.test(ref);
140
- // 目标解析:完整 id 直用;标题片段跨**全部项目**清点(同名列候选)
141
- let session = isExactId ? deps.resolveSession(ref) : undefined;
142
- let ambiguity = null;
143
- if (!session) {
144
- try {
145
- const value = await controller.list(new AbortController().signal);
146
- const rows = value && Array.isArray(value.items) ? value.items : (Array.isArray(value) ? value : []);
147
- const registry = hub.get('workspaceRegistry');
148
- const archivedSet = new Set(
149
- registry && Array.isArray(registry.archivedSessionIds)
150
- ? registry.archivedSessionIds.map(String) : [],
151
- );
152
- const candidates = [];
153
- for (const row of rows) {
154
- if (!row || !row.sessionId || archivedSet.has(String(row.sessionId))) continue;
155
- const t = row.projections && row.projections.values && typeof row.projections.values.title === 'string'
156
- ? row.projections.values.title : '';
157
- if (!t || !t.includes(ref)) continue;
158
- candidates.push({ id: String(row.sessionId), title: t });
159
- }
160
- if (candidates.length === 1) {
161
- session = { id: candidates[0].id, displayTitle: candidates[0].title };
162
- } else if (candidates.length > 1) {
163
- ambiguity = candidates;
164
- }
165
- } catch (error) { /* 清点失败退回 live 解析 */ }
166
- }
167
- if (!session) session = deps.resolveSession(ref);
168
- if (ambiguity && ambiguity.length > 1) {
169
- return {
170
- ok: false,
171
- ambiguous: true,
172
- candidates: ambiguity.map((c) => `${c.title} ${c.id}`),
173
- error: '同名多个会话',
174
- };
175
- }
176
- if (!session) return { ok: false, error: '找不到这个会话(可用 taskline_list 查完整 sessionId)' };
177
- // 唤醒 + 投递(ensureLiveSession 每次 resolveAgent,冷会话也能收)
178
- const live = await ensureLiveSession(hub, controller, String(session.id));
179
- if (!live.ok) return { ok: false, error: live.error || '会话不可用' };
180
- const callerAgent = exec && exec.agent;
181
- const callerTitle = callerAgent ? deps.callerTitleOf(callerAgent) : '';
182
- const targetTitle = deps.titleOfSession(live.session) || session.displayTitle || '';
183
- const projectOfTitle = (t) => (t && t.includes(' · ') ? t.slice(0, t.lastIndexOf(' · ')).trim() : '');
184
- const crossProject = Boolean(
185
- projectOfTitle(callerTitle) && projectOfTitle(targetTitle)
186
- && projectOfTitle(targetTitle) !== projectOfTitle(callerTitle),
187
- );
188
- const body = `【来自 ${callerTitle || '某 agent'}】${String(args.text || '').trim()}`
189
- + (args.replyExpected ? '\n(方便的话回我一句)' : '');
190
- // 投递模式(2026-10-23 用户令「要可以中途插队」):默认 steer。
191
- // steer = 目标在跑时在下一个 step 边界插入当前轮(不打断正在执行的工具调用);
192
- // 目标空闲时 DSH 侧自动退化为排队 —— 所以如实回报「实际生效模式」。
193
- const requestedMode = args.mode === 'queue' ? 'queue' : 'steer';
194
- let targetRunning = false;
195
- try {
196
- const agents = hub.get('agents');
197
- const agent = agents && typeof agents.get === 'function' ? agents.get(String(live.session.id)) : undefined;
198
- targetRunning = Boolean(agent && String(agent.status) === 'running');
199
- } catch (error) { /* 取不到就按空闲处理 */ }
200
- const effectiveMode = requestedMode === 'steer' && !targetRunning ? 'queue' : requestedMode;
201
- try {
202
- await controller.prompt({
203
- requestId: `agent-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
204
- sessionId: String(live.session.id),
205
- mode: requestedMode,
206
- content: [{ type: 'text', text: body }],
207
- }, new AbortController().signal);
208
- // P3(2026-10-27 杨航拍板「允许跨项目自动唤醒」):投递后若目标仍空闲,
209
- // 复用 resume_session 的同一实现唤醒一次,避免只建空 turn(lastTurn+1 / response="")。
210
- const wake = await deps.wakeAgentMessageTarget(String(live.session.id));
211
- return {
212
- ok: true,
213
- sessionId: String(live.session.id),
214
- targetTitle,
215
- crossProject,
216
- callerTitle,
217
- mode: effectiveMode,
218
- ...(wake.woke ? { woke: true } : {}),
219
- ...(wake.reason ? { wakeNote: wake.reason } : {}),
220
- };
221
- } catch (error) {
222
- return { ok: false, error: String((error && error.message) || error) };
223
- }
224
- },
225
- }
226
- ));
227
-
228
- // —— vision_read ——
229
- disposers.push(register({
230
- name: 'vision_read',
231
- description: '⛔【视觉模型调用即违规】能收图的模型(inputModalities 含 image)请直接读附件或 read_image,**不要调用本工具**——'
232
- + '本工具仅供**确认无图像输入能力的纯文本模型**兜底。'
233
- + '功能:让视觉模型替你看图,返回文字结果(描述 / 针对某个问题回答 / OCR 逐字转写)。'
234
- + '违反示例:视觉模型调用它 = 把原图转述成有损文字、多跳一次常失败的后端、浪费 token。',
235
- parameters: {
236
- type: 'object',
237
- additionalProperties: false,
238
- required: ['source'],
239
- properties: {
240
- source: { type: 'string', description: '图片路径:绝对路径、~ 开头或 file:// URL;无扩展名也能识别(按文件头)' },
241
- question: { type: 'string', description: '要问的问题;省略则返回整体描述' },
242
- ocr: { type: 'boolean', description: 'true = 逐字转写图中所有文字(此时忽略 question)' },
243
- },
244
- },
245
- output: {
246
- schema: {
247
- type: 'object',
248
- additionalProperties: false,
249
- required: ['ok', 'text'],
250
- properties: {
251
- ok: { type: 'boolean' },
252
- text: { type: 'string' },
253
- error: { type: 'string' },
254
- },
255
- },
256
- render: (_args, value) => [{
257
- type: 'text',
258
- text: value.ok ? value.text : `vision_read 失败:${value.error || '未知原因'}`,
259
- }],
260
- },
261
- timeoutMs: 300000,
262
- async execute(args, exec) {
263
- // ① 提示视觉模型"你本来就能看图"(2026-10-26 软化:不再硬拒答——
264
- // 客户端拦截后图片以路径文本进会话,视觉模型拿不到附件内容时也该能代看,
265
- // 否则会出现"路径给了但谁也看不了"的死角)。判定不出就照常工作。
266
- let visionModelNote = '';
267
- try {
268
- const llm = hub.get('llm');
269
- let selection;
270
- try {
271
- const agentsSvc = hub.get('agents');
272
- const ref = agentsSvc && typeof agentsSvc.selectionFor === 'function'
273
- ? agentsSvc.selectionFor(exec.agent) : undefined;
274
- selection = ref && ref.current;
275
- } catch (error) { /* 忽略 */ }
276
- if (!selection && exec.agent && typeof exec.agent.currentSelection === 'function') {
277
- selection = exec.agent.currentSelection();
278
- }
279
- if (selection && llm && typeof llm.resolveModelInfo === 'function') {
280
- const info = await llm.resolveModelInfo(selection.provider, selection.model, exec.signal);
281
- const modalities = info && info.inputModalities;
282
- if (Array.isArray(modalities) && modalities.includes('image')) {
283
- visionModelNote = `\n\n[注:当前模型(${selection.model})支持图像输入;若你能直接看到该图片附件,优先以原生视图为准。]`;
284
- }
285
- }
286
- } catch (error) { /* 判定失败:继续执行 */ }
287
-
288
- // ② 定位 vision-tools 工具包
289
- const toolkitCandidates = [
290
- process.env.DSH_VISION_TOOLKIT,
291
- join(homedir(), 'Documents', 'GitHub', 'AI-Agent-Hub', 'skills', 'vision-tools'),
292
- join(homedir(), '.dsh', 'skills', 'vision-tools'),
293
- ].filter(Boolean);
294
- const toolkit = toolkitCandidates.find((dir) => existsSync(join(dir, 'bin', 'glance')));
295
- if (!toolkit) {
296
- return { ok: false, text: '', error: `找不到 vision-tools(试过:${toolkitCandidates.join(' | ')})` };
297
- }
298
- const scriptsDir = join(toolkit, 'scripts');
299
-
300
- // ③ python 解释器
301
- const pythonCandidates = [
302
- process.env.DSH_VISION_PYTHON,
303
- join(process.env.LOCALAPPDATA || '', 'Programs', 'Python', 'Python313', 'python.exe'),
304
- 'python',
305
- ].filter(Boolean);
306
- const python = pythonCandidates.find((exe) => exe === 'python' || existsSync(exe)) || 'python';
307
-
308
- // ④ 图片路径 + 扩展名归一(DSH 附件对象名是 sha256,没有扩展名)
309
- let raw = String(args.source || '').trim();
310
- if (!raw) return { ok: false, text: '', error: 'source 不能为空' };
311
- if (raw.startsWith('file://')) {
312
- try { raw = decodeURIComponent(new URL(raw).pathname).replace(/^\/([A-Za-z]:)/, '$1'); } catch (error) { /* 保留原样 */ }
313
- }
314
- if (raw.startsWith('~')) raw = join(homedir(), raw.slice(1));
315
- const imagePath = resolve(raw);
316
- if (!existsSync(imagePath)) return { ok: false, text: '', error: `图片不存在:${imagePath}` };
317
- const knownExt = /\.(png|jpe?g|gif|webp)$/i.test(imagePath);
318
- let stagedPath = imagePath;
319
- let tempPath = '';
320
- if (!knownExt) {
321
- let magic = '';
322
- try { magic = readFileSync(imagePath).subarray(0, 12).toString('latin1'); } catch (error) { /* ignore */ }
323
- const ext = magic.startsWith('\u0089PNG') ? '.png'
324
- : magic.startsWith('\u00ff\u00d8\u00ff') ? '.jpg'
325
- : magic.startsWith('GIF8') ? '.gif'
326
- : (magic.slice(0, 4) === 'RIFF' && magic.slice(8, 12) === 'WEBP') ? '.webp'
327
- : '';
328
- if (!ext) return { ok: false, text: '', error: `无法识别图片格式(支持 png/jpeg/gif/webp):${imagePath}` };
329
- tempPath = join(tmpdir(), `dsh-vision-${randomUUID()}${ext}`);
330
- try { writeFileSync(tempPath, readFileSync(imagePath)); } catch (error) {
331
- return { ok: false, text: '', error: `暂存图片失败:${String(error && error.message)}` };
332
- }
333
- stagedPath = tempPath;
334
- }
335
-
336
- // ⑤a 首选 glance(skills/vision-tools 的本地视觉后端)
337
- const askQuestion = args.ocr === true
338
- ? '请逐字转写图里所有文字(保持原有换行与标点),不要解释。'
339
- : (args.question ? String(args.question) : '请描述这张图片:整体内容、关键元素、图内可见文字。');
340
- const cliArgs = [join(toolkit, 'bin', 'glance'), stagedPath];
341
- if (args.ocr === true) cliArgs.push('--ocr');
342
- else if (args.question) cliArgs.push('-q', String(args.question));
343
-
344
- const runOnce = () => new Promise((resolveRun) => {
345
- const child = spawn(python, cliArgs, {
346
- cwd: toolkit,
347
- env: { ...process.env, PYTHONPATH: scriptsDir },
348
- windowsHide: true,
349
- });
350
- let out = '';
351
- let err = '';
352
- const timer = setTimeout(() => { try { child.kill(); } catch (error) { /* ignore */ } }, 280000);
353
- child.stdout.on('data', (chunk) => { out += chunk.toString(); });
354
- child.stderr.on('data', (chunk) => { err += chunk.toString(); });
355
- child.on('error', (error) => { clearTimeout(timer); resolveRun({ code: -1, out, err: String(error && error.message) }); });
356
- child.on('close', (code) => { clearTimeout(timer); resolveRun({ code, out, err }); });
357
- });
358
-
359
- // ⑤b 兜底后端:OpenAI 兼容的视觉 Chat API(2026-10-26:本机 vision-tools 的
360
- // token-plan 后端全线 403 AccessDenied.Unpurchased,改用已验证接受图像输入的
361
- // Ark DeepSeek V4.1 经济版;可用环境变量覆盖 base/model/key)。
362
- const readArkKey = () => {
363
- if (process.env.DSH_VISION_API_KEY) return process.env.DSH_VISION_API_KEY;
364
- try {
365
- const creds = readFileSync(join(homedir(), '.dsh', '.credentials.yaml'), 'utf8');
366
- const m = /ARK_AGENT_PLAN_API_KEY:\s*['"]?([^'"\s]+)['"]?/.exec(creds);
367
- if (m) return m[1];
368
- } catch (error) { /* ignore */ }
369
- return process.env.ARK_AGENT_PLAN_API_KEY || '';
370
- };
371
- const fallbackVision = async () => {
372
- const key = readArkKey();
373
- if (!key) return { ok: false, error: '没有可用的视觉 API key(DSH_VISION_API_KEY / ARK_AGENT_PLAN_API_KEY)' };
374
- const base = String(process.env.DSH_VISION_BASE_URL || 'https://ark.cn-beijing.volces.com/api/plan/v3').replace(/\/+$/, '');
375
- const model = String(process.env.DSH_VISION_MODEL || 'deepseek-v4-1-flash-economy-261017');
376
- let b64 = '';
377
- try { b64 = readFileSync(stagedPath).toString('base64'); } catch (error) {
378
- return { ok: false, error: `读取图片失败:${String(error && error.message)}` };
379
- }
380
- const mime = /\.jpe?g$/i.test(stagedPath) ? 'image/jpeg'
381
- : /\.gif$/i.test(stagedPath) ? 'image/gif'
382
- : /\.webp$/i.test(stagedPath) ? 'image/webp' : 'image/png';
383
- const body = JSON.stringify({
384
- model,
385
- max_tokens: 2048,
386
- messages: [{
387
- role: 'user',
388
- content: [
389
- { type: 'text', text: askQuestion },
390
- { type: 'image_url', image_url: { url: `data:${mime};base64,${b64}` } },
391
- ],
392
- }],
393
- });
394
- try {
395
- const res = await fetch(`${base}/chat/completions`, {
396
- method: 'POST',
397
- headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
398
- body,
399
- signal: AbortSignal.timeout(180000),
400
- });
401
- const raw = await res.text();
402
- if (res.status >= 400) return { ok: false, error: `HTTP ${res.status}: ${raw.slice(0, 400)}` };
403
- const data = JSON.parse(raw);
404
- const text = String((((data.choices || [])[0] || {}).message || {}).content || '').trim();
405
- return text ? { ok: true, text: `${text}\n\n[via fallback vision model ${model}]` }
406
- : { ok: false, error: `视觉后端返回空内容:${raw.slice(0, 300)}` };
407
- } catch (error) {
408
- return { ok: false, error: String(error && error.message) };
409
- }
410
- };
411
-
412
- try {
413
- const result = await runOnce();
414
- const text = String(result.out || '').trim();
415
- if (result.code === 0 && text) return { ok: true, text: text + visionModelNote };
416
- const detail = String(result.err || '').trim() || text || `退出码 ${result.code}`;
417
- // glance 后端失败 → 走兜底视觉模型(不把两段错误都抛给模型,只留摘要)
418
- const fallback = await fallbackVision();
419
- if (fallback.ok) return { ok: true, text: fallback.text + visionModelNote };
420
- return {
421
- ok: false,
422
- text: '',
423
- error: `glance 失败:${detail.slice(0, 500)};兜底也失败:${String(fallback.error || '').slice(0, 500)}`,
424
- };
425
- } finally {
426
- if (tempPath) { try { rmSync(tempPath, { force: true }); } catch (error) { /* ignore */ } }
427
- }
428
- },
429
- }));
430
-
431
- // —— routing_put ——
432
- disposers.push(register({
433
- name: 'routing_put',
434
- description: '把用户消息归到任务线:合并写入 .hub/projects/<项目>/routing.json(原子写+自愈,不会覆盖已有条目)。'
435
- + '代替手工 read+edit routing.json——归线一律走这里。'
436
- + 'entries={"eventId":"taskId"} 逐条给;或 ids=[...] + lane="t7" 批量归同一条线。'
437
- + 'taskId 取值:_none(过程消息)/ meeting(会议)/ t<数字>(任务线)。',
438
- parameters: {
439
- type: 'object',
440
- additionalProperties: false,
441
- required: ['project'],
442
- properties: {
443
- project: { type: 'string', description: '项目名(.hub/projects/ 下的目录名)' },
444
- entries: {
445
- type: 'object',
446
- description: '{ "<eventId>": "<taskId>" } 逐条映射;eventId 是 taskline_messages 返回的 eventId',
447
- additionalProperties: { type: 'string' },
448
- },
449
- ids: { type: 'array', items: { type: 'string' }, description: '批量归同一条线时的事件 id 列表(与 lane 搭配)' },
450
- lane: { type: 'string', description: '批量模式的目标 taskId(如 t7 / _none / meeting)' },
451
- },
452
- },
453
- output: {
454
- schema: {
455
- type: 'object',
456
- additionalProperties: false,
457
- required: ['ok'],
458
- properties: {
459
- ok: { type: 'boolean' },
460
- written: { type: 'number' },
461
- skipped: { type: 'array', items: { type: 'string' } },
462
- total: { type: 'number' },
463
- error: { type: 'string' },
464
- },
465
- },
466
- render: (_args, value) => [{
467
- type: 'text',
468
- text: value.ok
469
- ? `routing.json 已合并写入 ${value.written} 条(总 ${value.total} 条)${value.skipped && value.skipped.length ? `;跳过已存在/非法:${value.skipped.join('、')}` : ''}`
470
- : `写入失败:${value.error || 'unknown'}`,
471
- }],
472
- },
473
- async execute(args) {
474
- try {
475
- const project = String(args.project || '').trim().replace(/[\\/:*?"<>|]/g, '_');
476
- if (!project) return { ok: false, error: 'project 不能为空' };
477
- // 组装待写映射
478
- const want = {};
479
- const skipped = [];
480
- // P12-①(2026-11-02):原来只认 `t<数字>`,但星阶等项目用的是**语义 taskId**
481
- // (status / stelscala-pm / week-plan-daily-exclude…),导致整批被 skipped
482
- // → 「没有可写条目」。现改为:`_none`/`meeting` 直通 ∪ tasks.json 里的真实 id ∪ slug 兜底。
483
- let knownTaskIds = new Set();
484
- try {
485
- const tasksFile = join(hostRoot(hub), '.hub', 'projects', project, 'tasks.json');
486
- const tasksRaw = readJsonForWrite(tasksFile);
487
- if (tasksRaw.ok && tasksRaw.value && Array.isArray(tasksRaw.value.tasks)) {
488
- knownTaskIds = new Set(tasksRaw.value.tasks
489
- .map((t) => (t && (t.id || t.taskId)) ? String(t.id || t.taskId) : '')
490
- .filter(Boolean));
491
- }
492
- } catch { /* 读不到 tasks.json 就只靠 slug 兜底 */ }
493
- const validLane = (v) => {
494
- const lane = String(v || '');
495
- if (lane === '_none' || lane === 'meeting') return true;
496
- if (knownTaskIds.has(lane)) return true;
497
- // slug 兜底:小写字母/数字开头,允许字母数字 _ - .(防手滑写成员名/中文进去)
498
- return /^[a-z0-9][a-z0-9_.-]{0,63}$/i.test(lane);
499
- };
500
- if (args.entries && typeof args.entries === 'object' && !Array.isArray(args.entries)) {
501
- for (const [id, lane] of Object.entries(args.entries)) {
502
- if (!id || !validLane(lane)) { skipped.push(`${id}:${lane}`); continue; }
503
- want[String(id)] = String(lane);
504
- }
505
- }
506
- if (Array.isArray(args.ids) && args.ids.length) {
507
- const lane = String(args.lane || '');
508
- if (!validLane(lane)) return { ok: false, error: `lane 非法:${lane}(须 _none/meeting/t<数字>)` };
509
- for (const id of args.ids) { if (id) want[String(id)] = lane; }
510
- }
511
- if (Object.keys(want).length === 0) {
512
- const known = [...knownTaskIds].slice(0, 12).join(' / ') || '(tasks.json 无可用 id)';
513
- return {
514
- ok: false,
515
- error: '没有可写条目。用法:entries={"<eventId>":"<taskId>"} 或 ids=[...]+lane="<taskId>";'
516
- + `taskId 可为 _none / meeting / 本项目 tasks.json 里的 id(如 ${known})。`
517
- + (skipped.length ? ` 被跳过的项:${skipped.slice(0, 5).join('、')}` : ''),
518
- skipped,
519
- };
520
- }
521
- const file = join(hostRoot(hub), '.hub', 'projects', project, 'routing.json');
522
- // 写前自愈对账(2026-10-26 同款),再走「读→合并→原子写」,绝不整文件覆盖
523
- guardRoutingFile(project, hostRoot(hub));
524
- const read = readJsonForWrite(file);
525
- if (!read.ok) return { ok: false, error: `routing.json 读取失败:${String(read.error)}` };
526
- const routing = read.value || {};
527
- let written = 0;
528
- for (const [id, lane] of Object.entries(want)) {
529
- if (routing[id]) { skipped.push(`${id}(已有)`); continue; }
530
- routing[id] = lane;
531
- written += 1;
532
- }
533
- if (written > 0) writeJsonAtomic(file, routing);
534
- return { ok: true, written, skipped, total: Object.keys(routing).length };
535
- } catch (error) {
536
- return { ok: false, error: String((error && error.message) || error) };
537
- }
538
- },
539
- }));
540
-
541
- // —— taskline_messages ——
542
- disposers.push(register({
543
- name: 'taskline_messages',
544
- description: '读取某个会话最近的消息,带 eventId(用于把用户消息归到具体任务线)。'
545
- + '默认每条截 400 字;full=true 时截 4000 字(超长内容请让对方写文件给路径,别靠本工具全文搬运)。',
546
- parameters: {
547
- type: 'object',
548
- additionalProperties: false,
549
- required: ['session'],
550
- properties: {
551
- session: { type: 'string', description: 'sessionId 或标题片段' },
552
- limit: { type: 'number', description: '返回最近多少条,默认 20' },
553
- full: { type: 'boolean', description: 'true=每条最多 4000 字(默认 400 字截断)' },
554
- },
555
- },
556
- output: {
557
- schema: {
558
- type: 'object',
559
- additionalProperties: false,
560
- required: ['ok'],
561
- properties: {
562
- ok: { type: 'boolean' },
563
- error: { type: 'string' },
564
- // t38-A:三态外显(ok=有消息 / empty=会话真空 / unreadable=读不到)
565
- state: { type: 'string' },
566
- title: { type: 'string' },
567
- diagnosis: { type: 'string' },
568
- items: {
569
- type: 'array',
570
- items: {
571
- type: 'object',
572
- additionalProperties: false,
573
- required: ['eventId', 'role', 'text'],
574
- properties: {
575
- eventId: { type: 'string' },
576
- role: { type: 'string' },
577
- time: { type: 'number' },
578
- text: { type: 'string' },
579
- },
580
- },
581
- },
582
- },
583
- },
584
- render: (_args, value) => {
585
- if (!value.ok) return [{ type: 'text', text: `读取失败:${value.error}` }];
586
- if (!value.items || value.items.length === 0) {
587
- // 三态可区分(t38-A,2026-11-10):空会话 ≠ 读不到。以前两者都显示
588
- //「这个会话没有消息」,导致把「读不到」误当「没消息」,归线随之断链。
589
- if (value.state === 'empty') {
590
- return [{ type: 'text', text: `(会话存在但确实还没有消息:${value.diagnosis || value.title || ''})` }];
591
- }
592
- if (value.state === 'unreadable') {
593
- return [{ type: 'text', text: `读取失败(读不到,不是空会话):${value.diagnosis || value.error || '未知原因'}` }];
594
- }
595
- return [{ type: 'text', text: '(这个会话没有消息)' }];
596
- }
597
- return [{
598
- type: 'text',
599
- text: value.items
600
- .map((item) => `${item.eventId} ${item.role === 'user' ? '我' : 'agent'} ${item.text}`)
601
- .join('\n'),
602
- }];
603
- },
604
- },
605
- async execute(args) {
606
- let session = deps.resolveSession(args.session);
607
- if (!session) {
608
- // t38-A(2026-11-10):resolveSession 只查内存 sessions.list(),冷/僵尸会话会被判「找不到」。
609
- // 若传的是完整 sessionId,就先当 id 直读持久日志(与 /sync/messages 同口径),
610
- // 读得到就正常返回;读不到再明确回「读不到」而不是含糊的「找不到」。
611
- const raw = String(args.session || '').trim();
612
- if (/^session-[0-9a-f-]{16,}$/i.test(raw)) {
613
- let probe = [];
614
- try {
615
- const read = await readSessionEvents(hub, raw);
616
- probe = Array.isArray(read && read.events) ? read.events : [];
617
- } catch { /* 下面按读不到处理 */ }
618
- if (probe.length) {
619
- session = { id: raw, title: raw, coldFallback: true, events: probe };
620
- } else {
621
- return {
622
- ok: true,
623
- items: [],
624
- state: 'unreadable',
625
- title: raw,
626
- diagnosis: '该 sessionId 既不在内存会话列表,持久日志也读不到(壳可能已被换掉或日志已删);'
627
- + '请用 taskline_list 取当前 sessionId。',
628
- };
629
- }
630
- } else {
631
- return { ok: false, error: '找不到这个会话' };
632
- }
633
- }
634
- const limit = Number.isFinite(args.limit) && args.limit > 0 ? Math.floor(args.limit) : 20;
635
- // P6(2026-10-27 夜场):默认截断 140→400 字;full=true 不截断(上限 4000 防爆)。
636
- // 长回报的标准做法仍是「写临时文件 + 消息给路径」,full 只用于核对关键句。
637
- const fullCap = 4000;
638
- const clip = (t) => String(t)
639
- .replace(/\s+/g, ' ')
640
- .slice(0, args.full === true ? fullCap : 400);
641
- // t38-A(2026-11-10 修「对部分会话恒返空/读不到」):
642
- // 旧实现只看 session.events(活会话)→ controller.inspect 的 data.message(活格式)。
643
- // 冷会话(如日常·Yat2 停着的线、PrimCell·Yat2)恰恰是:①内存里没有 events;
644
- // ②持久日志把回复存成 assistant/chunk 的 block-end/text-chunks,正文不在 data.message。
645
- // 于是两条都落空 → 恒返「没有消息」。现改为走 hub-core 的 readSessionEvents(活+冷都读)
646
- // + compactSessionEvents(把 chunk 形态重建成 assistant/message 正文),
647
- // 并对「会话存在但真空」与「读不到」分别给出可判定状态。
648
- const sid = session.id || (session && session.sessionId) || '';
649
- let events = [];
650
- let readError = '';
651
- try {
652
- const read = await readSessionEvents(hub, sid);
653
- events = Array.isArray(read && read.events) ? read.events : [];
654
- } catch (error) {
655
- readError = String((error && error.message) || error);
656
- }
657
- if (events.length) {
658
- try {
659
- // compactSessionEvents 返回 { events, lastSeq, streaming }(不是数组,2026-11-10 实测)
660
- const compacted = compactSessionEvents(events);
661
- const rebuilt = Array.isArray(compacted) ? compacted
662
- : (compacted && Array.isArray(compacted.events) ? compacted.events : []);
663
- if (rebuilt.length) events = rebuilt;
664
- } catch (error) {
665
- console.warn(`[hub-desktop-ui] compactSessionEvents failed: ${String(error)}`);
666
- }
667
- }
668
- const items = [];
669
- for (const event of events) {
670
- if (!event || (event.type !== 'user/message' && event.type !== 'assistant/message')) continue;
671
- const data = event.data || {};
672
- const message = data.message || data;
673
- if (message && message.role === 'user') {
674
- const source = message.source || {};
675
- if (source.kind && source.kind !== 'user') continue;
676
- }
677
- const parts = Array.isArray(message.content) ? message.content : [];
678
- const text = clip(parts
679
- .filter((part) => part && part.type === 'text' && typeof part.text === 'string')
680
- .map((part) => part.text)
681
- .join('\n')
682
- .trim());
683
- if (!text) continue;
684
- items.push({
685
- eventId: String(event.id || event.key || event.seq || ''),
686
- role: message.role === 'user' ? 'user' : 'agent',
687
- time: Number(event.time || 0),
688
- text,
689
- });
690
- }
691
- // 三态判定(t38-A):有消息 / 会话确实空 / 读不到。
692
- if (items.length === 0) {
693
- // 「读不到」判据:连一个事件都没拿到(不是拿到事件但都无正文)。
694
- if (!events.length) {
695
- return {
696
- ok: true,
697
- items: [],
698
- state: 'unreadable',
699
- title: (session && (session.title || session.name)) || '',
700
- diagnosis: `会话在注册表可见,但事件池为空${readError ? `(读取异常:${readError})` : ''}`
701
- + ';可能是壳已换(旧 sessionId 残留)或持久日志未落盘。'
702
- + '请用 taskline_list 取当前 sessionId 再试。',
703
- };
704
- }
705
- return {
706
- ok: true,
707
- items: [],
708
- state: 'empty',
709
- title: (session && (session.title || session.name)) || '',
710
- diagnosis: `读到 ${events.length} 个事件但无可展示正文`,
711
- };
712
- }
713
- return { ok: true, items: items.slice(-limit), state: 'ok' };
714
- },
715
- }));
716
-
717
- // —— taskline_archive ——
718
- disposers.push(register({
719
- name: 'taskline_archive',
720
- description: '归档(或恢复)任务线:归档后从侧栏移除、群聊里进「已归档」,可恢复。'
721
- + '**只用于一次性任务线 / 带「|」分身**;常驻成员线(tasks.json 登记的正式成员)归档会被拒绝,'
722
- + '确需换壳回收旧壳时显式传 force: true。',
723
- parameters: {
724
- type: 'object',
725
- additionalProperties: false,
726
- required: ['session'],
727
- properties: {
728
- session: { type: 'string', description: 'sessionId 或标题' },
729
- restore: { type: 'boolean', description: 'true = 取消归档' },
730
- force: { type: 'boolean', description: 'true = 强制归档常驻成员线(仅限换壳回收旧壳等明确场景)' },
731
- },
732
- },
733
- output: {
734
- schema: {
735
- type: 'object',
736
- additionalProperties: false,
737
- required: ['ok'],
738
- properties: {
739
- ok: { type: 'boolean' },
740
- cleaned: { type: 'object' },
741
- refused: { type: 'boolean' },
742
- handoffPath: { type: 'string' },
743
- handoffMessages: { type: 'number' },
744
- error: { type: 'string' },
745
- },
746
- },
747
- render: (_args, value) => [{
748
- type: 'text',
749
- text: !value.ok
750
- ? `归档失败:${value.error}`
751
- : `已完成归档操作${value.cleaned && value.cleaned.removedAvatar ? ';已清理该分身名的头像条目' : ''}`
752
- + (value.cleaned && value.cleaned.removedFromMembers && value.cleaned.removedFromMembers.length
753
- ? `;已从归档任务 ${value.cleaned.removedFromMembers.join(',')} 的 members 移除` : '')
754
- + (value.handoffPath
755
- ? `;交接档案已生成(${value.handoffMessages} 条 assistant 输出):${value.handoffPath}`
756
- : ''),
757
- }],
758
- },
759
- async execute(args, exec) {
760
- const registry = hub.get('workspaceRegistry');
761
- const live = deps.resolveSession(args.session);
762
- let sessionId = live && live.id;
763
- let targetTitle = deps.titleOfSession(live) || '';
764
- if (!sessionId) {
765
- // 冷会话(重启后未激活)不在 sessions.list() 里:
766
- // 用 sessionController.list() 兜底——id 精确匹配优先(同名新旧会话并存时按 id 无歧义)
767
- try {
768
- const controller = hub.get('sessionController');
769
- const rows = controller && typeof controller.list === 'function'
770
- ? await controller.list(new AbortController().signal) : [];
771
- const arr = Array.isArray(rows) ? rows : (rows && Array.isArray(rows.items) ? rows.items : []);
772
- const ref = String(args.session || '');
773
- const byId = arr.find((r) => r && String(r.sessionId) === ref);
774
- const byTitle = byId ? undefined : arr.find((r) => {
775
- const t = r && r.projections && r.projections.values ? r.projections.values.title : '';
776
- return typeof t === 'string' && t === ref;
777
- });
778
- const row = byId || byTitle;
779
- if (row) {
780
- sessionId = String(row.sessionId);
781
- const pt = row && row.projections && row.projections.values ? row.projections.values.title : '';
782
- if (!targetTitle && typeof pt === 'string') targetTitle = pt;
783
- }
784
- } catch (error) { /* 冷会话兜底失败 → 走原报错 */ }
785
- }
786
- if (!sessionId) return { ok: false, error: '找不到这个会话' };
787
- if (!registry) return { ok: false, error: 'workspaceRegistry 不可用' };
788
- // 2026-10-29 用户令「成员可自建分身并回收」——成员侧护栏:成员只能归档/恢复
789
- // 「<本项目> · <本人父名>|…」的分身;他人线 / 本体 / 常驻线一律拒(仍走管理者)。
790
- // 管理者(Yat2/Hermes)与用户会话不受此限,原有常驻护栏继续生效。
791
- {
792
- const callerAgent = exec && exec.agent;
793
- const callerTitle = callerAgent ? deps.callerTitleOf(callerAgent) : '';
794
- if (callerTitle.includes(' · ') && !/ · (Yat2|Hermes)$/.test(callerTitle)) {
795
- const callerProject = projectOfSessionTitle(callerTitle);
796
- const callerRoleRaw = callerTitle.slice(callerTitle.lastIndexOf(' · ') + 3).trim();
797
- const baseRole = callerRoleRaw.includes('|')
798
- ? callerRoleRaw.slice(0, callerRoleRaw.indexOf('|')).trim() : callerRoleRaw;
799
- const targetProject = projectOfSessionTitle(targetTitle);
800
- const targetMember = agentNameOfTitle(targetTitle);
801
- const isOwnClone = targetMember.includes('|')
802
- && targetMember.slice(0, targetMember.indexOf('|')).trim() === baseRole
803
- && targetProject === callerProject;
804
- if (!isOwnClone) {
805
- return {
806
- ok: false,
807
- error: `成员只能回收自己的分身(形如「${callerProject} · ${baseRole}|…」);`
808
- + `「${targetTitle || sessionId}」不是你的分身。他人线/本体/常驻线请走管理者。`,
809
- };
810
- }
811
- }
812
- }
813
- // 常驻成员护栏(2026-10-24「雨课堂被 Yat2 误归档」):tasks.json 登记的正式成员线
814
- // (无「|」)默认拒绝归档——管理者只能归档一次性线/分身;确需换壳回收旧壳传 force。
815
- if (!args.restore && args.force !== true) {
816
- try {
817
- const title = deps.titleOfSession(live) || '';
818
- const registeredIds = readRegisteredSessionIds(hostRoot(hub));
819
- if (registeredIds.has(String(sessionId)) && !title.includes('|')) {
820
- return {
821
- ok: false,
822
- refused: true,
823
- error: `「${title || sessionId}」是 tasks.json 登记的常驻成员线,不做常规归档`
824
- + '(做完回收只适用于一次性任务线 / 带「|」的分身)。'
825
- + '若确实要换壳回收旧壳,重试并显式传 force: true。',
826
- };
827
- }
828
- } catch (error) { /* 护栏判定失败不挡归档 */ }
829
- }
830
- try {
831
- if (args.restore) await registry.unarchiveSession(sessionId);
832
- else {
833
- await registry.archiveSession(sessionId, { stopActivity: true });
834
- // 归档动作顺手触发清理(2026-10-24 用户令:不用定时器,超限删最旧)
835
- await pruneArchivedSessions(hub).catch(() => {});
836
- }
837
- } catch (error) {
838
- return { ok: false, error: String(error && error.message) };
839
- }
840
- // 归档分身时同步清理注册表(2026-10-22 用户要求):
841
- // 历次「成员|具体任务」的分身名沉淀在 avatars 里,侧栏看起来像一堆细名本体。
842
- let cleaned = null;
843
- let handoff = null;
844
- if (!args.restore) {
845
- try {
846
- const title = deps.titleOfSession(live) || '';
847
- const project = projectOfSessionTitle(title);
848
- const memberName = agentNameOfTitle(title);
849
- if (project && memberName.includes('|')) {
850
- cleaned = cleanCloneRegistry(project, memberName, hostRoot(hub));
851
- // 交接档案(2026-10-23):分身归档后信息不能只埋在归档会话里,
852
- // 机械抽取它的全部 assistant 输出落盘,供管理者/父 agent 事后 grep。
853
- try {
854
- // 2026-11-09 修「归档时 assistant 消息数=0,但答案其实已产出」:
855
- // 实测 session-51e9dedd(「讲解已启用插件各自作用」)21:12:16 归档读到 0 条,
856
- // 而它的 session.v4.jsonl.zstd 在 21:12:18 才落盘(263KB,内容齐全)——
857
- // 归档动作早于持久化/内存 flush,于是交接档案空白。
858
- // 修法:先读一次,若 0 条 assistant 就等 1.2s 重读(最多 2 次),
859
- // 避免"回收早于产出"把分身成果丢掉。
860
- let session = live || (hub.get('sessions') || {}).get?.(sessionId);
861
- let events = await collectEvents(hub, session, sessionId);
862
- const countAssistant = (list) => (Array.isArray(list) ? list : [])
863
- .filter((e) => e && e.type === 'assistant/message').length;
864
- for (let attempt = 0; attempt < 2 && countAssistant(events) === 0; attempt += 1) {
865
- await new Promise((r) => setTimeout(r, 1200));
866
- session = live || (hub.get('sessions') || {}).get?.(sessionId);
867
- events = await collectEvents(hub, session, sessionId);
868
- console.warn(`[hub-desktop-ui] clone handoff empty, retried (attempt ${attempt + 1}) session=${sessionId}`);
869
- }
870
- handoff = writeCloneHandoff(project, memberName, sessionId, title, events, hostRoot(hub));
871
- if (!handoff.ok) {
872
- console.warn(`[hub-desktop-ui] clone handoff failed: ${String(handoff.error)}`);
873
- }
874
- } catch (error) {
875
- handoff = { ok: false, error: String((error && error.message) || error) };
876
- console.warn(`[hub-desktop-ui] clone handoff threw: ${String(error)}`);
877
- }
878
- }
879
- } catch (error) {
880
- cleaned = { ok: false, error: String((error && error.message) || error) };
881
- }
882
- }
883
- // 只在真的清理过(或明确失败)时才带 cleaned 字段——undefined 会让输出校验失败
884
- const result = { ok: true };
885
- if (cleaned && typeof cleaned === 'object') {
886
- result.cleaned = {
887
- removedAvatar: Boolean(cleaned.removedAvatar),
888
- removedFromMembers: Array.isArray(cleaned.removedFromMembers) ? cleaned.removedFromMembers : [],
889
- ...(cleaned.ok === false ? { error: String(cleaned.error || 'cleanup failed') } : {}),
890
- };
891
- }
892
- if (handoff && handoff.ok && handoff.path) {
893
- result.handoffPath = String(handoff.path);
894
- result.handoffMessages = Number(handoff.assistantMessages) || 0;
895
- }
896
- return result;
897
- },
898
- }));
899
-
900
- // —— taskline_rename ——
901
- disposers.push(register({
902
- name: 'taskline_rename',
903
- description: '给任务线(会话)改标题,标题会直接显示在侧栏和群聊卡片上。',
904
- parameters: {
905
- type: 'object',
906
- additionalProperties: false,
907
- required: ['session', 'title'],
908
- properties: {
909
- session: { type: 'string', description: 'sessionId 或当前标题' },
910
- title: { type: 'string', description: '新标题,建议「<项目> · <成员>」' },
911
- },
912
- },
913
- output: {
914
- schema: {
915
- type: 'object',
916
- additionalProperties: false,
917
- required: ['ok'],
918
- properties: {
919
- ok: { type: 'boolean' },
920
- title: { type: 'string' },
921
- error: { type: 'string' },
922
- },
923
- },
924
- render: (_args, value) => [{ type: 'text', text: value.ok ? `已改名:${value.title}` : `改名失败:${value.error}` }],
925
- },
926
- async execute(args) {
927
- const controller = hub.get('sessionController');
928
- let session = deps.resolveSession(args.session);
929
- // 冷会话回退(2026-10-27 修「找不到这个会话」):deps.resolveSession 只查 sessions.list()
930
- // (live 会话),冷/重启后会话不在其中。这里照 taskline_prompt 的做法查
931
- // controller.list() 全量底册——精确 id 优先,其次精确标题(同对多个标题不猜,报歧义)。
932
- if (!session && controller && typeof controller.list === 'function') {
933
- try {
934
- const value = await controller.list(new AbortController().signal);
935
- const rows = value && Array.isArray(value.items) ? value.items : (Array.isArray(value) ? value : []);
936
- const registry = hub.get('workspaceRegistry');
937
- const archivedSet = new Set(
938
- registry && Array.isArray(registry.archivedSessionIds)
939
- ? registry.archivedSessionIds.map(String) : [],
940
- );
941
- const ref = String(args.session || '').trim();
942
- const active = rows.filter((row) => row && row.sessionId
943
- && !archivedSet.has(String(row.sessionId)));
944
- const titleOf = (row) => (row.projections && row.projections.values
945
- && typeof row.projections.values.title === 'string'
946
- ? row.projections.values.title : '');
947
- const byId = active.find((row) => String(row.sessionId) === ref);
948
- if (byId) {
949
- session = { id: String(byId.sessionId), displayTitle: titleOf(byId) };
950
- } else {
951
- const byTitle = active.filter((row) => titleOf(row) === ref);
952
- if (byTitle.length === 1) {
953
- session = { id: String(byTitle[0].sessionId), displayTitle: titleOf(byTitle[0]) };
954
- } else if (byTitle.length > 1) {
955
- return { ok: false, error: `同名多个会话:${byTitle.map((r) => `${titleOf(r)} ${r.sessionId}`).join(';')}(请传完整 sessionId)` };
956
- }
957
- }
958
- } catch { /* 回退失败走原错误 */ }
959
- }
960
- if (!session) return { ok: false, error: '找不到这个会话(冷会话请传完整 sessionId,用 taskline_list 查)' };
961
- try {
962
- await controller.rename({ sessionId: session.id, title: args.title });
963
- // 改完立刻对账:若与新标题撞名的另一条还在活跃集合里,把非官方那条改「(旧壳 MM-DD)」
964
- dedupeActiveTitles(hub).catch(() => {});
965
- return { ok: true, title: args.title };
966
- } catch (error) {
967
- return { ok: false, error: String(error && error.message) };
968
- }
969
- },
970
- }));
971
-
972
- // —— resume_session ——
973
- disposers.push(register({
974
- name: 'resume_session',
975
- description: '让指定会话从断点续跑(发一条【续跑】提示,绕过 24h 自动续跑账本)。用于重启后没被自动续跑、或需要强制推进的会话。',
976
- parameters: {
977
- type: 'object',
978
- additionalProperties: false,
979
- required: ['session'],
980
- properties: {
981
- session: { type: 'string', description: '目标会话的 sessionId 或标题(可部分匹配)' },
982
- },
983
- },
984
- output: {
985
- schema: {
986
- type: 'object',
987
- additionalProperties: false,
988
- required: ['ok'],
989
- properties: { ok: { type: 'boolean' }, resumed: { type: 'array', items: { type: 'string' } }, error: { type: 'string' } },
990
- },
991
- render: (_args, value) => [{
992
- type: 'text',
993
- text: value.ok
994
- ? `已续跑 ${value.resumed.length} 个会话:${value.resumed.join('、') || '(无匹配)'}`
995
- : `续跑失败:${value.error || 'unknown'}`,
996
- }],
997
- },
998
- async execute(args) {
999
- try {
1000
- const result = await resumeInterruptedSessions(hub, { force: true, only: String(args.session || ''), max: 1 });
1001
- return { ok: true, resumed: result.resumed };
1002
- } catch (error) {
1003
- return { ok: false, error: String(error && error.message) };
1004
- }
1005
- },
1006
- }));
1007
-
1008
- // —— taskline_goal_status ——
1009
- disposers.push(register({
1010
- name: 'taskline_goal_status',
1011
- description: '查某个会话当前的 goal(标题 / 状态 / 轮次)。派活带 goal 后用它核验是否真的建进会话。',
1012
- parameters: {
1013
- type: 'object',
1014
- additionalProperties: false,
1015
- required: ['session'],
1016
- properties: {
1017
- session: { type: 'string', description: '目标会话的 sessionId 或标题(可部分匹配)' },
1018
- },
1019
- },
1020
- output: {
1021
- schema: {
1022
- type: 'object',
1023
- additionalProperties: false,
1024
- required: ['ok'],
1025
- properties: {
1026
- ok: { type: 'boolean' },
1027
- sessionId: { type: 'string' },
1028
- title: { type: 'string' },
1029
- hasGoal: { type: 'boolean' },
1030
- objective: { type: 'string' },
1031
- phase: { type: 'string' },
1032
- roundsStarted: { type: 'number' },
1033
- maxGoalRounds: { type: 'number' },
1034
- goalId: { type: 'string' },
1035
- error: { type: 'string' },
1036
- },
1037
- },
1038
- render: (_args, value) => [{
1039
- type: 'text',
1040
- text: !value.ok
1041
- ? `查询失败:${value.error || 'unknown'}`
1042
- : value.hasGoal
1043
- ? `「${value.title || value.sessionId}」goal=[${value.phase}] ${value.objective}(轮次 ${value.roundsStarted}/${value.maxGoalRounds})`
1044
- : `「${value.title || value.sessionId}」当前没有 goal`,
1045
- }],
1046
- },
1047
- async execute(args) {
1048
- const ref = String(args.session || '').trim();
1049
- const isExactId = /^session-[0-9a-f-]{20,}$/i.test(ref);
1050
- let session = isExactId ? (hub.get('sessions') && hub.get('sessions').get(ref)) : deps.resolveSession(ref);
1051
- if (!session && isExactId) session = { id: ref };
1052
- if (!session) return { ok: false, error: '找不到这个会话' };
1053
- const goalsSvc = hub.get('goals') || (typeof ctx.get === 'function' ? ctx.get('goals') : undefined);
1054
- const agentsSvc = hub.get('agents');
1055
- const agent = agentsSvc && typeof agentsSvc.get === 'function' ? agentsSvc.get(String(session.id)) : undefined;
1056
- if (!goalsSvc || typeof goalsSvc.get !== 'function') return { ok: false, error: 'goal 服务(ctx.goals)不可用' };
1057
- if (!agent || !agent.session) return { ok: false, sessionId: String(session.id), title: deps.titleOfSession(session), hasGoal: false, error: 'agent 未激活(冷会话需先唤醒)' };
1058
- try {
1059
- const view = goalsSvc.get(agent);
1060
- const goal = view && (view.goal || view);
1061
- if (!goal || !goal.id) {
1062
- return { ok: true, sessionId: String(session.id), title: deps.titleOfSession(session), hasGoal: false, error: '' };
1063
- }
1064
- return {
1065
- ok: true,
1066
- sessionId: String(session.id),
1067
- title: deps.titleOfSession(session),
1068
- hasGoal: true,
1069
- goalId: String(goal.id || ''),
1070
- objective: String(goal.objective || ''),
1071
- phase: String(goal.phase || ''),
1072
- roundsStarted: Number(goal.roundsStarted) || 0,
1073
- maxGoalRounds: Number(goal.maxGoalRounds) || 0,
1074
- error: '',
1075
- };
1076
- } catch (error) {
1077
- return { ok: false, sessionId: String(session.id), error: String((error && error.message) || error) };
1078
- }
1079
- },
1080
- }));
1081
-
1082
- // —— taskline_wait ——
1083
- disposers.push(register({
1084
- name: 'taskline_wait',
1085
- description: '例外工具(默认派完即转下一任务,不等成员):仅当用户要当场闭环结论时才用。'
1086
- + '等成员状态变化(不轮询):运行中→空闲或有新消息就返回;没有目标在跑立刻 noProgress。timeoutMs 建议 ≤60000。',
1087
- parameters: {
1088
- type: 'object',
1089
- additionalProperties: false,
1090
- required: [],
1091
- properties: {
1092
- session: { type: 'string', description: '单个目标:sessionId 或标题(可部分匹配)' },
1093
- sessionIds: {
1094
- type: 'array',
1095
- items: { type: 'string' },
1096
- description: '多个目标:sessionId 或标题片段。session 与 sessionIds 都不给 = 等任意成员(除调用者自己)',
1097
- },
1098
- timeoutMs: { type: 'number', description: '最长等待毫秒,默认 120000,上限 600000' },
1099
- },
1100
- },
1101
- output: {
1102
- schema: {
1103
- type: 'object',
1104
- additionalProperties: false,
1105
- required: ['status', 'waitedMs'],
1106
- properties: {
1107
- status: { type: 'string', enum: ['changed', 'noProgress', 'timeout'] },
1108
- waitedMs: { type: 'number' },
1109
- changed: {
1110
- type: 'array',
1111
- items: {
1112
- type: 'object',
1113
- additionalProperties: false,
1114
- required: ['sessionId', 'title', 'idle'],
1115
- properties: {
1116
- sessionId: { type: 'string' },
1117
- title: { type: 'string' },
1118
- idle: { type: 'boolean' },
1119
- },
1120
- },
1121
- },
1122
- error: { type: 'string' },
1123
- },
1124
- },
1125
- render: (_args, value) => {
1126
- if (value.status === 'changed') {
1127
- const list = (value.changed || [])
1128
- .map((item) => `${item.title || item.sessionId}${item.idle ? ' [已空闲]' : ' [有新消息]'}`)
1129
- .join('、');
1130
- return [{ type: 'text', text: `成员状态有变化(等了 ${value.waitedMs}ms):${list || '(无)'}` }];
1131
- }
1132
- if (value.status === 'noProgress') {
1133
- return [{ type: 'text', text: `没有目标在运行,立刻返回 noProgress(${value.waitedMs}ms)${value.error ? `\n${value.error}` : ''}` }];
1134
- }
1135
- return [{ type: 'text', text: `等待超时(${value.waitedMs}ms):目标仍无变化${value.error ? `\n${value.error}` : ''}` }];
1136
- },
1137
- },
1138
- async execute(args, exec) {
1139
- const startedAt = Date.now();
1140
- const sessionsSvc = hub.get('sessions');
1141
- const liveList = sessionsSvc && typeof sessionsSvc.list === 'function' ? sessionsSvc.list() : [];
1142
- const callerId = exec && exec.agent && exec.agent.id !== undefined ? String(exec.agent.id) : '';
1143
- const registry = hub.get('workspaceRegistry');
1144
- const archivedSet = new Set(
1145
- registry && Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds.map(String) : [],
1146
- );
1147
- const titleOfId = (id) => {
1148
- const live = liveList.find((session) => String(session.id) === String(id));
1149
- return live ? deps.titleOfSession(live) : '';
1150
- };
1151
- // 解析目标:显式 ref 优先,都不给 = 任意成员(排除调用者自己——它正在跑本次调用)
1152
- const refs = [];
1153
- if (args.session) refs.push(String(args.session).trim());
1154
- if (Array.isArray(args.sessionIds)) {
1155
- for (const ref of args.sessionIds) {
1156
- const text = String(ref || '').trim();
1157
- if (text) refs.push(text);
1158
- }
1159
- }
1160
- const targetIds = new Set();
1161
- const unknown = [];
1162
- if (refs.length === 0) {
1163
- for (const session of liveList) {
1164
- const id = String(session.id);
1165
- if (id === callerId || archivedSet.has(id)) continue;
1166
- targetIds.add(id);
1167
- }
1168
- } else {
1169
- for (const ref of refs) {
1170
- if (/^session-[0-9a-f-]{20,}$/i.test(ref)) {
1171
- targetIds.add(ref);
1172
- continue;
1173
- }
1174
- const hits = liveList.filter((session) => {
1175
- const title = deps.titleOfSession(session);
1176
- return title === ref || title.includes(ref);
1177
- });
1178
- if (hits.length === 0) unknown.push(ref);
1179
- else for (const session of hits) targetIds.add(String(session.id));
1180
- }
1181
- }
1182
- const unknownNote = unknown.length ? `找不到会话:${unknown.join(' / ')}` : '';
1183
- if (targetIds.size === 0) {
1184
- return { status: 'noProgress', waitedMs: Date.now() - startedAt, ...(unknownNote ? { error: unknownNote } : {}) };
1185
- }
1186
- // 没有目标在跑 → 不等待,立刻返回(验收 a)
1187
- const runningIds = [...targetIds].filter((id) => deps.isRunningNow(id));
1188
- if (runningIds.length === 0) {
1189
- return { status: 'noProgress', waitedMs: Date.now() - startedAt, ...(unknownNote ? { error: unknownNote } : {}) };
1190
- }
1191
- const requested = Number(args.timeoutMs);
1192
- const timeoutMs = Number.isFinite(requested) && requested > 0
1193
- ? Math.min(Math.max(Math.floor(requested), 1000), 600000)
1194
- : 120000;
1195
-
1196
- const outcome = await new Promise((finishWait) => {
1197
- let settled = false;
1198
- let offStatus;
1199
- let offSession;
1200
- let timer;
1201
- const idleChanged = () => [...targetIds]
1202
- .filter((id) => !deps.isRunningNow(id))
1203
- .map((id) => ({ sessionId: id, title: titleOfId(id), idle: true }));
1204
- const cleanup = () => {
1205
- if (timer) clearTimeout(timer);
1206
- for (const off of [offStatus, offSession]) {
1207
- if (typeof off === 'function') {
1208
- try { off(); } catch (error) { /* ignore */ }
1209
- }
1210
- }
1211
- };
1212
- const settle = (value) => {
1213
- if (settled) return;
1214
- settled = true;
1215
- cleanup();
1216
- finishWait({ ...value, waitedMs: Date.now() - startedAt });
1217
- };
1218
- timer = setTimeout(() => settle({ status: 'timeout' }), timeoutMs);
1219
- if (timer && typeof timer.unref === 'function') timer.unref();
1220
- // 1) 官方运行状态事件:目标 running → false 即返回
1221
- offStatus = hub.on('api-session/status', (changedId, running) => {
1222
- try {
1223
- const key = String(changedId);
1224
- if (!targetIds.has(key) || running) return;
1225
- settle({ status: 'changed', changed: idleChanged() });
1226
- } catch (error) { /* ignore */ }
1227
- });
1228
- // 2) 会话事件:回合结束(且确实空闲)或新用户消息也算「有变化」
1229
- offSession = hub.on('session/event', (session, event) => {
1230
- try {
1231
- const key = session && session.id !== undefined ? String(session.id) : '';
1232
- if (!targetIds.has(key) || !event) return;
1233
- const type = String(event.type || '');
1234
- const sourceKind = event.data && event.data.source ? event.data.source.kind : undefined;
1235
- const freshUserMessage = type === 'user/message'
1236
- && (!sourceKind || sourceKind === 'user');
1237
- if (type !== 'turn/end' && !freshUserMessage) return;
1238
- const idle = idleChanged();
1239
- if (idle.length > 0) {
1240
- settle({ status: 'changed', changed: idle });
1241
- return;
1242
- }
1243
- if (freshUserMessage) {
1244
- settle({ status: 'changed', changed: [{ sessionId: key, title: titleOfId(key), idle: false }] });
1245
- }
1246
- } catch (error) { /* ignore */ }
1247
- });
1248
- // 3) 调用方取消(用户点停止):不悬挂,清理监听后按取消返回
1249
- const signal = exec && exec.signal;
1250
- if (signal && typeof signal.addEventListener === 'function') {
1251
- if (signal.aborted) {
1252
- settle({ status: 'timeout', error: 'aborted' });
1253
- return;
1254
- }
1255
- signal.addEventListener('abort', () => settle({ status: 'timeout', error: 'aborted' }), { once: true });
1256
- }
1257
- // 4) 竞态兜底:目标可能在「检查 running」与「挂监听」之间就已经变 idle,
1258
- // 挂完监听再读一次真实状态,别白等到超时。
1259
- const alreadyIdle = idleChanged();
1260
- if (alreadyIdle.length > 0) settle({ status: 'changed', changed: alreadyIdle });
1261
- });
1262
- return unknownNote ? { ...outcome, error: outcome.error || unknownNote } : outcome;
1263
- },
1264
- }));
1265
-
1266
- // —— taskline_create ——
1267
- disposers.push(register({
1268
- name: 'taskline_create',
1269
- description: '新建一条任务线:创建一个新会话并命名(可选立刻给它派第一条任务)。\n'
1270
- + '**任务书里必须带「用户可见验收」段**(§20,2026-11-10 用户令):'
1271
- + '写清用户能**直接看/点/复制**的验收物——截图、可打开的文件或 URL、一段可粘贴的输出——'
1272
- + '而不是「测试通过 / 已自测」这类自证。模板:\n'
1273
- + '【验收(用户可见)】①<可打开的产物路径或 URL>;②<用户一条命令能复现的输出>;'
1274
- + '③<若涉及重启,写清重启后一步核验的方法>。\n'
1275
- + '**不写自证式验收("单测通过""逻辑正确")**:验收物必须由用户侧可见,审核者据此判定完成。',
1276
- parameters: {
1277
- type: 'object',
1278
- additionalProperties: false,
1279
- required: ['title'],
1280
- properties: {
1281
- title: { type: 'string', description: '新任务线标题,建议「<项目> · <成员>」' },
1282
- text: {
1283
- type: 'string',
1284
- description: '可选的初始任务内容。**必须含「【验收(用户可见)】」段**:'
1285
- + '列出用户可直接看/点/复制的验收物(产物路径或 URL、可复现命令与输出、重启后一步核验法),'
1286
- + '禁止只写「测试通过」「已自测」等自证。',
1287
- },
1288
- preset: { type: 'string', description: '可选的 agent preset,默认 cordis(Creator mode)' },
1289
- project: { type: 'string', description: '所属项目名,默认从 title 的「 · 」前解析' },
1290
- avatar: { type: 'string', description: '头像 emoji,可不填(自动从候选池分配一个未被占用的)' },
1291
- },
1292
- },
1293
- output: {
1294
- schema: {
1295
- type: 'object',
1296
- additionalProperties: false,
1297
- required: ['ok'],
1298
- properties: {
1299
- ok: { type: 'boolean' },
1300
- sessionId: { type: 'string' },
1301
- title: { type: 'string' },
1302
- avatar: { type: 'string' },
1303
- warning: { type: 'string' },
1304
- error: { type: 'string' },
1305
- },
1306
- },
1307
- render: (_args, value) => [{
1308
- type: 'text',
1309
- text: value.ok
1310
- ? `已建任务线:${value.title}(${value.sessionId})${value.warning ? `\n⚠️ ${value.warning}` : ''}`
1311
- : `新建失败:${value.error}`,
1312
- }],
1313
- },
1314
- async execute(args, exec) {
1315
- // 2026-10-29 用户令「每个 agent 多线任务时可自建分身并回收」——成员侧护栏:
1316
- // 成员(标题「项目 · 角色」且非管理者)只能建「<本项目> · <本人父名>|<任务短语>」
1317
- // 的分身;裸名本体 / 别人的分身 / 跨项目一律拒,仍走管理者。管理者/用户会话不受限。
1318
- const callerAgent = exec && exec.agent;
1319
- const callerTitle = callerAgent ? deps.callerTitleOf(callerAgent) : '';
1320
- if (callerTitle.includes(' · ') && !/ · (Yat2|Hermes)$/.test(callerTitle)) {
1321
- const callerProject = projectOfSessionTitle(callerTitle);
1322
- const callerRoleRaw = callerTitle.slice(callerTitle.lastIndexOf(' · ') + 3).trim();
1323
- // 分身再开子分身时按父名认亲(「DSH工程师|a」开的也必须是「DSH工程师|…」)
1324
- const baseRole = callerRoleRaw.includes('|')
1325
- ? callerRoleRaw.slice(0, callerRoleRaw.indexOf('|')).trim() : callerRoleRaw;
1326
- const wantMember = args.title.includes(' · ')
1327
- ? args.title.slice(args.title.lastIndexOf(' · ') + 3).trim() : args.title.trim();
1328
- const wantProject = args.project || (args.title.includes(' · ')
1329
- ? args.title.slice(0, args.title.indexOf(' · ')).trim() : callerProject);
1330
- const wantParent = wantMember.includes('|')
1331
- ? wantMember.slice(0, wantMember.indexOf('|')).trim() : '';
1332
- if (!wantMember.includes('|') || wantParent !== baseRole || wantProject !== callerProject) {
1333
- return {
1334
- ok: false,
1335
- error: `成员只能自建分身:标题形如「${callerProject} · ${baseRole}|<任务短语>」`
1336
- + `(父名必须是本人、项目必须是本项目)。建本体 / 他人分身 / 跨项目仍需管理者。`,
1337
- };
1338
- }
1339
- }
1340
- const controller = hub.get('sessionController');
1341
- if (!controller || typeof controller.create !== 'function') return { ok: false, error: 'sessionController 不可用' };
1342
- try {
1343
- // 命名校验前置(2026-10-23 用户令;2026-10-26 三改):必须发生在 controller.create **之前**,
1344
- // 否则违规名被拒时会话已建出来,留孤儿。
1345
- // 2026-10-26 用户新规则——校验分两路:
1346
- // A. 带「|」→ **分身**路:形如「<父名>|<任务短语>」(如 DSH工程师|切换提速)。
1347
- // 父名须是本项目已存在的成员;任务短语非空即过(2 字以上更佳但不强制);
1348
- // 「|副手/|二号」不再特殊,也是合法分身名;**无数量上限,完成即归档回收**。
1349
- // B. 不带「|」→ **本体**路:旧规则全保留——拒裸宽泛词(工程师/专家/管家…),
1350
- // 任务型名字给警告不拒绝。
1351
- const memberName = args.title.includes(' · ') ? args.title.slice(args.title.lastIndexOf(' · ') + 3).trim() : args.title.trim();
1352
- const projectNameEarly = args.project || (args.title.includes(' · ') ? args.title.slice(0, args.title.indexOf(' · ')).trim() : '');
1353
- const BARE_ROLE_WORDS = ['工程师', '专家', '管家', '助手', '成员', '分析师', '设计师', '研究员', 'agent', 'Agent'];
1354
- let nameWarning = '';
1355
- if (memberName.includes('|')) {
1356
- const barAt = memberName.indexOf('|');
1357
- const parentName = memberName.slice(0, barAt).trim();
1358
- const taskPhrase = memberName.slice(barAt + 1).trim();
1359
- if (!parentName) {
1360
- return { ok: false, error: `「${memberName}」缺父名——分身名形如「<父名>|<任务短语>」(如 DSH工程师|切换提速)` };
1361
- }
1362
- if (!taskPhrase) {
1363
- return { ok: false, error: `「${memberName}」缺任务短语——分身名形如「<父名>|<任务短语>」(如 DSH工程师|切换提速)` };
1364
- }
1365
- // 父名须是本项目已存在的成员(防拼错父名造出野分身)
1366
- if (projectNameEarly) {
1367
- const row = readTaskFile(projectNameEarly, hostRoot(hub));
1368
- const raw = row && row.tasks && typeof row.tasks === 'object' && !Array.isArray(row.tasks) ? row.tasks : {};
1369
- const tasksList = Array.isArray(raw.tasks) ? raw.tasks : [];
1370
- const sessionsMap = raw.memberSessions && typeof raw.memberSessions === 'object' ? raw.memberSessions : {};
1371
- const known = new Set(Object.keys(sessionsMap));
1372
- for (const task of tasksList) {
1373
- for (const name of (Array.isArray(task && task.members) ? task.members : [])) known.add(String(name));
1374
- }
1375
- if (known.size > 0 && !known.has(parentName)) {
1376
- return {
1377
- ok: false,
1378
- error: `分身父名「${parentName}」不是项目「${projectNameEarly}」的已登记成员(候选:${[...known].slice(0, 8).join(' / ')})——先有本体成员,再挂分身`,
1379
- };
1380
- }
1381
- // 2026-11-09 用户令(父名错第二次):分诊建线时父名必须是**被分诊线的成员**,
1382
- // 不是分诊者自己。分诊者通常是管理者 Yat2 → 会建出「Yat2|<UI 开发任务>」,
1383
- // 让管理者名下堆交付物线(违反「管理者不产出交付物」)。工具层直接拦。
1384
- if (parentName === 'Yat2' && known.size > 1) {
1385
- const executors = [...known].filter((x) => x !== 'Yat2');
1386
- return {
1387
- ok: false,
1388
- error: `父名不能用管理者「Yat2」——这条线该由真实执行成员挂:${executors.slice(0, 6).join(' / ')}`
1389
- + '(用户令 2026-11-09:分诊建的线父名必须是**被分诊线的成员**,不是分诊者自己;管理者不产出交付物)',
1390
- };
1391
- }
1392
- }
1393
- // 分身名 = 任务短语,天然任务导向,不再触发 looksTaskSpecificName 警告
1394
- } else {
1395
- if (BARE_ROLE_WORDS.includes(memberName)) {
1396
- return {
1397
- ok: false,
1398
- error: `「${memberName}」是裸宽泛角色名,禁止使用——请带领域前缀(如 DSH工程师 / 视网膜专家 / 出题工程师)`,
1399
- };
1400
- }
1401
- if (looksTaskSpecificName(memberName)) {
1402
- nameWarning = `「${memberName}」像是具体任务名——常驻本体建议起正式角色名(职责导向);**按任务开线请用分身**:「<父名>|<任务短语>」(完成即归档回收)`;
1403
- }
1404
- }
1405
- const created = await controller.create({ cwd: hostRoot(hub), agentPreset: args.preset || defaultPreset });
1406
- const sessionId = created && created.sessionId;
1407
- if (!sessionId) return { ok: false, error: '创建未返回 sessionId' };
1408
- await controller.rename({ sessionId, title: args.title });
1409
- const projectName = projectNameEarly;
1410
- const assignedAvatar = projectName ? ensureMemberAvatar(projectName, memberName, hostRoot(hub), args.avatar) : '';
1411
- // 2026-10-26:头像登记失败不再静默——告诉 Yat2 当场补(韩明明论文·论文写手
1412
- // 漏头像就是写错根 + 静默失败叠加的结果,排查靠人眼)
1413
- const avatarWarning = projectName && !assignedAvatar
1414
- ? `头像未登记(${projectName}/tasks.json 或全局 avatars.json 写入失败)——建完后请检查并手工补 .hub/avatars.json`
1415
- : '';
1416
- if (args.text) {
1417
- await controller.prompt({
1418
- requestId: `hub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1419
- sessionId,
1420
- mode: 'queue',
1421
- content: [{ type: 'text', text: args.text }],
1422
- }, new AbortController().signal);
1423
- }
1424
- return {
1425
- ok: true, sessionId, title: args.title,
1426
- // 2026-10-29:lossless JSON 不认 undefined 值(头像池空时报 invalid output)——改为条件展开
1427
- ...(assignedAvatar ? { avatar: assignedAvatar } : {}),
1428
- ...(([nameWarning, avatarWarning].filter(Boolean).join(';')) ? { warning: [nameWarning, avatarWarning].filter(Boolean).join(';') } : {}),
1429
- };
1430
- } catch (error) {
1431
- return { ok: false, error: String(error && error.message) };
1432
- }
1433
- },
1434
- }));
1435
-
1436
- // —— taskline_prompt ——
1437
- disposers.push(register({
1438
- name: 'taskline_prompt',
1439
- description: '给指定的任务线(会话)派活:按 sessionId 或会话标题找到它,发一条消息。用于分派、催办、重启任务线。',
1440
- parameters: {
1441
- type: 'object',
1442
- additionalProperties: false,
1443
- required: ['session', 'text'],
1444
- properties: {
1445
- session: { type: 'string', description: '目标会话的 sessionId 或标题(可部分匹配)' },
1446
- text: {
1447
- type: 'string',
1448
- description: '要发给它的话,写清任务与验收标准。'
1449
- + '**验收必须是「用户可见验收」**(§20):给出用户能直接看/点/复制的验收物'
1450
- + '(产物路径或 URL、可复现命令及输出、重启后一步核验法),'
1451
- + '不要写「测试通过」「已自测」这类自证;模板见 taskline_create。',
1452
- },
1453
- goal: {
1454
- type: 'string',
1455
- description: '本线目标(一句话、交付物导向)。传了会写进 tasks.json 的 goal 并把目标化指令插到 prompt 最前面;不传保持原行为',
1456
- },
1457
- task: {
1458
- type: 'string',
1459
- description: '任务线 id(如 client-ui / quota-panel)。同一成员属于多条线时必须传,否则写 goal 会因歧义被拒绝并返回候选列表',
1460
- },
1461
- mode: {
1462
- type: 'string',
1463
- enum: ['triage', 'queue', 'steer', 'interrupt'],
1464
- description: '投递方式,默认 triage(2026-11-08 用户令:排队不再干等)。'
1465
- + 'triage = 目标在跑时**不排队**,立刻开一条「<成员>|queue分诊」分身:它读主线上下文后判定'
1466
- + '「纠偏/补充/紧急 → steer 进主线当前轮」还是「独立新任务 → 分身自己做」,给出交接摘要后插件自动归档回收;'
1467
- + '目标空闲时退化为 queue(直接起一轮,不开分身);'
1468
- + 'queue = 老行为,真排队等它跑完当前轮(仅当你明确要「等它跑完」时用);'
1469
- + 'steer = 目标在跑时在下一个 step 边界把消息插进当前轮(不打断正在执行的工具调用),目标空闲时自动退化为 queue;'
1470
- + 'interrupt = 中断目标当前这一轮但保留已排队的后续消息(本条排进队列,等下次唤醒按序处理)',
1471
- },
1472
- timeoutMs: {
1473
- type: 'number',
1474
- description: `仅 interrupt 用:等目标变 idle 的上限毫秒,默认 ${INTERRUPT_IDLE_WAIT_MS}。`
1475
- + 'abort 慢于该值时返回 idle:false,但 interrupted:true 且已排队消息已保留(按 interrupted 判定中断成功即可)',
1476
- },
1477
- },
1478
- },
1479
- output: {
1480
- schema: {
1481
- type: 'object',
1482
- additionalProperties: false,
1483
- required: ['ok'],
1484
- properties: {
1485
- ok: { type: 'boolean' },
1486
- sessionId: { type: 'string' },
1487
- title: { type: 'string' },
1488
- goal: { type: 'string' },
1489
- goalTaskId: { type: 'string' },
1490
- goalError: { type: 'string' },
1491
- goalWritten: { type: 'boolean' },
1492
- goalCreated: { type: 'boolean' },
1493
- goalFailed: { type: 'string' },
1494
- goalMatchedBy: { type: 'string' },
1495
- candidates: { type: 'array', items: { type: 'string' } },
1496
- crossProject: { type: 'boolean' },
1497
- callerProject: { type: 'string' },
1498
- targetProject: { type: 'string' },
1499
- mode: { type: 'string' },
1500
- interrupted: { type: 'boolean' },
1501
- idle: { type: 'boolean' },
1502
- textQueued: { type: 'boolean' },
1503
- // 派活纪律(2026-10-26):目标运行状态与排队位次(工具层强制回传)
1504
- targetRunning: { type: 'boolean' },
1505
- pendingDispatches: { type: 'number' },
1506
- queuedBefore: { type: 'number' },
1507
- // queue 分诊(2026-11-08):不排队,改开分诊分身
1508
- triageSessionId: { type: 'string' },
1509
- triageTitle: { type: 'string' },
1510
- // 唯一投递结论(2026-11-09):triage / steer / queued / interrupted / started
1511
- delivery: { type: 'string' },
1512
- note: { type: 'string' },
1513
- error: { type: 'string' },
1514
- },
1515
- },
1516
- // 2026-11-09:render 抽成纯函数(同一返回只允许一个结论;回归用例直接断言它)
1517
- render: (_args, value) => [{ type: 'text', text: renderTasklinePrompt(value) }],
1518
- },
1519
- async execute(args, exec) {
1520
- const controller = hub.get('sessionController');
1521
- if (!controller || typeof controller.prompt !== 'function') return { ok: false, error: 'sessionController 不可用' };
1522
- // P8-①(2026-10-29):text 缺失/非字符串时**拒绝投递**并报错——此前 args.text 为
1523
- // undefined 会拼出字面 "undefined" 空消息投给成员(骨髓·单细胞师/生信分析师 10-29
1524
- // 16:52、16:55 各两连发)。宁可报错重发,不可投递垃圾轮。
1525
- if (typeof args.text !== 'string' || !args.text.trim()) {
1526
- return {
1527
- ok: false,
1528
- error: '缺少 text(收到 undefined/空/非字符串)。请检查 taskline_prompt 调用参数后重发;本条未投递。',
1529
- };
1530
- }
1531
- const ref = String(args.session || '').trim();
1532
- const isExactId = /^session-[0-9a-f-]{20,}$/i.test(ref);
1533
- let session = deps.resolveSession(ref);
1534
- // —— 同名歧义防护(重建后旧壳陷阱,DSH sync工程师 案例)——
1535
- // 标题寻址时:只有「唯一 live 命中,且不存在其它同名(冷/归档)」才直接投;
1536
- // 否则不静默投递,列出全部候选(id 尾 8 位 + 状态)让管理者用完整 sessionId 指定。
1537
- if (!isExactId) {
1538
- const liveSvc = hub.get('sessions');
1539
- const liveAll = liveSvc && typeof liveSvc.list === 'function' ? liveSvc.list() : [];
1540
- const liveHits = liveAll.filter((s) => {
1541
- const t = deps.titleOfSession(s);
1542
- return t === ref || t.includes(ref);
1543
- });
1544
- const registry = hub.get('workspaceRegistry');
1545
- const archivedSet = new Set(
1546
- registry && Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds.map(String) : [],
1547
- );
1548
- const candidates = liveHits.map((s) => ({
1549
- id: String(s.id),
1550
- title: deps.titleOfSession(s),
1551
- state: archivedSet.has(String(s.id)) ? 'archived' : 'live',
1552
- }));
1553
- const seen = new Set(candidates.map((c) => c.id));
1554
- // 冷/归档同名候选也纳入清点(controller.list 是全量底册)
1555
- try {
1556
- const rows = await controller.list(new AbortController().signal);
1557
- const arr = Array.isArray(rows) ? rows : (rows && Array.isArray(rows.items) ? rows.items : []);
1558
- for (const row of arr) {
1559
- if (!row || !row.sessionId) continue;
1560
- const id = String(row.sessionId);
1561
- const t = row.projections && row.projections.values && typeof row.projections.values.title === 'string'
1562
- ? row.projections.values.title : '';
1563
- if (!t || !t.includes(ref)) continue;
1564
- if (seen.has(id)) continue;
1565
- seen.add(id);
1566
- candidates.push({ id, title: t, state: archivedSet.has(id) ? 'archived' : 'cold' });
1567
- }
1568
- } catch { /* 底册列不全就只按 live 候选判 */ }
1569
- if (candidates.length !== 1) {
1570
- return {
1571
- ok: false,
1572
- error: `「${ref}」命中 ${candidates.length} 个会话(${candidates.length === 0
1573
- ? '一个都没找到'
1574
- : candidates.map((c) => `${c.title} ${c.id} [${c.state}]`).join(' ; ')})——`
1575
- + `会话重建后旧壳可能还在,请用 taskline_list 确认后传完整 sessionId 投递`,
1576
- };
1577
- }
1578
- session = liveHits[0];
1579
- }
1580
- if (!session) {
1581
- // 冷会话兜底:resolveAgent 会把它 resume 成活会话
1582
- //(SessionController.prompt 内部对 sessionId 走的就是 resolveAgent 同一条路)。
1583
- const agentsCtl = controller.agents;
1584
- if (agentsCtl && typeof agentsCtl.resolveAgent === 'function') {
1585
- try {
1586
- const found = await agentsCtl.resolveAgent(String(args.session));
1587
- if (found && found.agent) {
1588
- const resumedId = found.agent.session && found.agent.session.id ? found.agent.session.id : String(args.session);
1589
- const live = hub.get('sessions');
1590
- session = (live && typeof live.get === 'function' ? live.get(resumedId) : undefined)
1591
- || { id: resumedId };
1592
- console.log(`[hub-desktop-ui] taskline_prompt resumed cold session ${resumedId}`);
1593
- }
1594
- } catch (error) {
1595
- console.warn(`[hub-desktop-ui] cold resume failed: ${String(error)}`);
1596
- }
1597
- }
1598
- }
1599
- if (!session) return { ok: false, error: '找不到这个会话(先用 taskline_list 看 id;冷会话要传完整 sessionId)' };
1600
- // —— 项目边界护栏(2026-10-22 用户规则)——
1601
- // 每个项目的 agent 只能给本项目成员派活;跨项目直派会让群聊/侧栏出现
1602
- // 本项目看不见的成员(Book 的 Yat2 直派「应用开发 · DSH工程师」案例)。
1603
- // 工具层硬拒,不依赖管理者记住协议。exec.agent = 调用方(tools 服务注入)。
1604
- try {
1605
- const callerAgent = exec && exec.agent;
1606
- const callerTitle = callerAgent ? deps.callerTitleOf(callerAgent) : '';
1607
- const callerProject = projectOfSessionTitle(callerTitle);
1608
- const targetProject = projectOfSessionTitle(deps.titleOfSession(session));
1609
- if (callerProject && targetProject && targetProject !== callerProject) {
1610
- const targetName = agentNameOfTitle(deps.titleOfSession(session)) || deps.titleOfSession(session);
1611
- return {
1612
- ok: false,
1613
- crossProject: true,
1614
- callerProject,
1615
- targetProject,
1616
- error: `跨项目派活被拒:你属于「${callerProject}」,目标「${targetName}」属于「${targetProject}」。`
1617
- + `想用它的能力:先用 taskline_create 在本项目复制该成员(标题「${callerProject} · ${targetName}」,`
1618
- + `头像沿用它在「${targetProject}」tasks.json 里已有的 emoji,身份写明"从 ${targetProject} 借调"),再派活。`,
1619
- };
1620
- }
1621
- } catch (error) {
1622
- console.warn(`[hub-desktop-ui] project-boundary check failed: ${String(error)}`);
1623
- }
1624
- // 目标化派活(B):写 tasks.json 的 goal + 在 prompt 最前面插目标化指令。
1625
- // 写回失败不影响投递(只在返回值里告警),因为投递本身是主目的。
1626
- let goalNote = '';
1627
- let goalTaskId = '';
1628
- let goalError = '';
1629
- let goalMatchedBy = '';
1630
- let goalCandidates = [];
1631
- let goalCreated = false;
1632
- let goalCreateError = '';
1633
- const goalText = String(args.goal || '').trim();
1634
- if (goalText) {
1635
- try {
1636
- const project = projectOfSessionTitle(deps.titleOfSession(session));
1637
- const agentName = agentNameOfTitle(deps.titleOfSession(session));
1638
- if (project) {
1639
- // 传了 task 就精确写那条线;没传时:setTaskGoal 在「同一成员属于多条线」时返回
1640
- // ambiguous + candidates(2026-10-22 用户纠正);且**已有 goal 时拒绝覆盖**(P12-③)。
1641
- const wrote = setTaskGoal(
1642
- project, String(session.id), agentName, goalText, hostRoot(hub), String(args.task || ''),
1643
- );
1644
- if (wrote.ok) {
1645
- goalTaskId = wrote.taskId || '';
1646
- goalMatchedBy = wrote.matchedBy || '';
1647
- } else {
1648
- goalError = wrote.error || 'unknown';
1649
- if (Array.isArray(wrote.candidates)) goalCandidates = wrote.candidates;
1650
- }
1651
- } else {
1652
- goalError = '会话标题不是「<项目> · <成员>」格式,无法定位项目';
1653
- }
1654
- } catch (error) {
1655
- goalError = String(error && error.message);
1656
- }
1657
- // —— 真正替成员建 goal(2026-10-22)——
1658
- // 以前只发「请调用 create_goal」的文本指令,成员跳过就什么都没有;现在直接调
1659
- // DSH 的 goal 服务(ctx.goals = GoalService,签名 create(agent, {objective, maxGoalRounds})),
1660
- // 它会向**目标会话**的日志追加 `goal/change` 事件 —— 不依赖成员配合。
1661
- try {
1662
- const goalsSvc = hub.get('goals') || (typeof ctx.get === 'function' ? ctx.get('goals') : undefined);
1663
- const agentsSvc = hub.get('agents');
1664
- const targetAgent = (agentsSvc && typeof agentsSvc.get === 'function')
1665
- ? agentsSvc.get(String(session.id)) : undefined;
1666
- if (!goalsSvc || typeof goalsSvc.create !== 'function') {
1667
- goalCreateError = 'goal 服务(ctx.goals)不可用';
1668
- } else if (!targetAgent || !targetAgent.session) {
1669
- goalCreateError = '目标会话的 agent 不可用(可能未激活)';
1670
- } else {
1671
- try {
1672
- goalsSvc.create(targetAgent, { objective: goalText, maxGoalRounds: 12 });
1673
- goalCreated = true;
1674
- } catch (error) {
1675
- const msg = String((error && error.message) || error);
1676
- // 已有 goal:本质上"目标已存在",不算失败
1677
- if (/GOAL_ALREADY_EXISTS|already exists/i.test(msg)) {
1678
- goalCreated = true;
1679
- goalCreateError = '已存在 goal(跳过重复创建)';
1680
- } else {
1681
- goalCreateError = msg;
1682
- }
1683
- }
1684
- }
1685
- } catch (error) {
1686
- goalCreateError = String((error && error.message) || error);
1687
- }
1688
- if (goalCreateError && !goalCreated) {
1689
- console.warn(`[hub-desktop-ui] taskline_prompt goal create failed: ${goalCreateError}`);
1690
- }
1691
- goalNote = goalDirective(goalText);
1692
- }
1693
- // —— 投递方式(2026-10-22)——
1694
- // 不传 mode = 老行为完全不变(controller.prompt mode:'queue' → agent.followup)。
1695
- // steer:controller.prompt mode:'steer' → agent.steer(message)(next-step 队列 + 唤醒),
1696
- // 只在目标 running 时有意义;空闲时退化为 queue。
1697
- // interrupt:controller.cancel({sessionId}) → agent.cancel({kind:'user'},{keepInbox:true}),
1698
- // 只中断当前轮、**不清 inbox**,已排队的下一条消息原样保留;
1699
- // 本条以 plugin-owned v4 消息排进 next-turn(不唤醒),等下次唤醒按序处理。
1700
- // 2026-11-08 用户令:默认投递方式从 queue 改成 triage —— 目标在跑就开分诊分身,不排队干等。
1701
- // 显式 mode:"queue" 仍可强制真排队;HUB_QUEUE_TRIAGE=0 全局回退旧行为。
1702
- // 2026-11-09 用户令(返回自相矛盾修复):**一次快照 + 单一决策**——模式、note、
1703
- // 返回的 targetRunning、render 结论全部来自下面这一次 deps.snapshotTargetRunning。
1704
- const defaultMode = deps.queueTriageEnabled ? 'triage' : 'queue';
1705
- const requestedMode = String(args.mode || defaultMode).toLowerCase();
1706
- const agentsSvc = hub.get('agents');
1707
- const deliveryAgent = agentsSvc && typeof agentsSvc.get === 'function'
1708
- ? agentsSvc.get(String(session.id)) : undefined;
1709
- const runningSnapshot = await deps.snapshotTargetRunning(String(session.id));
1710
- const runningNow = runningSnapshot.running;
1711
- // interrupt 等 idle 的上限:调用方传 timeoutMs 就跟它走,否则用模块常量
1712
- //(长工具 abort 可能几秒;上限太短会误报 idle:false)
1713
- const requestedIdleWait = Number(args.timeoutMs);
1714
- const interruptIdleWaitMs = Number.isFinite(requestedIdleWait) && requestedIdleWait > 0
1715
- ? Math.min(Math.max(Math.floor(requestedIdleWait), 1000), 600000)
1716
- : INTERRUPT_IDLE_WAIT_MS;
1717
- const plan = planDelivery({
1718
- requestedMode,
1719
- running: runningNow,
1720
- triageEnabled: deps.queueTriageEnabled,
1721
- targetIsTriage: isQueueTriageTitle(deps.titleOfSession(session)),
1722
- });
1723
- let deliveredMode = plan.mode;
1724
- let modeNote = plan.note;
1725
- let interrupted = false;
1726
- let textQueued = false;
1727
- let idleAfterInterrupt;
1728
- const promptText = `${goalNote}${args.text}`;
1729
- if (deliveredMode === 'interrupt') {
1730
- if (!controller || typeof controller.cancel !== 'function') {
1731
- return { ok: false, error: 'sessionController.cancel 不可用,无法执行 interrupt' };
1732
- }
1733
- try {
1734
- await controller.cancel({ sessionId: String(session.id) });
1735
- } catch (error) {
1736
- return { ok: false, error: `中断失败:${String((error && error.message) || error)}` };
1737
- }
1738
- interrupted = true;
1739
- try {
1740
- if (deliveryAgent && deliveryAgent.inbox && typeof deliveryAgent.inbox.append === 'function') {
1741
- deliveryAgent.inbox.append('next-turn', noticeMessage(promptText, 'taskline-interrupt'));
1742
- textQueued = true;
1743
- } else {
1744
- modeNote = '拿不到目标 agent.inbox,本条未入队(已排队的消息仍保留)';
1745
- }
1746
- } catch (error) {
1747
- modeNote = `本条入队失败:${String((error && error.message) || error)}`;
1748
- }
1749
- idleAfterInterrupt = await deps.waitForAgentIdle(String(session.id), interruptIdleWaitMs);
1750
- }
1751
- // —— 分诊(2026-11-08 用户令):目标在跑就不排队,立刻开分身判「steer 主线 / 分身自己做」——
1752
- let triageInfo = null;
1753
- if (deliveredMode === 'triage') {
1754
- const callerAgentForTriage = exec && exec.agent;
1755
- triageInfo = await deps.spawnQueueTriage({
1756
- targetId: String(session.id),
1757
- targetTitle: deps.titleOfSession(session),
1758
- callerTitle: callerAgentForTriage ? deps.callerTitleOf(callerAgentForTriage) : '',
1759
- callerSessionId: callerAgentForTriage && callerAgentForTriage.id !== undefined
1760
- ? String(callerAgentForTriage.id) : '',
1761
- // 传原始 text(不带 goal 指令前缀):分诊分身看的是"这条消息本身"
1762
- text: String(args.text || ''),
1763
- });
1764
- if (!triageInfo.ok) {
1765
- // 分诊建不出来就退回普通排队——消息绝不能丢
1766
- deliveredMode = 'queue';
1767
- modeNote = `分诊分身建不出来(${triageInfo.error}),本条退回普通排队`;
1768
- }
1769
- }
1770
- try {
1771
- // 派活纪律(2026-10-26):目标状态 + 排队位次直接进返回值;状态一律用上面
1772
- // 那**一次**快照,绝不在这里再查一次(2026-11-09:两处快照不一致正是本 bug)。
1773
- const targetRunning = runningNow;
1774
- const pendingBefore = deps.pendingDispatchesOf(session.id);
1775
- if (!interrupted && !(triageInfo && triageInfo.ok)) {
1776
- await controller.prompt({
1777
- requestId: `hub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1778
- sessionId: session.id,
1779
- mode: deliveredMode === 'steer' ? 'steer' : 'queue',
1780
- content: [{ type: 'text', text: promptText }],
1781
- }, new AbortController().signal);
1782
- deps.recordDispatch(session.id, targetRunning);
1783
- }
1784
- const pendingAfter = deps.pendingDispatchesOf(session.id);
1785
- const delivery = deliveryConclusion({
1786
- ok: true,
1787
- mode: deliveredMode,
1788
- targetRunning,
1789
- triageSessionId: triageInfo && triageInfo.ok ? triageInfo.sessionId : undefined,
1790
- interrupted,
1791
- });
1792
- return {
1793
- ok: true,
1794
- sessionId: session.id,
1795
- title: deps.titleOfSession(session),
1796
- // 派活纪律(2026-10-26):目标状态 + 排队位次,直接进工具结果供管理者当场判断
1797
- targetRunning,
1798
- pendingDispatches: pendingAfter,
1799
- goal: goalText || '',
1800
- goalTaskId,
1801
- goalError,
1802
- // 三态:goalWritten = tasks.json 写入;goalCreated = 会话里真有 goal 事件;
1803
- // goalFailed = 建 goal 失败原因
1804
- goalWritten: Boolean(goalTaskId) && !goalError,
1805
- goalCreated,
1806
- goalFailed: goalText && !goalCreated ? (goalCreateError || 'unknown') : '',
1807
- goalMatchedBy,
1808
- ...(goalCandidates.length ? { candidates: goalCandidates } : {}),
1809
- mode: deliveredMode,
1810
- delivery,
1811
- ...(triageInfo && triageInfo.ok
1812
- ? { triageSessionId: triageInfo.sessionId, triageTitle: triageInfo.title }
1813
- : {}),
1814
- ...(interrupted ? { interrupted: true, textQueued } : {}),
1815
- ...(idleAfterInterrupt === undefined ? {} : { idle: idleAfterInterrupt }),
1816
- ...(modeNote ? { note: modeNote } : {}),
1817
- ...(pendingBefore > 0 ? { queuedBefore: pendingBefore } : {}),
1818
- };
1819
- } catch (error) {
1820
- return { ok: false, error: String(error && error.message) };
1821
- }
1822
- },
1823
- }));
1824
-
1825
- // —— taskline_list ——
1826
- disposers.push(register({
1827
- name: 'taskline_list',
1828
- description: '列出所有可见会话(任务线):sessionId、标题、是否运行中、是否分身。含重启后未激活的会话;sessionController 漏掉的冷会话从各项目 tasks.json 注册表补全(diag 里可见 rows/registry 两路计数)。',
1829
- parameters: { type: 'object', properties: {}, additionalProperties: false, required: [] },
1830
- output: {
1831
- schema: {
1832
- type: 'object',
1833
- additionalProperties: false,
1834
- required: ['items'],
1835
- properties: {
1836
- items: {
1837
- type: 'array',
1838
- items: {
1839
- type: 'object',
1840
- additionalProperties: false,
1841
- required: ['sessionId', 'title', 'running', 'subagent'],
1842
- properties: {
1843
- sessionId: { type: 'string' },
1844
- title: { type: 'string' },
1845
- running: { type: 'boolean' },
1846
- subagent: { type: 'boolean' },
1847
- },
1848
- },
1849
- },
1850
- diag: { type: 'string' },
1851
- },
1852
- },
1853
- render: (_args, value) => [{
1854
- type: 'text',
1855
- text: (value.items.length === 0
1856
- ? '(没有活着的会话)'
1857
- : value.items.map((item) => `${item.sessionId} ${item.title}${item.running ? ' [运行中]' : ''}${item.subagent ? ' [分身]' : ''}`).join('\n'))
1858
- + (value.diag ? `\n[diag] ${value.diag}` : ''),
1859
- }],
1860
- },
1861
- async execute() {
1862
- const sessions = hub.get('sessions');
1863
- // 底册用 sessionController.list():读**全部可见会话**(含重启后未激活的冷会话),
1864
- // 不激活任何 Agent;行上的 running 官方已用 `agents.get(id)?.status === 'running'`
1865
- // 算好(Agent 的字段是 status 字符串,没有 running 布尔——之前读错位置恒为 false)。
1866
- const controller = hub.get('sessionController');
1867
- // 归档集合:registry-global 的 archivedSessionIds(api-session-controller 也这么读),
1868
- // 已归档/已回收的分身不该再出现在「活会话」列表里。
1869
- const registry = hub.get('workspaceRegistry');
1870
- const archivedSet = new Set(
1871
- registry && Array.isArray(registry.archivedSessionIds)
1872
- ? registry.archivedSessionIds.map(String)
1873
- : [],
1874
- );
1875
- let rows = null;
1876
- let rowsSource = 'none';
1877
- let listError = '';
1878
- if (controller && typeof controller.list === 'function') {
1879
- try {
1880
- const value = await controller.list(new AbortController().signal);
1881
- // list() 返回 {items:[...]}(Remote('list') 包装)或裸数组——两种都收
1882
- rows = value && Array.isArray(value.items) ? value.items : value;
1883
- if (Array.isArray(rows)) rowsSource = 'controller.list';
1884
- } catch (error) {
1885
- listError = String((error && error.message) || error);
1886
- console.warn(`[hub-desktop-ui] sessionController.list failed: ${listError}`);
1887
- }
1888
- }
1889
- if (!Array.isArray(rows)) {
1890
- // 兜底:controller 不可用时退回旧路径(只含活会话、无 running 判据)
1891
- const list = sessions && typeof sessions.list === 'function' ? sessions.list() : [];
1892
- rows = list.map((session) => ({
1893
- sessionId: session.id,
1894
- running: false,
1895
- origin: session.header && session.header.origin,
1896
- parentSessionId: session.header && session.header.parentSession,
1897
- }));
1898
- rowsSource = `fallback(${listError.slice(0, 60)})`;
1899
- }
1900
- const diagRunning = [];
1901
- const items = rows
1902
- .filter((row) => row && row.sessionId && !archivedSet.has(String(row.sessionId)))
1903
- .map((row) => {
1904
- const key = String(row.sessionId);
1905
- const live = sessions && typeof sessions.get === 'function' ? sessions.get(row.sessionId) : undefined;
1906
- // 标题:活会话走 sessionTitle;冷会话读官方缓存的 title 投影(用户改名=session/title 事件,已入缓存)
1907
- const projected = row.projections && row.projections.values ? row.projections.values.title : undefined;
1908
- const title = (live ? deps.titleOfSession(live) : '')
1909
- || (typeof projected === 'string' ? projected : '')
1910
- || `(未命名 ${String(row.sessionId).slice(0, 8)}…)`;
1911
- // 运行中:事件集合优先,其次 list 行现算字段(任一为真就算在跑)
1912
- const tracked = deps.liveRunning.has(key);
1913
- let agentStatus = 'n/a';
1914
- try {
1915
- const agents = hub.get('agents');
1916
- const agent = agents && typeof agents.get === 'function' ? agents.get(row.sessionId) : undefined;
1917
- agentStatus = agent ? String(agent.status) : 'no-agent';
1918
- } catch (error) { agentStatus = 'err'; }
1919
- // 运行中判据(实测校准):`agents.get(id).status === 'running'` 是准的
1920
- //(诊断里自己的会话 st=running);`sessionController.list()` 行里的 running
1921
- // 经 wire 传输后恒为 false,不可信;事件集合做兜底。
1922
- const running = agentStatus === 'running' || tracked || Boolean(row.running);
1923
- // 诊断:归档集合规模 + 被判为运行中的会话 id(排障用,正文不受影响)
1924
- if (running) diagRunning.push(key);
1925
- return {
1926
- sessionId: row.sessionId,
1927
- title,
1928
- running,
1929
- subagent: Boolean(row.origin === 'subagent' || row.parentSessionId
1930
- || (live && live.header && (live.header.origin === 'subagent' || live.header.parentSession))),
1931
- };
1932
- });
1933
- // —— 注册表补全(2026-10-22 修「taskline_list 只回 2-3 条」)——
1934
- // `sessionController.list()` 的返回行数实测会缩水(磁盘 175 会话/未归档约 27,
1935
- // 只回 3 行),管理者据此误判成员已归档、派错对象。各项目 tasks.json 的
1936
- // sessionIds 是协议要求维护的**注册表**:把里面未归档、且 list 没回的会话补进来
1937
- // (标题 = 项目 · 成员名),冷会话 running=false。归档集只用于排除,不裁剪活跃窗口。
1938
- const rowsRaw = rows.length;
1939
- const seen = new Set(items.map((item) => String(item.sessionId)));
1940
- let registryAdded = 0;
1941
- try {
1942
- const projectsDir = join(hostRoot(hub), '.hub', 'projects');
1943
- for (const dir of readdirSync(projectsDir)) {
1944
- let data = null;
1945
- try { data = JSON.parse(readFileSync(join(projectsDir, dir, 'tasks.json'), 'utf8')); } catch (error) { continue; }
1946
- if (!data || !Array.isArray(data.tasks)) continue;
1947
- for (const task of data.tasks) {
1948
- if (!task || task.status === 'archived') continue;
1949
- const members = Array.isArray(task.members) ? task.members : [];
1950
- const ids = Array.isArray(task.sessionIds) ? task.sessionIds.map(String) : [];
1951
- for (const id of ids) {
1952
- if (seen.has(id) || archivedSet.has(id)) continue;
1953
- seen.add(id);
1954
- registryAdded += 1;
1955
- const member = String(members[0] || task.title || '成员');
1956
- items.push({
1957
- sessionId: id,
1958
- title: `${dir} · ${member}`,
1959
- running: deps.liveRunning.has(id),
1960
- subagent: member.includes('|'),
1961
- });
1962
- }
1963
- }
1964
- }
1965
- } catch (error) {
1966
- console.warn(`[hub-desktop-ui] registry merge failed: ${String(error)}`);
1967
- }
1968
- return {
1969
- items,
1970
- diag: `src=${rowsSource} rows=${rowsRaw} registry=+${registryAdded} archived=${archivedSet.size}`
1971
- + ` running=${diagRunning.length ? diagRunning.join(',') : 'none'}`,
1972
- };
1973
- },
1974
- }));
1975
-
1976
- return disposers;
1977
- }
1
+ /**
2
+ * dsh-agent-tasks — AI-Agent-Hub 的任务线 / agent 通信工具。
3
+ *
4
+ * 2026-11-10 起从这里起步:先把 **agent_message**(146 行、只依赖 hub.get、完全自包含)
5
+ * 从 hub-desktop-ui/index.js 的 register 块(原 L6191-6337)搬过来,作为拆包的样板。
6
+ * 其余 11 个工具按同样方法逐个搬入(各自依赖已在拆包前查清:sessions / hub.get /
7
+ * core 的 projectOfSessionTitle+hostRoot / clone-handoff 的 collectEvents)。
8
+ *
9
+ * 用法(hub-desktop-ui 里):
10
+ * import { registerAgentTools } from 'dsh-agent-tasks';
11
+ * const disposers = registerAgentTools({ hub, register, deps });
12
+ */
13
+ export const PACKAGE_VERSION = '0.1.0';
14
+
15
+ /**
16
+ * 注册本包负责的工具。
17
+ * @param {{ hub: object, register: (definition: object) => unknown, deps?: object }} opts
18
+ * @returns {unknown[]} disposers(与 tools.register 的返回值一致)
19
+ */
20
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { randomUUID } from 'node:crypto';
22
+ import { homedir, tmpdir } from 'node:os';
23
+ import { dirname, join, resolve } from 'node:path';
24
+ import { collectEvents, writeCloneHandoff } from 'dsh-clone-handoff';
25
+ import {
26
+ agentNameOfTitle,
27
+ cleanCloneRegistry,
28
+ dedupeActiveTitles,
29
+ deliveryConclusion,
30
+ ensureLiveSession,
31
+ ensureMemberAvatar,
32
+ goalDirective,
33
+ guardRoutingFile,
34
+ hostRoot,
35
+ INTERRUPT_IDLE_WAIT_MS,
36
+ isQueueTriageTitle,
37
+ looksTaskSpecificName,
38
+ noticeMessage,
39
+ planDelivery,
40
+ projectOfSessionTitle,
41
+ pruneArchivedSessions,
42
+ readJsonForWrite,
43
+ readRegisteredSessionIds,
44
+ readTaskFile,
45
+ renderTasklinePrompt,
46
+ resumeInterruptedSessions,
47
+ setTaskGoal,
48
+ writeJsonAtomic,
49
+ resumeLedgerPath,
50
+ RESUME_PROMPT,
51
+ RESUME_MAX_PER_BOOT,
52
+ titleOf,
53
+ readGlobalAvatars,
54
+ writeGlobalAvatar,
55
+ AVATAR_POOL,
56
+ recordResumeSweep,
57
+ shouldSkipResumeByLedger,
58
+ projectsDir,
59
+ boardPath,
60
+ tasksPath,
61
+ routingPath,
62
+ handoffsDir,
63
+ triageDecisionOf,
64
+ readSessionEvents,
65
+ compactSessionEvents,
66
+ } from 'dsh-hub-core';
67
+
68
+ export function registerAgentTools({ hub, register, deps = {} }) {
69
+ // defaultPreset(2026-11-10 修):12 个工具迁出 hub-desktop-ui 时漏注入了这个。
70
+ // 因为 taskline_create 里写的是 `args.preset || defaultPreset`(短路求值),
71
+ // 只要调用方传了 preset 就永远不会求值 → 一直没暴露;
72
+ // 一旦不传 preset 就直接 ReferenceError: defaultPreset is not defined。
73
+ // 兜底 'cordis' 与 hub-desktop-ui 那边 `config.defaultPreset || 'cordis'` 保持一致。
74
+ const { callerTitleOf, isRunningNow, pendingDispatchesOf, recordDispatch, resolveSession, snapshotTargetRunning, spawnQueueTriage, titleOfSession, waitForAgentIdle, wakeAgentMessageTarget, liveRunning, queueTriageEnabled, defaultPreset = 'cordis', restart: restartDesktop = null } = deps;
75
+ const disposers = [];
76
+
77
+ // —— hub_restart(2026-11-12 用户令「注册一个 hub_restart 工具让 agent 直接调」)——
78
+ // 场景:改的是 Host 侧插件代码/配置,必须重启才生效;让 agent 自己重启,不用用户手点。
79
+ // 实现要点:延迟 delayMs(默认 1.5s)再调 restartDesktop(),保证本条工具结果先回到模型;
80
+ // 重启会中断当前回合,DSH 重启后本会话自动续跑。
81
+ disposers.push(register({
82
+ name: 'hub_restart',
83
+ description: '重启 DSH 桌面端。改动的插件 / 配置需要重启才生效时用它。'
84
+ + '默认延迟 1.5 秒执行(让本条结果先回到你这边);重启会中断当前回合,重启后本会话自动续跑。'
85
+ + '25 秒内重复调用会被防抖跳过;不想等可以直接把 delayMs 调小。',
86
+ parameters: {
87
+ type: 'object',
88
+ additionalProperties: false,
89
+ properties: {
90
+ reason: { type: 'string', description: '为什么要重启(写进重启日志,便于以后回溯)' },
91
+ delayMs: { type: 'integer', description: '延迟多少毫秒后再关(默认 1500,范围 500~10000)' },
92
+ },
93
+ },
94
+ output: {
95
+ schema: {
96
+ type: 'object',
97
+ additionalProperties: false,
98
+ required: ['ok', 'scheduled'],
99
+ properties: {
100
+ ok: { type: 'boolean' },
101
+ scheduled: { type: 'boolean' },
102
+ delayMs: { type: 'integer' },
103
+ note: { type: 'string' },
104
+ error: { type: 'string' },
105
+ },
106
+ },
107
+ render: (_args, value) => [{
108
+ type: 'text',
109
+ text: value.ok
110
+ ? `已排定重启:${value.delayMs}ms 后关闭 DSH(本回合会被中断,重启后会话自动续跑)`
111
+ : `重启排定失败:${value.error || '未知原因'}`,
112
+ }],
113
+ },
114
+ async execute(args = {}) {
115
+ if (typeof restartDesktop !== 'function') {
116
+ return { ok: false, scheduled: false, error: '宿主未注入 restart(dsh-desktop-watchdog 未加载?)' };
117
+ }
118
+ const delay = Math.min(10000, Math.max(500, Number(args.delayMs) || 1500));
119
+ setTimeout(() => {
120
+ try {
121
+ const result = restartDesktop();
122
+ console.log(`[hub_restart] agent 请求重启 reason=${args.reason || '-'} result=${String(result)}`);
123
+ } catch (error) {
124
+ console.warn(`[hub_restart] 重启失败: ${String((error && error.message) || error)}`);
125
+ }
126
+ }, delay);
127
+ return { ok: true, scheduled: true, delayMs: delay, note: `reason=${args.reason || '-'}` };
128
+ },
129
+ }));
130
+
131
+ // —— agent_message(2026-10-23 用户令;原 hub-desktop-ui L6191-6337)——
132
+ disposers.push(register({
133
+ name: 'agent_message',
134
+ description: '给任意 agent(含其它项目)直接发一条消息:讨论、要资料、同步状态、协作求解。'
135
+ + '交流放行(跨项目可用);正式任务分派仍走本项目管理者的 taskline_prompt。'
136
+ + 'mode 默认 steer=**中途插队**(目标在跑时在下一个 step 边界插入当前轮,不打断正在执行的工具;目标空闲自动退化为排队),'
137
+ + 'mode=queue 才是排队等回合结束。'
138
+ + '别用它给忙碌的 agent 反复轰炸转派任务——接活请走管理者。',
139
+ parameters: {
140
+ type: 'object',
141
+ additionalProperties: false,
142
+ required: ['target', 'text'],
143
+ properties: {
144
+ target: { type: 'string', description: '目标:完整 sessionId 或「项目 · 成员」标题(可部分匹配)' },
145
+ text: { type: 'string', description: '要说的话(会自动加【来自 <你>】前缀)' },
146
+ replyExpected: { type: 'boolean', description: 'true 时文末提示对方回一句(默认不加)' },
147
+ mode: {
148
+ type: 'string',
149
+ enum: ['steer', 'queue'],
150
+ description: '投递模式:steer(默认)= 目标在跑时插进当前轮的下一个 step 边界(不打断当前工具调用;目标空闲退化为排队);'
151
+ + 'queue = 排队等它当前回合结束',
152
+ },
153
+ },
154
+ },
155
+ output: {
156
+ schema: {
157
+ type: 'object',
158
+ additionalProperties: false,
159
+ required: ['ok'],
160
+ properties: {
161
+ ok: { type: 'boolean' },
162
+ sessionId: { type: 'string' },
163
+ targetTitle: { type: 'string' },
164
+ crossProject: { type: 'boolean' },
165
+ callerTitle: { type: 'string' },
166
+ ambiguous: { type: 'boolean' },
167
+ candidates: { type: 'array', items: { type: 'string' } },
168
+ mode: { type: 'string' },
169
+ woke: { type: 'boolean' },
170
+ wakeNote: { type: 'string' },
171
+ error: { type: 'string' },
172
+ },
173
+ },
174
+ render: (_args, value) => [{
175
+ type: 'text',
176
+ text: value.ok
177
+ ? `已发给「${value.targetTitle}」${value.crossProject ? '(跨项目)' : ''}${value.woke ? ',并已唤醒它开始回复' : (value.wakeNote ? `(未唤醒:${value.wakeNote})` : '')}`
178
+ : (value.ambiguous
179
+ ? `命中多个会话,请用完整 sessionId 重发:\n${(value.candidates || []).join('\n')}`
180
+ : `发送失败:${value.error || 'unknown'}`),
181
+ }],
182
+ },
183
+ async execute(args, exec) {
184
+ const controller = hub.get('sessionController');
185
+ if (!controller || typeof controller.prompt !== 'function') {
186
+ return { ok: false, error: 'sessionController 不可用' };
187
+ }
188
+ // P8-①(2026-10-29):text 缺失/非字符串时拒绝投递(同 taskline_prompt 防 undefined)
189
+ if (typeof args.text !== 'string' || !args.text.trim()) {
190
+ return { ok: false, error: '缺少 text(收到 undefined/空/非字符串)。请检查 agent_message 调用参数后重发;本条未投递。' };
191
+ }
192
+ const ref = String(args.target || '').trim();
193
+ const isExactId = /^session-[0-9a-f-]{20,}$/i.test(ref);
194
+ // 目标解析:完整 id 直用;标题片段跨**全部项目**清点(同名列候选)
195
+ let session = isExactId ? deps.resolveSession(ref) : undefined;
196
+ let ambiguity = null;
197
+ if (!session) {
198
+ try {
199
+ const value = await controller.list(new AbortController().signal);
200
+ const rows = value && Array.isArray(value.items) ? value.items : (Array.isArray(value) ? value : []);
201
+ const registry = hub.get('workspaceRegistry');
202
+ const archivedSet = new Set(
203
+ registry && Array.isArray(registry.archivedSessionIds)
204
+ ? registry.archivedSessionIds.map(String) : [],
205
+ );
206
+ const candidates = [];
207
+ for (const row of rows) {
208
+ if (!row || !row.sessionId || archivedSet.has(String(row.sessionId))) continue;
209
+ const t = row.projections && row.projections.values && typeof row.projections.values.title === 'string'
210
+ ? row.projections.values.title : '';
211
+ if (!t || !t.includes(ref)) continue;
212
+ candidates.push({ id: String(row.sessionId), title: t });
213
+ }
214
+ if (candidates.length === 1) {
215
+ session = { id: candidates[0].id, displayTitle: candidates[0].title };
216
+ } else if (candidates.length > 1) {
217
+ ambiguity = candidates;
218
+ }
219
+ } catch (error) { /* 清点失败退回 live 解析 */ }
220
+ }
221
+ if (!session) session = deps.resolveSession(ref);
222
+ if (ambiguity && ambiguity.length > 1) {
223
+ return {
224
+ ok: false,
225
+ ambiguous: true,
226
+ candidates: ambiguity.map((c) => `${c.title} ${c.id}`),
227
+ error: '同名多个会话',
228
+ };
229
+ }
230
+ if (!session) return { ok: false, error: '找不到这个会话(可用 taskline_list 查完整 sessionId)' };
231
+ // 唤醒 + 投递(ensureLiveSession 每次 resolveAgent,冷会话也能收)
232
+ const live = await ensureLiveSession(hub, controller, String(session.id));
233
+ if (!live.ok) return { ok: false, error: live.error || '会话不可用' };
234
+ const callerAgent = exec && exec.agent;
235
+ const callerTitle = callerAgent ? deps.callerTitleOf(callerAgent) : '';
236
+ const targetTitle = deps.titleOfSession(live.session) || session.displayTitle || '';
237
+ const projectOfTitle = (t) => (t && t.includes(' · ') ? t.slice(0, t.lastIndexOf(' · ')).trim() : '');
238
+ const crossProject = Boolean(
239
+ projectOfTitle(callerTitle) && projectOfTitle(targetTitle)
240
+ && projectOfTitle(targetTitle) !== projectOfTitle(callerTitle),
241
+ );
242
+ const body = `【来自 ${callerTitle || '某 agent'}】${String(args.text || '').trim()}`
243
+ + (args.replyExpected ? '\n(方便的话回我一句)' : '');
244
+ // 投递模式(2026-10-23 用户令「要可以中途插队」):默认 steer。
245
+ // steer = 目标在跑时在下一个 step 边界插入当前轮(不打断正在执行的工具调用);
246
+ // 目标空闲时 DSH 侧自动退化为排队 —— 所以如实回报「实际生效模式」。
247
+ const requestedMode = args.mode === 'queue' ? 'queue' : 'steer';
248
+ let targetRunning = false;
249
+ try {
250
+ const agents = hub.get('agents');
251
+ const agent = agents && typeof agents.get === 'function' ? agents.get(String(live.session.id)) : undefined;
252
+ targetRunning = Boolean(agent && String(agent.status) === 'running');
253
+ } catch (error) { /* 取不到就按空闲处理 */ }
254
+ const effectiveMode = requestedMode === 'steer' && !targetRunning ? 'queue' : requestedMode;
255
+ try {
256
+ await controller.prompt({
257
+ requestId: `agent-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
258
+ sessionId: String(live.session.id),
259
+ mode: requestedMode,
260
+ content: [{ type: 'text', text: body }],
261
+ }, new AbortController().signal);
262
+ // P3(2026-10-27 杨航拍板「允许跨项目自动唤醒」):投递后若目标仍空闲,
263
+ // 复用 resume_session 的同一实现唤醒一次,避免只建空 turn(lastTurn+1 / response="")。
264
+ const wake = await deps.wakeAgentMessageTarget(String(live.session.id));
265
+ return {
266
+ ok: true,
267
+ sessionId: String(live.session.id),
268
+ targetTitle,
269
+ crossProject,
270
+ callerTitle,
271
+ mode: effectiveMode,
272
+ ...(wake.woke ? { woke: true } : {}),
273
+ ...(wake.reason ? { wakeNote: wake.reason } : {}),
274
+ };
275
+ } catch (error) {
276
+ return { ok: false, error: String((error && error.message) || error) };
277
+ }
278
+ },
279
+ }
280
+ ));
281
+
282
+ // —— vision_read ——
283
+ disposers.push(register({
284
+ name: 'vision_read',
285
+ description: '⛔【视觉模型调用即违规】能收图的模型(inputModalities 含 image)请直接读附件或 read_image,**不要调用本工具**——'
286
+ + '本工具仅供**确认无图像输入能力的纯文本模型**兜底。'
287
+ + '功能:让视觉模型替你看图,返回文字结果(描述 / 针对某个问题回答 / OCR 逐字转写)。'
288
+ + '违反示例:视觉模型调用它 = 把原图转述成有损文字、多跳一次常失败的后端、浪费 token。',
289
+ parameters: {
290
+ type: 'object',
291
+ additionalProperties: false,
292
+ required: ['source'],
293
+ properties: {
294
+ source: { type: 'string', description: '图片路径:绝对路径、~ 开头或 file:// URL;无扩展名也能识别(按文件头)' },
295
+ question: { type: 'string', description: '要问的问题;省略则返回整体描述' },
296
+ ocr: { type: 'boolean', description: 'true = 逐字转写图中所有文字(此时忽略 question)' },
297
+ },
298
+ },
299
+ output: {
300
+ schema: {
301
+ type: 'object',
302
+ additionalProperties: false,
303
+ required: ['ok', 'text'],
304
+ properties: {
305
+ ok: { type: 'boolean' },
306
+ text: { type: 'string' },
307
+ error: { type: 'string' },
308
+ },
309
+ },
310
+ render: (_args, value) => [{
311
+ type: 'text',
312
+ text: value.ok ? value.text : `vision_read 失败:${value.error || '未知原因'}`,
313
+ }],
314
+ },
315
+ timeoutMs: 300000,
316
+ async execute(args, exec) {
317
+ // ① 提示视觉模型"你本来就能看图"(2026-10-26 软化:不再硬拒答——
318
+ // 客户端拦截后图片以路径文本进会话,视觉模型拿不到附件内容时也该能代看,
319
+ // 否则会出现"路径给了但谁也看不了"的死角)。判定不出就照常工作。
320
+ let visionModelNote = '';
321
+ try {
322
+ const llm = hub.get('llm');
323
+ let selection;
324
+ try {
325
+ const agentsSvc = hub.get('agents');
326
+ const ref = agentsSvc && typeof agentsSvc.selectionFor === 'function'
327
+ ? agentsSvc.selectionFor(exec.agent) : undefined;
328
+ selection = ref && ref.current;
329
+ } catch (error) { /* 忽略 */ }
330
+ if (!selection && exec.agent && typeof exec.agent.currentSelection === 'function') {
331
+ selection = exec.agent.currentSelection();
332
+ }
333
+ if (selection && llm && typeof llm.resolveModelInfo === 'function') {
334
+ const info = await llm.resolveModelInfo(selection.provider, selection.model, exec.signal);
335
+ const modalities = info && info.inputModalities;
336
+ if (Array.isArray(modalities) && modalities.includes('image')) {
337
+ visionModelNote = `\n\n[注:当前模型(${selection.model})支持图像输入;若你能直接看到该图片附件,优先以原生视图为准。]`;
338
+ }
339
+ }
340
+ } catch (error) { /* 判定失败:继续执行 */ }
341
+
342
+ // ② 定位 vision-tools 工具包
343
+ const toolkitCandidates = [
344
+ process.env.DSH_VISION_TOOLKIT,
345
+ join(homedir(), 'Documents', 'GitHub', 'AI-Agent-Hub', 'skills', 'vision-tools'),
346
+ join(homedir(), '.dsh', 'skills', 'vision-tools'),
347
+ ].filter(Boolean);
348
+ const toolkit = toolkitCandidates.find((dir) => existsSync(join(dir, 'bin', 'glance')));
349
+ if (!toolkit) {
350
+ return { ok: false, text: '', error: `找不到 vision-tools(试过:${toolkitCandidates.join(' | ')})` };
351
+ }
352
+ const scriptsDir = join(toolkit, 'scripts');
353
+
354
+ // ③ python 解释器
355
+ const pythonCandidates = [
356
+ process.env.DSH_VISION_PYTHON,
357
+ join(process.env.LOCALAPPDATA || '', 'Programs', 'Python', 'Python313', 'python.exe'),
358
+ 'python',
359
+ ].filter(Boolean);
360
+ const python = pythonCandidates.find((exe) => exe === 'python' || existsSync(exe)) || 'python';
361
+
362
+ // ④ 图片路径 + 扩展名归一(DSH 附件对象名是 sha256,没有扩展名)
363
+ let raw = String(args.source || '').trim();
364
+ if (!raw) return { ok: false, text: '', error: 'source 不能为空' };
365
+ if (raw.startsWith('file://')) {
366
+ try { raw = decodeURIComponent(new URL(raw).pathname).replace(/^\/([A-Za-z]:)/, '$1'); } catch (error) { /* 保留原样 */ }
367
+ }
368
+ if (raw.startsWith('~')) raw = join(homedir(), raw.slice(1));
369
+ const imagePath = resolve(raw);
370
+ if (!existsSync(imagePath)) return { ok: false, text: '', error: `图片不存在:${imagePath}` };
371
+ const knownExt = /\.(png|jpe?g|gif|webp)$/i.test(imagePath);
372
+ let stagedPath = imagePath;
373
+ let tempPath = '';
374
+ if (!knownExt) {
375
+ let magic = '';
376
+ try { magic = readFileSync(imagePath).subarray(0, 12).toString('latin1'); } catch (error) { /* ignore */ }
377
+ const ext = magic.startsWith('\u0089PNG') ? '.png'
378
+ : magic.startsWith('\u00ff\u00d8\u00ff') ? '.jpg'
379
+ : magic.startsWith('GIF8') ? '.gif'
380
+ : (magic.slice(0, 4) === 'RIFF' && magic.slice(8, 12) === 'WEBP') ? '.webp'
381
+ : '';
382
+ if (!ext) return { ok: false, text: '', error: `无法识别图片格式(支持 png/jpeg/gif/webp):${imagePath}` };
383
+ tempPath = join(tmpdir(), `dsh-vision-${randomUUID()}${ext}`);
384
+ try { writeFileSync(tempPath, readFileSync(imagePath)); } catch (error) {
385
+ return { ok: false, text: '', error: `暂存图片失败:${String(error && error.message)}` };
386
+ }
387
+ stagedPath = tempPath;
388
+ }
389
+
390
+ // ⑤a 首选 glance(skills/vision-tools 的本地视觉后端)
391
+ const askQuestion = args.ocr === true
392
+ ? '请逐字转写图里所有文字(保持原有换行与标点),不要解释。'
393
+ : (args.question ? String(args.question) : '请描述这张图片:整体内容、关键元素、图内可见文字。');
394
+ const cliArgs = [join(toolkit, 'bin', 'glance'), stagedPath];
395
+ if (args.ocr === true) cliArgs.push('--ocr');
396
+ else if (args.question) cliArgs.push('-q', String(args.question));
397
+
398
+ const runOnce = () => new Promise((resolveRun) => {
399
+ const child = spawn(python, cliArgs, {
400
+ cwd: toolkit,
401
+ env: { ...process.env, PYTHONPATH: scriptsDir },
402
+ windowsHide: true,
403
+ });
404
+ let out = '';
405
+ let err = '';
406
+ const timer = setTimeout(() => { try { child.kill(); } catch (error) { /* ignore */ } }, 280000);
407
+ child.stdout.on('data', (chunk) => { out += chunk.toString(); });
408
+ child.stderr.on('data', (chunk) => { err += chunk.toString(); });
409
+ child.on('error', (error) => { clearTimeout(timer); resolveRun({ code: -1, out, err: String(error && error.message) }); });
410
+ child.on('close', (code) => { clearTimeout(timer); resolveRun({ code, out, err }); });
411
+ });
412
+
413
+ // ⑤b 兜底后端:OpenAI 兼容的视觉 Chat API(2026-10-26:本机 vision-tools 的
414
+ // token-plan 后端全线 403 AccessDenied.Unpurchased,改用已验证接受图像输入的
415
+ // Ark DeepSeek V4.1 经济版;可用环境变量覆盖 base/model/key)。
416
+ const readArkKey = () => {
417
+ if (process.env.DSH_VISION_API_KEY) return process.env.DSH_VISION_API_KEY;
418
+ try {
419
+ const creds = readFileSync(join(homedir(), '.dsh', '.credentials.yaml'), 'utf8');
420
+ const m = /ARK_AGENT_PLAN_API_KEY:\s*['"]?([^'"\s]+)['"]?/.exec(creds);
421
+ if (m) return m[1];
422
+ } catch (error) { /* ignore */ }
423
+ return process.env.ARK_AGENT_PLAN_API_KEY || '';
424
+ };
425
+ const fallbackVision = async () => {
426
+ const key = readArkKey();
427
+ if (!key) return { ok: false, error: '没有可用的视觉 API key(DSH_VISION_API_KEY / ARK_AGENT_PLAN_API_KEY)' };
428
+ const base = String(process.env.DSH_VISION_BASE_URL || 'https://ark.cn-beijing.volces.com/api/plan/v3').replace(/\/+$/, '');
429
+ const model = String(process.env.DSH_VISION_MODEL || 'deepseek-v4-1-flash-economy-261017');
430
+ let b64 = '';
431
+ try { b64 = readFileSync(stagedPath).toString('base64'); } catch (error) {
432
+ return { ok: false, error: `读取图片失败:${String(error && error.message)}` };
433
+ }
434
+ const mime = /\.jpe?g$/i.test(stagedPath) ? 'image/jpeg'
435
+ : /\.gif$/i.test(stagedPath) ? 'image/gif'
436
+ : /\.webp$/i.test(stagedPath) ? 'image/webp' : 'image/png';
437
+ const body = JSON.stringify({
438
+ model,
439
+ max_tokens: 2048,
440
+ messages: [{
441
+ role: 'user',
442
+ content: [
443
+ { type: 'text', text: askQuestion },
444
+ { type: 'image_url', image_url: { url: `data:${mime};base64,${b64}` } },
445
+ ],
446
+ }],
447
+ });
448
+ try {
449
+ const res = await fetch(`${base}/chat/completions`, {
450
+ method: 'POST',
451
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
452
+ body,
453
+ signal: AbortSignal.timeout(180000),
454
+ });
455
+ const raw = await res.text();
456
+ if (res.status >= 400) return { ok: false, error: `HTTP ${res.status}: ${raw.slice(0, 400)}` };
457
+ const data = JSON.parse(raw);
458
+ const text = String((((data.choices || [])[0] || {}).message || {}).content || '').trim();
459
+ return text ? { ok: true, text: `${text}\n\n[via fallback vision model ${model}]` }
460
+ : { ok: false, error: `视觉后端返回空内容:${raw.slice(0, 300)}` };
461
+ } catch (error) {
462
+ return { ok: false, error: String(error && error.message) };
463
+ }
464
+ };
465
+
466
+ try {
467
+ const result = await runOnce();
468
+ const text = String(result.out || '').trim();
469
+ if (result.code === 0 && text) return { ok: true, text: text + visionModelNote };
470
+ const detail = String(result.err || '').trim() || text || `退出码 ${result.code}`;
471
+ // glance 后端失败 → 走兜底视觉模型(不把两段错误都抛给模型,只留摘要)
472
+ const fallback = await fallbackVision();
473
+ if (fallback.ok) return { ok: true, text: fallback.text + visionModelNote };
474
+ return {
475
+ ok: false,
476
+ text: '',
477
+ error: `glance 失败:${detail.slice(0, 500)};兜底也失败:${String(fallback.error || '').slice(0, 500)}`,
478
+ };
479
+ } finally {
480
+ if (tempPath) { try { rmSync(tempPath, { force: true }); } catch (error) { /* ignore */ } }
481
+ }
482
+ },
483
+ }));
484
+
485
+ // —— routing_put ——
486
+ disposers.push(register({
487
+ name: 'routing_put',
488
+ description: '把用户消息归到任务线:合并写入 .hub/projects/<项目>/routing.json(原子写+自愈,不会覆盖已有条目)。'
489
+ + '代替手工 read+edit routing.json——归线一律走这里。'
490
+ + 'entries={"eventId":"taskId"} 逐条给;或 ids=[...] + lane="t7" 批量归同一条线。'
491
+ + 'taskId 取值:_none(过程消息)/ meeting(会议)/ t<数字>(任务线)。',
492
+ parameters: {
493
+ type: 'object',
494
+ additionalProperties: false,
495
+ required: ['project'],
496
+ properties: {
497
+ project: { type: 'string', description: '项目名(.hub/projects/ 下的目录名)' },
498
+ entries: {
499
+ type: 'object',
500
+ description: '{ "<eventId>": "<taskId>" } 逐条映射;eventId 是 taskline_messages 返回的 eventId',
501
+ additionalProperties: { type: 'string' },
502
+ },
503
+ ids: { type: 'array', items: { type: 'string' }, description: '批量归同一条线时的事件 id 列表(与 lane 搭配)' },
504
+ lane: { type: 'string', description: '批量模式的目标 taskId(如 t7 / _none / meeting)' },
505
+ },
506
+ },
507
+ output: {
508
+ schema: {
509
+ type: 'object',
510
+ additionalProperties: false,
511
+ required: ['ok'],
512
+ properties: {
513
+ ok: { type: 'boolean' },
514
+ written: { type: 'number' },
515
+ skipped: { type: 'array', items: { type: 'string' } },
516
+ total: { type: 'number' },
517
+ error: { type: 'string' },
518
+ },
519
+ },
520
+ render: (_args, value) => [{
521
+ type: 'text',
522
+ text: value.ok
523
+ ? `routing.json 已合并写入 ${value.written} 条(总 ${value.total} 条)${value.skipped && value.skipped.length ? `;跳过已存在/非法:${value.skipped.join('、')}` : ''}`
524
+ : `写入失败:${value.error || 'unknown'}`,
525
+ }],
526
+ },
527
+ async execute(args) {
528
+ try {
529
+ const project = String(args.project || '').trim().replace(/[\\/:*?"<>|]/g, '_');
530
+ if (!project) return { ok: false, error: 'project 不能为空' };
531
+ // 组装待写映射
532
+ const want = {};
533
+ const skipped = [];
534
+ // P12-①(2026-11-02):原来只认 `t<数字>`,但星阶等项目用的是**语义 taskId**
535
+ // (status / stelscala-pm / week-plan-daily-exclude…),导致整批被 skipped
536
+ // → 「没有可写条目」。现改为:`_none`/`meeting` 直通 ∪ tasks.json 里的真实 id ∪ slug 兜底。
537
+ let knownTaskIds = new Set();
538
+ try {
539
+ const tasksFile = join(hostRoot(hub), '.hub', 'projects', project, 'tasks.json');
540
+ const tasksRaw = readJsonForWrite(tasksFile);
541
+ if (tasksRaw.ok && tasksRaw.value && Array.isArray(tasksRaw.value.tasks)) {
542
+ knownTaskIds = new Set(tasksRaw.value.tasks
543
+ .map((t) => (t && (t.id || t.taskId)) ? String(t.id || t.taskId) : '')
544
+ .filter(Boolean));
545
+ }
546
+ } catch { /* 读不到 tasks.json 就只靠 slug 兜底 */ }
547
+ const validLane = (v) => {
548
+ const lane = String(v || '');
549
+ if (lane === '_none' || lane === 'meeting') return true;
550
+ if (knownTaskIds.has(lane)) return true;
551
+ // slug 兜底:小写字母/数字开头,允许字母数字 _ - .(防手滑写成员名/中文进去)
552
+ return /^[a-z0-9][a-z0-9_.-]{0,63}$/i.test(lane);
553
+ };
554
+ if (args.entries && typeof args.entries === 'object' && !Array.isArray(args.entries)) {
555
+ for (const [id, lane] of Object.entries(args.entries)) {
556
+ if (!id || !validLane(lane)) { skipped.push(`${id}:${lane}`); continue; }
557
+ want[String(id)] = String(lane);
558
+ }
559
+ }
560
+ if (Array.isArray(args.ids) && args.ids.length) {
561
+ const lane = String(args.lane || '');
562
+ if (!validLane(lane)) return { ok: false, error: `lane 非法:${lane}(须 _none/meeting/t<数字>)` };
563
+ for (const id of args.ids) { if (id) want[String(id)] = lane; }
564
+ }
565
+ if (Object.keys(want).length === 0) {
566
+ const known = [...knownTaskIds].slice(0, 12).join(' / ') || '(tasks.json 无可用 id)';
567
+ return {
568
+ ok: false,
569
+ error: '没有可写条目。用法:entries={"<eventId>":"<taskId>"} 或 ids=[...]+lane="<taskId>";'
570
+ + `taskId 可为 _none / meeting / 本项目 tasks.json 里的 id(如 ${known})。`
571
+ + (skipped.length ? ` 被跳过的项:${skipped.slice(0, 5).join('、')}` : ''),
572
+ skipped,
573
+ };
574
+ }
575
+ const file = join(hostRoot(hub), '.hub', 'projects', project, 'routing.json');
576
+ // 写前自愈对账(2026-10-26 同款),再走「读→合并→原子写」,绝不整文件覆盖
577
+ guardRoutingFile(project, hostRoot(hub));
578
+ const read = readJsonForWrite(file);
579
+ if (!read.ok) return { ok: false, error: `routing.json 读取失败:${String(read.error)}` };
580
+ const routing = read.value || {};
581
+ let written = 0;
582
+ for (const [id, lane] of Object.entries(want)) {
583
+ if (routing[id]) { skipped.push(`${id}(已有)`); continue; }
584
+ routing[id] = lane;
585
+ written += 1;
586
+ }
587
+ if (written > 0) writeJsonAtomic(file, routing);
588
+ return { ok: true, written, skipped, total: Object.keys(routing).length };
589
+ } catch (error) {
590
+ return { ok: false, error: String((error && error.message) || error) };
591
+ }
592
+ },
593
+ }));
594
+
595
+ // —— taskline_messages ——
596
+ disposers.push(register({
597
+ name: 'taskline_messages',
598
+ description: '读取某个会话最近的消息,带 eventId(用于把用户消息归到具体任务线)。'
599
+ + '默认每条截 400 字;full=true 时截 4000 字(超长内容请让对方写文件给路径,别靠本工具全文搬运)。',
600
+ parameters: {
601
+ type: 'object',
602
+ additionalProperties: false,
603
+ required: ['session'],
604
+ properties: {
605
+ session: { type: 'string', description: 'sessionId 或标题片段' },
606
+ limit: { type: 'number', description: '返回最近多少条,默认 20' },
607
+ full: { type: 'boolean', description: 'true=每条最多 4000 字(默认 400 字截断)' },
608
+ },
609
+ },
610
+ output: {
611
+ schema: {
612
+ type: 'object',
613
+ additionalProperties: false,
614
+ required: ['ok'],
615
+ properties: {
616
+ ok: { type: 'boolean' },
617
+ error: { type: 'string' },
618
+ // t38-A:三态外显(ok=有消息 / empty=会话真空 / unreadable=读不到)
619
+ state: { type: 'string' },
620
+ title: { type: 'string' },
621
+ diagnosis: { type: 'string' },
622
+ items: {
623
+ type: 'array',
624
+ items: {
625
+ type: 'object',
626
+ additionalProperties: false,
627
+ required: ['eventId', 'role', 'text'],
628
+ properties: {
629
+ eventId: { type: 'string' },
630
+ role: { type: 'string' },
631
+ time: { type: 'number' },
632
+ text: { type: 'string' },
633
+ },
634
+ },
635
+ },
636
+ },
637
+ },
638
+ render: (_args, value) => {
639
+ if (!value.ok) return [{ type: 'text', text: `读取失败:${value.error}` }];
640
+ if (!value.items || value.items.length === 0) {
641
+ // 三态可区分(t38-A,2026-11-10):空会话 ≠ 读不到。以前两者都显示
642
+ //「这个会话没有消息」,导致把「读不到」误当「没消息」,归线随之断链。
643
+ if (value.state === 'empty') {
644
+ return [{ type: 'text', text: `(会话存在但确实还没有消息:${value.diagnosis || value.title || ''})` }];
645
+ }
646
+ if (value.state === 'unreadable') {
647
+ return [{ type: 'text', text: `读取失败(读不到,不是空会话):${value.diagnosis || value.error || '未知原因'}` }];
648
+ }
649
+ return [{ type: 'text', text: '(这个会话没有消息)' }];
650
+ }
651
+ return [{
652
+ type: 'text',
653
+ text: value.items
654
+ .map((item) => `${item.eventId} ${item.role === 'user' ? '我' : 'agent'} ${item.text}`)
655
+ .join('\n'),
656
+ }];
657
+ },
658
+ },
659
+ async execute(args) {
660
+ let session = deps.resolveSession(args.session);
661
+ if (!session) {
662
+ // t38-A(2026-11-10):resolveSession 只查内存 sessions.list(),冷/僵尸会话会被判「找不到」。
663
+ // 若传的是完整 sessionId,就先当 id 直读持久日志(与 /sync/messages 同口径),
664
+ // 读得到就正常返回;读不到再明确回「读不到」而不是含糊的「找不到」。
665
+ const raw = String(args.session || '').trim();
666
+ if (/^session-[0-9a-f-]{16,}$/i.test(raw)) {
667
+ let probe = [];
668
+ try {
669
+ const read = await readSessionEvents(hub, raw);
670
+ probe = Array.isArray(read && read.events) ? read.events : [];
671
+ } catch { /* 下面按读不到处理 */ }
672
+ if (probe.length) {
673
+ session = { id: raw, title: raw, coldFallback: true, events: probe };
674
+ } else {
675
+ return {
676
+ ok: true,
677
+ items: [],
678
+ state: 'unreadable',
679
+ title: raw,
680
+ diagnosis: '该 sessionId 既不在内存会话列表,持久日志也读不到(壳可能已被换掉或日志已删);'
681
+ + '请用 taskline_list 取当前 sessionId。',
682
+ };
683
+ }
684
+ } else {
685
+ return { ok: false, error: '找不到这个会话' };
686
+ }
687
+ }
688
+ const limit = Number.isFinite(args.limit) && args.limit > 0 ? Math.floor(args.limit) : 20;
689
+ // P6(2026-10-27 夜场):默认截断 140→400 字;full=true 不截断(上限 4000 防爆)。
690
+ // 长回报的标准做法仍是「写临时文件 + 消息给路径」,full 只用于核对关键句。
691
+ const fullCap = 4000;
692
+ const clip = (t) => String(t)
693
+ .replace(/\s+/g, ' ')
694
+ .slice(0, args.full === true ? fullCap : 400);
695
+ // t38-A(2026-11-10 修「对部分会话恒返空/读不到」):
696
+ // 旧实现只看 session.events(活会话)→ controller.inspect 的 data.message(活格式)。
697
+ // 冷会话(如日常·Yat2 停着的线、PrimCell·Yat2)恰恰是:①内存里没有 events;
698
+ // ②持久日志把回复存成 assistant/chunk 的 block-end/text-chunks,正文不在 data.message。
699
+ // 于是两条都落空 → 恒返「没有消息」。现改为走 hub-core 的 readSessionEvents(活+冷都读)
700
+ // + compactSessionEvents(把 chunk 形态重建成 assistant/message 正文),
701
+ // 并对「会话存在但真空」与「读不到」分别给出可判定状态。
702
+ const sid = session.id || (session && session.sessionId) || '';
703
+ let events = [];
704
+ let readError = '';
705
+ try {
706
+ const read = await readSessionEvents(hub, sid);
707
+ events = Array.isArray(read && read.events) ? read.events : [];
708
+ } catch (error) {
709
+ readError = String((error && error.message) || error);
710
+ }
711
+ if (events.length) {
712
+ try {
713
+ // compactSessionEvents 返回 { events, lastSeq, streaming }(不是数组,2026-11-10 实测)
714
+ const compacted = compactSessionEvents(events);
715
+ const rebuilt = Array.isArray(compacted) ? compacted
716
+ : (compacted && Array.isArray(compacted.events) ? compacted.events : []);
717
+ if (rebuilt.length) events = rebuilt;
718
+ } catch (error) {
719
+ console.warn(`[hub-desktop-ui] compactSessionEvents failed: ${String(error)}`);
720
+ }
721
+ }
722
+ const items = [];
723
+ for (const event of events) {
724
+ if (!event || (event.type !== 'user/message' && event.type !== 'assistant/message')) continue;
725
+ const data = event.data || {};
726
+ const message = data.message || data;
727
+ if (message && message.role === 'user') {
728
+ const source = message.source || {};
729
+ if (source.kind && source.kind !== 'user') continue;
730
+ }
731
+ const parts = Array.isArray(message.content) ? message.content : [];
732
+ const text = clip(parts
733
+ .filter((part) => part && part.type === 'text' && typeof part.text === 'string')
734
+ .map((part) => part.text)
735
+ .join('\n')
736
+ .trim());
737
+ if (!text) continue;
738
+ items.push({
739
+ eventId: String(event.id || event.key || event.seq || ''),
740
+ role: message.role === 'user' ? 'user' : 'agent',
741
+ time: Number(event.time || 0),
742
+ text,
743
+ });
744
+ }
745
+ // 三态判定(t38-A):有消息 / 会话确实空 / 读不到。
746
+ if (items.length === 0) {
747
+ // 「读不到」判据:连一个事件都没拿到(不是拿到事件但都无正文)。
748
+ if (!events.length) {
749
+ return {
750
+ ok: true,
751
+ items: [],
752
+ state: 'unreadable',
753
+ title: (session && (session.title || session.name)) || '',
754
+ diagnosis: `会话在注册表可见,但事件池为空${readError ? `(读取异常:${readError})` : ''}`
755
+ + ';可能是壳已换(旧 sessionId 残留)或持久日志未落盘。'
756
+ + '请用 taskline_list 取当前 sessionId 再试。',
757
+ };
758
+ }
759
+ return {
760
+ ok: true,
761
+ items: [],
762
+ state: 'empty',
763
+ title: (session && (session.title || session.name)) || '',
764
+ diagnosis: `读到 ${events.length} 个事件但无可展示正文`,
765
+ };
766
+ }
767
+ return { ok: true, items: items.slice(-limit), state: 'ok' };
768
+ },
769
+ }));
770
+
771
+ // —— taskline_archive ——
772
+ disposers.push(register({
773
+ name: 'taskline_archive',
774
+ description: '归档(或恢复)任务线:归档后从侧栏移除、群聊里进「已归档」,可恢复。'
775
+ + '**只用于一次性任务线 / 带「|」分身**;常驻成员线(tasks.json 登记的正式成员)归档会被拒绝,'
776
+ + '确需换壳回收旧壳时显式传 force: true。',
777
+ parameters: {
778
+ type: 'object',
779
+ additionalProperties: false,
780
+ required: ['session'],
781
+ properties: {
782
+ session: { type: 'string', description: 'sessionId 或标题' },
783
+ restore: { type: 'boolean', description: 'true = 取消归档' },
784
+ force: { type: 'boolean', description: 'true = 强制归档常驻成员线(仅限换壳回收旧壳等明确场景)' },
785
+ },
786
+ },
787
+ output: {
788
+ schema: {
789
+ type: 'object',
790
+ additionalProperties: false,
791
+ required: ['ok'],
792
+ properties: {
793
+ ok: { type: 'boolean' },
794
+ cleaned: { type: 'object' },
795
+ refused: { type: 'boolean' },
796
+ handoffPath: { type: 'string' },
797
+ handoffMessages: { type: 'number' },
798
+ error: { type: 'string' },
799
+ },
800
+ },
801
+ render: (_args, value) => [{
802
+ type: 'text',
803
+ text: !value.ok
804
+ ? `归档失败:${value.error}`
805
+ : `已完成归档操作${value.cleaned && value.cleaned.removedAvatar ? ';已清理该分身名的头像条目' : ''}`
806
+ + (value.cleaned && value.cleaned.removedFromMembers && value.cleaned.removedFromMembers.length
807
+ ? `;已从归档任务 ${value.cleaned.removedFromMembers.join(',')} 的 members 移除` : '')
808
+ + (value.handoffPath
809
+ ? `;交接档案已生成(${value.handoffMessages} 条 assistant 输出):${value.handoffPath}`
810
+ : ''),
811
+ }],
812
+ },
813
+ async execute(args, exec) {
814
+ const registry = hub.get('workspaceRegistry');
815
+ const live = deps.resolveSession(args.session);
816
+ let sessionId = live && live.id;
817
+ let targetTitle = deps.titleOfSession(live) || '';
818
+ if (!sessionId) {
819
+ // 冷会话(重启后未激活)不在 sessions.list() 里:
820
+ // 用 sessionController.list() 兜底——id 精确匹配优先(同名新旧会话并存时按 id 无歧义)
821
+ try {
822
+ const controller = hub.get('sessionController');
823
+ const rows = controller && typeof controller.list === 'function'
824
+ ? await controller.list(new AbortController().signal) : [];
825
+ const arr = Array.isArray(rows) ? rows : (rows && Array.isArray(rows.items) ? rows.items : []);
826
+ const ref = String(args.session || '');
827
+ const byId = arr.find((r) => r && String(r.sessionId) === ref);
828
+ const byTitle = byId ? undefined : arr.find((r) => {
829
+ const t = r && r.projections && r.projections.values ? r.projections.values.title : '';
830
+ return typeof t === 'string' && t === ref;
831
+ });
832
+ const row = byId || byTitle;
833
+ if (row) {
834
+ sessionId = String(row.sessionId);
835
+ const pt = row && row.projections && row.projections.values ? row.projections.values.title : '';
836
+ if (!targetTitle && typeof pt === 'string') targetTitle = pt;
837
+ }
838
+ } catch (error) { /* 冷会话兜底失败 → 走原报错 */ }
839
+ }
840
+ if (!sessionId) return { ok: false, error: '找不到这个会话' };
841
+ if (!registry) return { ok: false, error: 'workspaceRegistry 不可用' };
842
+ // 2026-10-29 用户令「成员可自建分身并回收」——成员侧护栏:成员只能归档/恢复
843
+ // 「<本项目> · <本人父名>|…」的分身;他人线 / 本体 / 常驻线一律拒(仍走管理者)。
844
+ // 管理者(Yat2/Hermes)与用户会话不受此限,原有常驻护栏继续生效。
845
+ {
846
+ const callerAgent = exec && exec.agent;
847
+ const callerTitle = callerAgent ? deps.callerTitleOf(callerAgent) : '';
848
+ if (callerTitle.includes(' · ') && !/ · (Yat2|Hermes)$/.test(callerTitle)) {
849
+ const callerProject = projectOfSessionTitle(callerTitle);
850
+ const callerRoleRaw = callerTitle.slice(callerTitle.lastIndexOf(' · ') + 3).trim();
851
+ const baseRole = callerRoleRaw.includes('|')
852
+ ? callerRoleRaw.slice(0, callerRoleRaw.indexOf('|')).trim() : callerRoleRaw;
853
+ const targetProject = projectOfSessionTitle(targetTitle);
854
+ const targetMember = agentNameOfTitle(targetTitle);
855
+ const isOwnClone = targetMember.includes('|')
856
+ && targetMember.slice(0, targetMember.indexOf('|')).trim() === baseRole
857
+ && targetProject === callerProject;
858
+ if (!isOwnClone) {
859
+ return {
860
+ ok: false,
861
+ error: `成员只能回收自己的分身(形如「${callerProject} · ${baseRole}|…」);`
862
+ + `「${targetTitle || sessionId}」不是你的分身。他人线/本体/常驻线请走管理者。`,
863
+ };
864
+ }
865
+ }
866
+ }
867
+ // 常驻成员护栏(2026-10-24「雨课堂被 Yat2 误归档」):tasks.json 登记的正式成员线
868
+ // (无「|」)默认拒绝归档——管理者只能归档一次性线/分身;确需换壳回收旧壳传 force。
869
+ if (!args.restore && args.force !== true) {
870
+ try {
871
+ const title = deps.titleOfSession(live) || '';
872
+ const registeredIds = readRegisteredSessionIds(hostRoot(hub));
873
+ if (registeredIds.has(String(sessionId)) && !title.includes('|')) {
874
+ return {
875
+ ok: false,
876
+ refused: true,
877
+ error: `「${title || sessionId}」是 tasks.json 登记的常驻成员线,不做常规归档`
878
+ + '(做完回收只适用于一次性任务线 / 带「|」的分身)。'
879
+ + '若确实要换壳回收旧壳,重试并显式传 force: true。',
880
+ };
881
+ }
882
+ } catch (error) { /* 护栏判定失败不挡归档 */ }
883
+ }
884
+ try {
885
+ if (args.restore) await registry.unarchiveSession(sessionId);
886
+ else {
887
+ await registry.archiveSession(sessionId, { stopActivity: true });
888
+ // 归档动作顺手触发清理(2026-10-24 用户令:不用定时器,超限删最旧)
889
+ await pruneArchivedSessions(hub).catch(() => {});
890
+ }
891
+ } catch (error) {
892
+ return { ok: false, error: String(error && error.message) };
893
+ }
894
+ // 归档分身时同步清理注册表(2026-10-22 用户要求):
895
+ // 历次「成员|具体任务」的分身名沉淀在 avatars 里,侧栏看起来像一堆细名本体。
896
+ let cleaned = null;
897
+ let handoff = null;
898
+ if (!args.restore) {
899
+ try {
900
+ const title = deps.titleOfSession(live) || '';
901
+ const project = projectOfSessionTitle(title);
902
+ const memberName = agentNameOfTitle(title);
903
+ if (project && memberName.includes('|')) {
904
+ cleaned = cleanCloneRegistry(project, memberName, hostRoot(hub));
905
+ // 交接档案(2026-10-23):分身归档后信息不能只埋在归档会话里,
906
+ // 机械抽取它的全部 assistant 输出落盘,供管理者/父 agent 事后 grep。
907
+ try {
908
+ // 2026-11-09 修「归档时 assistant 消息数=0,但答案其实已产出」:
909
+ // 实测 session-51e9dedd(「讲解已启用插件各自作用」)21:12:16 归档读到 0 条,
910
+ // 而它的 session.v4.jsonl.zstd 在 21:12:18 才落盘(263KB,内容齐全)——
911
+ // 归档动作早于持久化/内存 flush,于是交接档案空白。
912
+ // 修法:先读一次,若 0 条 assistant 就等 1.2s 重读(最多 2 次),
913
+ // 避免"回收早于产出"把分身成果丢掉。
914
+ let session = live || (hub.get('sessions') || {}).get?.(sessionId);
915
+ let events = await collectEvents(hub, session, sessionId);
916
+ const countAssistant = (list) => (Array.isArray(list) ? list : [])
917
+ .filter((e) => e && e.type === 'assistant/message').length;
918
+ for (let attempt = 0; attempt < 2 && countAssistant(events) === 0; attempt += 1) {
919
+ await new Promise((r) => setTimeout(r, 1200));
920
+ session = live || (hub.get('sessions') || {}).get?.(sessionId);
921
+ events = await collectEvents(hub, session, sessionId);
922
+ console.warn(`[hub-desktop-ui] clone handoff empty, retried (attempt ${attempt + 1}) session=${sessionId}`);
923
+ }
924
+ handoff = writeCloneHandoff(project, memberName, sessionId, title, events, hostRoot(hub));
925
+ if (!handoff.ok) {
926
+ console.warn(`[hub-desktop-ui] clone handoff failed: ${String(handoff.error)}`);
927
+ }
928
+ } catch (error) {
929
+ handoff = { ok: false, error: String((error && error.message) || error) };
930
+ console.warn(`[hub-desktop-ui] clone handoff threw: ${String(error)}`);
931
+ }
932
+ }
933
+ } catch (error) {
934
+ cleaned = { ok: false, error: String((error && error.message) || error) };
935
+ }
936
+ }
937
+ // 只在真的清理过(或明确失败)时才带 cleaned 字段——undefined 会让输出校验失败
938
+ const result = { ok: true };
939
+ if (cleaned && typeof cleaned === 'object') {
940
+ result.cleaned = {
941
+ removedAvatar: Boolean(cleaned.removedAvatar),
942
+ removedFromMembers: Array.isArray(cleaned.removedFromMembers) ? cleaned.removedFromMembers : [],
943
+ ...(cleaned.ok === false ? { error: String(cleaned.error || 'cleanup failed') } : {}),
944
+ };
945
+ }
946
+ if (handoff && handoff.ok && handoff.path) {
947
+ result.handoffPath = String(handoff.path);
948
+ result.handoffMessages = Number(handoff.assistantMessages) || 0;
949
+ }
950
+ return result;
951
+ },
952
+ }));
953
+
954
+ // —— taskline_rename ——
955
+ disposers.push(register({
956
+ name: 'taskline_rename',
957
+ description: '给任务线(会话)改标题,标题会直接显示在侧栏和群聊卡片上。',
958
+ parameters: {
959
+ type: 'object',
960
+ additionalProperties: false,
961
+ required: ['session', 'title'],
962
+ properties: {
963
+ session: { type: 'string', description: 'sessionId 或当前标题' },
964
+ title: { type: 'string', description: '新标题,建议「<项目> · <成员>」' },
965
+ },
966
+ },
967
+ output: {
968
+ schema: {
969
+ type: 'object',
970
+ additionalProperties: false,
971
+ required: ['ok'],
972
+ properties: {
973
+ ok: { type: 'boolean' },
974
+ title: { type: 'string' },
975
+ error: { type: 'string' },
976
+ },
977
+ },
978
+ render: (_args, value) => [{ type: 'text', text: value.ok ? `已改名:${value.title}` : `改名失败:${value.error}` }],
979
+ },
980
+ async execute(args) {
981
+ const controller = hub.get('sessionController');
982
+ let session = deps.resolveSession(args.session);
983
+ // 冷会话回退(2026-10-27 修「找不到这个会话」):deps.resolveSession 只查 sessions.list()
984
+ // (live 会话),冷/重启后会话不在其中。这里照 taskline_prompt 的做法查
985
+ // controller.list() 全量底册——精确 id 优先,其次精确标题(同对多个标题不猜,报歧义)。
986
+ if (!session && controller && typeof controller.list === 'function') {
987
+ try {
988
+ const value = await controller.list(new AbortController().signal);
989
+ const rows = value && Array.isArray(value.items) ? value.items : (Array.isArray(value) ? value : []);
990
+ const registry = hub.get('workspaceRegistry');
991
+ const archivedSet = new Set(
992
+ registry && Array.isArray(registry.archivedSessionIds)
993
+ ? registry.archivedSessionIds.map(String) : [],
994
+ );
995
+ const ref = String(args.session || '').trim();
996
+ const active = rows.filter((row) => row && row.sessionId
997
+ && !archivedSet.has(String(row.sessionId)));
998
+ const titleOf = (row) => (row.projections && row.projections.values
999
+ && typeof row.projections.values.title === 'string'
1000
+ ? row.projections.values.title : '');
1001
+ const byId = active.find((row) => String(row.sessionId) === ref);
1002
+ if (byId) {
1003
+ session = { id: String(byId.sessionId), displayTitle: titleOf(byId) };
1004
+ } else {
1005
+ const byTitle = active.filter((row) => titleOf(row) === ref);
1006
+ if (byTitle.length === 1) {
1007
+ session = { id: String(byTitle[0].sessionId), displayTitle: titleOf(byTitle[0]) };
1008
+ } else if (byTitle.length > 1) {
1009
+ return { ok: false, error: `同名多个会话:${byTitle.map((r) => `${titleOf(r)} ${r.sessionId}`).join(';')}(请传完整 sessionId)` };
1010
+ }
1011
+ }
1012
+ } catch { /* 回退失败走原错误 */ }
1013
+ }
1014
+ if (!session) return { ok: false, error: '找不到这个会话(冷会话请传完整 sessionId,用 taskline_list 查)' };
1015
+ try {
1016
+ await controller.rename({ sessionId: session.id, title: args.title });
1017
+ // 改完立刻对账:若与新标题撞名的另一条还在活跃集合里,把非官方那条改「(旧壳 MM-DD)」
1018
+ dedupeActiveTitles(hub).catch(() => {});
1019
+ return { ok: true, title: args.title };
1020
+ } catch (error) {
1021
+ return { ok: false, error: String(error && error.message) };
1022
+ }
1023
+ },
1024
+ }));
1025
+
1026
+ // —— resume_session ——
1027
+ disposers.push(register({
1028
+ name: 'resume_session',
1029
+ description: '让指定会话从断点续跑(发一条【续跑】提示,绕过 24h 自动续跑账本)。用于重启后没被自动续跑、或需要强制推进的会话。',
1030
+ parameters: {
1031
+ type: 'object',
1032
+ additionalProperties: false,
1033
+ required: ['session'],
1034
+ properties: {
1035
+ session: { type: 'string', description: '目标会话的 sessionId 或标题(可部分匹配)' },
1036
+ },
1037
+ },
1038
+ output: {
1039
+ schema: {
1040
+ type: 'object',
1041
+ additionalProperties: false,
1042
+ required: ['ok'],
1043
+ properties: { ok: { type: 'boolean' }, resumed: { type: 'array', items: { type: 'string' } }, error: { type: 'string' } },
1044
+ },
1045
+ render: (_args, value) => [{
1046
+ type: 'text',
1047
+ text: value.ok
1048
+ ? `已续跑 ${value.resumed.length} 个会话:${value.resumed.join('、') || '(无匹配)'}`
1049
+ : `续跑失败:${value.error || 'unknown'}`,
1050
+ }],
1051
+ },
1052
+ async execute(args) {
1053
+ try {
1054
+ const result = await resumeInterruptedSessions(hub, { force: true, only: String(args.session || ''), max: 1 });
1055
+ return { ok: true, resumed: result.resumed };
1056
+ } catch (error) {
1057
+ return { ok: false, error: String(error && error.message) };
1058
+ }
1059
+ },
1060
+ }));
1061
+
1062
+ // —— taskline_goal_status ——
1063
+ disposers.push(register({
1064
+ name: 'taskline_goal_status',
1065
+ description: '查某个会话当前的 goal(标题 / 状态 / 轮次)。派活带 goal 后用它核验是否真的建进会话。',
1066
+ parameters: {
1067
+ type: 'object',
1068
+ additionalProperties: false,
1069
+ required: ['session'],
1070
+ properties: {
1071
+ session: { type: 'string', description: '目标会话的 sessionId 或标题(可部分匹配)' },
1072
+ },
1073
+ },
1074
+ output: {
1075
+ schema: {
1076
+ type: 'object',
1077
+ additionalProperties: false,
1078
+ required: ['ok'],
1079
+ properties: {
1080
+ ok: { type: 'boolean' },
1081
+ sessionId: { type: 'string' },
1082
+ title: { type: 'string' },
1083
+ hasGoal: { type: 'boolean' },
1084
+ objective: { type: 'string' },
1085
+ phase: { type: 'string' },
1086
+ roundsStarted: { type: 'number' },
1087
+ maxGoalRounds: { type: 'number' },
1088
+ goalId: { type: 'string' },
1089
+ error: { type: 'string' },
1090
+ },
1091
+ },
1092
+ render: (_args, value) => [{
1093
+ type: 'text',
1094
+ text: !value.ok
1095
+ ? `查询失败:${value.error || 'unknown'}`
1096
+ : value.hasGoal
1097
+ ? `「${value.title || value.sessionId}」goal=[${value.phase}] ${value.objective}(轮次 ${value.roundsStarted}/${value.maxGoalRounds})`
1098
+ : `「${value.title || value.sessionId}」当前没有 goal`,
1099
+ }],
1100
+ },
1101
+ async execute(args) {
1102
+ const ref = String(args.session || '').trim();
1103
+ const isExactId = /^session-[0-9a-f-]{20,}$/i.test(ref);
1104
+ let session = isExactId ? (hub.get('sessions') && hub.get('sessions').get(ref)) : deps.resolveSession(ref);
1105
+ if (!session && isExactId) session = { id: ref };
1106
+ if (!session) return { ok: false, error: '找不到这个会话' };
1107
+ const goalsSvc = hub.get('goals') || (typeof ctx.get === 'function' ? ctx.get('goals') : undefined);
1108
+ const agentsSvc = hub.get('agents');
1109
+ const agent = agentsSvc && typeof agentsSvc.get === 'function' ? agentsSvc.get(String(session.id)) : undefined;
1110
+ if (!goalsSvc || typeof goalsSvc.get !== 'function') return { ok: false, error: 'goal 服务(ctx.goals)不可用' };
1111
+ if (!agent || !agent.session) return { ok: false, sessionId: String(session.id), title: deps.titleOfSession(session), hasGoal: false, error: 'agent 未激活(冷会话需先唤醒)' };
1112
+ try {
1113
+ const view = goalsSvc.get(agent);
1114
+ const goal = view && (view.goal || view);
1115
+ if (!goal || !goal.id) {
1116
+ return { ok: true, sessionId: String(session.id), title: deps.titleOfSession(session), hasGoal: false, error: '' };
1117
+ }
1118
+ return {
1119
+ ok: true,
1120
+ sessionId: String(session.id),
1121
+ title: deps.titleOfSession(session),
1122
+ hasGoal: true,
1123
+ goalId: String(goal.id || ''),
1124
+ objective: String(goal.objective || ''),
1125
+ phase: String(goal.phase || ''),
1126
+ roundsStarted: Number(goal.roundsStarted) || 0,
1127
+ maxGoalRounds: Number(goal.maxGoalRounds) || 0,
1128
+ error: '',
1129
+ };
1130
+ } catch (error) {
1131
+ return { ok: false, sessionId: String(session.id), error: String((error && error.message) || error) };
1132
+ }
1133
+ },
1134
+ }));
1135
+
1136
+ // —— taskline_wait ——
1137
+ disposers.push(register({
1138
+ name: 'taskline_wait',
1139
+ description: '例外工具(默认派完即转下一任务,不等成员):仅当用户要当场闭环结论时才用。'
1140
+ + '等成员状态变化(不轮询):运行中→空闲或有新消息就返回;没有目标在跑立刻 noProgress。timeoutMs 建议 ≤60000。',
1141
+ parameters: {
1142
+ type: 'object',
1143
+ additionalProperties: false,
1144
+ required: [],
1145
+ properties: {
1146
+ session: { type: 'string', description: '单个目标:sessionId 或标题(可部分匹配)' },
1147
+ sessionIds: {
1148
+ type: 'array',
1149
+ items: { type: 'string' },
1150
+ description: '多个目标:sessionId 或标题片段。session 与 sessionIds 都不给 = 等任意成员(除调用者自己)',
1151
+ },
1152
+ timeoutMs: { type: 'number', description: '最长等待毫秒,默认 120000,上限 600000' },
1153
+ },
1154
+ },
1155
+ output: {
1156
+ schema: {
1157
+ type: 'object',
1158
+ additionalProperties: false,
1159
+ required: ['status', 'waitedMs'],
1160
+ properties: {
1161
+ status: { type: 'string', enum: ['changed', 'noProgress', 'timeout'] },
1162
+ waitedMs: { type: 'number' },
1163
+ changed: {
1164
+ type: 'array',
1165
+ items: {
1166
+ type: 'object',
1167
+ additionalProperties: false,
1168
+ required: ['sessionId', 'title', 'idle'],
1169
+ properties: {
1170
+ sessionId: { type: 'string' },
1171
+ title: { type: 'string' },
1172
+ idle: { type: 'boolean' },
1173
+ },
1174
+ },
1175
+ },
1176
+ error: { type: 'string' },
1177
+ },
1178
+ },
1179
+ render: (_args, value) => {
1180
+ if (value.status === 'changed') {
1181
+ const list = (value.changed || [])
1182
+ .map((item) => `${item.title || item.sessionId}${item.idle ? ' [已空闲]' : ' [有新消息]'}`)
1183
+ .join('、');
1184
+ return [{ type: 'text', text: `成员状态有变化(等了 ${value.waitedMs}ms):${list || '(无)'}` }];
1185
+ }
1186
+ if (value.status === 'noProgress') {
1187
+ return [{ type: 'text', text: `没有目标在运行,立刻返回 noProgress(${value.waitedMs}ms)${value.error ? `\n${value.error}` : ''}` }];
1188
+ }
1189
+ return [{ type: 'text', text: `等待超时(${value.waitedMs}ms):目标仍无变化${value.error ? `\n${value.error}` : ''}` }];
1190
+ },
1191
+ },
1192
+ async execute(args, exec) {
1193
+ const startedAt = Date.now();
1194
+ const sessionsSvc = hub.get('sessions');
1195
+ const liveList = sessionsSvc && typeof sessionsSvc.list === 'function' ? sessionsSvc.list() : [];
1196
+ const callerId = exec && exec.agent && exec.agent.id !== undefined ? String(exec.agent.id) : '';
1197
+ const registry = hub.get('workspaceRegistry');
1198
+ const archivedSet = new Set(
1199
+ registry && Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds.map(String) : [],
1200
+ );
1201
+ const titleOfId = (id) => {
1202
+ const live = liveList.find((session) => String(session.id) === String(id));
1203
+ return live ? deps.titleOfSession(live) : '';
1204
+ };
1205
+ // 解析目标:显式 ref 优先,都不给 = 任意成员(排除调用者自己——它正在跑本次调用)
1206
+ const refs = [];
1207
+ if (args.session) refs.push(String(args.session).trim());
1208
+ if (Array.isArray(args.sessionIds)) {
1209
+ for (const ref of args.sessionIds) {
1210
+ const text = String(ref || '').trim();
1211
+ if (text) refs.push(text);
1212
+ }
1213
+ }
1214
+ const targetIds = new Set();
1215
+ const unknown = [];
1216
+ if (refs.length === 0) {
1217
+ for (const session of liveList) {
1218
+ const id = String(session.id);
1219
+ if (id === callerId || archivedSet.has(id)) continue;
1220
+ targetIds.add(id);
1221
+ }
1222
+ } else {
1223
+ for (const ref of refs) {
1224
+ if (/^session-[0-9a-f-]{20,}$/i.test(ref)) {
1225
+ targetIds.add(ref);
1226
+ continue;
1227
+ }
1228
+ const hits = liveList.filter((session) => {
1229
+ const title = deps.titleOfSession(session);
1230
+ return title === ref || title.includes(ref);
1231
+ });
1232
+ if (hits.length === 0) unknown.push(ref);
1233
+ else for (const session of hits) targetIds.add(String(session.id));
1234
+ }
1235
+ }
1236
+ const unknownNote = unknown.length ? `找不到会话:${unknown.join(' / ')}` : '';
1237
+ if (targetIds.size === 0) {
1238
+ return { status: 'noProgress', waitedMs: Date.now() - startedAt, ...(unknownNote ? { error: unknownNote } : {}) };
1239
+ }
1240
+ // 没有目标在跑 → 不等待,立刻返回(验收 a)
1241
+ const runningIds = [...targetIds].filter((id) => deps.isRunningNow(id));
1242
+ if (runningIds.length === 0) {
1243
+ return { status: 'noProgress', waitedMs: Date.now() - startedAt, ...(unknownNote ? { error: unknownNote } : {}) };
1244
+ }
1245
+ const requested = Number(args.timeoutMs);
1246
+ const timeoutMs = Number.isFinite(requested) && requested > 0
1247
+ ? Math.min(Math.max(Math.floor(requested), 1000), 600000)
1248
+ : 120000;
1249
+
1250
+ const outcome = await new Promise((finishWait) => {
1251
+ let settled = false;
1252
+ let offStatus;
1253
+ let offSession;
1254
+ let timer;
1255
+ const idleChanged = () => [...targetIds]
1256
+ .filter((id) => !deps.isRunningNow(id))
1257
+ .map((id) => ({ sessionId: id, title: titleOfId(id), idle: true }));
1258
+ const cleanup = () => {
1259
+ if (timer) clearTimeout(timer);
1260
+ for (const off of [offStatus, offSession]) {
1261
+ if (typeof off === 'function') {
1262
+ try { off(); } catch (error) { /* ignore */ }
1263
+ }
1264
+ }
1265
+ };
1266
+ const settle = (value) => {
1267
+ if (settled) return;
1268
+ settled = true;
1269
+ cleanup();
1270
+ finishWait({ ...value, waitedMs: Date.now() - startedAt });
1271
+ };
1272
+ timer = setTimeout(() => settle({ status: 'timeout' }), timeoutMs);
1273
+ if (timer && typeof timer.unref === 'function') timer.unref();
1274
+ // 1) 官方运行状态事件:目标 running → false 即返回
1275
+ offStatus = hub.on('api-session/status', (changedId, running) => {
1276
+ try {
1277
+ const key = String(changedId);
1278
+ if (!targetIds.has(key) || running) return;
1279
+ settle({ status: 'changed', changed: idleChanged() });
1280
+ } catch (error) { /* ignore */ }
1281
+ });
1282
+ // 2) 会话事件:回合结束(且确实空闲)或新用户消息也算「有变化」
1283
+ offSession = hub.on('session/event', (session, event) => {
1284
+ try {
1285
+ const key = session && session.id !== undefined ? String(session.id) : '';
1286
+ if (!targetIds.has(key) || !event) return;
1287
+ const type = String(event.type || '');
1288
+ const sourceKind = event.data && event.data.source ? event.data.source.kind : undefined;
1289
+ const freshUserMessage = type === 'user/message'
1290
+ && (!sourceKind || sourceKind === 'user');
1291
+ if (type !== 'turn/end' && !freshUserMessage) return;
1292
+ const idle = idleChanged();
1293
+ if (idle.length > 0) {
1294
+ settle({ status: 'changed', changed: idle });
1295
+ return;
1296
+ }
1297
+ if (freshUserMessage) {
1298
+ settle({ status: 'changed', changed: [{ sessionId: key, title: titleOfId(key), idle: false }] });
1299
+ }
1300
+ } catch (error) { /* ignore */ }
1301
+ });
1302
+ // 3) 调用方取消(用户点停止):不悬挂,清理监听后按取消返回
1303
+ const signal = exec && exec.signal;
1304
+ if (signal && typeof signal.addEventListener === 'function') {
1305
+ if (signal.aborted) {
1306
+ settle({ status: 'timeout', error: 'aborted' });
1307
+ return;
1308
+ }
1309
+ signal.addEventListener('abort', () => settle({ status: 'timeout', error: 'aborted' }), { once: true });
1310
+ }
1311
+ // 4) 竞态兜底:目标可能在「检查 running」与「挂监听」之间就已经变 idle,
1312
+ // 挂完监听再读一次真实状态,别白等到超时。
1313
+ const alreadyIdle = idleChanged();
1314
+ if (alreadyIdle.length > 0) settle({ status: 'changed', changed: alreadyIdle });
1315
+ });
1316
+ return unknownNote ? { ...outcome, error: outcome.error || unknownNote } : outcome;
1317
+ },
1318
+ }));
1319
+
1320
+ // —— taskline_create ——
1321
+ disposers.push(register({
1322
+ name: 'taskline_create',
1323
+ description: '新建一条任务线:创建一个新会话并命名(可选立刻给它派第一条任务)。\n'
1324
+ + '**任务书里必须带「用户可见验收」段**(§20,2026-11-10 用户令):'
1325
+ + '写清用户能**直接看/点/复制**的验收物——截图、可打开的文件或 URL、一段可粘贴的输出——'
1326
+ + '而不是「测试通过 / 已自测」这类自证。模板:\n'
1327
+ + '【验收(用户可见)】①<可打开的产物路径或 URL>;②<用户一条命令能复现的输出>;'
1328
+ + '③<若涉及重启,写清重启后一步核验的方法>。\n'
1329
+ + '**不写自证式验收("单测通过""逻辑正确")**:验收物必须由用户侧可见,审核者据此判定完成。',
1330
+ parameters: {
1331
+ type: 'object',
1332
+ additionalProperties: false,
1333
+ required: ['title'],
1334
+ properties: {
1335
+ title: { type: 'string', description: '新任务线标题,建议「<项目> · <成员>」' },
1336
+ text: {
1337
+ type: 'string',
1338
+ description: '可选的初始任务内容。**必须含「【验收(用户可见)】」段**:'
1339
+ + '列出用户可直接看/点/复制的验收物(产物路径或 URL、可复现命令与输出、重启后一步核验法),'
1340
+ + '禁止只写「测试通过」「已自测」等自证。',
1341
+ },
1342
+ preset: { type: 'string', description: '可选的 agent preset,默认 cordis(Creator mode)' },
1343
+ project: { type: 'string', description: '所属项目名,默认从 title 的「 · 」前解析' },
1344
+ avatar: { type: 'string', description: '头像 emoji,可不填(自动从候选池分配一个未被占用的)' },
1345
+ },
1346
+ },
1347
+ output: {
1348
+ schema: {
1349
+ type: 'object',
1350
+ additionalProperties: false,
1351
+ required: ['ok'],
1352
+ properties: {
1353
+ ok: { type: 'boolean' },
1354
+ sessionId: { type: 'string' },
1355
+ title: { type: 'string' },
1356
+ avatar: { type: 'string' },
1357
+ warning: { type: 'string' },
1358
+ error: { type: 'string' },
1359
+ },
1360
+ },
1361
+ render: (_args, value) => [{
1362
+ type: 'text',
1363
+ text: value.ok
1364
+ ? `已建任务线:${value.title}(${value.sessionId})${value.warning ? `\n⚠️ ${value.warning}` : ''}`
1365
+ : `新建失败:${value.error}`,
1366
+ }],
1367
+ },
1368
+ async execute(args, exec) {
1369
+ // 2026-10-29 用户令「每个 agent 多线任务时可自建分身并回收」——成员侧护栏:
1370
+ // 成员(标题「项目 · 角色」且非管理者)只能建「<本项目> · <本人父名>|<任务短语>」
1371
+ // 的分身;裸名本体 / 别人的分身 / 跨项目一律拒,仍走管理者。管理者/用户会话不受限。
1372
+ const callerAgent = exec && exec.agent;
1373
+ const callerTitle = callerAgent ? deps.callerTitleOf(callerAgent) : '';
1374
+ if (callerTitle.includes(' · ') && !/ · (Yat2|Hermes)$/.test(callerTitle)) {
1375
+ const callerProject = projectOfSessionTitle(callerTitle);
1376
+ const callerRoleRaw = callerTitle.slice(callerTitle.lastIndexOf(' · ') + 3).trim();
1377
+ // 分身再开子分身时按父名认亲(「DSH工程师|a」开的也必须是「DSH工程师|…」)
1378
+ const baseRole = callerRoleRaw.includes('|')
1379
+ ? callerRoleRaw.slice(0, callerRoleRaw.indexOf('|')).trim() : callerRoleRaw;
1380
+ const wantMember = args.title.includes(' · ')
1381
+ ? args.title.slice(args.title.lastIndexOf(' · ') + 3).trim() : args.title.trim();
1382
+ const wantProject = args.project || (args.title.includes(' · ')
1383
+ ? args.title.slice(0, args.title.indexOf(' · ')).trim() : callerProject);
1384
+ const wantParent = wantMember.includes('|')
1385
+ ? wantMember.slice(0, wantMember.indexOf('|')).trim() : '';
1386
+ if (!wantMember.includes('|') || wantParent !== baseRole || wantProject !== callerProject) {
1387
+ return {
1388
+ ok: false,
1389
+ error: `成员只能自建分身:标题形如「${callerProject} · ${baseRole}|<任务短语>」`
1390
+ + `(父名必须是本人、项目必须是本项目)。建本体 / 他人分身 / 跨项目仍需管理者。`,
1391
+ };
1392
+ }
1393
+ }
1394
+ const controller = hub.get('sessionController');
1395
+ if (!controller || typeof controller.create !== 'function') return { ok: false, error: 'sessionController 不可用' };
1396
+ try {
1397
+ // 命名校验前置(2026-10-23 用户令;2026-10-26 三改):必须发生在 controller.create **之前**,
1398
+ // 否则违规名被拒时会话已建出来,留孤儿。
1399
+ // 2026-10-26 用户新规则——校验分两路:
1400
+ // A. 带「|」→ **分身**路:形如「<父名>|<任务短语>」(如 DSH工程师|切换提速)。
1401
+ // 父名须是本项目已存在的成员;任务短语非空即过(2 字以上更佳但不强制);
1402
+ // 「|副手/|二号」不再特殊,也是合法分身名;**无数量上限,完成即归档回收**。
1403
+ // B. 不带「|」→ **本体**路:旧规则全保留——拒裸宽泛词(工程师/专家/管家…),
1404
+ // 任务型名字给警告不拒绝。
1405
+ const memberName = args.title.includes(' · ') ? args.title.slice(args.title.lastIndexOf(' · ') + 3).trim() : args.title.trim();
1406
+ const projectNameEarly = args.project || (args.title.includes(' · ') ? args.title.slice(0, args.title.indexOf(' · ')).trim() : '');
1407
+ const BARE_ROLE_WORDS = ['工程师', '专家', '管家', '助手', '成员', '分析师', '设计师', '研究员', 'agent', 'Agent'];
1408
+ let nameWarning = '';
1409
+ if (memberName.includes('|')) {
1410
+ const barAt = memberName.indexOf('|');
1411
+ const parentName = memberName.slice(0, barAt).trim();
1412
+ const taskPhrase = memberName.slice(barAt + 1).trim();
1413
+ if (!parentName) {
1414
+ return { ok: false, error: `「${memberName}」缺父名——分身名形如「<父名>|<任务短语>」(如 DSH工程师|切换提速)` };
1415
+ }
1416
+ if (!taskPhrase) {
1417
+ return { ok: false, error: `「${memberName}」缺任务短语——分身名形如「<父名>|<任务短语>」(如 DSH工程师|切换提速)` };
1418
+ }
1419
+ // 父名须是本项目已存在的成员(防拼错父名造出野分身)
1420
+ if (projectNameEarly) {
1421
+ const row = readTaskFile(projectNameEarly, hostRoot(hub));
1422
+ const raw = row && row.tasks && typeof row.tasks === 'object' && !Array.isArray(row.tasks) ? row.tasks : {};
1423
+ const tasksList = Array.isArray(raw.tasks) ? raw.tasks : [];
1424
+ const sessionsMap = raw.memberSessions && typeof raw.memberSessions === 'object' ? raw.memberSessions : {};
1425
+ const known = new Set(Object.keys(sessionsMap));
1426
+ for (const task of tasksList) {
1427
+ for (const name of (Array.isArray(task && task.members) ? task.members : [])) known.add(String(name));
1428
+ }
1429
+ if (known.size > 0 && !known.has(parentName)) {
1430
+ return {
1431
+ ok: false,
1432
+ error: `分身父名「${parentName}」不是项目「${projectNameEarly}」的已登记成员(候选:${[...known].slice(0, 8).join(' / ')})——先有本体成员,再挂分身`,
1433
+ };
1434
+ }
1435
+ // 2026-11-09 用户令(父名错第二次):分诊建线时父名必须是**被分诊线的成员**,
1436
+ // 不是分诊者自己。分诊者通常是管理者 Yat2 → 会建出「Yat2|<UI 开发任务>」,
1437
+ // 让管理者名下堆交付物线(违反「管理者不产出交付物」)。工具层直接拦。
1438
+ if (parentName === 'Yat2' && known.size > 1) {
1439
+ const executors = [...known].filter((x) => x !== 'Yat2');
1440
+ return {
1441
+ ok: false,
1442
+ error: `父名不能用管理者「Yat2」——这条线该由真实执行成员挂:${executors.slice(0, 6).join(' / ')}`
1443
+ + '(用户令 2026-11-09:分诊建的线父名必须是**被分诊线的成员**,不是分诊者自己;管理者不产出交付物)',
1444
+ };
1445
+ }
1446
+ }
1447
+ // 分身名 = 任务短语,天然任务导向,不再触发 looksTaskSpecificName 警告
1448
+ } else {
1449
+ if (BARE_ROLE_WORDS.includes(memberName)) {
1450
+ return {
1451
+ ok: false,
1452
+ error: `「${memberName}」是裸宽泛角色名,禁止使用——请带领域前缀(如 DSH工程师 / 视网膜专家 / 出题工程师)`,
1453
+ };
1454
+ }
1455
+ if (looksTaskSpecificName(memberName)) {
1456
+ nameWarning = `「${memberName}」像是具体任务名——常驻本体建议起正式角色名(职责导向);**按任务开线请用分身**:「<父名>|<任务短语>」(完成即归档回收)`;
1457
+ }
1458
+ }
1459
+ const created = await controller.create({ cwd: hostRoot(hub), agentPreset: args.preset || defaultPreset });
1460
+ const sessionId = created && created.sessionId;
1461
+ if (!sessionId) return { ok: false, error: '创建未返回 sessionId' };
1462
+ await controller.rename({ sessionId, title: args.title });
1463
+ const projectName = projectNameEarly;
1464
+ const assignedAvatar = projectName ? ensureMemberAvatar(projectName, memberName, hostRoot(hub), args.avatar) : '';
1465
+ // 2026-10-26:头像登记失败不再静默——告诉 Yat2 当场补(韩明明论文·论文写手
1466
+ // 漏头像就是写错根 + 静默失败叠加的结果,排查靠人眼)
1467
+ const avatarWarning = projectName && !assignedAvatar
1468
+ ? `头像未登记(${projectName}/tasks.json 或全局 avatars.json 写入失败)——建完后请检查并手工补 .hub/avatars.json`
1469
+ : '';
1470
+ if (args.text) {
1471
+ await controller.prompt({
1472
+ requestId: `hub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1473
+ sessionId,
1474
+ mode: 'queue',
1475
+ content: [{ type: 'text', text: args.text }],
1476
+ }, new AbortController().signal);
1477
+ }
1478
+ return {
1479
+ ok: true, sessionId, title: args.title,
1480
+ // 2026-10-29:lossless JSON 不认 undefined 值(头像池空时报 invalid output)——改为条件展开
1481
+ ...(assignedAvatar ? { avatar: assignedAvatar } : {}),
1482
+ ...(([nameWarning, avatarWarning].filter(Boolean).join(';')) ? { warning: [nameWarning, avatarWarning].filter(Boolean).join(';') } : {}),
1483
+ };
1484
+ } catch (error) {
1485
+ return { ok: false, error: String(error && error.message) };
1486
+ }
1487
+ },
1488
+ }));
1489
+
1490
+ // —— taskline_prompt ——
1491
+ disposers.push(register({
1492
+ name: 'taskline_prompt',
1493
+ description: '给指定的任务线(会话)派活:按 sessionId 或会话标题找到它,发一条消息。用于分派、催办、重启任务线。',
1494
+ parameters: {
1495
+ type: 'object',
1496
+ additionalProperties: false,
1497
+ required: ['session', 'text'],
1498
+ properties: {
1499
+ session: { type: 'string', description: '目标会话的 sessionId 或标题(可部分匹配)' },
1500
+ text: {
1501
+ type: 'string',
1502
+ description: '要发给它的话,写清任务与验收标准。'
1503
+ + '**验收必须是「用户可见验收」**(§20):给出用户能直接看/点/复制的验收物'
1504
+ + '(产物路径或 URL、可复现命令及输出、重启后一步核验法),'
1505
+ + '不要写「测试通过」「已自测」这类自证;模板见 taskline_create。',
1506
+ },
1507
+ goal: {
1508
+ type: 'string',
1509
+ description: '本线目标(一句话、交付物导向)。传了会写进 tasks.json 的 goal 并把目标化指令插到 prompt 最前面;不传保持原行为',
1510
+ },
1511
+ task: {
1512
+ type: 'string',
1513
+ description: '任务线 id(如 client-ui / quota-panel)。同一成员属于多条线时必须传,否则写 goal 会因歧义被拒绝并返回候选列表',
1514
+ },
1515
+ mode: {
1516
+ type: 'string',
1517
+ enum: ['triage', 'queue', 'steer', 'interrupt'],
1518
+ description: '投递方式,默认 triage(2026-11-08 用户令:排队不再干等)。'
1519
+ + 'triage = 目标在跑时**不排队**,立刻开一条「<成员>|queue分诊」分身:它读主线上下文后判定'
1520
+ + '「纠偏/补充/紧急 → steer 进主线当前轮」还是「独立新任务 → 分身自己做」,给出交接摘要后插件自动归档回收;'
1521
+ + '目标空闲时退化为 queue(直接起一轮,不开分身);'
1522
+ + 'queue = 老行为,真排队等它跑完当前轮(仅当你明确要「等它跑完」时用);'
1523
+ + 'steer = 目标在跑时在下一个 step 边界把消息插进当前轮(不打断正在执行的工具调用),目标空闲时自动退化为 queue;'
1524
+ + 'interrupt = 中断目标当前这一轮但保留已排队的后续消息(本条排进队列,等下次唤醒按序处理)',
1525
+ },
1526
+ timeoutMs: {
1527
+ type: 'number',
1528
+ description: `仅 interrupt 用:等目标变 idle 的上限毫秒,默认 ${INTERRUPT_IDLE_WAIT_MS}。`
1529
+ + 'abort 慢于该值时返回 idle:false,但 interrupted:true 且已排队消息已保留(按 interrupted 判定中断成功即可)',
1530
+ },
1531
+ },
1532
+ },
1533
+ output: {
1534
+ schema: {
1535
+ type: 'object',
1536
+ additionalProperties: false,
1537
+ required: ['ok'],
1538
+ properties: {
1539
+ ok: { type: 'boolean' },
1540
+ sessionId: { type: 'string' },
1541
+ title: { type: 'string' },
1542
+ goal: { type: 'string' },
1543
+ goalTaskId: { type: 'string' },
1544
+ goalError: { type: 'string' },
1545
+ goalWritten: { type: 'boolean' },
1546
+ goalCreated: { type: 'boolean' },
1547
+ goalFailed: { type: 'string' },
1548
+ goalMatchedBy: { type: 'string' },
1549
+ candidates: { type: 'array', items: { type: 'string' } },
1550
+ crossProject: { type: 'boolean' },
1551
+ callerProject: { type: 'string' },
1552
+ targetProject: { type: 'string' },
1553
+ mode: { type: 'string' },
1554
+ interrupted: { type: 'boolean' },
1555
+ idle: { type: 'boolean' },
1556
+ textQueued: { type: 'boolean' },
1557
+ // 派活纪律(2026-10-26):目标运行状态与排队位次(工具层强制回传)
1558
+ targetRunning: { type: 'boolean' },
1559
+ pendingDispatches: { type: 'number' },
1560
+ queuedBefore: { type: 'number' },
1561
+ // queue 分诊(2026-11-08):不排队,改开分诊分身
1562
+ triageSessionId: { type: 'string' },
1563
+ triageTitle: { type: 'string' },
1564
+ // 唯一投递结论(2026-11-09):triage / steer / queued / interrupted / started
1565
+ delivery: { type: 'string' },
1566
+ note: { type: 'string' },
1567
+ error: { type: 'string' },
1568
+ },
1569
+ },
1570
+ // 2026-11-09:render 抽成纯函数(同一返回只允许一个结论;回归用例直接断言它)
1571
+ render: (_args, value) => [{ type: 'text', text: renderTasklinePrompt(value) }],
1572
+ },
1573
+ async execute(args, exec) {
1574
+ const controller = hub.get('sessionController');
1575
+ if (!controller || typeof controller.prompt !== 'function') return { ok: false, error: 'sessionController 不可用' };
1576
+ // P8-①(2026-10-29):text 缺失/非字符串时**拒绝投递**并报错——此前 args.text 为
1577
+ // undefined 会拼出字面 "undefined" 空消息投给成员(骨髓·单细胞师/生信分析师 10-29
1578
+ // 16:52、16:55 各两连发)。宁可报错重发,不可投递垃圾轮。
1579
+ if (typeof args.text !== 'string' || !args.text.trim()) {
1580
+ return {
1581
+ ok: false,
1582
+ error: '缺少 text(收到 undefined/空/非字符串)。请检查 taskline_prompt 调用参数后重发;本条未投递。',
1583
+ };
1584
+ }
1585
+ const ref = String(args.session || '').trim();
1586
+ const isExactId = /^session-[0-9a-f-]{20,}$/i.test(ref);
1587
+ let session = deps.resolveSession(ref);
1588
+ // —— 同名歧义防护(重建后旧壳陷阱,DSH sync工程师 案例)——
1589
+ // 标题寻址时:只有「唯一 live 命中,且不存在其它同名(冷/归档)」才直接投;
1590
+ // 否则不静默投递,列出全部候选(id 尾 8 位 + 状态)让管理者用完整 sessionId 指定。
1591
+ if (!isExactId) {
1592
+ const liveSvc = hub.get('sessions');
1593
+ const liveAll = liveSvc && typeof liveSvc.list === 'function' ? liveSvc.list() : [];
1594
+ const liveHits = liveAll.filter((s) => {
1595
+ const t = deps.titleOfSession(s);
1596
+ return t === ref || t.includes(ref);
1597
+ });
1598
+ const registry = hub.get('workspaceRegistry');
1599
+ const archivedSet = new Set(
1600
+ registry && Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds.map(String) : [],
1601
+ );
1602
+ const candidates = liveHits.map((s) => ({
1603
+ id: String(s.id),
1604
+ title: deps.titleOfSession(s),
1605
+ state: archivedSet.has(String(s.id)) ? 'archived' : 'live',
1606
+ }));
1607
+ const seen = new Set(candidates.map((c) => c.id));
1608
+ // 冷/归档同名候选也纳入清点(controller.list 是全量底册)
1609
+ try {
1610
+ const rows = await controller.list(new AbortController().signal);
1611
+ const arr = Array.isArray(rows) ? rows : (rows && Array.isArray(rows.items) ? rows.items : []);
1612
+ for (const row of arr) {
1613
+ if (!row || !row.sessionId) continue;
1614
+ const id = String(row.sessionId);
1615
+ const t = row.projections && row.projections.values && typeof row.projections.values.title === 'string'
1616
+ ? row.projections.values.title : '';
1617
+ if (!t || !t.includes(ref)) continue;
1618
+ if (seen.has(id)) continue;
1619
+ seen.add(id);
1620
+ candidates.push({ id, title: t, state: archivedSet.has(id) ? 'archived' : 'cold' });
1621
+ }
1622
+ } catch { /* 底册列不全就只按 live 候选判 */ }
1623
+ if (candidates.length !== 1) {
1624
+ return {
1625
+ ok: false,
1626
+ error: `「${ref}」命中 ${candidates.length} 个会话(${candidates.length === 0
1627
+ ? '一个都没找到'
1628
+ : candidates.map((c) => `${c.title} ${c.id} [${c.state}]`).join(' ; ')})——`
1629
+ + `会话重建后旧壳可能还在,请用 taskline_list 确认后传完整 sessionId 投递`,
1630
+ };
1631
+ }
1632
+ session = liveHits[0];
1633
+ }
1634
+ if (!session) {
1635
+ // 冷会话兜底:resolveAgent 会把它 resume 成活会话
1636
+ //(SessionController.prompt 内部对 sessionId 走的就是 resolveAgent 同一条路)。
1637
+ const agentsCtl = controller.agents;
1638
+ if (agentsCtl && typeof agentsCtl.resolveAgent === 'function') {
1639
+ try {
1640
+ const found = await agentsCtl.resolveAgent(String(args.session));
1641
+ if (found && found.agent) {
1642
+ const resumedId = found.agent.session && found.agent.session.id ? found.agent.session.id : String(args.session);
1643
+ const live = hub.get('sessions');
1644
+ session = (live && typeof live.get === 'function' ? live.get(resumedId) : undefined)
1645
+ || { id: resumedId };
1646
+ console.log(`[hub-desktop-ui] taskline_prompt resumed cold session ${resumedId}`);
1647
+ }
1648
+ } catch (error) {
1649
+ console.warn(`[hub-desktop-ui] cold resume failed: ${String(error)}`);
1650
+ }
1651
+ }
1652
+ }
1653
+ if (!session) return { ok: false, error: '找不到这个会话(先用 taskline_list 看 id;冷会话要传完整 sessionId)' };
1654
+ // —— 项目边界护栏(2026-10-22 用户规则)——
1655
+ // 每个项目的 agent 只能给本项目成员派活;跨项目直派会让群聊/侧栏出现
1656
+ // 本项目看不见的成员(Book 的 Yat2 直派「应用开发 · DSH工程师」案例)。
1657
+ // 工具层硬拒,不依赖管理者记住协议。exec.agent = 调用方(tools 服务注入)。
1658
+ try {
1659
+ const callerAgent = exec && exec.agent;
1660
+ const callerTitle = callerAgent ? deps.callerTitleOf(callerAgent) : '';
1661
+ const callerProject = projectOfSessionTitle(callerTitle);
1662
+ const targetProject = projectOfSessionTitle(deps.titleOfSession(session));
1663
+ if (callerProject && targetProject && targetProject !== callerProject) {
1664
+ const targetName = agentNameOfTitle(deps.titleOfSession(session)) || deps.titleOfSession(session);
1665
+ return {
1666
+ ok: false,
1667
+ crossProject: true,
1668
+ callerProject,
1669
+ targetProject,
1670
+ error: `跨项目派活被拒:你属于「${callerProject}」,目标「${targetName}」属于「${targetProject}」。`
1671
+ + `想用它的能力:先用 taskline_create 在本项目复制该成员(标题「${callerProject} · ${targetName}」,`
1672
+ + `头像沿用它在「${targetProject}」tasks.json 里已有的 emoji,身份写明"从 ${targetProject} 借调"),再派活。`,
1673
+ };
1674
+ }
1675
+ } catch (error) {
1676
+ console.warn(`[hub-desktop-ui] project-boundary check failed: ${String(error)}`);
1677
+ }
1678
+ // 目标化派活(B):写 tasks.json 的 goal + 在 prompt 最前面插目标化指令。
1679
+ // 写回失败不影响投递(只在返回值里告警),因为投递本身是主目的。
1680
+ let goalNote = '';
1681
+ let goalTaskId = '';
1682
+ let goalError = '';
1683
+ let goalMatchedBy = '';
1684
+ let goalCandidates = [];
1685
+ let goalCreated = false;
1686
+ let goalCreateError = '';
1687
+ const goalText = String(args.goal || '').trim();
1688
+ if (goalText) {
1689
+ try {
1690
+ const project = projectOfSessionTitle(deps.titleOfSession(session));
1691
+ const agentName = agentNameOfTitle(deps.titleOfSession(session));
1692
+ if (project) {
1693
+ // 传了 task 就精确写那条线;没传时:setTaskGoal 在「同一成员属于多条线」时返回
1694
+ // ambiguous + candidates(2026-10-22 用户纠正);且**已有 goal 时拒绝覆盖**(P12-③)。
1695
+ const wrote = setTaskGoal(
1696
+ project, String(session.id), agentName, goalText, hostRoot(hub), String(args.task || ''),
1697
+ );
1698
+ if (wrote.ok) {
1699
+ goalTaskId = wrote.taskId || '';
1700
+ goalMatchedBy = wrote.matchedBy || '';
1701
+ } else {
1702
+ goalError = wrote.error || 'unknown';
1703
+ if (Array.isArray(wrote.candidates)) goalCandidates = wrote.candidates;
1704
+ }
1705
+ } else {
1706
+ goalError = '会话标题不是「<项目> · <成员>」格式,无法定位项目';
1707
+ }
1708
+ } catch (error) {
1709
+ goalError = String(error && error.message);
1710
+ }
1711
+ // —— 真正替成员建 goal(2026-10-22)——
1712
+ // 以前只发「请调用 create_goal」的文本指令,成员跳过就什么都没有;现在直接调
1713
+ // DSH 的 goal 服务(ctx.goals = GoalService,签名 create(agent, {objective, maxGoalRounds})),
1714
+ // 它会向**目标会话**的日志追加 `goal/change` 事件 —— 不依赖成员配合。
1715
+ try {
1716
+ const goalsSvc = hub.get('goals') || (typeof ctx.get === 'function' ? ctx.get('goals') : undefined);
1717
+ const agentsSvc = hub.get('agents');
1718
+ const targetAgent = (agentsSvc && typeof agentsSvc.get === 'function')
1719
+ ? agentsSvc.get(String(session.id)) : undefined;
1720
+ if (!goalsSvc || typeof goalsSvc.create !== 'function') {
1721
+ goalCreateError = 'goal 服务(ctx.goals)不可用';
1722
+ } else if (!targetAgent || !targetAgent.session) {
1723
+ goalCreateError = '目标会话的 agent 不可用(可能未激活)';
1724
+ } else {
1725
+ try {
1726
+ goalsSvc.create(targetAgent, { objective: goalText, maxGoalRounds: 12 });
1727
+ goalCreated = true;
1728
+ } catch (error) {
1729
+ const msg = String((error && error.message) || error);
1730
+ // 已有 goal:本质上"目标已存在",不算失败
1731
+ if (/GOAL_ALREADY_EXISTS|already exists/i.test(msg)) {
1732
+ goalCreated = true;
1733
+ goalCreateError = '已存在 goal(跳过重复创建)';
1734
+ } else {
1735
+ goalCreateError = msg;
1736
+ }
1737
+ }
1738
+ }
1739
+ } catch (error) {
1740
+ goalCreateError = String((error && error.message) || error);
1741
+ }
1742
+ if (goalCreateError && !goalCreated) {
1743
+ console.warn(`[hub-desktop-ui] taskline_prompt goal create failed: ${goalCreateError}`);
1744
+ }
1745
+ goalNote = goalDirective(goalText);
1746
+ }
1747
+ // —— 投递方式(2026-10-22)——
1748
+ // 不传 mode = 老行为完全不变(controller.prompt mode:'queue' → agent.followup)。
1749
+ // steer:controller.prompt mode:'steer' → agent.steer(message)(next-step 队列 + 唤醒),
1750
+ // 只在目标 running 时有意义;空闲时退化为 queue。
1751
+ // interrupt:controller.cancel({sessionId}) → agent.cancel({kind:'user'},{keepInbox:true}),
1752
+ // 只中断当前轮、**不清 inbox**,已排队的下一条消息原样保留;
1753
+ // 本条以 plugin-owned v4 消息排进 next-turn(不唤醒),等下次唤醒按序处理。
1754
+ // 2026-11-08 用户令:默认投递方式从 queue 改成 triage —— 目标在跑就开分诊分身,不排队干等。
1755
+ // 显式 mode:"queue" 仍可强制真排队;HUB_QUEUE_TRIAGE=0 全局回退旧行为。
1756
+ // 2026-11-09 用户令(返回自相矛盾修复):**一次快照 + 单一决策**——模式、note、
1757
+ // 返回的 targetRunning、render 结论全部来自下面这一次 deps.snapshotTargetRunning。
1758
+ const defaultMode = deps.queueTriageEnabled ? 'triage' : 'queue';
1759
+ const requestedMode = String(args.mode || defaultMode).toLowerCase();
1760
+ const agentsSvc = hub.get('agents');
1761
+ const deliveryAgent = agentsSvc && typeof agentsSvc.get === 'function'
1762
+ ? agentsSvc.get(String(session.id)) : undefined;
1763
+ const runningSnapshot = await deps.snapshotTargetRunning(String(session.id));
1764
+ const runningNow = runningSnapshot.running;
1765
+ // interrupt 等 idle 的上限:调用方传 timeoutMs 就跟它走,否则用模块常量
1766
+ //(长工具 abort 可能几秒;上限太短会误报 idle:false)
1767
+ const requestedIdleWait = Number(args.timeoutMs);
1768
+ const interruptIdleWaitMs = Number.isFinite(requestedIdleWait) && requestedIdleWait > 0
1769
+ ? Math.min(Math.max(Math.floor(requestedIdleWait), 1000), 600000)
1770
+ : INTERRUPT_IDLE_WAIT_MS;
1771
+ const plan = planDelivery({
1772
+ requestedMode,
1773
+ running: runningNow,
1774
+ triageEnabled: deps.queueTriageEnabled,
1775
+ targetIsTriage: isQueueTriageTitle(deps.titleOfSession(session)),
1776
+ });
1777
+ let deliveredMode = plan.mode;
1778
+ let modeNote = plan.note;
1779
+ let interrupted = false;
1780
+ let textQueued = false;
1781
+ let idleAfterInterrupt;
1782
+ const promptText = `${goalNote}${args.text}`;
1783
+ if (deliveredMode === 'interrupt') {
1784
+ if (!controller || typeof controller.cancel !== 'function') {
1785
+ return { ok: false, error: 'sessionController.cancel 不可用,无法执行 interrupt' };
1786
+ }
1787
+ try {
1788
+ await controller.cancel({ sessionId: String(session.id) });
1789
+ } catch (error) {
1790
+ return { ok: false, error: `中断失败:${String((error && error.message) || error)}` };
1791
+ }
1792
+ interrupted = true;
1793
+ try {
1794
+ if (deliveryAgent && deliveryAgent.inbox && typeof deliveryAgent.inbox.append === 'function') {
1795
+ deliveryAgent.inbox.append('next-turn', noticeMessage(promptText, 'taskline-interrupt'));
1796
+ textQueued = true;
1797
+ } else {
1798
+ modeNote = '拿不到目标 agent.inbox,本条未入队(已排队的消息仍保留)';
1799
+ }
1800
+ } catch (error) {
1801
+ modeNote = `本条入队失败:${String((error && error.message) || error)}`;
1802
+ }
1803
+ idleAfterInterrupt = await deps.waitForAgentIdle(String(session.id), interruptIdleWaitMs);
1804
+ }
1805
+ // —— 分诊(2026-11-08 用户令):目标在跑就不排队,立刻开分身判「steer 主线 / 分身自己做」——
1806
+ let triageInfo = null;
1807
+ if (deliveredMode === 'triage') {
1808
+ const callerAgentForTriage = exec && exec.agent;
1809
+ triageInfo = await deps.spawnQueueTriage({
1810
+ targetId: String(session.id),
1811
+ targetTitle: deps.titleOfSession(session),
1812
+ callerTitle: callerAgentForTriage ? deps.callerTitleOf(callerAgentForTriage) : '',
1813
+ callerSessionId: callerAgentForTriage && callerAgentForTriage.id !== undefined
1814
+ ? String(callerAgentForTriage.id) : '',
1815
+ // 传原始 text(不带 goal 指令前缀):分诊分身看的是"这条消息本身"
1816
+ text: String(args.text || ''),
1817
+ });
1818
+ if (!triageInfo.ok) {
1819
+ // 分诊建不出来就退回普通排队——消息绝不能丢
1820
+ deliveredMode = 'queue';
1821
+ modeNote = `分诊分身建不出来(${triageInfo.error}),本条退回普通排队`;
1822
+ }
1823
+ }
1824
+ try {
1825
+ // 派活纪律(2026-10-26):目标状态 + 排队位次直接进返回值;状态一律用上面
1826
+ // 那**一次**快照,绝不在这里再查一次(2026-11-09:两处快照不一致正是本 bug)。
1827
+ const targetRunning = runningNow;
1828
+ const pendingBefore = deps.pendingDispatchesOf(session.id);
1829
+ if (!interrupted && !(triageInfo && triageInfo.ok)) {
1830
+ await controller.prompt({
1831
+ requestId: `hub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1832
+ sessionId: session.id,
1833
+ mode: deliveredMode === 'steer' ? 'steer' : 'queue',
1834
+ content: [{ type: 'text', text: promptText }],
1835
+ }, new AbortController().signal);
1836
+ deps.recordDispatch(session.id, targetRunning);
1837
+ }
1838
+ const pendingAfter = deps.pendingDispatchesOf(session.id);
1839
+ const delivery = deliveryConclusion({
1840
+ ok: true,
1841
+ mode: deliveredMode,
1842
+ targetRunning,
1843
+ triageSessionId: triageInfo && triageInfo.ok ? triageInfo.sessionId : undefined,
1844
+ interrupted,
1845
+ });
1846
+ return {
1847
+ ok: true,
1848
+ sessionId: session.id,
1849
+ title: deps.titleOfSession(session),
1850
+ // 派活纪律(2026-10-26):目标状态 + 排队位次,直接进工具结果供管理者当场判断
1851
+ targetRunning,
1852
+ pendingDispatches: pendingAfter,
1853
+ goal: goalText || '',
1854
+ goalTaskId,
1855
+ goalError,
1856
+ // 三态:goalWritten = tasks.json 写入;goalCreated = 会话里真有 goal 事件;
1857
+ // goalFailed = 建 goal 失败原因
1858
+ goalWritten: Boolean(goalTaskId) && !goalError,
1859
+ goalCreated,
1860
+ goalFailed: goalText && !goalCreated ? (goalCreateError || 'unknown') : '',
1861
+ goalMatchedBy,
1862
+ ...(goalCandidates.length ? { candidates: goalCandidates } : {}),
1863
+ mode: deliveredMode,
1864
+ delivery,
1865
+ ...(triageInfo && triageInfo.ok
1866
+ ? { triageSessionId: triageInfo.sessionId, triageTitle: triageInfo.title }
1867
+ : {}),
1868
+ ...(interrupted ? { interrupted: true, textQueued } : {}),
1869
+ ...(idleAfterInterrupt === undefined ? {} : { idle: idleAfterInterrupt }),
1870
+ ...(modeNote ? { note: modeNote } : {}),
1871
+ ...(pendingBefore > 0 ? { queuedBefore: pendingBefore } : {}),
1872
+ };
1873
+ } catch (error) {
1874
+ return { ok: false, error: String(error && error.message) };
1875
+ }
1876
+ },
1877
+ }));
1878
+
1879
+ // —— taskline_list ——
1880
+ disposers.push(register({
1881
+ name: 'taskline_list',
1882
+ description: '列出所有可见会话(任务线):sessionId、标题、是否运行中、是否分身。含重启后未激活的会话;sessionController 漏掉的冷会话从各项目 tasks.json 注册表补全(diag 里可见 rows/registry 两路计数)。',
1883
+ parameters: { type: 'object', properties: {}, additionalProperties: false, required: [] },
1884
+ output: {
1885
+ schema: {
1886
+ type: 'object',
1887
+ additionalProperties: false,
1888
+ required: ['items'],
1889
+ properties: {
1890
+ items: {
1891
+ type: 'array',
1892
+ items: {
1893
+ type: 'object',
1894
+ additionalProperties: false,
1895
+ required: ['sessionId', 'title', 'running', 'subagent'],
1896
+ properties: {
1897
+ sessionId: { type: 'string' },
1898
+ title: { type: 'string' },
1899
+ running: { type: 'boolean' },
1900
+ subagent: { type: 'boolean' },
1901
+ },
1902
+ },
1903
+ },
1904
+ diag: { type: 'string' },
1905
+ },
1906
+ },
1907
+ render: (_args, value) => [{
1908
+ type: 'text',
1909
+ text: (value.items.length === 0
1910
+ ? '(没有活着的会话)'
1911
+ : value.items.map((item) => `${item.sessionId} ${item.title}${item.running ? ' [运行中]' : ''}${item.subagent ? ' [分身]' : ''}`).join('\n'))
1912
+ + (value.diag ? `\n[diag] ${value.diag}` : ''),
1913
+ }],
1914
+ },
1915
+ async execute() {
1916
+ const sessions = hub.get('sessions');
1917
+ // 底册用 sessionController.list():读**全部可见会话**(含重启后未激活的冷会话),
1918
+ // 不激活任何 Agent;行上的 running 官方已用 `agents.get(id)?.status === 'running'`
1919
+ // 算好(Agent 的字段是 status 字符串,没有 running 布尔——之前读错位置恒为 false)。
1920
+ const controller = hub.get('sessionController');
1921
+ // 归档集合:registry-global 的 archivedSessionIds(api-session-controller 也这么读),
1922
+ // 已归档/已回收的分身不该再出现在「活会话」列表里。
1923
+ const registry = hub.get('workspaceRegistry');
1924
+ const archivedSet = new Set(
1925
+ registry && Array.isArray(registry.archivedSessionIds)
1926
+ ? registry.archivedSessionIds.map(String)
1927
+ : [],
1928
+ );
1929
+ let rows = null;
1930
+ let rowsSource = 'none';
1931
+ let listError = '';
1932
+ if (controller && typeof controller.list === 'function') {
1933
+ try {
1934
+ const value = await controller.list(new AbortController().signal);
1935
+ // list() 返回 {items:[...]}(Remote('list') 包装)或裸数组——两种都收
1936
+ rows = value && Array.isArray(value.items) ? value.items : value;
1937
+ if (Array.isArray(rows)) rowsSource = 'controller.list';
1938
+ } catch (error) {
1939
+ listError = String((error && error.message) || error);
1940
+ console.warn(`[hub-desktop-ui] sessionController.list failed: ${listError}`);
1941
+ }
1942
+ }
1943
+ if (!Array.isArray(rows)) {
1944
+ // 兜底:controller 不可用时退回旧路径(只含活会话、无 running 判据)
1945
+ const list = sessions && typeof sessions.list === 'function' ? sessions.list() : [];
1946
+ rows = list.map((session) => ({
1947
+ sessionId: session.id,
1948
+ running: false,
1949
+ origin: session.header && session.header.origin,
1950
+ parentSessionId: session.header && session.header.parentSession,
1951
+ }));
1952
+ rowsSource = `fallback(${listError.slice(0, 60)})`;
1953
+ }
1954
+ const diagRunning = [];
1955
+ const items = rows
1956
+ .filter((row) => row && row.sessionId && !archivedSet.has(String(row.sessionId)))
1957
+ .map((row) => {
1958
+ const key = String(row.sessionId);
1959
+ const live = sessions && typeof sessions.get === 'function' ? sessions.get(row.sessionId) : undefined;
1960
+ // 标题:活会话走 sessionTitle;冷会话读官方缓存的 title 投影(用户改名=session/title 事件,已入缓存)
1961
+ const projected = row.projections && row.projections.values ? row.projections.values.title : undefined;
1962
+ const title = (live ? deps.titleOfSession(live) : '')
1963
+ || (typeof projected === 'string' ? projected : '')
1964
+ || `(未命名 ${String(row.sessionId).slice(0, 8)}…)`;
1965
+ // 运行中:事件集合优先,其次 list 行现算字段(任一为真就算在跑)
1966
+ const tracked = deps.liveRunning.has(key);
1967
+ let agentStatus = 'n/a';
1968
+ try {
1969
+ const agents = hub.get('agents');
1970
+ const agent = agents && typeof agents.get === 'function' ? agents.get(row.sessionId) : undefined;
1971
+ agentStatus = agent ? String(agent.status) : 'no-agent';
1972
+ } catch (error) { agentStatus = 'err'; }
1973
+ // 运行中判据(实测校准):`agents.get(id).status === 'running'` 是准的
1974
+ //(诊断里自己的会话 st=running);`sessionController.list()` 行里的 running
1975
+ // 经 wire 传输后恒为 false,不可信;事件集合做兜底。
1976
+ const running = agentStatus === 'running' || tracked || Boolean(row.running);
1977
+ // 诊断:归档集合规模 + 被判为运行中的会话 id(排障用,正文不受影响)
1978
+ if (running) diagRunning.push(key);
1979
+ return {
1980
+ sessionId: row.sessionId,
1981
+ title,
1982
+ running,
1983
+ subagent: Boolean(row.origin === 'subagent' || row.parentSessionId
1984
+ || (live && live.header && (live.header.origin === 'subagent' || live.header.parentSession))),
1985
+ };
1986
+ });
1987
+ // —— 注册表补全(2026-10-22 修「taskline_list 只回 2-3 条」)——
1988
+ // `sessionController.list()` 的返回行数实测会缩水(磁盘 175 会话/未归档约 27,
1989
+ // 只回 3 行),管理者据此误判成员已归档、派错对象。各项目 tasks.json 的
1990
+ // sessionIds 是协议要求维护的**注册表**:把里面未归档、且 list 没回的会话补进来
1991
+ // (标题 = 项目 · 成员名),冷会话 running=false。归档集只用于排除,不裁剪活跃窗口。
1992
+ const rowsRaw = rows.length;
1993
+ const seen = new Set(items.map((item) => String(item.sessionId)));
1994
+ let registryAdded = 0;
1995
+ try {
1996
+ const projectsDir = join(hostRoot(hub), '.hub', 'projects');
1997
+ for (const dir of readdirSync(projectsDir)) {
1998
+ let data = null;
1999
+ try { data = JSON.parse(readFileSync(join(projectsDir, dir, 'tasks.json'), 'utf8')); } catch (error) { continue; }
2000
+ if (!data || !Array.isArray(data.tasks)) continue;
2001
+ for (const task of data.tasks) {
2002
+ if (!task || task.status === 'archived') continue;
2003
+ const members = Array.isArray(task.members) ? task.members : [];
2004
+ const ids = Array.isArray(task.sessionIds) ? task.sessionIds.map(String) : [];
2005
+ for (const id of ids) {
2006
+ if (seen.has(id) || archivedSet.has(id)) continue;
2007
+ seen.add(id);
2008
+ registryAdded += 1;
2009
+ const member = String(members[0] || task.title || '成员');
2010
+ items.push({
2011
+ sessionId: id,
2012
+ title: `${dir} · ${member}`,
2013
+ running: deps.liveRunning.has(id),
2014
+ subagent: member.includes('|'),
2015
+ });
2016
+ }
2017
+ }
2018
+ }
2019
+ } catch (error) {
2020
+ console.warn(`[hub-desktop-ui] registry merge failed: ${String(error)}`);
2021
+ }
2022
+ return {
2023
+ items,
2024
+ diag: `src=${rowsSource} rows=${rowsRaw} registry=+${registryAdded} archived=${archivedSet.size}`
2025
+ + ` running=${diagRunning.length ? diagRunning.join(',') : 'none'}`,
2026
+ };
2027
+ },
2028
+ }));
2029
+
2030
+ return disposers;
2031
+ }