@khorsheed/dsh-room 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/lib/client.js +2309 -1127
  3. package/lib/index.js +572 -36
  4. package/lib/typert.host.js +249 -65
  5. package/lib/typert.remote-client.d.ts +4 -1
  6. package/lib/typert.remote-client.js +208 -53
  7. package/lib/types/client/InviteDialog.js +11 -4
  8. package/lib/types/client/RoomAttachments.d.ts +20 -0
  9. package/lib/types/client/RoomAttachments.js +36 -0
  10. package/lib/types/client/RoomComposer.d.ts +6 -12
  11. package/lib/types/client/RoomComposer.js +116 -12
  12. package/lib/types/client/RoomQuestionView.d.ts +10 -2
  13. package/lib/types/client/RoomQuestionView.js +18 -5
  14. package/lib/types/client/RoomRunView.d.ts +1 -1
  15. package/lib/types/client/RoomRunView.js +15 -5
  16. package/lib/types/client/RoomSpeechView.d.ts +24 -1
  17. package/lib/types/client/RoomSpeechView.js +39 -3
  18. package/lib/types/client/RoomStatsLine.d.ts +3 -1
  19. package/lib/types/client/RoomStatsLine.js +3 -1
  20. package/lib/types/client/RoomTaskCapsule.d.ts +6 -1
  21. package/lib/types/client/RoomTaskCapsule.js +2 -2
  22. package/lib/types/client/TasksView.js +1 -1
  23. package/lib/types/client/TeamView.js +1 -1
  24. package/lib/types/client/icons.d.ts +2 -1
  25. package/lib/types/client/icons.js +1 -0
  26. package/lib/types/client/index.js +94 -15
  27. package/lib/types/client/locales.d.ts +44 -0
  28. package/lib/types/client/locales.js +44 -0
  29. package/lib/types/client/nodes.d.ts +22 -3
  30. package/lib/types/client/nodes.js +28 -5
  31. package/lib/types/client/path-mentions.d.ts +34 -0
  32. package/lib/types/client/path-mentions.js +39 -0
  33. package/lib/types/client/slots.d.ts +51 -1
  34. package/lib/types/dispatch.d.ts +60 -0
  35. package/lib/types/dispatch.js +136 -1
  36. package/lib/types/index.d.ts +69 -1
  37. package/lib/types/index.js +368 -20
  38. package/lib/types/jobs.js +7 -3
  39. package/lib/types/journal.d.ts +1 -1
  40. package/lib/types/journal.js +18 -2
  41. package/lib/types/types.d.ts +77 -0
  42. package/package.json +10 -6
  43. package/lib/journal-B4FOGO6S.js +0 -482
  44. package/lib/journal-BWL3-i0s.js +0 -462
  45. package/lib/journal-Rid9AAnD.js +0 -485
@@ -32,6 +32,58 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
32
32
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
33
  done = true;
34
34
  };
35
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
36
+ if (value !== null && value !== void 0) {
37
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
38
+ var dispose, inner;
39
+ if (async) {
40
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
41
+ dispose = value[Symbol.asyncDispose];
42
+ }
43
+ if (dispose === void 0) {
44
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
45
+ dispose = value[Symbol.dispose];
46
+ if (async) inner = dispose;
47
+ }
48
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
49
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
50
+ env.stack.push({ value: value, dispose: dispose, async: async });
51
+ }
52
+ else if (async) {
53
+ env.stack.push({ async: true });
54
+ }
55
+ return value;
56
+ };
57
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
58
+ return function (env) {
59
+ function fail(e) {
60
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
61
+ env.hasError = true;
62
+ }
63
+ var r, s = 0;
64
+ function next() {
65
+ while (r = env.stack.pop()) {
66
+ try {
67
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
68
+ if (r.dispose) {
69
+ var result = r.dispose.call(r.value);
70
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
71
+ }
72
+ else s |= 1;
73
+ }
74
+ catch (e) {
75
+ fail(e);
76
+ }
77
+ }
78
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
79
+ if (env.hasError) throw env.error;
80
+ }
81
+ return next();
82
+ };
83
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
84
+ var e = new Error(message);
85
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
86
+ });
35
87
  var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
