@khorsheed/dsh-room 0.2.4 → 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 +13 -0
  2. package/lib/client.js +2256 -1074
  3. package/lib/index.js +548 -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 +50 -0
  35. package/lib/types/dispatch.js +115 -1
  36. package/lib/types/index.d.ts +69 -1
  37. package/lib/types/index.js +362 -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
@@ -1364,9 +1573,13 @@ let RoomService = (() => {
1364
1573
  const loaded = await this.ensureLive(request.sessionId);
1365
1574
  if (!loaded.ok)
1366
1575
  return { ok: false, error: loaded.error };
1367
- if (request.text.trim() === '')
1576
+ const attached = request.attachments ?? [];
1577
+ if (request.text.trim() === '' && attached.length === 0)
1368
1578
  return { ok: false, error: { code: 'empty-text' } };
1369
- 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');
1370
1583
  if (request.requestId !== undefined) {
1371
1584
  if (request.requestId.trim() === '' || request.requestId.length > 128)
1372
1585
  return { ok: false, error: { code: 'request-conflict' } };
@@ -1384,7 +1597,7 @@ let RoomService = (() => {
1384
1597
  for (const target of original.targetIds ?? []) {
1385
1598
  const member = replay(loaded.session.snapshotEvents()).members.find(member => memberId(loaded.session.snapshotEvents(), member) === target);
1386
1599
  if (member !== undefined)
1387
- 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 });
1388
1601
  }
1389
1602
  return { ok: true, value: { parsed: { targets: original.targets, text: original.text }, seq: previous.seq } };
1390
1603
  }
@@ -1407,9 +1620,10 @@ let RoomService = (() => {
1407
1620
  // The human pointing at a session outside the room is a help request: say
1408
1621
  // so every time (a member's briefing reaches it once, on its first round).
1409
1622
  const outside = [...said.matchAll(/@\[((?:\\.|[^\\\]\n])+)\]\((dsh-session:[^)\s]+)\)/gu)];
1410
- const text = said;
1623
+ // An attachment-only message still dispatches: its words are the attachments.
1624
+ const text = said === '' && attached.length > 0 ? '(见附件)' : said;
1411
1625
  // The hint rides the member's prompt only; the journal keeps the human's words.
1412
- const prompt = outside.length === 0 ? said : [
1626
+ const prompt = outside.length === 0 ? text : [
1413
1627
  said,
1414
1628
  '',
1415
1629
  `(人点名了房间外的会话:${outside.map(match => `「${match[1].replace(/\\(.)/gu, '$1')}」`).join('、')}。用 room_help 去问:session 参数原样传那个 @[…](dsh-session:…) 引用;标签是「成员(房间)」时会交给那个成员。不要转给别的成员代问。)`,
@@ -1420,23 +1634,103 @@ let RoomService = (() => {
1420
1634
  const unknown = targets.filter(target => !roster.has(target));
1421
1635
  if (unknown.length > 0)
1422
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 };
1423
1640
  const human = createUserMessage({
1424
- content: [{ type: 'text', text: request.text.trim() }],
1641
+ content: [...admitted.blocks, ...request.text.trim() === '' ? [] : [{ type: 'text', text: request.text.trim() }]],
1425
1642
  source: { kind: 'user' },
1426
1643
  });
1427
- 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);
1428
1661
  const filed = await this.fileHumanMessage(loaded.session, human.id, request.text.trim(), request.task ?? this.consumeTaskHint(request.sessionId));
1429
- this.moveOnFromLostRounds(loaded.session, targets);
1662
+ const carry = this.moveOnFromLostRounds(loaded.session, targets);
1430
1663
  const dispatch = loaded.session.append('room/dispatch', {
1431
1664
  id: request.requestId ?? randomUUID(), requestSignature, targets, text, origin: 'human', task: filed.task,
1665
+ ...admitted.records.length === 0 ? {} : { attachments: admitted.records },
1432
1666
  targetIds: targets.map(name => memberId(loaded.session.snapshotEvents(), loaded.state.members.find(member => member.name === name))),
1433
1667
  });
1434
1668
  await this.ctx.sessions.flush(loaded.session);
1435
1669
  for (const target of targets) {
1436
- 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 });
1437
1671
  }
1438
1672
  return { ok: true, value: { parsed: { targets, text }, seq: dispatch.seq } };
1439
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
+ }
1440
1734
  /**
1441
1735
  * Send one message from a member to another (host-only — the `room_message`
1442
1736
  * tool and the bridge's `member_message`/`room_message` land here; the
@@ -1640,7 +1934,7 @@ let RoomService = (() => {
1640
1934
  id: 'help', header: '求助',
1641
1935
  question: `${asker.name} 想向会话「${target.title}」求助:${text.length > 80 ? `${text.slice(0, 80)}…` : text}`,
1642
1936
  options: [{ label: '允许' }, { label: '不允许' }],
1643
- }], 10 * 60_000);
1937
+ }], 10 * 60_000, true);
1644
1938
  if (answers?.[0]?.selected[0] !== '允许')
1645
1939
  return `The human did not allow asking "${target.title}". Do not retry unless they @ that session in the room.`;
1646
1940
  }
@@ -1679,7 +1973,7 @@ let RoomService = (() => {
1679
1973
  // A round a restart left uncertain blocks the member's later work until a
1680
1974
  // human reconciles it — silently, for a request from outside. Move on
1681
1975
  // from it, as a human message to that member does.
1682
- this.moveOnFromLostRounds(other.session, [member.name], '另一个协作间来求助;被重启打断的这一轮按没做完处理');
1976
+ const carry = this.moveOnFromLostRounds(other.session, [member.name], '另一个协作间来求助;被重启打断的这一轮按没做完处理');
1683
1977
  const sent = sendOrHold(other.session, {
1684
1978
  to: member, text, fromName: `${help.fromName}(${origin.roomTitle})`,
1685
1979
  intent: { origin: 'help', hop: 1, kind: 'task', help: origin, ...op === 'request' ? { task: SELF_TASK } : {} },
@@ -1689,7 +1983,7 @@ let RoomService = (() => {
1689
1983
  room.append('room/help-state', { help: help.id, state: sent.delivered ? 'running' : 'queued' });
1690
1984
  await this.ctx.sessions.flush(room);
1691
1985
  if (sent.delivered)
1692
- 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 });
1693
1987
  const who = `${member.name} in the room "${help.target.title}"`;
1694
1988
  return `${sent.delivered ? `Sent to ${who}.` : `Sent to ${who}; that room is paused, so the request waits until it resumes.`}`
1695
1989
  + ` Help id: ${help.id}. The reply reaches you on its own — end your turn now; do not poll.`;
@@ -2009,13 +2303,23 @@ let RoomService = (() => {
2009
2303
  * @param timeoutMs - how long the call waits before it gives up.
2010
2304
  * @returns the answers, or undefined when nobody answered.
2011
2305
  */
2012
- async askQuestions(session, member, questions, timeoutMs) {
2306
+ async askQuestions(session, member, questions, timeoutMs, consent = false) {
2013
2307
  const id = randomUUID();
2014
2308
  const answered = new Promise((resolve) => {
2015
2309
  this.questionWaiters.set(id, resolve);
2016
2310
  setTimeout(() => { resolve(undefined); }, timeoutMs).unref?.();
2017
2311
  });
2018
- 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
+ });
2019
2323
  await this.ctx.sessions.flush(session);
2020
2324
  const answers = await answered;
2021
2325
  this.questionWaiters.delete(id);
@@ -2037,7 +2341,7 @@ let RoomService = (() => {
2037
2341
  const waiter = this.questionWaiters.get(request.id);
2038
2342
  const pending = loaded.state.questions?.find(entry => entry.id === request.id);
2039
2343
  if (waiter === undefined || pending === undefined)
2040
- return { ok: false, error: { code: 'question-not-pending' } };
2344
+ return this.answerExpiredQuestion(loaded.session, request);
2041
2345
  // Keep only answers to this card's own questions, in the shape the asker expects.
2042
2346
  const answers = pending.questions.map((question) => {
2043
2347
  const given = request.answers.find(answer => answer.id === question.id);
@@ -2049,6 +2353,44 @@ let RoomService = (() => {
2049
2353
  waiter(answers);
2050
2354
  return { ok: true };
2051
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
+ }
2052
2394
  /** Shared backend for external MCP room tools. Not exposed as a browser Remote. */
2053
2395
  async receiveMemberCommand(actor, command) {
2054
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. */
@@ -44,6 +44,7 @@ export const ROOM_EVENT_TYPES = [
44
44
  'room/dispatch',
45
45
  'room/speech',
46
46
  'room/run-state',
47
+ 'room/run-moved',
47
48
  'room/execution-metadata',
48
49
  'room/relay',
49
50
  'room/relay-resolved',
@@ -117,6 +118,7 @@ export function replay(events) {
117
118
  const relays = [];
118
119
  const approvals = new Map();
119
120
  const questions = new Map();
121
+ const asks = new Map();
120
122
  const relayById = new Map();
121
123
  const tasks = [];
122
124
  const taskById = new Map();
@@ -269,10 +271,24 @@ export function replay(events) {
269
271
  break;
270
272
  case 'room/question':
271
273
  questions.set(event.data.id, { id: event.data.id, member: event.data.member, questions: event.data.questions, time: event.time });
274
+ asks.set(event.data.id, {
275
+ id: event.data.id, member: event.data.member, questions: event.data.questions, state: 'pending', time: event.time,
276
+ ...event.data.runId === undefined ? {} : { runId: event.data.runId },
277
+ ...event.data.consent === true ? { consent: true } : {},
278
+ });
272
279
  break;
273
- case 'room/question-resolved':
280
+ case 'room/question-resolved': {
274
281
  questions.delete(event.data.id);
282
+ const ask = asks.get(event.data.id);
283
+ if (ask !== undefined) {
284
+ asks.set(ask.id, {
285
+ ...ask, state: event.data.outcome,
286
+ ...event.data.answers === undefined ? {} : { answers: event.data.answers },
287
+ ...event.data.late === true ? { late: true } : {},
288
+ });
289
+ }
275
290
  break;
291
+ }
276
292
  case 'room/approval-resolved':
277
293
  approvals.delete(event.data.id);
278
294
  break;
@@ -395,7 +411,7 @@ export function replay(events) {
395
411
  return {
396
412
  ...helps.length === 0 ? {} : { helps },
397
413
  ...links.linked.length === 0 ? {} : { linked: links.linked },
398
- ...links.nudgesDismissed.length === 0 ? {} : { helpNudgesDismissed: links.nudgesDismissed }, members, relays, ...approvals.size === 0 ? {} : { approvals: [...approvals.values()] }, ...questions.size === 0 ? {} : { questions: [...questions.values()] }, tasks, ...executions.size === 0 ? {} : { executions: [...executions.values()] }, ...plan === undefined ? {} : { plan }, ...deliveries.length === 0 ? {} : { deliveries }, runs: [...runs.values()], ...coordinator === undefined ? {} : { coordinator }, ...goal === undefined ? {} : { goal }, ...latestReplySeq === undefined ? {} : { latestReplySeq }, ...latestSpeechSeq === undefined ? {} : { latestSpeechSeq }, settings, ...jobs.length === 0 ? {} : { jobs }
414
+ ...links.nudgesDismissed.length === 0 ? {} : { helpNudgesDismissed: links.nudgesDismissed }, members, relays, ...approvals.size === 0 ? {} : { approvals: [...approvals.values()] }, ...questions.size === 0 ? {} : { questions: [...questions.values()] }, ...asks.size === 0 ? {} : { askLog: [...asks.values()] }, tasks, ...executions.size === 0 ? {} : { executions: [...executions.values()] }, ...plan === undefined ? {} : { plan }, ...deliveries.length === 0 ? {} : { deliveries }, runs: [...runs.values()], ...coordinator === undefined ? {} : { coordinator }, ...goal === undefined ? {} : { goal }, ...latestReplySeq === undefined ? {} : { latestReplySeq }, ...latestSpeechSeq === undefined ? {} : { latestSpeechSeq }, settings, ...jobs.length === 0 ? {} : { jobs }
399
415
  };
400
416
  }
401
417
  /**