bothouse-dsh 0.1.0 → 0.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/client.js +18267 -648
- package/collab/approvals.mjs +177 -0
- package/collab/attachments.mjs +144 -0
- package/collab/avatar-uploads.mjs +53 -0
- package/collab/avatars.mjs +1 -1
- package/collab/cli-member.mjs +117 -32
- package/collab/cloudweb.mjs +173 -114
- package/collab/dsh-member.mjs +588 -44
- package/collab/gate-context.mjs +80 -0
- package/collab/handoff-rules.mjs +122 -0
- package/collab/hub.mjs +23 -4
- package/collab/later.mjs +146 -0
- package/collab/memory-index.mjs +61 -0
- package/collab/mentions.mjs +8 -0
- package/collab/page.html +24 -3
- package/collab/personas.mjs +4 -0
- package/collab/plugin.mjs +385 -32
- package/collab/quota-cooling.mjs +169 -0
- package/collab/roster.mjs +81 -16
- package/collab/store.mjs +220 -45
- package/collab/tasks.mjs +646 -38
- package/collab/team-exit.mjs +189 -11
- package/collab/verify-multichannel.mjs +42 -0
- package/package.json +13 -3
package/collab/dsh-member.mjs
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
import { readFileSync } from 'node:fs';
|
|
11
11
|
import { exitTarget } from './team-exit.mjs';
|
|
12
|
-
import { DEFAULT_DISPATCH_MODE } from './store.mjs';
|
|
12
|
+
import { DEFAULT_DISPATCH_MODE, MAX_TEXT } from './store.mjs';
|
|
13
|
+
import { ChannelApprovals, DEFAULT_ACTIONS } from './approvals.mjs';
|
|
14
|
+
import { DEFAULT_PERMISSION_PRESET, presetDecision } from './cli-member.mjs';
|
|
15
|
+
import { GateContext } from './gate-context.mjs';
|
|
16
|
+
import { MemoryIndex } from './memory-index.mjs';
|
|
13
17
|
// Shared with the bridge (bridge/core/team_mode.go): a common part, then one
|
|
14
18
|
// "## <mode>" section per dispatch mode; a member sees only its room's section.
|
|
15
19
|
const TEAM_MODE_FILE = readFileSync(new URL('./team-mode-rules.md', import.meta.url), 'utf8');
|
|
@@ -18,12 +22,30 @@ export function teamModeRules(mode = DEFAULT_DISPATCH_MODE) {
|
|
|
18
22
|
const section = parts.find(p => p.startsWith(`${mode}\n`)) ?? parts.find(p => p.startsWith(`${DEFAULT_DISPATCH_MODE}\n`));
|
|
19
23
|
return `${common.trim()}\n${section.slice(section.indexOf('\n') + 1).trim()}`;
|
|
20
24
|
}
|
|
25
|
+
/** #597 G24: the open-workspace command, for the selected primary role only, never in bridge prompts. */
|
|
26
|
+
export function workspaceHint(hub, later, memberId, roomId) {
|
|
27
|
+
if (!later?.workspaceInstructions || hub?.primaryIn?.(roomId)?.id !== memberId) return '';
|
|
28
|
+
return later.workspaceInstructions(memberId, roomId);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* #597 G24: DSH members have no bridge session, so the shared line about `bothouse workspace new` /
|
|
32
|
+
* the bare workspaces POST is swapped (whole line, DSH side only) for the ticketed command. The
|
|
33
|
+
* shared file stays as is: the bridge reads the same line. Throws when the line is gone so a reword
|
|
34
|
+
* of the shared file fails tests instead of silently dropping the swap.
|
|
35
|
+
*/
|
|
36
|
+
const WORKSPACE_LINE = /^- .*\/collab\/api\/rooms\/\{聊天频道 id\}\/workspaces.*$/m;
|
|
37
|
+
export const DSH_WORKSPACE_RULE = '- 大活(读长文、改代码、等命令)交给工作区:主角色用提示末尾【开工作区】给的本机命令或 curl:POST /collab/api/rooms/{聊天频道 id}/workspaces(头 X-Later-Ticket 带你的票据,只对你和本频道有效)。建工作区=这一条,任务和十分钟看门会自动开;不要调 /tasks(它不认票据)。一次带上标题、接活人(不写就是你自己)和交接正文(用户原话、要做什么、验收标准);系统建好工作区、开任务、挂十分钟看门并叫醒接活人,交过去的活不再在频道里 @ 另派。DSH 上不用 `bothouse workspace new`。何时交活、交给哪个、要不要新建,由主角色或正在干活的角色决定;一个工作区同一时间只做一件活,满 4 个就排队。';
|
|
38
|
+
export function dshTeamModeRules(mode) {
|
|
39
|
+
const rules = teamModeRules(mode);
|
|
40
|
+
if (!WORKSPACE_LINE.test(rules)) throw new Error('team-mode-rules.md: the workspace line moved; update WORKSPACE_LINE');
|
|
41
|
+
return rules.replace(WORKSPACE_LINE, DSH_WORKSPACE_RULE);
|
|
42
|
+
}
|
|
21
43
|
/** Opening brief for a new workspace: the rules of the chat channel's mode. */
|
|
22
44
|
export function workspaceInstructions(mode) { return teamModeRules(mode); }
|
|
23
|
-
export function memberStatus(hub, tasks = hub?.tasks) {
|
|
45
|
+
export function memberStatus(hub, tasks = hub?.tasks, roomId) {
|
|
24
46
|
const thinking = new Set([...(hub?.thinking?.values() ?? [])].flatMap(s => [...s]));
|
|
25
47
|
const busyMap = tasks?.busyMap?.() ?? new Map();
|
|
26
|
-
return '【队友状态】\n' + hub.routable().map(m => {
|
|
48
|
+
return '【队友状态】\n' + (roomId === undefined ? hub.routable() : hub.routableIn(roomId)).map(m => {
|
|
27
49
|
if (m.online === false || m.error) return `${m.name} (${m.id}):离线`;
|
|
28
50
|
const taskBusy = busyMap.get(m.id);
|
|
29
51
|
if (taskBusy) {
|
|
@@ -33,8 +55,10 @@ export function memberStatus(hub, tasks = hub?.tasks) {
|
|
|
33
55
|
return `${m.name} (${m.id}):空闲`;
|
|
34
56
|
}).join('\n');
|
|
35
57
|
}
|
|
36
|
-
import { mentions, nameCalled } from './mentions.mjs';
|
|
58
|
+
import { mentions, nameCalled, split } from './mentions.mjs';
|
|
37
59
|
import { quotePrefix } from './store.mjs';
|
|
60
|
+
import { progressCardOf } from './cloudweb.mjs';
|
|
61
|
+
import { attachmentBlocks, namedAttachmentBlocks, nativeAttachmentBlocks, undeliveredReasonZh } from './attachments.mjs';
|
|
38
62
|
|
|
39
63
|
const CONTEXT_LIMIT = 50;
|
|
40
64
|
const CLOSE_TIMEOUT_MS = 5_000;
|
|
@@ -99,8 +123,8 @@ export function promptText(room, member, messages, status = '', mode = DEFAULT_D
|
|
|
99
123
|
const persona = member.persona?.trim() ? ['你的人设:', member.persona.trim(), ''] : [];
|
|
100
124
|
return [
|
|
101
125
|
`你是频道 #${room.name} 的成员「${member.name}」。${model}频道里其他人各有自己的身份,他们的自我介绍说的是他们自己,不是你。`,
|
|
102
|
-
...persona,
|
|
103
|
-
|
|
126
|
+
...(persona.length ? persona : ['显示名只用于频道 @ 和路由,不要求扮演人物。没有人设时,以实际 AI 身份直接完成工作。']),
|
|
127
|
+
dshTeamModeRules(mode),
|
|
104
128
|
status,
|
|
105
129
|
'下面是频道里你还没看过的消息,最后一条 @ 了你。直接写出你要在频道里说的话;你这一轮最后一条回复会原样发到频道。',
|
|
106
130
|
'',
|
|
@@ -108,6 +132,11 @@ export function promptText(room, member, messages, status = '', mode = DEFAULT_D
|
|
|
108
132
|
].join('\n');
|
|
109
133
|
}
|
|
110
134
|
|
|
135
|
+
/** The teammate status, then the gate's block (each '' when absent). */
|
|
136
|
+
function withBlock(status, block) {
|
|
137
|
+
return [status, block].filter(Boolean).join('\n\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
111
140
|
/** One-line turn/end reason: kind plus a truncated error message when present. */
|
|
112
141
|
export function describeReason(reason) {
|
|
113
142
|
if (reason === undefined || reason === null) return 'unknown';
|
|
@@ -119,6 +148,7 @@ export function describeReason(reason) {
|
|
|
119
148
|
const INTERRUPTED = Symbol('interrupted');
|
|
120
149
|
|
|
121
150
|
export function friendlyFailure(raw) {
|
|
151
|
+
if (raw?.code === 'COLLAB_CLI_START') return raw.message;
|
|
122
152
|
const text = String(raw);
|
|
123
153
|
if (/no.{0,12}(api.?key|key)|missing.{0,12}(key|credential)|api.?key.{0,12}(missing|not set|required)|未配置.*key/i.test(text)) return '还没有配置模型的 API Key,请先去配置模型。';
|
|
124
154
|
if (/model.{0,30}(not found|unavailable|not available|unsupported)|unknown model|模型.*不可用|404/i.test(text)) return '这个模型暂时不可用,请检查模型名称或换一个模型。';
|
|
@@ -127,6 +157,133 @@ export function friendlyFailure(raw) {
|
|
|
127
157
|
return `这次没能完成,请检查配置后重试。原始错误:${text}`;
|
|
128
158
|
}
|
|
129
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Card text of one approval request, in cc-connect's permission prompt layout:
|
|
162
|
+
* the tool, what it wants to run (the call's first argument), and DSH's reason.
|
|
163
|
+
*/
|
|
164
|
+
function approvalText(request, session, seqBefore) {
|
|
165
|
+
const call = request.callId === undefined ? undefined
|
|
166
|
+
: session.snapshotEvents(seqBefore).find(e => e.type === 'tool/call' && e.data?.callId === request.callId);
|
|
167
|
+
const arg = call === undefined ? '' : toolArgText(call.data.arguments).trim();
|
|
168
|
+
// displayReason is a locale map ({ en, zh?, ... }) in DSH's escalation request; reason is a plain string.
|
|
169
|
+
const shown = request.displayReason;
|
|
170
|
+
const reason = String((shown && typeof shown === 'object' ? shown.zh ?? shown.en : shown) ?? request.reason ?? '').trim();
|
|
171
|
+
const lines = [`⚠️ **权限请求** ${request.toolName}`, arg && `\`${arg.replace(/`/g, "'")}\``, reason, '回复 **允许** / **拒绝** / **允许所有**'];
|
|
172
|
+
return lines.filter(Boolean).join('\n').slice(0, MAX_TEXT);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Card of one ACP session/request_permission from a local CLI member: the same
|
|
177
|
+
* layout as approvalText, with only the buttons the CLI offered. ACP option
|
|
178
|
+
* kinds (agentclientprotocol.com/protocol/tool-calls#permission-options) map to
|
|
179
|
+
* the card decisions: allow_once -> allow, allow_always -> allow_all, reject_* -> deny.
|
|
180
|
+
*/
|
|
181
|
+
export function cliApprovalCard(request) {
|
|
182
|
+
const kinds = { allow_once: 'allow', allow_always: 'allow_all', reject_once: 'deny', reject_always: 'deny' };
|
|
183
|
+
const byDecision = new Map();
|
|
184
|
+
for (const o of request.options ?? []) {
|
|
185
|
+
const d = kinds[o.kind];
|
|
186
|
+
if (d !== undefined && !byDecision.has(d)) byDecision.set(d, o.optionId);
|
|
187
|
+
}
|
|
188
|
+
const actions = DEFAULT_ACTIONS.filter(a => byDecision.has(a.decision));
|
|
189
|
+
const tc = request.toolCall ?? {};
|
|
190
|
+
const arg = toolArgText(JSON.stringify(tc.rawInput ?? {})).trim();
|
|
191
|
+
const words = { allow: '**允许**', deny: '**拒绝**', allow_all: '**允许所有**' };
|
|
192
|
+
const lines = [`⚠️ **权限请求** ${tc.title ?? tc.kind ?? ''}`, arg && arg !== '{}' && `\`${arg.replace(/`/g, "'")}\``, `回复 ${actions.map(a => words[a.decision]).join(' / ')}`];
|
|
193
|
+
// A text reply can say 允许所有 even when the CLI offered no allow_always: answer with
|
|
194
|
+
// its allow_once instead, and let the card show what was actually sent.
|
|
195
|
+
const mapDecision = d => (d === 'allow_all' && !byDecision.has(d) && byDecision.has('allow') ? 'allow' : d);
|
|
196
|
+
return { text: lines.filter(Boolean).join('\n').slice(0, MAX_TEXT), actions, byDecision, mapDecision };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Short text of a tool call's arguments for the card: the first string field (command, path, query), else the raw text. */
|
|
200
|
+
function toolArgText(raw) {
|
|
201
|
+
try {
|
|
202
|
+
const args = JSON.parse(raw);
|
|
203
|
+
const first = args !== null && typeof args === 'object' ? Object.values(args).find(v => typeof v === 'string' && v.trim()) : undefined;
|
|
204
|
+
if (first !== undefined) return first;
|
|
205
|
+
} catch { /* not JSON: show it as is */ }
|
|
206
|
+
return typeof raw === 'string' ? raw : '';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Live progress card of one member turn, the DSH-side counterpart of the
|
|
211
|
+
* bridge's progress card (cloudweb.mjs progressCard): posted in the room at the
|
|
212
|
+
* turn's first event (thought or tool call), edited as events arrive, and
|
|
213
|
+
* closed as 已完成 or 失败 when the turn ends. A turn without thoughts or tool
|
|
214
|
+
* calls posts no card, like the bridge.
|
|
215
|
+
*/
|
|
216
|
+
export class TurnProgress {
|
|
217
|
+
constructor(hub, roomId, member, jobId, now = Date.now) {
|
|
218
|
+
this.hub = hub;
|
|
219
|
+
this.roomId = roomId;
|
|
220
|
+
this.member = member;
|
|
221
|
+
this.jobId = jobId;
|
|
222
|
+
this.now = now;
|
|
223
|
+
this.started = now();
|
|
224
|
+
this.items = [];
|
|
225
|
+
this.steps = 0;
|
|
226
|
+
this.names = new Map();
|
|
227
|
+
this.resolved = new Set();
|
|
228
|
+
this.id = undefined;
|
|
229
|
+
this.queued = false;
|
|
230
|
+
this.ended = false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
call(callId, name, args) {
|
|
234
|
+
if (this.ended) return;
|
|
235
|
+
this.steps++;
|
|
236
|
+
if (callId !== undefined) this.names.set(callId, name);
|
|
237
|
+
this.items.push({ kind: 'tool_use', tool: name, text: toolArgText(args) });
|
|
238
|
+
this.schedule();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** One thought chunk (ACP agent_thought_chunk); consecutive chunks grow one thinking line. */
|
|
242
|
+
thought(text) {
|
|
243
|
+
if (this.ended || typeof text !== 'string' || text === '') return;
|
|
244
|
+
const last = this.items[this.items.length - 1];
|
|
245
|
+
if (last?.kind === 'thinking') last.text += text;
|
|
246
|
+
else this.items.push({ kind: 'thinking', text });
|
|
247
|
+
this.schedule();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
result(callId, failed, reason) {
|
|
251
|
+
if (this.ended || this.resolved.has(callId)) return;
|
|
252
|
+
if (callId !== undefined) this.resolved.add(callId);
|
|
253
|
+
this.items.push({ kind: 'tool_result', tool: this.names.get(callId) ?? '', success: !failed, text: failed ? (reason ?? '') : '' });
|
|
254
|
+
this.schedule();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Coalesce a burst of events into one store write per event-loop turn. */
|
|
258
|
+
schedule() {
|
|
259
|
+
if (this.queued) return;
|
|
260
|
+
this.queued = true;
|
|
261
|
+
setImmediate(() => { this.queued = false; if (!this.ended) this.write('running'); });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
end(state) {
|
|
265
|
+
if (this.ended) return;
|
|
266
|
+
this.ended = true;
|
|
267
|
+
// Only a turn that had events has a card to close.
|
|
268
|
+
if (this.steps > 0 || this.items.length > 0) this.write(state);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
write(state) {
|
|
272
|
+
const body = progressCardOf({ agent: this.member.name, state, steps: this.steps, elapsed_sec: Math.floor((this.now() - this.started) / 1000), items: this.items });
|
|
273
|
+
if (this.id === undefined) {
|
|
274
|
+
this.id = this.hub.post(this.roomId, { authorKind: 'role', author: this.member.id, name: this.member.name, clientKey: `dsh-job-${this.jobId}-progress`, text: body.text, card: body.card }).message.id;
|
|
275
|
+
} else {
|
|
276
|
+
this.hub.edit(this.id, 'role', this.member.id, body.text, body.card);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Whether text, with @-mentions removed, is the /new command (bridge core cmdNew). */
|
|
282
|
+
export function isNewCommand(text, members) {
|
|
283
|
+
const rest = split(text, members).filter(([, id]) => id === null).map(([seg]) => seg).join('').trim();
|
|
284
|
+
return /^\/new(\s|$)/.test(rest);
|
|
285
|
+
}
|
|
286
|
+
|
|
130
287
|
export class DshMembers {
|
|
131
288
|
/**
|
|
132
289
|
* @param hub - Hub instance.
|
|
@@ -150,17 +307,25 @@ export class DshMembers {
|
|
|
150
307
|
this.hooked = new WeakSet();
|
|
151
308
|
/** memberId -> { cli, agent } live local CLI agents (members with a `cli` field). */
|
|
152
309
|
this.cliAgents = new Map();
|
|
310
|
+
/** #557: permission cards of channel-started turns, shared with cloud-web bots. */
|
|
311
|
+
this.approvals = ChannelApprovals.of(hub);
|
|
312
|
+
/** Session ids whose owner chose 允许所有: later asks in that Session are allowed without a card (CLI members: `cli:<ACP session id>`). */
|
|
313
|
+
this.allowAll = new Set();
|
|
314
|
+
/** Per-turn wiki pages and task board from the local gate, each page once per conversation. */
|
|
315
|
+
this.gateContext = runtime.gateContext ?? new GateContext({ logger: runtime.logger });
|
|
316
|
+
/** The member's own MEMORY.md on the first turn of each conversation (bridge memory_index). */
|
|
317
|
+
this.memoryIndex = runtime.memoryIndex ?? new MemoryIndex();
|
|
153
318
|
for (const m of members) this.publish(m);
|
|
154
319
|
hub.onPlan(message => this.plan(message));
|
|
155
320
|
}
|
|
156
321
|
|
|
157
322
|
publish(m) {
|
|
158
|
-
this.hub.setMember({ id: m.id, name: m.name, kind: 'dsh', online: true, model: m.model, ...(m.cli ? { cli: m.cli } : {}), persona: m.persona ?? '', ...(m.avatar ? { avatar: m.avatar } : {}), source: m.source ?? 'config' });
|
|
323
|
+
this.hub.setMember({ id: m.id, name: m.name, rooms: m.rooms, kind: 'dsh', online: true, model: m.model, ...(m.cli ? { cli: m.cli, ...(m.permissionPreset ? { permissionPreset: m.permissionPreset } : {}) } : {}), persona: m.persona ?? '', ...(m.avatar ? { avatar: m.avatar } : {}), source: m.source ?? 'config' });
|
|
159
324
|
}
|
|
160
325
|
|
|
161
326
|
/** Add or replace a member at runtime; its bound Sessions pick up the new model on their next request. */
|
|
162
327
|
putMember(m) {
|
|
163
|
-
if (this.
|
|
328
|
+
if (this.config.get(m.id)?.cli !== m.cli) this.dropCli(m.id);
|
|
164
329
|
this.config.set(m.id, m);
|
|
165
330
|
this.publish(m);
|
|
166
331
|
}
|
|
@@ -177,10 +342,21 @@ export class DshMembers {
|
|
|
177
342
|
if (event.type === 'turn/start') this.openTurns.set(session.id, event.data?.turn);
|
|
178
343
|
const w = this.watching.get(session.id);
|
|
179
344
|
if (w === undefined) return;
|
|
345
|
+
const job = this.store.job(w.jobId);
|
|
346
|
+
if (job) this.hub.quota?.observe(job.member_id, job.room_id, event);
|
|
180
347
|
if (event.type === 'user/message' && event.data?.id === w.inputId && w.turn === undefined) {
|
|
181
348
|
w.turn = this.openTurns.get(session.id);
|
|
182
349
|
return;
|
|
183
350
|
}
|
|
351
|
+
if (w.turn !== undefined && event.data?.turn === w.turn) {
|
|
352
|
+
if (event.type === 'tool/call') w.progress?.call(event.data.callId, event.data.name, event.data.arguments);
|
|
353
|
+
else if (event.type === 'tool/result') w.progress?.result(event.data.message?.toolCallId, event.data.error !== undefined || event.data.message?.isError === true, event.data.error?.reason ?? event.data.error?.name);
|
|
354
|
+
else if (event.type === 'assistant/message') {
|
|
355
|
+
for (const part of event.data.message?.content ?? []) {
|
|
356
|
+
if (part?.type === 'reasoning' && typeof part.text === 'string' && part.text !== '') w.progress?.thought(part.text);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
184
360
|
if (event.type === 'turn/end' && w.turn !== undefined && event.data?.turn === w.turn) {
|
|
185
361
|
this.watching.delete(session.id);
|
|
186
362
|
w.resolve({ events: turnEvents(session.snapshotEvents(w.seqBefore), w.turn), reason: event.data?.reason });
|
|
@@ -193,6 +369,63 @@ export class DshMembers {
|
|
|
193
369
|
if (w !== undefined && w.inputId === messageId) w.turn = turn;
|
|
194
370
|
}
|
|
195
371
|
|
|
372
|
+
/**
|
|
373
|
+
* #148: the owner withdrew trigger message triggerId. Every job it planned
|
|
374
|
+
* ends quietly, with no reply and no failure card: a queued one is closed, an
|
|
375
|
+
* input still waiting in the member's inbox is taken back, and a turn already
|
|
376
|
+
* running is cancelled the way DSH's stop button does (keepInbox, so the
|
|
377
|
+
* member's later inputs still run). Jobs this cannot reach (a CLI member's
|
|
378
|
+
* ask) keep running.
|
|
379
|
+
* @returns { stopped, running } member ids whose turn was cancelled / left running.
|
|
380
|
+
*/
|
|
381
|
+
recall(triggerId) {
|
|
382
|
+
const stopped = [];
|
|
383
|
+
const running = [];
|
|
384
|
+
for (const job of this.store.openJobs()) {
|
|
385
|
+
if (job.trigger_id !== triggerId) continue;
|
|
386
|
+
if (job.state === 'pending') { this.store.finishJob(job.id, 'interrupted'); continue; }
|
|
387
|
+
const w = this.watching.get(job.session_id);
|
|
388
|
+
const agent = job.input_id ? this.rt.ctx.agents.get(job.session_id) : undefined;
|
|
389
|
+
if (w?.jobId !== job.id || agent === undefined) { running.push(job.member_id); continue; }
|
|
390
|
+
// Close the job first, so neither the discarded listener nor the settle path posts a card or a reply.
|
|
391
|
+
this.store.finishJob(job.id, 'interrupted');
|
|
392
|
+
this.approvals.expireWhere(p => p.meta?.sessionId === job.session_id);
|
|
393
|
+
if (w.turn === undefined && agent.inbox.remove(job.input_id)) continue;
|
|
394
|
+
this.watching.delete(job.session_id);
|
|
395
|
+
w.resolve(INTERRUPTED);
|
|
396
|
+
agent.cancel({ kind: 'user' }, { keepInbox: true });
|
|
397
|
+
stopped.push(job.member_id);
|
|
398
|
+
}
|
|
399
|
+
return { stopped, running };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* #557: root approval/request answerer (installed prepended by the plugin).
|
|
404
|
+
* The Session is held out of the DSH conversation list, so its approval
|
|
405
|
+
* popup has nowhere to show: a turn the channel started asks in the room of
|
|
406
|
+
* its job, as a permission card (approvals.mjs). Any other turn — one started
|
|
407
|
+
* from the DSH Web UI — goes on to the next answerer. The approval service
|
|
408
|
+
* applies the Session's policy first (Full access: no request at all;
|
|
409
|
+
* \`never\`: rejected before any answerer), so a card appears exactly when
|
|
410
|
+
* DSH would have asked. Withdrawing the request (turn cancelled or recalled)
|
|
411
|
+
* expires the card.
|
|
412
|
+
*/
|
|
413
|
+
async onApprovalRequest(request, next) {
|
|
414
|
+
const session = request.agent?.session;
|
|
415
|
+
const w = session === undefined ? undefined : this.watching.get(session.id);
|
|
416
|
+
const job = w === undefined ? undefined : this.store.job(w.jobId);
|
|
417
|
+
const member = job === undefined ? undefined : this.config.get(job.member_id);
|
|
418
|
+
if (member === undefined || member.cli !== undefined) return next();
|
|
419
|
+
if (this.allowAll.has(session.id)) return 'allowed-once';
|
|
420
|
+
const { decision } = this.approvals.open({
|
|
421
|
+
room: job.room_id, author: { id: member.id, name: member.name }, text: approvalText(request, session, w.seqBefore),
|
|
422
|
+
signal: request.signal, meta: { sessionId: session.id },
|
|
423
|
+
});
|
|
424
|
+
const d = await decision;
|
|
425
|
+
if (d === 'allow_all') this.allowAll.add(session.id);
|
|
426
|
+
return d === 'allow' || d === 'allow_all' ? 'allowed-once' : d === 'deny' ? 'rejected' : 'cancelled';
|
|
427
|
+
}
|
|
428
|
+
|
|
196
429
|
/** Root agent/inbox/discarded listener: a withdrawn input ends its job. */
|
|
197
430
|
onDiscarded(sessionId, messageId) {
|
|
198
431
|
const w = this.watching.get(sessionId);
|
|
@@ -212,13 +445,22 @@ export class DshMembers {
|
|
|
212
445
|
// Solo mode keeps agent-to-agent relays inside the lead's own runtime;
|
|
213
446
|
// an explicit user mention still routes to the named DSH member.
|
|
214
447
|
if (mode === 'solo' && msg.author_kind === 'role') return undefined;
|
|
215
|
-
|
|
448
|
+
// An owner text reply that decides (or is withheld from) a member's permission card is not a new ask for it.
|
|
449
|
+
const answered = this.approvals.answerByText(msg);
|
|
450
|
+
const hit = mentions(msg.text, this.hub.routableIn(msg.room_id));
|
|
216
451
|
const only = this.hub.replyRoute(msg);
|
|
217
|
-
const leadID = this.hub.
|
|
452
|
+
const leadID = this.hub.primaryIn(msg.room_id)?.id;
|
|
218
453
|
const leadTarget = msg.author_kind === 'user' && leadID !== undefined && target === undefined && only === null && hit.size === 0;
|
|
219
|
-
const
|
|
454
|
+
const visible = new Set(this.hub.routableIn(msg.room_id).map(m => m.id));
|
|
455
|
+
const targets = [...this.config.keys()].filter(id => !this.hub.quota?.cooling(id) && visible.has(id) && id !== msg.author && !answered.has(id) && (!target || target === id)
|
|
220
456
|
&& (only !== null ? only.has(id) : hit.has(id) || (leadTarget && id === leadID) || (msg.author_kind === 'role' && nameCalled(msg.text, this.config.get(id).name, [...this.config.keys()].filter(o => o !== id).map(o => this.config.get(o).name)))));
|
|
221
457
|
if (targets.length === 0) return undefined;
|
|
458
|
+
// /new goes to the members the message reaches, as on Discord where only the addressed bot handles it.
|
|
459
|
+
if (msg.author_kind === 'user' && isNewCommand(msg.text, this.hub.routableIn(msg.room_id))) {
|
|
460
|
+
return () => {
|
|
461
|
+
for (const id of targets) this.enqueue({ member_id: id, room_id: msg.room_id }, () => this.renew(id, msg));
|
|
462
|
+
};
|
|
463
|
+
}
|
|
222
464
|
if (msg.author_kind === 'role' && this.store.relayDepth(msg.room_id, msg.id) > this.maxRelay) {
|
|
223
465
|
return () => this.hub.post(msg.room_id, {
|
|
224
466
|
authorKind: 'system', author: 'system', name: 'system', clientKey: `relay-cap-${msg.id}`,
|
|
@@ -237,18 +479,34 @@ export class DshMembers {
|
|
|
237
479
|
* Returns false when the id is not a DSH member.
|
|
238
480
|
*/
|
|
239
481
|
nudge(memberId, msg) {
|
|
240
|
-
if (
|
|
482
|
+
if (this.hub.quota?.cooling(memberId)) return false;
|
|
483
|
+
if (!this.config.has(memberId) || !this.hub.routableIn(msg.room_id).some(m => m.id === memberId)) return false;
|
|
241
484
|
const job = this.store.addJob(memberId, msg.room_id, msg.id);
|
|
242
485
|
if (job) this.enqueue(job, () => this.run(job.id));
|
|
243
486
|
return true;
|
|
244
487
|
}
|
|
245
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Retry a previous failed turn for (memberId, msg).
|
|
491
|
+
* Wakes the member with the original trigger message and resets its job.
|
|
492
|
+
*/
|
|
493
|
+
retry(memberId, msg) {
|
|
494
|
+
if (!this.config.has(memberId) || !this.hub.routableIn(msg.room_id).some(m => m.id === memberId)) return false;
|
|
495
|
+
const job = this.store.retryJob(memberId, msg.room_id, msg.id);
|
|
496
|
+
if (job) {
|
|
497
|
+
this.hub.setError(memberId, undefined);
|
|
498
|
+
this.enqueue(job, () => this.run(job.id));
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
|
|
246
504
|
/** Serialize work per (member, room): runs, recoveries, and Session resumes never overlap. */
|
|
247
505
|
enqueue(job, task) {
|
|
248
506
|
const key = `${job.member_id}\u0000${job.room_id}`;
|
|
249
507
|
const tail = (this.queues.get(key) ?? Promise.resolve()).then(() => (this.closed ? undefined : task())).catch((error) => {
|
|
250
508
|
this.rt.logger.warn('collab: DSH job %d failed: %s', job.id, error?.stack ?? error);
|
|
251
|
-
if (!this.closed) this.fail(job.id, `出错了:${error?.message ?? error}`);
|
|
509
|
+
if (!this.closed) this.fail(job.id, error?.code === 'COLLAB_CLI_START' ? error : `出错了:${error?.message ?? error}`);
|
|
252
510
|
});
|
|
253
511
|
this.queues.set(key, tail);
|
|
254
512
|
return tail;
|
|
@@ -257,6 +515,10 @@ export class DshMembers {
|
|
|
257
515
|
async run(jobId) {
|
|
258
516
|
const job = this.store.job(jobId);
|
|
259
517
|
if (job === undefined || job.state !== 'pending') return;
|
|
518
|
+
if (this.hub.quota?.cooling(job.member_id)) {
|
|
519
|
+
this.store.finishJob(jobId, 'failed');
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
260
522
|
const member = this.config.get(job.member_id);
|
|
261
523
|
const room = this.store.room(job.room_id);
|
|
262
524
|
if (member === undefined || room === undefined) { this.store.finishJob(jobId, 'failed'); return; }
|
|
@@ -266,11 +528,26 @@ export class DshMembers {
|
|
|
266
528
|
this.syncSelection(agent, member);
|
|
267
529
|
const binding = this.store.binding(member.id, room.id);
|
|
268
530
|
const unseen = this.store.page(room.id, job.trigger_id + 1, CONTEXT_LIMIT * 4)
|
|
269
|
-
.filter(m => m
|
|
531
|
+
.filter(m => turnSees(m, job, binding, member))
|
|
270
532
|
.slice(-CONTEXT_LIMIT);
|
|
271
|
-
|
|
533
|
+
// DSH validates images again (count, total bytes, type, pixels). Attachments it
|
|
534
|
+
// refuses are dropped one by one (the rest still go through), the member is told in
|
|
535
|
+
// text and the channel gets a system note, instead of failing the turn: a failed turn
|
|
536
|
+
// never advances the cursor, so the same image would block the room.
|
|
537
|
+
const { parts: media, dropped: refused } = await nativeAttachmentBlocks(this.rt.ctx.attachments, namedAttachmentBlocks(this.rt.files, unseen));
|
|
538
|
+
const dropped = refused.map(d => `\n\n[${d.name}: 附件未送达(${undeliveredReasonZh(d)})]`).join('');
|
|
539
|
+
const wiki = await this.turnContext(member, room, agent.session.id, unseen);
|
|
540
|
+
if (this.closed) return;
|
|
541
|
+
const input = this.rt.createUserMessage({ content: [{ type: 'text', text: promptText(room, member, unseen, withBlock(memberStatus(this.hub, undefined, room.id), wiki.block), this.store.dispatchMode(room.id), this.store) + dropped + (this.rt.later?.instructions(member.id, room.id) ?? '') + workspaceHint(this.hub, this.rt.later, member.id, room.id) }, ...media], source: { kind: 'user' } });
|
|
272
542
|
const seqBefore = agent.session.seq;
|
|
273
543
|
if (!this.store.markSent(jobId, agent.session.id, seqBefore, input.id)) return;
|
|
544
|
+
wiki.commit();
|
|
545
|
+
if (refused.length) {
|
|
546
|
+
this.hub.post(room.id, {
|
|
547
|
+
authorKind: 'system', author: 'system', name: 'system', clientKey: `dsh-job-${jobId}-attachments-dropped`,
|
|
548
|
+
text: `⚠️ 有附件没送到 ${member.name}:${refused.map(d => `${d.name}(${undeliveredReasonZh(d)})`).join(';')}`,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
274
551
|
await this.admitAndSettle(job, member, agent, input, seqBefore);
|
|
275
552
|
}
|
|
276
553
|
|
|
@@ -280,18 +557,27 @@ export class DshMembers {
|
|
|
280
557
|
* own ACP session for this room and its reply text is posted directly.
|
|
281
558
|
*/
|
|
282
559
|
async runCli(job, member, room) {
|
|
283
|
-
|
|
284
|
-
|
|
560
|
+
// Until the ACP session exists the binding holds a placeholder unique to (member, room);
|
|
561
|
+
// onSession below swaps in the real ACP sessionId so a restart can session/load it.
|
|
562
|
+
const placeholder = `cli:${member.cli}:${member.id}:${room.id}`;
|
|
563
|
+
if (this.store.binding(member.id, room.id) === undefined) this.store.bind(member.id, room.id, placeholder);
|
|
285
564
|
const binding = this.store.binding(member.id, room.id);
|
|
565
|
+
const sessionId = binding.session_id;
|
|
566
|
+
const resumeId = sessionId.startsWith('cli:') ? undefined : sessionId;
|
|
286
567
|
const unseen = this.store.page(room.id, job.trigger_id + 1, CONTEXT_LIMIT * 4)
|
|
287
|
-
.filter(m => m
|
|
568
|
+
.filter(m => turnSees(m, job, binding, member))
|
|
288
569
|
.slice(-CONTEXT_LIMIT);
|
|
570
|
+
const wiki = await this.turnContext(member, room, undefined, unseen);
|
|
571
|
+
if (this.closed) return;
|
|
289
572
|
if (!this.store.markSent(job.id, sessionId, 0, undefined)) return;
|
|
290
|
-
|
|
573
|
+
const cwd = this.store.bindingWorkspacePath(member.id, room.id, this.store.workspacePath(room.id, this.rt.workspacePath));
|
|
574
|
+
this.store.setBindingWorkspacePath(member.id, room.id, cwd);
|
|
575
|
+
const cliKey = JSON.stringify([member.id, cwd]);
|
|
576
|
+
let entry = this.cliAgents.get(cliKey);
|
|
291
577
|
if (entry === undefined) {
|
|
292
578
|
// Persona goes through promptText every turn, so an edit applies without restarting the CLI.
|
|
293
|
-
entry = { cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd
|
|
294
|
-
this.cliAgents.set(
|
|
579
|
+
entry = { memberId: member.id, cli: member.cli, agent: this.rt.createCliMember({ cli: member.cli, cwd, persona: '' }) };
|
|
580
|
+
this.cliAgents.set(cliKey, entry);
|
|
295
581
|
}
|
|
296
582
|
this.hub.setError(member.id, undefined);
|
|
297
583
|
this.hub.setThinking(job.room_id, member.id, true);
|
|
@@ -313,40 +599,126 @@ export class DshMembers {
|
|
|
313
599
|
latest = full;
|
|
314
600
|
if (!flushing) { flushing = true; setImmediate(flush); }
|
|
315
601
|
};
|
|
602
|
+
const progress = new TurnProgress(this.hub, job.room_id, member, job.id);
|
|
603
|
+
// ACP tool calls (agentclientprotocol.com/protocol/tool-calls): one call per toolCallId, its update says completed or failed.
|
|
604
|
+
// Thoughts (agent_thought_chunk) feed the card too, as on the bridge.
|
|
605
|
+
const onTool = (u) => {
|
|
606
|
+
if (u.sessionUpdate === 'agent_thought_chunk') { if (u.content?.type === 'text') progress.thought(u.content.text); return; }
|
|
607
|
+
if (u.sessionUpdate === 'tool_call') progress.call(u.toolCallId, u.title ?? u.kind ?? '', JSON.stringify(u.rawInput ?? {}));
|
|
608
|
+
if (u.status === 'completed' || u.status === 'failed') progress.result(u.toolCallId, u.status === 'failed');
|
|
609
|
+
};
|
|
610
|
+
// Tool approval follows the member's DSH permission preset (cli-member.mjs presetDecision):
|
|
611
|
+
// Full access answers at once, Read Only refuses, Workspace Write asks the owner in the room.
|
|
612
|
+
// No timeout while the card waits, as cc-connect (bridge core/engine.go handlePendingPermission
|
|
613
|
+
// has none); the turn ending withdraws the card and the request is cancelled.
|
|
614
|
+
const turnOver = new AbortController();
|
|
615
|
+
const onPermission = async (request) => {
|
|
616
|
+
const preset = member.permissionPreset ?? DEFAULT_PERMISSION_PRESET;
|
|
617
|
+
const fixed = presetDecision(preset, request.options ?? []);
|
|
618
|
+
if (fixed === null) return { outcome: 'cancelled' };
|
|
619
|
+
if (fixed !== 'ask') return { outcome: 'selected', ...fixed };
|
|
620
|
+
const card = cliApprovalCard(request);
|
|
621
|
+
if (card.actions.length === 0) return { outcome: 'cancelled' };
|
|
622
|
+
// 允许所有 holds for the rest of this ACP session (one per member and room), as
|
|
623
|
+
// onApprovalRequest does for DSH-native members: the CLI's own allow_always may
|
|
624
|
+
// only cover that one command, so collab remembers it and answers without a card.
|
|
625
|
+
const allowAllKey = `cli:${request.sessionId}`;
|
|
626
|
+
if (this.allowAll.has(allowAllKey)) {
|
|
627
|
+
const optionId = card.byDecision.get('allow') ?? card.byDecision.get('allow_all');
|
|
628
|
+
if (optionId !== undefined) return { outcome: 'selected', optionId };
|
|
629
|
+
}
|
|
630
|
+
const { decision } = this.approvals.open({
|
|
631
|
+
room: job.room_id, author: { id: member.id, name: member.name }, text: card.text, actions: card.actions,
|
|
632
|
+
signal: turnOver.signal, meta: { cliMember: member.id, jobId: job.id }, mapDecision: card.mapDecision,
|
|
633
|
+
});
|
|
634
|
+
const d = await decision;
|
|
635
|
+
if (d === 'allow_all') this.allowAll.add(allowAllKey);
|
|
636
|
+
const optionId = card.byDecision.get(d);
|
|
637
|
+
return optionId === undefined ? { outcome: 'cancelled' } : { outcome: 'selected', optionId };
|
|
638
|
+
};
|
|
316
639
|
let done = false;
|
|
317
640
|
try {
|
|
318
641
|
this.store.advanceCursor(member.id, job.room_id, job.trigger_id);
|
|
319
|
-
const
|
|
642
|
+
const onSession = ({ sessionId: acpId, lost }) => {
|
|
643
|
+
if (this.store.binding(member.id, room.id)?.session_id !== acpId) this.store.rebind(member.id, room.id, acpId);
|
|
644
|
+
if (lost) {
|
|
645
|
+
// the CLI's new session has none of what this conversation was given: the next turn gives it again
|
|
646
|
+
this.memoryIndex.forget(member.id, room.id);
|
|
647
|
+
this.gateContext.forget(member.id, room.id);
|
|
648
|
+
this.hub.post(job.room_id, {
|
|
649
|
+
authorKind: 'system', author: 'system', name: 'system', clientKey: `dsh-job-${job.id}-session-lost`,
|
|
650
|
+
text: `${member.name}(${member.cli})这轮起是新会话,之前的上下文没了:${member.cli} 不支持续上原会话,或续会话失败。`,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
const prompt = promptText(room, member, unseen, withBlock(memberStatus(this.hub, undefined, room.id), wiki.block), this.store.dispatchMode(room.id), this.store) + (this.rt.later?.instructions(member.id, room.id) ?? '') + workspaceHint(this.hub, this.rt.later, member.id, room.id);
|
|
655
|
+
wiki.commit();
|
|
656
|
+
const text = (await entry.agent.ask(prompt, String(room.id), onText, onTool, { resumeId, onSession, onPermission, attachments: attachmentBlocks(this.rt.files, unseen) }))?.trim();
|
|
320
657
|
if (this.closed) return;
|
|
658
|
+
this.hub.quota?.observe(member.id, room.id, { error: text });
|
|
321
659
|
if (!text) { this.fail(job.id, `${member.name} 这一轮没有给出回复(${member.cli} 没有输出文字)`); return; }
|
|
322
660
|
latest = '';
|
|
323
|
-
const
|
|
324
|
-
const
|
|
661
|
+
const reply = this.returnToLead(job, member, text);
|
|
662
|
+
const committed = draft === undefined ? undefined : this.hub.commit(draft.id, 'role', member.id, reply);
|
|
663
|
+
const message = committed ?? this.hub.post(job.room_id, { authorKind: 'role', author: member.id, name: member.name, clientKey, text: reply }).message;
|
|
325
664
|
done = true;
|
|
326
665
|
this.store.finishJob(job.id, 'done', message.id);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
if (error?.code === 'COLLAB_CLI_START') {
|
|
668
|
+
throw Object.assign(new Error(`频道 #${room.name} ${error.message}`, { cause: error }), {
|
|
669
|
+
code: error.code, cwd: error.cwd, cli: error.cli,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
throw error;
|
|
327
673
|
} finally {
|
|
674
|
+
turnOver.abort();
|
|
328
675
|
latest = '';
|
|
676
|
+
progress.end(done ? 'completed' : 'failed');
|
|
329
677
|
// Error, cancel or empty turn: drop the partial draft so the room never keeps a cut-off reply.
|
|
330
678
|
if (!done && draft?.draft) this.hub.remove(draft.id, 'role', member.id);
|
|
331
679
|
this.hub.setThinking(job.room_id, member.id, false);
|
|
332
680
|
}
|
|
333
681
|
}
|
|
334
682
|
|
|
683
|
+
/**
|
|
684
|
+
* This turn's context blocks: the member's own MEMORY.md on a conversation's first turn, then the gate's
|
|
685
|
+
* pages (wiki, task board) queried with the message that @-ed the member. commit() records both as given.
|
|
686
|
+
*/
|
|
687
|
+
async turnContext(member, room, session, unseen) {
|
|
688
|
+
const trigger = unseen.at(-1);
|
|
689
|
+
const memory = this.memoryIndex.turn({ member: member.id, room: room.id, session });
|
|
690
|
+
const gate = await this.gateContext.turn({ member: member.id, room: room.id, session, text: trigger?.text ?? '' });
|
|
691
|
+
return {
|
|
692
|
+
block: [memory.block, gate.block].filter(Boolean).join('\n\n'),
|
|
693
|
+
commit: () => { memory.commit(); gate.commit(); },
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
335
697
|
/** A released workspace starts over: drop each local CLI member's ACP session for roomId. */
|
|
336
698
|
resetRoom(roomId) {
|
|
337
|
-
|
|
699
|
+
this.gateContext.resetRoom(roomId);
|
|
700
|
+
this.memoryIndex.resetRoom(roomId);
|
|
701
|
+
for (const { agent } of this.cliAgents.values()) {
|
|
702
|
+
agent.sessions?.delete(String(roomId));
|
|
703
|
+
agent.saved?.delete(String(roomId));
|
|
704
|
+
}
|
|
338
705
|
}
|
|
339
706
|
|
|
340
707
|
dropCli(id) {
|
|
341
|
-
this.cliAgents
|
|
342
|
-
|
|
708
|
+
for (const [key, entry] of this.cliAgents) {
|
|
709
|
+
if (entry.memberId !== id) continue;
|
|
710
|
+
entry.agent.close();
|
|
711
|
+
this.cliAgents.delete(key);
|
|
712
|
+
}
|
|
343
713
|
}
|
|
344
714
|
|
|
345
715
|
/** Watch for the consuming turn, admit the input, and post the outcome. */
|
|
346
716
|
async admitAndSettle(job, member, agent, input, seqBefore) {
|
|
347
717
|
const sessionId = agent.session.id;
|
|
718
|
+
const progress = new TurnProgress(this.hub, job.room_id, member, job.id);
|
|
719
|
+
let settled = 'failed';
|
|
348
720
|
const done = new Promise((resolve) => {
|
|
349
|
-
this.watching.set(sessionId, { jobId: job.id, inputId: input.id, seqBefore, turn: undefined, resolve });
|
|
721
|
+
this.watching.set(sessionId, { jobId: job.id, inputId: input.id, seqBefore, turn: undefined, resolve, progress });
|
|
350
722
|
});
|
|
351
723
|
this.hub.setError(member.id, undefined);
|
|
352
724
|
this.hub.setThinking(job.room_id, member.id, true);
|
|
@@ -356,8 +728,9 @@ export class DshMembers {
|
|
|
356
728
|
const outcome = await done;
|
|
357
729
|
if (outcome === INTERRUPTED || this.closed) return;
|
|
358
730
|
if (outcome.discarded) this.fail(job.id, `${member.name} 的这条任务在开始前被取消了。`);
|
|
359
|
-
else this.finish(job, member, outcome);
|
|
731
|
+
else if (this.finish(job, member, outcome)) settled = 'completed';
|
|
360
732
|
} finally {
|
|
733
|
+
progress.end(settled);
|
|
361
734
|
// A failed admission must not leave a watcher that claims a later, unrelated turn.
|
|
362
735
|
if (this.watching.get(sessionId)?.jobId === job.id) this.watching.delete(sessionId);
|
|
363
736
|
this.hub.setThinking(job.room_id, member.id, false);
|
|
@@ -368,33 +741,119 @@ export class DshMembers {
|
|
|
368
741
|
const text = finalText(events);
|
|
369
742
|
if (text === undefined) {
|
|
370
743
|
this.fail(job.id, `${member.name} 这一轮没有给出回复(${describeReason(reason)})`);
|
|
371
|
-
return;
|
|
744
|
+
return false;
|
|
372
745
|
}
|
|
373
746
|
const kind = reason?.kind;
|
|
374
747
|
const complete = kind === undefined || kind === 'completed' || kind === 'max-tokens';
|
|
375
748
|
const { message } = this.hub.post(job.room_id, {
|
|
376
749
|
authorKind: 'role', author: member.id, name: member.name, clientKey: `dsh-job-${job.id}`,
|
|
377
|
-
text: complete ? text : `${text}\n\n(这一轮没有正常结束:${describeReason(reason)}
|
|
750
|
+
text: this.returnToLead(job, member, complete ? text : `${text}\n\n(这一轮没有正常结束:${describeReason(reason)})`),
|
|
378
751
|
});
|
|
379
752
|
this.store.finishJob(job.id, 'done', message.id);
|
|
753
|
+
return complete;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The primary role whose dispatch this job answers, or undefined. Same rule as
|
|
758
|
+
* bridge/platform/cloud-web/lead_return.go dispatchedBy: the lead's own message
|
|
759
|
+
* called this member, or a person's message @-s this member and the lead together.
|
|
760
|
+
*/
|
|
761
|
+
dispatchedBy(job, member) {
|
|
762
|
+
const lead = this.hub.primaryIn(job.room_id);
|
|
763
|
+
if (lead === undefined || lead.id === member.id) return undefined;
|
|
764
|
+
const trigger = this.store.message(job.trigger_id);
|
|
765
|
+
if (trigger === undefined) return undefined;
|
|
766
|
+
if (trigger.author_kind === 'role') return trigger.author === lead.id ? lead : undefined;
|
|
767
|
+
if (trigger.author_kind !== 'user') return undefined;
|
|
768
|
+
return mentions(trigger.text, this.hub.routableIn(job.room_id)).has(lead.id) ? lead : undefined;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Port of lead_return.go returnToLead: a member's final answer to the lead's
|
|
773
|
+
* dispatch starts with @lead even if the model forgot it, so the hand-back
|
|
774
|
+
* wakes the lead the same way a cloud-web bot's does.
|
|
775
|
+
*/
|
|
776
|
+
returnToLead(job, member, text) {
|
|
777
|
+
const lead = this.dispatchedBy(job, member);
|
|
778
|
+
if (lead === undefined || !text.trim() || mentions(text, this.hub.routableIn(job.room_id)).has(lead.id)) return text;
|
|
779
|
+
return `@${lead.name} ${text}`;
|
|
380
780
|
}
|
|
381
781
|
|
|
382
782
|
fail(jobId, notice, state = 'failed') {
|
|
383
783
|
if (this.closed) return;
|
|
384
784
|
const job = this.store.job(jobId);
|
|
385
785
|
if (job === undefined || !this.store.finishJob(jobId, state)) return;
|
|
786
|
+
this.hub.quota?.observe(job.member_id, job.room_id, { error: String(notice) });
|
|
386
787
|
const error = friendlyFailure(notice);
|
|
387
788
|
this.hub.setError(job.member_id, error);
|
|
388
789
|
this.hub.post(job.room_id, {
|
|
389
|
-
authorKind: 'system', author: 'system', name: 'system', text: error, clientKey: `dsh-job-${jobId}-${state}`,
|
|
390
|
-
card: JSON.stringify({
|
|
790
|
+
authorKind: 'system', author: 'system', name: 'system', text: error, clientKey: `dsh-job-${jobId}-${state}-${Date.now()}`,
|
|
791
|
+
card: JSON.stringify({
|
|
792
|
+
title: '成员出错',
|
|
793
|
+
state: 'failed',
|
|
794
|
+
action: 'configure-model',
|
|
795
|
+
job_id: jobId,
|
|
796
|
+
member_id: job.member_id,
|
|
797
|
+
trigger_id: job.trigger_id,
|
|
798
|
+
}),
|
|
391
799
|
});
|
|
392
800
|
}
|
|
393
801
|
|
|
394
|
-
/**
|
|
802
|
+
/**
|
|
803
|
+
* Live Agent for (member, room): loaded, resumed, or newly created and bound.
|
|
804
|
+
* A bound Session that was archived or is gone from session persistence is
|
|
805
|
+
* replaced by a new one (rebind), so the member keeps answering; its old
|
|
806
|
+
* memory stays in the old Session. Other resume errors propagate.
|
|
807
|
+
*/
|
|
395
808
|
async ensureAgent(member, room) {
|
|
396
809
|
const binding = this.store.binding(member.id, room.id);
|
|
397
|
-
if (binding
|
|
810
|
+
if (binding === undefined) return this.createAgent(member, room);
|
|
811
|
+
const agent = await this.boundAgent(binding.session_id, member);
|
|
812
|
+
return agent ?? this.createAgent(member, room);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
sessionTitle(room, member) {
|
|
816
|
+
const parent = room.kind === 'workspace' ? this.store.room(room.parent_id) : undefined;
|
|
817
|
+
return `#${parent?.name ?? room.name} · ${member.name}`;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Refresh persisted bindings too, including sessions not currently loaded by DSH. */
|
|
821
|
+
async refreshRoomTitles(roomId) {
|
|
822
|
+
const rooms = this.store.roomsView().filter(r => r.id === roomId || r.parent_id === roomId);
|
|
823
|
+
for (const room of rooms) {
|
|
824
|
+
for (const member of this.config.values()) {
|
|
825
|
+
if (member.cli !== undefined) continue;
|
|
826
|
+
const binding = this.store.binding(member.id, room.id);
|
|
827
|
+
if (binding === undefined) continue;
|
|
828
|
+
try {
|
|
829
|
+
const agent = await this.boundAgent(binding.session_id, member);
|
|
830
|
+
if (agent !== undefined) await this.rt.ctx.sessionTitle.rename(agent.session, this.sessionTitle(room, member));
|
|
831
|
+
} catch (error) {
|
|
832
|
+
this.rt.logger.warn('collab: could not refresh room %d session %s title: %s', room.id, binding.session_id, error?.stack ?? error);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** The bound Session's Agent, or undefined when the Session can no longer serve the channel. */
|
|
839
|
+
async boundAgent(sessionId, member) {
|
|
840
|
+
const { ctx } = this.rt;
|
|
841
|
+
const why = ctx.workspaceRegistry.archivedSessionIds?.includes(sessionId) ? 'archived'
|
|
842
|
+
: ctx.agents.get(sessionId) === undefined && await ctx.sessionPersistence.stat(sessionId) === undefined ? 'missing' : undefined;
|
|
843
|
+
// Any other resume error is a real failure: let it surface so the old
|
|
844
|
+
// Session (and the member's memory in it) is not silently replaced.
|
|
845
|
+
if (why === undefined) return this.liveAgent(sessionId, member);
|
|
846
|
+
this.rt.logger.info('collab: Session %s is %s, starting a new one', sessionId, why);
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Create a new Session for (member, room) and bind it, replacing any old
|
|
852
|
+
* binding. The Session carries origin "subagent" so DSH keeps it out of the
|
|
853
|
+
* conversation list: the plugin holds it, the user cannot archive it, and
|
|
854
|
+
* /new in the channel is the way to start over.
|
|
855
|
+
*/
|
|
856
|
+
async createAgent(member, room) {
|
|
398
857
|
const { ctx } = this.rt;
|
|
399
858
|
const preset = await ctx.agentPresets.resolve(member.agentPreset);
|
|
400
859
|
const scope = await ctx.agentPresets.acquireScope(preset.id);
|
|
@@ -402,10 +861,15 @@ export class DshMembers {
|
|
|
402
861
|
let handle;
|
|
403
862
|
let workspace;
|
|
404
863
|
try {
|
|
405
|
-
|
|
864
|
+
const path = this.store.workspacePath(room.id, this.rt.workspacePath);
|
|
865
|
+
try {
|
|
866
|
+
workspace = await ctx.workspaceRegistry.create(path);
|
|
867
|
+
} catch (error) {
|
|
868
|
+
throw new Error(`频道 #${room.name} 的工作目录 ${path} 无法打开:${error.message}`, { cause: error });
|
|
869
|
+
}
|
|
406
870
|
handle = await ctx.agents.create({
|
|
407
871
|
sessionId,
|
|
408
|
-
meta: { cwd: workspace.path, agentPreset: preset.id },
|
|
872
|
+
meta: { cwd: workspace.path, agentPreset: preset.id, origin: 'subagent' },
|
|
409
873
|
agentOptions: this.selection(member),
|
|
410
874
|
setup: async (agentCtx) => {
|
|
411
875
|
await ctx.agentPresets.mount(agentCtx, preset.id);
|
|
@@ -416,12 +880,62 @@ export class DshMembers {
|
|
|
416
880
|
await scope[Symbol.asyncDispose]();
|
|
417
881
|
}
|
|
418
882
|
await workspace.attachSession(sessionId);
|
|
883
|
+
// setup ran before the Agent existed, so record it here: liveAgent must not hook it again.
|
|
884
|
+
this.hooked.add(handle.agent);
|
|
419
885
|
if (member.permissionPreset !== undefined) ctx.permissionPresets.set(handle.agent.session, member.permissionPreset);
|
|
420
|
-
ctx.sessionTitle.rename(handle.agent.session,
|
|
421
|
-
this.store.bind(member.id, room.id, sessionId);
|
|
886
|
+
ctx.sessionTitle.rename(handle.agent.session, this.sessionTitle(this.store.room(room.id), member));
|
|
887
|
+
if (this.store.binding(member.id, room.id) === undefined) this.store.bind(member.id, room.id, sessionId);
|
|
888
|
+
else this.store.rebind(member.id, room.id, sessionId);
|
|
422
889
|
return handle.agent;
|
|
423
890
|
}
|
|
424
891
|
|
|
892
|
+
/**
|
|
893
|
+
* /new in the channel: each addressed member starts over in this room. A
|
|
894
|
+
* DSH member gets a new Session (the old one is dropped from the binding),
|
|
895
|
+
* a CLI member drops its ACP session for the room; both read on from the
|
|
896
|
+
* /new message, not from earlier history.
|
|
897
|
+
*/
|
|
898
|
+
async renew(memberId, msg) {
|
|
899
|
+
const member = this.config.get(memberId);
|
|
900
|
+
const room = this.store.room(msg.room_id);
|
|
901
|
+
if (member === undefined || room === undefined) return;
|
|
902
|
+
if (member.cli !== undefined) {
|
|
903
|
+
// Drop the live and the persisted ACP session and point the binding back at
|
|
904
|
+
// runCli's placeholder, so the next turn has no resumeId to session/load.
|
|
905
|
+
for (const entry of this.cliAgents.values()) {
|
|
906
|
+
if (entry.memberId !== member.id) continue;
|
|
907
|
+
entry.agent.sessions?.delete(String(room.id));
|
|
908
|
+
entry.agent.saved?.delete(String(room.id));
|
|
909
|
+
}
|
|
910
|
+
if (this.store.binding(member.id, room.id) !== undefined) this.store.rebind(member.id, room.id, `cli:${member.cli}:${member.id}:${room.id}`);
|
|
911
|
+
this.store.setBindingWorkspacePath(member.id, room.id, this.store.workspacePath(room.id, this.rt.workspacePath));
|
|
912
|
+
// runCli keys a CLI member's context records by (member, room) alone, so clear them here:
|
|
913
|
+
// the fresh ACP context gets its MEMORY.md, task board and wiki pages again.
|
|
914
|
+
this.gateContext.reset(member.id, room.id);
|
|
915
|
+
this.memoryIndex.reset(member.id, room.id);
|
|
916
|
+
} else await this.createAgent(member, room);
|
|
917
|
+
if (this.closed) return;
|
|
918
|
+
this.store.advanceCursor(member.id, room.id, msg.id);
|
|
919
|
+
this.hub.post(room.id, {
|
|
920
|
+
authorKind: 'system', author: 'system', name: 'system', clientKey: `dsh-new-${msg.id}-${member.id}`,
|
|
921
|
+
text: `✅ ${member.name} 的新会话已创建`,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Serialize owner-requested renewals with each member's existing channel work. */
|
|
926
|
+
async renewRoom(roomId) {
|
|
927
|
+
const msg = this.hub.post(roomId, {
|
|
928
|
+
authorKind: 'system', author: 'system', name: 'system', text: '正在按新的工作目录重建频道会话。',
|
|
929
|
+
}).message;
|
|
930
|
+
const ids = this.hub.routableIn(roomId).filter(m => this.config.has(m.id)).map(m => m.id);
|
|
931
|
+
const errors = [];
|
|
932
|
+
await Promise.all(ids.map(id => this.enqueue({ member_id: id, room_id: roomId }, async () => {
|
|
933
|
+
try { await this.renew(id, msg); } catch (error) { errors.push(`${this.config.get(id).name}:${error.message}`); }
|
|
934
|
+
})));
|
|
935
|
+
if (errors.length) throw new Error(`重建会话失败:${errors.join(';')}`);
|
|
936
|
+
return ids;
|
|
937
|
+
}
|
|
938
|
+
|
|
425
939
|
async liveAgent(sessionId, member) {
|
|
426
940
|
const { ctx } = this.rt;
|
|
427
941
|
const loaded = ctx.agents.get(sessionId);
|
|
@@ -431,10 +945,29 @@ export class DshMembers {
|
|
|
431
945
|
if (member !== undefined && !this.hooked.has(loaded)) this.setup(loaded.ctx, member, loaded);
|
|
432
946
|
return loaded;
|
|
433
947
|
}
|
|
434
|
-
return (await ctx.agents.resume({
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
948
|
+
if (member === undefined) return (await ctx.agents.resume({ resumeSessionId: sessionId })).agent;
|
|
949
|
+
// Resume assembles the Agent the same way createAgent does: mount the
|
|
950
|
+
// member's agent preset (its tool set) and apply its permission preset.
|
|
951
|
+
// Installing only the request hook left a resumed Session with just the
|
|
952
|
+
// built-in tools after a DSH restart.
|
|
953
|
+
const preset = await ctx.agentPresets.resolve(member.agentPreset);
|
|
954
|
+
const scope = await ctx.agentPresets.acquireScope(preset.id);
|
|
955
|
+
let handle;
|
|
956
|
+
try {
|
|
957
|
+
handle = await ctx.agents.resume({
|
|
958
|
+
resumeSessionId: sessionId,
|
|
959
|
+
agentOptions: this.selection(member),
|
|
960
|
+
setup: async (agentCtx) => {
|
|
961
|
+
await ctx.agentPresets.mount(agentCtx, preset.id);
|
|
962
|
+
this.setup(agentCtx, member);
|
|
963
|
+
},
|
|
964
|
+
});
|
|
965
|
+
} finally {
|
|
966
|
+
await scope[Symbol.asyncDispose]();
|
|
967
|
+
}
|
|
968
|
+
this.hooked.add(handle.agent);
|
|
969
|
+
if (member.permissionPreset !== undefined) ctx.permissionPresets.set(handle.agent.session, member.permissionPreset);
|
|
970
|
+
return handle.agent;
|
|
438
971
|
}
|
|
439
972
|
|
|
440
973
|
/** The member's model, or the host default at the time of the call (webhook pattern). */
|
|
@@ -522,7 +1055,8 @@ export class DshMembers {
|
|
|
522
1055
|
this.closed = true;
|
|
523
1056
|
for (const w of this.watching.values()) w.resolve(INTERRUPTED);
|
|
524
1057
|
this.watching.clear();
|
|
525
|
-
for (const
|
|
1058
|
+
for (const { agent } of this.cliAgents.values()) agent.close();
|
|
1059
|
+
this.cliAgents.clear();
|
|
526
1060
|
// A queue still waiting on the host (for example a resume during shutdown) must not block unload forever.
|
|
527
1061
|
let timer;
|
|
528
1062
|
const limit = new Promise((resolve) => { timer = setTimeout(resolve, CLOSE_TIMEOUT_MS); });
|
|
@@ -530,3 +1064,13 @@ export class DshMembers {
|
|
|
530
1064
|
clearTimeout(timer);
|
|
531
1065
|
}
|
|
532
1066
|
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* A row a member's turn reads: unread, not its own, no draft; system rows and cards only as the
|
|
1070
|
+
* trigger itself (a task reminder or pause record carries a task card and must still reach the member).
|
|
1071
|
+
*/
|
|
1072
|
+
function turnSees(m, job, binding, member) {
|
|
1073
|
+
const trigger = m.id === job.trigger_id;
|
|
1074
|
+
return (m.id > binding.context_cursor || trigger) && m.author !== member.id
|
|
1075
|
+
&& (m.author_kind !== 'system' || trigger) && (m.card === null || trigger) && !m.draft;
|
|
1076
|
+
}
|