36
88
  if (typeof path === "string" && /^\.\.?\//.test(path)) {
37
89
  return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
@@ -87,7 +139,7 @@ import { composeRoomAgent, deriveSessionPreset, probeAgentPresets, inspectCold,
87
139
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
88
140
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
89
141
  import { probeLocalAgent, probeLocalAgentRoster } from './adapter.js';
90
- import { DispatchEngine, heldReportsText, isRoomSource, roomSource, runChildSessionId, runChildTurn } from './dispatch.js';
142
+ import { BEFORE_TAIL, DispatchEngine, MERGED_BACKLOG_TEXT, roundTextSoFar, MOVED_ON_TEXT, floatRunningCards, heldReportsText, isRoomSource, roomSource, runChildSessionId, runChildTurn } from './dispatch.js';
91
143
  import { coordinatorMember, DEFAULT_ROOM_SETTINGS, memberId, isRoomLog, MAIN_AGENT_MEMBER, parseMentions, replay, ROOM_EVENT_TYPES, ROOM_ROLE_MAX } from './journal.js';
92
144
  import { messageId, parseMessageRef, releaseIntent, SELF_TASK, sendOrHold, workingHop } from './messaging.js';
93
145
  import { isHumanMessage, linkedTask, taskLinks } from './deliveries.js';
@@ -155,6 +207,43 @@ export function parseQuestions(raw) {
155
207
  return 'Question ids must be unique';
156
208
  return questions;
157
209
  }
210
+ /**
211
+ * The message a late answer reaches its member as: each question with the
212
+ * human's pick, so the member can go on (or correct what it assumed).
213
+ * @param questions - the card's questions.
214
+ * @param answers - the human's answers, one per question.
215
+ * @returns the dispatch text.
216
+ */
217
+ export function lateAnswerText(questions, answers) {
218
+ const lines = questions.map((question) => {
219
+ const answer = answers.find(entry => entry.id === question.id);
220
+ const picked = [...answer?.selected ?? [], ...answer?.custom === undefined ? [] : [answer.custom]].join(';');
221
+ return `- ${question.header === undefined ? '' : `${question.header}:`}${question.question} → ${picked === '' ? '(没选)' : picked}`;
222
+ });
223
+ return [
224
+ '你之前用 ask_user_question 问的问题没等到回答就过期了,人现在补答了:',
225
+ ...lines,
226
+ '',
227
+ '按这些答案继续。如果你已经按自己的假设往下做了,对照答案调整,并说一下改了什么。',
228
+ ].join('\n');
229
+ }
230
+ /**
231
+ * The dispatch text with the message's attachments appended as local paths:
232
+ * every member — CLI or native — opens them with its own read tool, so one
233
+ * form reaches all of them.
234
+ * @param text - the dispatched text.
235
+ * @param attachments - the admitted attachments.
236
+ * @returns the text members receive.
237
+ */
238
+ export function withAttachmentPaths(text, attachments) {
239
+ if (attachments === undefined || attachments.length === 0)
240
+ return text;
241
+ const lines = attachments.map((entry) => {
242
+ const label = `${entry.kind === 'image' ? '图片' : '文件'}${entry.name === undefined ? '' : ` ${entry.name}`}`;
243
+ return `- ${label}:${entry.path ?? '(宿主没有给出本地路径,打不开就告诉人)'}`;
244
+ });
245
+ return [text, '', '人随消息附了这些附件,用你自己的读取工具打开(图片用能看图的读取工具):', ...lines].join('\n');
246
+ }
158
247
  function messageReceipt(receipt, kind) {
159
248
  if (receipt.held !== undefined) {
160
249
  const why = receipt.held === 'paused' ? '房间已暂停,恢复后自动投递'
@@ -205,6 +294,7 @@ export function handoffText(_events, state, sessionId, from, to) {
205
294
  let RoomService = (() => {
206
295
  let _classSuper = TypertRemoteService;
207
296
  let _instanceExtraInitializers = [];
297
+ let _pendingCards_decorators;
208
298
  let _isRoom_decorators;
209
299
  let _listProviders_decorators;
210
300
  let _setTaskHint_decorators;
@@ -234,6 +324,7 @@ let RoomService = (() => {
234
324
  return class RoomService extends _classSuper {
235
325
  static {
236
326
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
327
+ _pendingCards_decorators = [Remote('pendingCards')];
237
328
  _isRoom_decorators = [Remote('isRoom')];
238
329
  _listProviders_decorators = [Remote('listProviders')];
239
330
  _setTaskHint_decorators = [Remote('setTaskHint')];
@@ -260,6 +351,7 @@ let RoomService = (() => {
260
351
  _setGoal_decorators = [Remote('setGoal')];
261
352
  _cancel_decorators = [Remote('cancel')];
262
353
  _cancelChild_decorators = [Remote('cancelChild')];
354
+ __esDecorate(this, null, _pendingCards_decorators, { kind: "method", name: "pendingCards", static: false, private: false, access: { has: obj => "pendingCards" in obj, get: obj => obj.pendingCards }, metadata: _metadata }, null, _instanceExtraInitializers);
263
355
  __esDecorate(this, null, _isRoom_decorators, { kind: "method", name: "isRoom", static: false, private: false, access: { has: obj => "isRoom" in obj, get: obj => obj.isRoom }, metadata: _metadata }, null, _instanceExtraInitializers);
264
356
  __esDecorate(this, null, _listProviders_decorators, { kind: "method", name: "listProviders", static: false, private: false, access: { has: obj => "listProviders" in obj, get: obj => obj.listProviders }, metadata: _metadata }, null, _instanceExtraInitializers);
265
357
  __esDecorate(this, null, _setTaskHint_decorators, { kind: "method", name: "setTaskHint", static: false, private: false, access: { has: obj => "setTaskHint" in obj, get: obj => obj.setTaskHint }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -421,6 +513,15 @@ let RoomService = (() => {
421
513
  }
422
514
  return { kind: 'enter', messages: [] };
423
515
  });
516
+ // The session list's status (the host shows a session running only while
517
+ // its own agent runs): a room is running while any member runs, and its
518
+ // waiting question/approval cards are tracked for the browser.
519
+ ctx.on('session/event', (session, event) => { this.observeActivity(session, event); });
520
+ ctx.on('agent/status', ({ agent, status }) => {
521
+ // The room's own agent settling must not hide members still at work.
522
+ if (status !== 'running' && (this.activeRuns.get(String(agent.id))?.size ?? 0) > 0)
523
+ this.emitStatus(String(agent.id), true);
524
+ });
424
525
  // Cross-session help (help.ts): a help message reaching a helper's turn
425
526
  // equips that agent with the helper tools and marks the thread running;
426
527
  // an agent whose log already holds help requests (a restart dropped its
@@ -541,6 +642,95 @@ let RoomService = (() => {
541
642
  * @param request - session identity.
542
643
  * @returns true when the session carries the marker (false for unknown sessions too).
543
644
  */
645
+ /** Run ids of the members working in each room (the session list's 运行中). */
646
+ activeRuns = new Map();
647
+ /** Question/approval cards waiting for the human, per room. */
648
+ waitingCards = new Map();
649
+ /**
650
+ * Follow a room's run and card edges as they are journaled: a room turns
651
+ * running when its first member starts and settles when its last one ends
652
+ * (the host's `api-session/status`, so the list shows 运行中 and then
653
+ * 已完成), and its waiting cards are kept for {@link pendingCards}.
654
+ * @param session - the session the event landed in.
655
+ * @param event - the journaled event.
656
+ */
657
+ observeActivity(session, event) {
658
+ const id = String(session.id);
659
+ switch (event.type) {
660
+ case 'room/run-state': {
661
+ const runs = this.activeRuns.get(id) ?? new Set();
662
+ const before = runs.size;
663
+ const key = event.data.runId ?? `${event.data.member}:${event.data.startedAt}`;
664
+ if (event.data.state === 'running')
665
+ runs.add(key);
666
+ else
667
+ runs.delete(key);
668
+ if (runs.size === 0)
669
+ this.activeRuns.delete(id);
670
+ else
671
+ this.activeRuns.set(id, runs);
672
+ // A member whose round ended is no longer waiting on its cards: its
673
+ // bridge call is gone (a CLI's own MCP timeout can end it early, and
674
+ // the round then goes on without the answer). Expire them, so an
675
+ // answer given now reaches the member as a new message.
676
+ if (event.data.state !== 'running')
677
+ this.expireCardsOf(id, event.data.member);
678
+ if (before === 0 && runs.size > 0)
679
+ this.emitStatus(id, true);
680
+ // The room's own agent still running keeps the row running.
681
+ else if (before > 0 && runs.size === 0 && this.ctx.agents.get(session.id)?.status !== 'running')
682
+ this.emitStatus(id, false);
683
+ return;
684
+ }
685
+ case 'room/question':
686
+ case 'room/approval': {
687
+ const cards = this.waitingCards.get(id) ?? new Map();
688
+ cards.set(event.data.id, { kind: event.type === 'room/question' ? 'question' : 'approval', member: event.data.member });
689
+ this.waitingCards.set(id, cards);
690
+ return;
691
+ }
692
+ case 'room/question-resolved':
693
+ case 'room/approval-resolved': {
694
+ const cards = this.waitingCards.get(id);
695
+ cards?.delete(event.data.id);
696
+ if (cards?.size === 0)
697
+ this.waitingCards.delete(id);
698
+ return;
699
+ }
700
+ default:
701
+ }
702
+ }
703
+ /**
704
+ * End the waits behind one member's open cards (its round settled): the
705
+ * question/approval calls resolve as unanswered, which journals them
706
+ * expired — still answerable, by a new message (answerExpiredQuestion).
707
+ * @param roomId - the room session.
708
+ * @param member - the member whose round ended.
709
+ */
710
+ expireCardsOf(roomId, member) {
711
+ for (const [cardId, card] of this.waitingCards.get(roomId) ?? []) {
712
+ if (card.member !== member)
713
+ continue;
714
+ if (card.kind === 'question')
715
+ this.questionWaiters.get(cardId)?.(undefined);
716
+ else
717
+ this.approvalWaiters.get(cardId)?.('cancelled');
718
+ }
719
+ }
720
+ /** Broadcast a room's running flip on the host's session-status channel. */
721
+ emitStatus(sessionId, running) {
722
+ this.ctx.emit('api-session/status', SessionId(sessionId), running);
723
+ }
724
+ /**
725
+ * The question/approval cards waiting for the human across live rooms —
726
+ * the browser publishes them as the session list's 等你处理 status.
727
+ * @param request - optionally one room only.
728
+ * @returns the waiting cards.
729
+ */
730
+ async pendingCards(request) {
731
+ const rooms = request.sessionId === undefined ? [...this.waitingCards.keys()] : [String(request.sessionId)];
732
+ return rooms.flatMap(sessionId => [...this.waitingCards.get(sessionId) ?? []].map(([id, card]) => ({ sessionId, id, kind: card.kind })));
733
+ }
544
734
  async isRoom(request) {
545
735
  const session = this.ctx.sessions.get(request.sessionId);
546
736
  if (session !== undefined)
@@ -800,14 +990,14 @@ let RoomService = (() => {
800
990
  return { ok: false, code: 'unknown-targets' };
801
991
  const body = parsed.targets.length > 0 ? parsed.text : text;
802
992
  const filed = await this.fileHumanMessage(session, edit.data.id, text, task);
803
- this.moveOnFromLostRounds(session, targets);
993
+ const carry = this.moveOnFromLostRounds(session, targets);
804
994
  const dispatch = session.append('room/dispatch', {
805
995
  id: requestId, targets, text: body, origin: 'human', task: filed.task,
806
996
  targetIds: targets.map(name => memberId(session.snapshotEvents(), current.members.find(member => member.name === name))),
807
997
  });
808
998
  await this.ctx.sessions.flush(session);
809
999
  for (const target of targets)
810
- this.engine.dispatch(session, target, body, { dispatchSeq: dispatch.seq });
1000
+ this.engine.dispatch(session, target, carry(target, body), { dispatchSeq: dispatch.seq });
811
1001
  return { ok: true };
812
1002
  }
813
1003
  /**
@@ -816,10 +1006,12 @@ let RoomService = (() => {
816
1006
  * message runs instead of queueing behind a reconciliation. Formal-goal
817
1007
  * attempts keep their explicit reconciliation.
818
1008
  */
819
- moveOnFromLostRounds(session, targets, why = '你发了新消息;被重启打断的这一轮按没做完处理') {
1009
+ moveOnFromLostRounds(session, targets, why = MOVED_ON_TEXT) {
820
1010
  const events = session.snapshotEvents();
821
1011
  const state = replay(events);
822
- const ids = new Set(state.members.filter(member => targets.includes(member.name)).map(member => memberId(events, member)));
1012
+ const members = state.members.filter(member => targets.includes(member.name));
1013
+ const ids = new Set(members.map(member => memberId(events, member)));
1014
+ const backlog = new Map();
823
1015
  for (const row of state.deliveries ?? []) {
824
1016
  if (row.status !== 'uncertain' || row.plan?.taskId !== undefined || !ids.has(row.memberId))
825
1017
  continue;
@@ -827,7 +1019,24 @@ let RoomService = (() => {
827
1019
  const run = state.runs.find(entry => entry.runId === row.id);
828
1020
  if (run !== undefined)
829
1021
  session.append('room/run-state', { member: run.member, runId: row.id, startedAt: run.startedAt, state: 'cancelled', ...this.terminalRunStateFields(session, state, run.member) });
1022
+ // The messages that queued behind the lost round never ran (the round
1023
+ // blocked them, the restart lost the queue): they ride along with this
1024
+ // message, one round, instead of replaying stale rounds one by one.
1025
+ const name = members.find(member => memberId(events, member) === row.memberId)?.name;
1026
+ if (name === undefined)
1027
+ continue;
1028
+ for (const queued of state.deliveries ?? []) {
1029
+ if (queued.status !== 'queued' || queued.memberId !== row.memberId || queued.dispatchSeq <= row.dispatchSeq || queued.plan?.taskId !== undefined)
1030
+ continue;
1031
+ session.append('room/delivery-state', { id: queued.id, dispatchSeq: queued.dispatchSeq, memberId: queued.memberId, state: 'done', text: MERGED_BACKLOG_TEXT });
1032
+ const from = queued.from === undefined ? undefined : state.members.find(member => memberId(events, member) === queued.from)?.name;
1033
+ backlog.set(name, [...backlog.get(name) ?? [], `- ${from === undefined ? '' : `来自 ${from}:`}${queued.text}`]);
1034
+ }
830
1035
  }
1036
+ return (member, text) => {
1037
+ const lines = backlog.get(member);
1038
+ return lines === undefined ? text : [text, '', '(你被重启打断期间,房间里还有这些消息排着没送到,一并给你:', ...lines, ')'].join('\n');
1039
+ };
831
1040
  }
832
1041
  /**
833
1042
  * Where a member's next send belongs: the task and delivery it is working
@@ -891,6 +1100,12 @@ let RoomService = (() => {
891
1100
  const loaded = await this.loadCold(request.sessionId);
892
1101
  if (!loaded.ok)
893
1102
  return { ok: false, error: loaded.error };
1103
+ // A held report's wake timer lives in the process that held it: after a
1104
+ // restart nothing would ever hand it over. A cold room holding one is
1105
+ // brought live, whose recovery hands every untimed hold over.
1106
+ if (this.ctx.sessions.get(request.sessionId) === undefined && (loaded.state.deliveries ?? []).some(row => row.status === 'held')) {
1107
+ void this.ensureLive(request.sessionId).catch((error) => { this.ctx.logger.warn(`room: reviving held reports failed: ${String(error)}`); });
1108
+ }
894
1109
  return { ok: true, value: { ...this.engine.view(request.sessionId, loaded.state), ...loaded.state.plan === undefined ? {} : { plan: this.plans.view(request.sessionId, loaded.state.plan) } } };
895
1110
  }
896
1111
  /**
@@ -1358,9 +1573,13 @@ let RoomService = (() => {
1358
1573
  const loaded = await this.ensureLive(request.sessionId);
1359
1574
  if (!loaded.ok)
1360
1575
  return { ok: false, error: loaded.error };
1361
- if (request.text.trim() === '')
1576
+ const attached = request.attachments ?? [];
1577
+ if (request.text.trim() === '' && attached.length === 0)
1362
1578
  return { ok: false, error: { code: 'empty-text' } };
1363
- const requestSignature = createHash('sha256').update(JSON.stringify({ text: request.text.trim(), targets: [...new Set(request.targets ?? [])].sort() })).digest('hex');
1579
+ const requestSignature = createHash('sha256').update(JSON.stringify({
1580
+ text: request.text.trim(), targets: [...new Set(request.targets ?? [])].sort(),
1581
+ ...attached.length === 0 ? {} : { attachments: attached.map(entry => entry.type === 'file' ? entry.receiptId : `${entry.mediaType}:${entry.data.length}`) },
1582
+ })).digest('hex');
1364
1583
  if (request.requestId !== undefined) {
1365
1584
  if (request.requestId.trim() === '' || request.requestId.length > 128)
1366
1585
  return { ok: false, error: { code: 'request-conflict' } };
@@ -1378,7 +1597,7 @@ let RoomService = (() => {
1378
1597
  for (const target of original.targetIds ?? []) {
1379
1598
  const member = replay(loaded.session.snapshotEvents()).members.find(member => memberId(loaded.session.snapshotEvents(), member) === target);
1380
1599
  if (member !== undefined)
1381
- this.engine.dispatch(loaded.session, member.name, original.text, { dispatchSeq: previous.seq, targetId: target });
1600
+ this.engine.dispatch(loaded.session, member.name, withAttachmentPaths(original.text, original.attachments), { dispatchSeq: previous.seq, targetId: target });
1382
1601
  }
1383
1602
  return { ok: true, value: { parsed: { targets: original.targets, text: original.text }, seq: previous.seq } };
1384
1603
  }
@@ -1401,9 +1620,10 @@ let RoomService = (() => {
1401
1620
  // The human pointing at a session outside the room is a help request: say
1402
1621
  // so every time (a member's briefing reaches it once, on its first round).
1403
1622
  const outside = [...said.matchAll(/@\[((?:\\.|[^\\\]\n])+)\]\((dsh-session:[^)\s]+)\)/gu)];
1404
- const text = said;
1623
+ // An attachment-only message still dispatches: its words are the attachments.
1624
+ const text = said === '' && attached.length > 0 ? '(见附件)' : said;
1405
1625
  // The hint rides the member's prompt only; the journal keeps the human's words.
1406
- const prompt = outside.length === 0 ? said : [
1626
+ const prompt = outside.length === 0 ? text : [
1407
1627
  said,
1408
1628
  '',
1409
1629
  `(人点名了房间外的会话:${outside.map(match => `「${match[1].replace(/\\(.)/gu, '$1')}」`).join('、')}。用 room_help 去问:session 参数原样传那个 @[…](dsh-session:…) 引用;标签是「成员(房间)」时会交给那个成员。不要转给别的成员代问。)`,
@@ -1414,23 +1634,103 @@ let RoomService = (() => {
1414
1634
  const unknown = targets.filter(target => !roster.has(target));
1415
1635
  if (unknown.length > 0)
1416
1636
  return { ok: false, error: { code: 'unknown-targets', names: unknown } };
1637
+ const admitted = await this.admitAttachments(request.sessionId, attached);
1638
+ if (!admitted.ok)
1639
+ return { ok: false, error: admitted.error };
1417
1640
  const human = createUserMessage({
1418
- content: [{ type: 'text', text: request.text.trim() }],
1641
+ content: [...admitted.blocks, ...request.text.trim() === '' ? [] : [{ type: 'text', text: request.text.trim() }]],
1419
1642
  source: { kind: 'user' },
1420
1643
  });
1421
- loaded.session.append('user/message', human, { surfaceOp: 'append' });
1644
+ {
1645
+ const env_1 = { stack: [], error: void 0, hasError: false };
1646
+ try {
1647
+ // Uploaded files are bound to this message once it is in the log (an unbound receipt is reclaimed).
1648
+ const binding = __addDisposableResource(env_1, admitted.bind(request.requestId ?? String(human.id)), false);
1649
+ loaded.session.append('user/message', human, { surfaceOp: 'append' });
1650
+ binding?.commit();
1651
+ }
1652
+ catch (e_1) {
1653
+ env_1.error = e_1;
1654
+ env_1.hasError = true;
1655
+ }
1656
+ finally {
1657
+ __disposeResources(env_1);
1658
+ }
1659
+ }
1660
+ floatRunningCards(loaded.session);
1422
1661
  const filed = await this.fileHumanMessage(loaded.session, human.id, request.text.trim(), request.task ?? this.consumeTaskHint(request.sessionId));
1423
- this.moveOnFromLostRounds(loaded.session, targets);
1662
+ const carry = this.moveOnFromLostRounds(loaded.session, targets);
1424
1663
  const dispatch = loaded.session.append('room/dispatch', {
1425
1664
  id: request.requestId ?? randomUUID(), requestSignature, targets, text, origin: 'human', task: filed.task,
1665
+ ...admitted.records.length === 0 ? {} : { attachments: admitted.records },
1426
1666
  targetIds: targets.map(name => memberId(loaded.session.snapshotEvents(), loaded.state.members.find(member => member.name === name))),
1427
1667
  });
1428
1668
  await this.ctx.sessions.flush(loaded.session);
1429
1669
  for (const target of targets) {
1430
- this.engine.dispatch(loaded.session, target, prompt, { dispatchSeq: dispatch.seq });
1670
+ this.engine.dispatch(loaded.session, target, carry(target, withAttachmentPaths(prompt, admitted.records)), { dispatchSeq: dispatch.seq });
1431
1671
  }
1432
1672
  return { ok: true, value: { parsed: { targets, text }, seq: dispatch.seq } };
1433
1673
  }
1674
+ /**
1675
+ * Admit a human message's composer attachments the way the host's own
1676
+ * prompt path does: file receipts resolve against this room session's
1677
+ * uploads, images are stored, and every attachment becomes a durable
1678
+ * content block for the message — plus the local path members read it by.
1679
+ * @param sessionId - the room session (its agent owns the uploads).
1680
+ * @param inputs - the wire attachments.
1681
+ * @returns the content blocks, the dispatch records, and the receipt binder; or a refusal.
1682
+ */
1683
+ async admitAttachments(sessionId, inputs) {
1684
+ if (inputs.length === 0)
1685
+ return { ok: true, blocks: [], records: [], bind: () => undefined };
1686
+ const probe = this.ctx.get;
1687
+ const services = {
1688
+ attachments: probe('attachments'),
1689
+ fileUploads: probe('fileUploads'),
1690
+ };
1691
+ const store = services.attachments;
1692
+ // The uploads are staged on the room session's agent; a cold room wakes it.
1693
+ let agent = this.ctx.agents.get(sessionId);
1694
+ if (agent === undefined) {
1695
+ const controller = this.ctx.get('sessionController');
1696
+ const resolved = await controller?.resolveAgent?.(sessionId).catch(() => undefined);
1697
+ if (resolved !== undefined && 'agent' in resolved)
1698
+ agent = resolved.agent;
1699
+ }
1700
+ if (store === undefined || agent === undefined)
1701
+ return { ok: false, error: { code: 'attachment-invalid', message: 'attachments are unavailable on this host' } };
1702
+ const receipts = [];
1703
+ const parts = [];
1704
+ for (const input of inputs) {
1705
+ if (input.type === 'image') {
1706
+ parts.push({ type: 'image', data: input.data, mediaType: input.mediaType, ...input.name === undefined ? {} : { name: input.name } });
1707
+ continue;
1708
+ }
1709
+ const ref = services.fileUploads?.resolve(agent, input.receiptId);
1710
+ if (ref === undefined)
1711
+ return { ok: false, error: { code: 'attachment-invalid', message: 'a file was not uploaded to this room' } };
1712
+ receipts.push(input.receiptId);
1713
+ parts.push({ type: 'file', attachment: ref });
1714
+ }
1715
+ let blocks;
1716
+ try {
1717
+ blocks = await store.admitPromptContent(parts);
1718
+ }
1719
+ catch (error) {
1720
+ return { ok: false, error: { code: 'attachment-invalid', message: error instanceof Error ? error.message : String(error) } };
1721
+ }
1722
+ const records = blocks.flatMap((block) => {
1723
+ if (block.type !== 'image' && block.type !== 'file')
1724
+ return [];
1725
+ const path = block.type === 'image' ? store.imageHostPath?.(block.attachment) : store.fileHostPath?.(block.attachment);
1726
+ return [{ kind: block.type, ...block.attachment?.name === undefined ? {} : { name: block.attachment.name }, ...path === undefined ? {} : { path } }];
1727
+ });
1728
+ const uploads = services.fileUploads;
1729
+ return {
1730
+ ok: true, blocks, records,
1731
+ bind: requestId => receipts.length === 0 || uploads === undefined ? undefined : uploads.bindPrompt(agent, receipts, requestId),
1732
+ };
1733
+ }
1434
1734
  /**
1435
1735
  * Send one message from a member to another (host-only — the `room_message`
1436
1736
  * tool and the bridge's `member_message`/`room_message` land here; the
@@ -1634,7 +1934,7 @@ let RoomService = (() => {
1634
1934
  id: 'help', header: '求助',
1635
1935
  question: `${asker.name} 想向会话「${target.title}」求助:${text.length > 80 ? `${text.slice(0, 80)}…` : text}`,
1636
1936
  options: [{ label: '允许' }, { label: '不允许' }],
1637
- }], 10 * 60_000);
1937
+ }], 10 * 60_000, true);
1638
1938
  if (answers?.[0]?.selected[0] !== '允许')
1639
1939
  return `The human did not allow asking "${target.title}". Do not retry unless they @ that session in the room.`;
1640
1940
  }
@@ -1673,7 +1973,7 @@ let RoomService = (() => {
1673
1973
  // A round a restart left uncertain blocks the member's later work until a
1674
1974
  // human reconciles it — silently, for a request from outside. Move on
1675
1975
  // from it, as a human message to that member does.
1676
- this.moveOnFromLostRounds(other.session, [member.name], '另一个协作间来求助;被重启打断的这一轮按没做完处理');
1976
+ const carry = this.moveOnFromLostRounds(other.session, [member.name], '另一个协作间来求助;被重启打断的这一轮按没做完处理');
1677
1977
  const sent = sendOrHold(other.session, {
1678
1978
  to: member, text, fromName: `${help.fromName}(${origin.roomTitle})`,
1679
1979
  intent: { origin: 'help', hop: 1, kind: 'task', help: origin, ...op === 'request' ? { task: SELF_TASK } : {} },
@@ -1683,7 +1983,7 @@ let RoomService = (() => {
1683
1983
  room.append('room/help-state', { help: help.id, state: sent.delivered ? 'running' : 'queued' });
1684
1984
  await this.ctx.sessions.flush(room);
1685
1985
  if (sent.delivered)
1686
- this.engine.dispatch(other.session, member.name, text, { dispatchSeq: sent.seq });
1986
+ this.engine.dispatch(other.session, member.name, carry(member.name, text), { dispatchSeq: sent.seq });
1687
1987
  const who = `${member.name} in the room "${help.target.title}"`;
1688
1988
  return `${sent.delivered ? `Sent to ${who}.` : `Sent to ${who}; that room is paused, so the request waits until it resumes.`}`
1689
1989
  + ` Help id: ${help.id}. The reply reaches you on its own — end your turn now; do not poll.`;
@@ -2003,13 +2303,23 @@ let RoomService = (() => {
2003
2303
  * @param timeoutMs - how long the call waits before it gives up.
2004
2304
  * @returns the answers, or undefined when nobody answered.
2005
2305
  */
2006
- async askQuestions(session, member, questions, timeoutMs) {
2306
+ async askQuestions(session, member, questions, timeoutMs, consent = false) {
2007
2307
  const id = randomUUID();
2008
2308
  const answered = new Promise((resolve) => {
2009
2309
  this.questionWaiters.set(id, resolve);
2010
2310
  setTimeout(() => { resolve(undefined); }, timeoutMs).unref?.();
2011
2311
  });
2012
- session.append('room/question', { id, member: member.name, memberId: memberId(session.snapshotEvents(), member), questions });
2312
+ // Place the question inside the asker's round: its run, and the tail of
2313
+ // what the round had said so far (the reply splits there when it lands).
2314
+ const run = replay(session.snapshotEvents()).runs.find(entry => entry.member === member.name && entry.state === 'running');
2315
+ const child = member.childSessionId === undefined ? undefined : this.ctx.sessions.get(member.childSessionId);
2316
+ const before = child === undefined ? '' : roundTextSoFar(child.snapshotEvents()).slice(-BEFORE_TAIL);
2317
+ session.append('room/question', {
2318
+ id, member: member.name, memberId: memberId(session.snapshotEvents(), member), questions,
2319
+ ...consent ? { consent: true } : {},
2320
+ ...run?.runId === undefined ? {} : { runId: run.runId },
2321
+ ...before.trim() === '' ? {} : { before },
2322
+ });
2013
2323
  await this.ctx.sessions.flush(session);
2014
2324
  const answers = await answered;
2015
2325
  this.questionWaiters.delete(id);
@@ -2031,7 +2341,7 @@ let RoomService = (() => {
2031
2341
  const waiter = this.questionWaiters.get(request.id);
2032
2342
  const pending = loaded.state.questions?.find(entry => entry.id === request.id);
2033
2343
  if (waiter === undefined || pending === undefined)
2034
- return { ok: false, error: { code: 'question-not-pending' } };
2344
+ return this.answerExpiredQuestion(loaded.session, request);
2035
2345
  // Keep only answers to this card's own questions, in the shape the asker expects.
2036
2346
  const answers = pending.questions.map((question) => {
2037
2347
  const given = request.answers.find(answer => answer.id === question.id);
@@ -2043,6 +2353,44 @@ let RoomService = (() => {
2043
2353
  waiter(answers);
2044
2354
  return { ok: true };
2045
2355
  }
2356
+ /**
2357
+ * Answer a card whose waiting call is gone (it timed out, or a restart
2358
+ * ended the member's process): the card records the answers, and they
2359
+ * reach the member as a new message that wakes it with its own context —
2360
+ * the member asked so it could go on, so a late answer is still worth it.
2361
+ * A consent gate is never answered late: its call already decided.
2362
+ * @param session - the live room session.
2363
+ * @param request - card id and the human's answers.
2364
+ * @returns ok, or `question-not-pending` when the card is not an expired, unanswered question.
2365
+ */
2366
+ async answerExpiredQuestion(session, request) {
2367
+ const events = session.snapshotEvents();
2368
+ const asked = events.find(event => event.type === 'room/question' && event.data.id === request.id);
2369
+ if (asked?.type !== 'room/question' || asked.data.consent === true)
2370
+ return { ok: false, error: { code: 'question-not-pending' } };
2371
+ const outcomes = events.filter(event => event.type === 'room/question-resolved' && event.data.id === request.id);
2372
+ // Expired, or still open with no call behind it (a restart this room has not swept yet).
2373
+ const last = outcomes.at(-1);
2374
+ if (last !== undefined && (last.type !== 'room/question-resolved' || last.data.outcome !== 'cancelled'))
2375
+ return { ok: false, error: { code: 'question-not-pending' } };
2376
+ const member = replay(events).members.find(entry => memberId(events, entry) === asked.data.memberId);
2377
+ if (member === undefined)
2378
+ return { ok: false, error: { code: 'question-not-pending' } };
2379
+ const answers = asked.data.questions.map((question) => {
2380
+ const given = request.answers.find(answer => answer.id === question.id);
2381
+ const custom = given?.custom?.trim();
2382
+ return { id: question.id, selected: [...given?.selected ?? []].filter(label => typeof label === 'string'), ...custom === undefined || custom === '' ? {} : { custom } };
2383
+ });
2384
+ session.append('room/question-resolved', { id: request.id, outcome: 'answered', answers, late: true });
2385
+ const text = lateAnswerText(asked.data.questions, answers);
2386
+ const carry = this.moveOnFromLostRounds(session, [member.name]);
2387
+ const dispatch = session.append('room/dispatch', {
2388
+ id: randomUUID(), targets: [member.name], targetIds: [memberId(events, member)], text, origin: 'human',
2389
+ });
2390
+ await this.ctx.sessions.flush(session);
2391
+ this.engine.dispatch(session, member.name, carry(member.name, text), { dispatchSeq: dispatch.seq });
2392
+ return { ok: true };
2393
+ }
2046
2394
  /** Shared backend for external MCP room tools. Not exposed as a browser Remote. */
2047
2395
  async receiveMemberCommand(actor, command) {
2048
2396
  try {
package/lib/types/jobs.js CHANGED
@@ -22,8 +22,12 @@ export function replayJobs(events, deliveries) {
22
22
  }
23
23
  else if (event.type === 'room/job-stopped')
24
24
  stopped.add(event.data.task);
25
- else if (event.type === 'room/dispatch' && event.data.id !== undefined)
26
- dispatchById.set(event.data.id, { time: event.time, text: event.data.text });
25
+ else if (event.type === 'room/dispatch' && event.data.id !== undefined) {
26
+ // A task rooted in a member's dispatch (the coordinator delegating) is
27
+ // that member's words, not the human's.
28
+ const from = event.data.origin !== 'human' && event.data.from !== undefined ? event.data.from : undefined;
29
+ dispatchById.set(event.data.id, { time: event.time, text: event.data.text, ...from === undefined ? {} : { from } });
30
+ }
27
31
  }
28
32
  const byTask = new Map();
29
33
  for (const row of deliveries) {
@@ -55,7 +59,7 @@ export function replayJobs(events, deliveries) {
55
59
  id, number: index + 1,
56
60
  // Leading @-mentions address members; the request is what follows.
57
61
  title: (root.text.replace(/^(?:@\S+\s+)+/, '').split('\n', 1)[0] ?? '').slice(0, 120),
58
- messages: [{ id, time: root.time, text: root.text }, ...follow].sort((a, b) => a.time - b.time),
62
+ messages: [{ id, time: root.time, text: root.text, ...root.from === undefined ? {} : { from: root.from } }, ...follow].sort((a, b) => a.time - b.time),
59
63
  status, startedAt: root.time,
60
64
  ...open ? {} : { endedAt: ended },
61
65
  deliveries: rows.map(row => row.id),
@@ -20,7 +20,7 @@ export declare const ROOM_ROLE_MAX = 30;
20
20
  * in-place add is that surface's temporary form — migrate to the official
21
21
  * one when it lands upstream.
22
22
  */
23
- export declare const ROOM_EVENT_TYPES: readonly ['room/created', 'room/plan-state', 'room/coordinator', 'room/delivery-state', 'room/member-added', 'room/member-updated', 'room/member-removed', 'room/dispatch', 'room/speech', 'room/run-state', 'room/execution-metadata', 'room/relay', 'room/relay-resolved', 'room/approval', 'room/approval-resolved', 'room/question', 'room/question-resolved', 'room/task-added', 'room/task-updated', 'room/task-edited', 'room/goal', 'room/settings', 'room/report-gate', 'room/decision', 'room/task-link', 'room/job-stopped', 'room/help', 'room/help-message', 'room/help-state', 'room/help-in', 'room/help-link', 'room/help-unlink', 'room/help-nudge-dismissed'];
23
+ export declare const ROOM_EVENT_TYPES: readonly ['room/created', 'room/plan-state', 'room/coordinator', 'room/delivery-state', 'room/member-added', 'room/member-updated', 'room/member-removed', 'room/dispatch', 'room/speech', 'room/run-state', 'room/run-moved', 'room/execution-metadata', 'room/relay', 'room/relay-resolved', 'room/approval', 'room/approval-resolved', 'room/question', 'room/question-resolved', 'room/task-added', 'room/task-updated', 'room/task-edited', 'room/goal', 'room/settings', 'room/report-gate', 'room/decision', 'room/task-link', 'room/job-stopped', 'room/help', 'room/help-message', 'room/help-state', 'room/help-in', 'room/help-link', 'room/help-unlink', 'room/help-nudge-dismissed'];
24
24
  /** Collaboration settings of a room that never changed them. */
25
25
  export declare const DEFAULT_ROOM_SETTINGS: RoomSettings;
26
26
  /** Whether an event log carries the room identity marker. */