bothouse-dsh 0.1.0 → 0.1.40

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.
@@ -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
- teamModeRules(mode),
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.cliAgents.get(m.id)?.cli !== m.cli) this.dropCli(m.id);
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
- const hit = mentions(msg.text, this.hub.routable());
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.selectedPrimaryMember()?.id;
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 targets = [...this.config.keys()].filter(id => id !== msg.author && (!target || target === id)
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 (!this.config.has(memberId)) return false;
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.id > binding.context_cursor && m.author !== member.id && (m.author_kind !== 'system' || m.id === job.trigger_id) && m.card === null && !m.draft)
531
+ .filter(m => turnSees(m, job, binding, member))
270
532
  .slice(-CONTEXT_LIMIT);
271
- const input = this.rt.createUserMessage({ content: [{ type: 'text', text: promptText(room, member, unseen, memberStatus(this.hub), this.store.dispatchMode(room.id), this.store) }], source: { kind: 'user' } });
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
- const sessionId = `cli:${member.cli}`;
284
- if (this.store.binding(member.id, room.id) === undefined) this.store.bind(member.id, room.id, sessionId);
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.id > binding.context_cursor && m.author !== member.id && (m.author_kind !== 'system' || m.id === job.trigger_id) && m.card === null && !m.draft)
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
- let entry = this.cliAgents.get(member.id);
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: this.rt.workspacePath, persona: '' }) };
294
- this.cliAgents.set(member.id, entry);
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 text = (await entry.agent.ask(promptText(room, member, unseen, memberStatus(this.hub), this.store.dispatchMode(room.id), this.store), String(room.id), onText))?.trim();
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 committed = draft === undefined ? undefined : this.hub.commit(draft.id, 'role', member.id, text);
324
- const message = committed ?? this.hub.post(job.room_id, { authorKind: 'role', author: member.id, name: member.name, clientKey, text }).message;
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
- for (const { agent } of this.cliAgents.values()) agent.sessions?.delete(String(roomId));
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.get(id)?.agent.close();
342
- this.cliAgents.delete(id);
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({ title: '成员出错', state: 'failed', action: 'configure-model' }),
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
- /** Live Agent for (member, room): loaded, resumed, or newly created and bound. */
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 !== undefined) return this.liveAgent(binding.session_id, member);
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
- workspace = await ctx.workspaceRegistry.create(this.rt.workspacePath);
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, `#${room.name} · ${member.name}`);
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
- resumeSessionId: sessionId,
436
- ...(member === undefined ? {} : { agentOptions: this.selection(member), setup: s => this.setup(s, member) }),
437
- })).agent;
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 id of [...this.cliAgents.keys()]) this.dropCli(id);
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
+ }