@khorsheed/dsh-room 0.2.2 → 0.2.4

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 (109) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.en.md +29 -62
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +28 -61
  5. package/lib/client.js +14620 -5705
  6. package/lib/index.js +4069 -730
  7. package/lib/journal-B4FOGO6S.js +482 -0
  8. package/lib/journal-Rid9AAnD.js +485 -0
  9. package/lib/tool.js +112 -166
  10. package/lib/typert.host.js +2031 -374
  11. package/lib/typert.remote-client.d.ts +34 -10
  12. package/lib/typert.remote-client.js +1599 -210
  13. package/lib/types/agent-setup.d.ts +1 -1
  14. package/lib/types/client/InviteAgentAction.js +4 -16
  15. package/lib/types/client/InviteDialog.d.ts +28 -6
  16. package/lib/types/client/InviteDialog.js +76 -12
  17. package/lib/types/client/MemberCard.d.ts +5 -1
  18. package/lib/types/client/MemberCard.js +4 -4
  19. package/lib/types/client/MembersView.d.ts +1 -1
  20. package/lib/types/client/MembersView.js +58 -22
  21. package/lib/types/client/RemoveMemberConfirm.d.ts +40 -0
  22. package/lib/types/client/RemoveMemberConfirm.js +85 -0
  23. package/lib/types/client/RoomApprovalView.d.ts +11 -0
  24. package/lib/types/client/RoomApprovalView.js +28 -0
  25. package/lib/types/client/RoomComposer.d.ts +1 -1
  26. package/lib/types/client/RoomComposer.js +195 -29
  27. package/lib/types/client/RoomDecisionView.d.ts +22 -0
  28. package/lib/types/client/RoomDecisionView.js +35 -0
  29. package/lib/types/client/RoomHelpView.d.ts +17 -0
  30. package/lib/types/client/RoomHelpView.js +56 -0
  31. package/lib/types/client/RoomInboxView.d.ts +10 -0
  32. package/lib/types/client/RoomInboxView.js +14 -0
  33. package/lib/types/client/RoomQuestionView.d.ts +13 -0
  34. package/lib/types/client/RoomQuestionView.js +61 -0
  35. package/lib/types/client/RoomRelayView.js +1 -1
  36. package/lib/types/client/RoomRunView.d.ts +3 -1
  37. package/lib/types/client/RoomRunView.js +12 -5
  38. package/lib/types/client/RoomSettingsView.d.ts +19 -0
  39. package/lib/types/client/RoomSettingsView.js +30 -0
  40. package/lib/types/client/RoomSpeechView.d.ts +22 -8
  41. package/lib/types/client/RoomSpeechView.js +94 -11
  42. package/lib/types/client/RoomStatsLine.d.ts +26 -5
  43. package/lib/types/client/RoomStatsLine.js +53 -7
  44. package/lib/types/client/RoomTaskCapsule.d.ts +19 -0
  45. package/lib/types/client/RoomTaskCapsule.js +37 -0
  46. package/lib/types/client/TasksTab.d.ts +41 -0
  47. package/lib/types/client/TasksTab.js +43 -0
  48. package/lib/types/client/TasksView.d.ts +44 -0
  49. package/lib/types/client/TasksView.js +149 -0
  50. package/lib/types/client/TeamView.d.ts +33 -0
  51. package/lib/types/client/TeamView.js +210 -0
  52. package/lib/types/client/at-menu.d.ts +127 -0
  53. package/lib/types/client/at-menu.js +173 -0
  54. package/lib/types/client/draft-chips.d.ts +67 -0
  55. package/lib/types/client/draft-chips.js +95 -0
  56. package/lib/types/client/executions.d.ts +0 -30
  57. package/lib/types/client/executions.js +4 -34
  58. package/lib/types/client/icons-local.d.ts +25 -0
  59. package/lib/types/client/icons-local.js +10 -0
  60. package/lib/types/client/icons.d.ts +2 -1
  61. package/lib/types/client/icons.js +1 -0
  62. package/lib/types/client/index.js +276 -72
  63. package/lib/types/client/locales.d.ts +482 -52
  64. package/lib/types/client/locales.js +490 -60
  65. package/lib/types/client/nodes.d.ts +112 -17
  66. package/lib/types/client/nodes.js +197 -26
  67. package/lib/types/client/slots.d.ts +117 -47
  68. package/lib/types/client/task-tag.d.ts +32 -0
  69. package/lib/types/client/task-tag.js +33 -0
  70. package/lib/types/client/tasks.d.ts +50 -0
  71. package/lib/types/client/tasks.js +82 -0
  72. package/lib/types/client/team.d.ts +73 -0
  73. package/lib/types/client/team.js +187 -0
  74. package/lib/types/decisions.d.ts +49 -0
  75. package/lib/types/decisions.js +101 -0
  76. package/lib/types/deliveries.d.ts +17 -1
  77. package/lib/types/deliveries.js +82 -6
  78. package/lib/types/dispatch.d.ts +102 -4
  79. package/lib/types/dispatch.js +498 -109
  80. package/lib/types/help.d.ts +137 -0
  81. package/lib/types/help.js +216 -0
  82. package/lib/types/helps.d.ts +26 -0
  83. package/lib/types/helps.js +47 -0
  84. package/lib/types/index.d.ts +292 -52
  85. package/lib/types/index.js +1421 -199
  86. package/lib/types/jobs.d.ts +22 -0
  87. package/lib/types/jobs.js +77 -0
  88. package/lib/types/journal.d.ts +17 -27
  89. package/lib/types/journal.js +120 -43
  90. package/lib/types/messaging.d.ts +65 -0
  91. package/lib/types/messaging.js +160 -0
  92. package/lib/types/plan-service.js +7 -1
  93. package/lib/types/report-gate.d.ts +81 -0
  94. package/lib/types/report-gate.js +89 -0
  95. package/lib/types/tool-definition.d.ts +43 -20
  96. package/lib/types/tool-definition.js +145 -212
  97. package/lib/types/tool.d.ts +2 -2
  98. package/lib/types/tool.js +3 -2
  99. package/lib/types/types.d.ts +755 -78
  100. package/locale/zh.json +1 -1
  101. package/package.json +3 -3
  102. package/lib/types/client/RoomActivityDock.d.ts +0 -19
  103. package/lib/types/client/RoomActivityDock.js +0 -105
  104. package/lib/types/client/RoomDockCapsules.d.ts +0 -64
  105. package/lib/types/client/RoomDockCapsules.js +0 -277
  106. package/lib/types/client/RoomPlanTab.d.ts +0 -15
  107. package/lib/types/client/RoomPlanTab.js +0 -13
  108. package/lib/types/client/RoomTaskLineView.d.ts +0 -13
  109. package/lib/types/client/RoomTaskLineView.js +0 -21
@@ -42,7 +42,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
42
42
  };
43
43
  /**
44
44
  * room host half: the `room` Typert Remote service. (The `room_invite` /
45
- * `room_task` / `room_message` model tools left this package's profile-root
45
+ * `room_message` / `room_read` / `room_plan` model tools left this package's profile-root
46
46
  * registration in the M4' tool-row split — their definition factories live
47
47
  * in ./tool-definition.ts and the package's own `./tool` composition entry
48
48
  * mounts them inside agent-preset compositions.) The room's entire state is the session's `room/*` custom-event
@@ -83,25 +83,121 @@ const catalogModule = await import(__rewriteRelativeImportExtension(catalogSpeci
83
83
  .catch(() => import('@deepseek-ai/dsh-session'));
84
84
  for (const type of ROOM_EVENT_TYPES)
85
85
  catalogModule.KNOWN_SESSION_EVENT_TYPES.add(type);
86
- import { composeRoomAgent, deriveSessionPreset, inspectCold, preloadPresetDerivationHost, presetDerivationHost, roomSessionPreset } from './agent-setup.js';
86
+ import { composeRoomAgent, deriveSessionPreset, probeAgentPresets, inspectCold, preloadPresetDerivationHost, presetDerivationHost, roomSessionPreset } from './agent-setup.js';
87
87
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
88
88
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
89
89
  import { probeLocalAgent, probeLocalAgentRoster } from './adapter.js';
90
- import { DispatchEngine, isRoomSource } from './dispatch.js';
91
- import { coordinatorMember, memberId, isRoomLog, MAIN_AGENT_MEMBER, parseMentions, replay, ROOM_EVENT_TYPES } from './journal.js';
90
+ import { DispatchEngine, heldReportsText, isRoomSource, roomSource, runChildSessionId, runChildTurn } from './dispatch.js';
91
+ import { coordinatorMember, DEFAULT_ROOM_SETTINGS, memberId, isRoomLog, MAIN_AGENT_MEMBER, parseMentions, replay, ROOM_EVENT_TYPES, ROOM_ROLE_MAX } from './journal.js';
92
+ import { messageId, parseMessageRef, releaseIntent, SELF_TASK, sendOrHold, workingHop } from './messaging.js';
93
+ import { isHumanMessage, linkedTask, taskLinks } from './deliveries.js';
94
+ import { resolveTaskRef } from './jobs.js';
95
+ import { suggestTask } from './report-gate.js';
96
+ import { decisionRecord } from './decisions.js';
97
+ import { askerPrompt, claimed, helperTools, helpMessage, memberFromLabel, mentionedSessions, messagePrompt, readHelpSource, receivedHelps, referencedSessions, replayHelps, requestPrompt, turnAnswer } from './help.js';
92
98
  export { MAIN_AGENT_MEMBER, ROOM_EVENT_TYPES } from './journal.js';
93
99
  /** Addressing/display names must stay parseable by the composer @-grammar. */
94
100
  function validName(name) {
95
101
  return name !== '' && !/\s/.test(name) && !name.includes('@');
96
102
  }
97
103
  /** A task title is the dispatch text's first line, truncated. */
98
- function taskTitle(text) {
99
- const first = text.trim().split('\n', 1)[0] ?? '';
100
- return first.length > 60 ? `${first.slice(0, 60)}…` : first;
104
+ /**
105
+ * The receipt a sending model reads back: the message id and what happens
106
+ * next, or why it waits.
107
+ */
108
+ /** How long a member's question card waits for the human before its call gives up. */
109
+ const QUESTION_TIMEOUT_MS = 30 * 60_000;
110
+ /**
111
+ * Validate a member's `ask_user_question` questions: an array, or that array
112
+ * as JSON text (some models only send flat strings). Options may be labels or
113
+ * `{ label, description }`; `multi_select` (the host tool's spelling) or
114
+ * `multiSelect` marks a multi-pick question.
115
+ * @param raw - the tool argument.
116
+ * @returns the questions, or the error text the model reads.
117
+ */
118
+ export function parseQuestions(raw) {
119
+ let value = raw;
120
+ if (typeof value === 'string') {
121
+ try {
122
+ value = JSON.parse(value);
123
+ }
124
+ catch {
125
+ return 'questions must be an array (or its JSON text)';
126
+ }
127
+ }
128
+ if (!Array.isArray(value) || value.length === 0)
129
+ return 'questions must be a non-empty array';
130
+ if (value.length > 4)
131
+ return 'Ask at most 4 questions at a time';
132
+ const questions = [];
133
+ for (const [index, entry] of value.entries()) {
134
+ if (entry === null || typeof entry !== 'object')
135
+ return `questions[${index}] must be an object`;
136
+ const row = entry;
137
+ if (typeof row.question !== 'string' || row.question.trim() === '')
138
+ return `questions[${index}].question is required`;
139
+ const options = [];
140
+ for (const option of Array.isArray(row.options) ? row.options : []) {
141
+ const label = typeof option === 'string' ? option : option?.label;
142
+ if (typeof label !== 'string' || label.trim() === '')
143
+ continue;
144
+ const description = typeof option === 'object' && option !== null && typeof option.description === 'string' ? option.description.trim() : '';
145
+ options.push({ label: label.trim().slice(0, 200), ...description === '' ? {} : { description: description.slice(0, 500) } });
146
+ }
147
+ if (options.length > 8)
148
+ return `questions[${index}] has more than 8 options`;
149
+ const id = typeof row.id === 'string' && row.id.trim() !== '' ? row.id.trim() : `q${index + 1}`;
150
+ const header = typeof row.header === 'string' && row.header.trim() !== '' ? row.header.trim().slice(0, 40) : undefined;
151
+ const multiSelect = row.multi_select === true || row.multiSelect === true;
152
+ questions.push({ id, question: row.question.trim().slice(0, 2000), ...header === undefined ? {} : { header }, ...options.length === 0 ? {} : { options }, ...multiSelect ? { multiSelect } : {} });
153
+ }
154
+ if (new Set(questions.map(question => question.id)).size !== questions.length)
155
+ return 'Question ids must be unique';
156
+ return questions;
157
+ }
158
+ function messageReceipt(receipt, kind) {
159
+ if (receipt.held !== undefined) {
160
+ const why = receipt.held === 'paused' ? '房间已暂停,恢复后自动投递'
161
+ : receipt.held === 'hop-limit' ? '这条消息链已到跳数上限,等房间主人决定是否继续'
162
+ : '房间设置为成员间消息需房间主人确认';
163
+ return `pending-confirm (${why})`;
164
+ }
165
+ return kind === 'notify'
166
+ ? `sent #${receipt.messageId}(知会,不会回报)`
167
+ : `sent #${receipt.messageId}(${receipt.member} 做完后结果会自动回报给你;不要 sleep 等待,也不要用 room_read 轮询)`;
168
+ }
169
+ /** The message-tools edit trigger (data only — no dependency on that plugin). */
170
+ function isEditTrigger(source) {
171
+ return source.kind === 'message-tools' && source.op === 'edit-trigger';
172
+ }
173
+ /** Whether a user/message is message-tools' in-place edit of a human message. */
174
+ function editedText(source) {
175
+ return source.kind === 'message-tools' && source.op === 'edit';
176
+ }
177
+ /** A message's text blocks, joined. */
178
+ function textBlocks(content) {
179
+ return content.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n').trim();
180
+ }
181
+ /**
182
+ * What a new coordinator reads once, on its first round: who handed over,
183
+ * the goal and open tasks, and where to look. The conversation itself is
184
+ * not replayed — a member that was in the room has most of it already, and
185
+ * re-sending it cost a five-figure prompt; it reads the room when it needs to.
186
+ */
187
+ export function handoffText(_events, state, sessionId, from, to) {
188
+ const open = (state.jobs ?? []).filter(job => job.status === 'running' || job.status === 'attention');
189
+ return [
190
+ `【协调者交接】${from} 把协调者交给了你(${to})。从现在起,人发的不带 @ 的消息都由你来接。`,
191
+ `房间会话:${sessionId}。各成员之前的原生会话都还在,可按会话 ID 查看;完整状态用 room_read。`,
192
+ state.goal === undefined ? '' : `房间目标:${state.goal}`,
193
+ state.plan === undefined ? '' : `正式目标 ${state.plan.id}(第 ${state.plan.revision} 版):${state.plan.objective}(${state.plan.status})。尝试、证据、依赖和预算用 room_read 查看;保持当前的暂停状态。`,
194
+ ...open.map(job => `进行中的任务 ${job.number}(${job.status === 'attention' ? '需要人处理' : '进行中'}):${job.title}`),
195
+ '之前的对话没有附上:需要时用 room_read,或打开房间会话自己看。',
196
+ ].filter(Boolean).join('\n').slice(0, 16000);
101
197
  }
102
198
  /**
103
199
  * room Remote service: promotion, roster management, the human @-message
104
- * intake, the notification gate, the task board, and run cancellation. A
200
+ * intake, the notification gate, tasks, and run cancellation. A
105
201
  * room IS a normal session; the journal is its only state. A room is never
106
202
  * CREATED apart from a session — inviting an agent into any session promotes
107
203
  * it (see ensureRoom; the room-session-promotion proposal).
@@ -111,6 +207,8 @@ let RoomService = (() => {
111
207
  let _instanceExtraInitializers = [];
112
208
  let _isRoom_decorators;
113
209
  let _listProviders_decorators;
210
+ let _setTaskHint_decorators;
211
+ let _stopJob_decorators;
114
212
  let _getState_decorators;
115
213
  let _invite_decorators;
116
214
  let _updateMember_decorators;
@@ -118,12 +216,18 @@ let RoomService = (() => {
118
216
  let _reconcileDelivery_decorators;
119
217
  let _setCoordinator_decorators;
120
218
  let _removeMember_decorators;
219
+ let _restoreMember_decorators;
121
220
  let _postMessage_decorators;
122
221
  let _planCommand_decorators;
222
+ let _answerApproval_decorators;
223
+ let _answerQuestion_decorators;
123
224
  let _confirmRelay_decorators;
225
+ let _releaseReports_decorators;
226
+ let _setSettings_decorators;
227
+ let _linkSession_decorators;
228
+ let _unlinkSession_decorators;
229
+ let _dismissHelpNudge_decorators;
124
230
  let _dismissRelay_decorators;
125
- let _addTask_decorators;
126
- let _closeTask_decorators;
127
231
  let _setGoal_decorators;
128
232
  let _cancel_decorators;
129
233
  let _cancelChild_decorators;
@@ -132,6 +236,8 @@ let RoomService = (() => {
132
236
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
133
237
  _isRoom_decorators = [Remote('isRoom')];
134
238
  _listProviders_decorators = [Remote('listProviders')];
239
+ _setTaskHint_decorators = [Remote('setTaskHint')];
240
+ _stopJob_decorators = [Remote('stopJob')];
135
241
  _getState_decorators = [Remote('getState')];
136
242
  _invite_decorators = [Remote('invite')];
137
243
  _updateMember_decorators = [Remote('updateMember')];
@@ -139,17 +245,25 @@ let RoomService = (() => {
139
245
  _reconcileDelivery_decorators = [Remote('reconcileDelivery')];
140
246
  _setCoordinator_decorators = [Remote('setCoordinator')];
141
247
  _removeMember_decorators = [Remote('removeMember')];
248
+ _restoreMember_decorators = [Remote('restoreMember')];
142
249
  _postMessage_decorators = [Remote('postMessage')];
143
250
  _planCommand_decorators = [Remote('planCommand')];
251
+ _answerApproval_decorators = [Remote('answerApproval')];
252
+ _answerQuestion_decorators = [Remote('answerQuestion')];
144
253
  _confirmRelay_decorators = [Remote('confirmRelay')];
254
+ _releaseReports_decorators = [Remote('releaseReports')];
255
+ _setSettings_decorators = [Remote('setSettings')];
256
+ _linkSession_decorators = [Remote('linkSession')];
257
+ _unlinkSession_decorators = [Remote('unlinkSession')];
258
+ _dismissHelpNudge_decorators = [Remote('dismissHelpNudge')];
145
259
  _dismissRelay_decorators = [Remote('dismissRelay')];
146
- _addTask_decorators = [Remote('addTask')];
147
- _closeTask_decorators = [Remote('closeTask')];
148
260
  _setGoal_decorators = [Remote('setGoal')];
149
261
  _cancel_decorators = [Remote('cancel')];
150
262
  _cancelChild_decorators = [Remote('cancelChild')];
151
263
  __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);
152
264
  __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
+ __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);
266
+ __esDecorate(this, null, _stopJob_decorators, { kind: "method", name: "stopJob", static: false, private: false, access: { has: obj => "stopJob" in obj, get: obj => obj.stopJob }, metadata: _metadata }, null, _instanceExtraInitializers);
153
267
  __esDecorate(this, null, _getState_decorators, { kind: "method", name: "getState", static: false, private: false, access: { has: obj => "getState" in obj, get: obj => obj.getState }, metadata: _metadata }, null, _instanceExtraInitializers);
154
268
  __esDecorate(this, null, _invite_decorators, { kind: "method", name: "invite", static: false, private: false, access: { has: obj => "invite" in obj, get: obj => obj.invite }, metadata: _metadata }, null, _instanceExtraInitializers);
155
269
  __esDecorate(this, null, _updateMember_decorators, { kind: "method", name: "updateMember", static: false, private: false, access: { has: obj => "updateMember" in obj, get: obj => obj.updateMember }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -157,12 +271,18 @@ let RoomService = (() => {
157
271
  __esDecorate(this, null, _reconcileDelivery_decorators, { kind: "method", name: "reconcileDelivery", static: false, private: false, access: { has: obj => "reconcileDelivery" in obj, get: obj => obj.reconcileDelivery }, metadata: _metadata }, null, _instanceExtraInitializers);
158
272
  __esDecorate(this, null, _setCoordinator_decorators, { kind: "method", name: "setCoordinator", static: false, private: false, access: { has: obj => "setCoordinator" in obj, get: obj => obj.setCoordinator }, metadata: _metadata }, null, _instanceExtraInitializers);
159
273
  __esDecorate(this, null, _removeMember_decorators, { kind: "method", name: "removeMember", static: false, private: false, access: { has: obj => "removeMember" in obj, get: obj => obj.removeMember }, metadata: _metadata }, null, _instanceExtraInitializers);
274
+ __esDecorate(this, null, _restoreMember_decorators, { kind: "method", name: "restoreMember", static: false, private: false, access: { has: obj => "restoreMember" in obj, get: obj => obj.restoreMember }, metadata: _metadata }, null, _instanceExtraInitializers);
160
275
  __esDecorate(this, null, _postMessage_decorators, { kind: "method", name: "postMessage", static: false, private: false, access: { has: obj => "postMessage" in obj, get: obj => obj.postMessage }, metadata: _metadata }, null, _instanceExtraInitializers);
161
276
  __esDecorate(this, null, _planCommand_decorators, { kind: "method", name: "planCommand", static: false, private: false, access: { has: obj => "planCommand" in obj, get: obj => obj.planCommand }, metadata: _metadata }, null, _instanceExtraInitializers);
277
+ __esDecorate(this, null, _answerApproval_decorators, { kind: "method", name: "answerApproval", static: false, private: false, access: { has: obj => "answerApproval" in obj, get: obj => obj.answerApproval }, metadata: _metadata }, null, _instanceExtraInitializers);
278
+ __esDecorate(this, null, _answerQuestion_decorators, { kind: "method", name: "answerQuestion", static: false, private: false, access: { has: obj => "answerQuestion" in obj, get: obj => obj.answerQuestion }, metadata: _metadata }, null, _instanceExtraInitializers);
162
279
  __esDecorate(this, null, _confirmRelay_decorators, { kind: "method", name: "confirmRelay", static: false, private: false, access: { has: obj => "confirmRelay" in obj, get: obj => obj.confirmRelay }, metadata: _metadata }, null, _instanceExtraInitializers);
280
+ __esDecorate(this, null, _releaseReports_decorators, { kind: "method", name: "releaseReports", static: false, private: false, access: { has: obj => "releaseReports" in obj, get: obj => obj.releaseReports }, metadata: _metadata }, null, _instanceExtraInitializers);
281
+ __esDecorate(this, null, _setSettings_decorators, { kind: "method", name: "setSettings", static: false, private: false, access: { has: obj => "setSettings" in obj, get: obj => obj.setSettings }, metadata: _metadata }, null, _instanceExtraInitializers);
282
+ __esDecorate(this, null, _linkSession_decorators, { kind: "method", name: "linkSession", static: false, private: false, access: { has: obj => "linkSession" in obj, get: obj => obj.linkSession }, metadata: _metadata }, null, _instanceExtraInitializers);
283
+ __esDecorate(this, null, _unlinkSession_decorators, { kind: "method", name: "unlinkSession", static: false, private: false, access: { has: obj => "unlinkSession" in obj, get: obj => obj.unlinkSession }, metadata: _metadata }, null, _instanceExtraInitializers);
284
+ __esDecorate(this, null, _dismissHelpNudge_decorators, { kind: "method", name: "dismissHelpNudge", static: false, private: false, access: { has: obj => "dismissHelpNudge" in obj, get: obj => obj.dismissHelpNudge }, metadata: _metadata }, null, _instanceExtraInitializers);
163
285
  __esDecorate(this, null, _dismissRelay_decorators, { kind: "method", name: "dismissRelay", static: false, private: false, access: { has: obj => "dismissRelay" in obj, get: obj => obj.dismissRelay }, metadata: _metadata }, null, _instanceExtraInitializers);
164
- __esDecorate(this, null, _addTask_decorators, { kind: "method", name: "addTask", static: false, private: false, access: { has: obj => "addTask" in obj, get: obj => obj.addTask }, metadata: _metadata }, null, _instanceExtraInitializers);
165
- __esDecorate(this, null, _closeTask_decorators, { kind: "method", name: "closeTask", static: false, private: false, access: { has: obj => "closeTask" in obj, get: obj => obj.closeTask }, metadata: _metadata }, null, _instanceExtraInitializers);
166
286
  __esDecorate(this, null, _setGoal_decorators, { kind: "method", name: "setGoal", static: false, private: false, access: { has: obj => "setGoal" in obj, get: obj => obj.setGoal }, metadata: _metadata }, null, _instanceExtraInitializers);
167
287
  __esDecorate(this, null, _cancel_decorators, { kind: "method", name: "cancel", static: false, private: false, access: { has: obj => "cancel" in obj, get: obj => obj.cancel }, metadata: _metadata }, null, _instanceExtraInitializers);
168
288
  __esDecorate(this, null, _cancelChild_decorators, { kind: "method", name: "cancelChild", static: false, private: false, access: { has: obj => "cancelChild" in obj, get: obj => obj.cancelChild }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -173,6 +293,19 @@ let RoomService = (() => {
173
293
  plans = __runInitializers(this, _instanceExtraInitializers);
174
294
  engine;
175
295
  handoffs = new Set();
296
+ /**
297
+ * The terminal run-state fields Context Lens correlates on: the child
298
+ * identity plus the round the run reached (live sessions only).
299
+ */
300
+ terminalRunStateFields(session, state, memberName) {
301
+ const member = state.members.find(entry => entry.name === memberName);
302
+ const childSessionId = runChildSessionId(session.id, member);
303
+ const childTurn = runChildTurn(childSessionId === undefined ? undefined : this.ctx.sessions.get(childSessionId));
304
+ return {
305
+ ...childSessionId === undefined ? {} : { childSessionId },
306
+ ...childTurn === undefined ? {} : { childTurn },
307
+ };
308
+ }
176
309
  /** sessionId → in-flight cold resume (mutations on a cold room dedupe). */
177
310
  resumes = new Map();
178
311
  /**
@@ -205,9 +338,53 @@ let RoomService = (() => {
205
338
  this.engine = new DispatchEngine(ctx, {
206
339
  allows: (room, seq) => this.plans.allows(room, seq),
207
340
  admitted: (room, seq) => this.plans.admitted(room, seq),
208
- settled: (room, seq, outcome) => this.plans.settled(room, seq, outcome),
341
+ settled: async (room, seq, outcome) => {
342
+ await this.plans.settled(room, seq, outcome);
343
+ void this.helpSettled(room, seq, outcome).catch((error) => { this.ctx.logger.warn(`room: help reply faulted: ${String(error)}`); });
344
+ },
209
345
  });
210
346
  ctx.effect(() => () => this.plans.dispose());
347
+ // The human talking to the room's own agent directly (the official input
348
+ // path): file the message under a task before the model reads it — the
349
+ // composer's tag, else the decision model while tasks are open — and tell
350
+ // the coordinator when it was filed under an existing task.
351
+ ctx.on('agent/pre-step', async ({ agent, step }, next) => {
352
+ const decision = await next();
353
+ if (step !== 1 || decision.kind !== 'enter')
354
+ return decision;
355
+ const events = agent.session.snapshotEvents();
356
+ if (!isRoomLog(events) || coordinatorMember(replay(events), events)?.kind !== 'main-agent')
357
+ return decision;
358
+ const notes = [];
359
+ for (const message of decision.messages) {
360
+ if (message.source.kind !== 'user')
361
+ continue;
362
+ const text = message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
363
+ const filed = await this.fileHumanMessage(agent.session, message.id, text, this.consumeTaskHint(agent.session.id));
364
+ if (filed.note !== undefined)
365
+ notes.push(filed.note);
366
+ }
367
+ if (notes.length === 0)
368
+ return decision;
369
+ return { ...decision, messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text: notes.join('\n') }], source: roomSource() })] };
370
+ });
371
+ // A turn the room did not start (the human talking to the room's agent or
372
+ // to a native member directly) still carries the reports the gate holds
373
+ // for that agent: one extra message on the turn's first step, so the
374
+ // agent never answers without results that already arrived.
375
+ ctx.on('agent/pre-step', async ({ agent, step }, next) => {
376
+ const decision = await next();
377
+ if (step !== 1 || decision.kind !== 'enter' || decision.messages.some(message => isRoomSource(message.source)))
378
+ return decision;
379
+ const target = this.heldTarget(agent.session.id);
380
+ if (target === undefined)
381
+ return decision;
382
+ const held = heldReportsText(target.room.snapshotEvents(), memberId(target.room.snapshotEvents(), target.member));
383
+ if (held === undefined)
384
+ return decision;
385
+ await this.engine.markHeldReleased(target.room, target.member, held.seqs);
386
+ return { ...decision, messages: [createUserMessage({ content: [{ type: 'text', text: held.text }], source: roomSource() }), ...decision.messages] };
387
+ });
211
388
  // A stale client or another official input surface must not wake a DSH
212
389
  // model behind an external coordinator. Public pre-step admits an empty
213
390
  // initial step without a model call after room has durably accepted it.
@@ -224,6 +401,16 @@ let RoomService = (() => {
224
401
  nativeTurns.set(agent, turn);
225
402
  return next();
226
403
  }
404
+ // An edit of the human's message (message-tools) wakes this agent to
405
+ // answer again; behind an external coordinator the edited text goes
406
+ // to the coordinator through the room instead.
407
+ const trigger = messages.find(message => isEditTrigger(message.source));
408
+ if (trigger !== undefined) {
409
+ const routed = await this.routeEdit(agent.session, String(trigger.id));
410
+ if (!routed.ok)
411
+ throw new Error(`Room input routing failed: ${routed.code}`);
412
+ return { kind: 'enter', messages: [] };
413
+ }
227
414
  for (const message of messages) {
228
415
  if (message.source.kind !== 'user' || message.content.some(block => block.type !== 'text')) {
229
416
  throw new Error('Use the room input route for this coordinator; this input cannot be forwarded losslessly');
@@ -234,6 +421,31 @@ let RoomService = (() => {
234
421
  }
235
422
  return { kind: 'enter', messages: [] };
236
423
  });
424
+ // Cross-session help (help.ts): a help message reaching a helper's turn
425
+ // equips that agent with the helper tools and marks the thread running;
426
+ // an agent whose log already holds help requests (a restart dropped its
427
+ // agent-local tools) is equipped on its first step.
428
+ const scanned = new WeakSet();
429
+ ctx.on('agent/pre-step', async ({ agent, step }, next) => {
430
+ const decision = await next();
431
+ if (decision.kind !== 'enter')
432
+ return decision;
433
+ const arriving = decision.messages.flatMap((message) => {
434
+ const source = readHelpSource(message.source);
435
+ return source === undefined ? [] : [source];
436
+ });
437
+ if (arriving.length > 0) {
438
+ this.equipHelper(agent);
439
+ for (const source of arriving)
440
+ await this.markHelpRunning(source);
441
+ }
442
+ else if (step === 1 && !scanned.has(agent)) {
443
+ scanned.add(agent);
444
+ if (receivedHelps(agent.session.snapshotEvents()).length > 0)
445
+ this.equipHelper(agent);
446
+ }
447
+ return decision;
448
+ });
237
449
  // The model-facing room tools are deliberately NOT registered here: they
238
450
  // belong to this package's `./tool` composition entry (src/tool.ts),
239
451
  // which mounts the tool row inside agent-preset compositions
@@ -285,6 +497,8 @@ let RoomService = (() => {
285
497
  return (async () => {
286
498
  await this.plans.recover(hit.session);
287
499
  await this.engine.recover(hit.session);
500
+ this.expireApprovals(hit.session);
501
+ this.expireQuestions(hit.session);
288
502
  return this.load(sessionId);
289
503
  })();
290
504
  if (hit.error.code !== 'session-not-found')
@@ -315,8 +529,10 @@ let RoomService = (() => {
315
529
  if (loaded.ok) {
316
530
  await this.plans.recover(loaded.session);
317
531
  await this.engine.recover(loaded.session);
532
+ this.expireApprovals(loaded.session);
533
+ this.expireQuestions(loaded.session);
318
534
  }
319
- return loaded;
535
+ return this.load(sessionId);
320
536
  }
321
537
  /**
322
538
  * Probe whether a session is a room: replay its event log looking for the
@@ -373,11 +589,14 @@ let RoomService = (() => {
373
589
  * @param _request - no parameters.
374
590
  * @returns the provider list and the facade verdict.
375
591
  */
376
- async listProviders(_request) {
592
+ async listProviders(request) {
377
593
  const localAgentAvailable = probeLocalAgent(this.ctx) !== undefined;
594
+ const listed = await this.listPresets();
595
+ const sessionPreset = request.sessionId === undefined || listed === undefined ? undefined : await this.sessionPreset(request.sessionId);
596
+ const presets = listed === undefined ? {} : { presets: listed, ...sessionPreset === undefined ? {} : { sessionPreset } };
378
597
  const roster = probeLocalAgentRoster(this.ctx);
379
598
  if (roster === undefined)
380
- return { localAgentAvailable, providers: [] };
599
+ return { localAgentAvailable, providers: [], ...presets };
381
600
  const providers = [];
382
601
  for (const row of roster.roster()) {
383
602
  const status = await roster.statusOf(row.name);
@@ -393,7 +612,273 @@ let RoomService = (() => {
393
612
  authenticated: status.authenticated,
394
613
  });
395
614
  }
396
- return { localAgentAvailable, providers };
615
+ return { localAgentAvailable, providers, ...presets };
616
+ }
617
+ /** A session's own mode (preset id), live or cold; undefined when unreadable. */
618
+ async sessionPreset(sessionId) {
619
+ try {
620
+ const live = this.ctx.sessions.get(sessionId);
621
+ if (live !== undefined)
622
+ return roomSessionPreset(live);
623
+ const inspected = await inspectCold(this.ctx, sessionId);
624
+ return inspected === undefined ? undefined : deriveSessionPreset(presetDerivationHost(), { header: inspected.meta, events: inspected.events });
625
+ }
626
+ catch {
627
+ return undefined;
628
+ }
629
+ }
630
+ /** The host's agent presets for a native member, or undefined without a registry. */
631
+ async listPresets() {
632
+ const presets = probeAgentPresets(this.ctx);
633
+ if (presets?.list === undefined)
634
+ return undefined;
635
+ try {
636
+ const fallback = (await presets.resolve()).id;
637
+ return (await presets.list()).map(row => ({
638
+ id: row.id, isDefault: row.id === fallback,
639
+ ...row.name === undefined ? {} : { name: row.name },
640
+ ...row.description === undefined ? {} : { description: row.description },
641
+ ...row.broken === undefined ? {} : { broken: row.broken },
642
+ }));
643
+ }
644
+ catch {
645
+ return undefined;
646
+ }
647
+ }
648
+ /** The composer's task tags, consumed by the human's next message in that room. */
649
+ taskHints = new Map();
650
+ consumeTaskHint(sessionId) {
651
+ const hint = this.taskHints.get(sessionId);
652
+ this.taskHints.delete(sessionId);
653
+ return hint;
654
+ }
655
+ /**
656
+ * File a new human message under a task: the composer's tag when given
657
+ * (a task root id, or `new`), otherwise — while some task is still open —
658
+ * the decision model's suggestion; with neither it starts a task of its own.
659
+ * A filing other than its own task is journaled as a `room/task-link`.
660
+ * @returns the task root id and, for a model suggestion, the note the coordinator should read.
661
+ */
662
+ async fileHumanMessage(session, messageId, text, tag) {
663
+ const jobs = replay(session.snapshotEvents()).jobs ?? [];
664
+ if (tag === 'new')
665
+ return { task: messageId };
666
+ if (tag !== undefined) {
667
+ const target = jobs.find(job => job.id === tag);
668
+ if (target === undefined)
669
+ return { task: messageId };
670
+ session.append('room/task-link', { message: messageId, task: target.id, by: 'human' });
671
+ return { task: target.id, note: `【任务归属】这条消息是用户在任务页对「任务 ${target.number}:${target.title}」的追问。` };
672
+ }
673
+ const open = jobs.filter(job => job.status === 'running' || job.status === 'attention');
674
+ const events = session.snapshotEvents();
675
+ const state = replay(events);
676
+ // Nothing to file into, or automatic filing switched off: a task of its own.
677
+ if (open.length === 0 || state.settings?.autoTaskFiling === false)
678
+ return { task: messageId };
679
+ const suggestion = await suggestTask(this.ctx, text, open.map(job => ({
680
+ number: job.number, title: job.title,
681
+ running: (state.deliveries ?? []).filter(row => job.deliveries.includes(row.id) && (row.status === 'running' || row.status === 'queued'))
682
+ .map(row => state.members.find(member => memberId(events, member) === row.memberId)?.name ?? row.memberId),
683
+ })));
684
+ const target = suggestion.task === 'new' ? undefined : jobs.find(job => job.number === suggestion.task);
685
+ // Every filing that weighed the open tasks is recorded (the timeline row, Context Lens).
686
+ session.append('room/decision', decisionRecord('task-filing', {
687
+ outcome: target === undefined ? 'new' : String(target.number),
688
+ ...suggestion.rule === undefined ? {} : { rule: suggestion.rule },
689
+ ...suggestion.probability === undefined ? {} : { probability: suggestion.probability },
690
+ ...suggestion.call === undefined ? {} : { call: suggestion.call },
691
+ about: { message: messageId, ...target === undefined ? {} : { task: { number: target.number, title: target.title.slice(0, 200) } } },
692
+ }));
693
+ if (target === undefined)
694
+ return { task: messageId };
695
+ const probability = suggestion.probability ?? 0;
696
+ session.append('room/task-link', { message: messageId, task: target.id, by: 'model', probability });
697
+ return { task: target.id, note: `【任务归属】这条消息看起来是在接着做「任务 ${target.number}:${target.title}」(判断置信 ${probability.toFixed(2)})。如果不对,派活时用 task: "new" 或别的任务编号指明,房间会改过来。` };
698
+ }
699
+ /**
700
+ * Tag the human's next message with a task (the task page's 追问 /
701
+ * 新任务), or clear the tag.
702
+ * @param request - room session and the task root id, `new`, or null.
703
+ * @returns acknowledgement.
704
+ */
705
+ async setTaskHint(request) {
706
+ if (request.task === null)
707
+ this.taskHints.delete(request.sessionId);
708
+ else
709
+ this.taskHints.set(request.sessionId, request.task);
710
+ return { ok: true };
711
+ }
712
+ /**
713
+ * Stop a whole task: running rounds of its deliveries are cancelled (the
714
+ * same cancel the stop button uses), queued and held ones never run, and
715
+ * ordinary rounds whose outcome a restart lost close as cancelled — the
716
+ * human gave up on them — which unblocks those members' later messages.
717
+ * @param request - room session and the task root id.
718
+ * @returns how many deliveries were stopped.
719
+ */
720
+ async stopJob(request) {
721
+ const loaded = await this.ensureLive(request.sessionId);
722
+ if (!loaded.ok)
723
+ return { ok: false, error: loaded.error };
724
+ const job = loaded.state.jobs?.find(entry => entry.id === request.task);
725
+ if (job === undefined)
726
+ return { ok: false, error: { code: 'unknown-task' } };
727
+ let stopped = 0;
728
+ for (const row of loaded.state.deliveries ?? []) {
729
+ if (!job.deliveries.includes(row.id))
730
+ continue;
731
+ if (row.status === 'running') {
732
+ const member = loaded.state.members.find(entry => (entry.id ?? entry.name) === row.memberId);
733
+ if (member !== undefined && (await this.cancel({ sessionId: request.sessionId, name: member.name, expectedRunId: row.id })).ok)
734
+ stopped++;
735
+ }
736
+ else if (row.status === 'queued' || row.status === 'held') {
737
+ loaded.session.append('room/delivery-state', { id: row.id, dispatchSeq: row.dispatchSeq, memberId: row.memberId, state: 'cancelled', error: '任务已被停止' });
738
+ stopped++;
739
+ }
740
+ else if (row.status === 'uncertain' && row.plan?.taskId === undefined) {
741
+ // Stopping is the human abandoning the round whose outcome a restart
742
+ // lost: that settles it, so the member's later messages run again.
743
+ loaded.session.append('room/delivery-state', { id: row.id, dispatchSeq: row.dispatchSeq, memberId: row.memberId, state: 'cancelled', text: '任务已被停止;这一轮的结果没有核对' });
744
+ const run = loaded.state.runs.find(entry => entry.runId === row.id);
745
+ if (run !== undefined)
746
+ loaded.session.append('room/run-state', { member: run.member, runId: row.id, startedAt: run.startedAt, state: 'cancelled', ...this.terminalRunStateFields(loaded.session, loaded.state, run.member) });
747
+ stopped++;
748
+ }
749
+ }
750
+ loaded.session.append('room/job-stopped', { task: job.id });
751
+ await this.ctx.sessions.flush(loaded.session);
752
+ await this.engine.recover(loaded.session);
753
+ return { ok: true, value: { stopped } };
754
+ }
755
+ /**
756
+ * Route the human's edited message (message-tools replaced the bubble in
757
+ * place) to the room: the original message's dispatch stops — editing is
758
+ * changing one's mind — and the edited text dispatches like a new human
759
+ * message, filed under the original's task, without a second bubble.
760
+ * Idempotent per edit trigger.
761
+ */
762
+ async routeEdit(session, requestId) {
763
+ const events = session.snapshotEvents();
764
+ if (events.some(event => event.type === 'room/dispatch' && event.data.id === requestId))
765
+ return { ok: true };
766
+ const edit = events.findLast(event => event.type === 'user/message' && editedText(event.data.source));
767
+ if (edit?.type !== 'user/message')
768
+ return { ok: false, code: 'edit-not-found' };
769
+ const text = textBlocks(edit.data.content);
770
+ if (text === '')
771
+ return { ok: false, code: 'empty-text' };
772
+ const replaced = edit.sourceEventSeqs ?? [];
773
+ const originals = events.filter(event => replaced.includes(event.seq) && isHumanMessage(event));
774
+ const links = taskLinks(events);
775
+ const task = originals.length === 0 ? undefined : linkedTask(links, originals[0].type === 'user/message' ? originals[0].data.id : '');
776
+ // The original's dispatch: the human dispatch that followed it.
777
+ const state = replay(events);
778
+ for (const original of originals) {
779
+ const next = events.find(event => event.seq > original.seq && (event.type === 'room/dispatch' || isHumanMessage(event)));
780
+ if (next?.type !== 'room/dispatch' || next.data.origin !== 'human')
781
+ continue;
782
+ for (const row of (state.deliveries ?? []).filter(entry => entry.dispatchSeq === next.seq)) {
783
+ const member = state.members.find(entry => memberId(events, entry) === row.memberId);
784
+ if (row.status === 'running' && member !== undefined)
785
+ await this.cancel({ sessionId: session.id, name: member.name, expectedRunId: row.id });
786
+ else if (row.status === 'queued' || row.status === 'held')
787
+ session.append('room/delivery-state', { id: row.id, dispatchSeq: row.dispatchSeq, memberId: row.memberId, state: 'cancelled', error: '你编辑了这条消息' });
788
+ }
789
+ }
790
+ const parsed = parseMentions(text);
791
+ const targets = [...parsed.targets];
792
+ const current = replay(session.snapshotEvents());
793
+ if (targets.length === 0) {
794
+ const coordinator = coordinatorMember(current, session.snapshotEvents());
795
+ if (coordinator === undefined)
796
+ return { ok: false, code: 'member-not-found' };
797
+ targets.push(coordinator.name);
798
+ }
799
+ if (targets.some(target => !current.members.some(member => member.name === target)))
800
+ return { ok: false, code: 'unknown-targets' };
801
+ const body = parsed.targets.length > 0 ? parsed.text : text;
802
+ const filed = await this.fileHumanMessage(session, edit.data.id, text, task);
803
+ this.moveOnFromLostRounds(session, targets);
804
+ const dispatch = session.append('room/dispatch', {
805
+ id: requestId, targets, text: body, origin: 'human', task: filed.task,
806
+ targetIds: targets.map(name => memberId(session.snapshotEvents(), current.members.find(member => member.name === name))),
807
+ });
808
+ await this.ctx.sessions.flush(session);
809
+ for (const target of targets)
810
+ this.engine.dispatch(session, target, body, { dispatchSeq: dispatch.seq });
811
+ return { ok: true };
812
+ }
813
+ /**
814
+ * The human messaging a member moves on from that member's ordinary
815
+ * rounds a restart left uncertain: they close as not finished, so the new
816
+ * message runs instead of queueing behind a reconciliation. Formal-goal
817
+ * attempts keep their explicit reconciliation.
818
+ */
819
+ moveOnFromLostRounds(session, targets, why = '你发了新消息;被重启打断的这一轮按没做完处理') {
820
+ const events = session.snapshotEvents();
821
+ const state = replay(events);
822
+ const ids = new Set(state.members.filter(member => targets.includes(member.name)).map(member => memberId(events, member)));
823
+ for (const row of state.deliveries ?? []) {
824
+ if (row.status !== 'uncertain' || row.plan?.taskId !== undefined || !ids.has(row.memberId))
825
+ continue;
826
+ session.append('room/delivery-state', { id: row.id, dispatchSeq: row.dispatchSeq, memberId: row.memberId, state: 'cancelled', text: why });
827
+ const run = state.runs.find(entry => entry.runId === row.id);
828
+ if (run !== undefined)
829
+ session.append('room/run-state', { member: run.member, runId: row.id, startedAt: run.startedAt, state: 'cancelled', ...this.terminalRunStateFields(session, state, run.member) });
830
+ }
831
+ }
832
+ /**
833
+ * Where a member's next send belongs: the task and delivery it is working
834
+ * on (a member inside a room round), or — for the room's own agent answering
835
+ * the human directly — the task of the human message that started its turn
836
+ * (`trigger`, which the coordinator may re-file with `task`).
837
+ */
838
+ sendContext(session, sender) {
839
+ const events = session.snapshotEvents();
840
+ const state = replay(events);
841
+ const run = state.runs.find(entry => entry.member === sender.name && entry.state === 'running');
842
+ const row = run?.runId === undefined ? undefined : state.deliveries?.find(entry => entry.id === run.runId);
843
+ if (row !== undefined)
844
+ return { ...row.task === undefined ? {} : { task: row.task }, parent: row.id };
845
+ if (sender.kind !== 'main-agent')
846
+ return {};
847
+ const human = events.findLast(isHumanMessage);
848
+ if (human === undefined)
849
+ return {};
850
+ return { task: linkedTask(taskLinks(events), human.data.id), trigger: human.data.id };
851
+ }
852
+ /** The room and member whose held reports a session's agent should carry. */
853
+ heldTarget(sessionId) {
854
+ const own = this.ctx.sessions.get(SessionId(sessionId));
855
+ if (own !== undefined && isRoomLog(own.snapshotEvents())) {
856
+ const member = replay(own.snapshotEvents()).members.find(entry => entry.kind === 'main-agent');
857
+ return member === undefined ? undefined : { room: own, member };
858
+ }
859
+ const routed = this.nativeRoute(sessionId);
860
+ const room = routed === undefined ? undefined : this.ctx.sessions.get(routed.roomId);
861
+ const member = room === undefined ? undefined : replay(room.snapshotEvents()).members.find(entry => entry.childSessionId === routed.actor);
862
+ return room === undefined || member === undefined ? undefined : { room, member };
863
+ }
864
+ /**
865
+ * A native member's own session calling a room tool: the tools see the
866
+ * child session, so route to its room with the child as the authenticated
867
+ * actor instead of promoting the child into a room of its own.
868
+ * @param sessionId - the calling agent's session.
869
+ * @returns the room and actor, or undefined when the caller is no native member.
870
+ */
871
+ nativeRoute(sessionId) {
872
+ const parent = this.ctx.sessions.get(SessionId(sessionId))?.header.parentSession;
873
+ const room = parent === undefined ? undefined : this.ctx.sessions.get(parent);
874
+ if (room === undefined || parent === undefined)
875
+ return undefined;
876
+ const events = room.snapshotEvents();
877
+ if (!isRoomLog(events))
878
+ return undefined;
879
+ return replay(events).members.some(member => member.kind === 'native' && member.childSessionId === sessionId)
880
+ ? { roomId: parent, actor: sessionId }
881
+ : undefined;
397
882
  }
398
883
  /**
399
884
  * Replay the room's journal into the derived state (roster, notification
@@ -406,6 +891,12 @@ let RoomService = (() => {
406
891
  const loaded = await this.loadCold(request.sessionId);
407
892
  if (!loaded.ok)
408
893
  return { ok: false, error: loaded.error };
894
+ // A held report's wake timer lives in the process that held it: after a
895
+ // restart nothing would ever hand it over. A cold room holding one is
896
+ // brought live, whose recovery hands every untimed hold over.
897
+ if (this.ctx.sessions.get(request.sessionId) === undefined && (loaded.state.deliveries ?? []).some(row => row.status === 'held')) {
898
+ void this.ensureLive(request.sessionId).catch((error) => { this.ctx.logger.warn(`room: reviving held reports failed: ${String(error)}`); });
899
+ }
409
900
  return { ok: true, value: { ...this.engine.view(request.sessionId, loaded.state), ...loaded.state.plan === undefined ? {} : { plan: this.plans.view(request.sessionId, loaded.state.plan) } } };
410
901
  }
411
902
  /**
@@ -427,6 +918,9 @@ let RoomService = (() => {
427
918
  * @returns the invitation receipt, or a rejection.
428
919
  */
429
920
  async inviteMember(request, invitedBy, actorChildSessionId) {
921
+ const routed = invitedBy === 'agent' && actorChildSessionId === undefined ? this.nativeRoute(request.sessionId) : undefined;
922
+ if (routed !== undefined)
923
+ return this.inviteMember({ ...request, sessionId: routed.roomId }, invitedBy, routed.actor);
430
924
  // Invite PROMOTES: inviting an agent into a plain session makes it a room.
431
925
  const loaded = await this.ensureRoom(request.sessionId);
432
926
  if (!loaded.ok)
@@ -448,23 +942,59 @@ let RoomService = (() => {
448
942
  if (request.firstTask !== undefined && request.firstTask.trim() === '') {
449
943
  return { ok: false, error: { code: 'empty-text' } };
450
944
  }
945
+ if (request.role !== undefined && request.role.trim().length > ROOM_ROLE_MAX) {
946
+ return { ok: false, error: { code: 'role-too-long' } };
947
+ }
451
948
  if (request.model !== undefined && request.model.trim() === '') {
452
949
  return { ok: false, error: { code: 'empty-text' } };
453
950
  }
454
- if (probeLocalAgent(this.ctx) === undefined) {
951
+ if (request.effort !== undefined && request.effort.trim() === '') {
952
+ return { ok: false, error: { code: 'empty-text' } };
953
+ }
954
+ const native = request.kind === 'native';
955
+ // The coordinator's default: the member works in the room's own mode.
956
+ const inherited = request.preset === undefined && request.inheritPreset === true ? roomSessionPreset(loaded.session) : undefined;
957
+ if (inherited !== undefined)
958
+ request = { ...request, preset: inherited };
959
+ if (request.preset !== undefined) {
960
+ // A native member composes a host preset; a CLI member reaches that
961
+ // preset's plugin tools through its bridge. Reject an id the host does
962
+ // not declare instead of failing at the member's first round.
963
+ const presets = probeAgentPresets(this.ctx);
964
+ if (presets !== undefined) {
965
+ try {
966
+ await presets.resolve(request.preset);
967
+ }
968
+ catch {
969
+ const available = (await presets.list?.())?.map(row => row.id) ?? [];
970
+ return { ok: false, error: { code: 'unknown-preset', preset: request.preset, available } };
971
+ }
972
+ }
973
+ }
974
+ if (!native && probeLocalAgent(this.ctx) === undefined) {
455
975
  return { ok: false, error: { code: 'local-agent-unavailable' } };
456
976
  }
457
- const roster = probeLocalAgentRoster(this.ctx);
977
+ const roster = native ? undefined : probeLocalAgentRoster(this.ctx);
458
978
  if (roster !== undefined) {
459
979
  // The same delegationProvider set listProviders serves: invite accepts
460
980
  // exactly what dispatch can resolve, and the rejection carries the
461
981
  // legal set so a model caller can self-correct (rename and retry).
462
982
  const available = [];
983
+ // The names a model reaches for first — the harness name (claude-code)
984
+ // and its display name (Claude Code) — resolve to the provider id.
985
+ const aliases = new Map();
986
+ const key = (name) => name.toLowerCase().replace(/[\s_-]+/g, '');
463
987
  for (const row of roster.roster()) {
464
988
  const status = await roster.statusOf(row.name);
465
- if (status.delegationProvider !== undefined)
466
- available.push(status.delegationProvider);
989
+ if (status.delegationProvider === undefined)
990
+ continue;
991
+ available.push(status.delegationProvider);
992
+ for (const alias of [row.name, row.displayName, status.delegationProvider])
993
+ aliases.set(key(alias), status.delegationProvider);
467
994
  }
995
+ const aliased = aliases.get(key(request.provider));
996
+ if (!available.includes(request.provider) && aliased !== undefined)
997
+ request = { ...request, provider: aliased };
468
998
  if (!available.includes(request.provider)) {
469
999
  return { ok: false, error: { code: 'unknown-provider', provider: request.provider, available } };
470
1000
  }
@@ -478,31 +1008,40 @@ let RoomService = (() => {
478
1008
  loaded.session.append('room/member-added', {
479
1009
  id: randomUUID(),
480
1010
  name: request.name,
481
- kind: 'cli',
482
- provider: request.provider,
1011
+ kind: native ? 'native' : 'cli',
1012
+ provider: native ? 'dsh' : request.provider,
1013
+ ...request.preset !== undefined ? { preset: request.preset } : {},
483
1014
  invitedBy,
484
1015
  ...request.instructions === undefined ? {} : { instructions: request.instructions },
1016
+ ...request.role === undefined || request.role.trim() === '' ? {} : { role: request.role.trim() },
485
1017
  ...request.cwd === undefined || request.cwd.trim() === '' ? {} : { cwd: request.cwd.trim() },
486
1018
  ...request.model === undefined || request.model.trim() === '' ? {} : { model: request.model.trim() },
1019
+ ...request.effort === undefined || request.effort.trim() === '' ? {} : { effort: request.effort.trim() },
487
1020
  });
488
1021
  let firstTaskSeq;
489
1022
  if (request.firstTask !== undefined) {
1023
+ // A human's invite starts a task of its own; an agent's belongs to the task it is working on.
1024
+ const inviter = invitedBy === 'agent'
1025
+ ? replay(loaded.session.snapshotEvents()).members.find(member => actorChildSessionId === undefined ? member.kind === 'main-agent' : member.childSessionId === actorChildSessionId)
1026
+ : undefined;
1027
+ const context = inviter === undefined ? {} : this.sendContext(loaded.session, inviter);
1028
+ const dispatchId = randomUUID();
1029
+ const firstTaskContext = { id: dispatchId, task: invitedBy === 'human' ? dispatchId : context.task ?? dispatchId,
1030
+ ...context.parent === undefined ? {} : { parent: context.parent } };
490
1031
  firstTaskSeq = loaded.session.append('room/dispatch', {
491
- id: randomUUID(), origin: invitedBy === 'human' ? 'human' : 'coordinator',
1032
+ origin: invitedBy === 'human' ? 'human' : 'coordinator',
492
1033
  ...invitedBy === 'human' ? {} : { replyTo: memberId(loaded.session.snapshotEvents(), coordinatorMember(replay(loaded.session.snapshotEvents()), loaded.session.snapshotEvents())) },
1034
+ ...request.note === undefined || request.note.trim() === '' ? {} : { note: request.note.trim().slice(0, 500) },
1035
+ ...firstTaskContext,
493
1036
  targetIds: [memberId(loaded.session.snapshotEvents(), replay(loaded.session.snapshotEvents()).members.find(member => member.name === request.name))],
494
1037
  targets: [request.name], text: request.firstTask,
495
1038
  }).seq;
496
- loaded.session.append('room/task-added', {
497
- id: randomUUID(), member: request.name, title: taskTitle(request.firstTask), status: 'in_progress',
498
- deliveryId: `${firstTaskSeq}:${memberId(loaded.session.snapshotEvents(), replay(loaded.session.snapshotEvents()).members.find(member => member.name === request.name))}`,
499
- });
500
1039
  }
501
1040
  await this.ctx.sessions.flush(loaded.session);
502
1041
  if (request.firstTask !== undefined && firstTaskSeq !== undefined) {
503
1042
  this.engine.dispatch(loaded.session, request.name, request.firstTask, { dispatchSeq: firstTaskSeq });
504
1043
  }
505
- return { ok: true, value: { name: request.name, pendingFirstTask: request.firstTask !== undefined } };
1044
+ return { ok: true, value: { name: request.name, pendingFirstTask: request.firstTask !== undefined, ...request.preset === undefined ? {} : { preset: request.preset } } };
506
1045
  }
507
1046
  /**
508
1047
  * Invite a CLI member (the human path; see inviteMember).
@@ -544,8 +1083,10 @@ let RoomService = (() => {
544
1083
  return { ok: false, error: { code: 'member-cwd-bound' } };
545
1084
  if (request.model !== undefined && member.childSessionId !== undefined)
546
1085
  return { ok: false, error: { code: 'configuration-owned-by-core' } };
547
- if (request.rename === undefined && request.instructions === undefined
548
- && request.cwd === undefined && request.model === undefined) {
1086
+ if (request.effort !== undefined && member.childSessionId !== undefined)
1087
+ return { ok: false, error: { code: 'configuration-owned-by-core' } };
1088
+ if (request.rename === undefined && request.instructions === undefined && request.role === undefined
1089
+ && request.cwd === undefined && request.model === undefined && request.effort === undefined) {
549
1090
  return { ok: false, error: { code: 'nothing-to-update' } };
550
1091
  }
551
1092
  if (request.rename !== undefined && request.rename !== request.name) {
@@ -562,18 +1103,28 @@ let RoomService = (() => {
562
1103
  const instructions = typeof request.instructions === 'string' && request.instructions.trim() === ''
563
1104
  ? null
564
1105
  : request.instructions;
1106
+ const role = typeof request.role === 'string'
1107
+ ? (request.role.trim() === '' ? null : request.role.trim())
1108
+ : request.role;
1109
+ if (typeof role === 'string' && role.length > ROOM_ROLE_MAX)
1110
+ return { ok: false, error: { code: 'role-too-long' } };
565
1111
  const cwd = typeof request.cwd === 'string'
566
1112
  ? (request.cwd.trim() === '' ? null : request.cwd.trim())
567
1113
  : request.cwd;
568
1114
  const model = typeof request.model === 'string'
569
1115
  ? (request.model.trim() === '' ? null : request.model.trim())
570
1116
  : request.model;
1117
+ const effort = typeof request.effort === 'string'
1118
+ ? (request.effort.trim() === '' ? null : request.effort.trim())
1119
+ : request.effort;
571
1120
  loaded.session.append('room/member-updated', {
572
1121
  name: request.name,
573
1122
  ...request.rename === undefined ? {} : { rename: request.rename },
574
1123
  ...instructions === undefined ? {} : { instructions },
1124
+ ...role === undefined ? {} : { role },
575
1125
  ...cwd === undefined ? {} : { cwd },
576
1126
  ...model === undefined ? {} : { model },
1127
+ ...effort === undefined ? {} : { effort },
577
1128
  });
578
1129
  await this.ctx.sessions.flush(loaded.session);
579
1130
  return { ok: true, value: { name: request.name } };
@@ -602,7 +1153,7 @@ let RoomService = (() => {
602
1153
  return { ok: false, error: { code: 'coordinator-not-ready', message: 'Native preparation is unavailable in this family core' } };
603
1154
  try {
604
1155
  const childSessionId = await family.prepareMember(request.sessionId, member.provider, member.childSessionId ?? memberId(loaded.session.snapshotEvents(), member), {
605
- ...member.cwd === undefined ? {} : { cwd: member.cwd }, ...member.model === undefined ? {} : { model: member.model },
1156
+ ...member.cwd === undefined ? {} : { cwd: member.cwd }, ...member.model === undefined ? {} : { model: member.model }, ...member.effort === undefined ? {} : { effort: member.effort },
606
1157
  });
607
1158
  loaded.session.append('room/member-updated', { name: member.name, childSessionId: SessionId(childSessionId) });
608
1159
  await this.ctx.sessions.flush(loaded.session);
@@ -627,7 +1178,7 @@ let RoomService = (() => {
627
1178
  loaded.session.append('room/delivery-state', { id: delivery.id, dispatchSeq: delivery.dispatchSeq, memberId: delivery.memberId, state: request.outcome, text: request.evidence.trim() });
628
1179
  const run = loaded.state.runs.find(run => run.runId === delivery.id);
629
1180
  if (run !== undefined)
630
- loaded.session.append('room/run-state', { member: run.member, runId: delivery.id, startedAt: run.startedAt, state: request.outcome });
1181
+ loaded.session.append('room/run-state', { member: run.member, runId: delivery.id, startedAt: run.startedAt, state: request.outcome, ...this.terminalRunStateFields(loaded.session, loaded.state, run.member) });
631
1182
  await this.ctx.sessions.flush(loaded.session);
632
1183
  await this.engine.recover(loaded.session);
633
1184
  return { ok: true };
@@ -646,6 +1197,10 @@ let RoomService = (() => {
646
1197
  const previous = coordinatorMember(loaded.state, events);
647
1198
  if (candidate === undefined || previous === undefined)
648
1199
  return { ok: false, error: { code: 'member-not-found' } };
1200
+ // A native member answers inside this host already; making it the
1201
+ // external coordinator would need the CLI coordination contract it does not speak.
1202
+ if (candidate.kind === 'native')
1203
+ return { ok: false, error: { code: 'coordinator-not-ready', message: 'A native DSH member cannot coordinate yet; promote the room\'s own dsh or a CLI member' } };
649
1204
  if ((loaded.state.coordinator?.revision ?? 0) !== request.expectedRevision)
650
1205
  return { ok: false, error: { code: 'coordinator-conflict' } };
651
1206
  const agent = this.ctx.agents.get(request.sessionId);
@@ -688,15 +1243,7 @@ let RoomService = (() => {
688
1243
  return { ok: false, error: { code: 'coordinator-not-ready', message: 'Member configuration has not converged or is locked' } };
689
1244
  }
690
1245
  }
691
- const handoff = [
692
- `Coordinator handoff from ${previous.name} to ${candidate.name}.`,
693
- `Room session: ${request.sessionId}. Earlier native conversations remain available by their session IDs.`,
694
- loaded.state.goal === undefined ? '' : `Goal: ${loaded.state.goal}`,
695
- loaded.state.plan === undefined ? '' : `Formal goal ${loaded.state.plan.id}, revision ${loaded.state.plan.revision}: ${loaded.state.plan.objective} (${loaded.state.plan.status}). Read room_read for attempts, evidence, dependencies and budget; preserve the current pause state.`,
696
- ...loaded.state.tasks.filter(task => !['done', 'cancelled'].includes(task.status)).map(task => `Open task ${task.id}: ${task.member}: ${task.title} (${task.status})`),
697
- ...events.filter(event => event.type === 'room/speech' || event.type === 'room/dispatch').slice(-12).map(event => event.type === 'room/speech' ? `${event.data.member}: ${event.data.text.slice(0, 1500)}`
698
- : event.type === 'room/dispatch' ? `To ${event.data.targets.join(', ')}: ${event.data.text.slice(0, 1500)}` : ''),
699
- ].filter(Boolean).join('\n').slice(0, 16000);
1246
+ const handoff = handoffText(events, loaded.state, request.sessionId, previous.name, candidate.name);
700
1247
  const value = { version: 1, memberId: request.memberId, previousMemberId: memberId(events, previous), revision: request.expectedRevision + 1, handoff };
701
1248
  loaded.session.append('room/coordinator', value);
702
1249
  await this.ctx.sessions.flush(loaded.session);
@@ -710,10 +1257,14 @@ let RoomService = (() => {
710
1257
  }
711
1258
  }
712
1259
  /**
713
- * Remove a member from the roster. An in-flight run is NOT interrupted
714
- * here — cancel() first when the member is running.
1260
+ * Remove a member and settle everything that depended on it, so the
1261
+ * confirmation's promises hold: a running round is stopped first (the same
1262
+ * cancel the stop button uses), queued messages to it are cancelled, every
1263
+ * member waiting for one of its reports is told it will not come, and only
1264
+ * then does it leave the roster. Its own session and history stay;
1265
+ * {@link restoreMember} brings it back onto them.
715
1266
  * @param request - room session, member name.
716
- * @returns the removal receipt, or a rejection.
1267
+ * @returns what the removal did, or a rejection.
717
1268
  */
718
1269
  async removeMember(request) {
719
1270
  const loaded = await this.ensureLive(request.sessionId);
@@ -721,16 +1272,71 @@ let RoomService = (() => {
721
1272
  return { ok: false, error: loaded.error };
722
1273
  if (this.handoffs.has(request.sessionId))
723
1274
  return { ok: false, error: { code: 'coordinator-busy' } };
724
- if (!loaded.state.members.some(member => member.name === request.name)) {
1275
+ const member = loaded.state.members.find(entry => entry.name === request.name);
1276
+ if (member === undefined)
725
1277
  return { ok: false, error: { code: 'member-not-found' } };
726
- }
727
1278
  if (coordinatorMember(loaded.state, loaded.session.snapshotEvents())?.name === request.name)
728
1279
  return { ok: false, error: { code: 'active-coordinator' } };
729
- if (loaded.state.members.find(member => member.name === request.name)?.kind === 'main-agent')
1280
+ if (member.kind === 'main-agent')
730
1281
  return { ok: false, error: { code: 'main-member' } };
731
- loaded.session.append('room/member-removed', { name: request.name });
1282
+ const stopped = loaded.state.runs.some(run => run.member === request.name && run.state === 'running')
1283
+ && (await this.cancel({ sessionId: request.sessionId, name: request.name })).ok;
1284
+ const session = loaded.session;
1285
+ const events = session.snapshotEvents();
1286
+ const id = memberId(events, member);
1287
+ const queued = replay(events).deliveries?.filter(row => row.memberId === id && row.status === 'queued') ?? [];
1288
+ for (const row of queued) {
1289
+ session.append('room/delivery-state', { id: row.id, dispatchSeq: row.dispatchSeq, memberId: id, state: 'cancelled', error: `${request.name} 已被移除` });
1290
+ }
1291
+ session.append('room/member-removed', { name: request.name });
1292
+ await this.ctx.sessions.flush(session);
1293
+ // Tell whoever waits on a cancelled delivery, now that the roster no
1294
+ // longer holds the member (the report reads its last known name).
1295
+ const before = session.snapshotEvents().filter(event => event.type === 'room/dispatch').length;
1296
+ for (const row of queued) {
1297
+ await this.engine.reportOutcome(session, row.dispatchSeq, row.id, id, 'cancelled', `${request.name} 已被移除,这条没有执行。`);
1298
+ }
1299
+ const reports = session.snapshotEvents().filter(event => event.type === 'room/dispatch').slice(before);
1300
+ const notified = [...new Set(reports.flatMap(event => event.type === 'room/dispatch' ? event.data.targets : []))];
1301
+ return { ok: true, value: { name: request.name, stopped, cancelledDeliveries: queued.length, notified } };
1302
+ }
1303
+ /**
1304
+ * Undo a removal: the member rejoins under its old identity, provider,
1305
+ * role, instructions and native session, so its memory carries on. Work
1306
+ * the removal cancelled stays cancelled. Rejected when the name was taken
1307
+ * in between or the member was never removed.
1308
+ * @param request - room session, the removed member's name.
1309
+ * @returns the restored name, or a rejection.
1310
+ */
1311
+ async restoreMember(request) {
1312
+ const loaded = await this.ensureLive(request.sessionId);
1313
+ if (!loaded.ok)
1314
+ return { ok: false, error: loaded.error };
1315
+ if (loaded.state.members.some(entry => entry.name === request.name))
1316
+ return { ok: false, error: { code: 'duplicate-name' } };
1317
+ const events = loaded.session.snapshotEvents();
1318
+ const removal = events.findLast(event => event.type === 'room/member-removed' && event.data.name === request.name);
1319
+ if (removal === undefined)
1320
+ return { ok: false, error: { code: 'member-not-found' } };
1321
+ const member = replay(events.filter(event => event.seq < removal.seq)).members.find(entry => entry.name === request.name);
1322
+ if (member === undefined)
1323
+ return { ok: false, error: { code: 'member-not-found' } };
1324
+ loaded.session.append('room/member-added', {
1325
+ id: memberId(events, member),
1326
+ name: member.name,
1327
+ kind: member.kind,
1328
+ invitedBy: member.invitedBy,
1329
+ ...member.provider === undefined ? {} : { provider: member.provider },
1330
+ ...member.instructions === undefined ? {} : { instructions: member.instructions },
1331
+ ...member.role === undefined ? {} : { role: member.role },
1332
+ ...member.preset === undefined ? {} : { preset: member.preset },
1333
+ ...member.cwd === undefined ? {} : { cwd: member.cwd },
1334
+ ...member.model === undefined ? {} : { model: member.model },
1335
+ ...member.effort === undefined ? {} : { effort: member.effort },
1336
+ ...member.childSessionId === undefined ? {} : { childSessionId: member.childSessionId },
1337
+ });
732
1338
  await this.ctx.sessions.flush(loaded.session);
733
- return { ok: true, value: { name: request.name } };
1339
+ return { ok: true, value: { name: member.name } };
734
1340
  }
735
1341
  /**
736
1342
  * Post a human @-message into the room: leading `@name` tokens address
@@ -797,73 +1403,111 @@ let RoomService = (() => {
797
1403
  }
798
1404
  // Leading tokens are stripped from the dispatched text; a picked mid-
799
1405
  // sentence mention stays — the sentence is dispatched verbatim.
800
- const text = parsed.targets.length > 0 ? parsed.text : request.text.trim();
1406
+ const said = parsed.targets.length > 0 ? parsed.text : request.text.trim();
1407
+ // The human pointing at a session outside the room is a help request: say
1408
+ // so every time (a member's briefing reaches it once, on its first round).
1409
+ const outside = [...said.matchAll(/@\[((?:\\.|[^\\\]\n])+)\]\((dsh-session:[^)\s]+)\)/gu)];
1410
+ const text = said;
1411
+ // The hint rides the member's prompt only; the journal keeps the human's words.
1412
+ const prompt = outside.length === 0 ? said : [
1413
+ said,
1414
+ '',
1415
+ `(人点名了房间外的会话:${outside.map(match => `「${match[1].replace(/\\(.)/gu, '$1')}」`).join('、')}。用 room_help 去问:session 参数原样传那个 @[…](dsh-session:…) 引用;标签是「成员(房间)」时会交给那个成员。不要转给别的成员代问。)`,
1416
+ ].join('\n');
801
1417
  if (text === '')
802
1418
  return { ok: false, error: { code: 'empty-text' } };
803
1419
  const roster = new Set(loaded.state.members.map(member => member.name));
804
1420
  const unknown = targets.filter(target => !roster.has(target));
805
1421
  if (unknown.length > 0)
806
1422
  return { ok: false, error: { code: 'unknown-targets', names: unknown } };
807
- loaded.session.append('user/message', createUserMessage({
1423
+ const human = createUserMessage({
808
1424
  content: [{ type: 'text', text: request.text.trim() }],
809
1425
  source: { kind: 'user' },
810
- }), { surfaceOp: 'append' });
1426
+ });
1427
+ loaded.session.append('user/message', human, { surfaceOp: 'append' });
1428
+ const filed = await this.fileHumanMessage(loaded.session, human.id, request.text.trim(), request.task ?? this.consumeTaskHint(request.sessionId));
1429
+ this.moveOnFromLostRounds(loaded.session, targets);
811
1430
  const dispatch = loaded.session.append('room/dispatch', {
812
- id: request.requestId ?? randomUUID(), requestSignature, targets, text, origin: 'human',
1431
+ id: request.requestId ?? randomUUID(), requestSignature, targets, text, origin: 'human', task: filed.task,
813
1432
  targetIds: targets.map(name => memberId(loaded.session.snapshotEvents(), loaded.state.members.find(member => member.name === name))),
814
1433
  });
815
- for (const target of targets) {
816
- loaded.session.append('room/task-added', {
817
- id: randomUUID(), member: target, title: taskTitle(text), status: 'in_progress',
818
- deliveryId: `${dispatch.seq}:${memberId(loaded.session.snapshotEvents(), loaded.state.members.find(member => member.name === target))}`,
819
- });
820
- }
821
1434
  await this.ctx.sessions.flush(loaded.session);
822
1435
  for (const target of targets) {
823
- this.engine.dispatch(loaded.session, target, text, { dispatchSeq: dispatch.seq });
1436
+ this.engine.dispatch(loaded.session, target, prompt, { dispatchSeq: dispatch.seq });
824
1437
  }
825
1438
  return { ok: true, value: { parsed: { targets, text }, seq: dispatch.seq } };
826
1439
  }
827
1440
  /**
828
- * Dispatch one message to one member (host-only — the `room_message` tool's
829
- * path; the model-facing equivalent of the human's `@member text`). Same
830
- * dispatch internals as postMessage minus the user/message bubble: the
831
- * caller is the room's own main agent, so the text must NOT wear the human
832
- * bubble (source kind 'user' would misattribute it). The dispatch record
833
- * and the auto-opened in_progress task journal as usual.
834
- * @param request - room session, addressee, text.
835
- * @returns the dispatch receipt, or a rejection.
1441
+ * Send one message from a member to another (host-only — the `room_message`
1442
+ * tool and the bridge's `member_message`/`room_message` land here; the
1443
+ * human's path is postMessage). Any member may message any other: the
1444
+ * coordinator's sends journal as `coordinator` dispatches, everyone else's
1445
+ * as `member` ones. A `task` (default) has the recipient's final reply
1446
+ * reported back to the sender; a `notify` is FYI. The room's settings may
1447
+ * hold the message for the human instead (see messaging.ts).
1448
+ * @param request - room session, addressee, text, kind, optional reply reference.
1449
+ * @param actorChildSessionId - the authenticated CLI sender; absent = the room's own agent.
1450
+ * @returns the delivery receipt (with its `m<seq>` id) or the hold, or a rejection.
836
1451
  */
837
1452
  async messageMember(request, actorChildSessionId) {
1453
+ const routed = actorChildSessionId === undefined ? this.nativeRoute(request.sessionId) : undefined;
1454
+ if (routed !== undefined)
1455
+ return this.messageMember({ ...request, sessionId: routed.roomId }, routed.actor);
838
1456
  // Messaging PROMOTES too (the room_message tool's gate): the main agent
839
1457
  // answering "把 kimi 拉进来问一下…" promotes its own session, then the
840
1458
  // roster check runs against the promoted state.
841
1459
  const loaded = await this.ensureRoom(request.sessionId);
842
1460
  if (!loaded.ok)
843
1461
  return { ok: false, error: loaded.error };
844
- if (!this.isCurrentCoordinator(loaded.session, actorChildSessionId))
845
- return { ok: false, error: { code: 'not-coordinator' } };
846
1462
  if (this.handoffs.has(request.sessionId))
847
1463
  return { ok: false, error: { code: 'coordinator-busy' } };
848
- if (!loaded.state.members.some(member => member.name === request.member)) {
1464
+ const events = loaded.session.snapshotEvents();
1465
+ const sender = loaded.state.members.find(member => actorChildSessionId === undefined ? member.kind === 'main-agent' : member.childSessionId === actorChildSessionId);
1466
+ if (sender === undefined)
1467
+ return { ok: false, error: { code: 'member-not-found' } };
1468
+ const target = loaded.state.members.find(member => member.name === request.member || (member.childSessionId !== undefined && member.childSessionId === request.member));
1469
+ if (target === undefined)
849
1470
  return { ok: false, error: { code: 'member-not-found' } };
850
- }
851
1471
  const text = request.text.trim();
852
1472
  if (text === '')
853
1473
  return { ok: false, error: { code: 'empty-text' } };
854
- const coordinator = coordinatorMember(loaded.state, loaded.session.snapshotEvents());
855
- const target = loaded.state.members.find(member => member.name === request.member);
856
- const dispatch = loaded.session.append('room/dispatch', {
857
- id: randomUUID(), targets: [request.member], targetIds: [memberId(loaded.session.snapshotEvents(), target)], text, origin: 'coordinator',
858
- ...coordinator === undefined || coordinator.name === request.member ? {} : { replyTo: memberId(loaded.session.snapshotEvents(), coordinator) },
859
- });
860
- loaded.session.append('room/task-added', {
861
- id: randomUUID(), member: request.member, title: taskTitle(text), status: 'in_progress',
862
- deliveryId: `${dispatch.seq}:${memberId(loaded.session.snapshotEvents(), target)}`,
1474
+ if (target.name === sender.name)
1475
+ return { ok: false, error: { code: 'self-message' } };
1476
+ const senderId = memberId(events, sender);
1477
+ const kind = request.kind ?? 'task';
1478
+ const inReplyTo = parseMessageRef(request.inReplyTo);
1479
+ const context = this.sendContext(loaded.session, sender);
1480
+ let task = context.task;
1481
+ if (request.task !== undefined && request.task.trim() !== '') {
1482
+ const ref = resolveTaskRef(loaded.state.jobs ?? [], request.task);
1483
+ if (ref === undefined)
1484
+ return { ok: false, error: { code: 'unknown-task' } };
1485
+ // The coordinator's call re-files the human message its turn answers.
1486
+ if (context.trigger !== undefined) {
1487
+ task = ref === 'new' ? context.trigger : ref;
1488
+ loaded.session.append('room/task-link', { message: context.trigger, task, by: 'coordinator' });
1489
+ }
1490
+ else {
1491
+ task = ref === 'new' ? SELF_TASK : ref;
1492
+ }
1493
+ }
1494
+ const sent = sendOrHold(loaded.session, {
1495
+ to: target, text, fromName: sender.name,
1496
+ intent: {
1497
+ origin: this.isCurrentCoordinator(loaded.session, actorChildSessionId) ? 'coordinator' : 'member',
1498
+ from: senderId, kind, hop: workingHop(events, loaded.state, sender) + 1,
1499
+ ...kind === 'task' ? { replyTo: senderId } : {},
1500
+ ...inReplyTo === undefined ? {} : { inReplyTo },
1501
+ ...request.note === undefined || request.note.trim() === '' ? {} : { note: request.note.trim().slice(0, 500) },
1502
+ ...task === undefined ? {} : { task },
1503
+ ...context.parent === undefined ? {} : { parent: context.parent },
1504
+ },
863
1505
  });
864
1506
  await this.ctx.sessions.flush(loaded.session);
865
- this.engine.dispatch(loaded.session, request.member, text, { dispatchSeq: dispatch.seq });
866
- return { ok: true, value: { member: request.member } };
1507
+ if (!sent.delivered)
1508
+ return { ok: true, value: { member: target.name, held: sent.reason } };
1509
+ this.engine.dispatch(loaded.session, target.name, text, { dispatchSeq: sent.seq });
1510
+ return { ok: true, value: { member: target.name, messageId: messageId(sent.seq) } };
867
1511
  }
868
1512
  /** Host-authenticated actor; model tool arguments never provide the room or sender. */
869
1513
  isCurrentCoordinator(session, actorChildSessionId) {
@@ -871,15 +1515,358 @@ let RoomService = (() => {
871
1515
  const coordinator = coordinatorMember(replay(events), events);
872
1516
  return actorChildSessionId === undefined ? coordinator?.kind === 'main-agent' : coordinator?.childSessionId === actorChildSessionId;
873
1517
  }
1518
+ // ── Cross-session help (help.ts) ──────────────────────────────────────────
1519
+ equipped = new WeakSet();
1520
+ /** Register the helper tools on one helper agent (agent-local: they unwind with it). */
1521
+ equipHelper(agent) {
1522
+ if (this.equipped.has(agent))
1523
+ return;
1524
+ const tools = agent.ctx.get('tools');
1525
+ if (tools === undefined)
1526
+ return;
1527
+ this.equipped.add(agent);
1528
+ for (const def of helperTools(this)) {
1529
+ try {
1530
+ tools.register(def);
1531
+ }
1532
+ catch (error) {
1533
+ this.ctx.logger.warn(`room: helper tool on "${agent.session.id}" was refused: ${String(error)}`);
1534
+ }
1535
+ }
1536
+ }
1537
+ /** A room's display title (its latest `session/title`), else its id. */
1538
+ roomTitle(session) {
1539
+ const titled = session.snapshotEvents().findLast(event => event.type === 'session/title');
1540
+ return typeof titled?.data?.title === 'string' && titled.data.title !== '' ? titled.data.title : String(session.id);
1541
+ }
1542
+ /**
1543
+ * Resolve the session a member names: an `@[…](dsh-session:…)` mention, a
1544
+ * session id, or a title (through the host's session-reference discovery).
1545
+ */
1546
+ async resolveHelpTarget(caller, name) {
1547
+ const resolver = this.ctx.get('sessionReferenceResolver');
1548
+ if (resolver?.listCandidates === undefined)
1549
+ return { error: 'This host cannot look sessions up; room_help is unavailable.' };
1550
+ const mentioned = mentionedSessions(name)[0];
1551
+ const query = (mentioned ?? name.replace(/^@/u, '')).trim();
1552
+ let candidates = await resolver.listCandidates(caller, query, 8);
1553
+ // 「成员(房间)」 typed as text: look the room up by its own title.
1554
+ const inRoom = mentioned === undefined ? /^(.+)((.+))$/u.exec(query) : null;
1555
+ if (candidates.length === 0 && inRoom !== null)
1556
+ candidates = await resolver.listCandidates(caller, inRoom[2], 8);
1557
+ const titleOf = (entry) => entry.displayTitle ?? entry.label;
1558
+ const exact = candidates.find(entry => entry.sessionId === query) ?? candidates.find(entry => titleOf(entry) === query)
1559
+ ?? (inRoom === null ? undefined : candidates.find(entry => titleOf(entry) === inRoom[2]));
1560
+ const picked = exact ?? (candidates.length === 1 ? candidates[0] : undefined);
1561
+ if (picked !== undefined)
1562
+ return { sessionId: picked.sessionId, title: titleOf(picked) };
1563
+ if (candidates.length === 0)
1564
+ return { error: `No session matches "${name}". Ask the human to @ the session in the room.` };
1565
+ return { error: `"${name}" matches several sessions: ${candidates.map(entry => `"${titleOf(entry)}" (${entry.sessionId})`).join(', ')}. Retry with the exact title or id.` };
1566
+ }
1567
+ /**
1568
+ * A member asks another session for help, or writes again on a thread
1569
+ * (the `room_help` tool). The first ask of a session the human never
1570
+ * @-mentioned in this room waits for the human's go-ahead.
1571
+ * @param request - the calling session, the target session (new thread) or help id (existing), the text.
1572
+ * @param caller - the calling agent.
1573
+ * @returns the tool's text result.
1574
+ */
1575
+ async askHelp(request, caller, actorChildSessionId) {
1576
+ const text = request.text.trim();
1577
+ if (text === '')
1578
+ return 'The help text must be non-blank.';
1579
+ // The asker: a CLI member (authenticated by the bridge), a native member
1580
+ // (its own session routes to the room), else the room's own agent.
1581
+ const routed = actorChildSessionId === undefined ? this.nativeRoute(request.sessionId) : { roomId: SessionId(request.sessionId), actor: actorChildSessionId };
1582
+ const roomId = routed?.roomId ?? SessionId(request.sessionId);
1583
+ const loaded = this.ctx.sessions.get(roomId) !== undefined && isRoomLog(this.ctx.sessions.get(roomId).snapshotEvents())
1584
+ ? await this.ensureLive(roomId)
1585
+ : undefined;
1586
+ if (loaded === undefined || !loaded.ok)
1587
+ return 'room_help works from inside a room; this session is not one.';
1588
+ const room = loaded.session;
1589
+ const events = room.snapshotEvents();
1590
+ const asker = loaded.state.members.find(member => routed === undefined ? member.kind === 'main-agent' : member.childSessionId === routed.actor);
1591
+ if (asker === undefined)
1592
+ return 'Only a member of this room can ask for help.';
1593
+ const roomTitle = this.roomTitle(room);
1594
+ const helps = replayHelps(events);
1595
+ const threadId = request.help?.trim();
1596
+ if (threadId !== undefined && threadId !== '') {
1597
+ const help = helps.find(entry => entry.id === threadId);
1598
+ if (help === undefined)
1599
+ return `No help thread "${threadId}" in this room.`;
1600
+ room.append('room/help-message', { help: help.id, kind: 'message', text });
1601
+ await this.ctx.sessions.flush(room);
1602
+ if (help.target.room === true)
1603
+ return this.deliverToRoom(room, help, text, 'message');
1604
+ return this.deliverHelp(room, help, messagePrompt(help, roomTitle, text), 'message', text);
1605
+ }
1606
+ if (request.session === undefined || request.session.trim() === '')
1607
+ return 'Name the session to ask: its title, its id, or the @ mention the human wrote.';
1608
+ // Discovery ranks by the asking agent's workspace; a CLI member has no
1609
+ // agent here, so the room's own stands in.
1610
+ const finder = caller ?? this.ctx.agents.get(room.id);
1611
+ if (finder === undefined)
1612
+ return 'This room has no live agent to look sessions up with; try again in a moment.';
1613
+ const target = await this.resolveHelpTarget(finder, request.session.trim());
1614
+ if ('error' in target)
1615
+ return target.error;
1616
+ if (target.sessionId === room.id || loaded.state.members.some(member => member.childSessionId === target.sessionId)) {
1617
+ return 'That session is this room or one of its members; use room_message instead.';
1618
+ }
1619
+ // The host fences archived sessions off (they are not listed, cannot be
1620
+ // opened, and a waking delivery to their agent is refused): say so.
1621
+ const archived = this.ctx.get('workspaceRegistry');
1622
+ if (archived?.archivedSessionIds?.includes(target.sessionId) === true) {
1623
+ return `"${target.title}" is archived; it cannot be asked. Tell the human to unarchive it first, or pick another session.`;
1624
+ }
1625
+ // A room target goes through that room's own roster: the named member
1626
+ // (the argument, or the 「成员(房间)」 label the @ menu wrote), else its coordinator.
1627
+ const targetRoom = (await this.isRoom({ sessionId: SessionId(target.sessionId) })) === true;
1628
+ const named = request.member?.trim() || (targetRoom ? memberFromLabel(request.session, target.title) : undefined);
1629
+ if (targetRoom) {
1630
+ const other = await this.ensureLive(SessionId(target.sessionId));
1631
+ if (!other.ok)
1632
+ return `Could not open the room "${target.title}".`;
1633
+ if (named !== undefined && !other.state.members.some(member => member.name === named)) {
1634
+ return `The room "${target.title}" has no member "${named}"; its members are ${other.state.members.map(member => member.name).join(', ')}.`;
1635
+ }
1636
+ }
1637
+ const consented = referencedSessions(events).has(target.sessionId) || helps.some(entry => entry.target.sessionId === target.sessionId);
1638
+ if (!consented) {
1639
+ const answers = await this.askQuestions(room, asker, [{
1640
+ id: 'help', header: '求助',
1641
+ question: `${asker.name} 想向会话「${target.title}」求助:${text.length > 80 ? `${text.slice(0, 80)}…` : text}`,
1642
+ options: [{ label: '允许' }, { label: '不允许' }],
1643
+ }], 10 * 60_000);
1644
+ if (answers?.[0]?.selected[0] !== '允许')
1645
+ return `The human did not allow asking "${target.title}". Do not retry unless they @ that session in the room.`;
1646
+ }
1647
+ const help = {
1648
+ id: randomUUID().slice(0, 8), from: memberId(events, asker), fromName: asker.name,
1649
+ target: targetRoom ? { ...target, room: true, ...named === undefined ? {} : { member: named } } : target, text,
1650
+ };
1651
+ room.append('room/help', help);
1652
+ await this.ctx.sessions.flush(room);
1653
+ if (targetRoom)
1654
+ return this.deliverToRoom(room, help, text, 'request');
1655
+ return this.deliverHelp(room, help, requestPrompt(help, roomTitle), 'request', text);
1656
+ }
1657
+ /**
1658
+ * Hand a help request (or a follow-up on it) to another room: its named
1659
+ * member, else its coordinator, as a `help`-origin dispatch — held like any
1660
+ * agent message while that room is paused — and shown on that room's
1661
+ * timeline. The member's final reply comes back through {@link helpSettled}.
1662
+ */
1663
+ async deliverToRoom(room, help, text, op) {
1664
+ const fail = async (error) => {
1665
+ room.append('room/help-state', { help: help.id, state: 'failed', error });
1666
+ await this.ctx.sessions.flush(room);
1667
+ return `Could not reach "${help.target.title}": ${error}`;
1668
+ };
1669
+ const other = await this.ensureLive(SessionId(help.target.sessionId));
1670
+ if (!other.ok)
1671
+ return fail('that room is not available');
1672
+ const events = other.session.snapshotEvents();
1673
+ const member = help.target.member === undefined
1674
+ ? coordinatorMember(other.state, events)
1675
+ : other.state.members.find(entry => entry.name === help.target.member);
1676
+ if (member === undefined)
1677
+ return fail(help.target.member === undefined ? 'that room has no coordinator' : `that room has no member "${help.target.member}"`);
1678
+ const origin = { id: help.id, room: room.id, roomTitle: this.roomTitle(room), from: help.fromName };
1679
+ // A round a restart left uncertain blocks the member's later work until a
1680
+ // human reconciles it — silently, for a request from outside. Move on
1681
+ // from it, as a human message to that member does.
1682
+ this.moveOnFromLostRounds(other.session, [member.name], '另一个协作间来求助;被重启打断的这一轮按没做完处理');
1683
+ const sent = sendOrHold(other.session, {
1684
+ to: member, text, fromName: `${help.fromName}(${origin.roomTitle})`,
1685
+ intent: { origin: 'help', hop: 1, kind: 'task', help: origin, ...op === 'request' ? { task: SELF_TASK } : {} },
1686
+ });
1687
+ other.session.append('room/help-in', { ...origin, member: member.name, text, op, ...sent.delivered ? {} : { held: true } });
1688
+ await this.ctx.sessions.flush(other.session);
1689
+ room.append('room/help-state', { help: help.id, state: sent.delivered ? 'running' : 'queued' });
1690
+ await this.ctx.sessions.flush(room);
1691
+ if (sent.delivered)
1692
+ this.engine.dispatch(other.session, member.name, text, { dispatchSeq: sent.seq });
1693
+ const who = `${member.name} in the room "${help.target.title}"`;
1694
+ return `${sent.delivered ? `Sent to ${who}.` : `Sent to ${who}; that room is paused, so the request waits until it resumes.`}`
1695
+ + ` Help id: ${help.id}. The reply reaches you on its own — end your turn now; do not poll.`;
1696
+ }
1697
+ /**
1698
+ * A help dispatch in this room settled: its final reply (or its failure)
1699
+ * goes back to the asking room's thread.
1700
+ * @param room - the room that was asked.
1701
+ * @param dispatchSeq - the settled dispatch.
1702
+ * @param outcome - the run's outcome.
1703
+ */
1704
+ async helpSettled(room, dispatchSeq, outcome) {
1705
+ const source = dispatchSeq === undefined ? undefined : room.snapshotEvents().find(event => event.seq === dispatchSeq);
1706
+ if (source?.type !== 'room/dispatch' || source.data.help === undefined)
1707
+ return;
1708
+ const asked = source.data.help;
1709
+ const loaded = await this.ensureLive(SessionId(asked.room));
1710
+ if (!loaded.ok)
1711
+ return;
1712
+ const thread = replayHelps(loaded.session.snapshotEvents()).find(entry => entry.id === asked.id);
1713
+ if (thread === undefined)
1714
+ return;
1715
+ if (outcome.state === 'done') {
1716
+ const text = outcome.text?.trim();
1717
+ await this.recordHelperMessage(loaded.session, thread, { kind: 'reply', text: text === undefined || text === '' ? '(对方这一轮结束了,没有给出答复)' : text });
1718
+ return;
1719
+ }
1720
+ loaded.session.append('room/help-state', { help: thread.id, state: 'failed', error: outcome.error ?? `对方这一轮${outcome.state === 'cancelled' ? '被取消了' : '没有完成'}` });
1721
+ await this.ctx.sessions.flush(loaded.session);
1722
+ }
1723
+ /** Queue one help message in the helper's session (waking it when cold) and watch its turn. */
1724
+ async deliverHelp(room, help, prompt, op, display) {
1725
+ const fail = async (error) => {
1726
+ room.append('room/help-state', { help: help.id, state: 'failed', error });
1727
+ await this.ctx.sessions.flush(room);
1728
+ return `Could not reach "${help.target.title}": ${error}`;
1729
+ };
1730
+ const controller = this.ctx.get('sessionController');
1731
+ if (controller?.resolveAgent === undefined)
1732
+ return fail('this host cannot wake other sessions');
1733
+ let agent;
1734
+ try {
1735
+ const resolved = await controller.resolveAgent(SessionId(help.target.sessionId));
1736
+ if ('error' in resolved)
1737
+ return await fail(resolved.error.message);
1738
+ agent = resolved.agent;
1739
+ }
1740
+ catch (error) {
1741
+ return fail(String(error));
1742
+ }
1743
+ this.equipHelper(agent);
1744
+ const queued = agent.status === 'running';
1745
+ const message = helpMessage(prompt, { help: help.id, room: room.id, roomTitle: this.roomTitle(room), from: help.fromName, op, text: display.slice(0, 4000) });
1746
+ room.append('room/help-state', { help: help.id, state: queued ? 'queued' : 'running' });
1747
+ await this.ctx.sessions.flush(room);
1748
+ const mark = room.snapshotEvents().at(-1)?.seq ?? -1;
1749
+ agent.followup(message);
1750
+ this.watchHelp(room.id, help.id, agent, String(message.id), mark);
1751
+ return `${queued ? `Sent to "${help.target.title}"; it is busy, so the request waits in its queue.` : `Sent to "${help.target.title}".`}`
1752
+ + ` Help id: ${help.id}. Its reply reaches you on its own — end your turn now; do not poll.`;
1753
+ }
1754
+ /**
1755
+ * After the helper's turn: a turn that never claimed the message failed the
1756
+ * thread; one that ended without the reply or question tool hands over its
1757
+ * last answer as the reply.
1758
+ */
1759
+ watchHelp(roomId, helpId, agent, messageId, mark) {
1760
+ void (async () => {
1761
+ await agent.whenIdle();
1762
+ const loaded = await this.ensureLive(roomId);
1763
+ if (!loaded.ok)
1764
+ return;
1765
+ const room = loaded.session;
1766
+ const help = replayHelps(room.snapshotEvents()).find(entry => entry.id === helpId);
1767
+ if (help === undefined || help.state === 'failed')
1768
+ return;
1769
+ const answered = room.snapshotEvents().some(event => event.seq > mark && event.type === 'room/help-message' && event.data.help === helpId && event.data.kind !== 'message');
1770
+ if (answered)
1771
+ return;
1772
+ const helperEvents = agent.session.snapshotEvents();
1773
+ if (!claimed(helperEvents, messageId)) {
1774
+ room.append('room/help-state', { help: helpId, state: 'failed', error: 'the request left the helper\'s queue without being handled' });
1775
+ await this.ctx.sessions.flush(room);
1776
+ return;
1777
+ }
1778
+ const answer = turnAnswer(helperEvents, messageId);
1779
+ await this.recordHelperMessage(room, help, { kind: 'reply', text: answer === '' ? '(对方这一轮结束了,没有给出答复)' : answer, fallback: true });
1780
+ })().catch((error) => { this.ctx.logger.warn(`room: help ${helpId} watch faulted: ${String(error)}`); });
1781
+ }
1782
+ /** A help request reached the helper's turn: a queued thread is running now. */
1783
+ async markHelpRunning(source) {
1784
+ const room = this.ctx.sessions.get(SessionId(source.room));
1785
+ if (room === undefined)
1786
+ return;
1787
+ const help = replayHelps(room.snapshotEvents()).find(entry => entry.id === source.help);
1788
+ if (help === undefined || help.state === 'running')
1789
+ return;
1790
+ if (help.state !== 'queued' && !(help.state === 'asking' && source.op === 'message') && !(help.state === 'replied' && source.op === 'message'))
1791
+ return;
1792
+ room.append('room/help-state', { help: help.id, state: 'running' });
1793
+ await this.ctx.sessions.flush(room);
1794
+ }
1795
+ /** Journal a helper's question or reply and hand it to the asking member. */
1796
+ async recordHelperMessage(room, help, message) {
1797
+ room.append('room/help-message', { help: help.id, kind: message.kind, text: message.text, ...message.handoff === undefined ? {} : { handoff: message.handoff }, ...message.fallback === undefined ? {} : { fallback: true } });
1798
+ room.append('room/help-state', { help: help.id, state: message.kind === 'reply' ? 'replied' : 'asking' });
1799
+ const events = room.snapshotEvents();
1800
+ const state = replay(events);
1801
+ const asker = state.members.find(member => memberId(events, member) === help.from) ?? coordinatorMember(state, events);
1802
+ if (asker === undefined) {
1803
+ await this.ctx.sessions.flush(room);
1804
+ return;
1805
+ }
1806
+ const text = askerPrompt(help, message);
1807
+ const dispatch = room.append('room/dispatch', {
1808
+ id: randomUUID(), origin: 'report', reportFor: `help:${help.id}`, targets: [asker.name], targetIds: [memberId(events, asker)], text,
1809
+ });
1810
+ await this.ctx.sessions.flush(room);
1811
+ this.engine.dispatch(room, asker.name, text, { dispatchSeq: dispatch.seq });
1812
+ }
1813
+ /**
1814
+ * The helper's `room_help_reply` / `room_help_ask`: find the thread from
1815
+ * the help requests in the helper's own log, then carry the message back.
1816
+ * @param agent - the helper agent.
1817
+ * @param request - thread (optional when only one), kind, text, handoff.
1818
+ * @returns the tool's text result.
1819
+ */
1820
+ async helperMessage(agent, request) {
1821
+ const received = receivedHelps(agent.session.snapshotEvents());
1822
+ const wanted = request.help?.trim();
1823
+ const source = wanted === undefined || wanted === '' ? received.at(-1) : received.find(entry => entry.help === wanted);
1824
+ if (source === undefined) {
1825
+ return received.length === 0
1826
+ ? 'No help request reached this session.'
1827
+ : `No help "${wanted}" here; the requests this session received: ${received.map(entry => `${entry.help} (from ${entry.from} in "${entry.roomTitle}")`).join(', ')}.`;
1828
+ }
1829
+ const text = request.text.trim();
1830
+ if (text === '')
1831
+ return 'The text must be non-blank.';
1832
+ const loaded = await this.ensureLive(SessionId(source.room));
1833
+ if (!loaded.ok)
1834
+ return `The room "${source.roomTitle}" that asked is no longer available.`;
1835
+ const help = replayHelps(loaded.session.snapshotEvents()).find(entry => entry.id === source.help);
1836
+ if (help === undefined)
1837
+ return 'That help thread is gone.';
1838
+ const handoff = request.handoff?.trim();
1839
+ await this.recordHelperMessage(loaded.session, help, { kind: request.kind, text, ...handoff === undefined || handoff === '' ? {} : { handoff } });
1840
+ return request.kind === 'reply'
1841
+ ? `Your reply reached ${help.fromName} in "${source.roomTitle}".`
1842
+ : `Your question went to ${help.fromName} in "${source.roomTitle}"; the answer arrives as a new message. End your turn now.`;
1843
+ }
874
1844
  /** Bounded shared room context for native and external model tools; reading never wakes an agent. */
875
1845
  async readRoomContext(sessionId) {
1846
+ const routed = this.nativeRoute(sessionId);
1847
+ if (routed !== undefined) {
1848
+ const self = replay(this.ctx.sessions.get(routed.roomId).snapshotEvents()).members.find(member => member.childSessionId === routed.actor)?.name;
1849
+ return JSON.stringify({ self, ...JSON.parse(await this.readRoomContext(routed.roomId)) });
1850
+ }
876
1851
  const state = await this.getState({ sessionId: SessionId(sessionId) });
1852
+ if (!state.ok && state.error.code === 'not-a-room') {
1853
+ // A plain session: everything an invite needs, instead of an error that
1854
+ // sends the model guessing provider ids.
1855
+ const offered = await this.listProviders({ sessionId: SessionId(sessionId) });
1856
+ return JSON.stringify({
1857
+ room: false,
1858
+ note: 'This session is not a room yet. room_invite promotes it into one: no other setup is needed.',
1859
+ providers: offered.providers.map(entry => ({ provider: entry.provider, name: entry.displayName, ...entry.authenticated ? {} : { loggedOut: true } })),
1860
+ ...offered.presets === undefined ? {} : { modes: offered.presets.map(entry => ({ id: entry.id, ...entry.name === undefined ? {} : { name: entry.name } })) },
1861
+ ...offered.sessionPreset === undefined ? {} : { sessionMode: offered.sessionPreset },
1862
+ });
1863
+ }
877
1864
  if (!state.ok)
878
1865
  throw new Error(`Room unavailable: ${state.error.code}`);
879
1866
  const events = this.ctx.sessions.get(SessionId(sessionId))?.snapshotEvents() ?? [];
880
1867
  const recent = events.filter(event => event.type === 'room/speech').slice(-12).map(event => event.type === 'room/speech' ? { member: event.data.member, text: event.data.text.slice(0, 2000) } : null);
881
1868
  return JSON.stringify({ planCommands: planCommandContract(), coordinator: coordinatorMember(state.value)?.name,
882
- state: { ...state.value, deliveries: state.value.deliveries?.slice(-30), tasks: state.value.tasks.slice(-100), relays: state.value.relays.slice(-30) },
1869
+ state: { ...state.value, deliveries: state.value.deliveries?.slice(-30), jobs: state.value.jobs?.slice(0, 20), relays: state.value.relays.slice(-30) },
883
1870
  providers: await this.listProviders({}), recent });
884
1871
  }
885
1872
  /** Human UI and native/external tools share the same durable transition service. */
@@ -899,6 +1886,9 @@ let RoomService = (() => {
899
1886
  }
900
1887
  /** The actor is derived from the calling native agent or authenticated bridge. */
901
1888
  async commandPlan(sessionId, command, actorChildSessionId) {
1889
+ const routed = actorChildSessionId === undefined ? this.nativeRoute(sessionId) : undefined;
1890
+ if (routed !== undefined)
1891
+ return this.commandPlan(routed.roomId, command, routed.actor);
902
1892
  const loaded = await this.ensureLive(SessionId(sessionId));
903
1893
  if (!loaded.ok)
904
1894
  throw new Error(loaded.error.code);
@@ -914,6 +1904,151 @@ let RoomService = (() => {
914
1904
  const { requests: _requests, ...view } = plan;
915
1905
  return JSON.stringify(view);
916
1906
  }
1907
+ /**
1908
+ * The mode of an external member — the host preset whose plugin tools its
1909
+ * bridge offers (local-agent's preset tool host runs them) — with its
1910
+ * working directory. Host method for the member channel, never a Remote.
1911
+ * @param actor - the authenticated member run.
1912
+ * @returns the mode, or undefined when the member has none or is not here.
1913
+ */
1914
+ async memberMode(actor) {
1915
+ const loaded = await this.ensureLive(SessionId(actor.parentSessionId));
1916
+ if (!loaded.ok)
1917
+ return undefined;
1918
+ const cli = loaded.state.members.filter(entry => entry.kind === 'cli' && entry.provider === actor.provider);
1919
+ // A member's first round asks before the journal knows its child session
1920
+ // (claude lists MCP tools at spawn): the one first start in flight for
1921
+ // this provider is the asker. Two at once stay ambiguous — no mode.
1922
+ const starting = this.engine.startingMembers(actor.parentSessionId);
1923
+ const fresh = cli.filter(entry => entry.childSessionId === undefined && starting.includes(memberId(loaded.session.snapshotEvents(), entry)));
1924
+ const member = cli.find(entry => entry.childSessionId === actor.childSessionId)
1925
+ ?? (fresh.length === 1 ? fresh[0] : undefined);
1926
+ if (member?.preset === undefined)
1927
+ return undefined;
1928
+ const cwd = member.cwd ?? loaded.session.header.cwd;
1929
+ return { preset: member.preset, ...cwd === undefined ? {} : { cwd } };
1930
+ }
1931
+ /** Pending member tool approvals this process can still answer: approval id → its waiting call. */
1932
+ approvalWaiters = new Map();
1933
+ /** Close approvals no waiting call stands behind any more (a restart lost it). */
1934
+ expireApprovals(session) {
1935
+ for (const approval of replay(session.snapshotEvents()).approvals ?? []) {
1936
+ if (!this.approvalWaiters.has(approval.id))
1937
+ session.append('room/approval-resolved', { id: approval.id, outcome: 'cancelled' });
1938
+ }
1939
+ }
1940
+ /**
1941
+ * A CLI member's mode tool needs the human's approval: ask in the room's
1942
+ * conversation (a 允许 / 拒绝 line) and resolve when the human answers,
1943
+ * the call is cancelled, or the room is gone. Host method for the member
1944
+ * channel (local-agent's approval hook), never a Remote.
1945
+ * @param actor - the authenticated member run.
1946
+ * @param request - the tool, an optional reason, and the call's signal.
1947
+ * @returns the approval outcome the tool registry expects.
1948
+ */
1949
+ async askMemberApproval(actor, request) {
1950
+ const loaded = await this.ensureLive(SessionId(actor.parentSessionId));
1951
+ if (!loaded.ok)
1952
+ return 'unavailable';
1953
+ const events = loaded.session.snapshotEvents();
1954
+ const member = loaded.state.members.find(entry => entry.childSessionId === actor.childSessionId && entry.provider === actor.provider);
1955
+ if (member === undefined)
1956
+ return 'unavailable';
1957
+ if (request.signal?.aborted === true)
1958
+ return 'cancelled';
1959
+ const id = randomUUID();
1960
+ const session = loaded.session;
1961
+ const answered = new Promise((resolve) => {
1962
+ this.approvalWaiters.set(id, resolve);
1963
+ request.signal?.addEventListener('abort', () => { resolve('cancelled'); }, { once: true });
1964
+ });
1965
+ session.append('room/approval', { id, member: member.name, memberId: memberId(events, member), tool: request.toolName,
1966
+ ...request.reason === undefined || request.reason.trim() === '' ? {} : { reason: request.reason.trim().slice(0, 500) } });
1967
+ await this.ctx.sessions.flush(session);
1968
+ const outcome = await answered;
1969
+ this.approvalWaiters.delete(id);
1970
+ if (replay(session.snapshotEvents()).approvals?.some(entry => entry.id === id) === true) {
1971
+ session.append('room/approval-resolved', { id, outcome });
1972
+ await this.ctx.sessions.flush(session);
1973
+ }
1974
+ return outcome;
1975
+ }
1976
+ /**
1977
+ * The human answers a member's tool approval from the room.
1978
+ * @param request - room session, approval id, allow once or reject.
1979
+ * @returns ok, or `approval-not-pending` when it already ended.
1980
+ */
1981
+ async answerApproval(request) {
1982
+ const loaded = await this.ensureLive(request.sessionId);
1983
+ if (!loaded.ok)
1984
+ return { ok: false, error: loaded.error };
1985
+ const waiter = this.approvalWaiters.get(request.id);
1986
+ if (waiter === undefined || loaded.state.approvals?.some(entry => entry.id === request.id) !== true)
1987
+ return { ok: false, error: { code: 'approval-not-pending' } };
1988
+ loaded.session.append('room/approval-resolved', { id: request.id, outcome: request.outcome });
1989
+ await this.ctx.sessions.flush(loaded.session);
1990
+ waiter(request.outcome);
1991
+ return { ok: true };
1992
+ }
1993
+ /** Pending member questions this process can still answer: question id → its waiting call. */
1994
+ questionWaiters = new Map();
1995
+ /** Close question cards no waiting call stands behind any more (a restart lost it). */
1996
+ expireQuestions(session) {
1997
+ for (const question of replay(session.snapshotEvents()).questions ?? []) {
1998
+ if (!this.questionWaiters.has(question.id))
1999
+ session.append('room/question-resolved', { id: question.id, outcome: 'cancelled' });
2000
+ }
2001
+ }
2002
+ /**
2003
+ * A member asks the human questions (its bridge's `ask_user_question`):
2004
+ * journal a pick-to-answer card in the room and wait for the answer, the
2005
+ * timeout, or the room going away.
2006
+ * @param session - the live room session.
2007
+ * @param member - the asking member.
2008
+ * @param questions - the validated questions.
2009
+ * @param timeoutMs - how long the call waits before it gives up.
2010
+ * @returns the answers, or undefined when nobody answered.
2011
+ */
2012
+ async askQuestions(session, member, questions, timeoutMs) {
2013
+ const id = randomUUID();
2014
+ const answered = new Promise((resolve) => {
2015
+ this.questionWaiters.set(id, resolve);
2016
+ setTimeout(() => { resolve(undefined); }, timeoutMs).unref?.();
2017
+ });
2018
+ session.append('room/question', { id, member: member.name, memberId: memberId(session.snapshotEvents(), member), questions });
2019
+ await this.ctx.sessions.flush(session);
2020
+ const answers = await answered;
2021
+ this.questionWaiters.delete(id);
2022
+ if (answers === undefined && replay(session.snapshotEvents()).questions?.some(entry => entry.id === id) === true) {
2023
+ session.append('room/question-resolved', { id, outcome: 'cancelled' });
2024
+ await this.ctx.sessions.flush(session);
2025
+ }
2026
+ return answers;
2027
+ }
2028
+ /**
2029
+ * The human answers a member's question card from the room.
2030
+ * @param request - room session, card id, one answer per question.
2031
+ * @returns ok, or `question-not-pending` when it already ended.
2032
+ */
2033
+ async answerQuestion(request) {
2034
+ const loaded = await this.ensureLive(request.sessionId);
2035
+ if (!loaded.ok)
2036
+ return { ok: false, error: loaded.error };
2037
+ const waiter = this.questionWaiters.get(request.id);
2038
+ const pending = loaded.state.questions?.find(entry => entry.id === request.id);
2039
+ if (waiter === undefined || pending === undefined)
2040
+ return { ok: false, error: { code: 'question-not-pending' } };
2041
+ // Keep only answers to this card's own questions, in the shape the asker expects.
2042
+ const answers = pending.questions.map((question) => {
2043
+ const given = request.answers.find(answer => answer.id === question.id);
2044
+ const custom = given?.custom?.trim();
2045
+ return { id: question.id, selected: [...given?.selected ?? []].filter(label => typeof label === 'string'), ...custom === undefined || custom === '' ? {} : { custom } };
2046
+ });
2047
+ loaded.session.append('room/question-resolved', { id: request.id, outcome: 'answered', answers });
2048
+ await this.ctx.sessions.flush(loaded.session);
2049
+ waiter(answers);
2050
+ return { ok: true };
2051
+ }
917
2052
  /** Shared backend for external MCP room tools. Not exposed as a browser Remote. */
918
2053
  async receiveMemberCommand(actor, command) {
919
2054
  try {
@@ -926,7 +2061,7 @@ let RoomService = (() => {
926
2061
  if (command === null || typeof command !== 'object' || command.arguments === null || typeof command.arguments !== 'object' || Array.isArray(command.arguments))
927
2062
  return { ok: false, error: 'Malformed room command' };
928
2063
  const args = command.arguments;
929
- const allowed = { room_plan: ['command'], room_read: [], room_invite: ['provider', 'name', 'instructions', 'cwd', 'model', 'firstTask'], room_message: ['member', 'text'] };
2064
+ const allowed = { room_plan: ['command'], room_read: [], room_invite: ['provider', 'name', 'role', 'instructions', 'cwd', 'model', 'effort', 'mode', 'firstTask', 'note'], room_message: ['member', 'text', 'kind', 'inReplyTo', 'note', 'task'], ask_user_question: ['questions'], room_help: ['session', 'member', 'help', 'text'] };
930
2065
  const keys = allowed[command.name];
931
2066
  if (keys === undefined || Object.keys(args).some(key => !keys.includes(key)))
932
2067
  return { ok: false, error: 'Unknown room command or argument; room and actor identity are host-owned' };
@@ -937,21 +2072,50 @@ let RoomService = (() => {
937
2072
  return value;
938
2073
  };
939
2074
  const optional = (key) => args[key] === undefined ? undefined : required(key);
2075
+ if (command.name === 'ask_user_question') {
2076
+ const questions = parseQuestions(args.questions);
2077
+ if (typeof questions === 'string')
2078
+ return { ok: false, error: questions };
2079
+ const answers = await this.askQuestions(loaded.session, sender, questions, QUESTION_TIMEOUT_MS);
2080
+ return answers === undefined
2081
+ ? { ok: false, error: 'The human did not answer (the question expired or the room was stopped). Continue with your best judgement and say what you assumed.' }
2082
+ : { ok: true, receipt: JSON.stringify({ answers }) };
2083
+ }
2084
+ if (command.name === 'room_help') {
2085
+ const text = await this.askHelp({ sessionId: loaded.session.id, text: required('text'),
2086
+ ...optional('session') === undefined ? {} : { session: optional('session') },
2087
+ ...optional('member') === undefined ? {} : { member: optional('member') },
2088
+ ...optional('help') === undefined ? {} : { help: optional('help') } }, undefined, actor.childSessionId);
2089
+ return { ok: true, receipt: text };
2090
+ }
940
2091
  if (command.name === 'room_read') {
941
2092
  return { ok: true, receipt: JSON.stringify({ self: sender.name, ...JSON.parse(await this.readRoomContext(loaded.session.id)) }) };
942
2093
  }
943
2094
  if (command.name === 'room_plan')
944
2095
  return { ok: true, receipt: await this.commandPlan(loaded.session.id, required('command'), actor.childSessionId) };
2096
+ if (command.name === 'room_message') {
2097
+ const kind = optional('kind');
2098
+ if (kind !== undefined && kind !== 'task' && kind !== 'notify')
2099
+ return { ok: false, error: 'kind must be "task" or "notify"' };
2100
+ const sent = await this.messageMember({ sessionId: loaded.session.id, member: required('member'), text: required('text'),
2101
+ ...kind === undefined ? {} : { kind }, ...optional('inReplyTo') === undefined ? {} : { inReplyTo: optional('inReplyTo') },
2102
+ ...optional('note') === undefined ? {} : { note: optional('note') },
2103
+ ...optional('task') === undefined ? {} : { task: optional('task') } }, actor.childSessionId);
2104
+ return sent.ok ? { ok: true, receipt: messageReceipt(sent.value, kind ?? 'task') } : { ok: false, error: sent.error.code };
2105
+ }
945
2106
  if (!this.isCurrentCoordinator(loaded.session, actor.childSessionId))
946
- return { ok: false, error: 'Only the current coordinator may invite members or dispatch work' };
947
- const result = command.name === 'room_invite'
948
- ? await this.inviteMember({ sessionId: loaded.session.id, provider: required('provider'), name: required('name'),
949
- ...optional('instructions') === undefined ? {} : { instructions: optional('instructions') },
950
- ...optional('cwd') === undefined ? {} : { cwd: optional('cwd') },
951
- ...optional('model') === undefined ? {} : { model: optional('model') },
952
- ...optional('firstTask') === undefined ? {} : { firstTask: optional('firstTask') },
953
- }, 'agent', actor.childSessionId)
954
- : await this.messageMember({ sessionId: loaded.session.id, member: required('member'), text: required('text') }, actor.childSessionId);
2107
+ return { ok: false, error: 'Only the current coordinator may invite members' };
2108
+ const result = await this.inviteMember({ sessionId: loaded.session.id, provider: required('provider'), name: required('name'),
2109
+ ...optional('role') === undefined ? {} : { role: optional('role') },
2110
+ ...optional('instructions') === undefined ? {} : { instructions: optional('instructions') },
2111
+ ...optional('cwd') === undefined ? {} : { cwd: optional('cwd') },
2112
+ ...optional('model') === undefined ? {} : { model: optional('model') },
2113
+ ...optional('effort') === undefined ? {} : { effort: optional('effort') },
2114
+ ...optional('firstTask') === undefined ? {} : { firstTask: optional('firstTask') },
2115
+ ...optional('note') === undefined ? {} : { note: optional('note') },
2116
+ // Omitted keeps the room's own mode; "none" asks for none.
2117
+ ...optional('mode') === undefined ? { inheritPreset: true } : optional('mode') === 'none' ? {} : { preset: optional('mode') },
2118
+ }, 'agent', actor.childSessionId);
955
2119
  return result.ok ? { ok: true, receipt: JSON.stringify(result.value) } : { ok: false, error: result.error.code };
956
2120
  }
957
2121
  catch (error) {
@@ -959,20 +2123,18 @@ let RoomService = (() => {
959
2123
  }
960
2124
  }
961
2125
  /**
962
- * The notification gate entry, verbatim-frozen contract for the local-agent
963
- * family bridge (`ctx.get('room')` + duck-typed call). The call crosses an
964
- * untyped package boundary, so the message shape is validated at runtime:
965
- * a malformed message throws (the bridge treats a rejection as "room does
2126
+ * The bridge's `member_message` entry (contract shared with the local-agent
2127
+ * family: `ctx.get('room')` + duck-typed call). The call crosses an untyped
2128
+ * package boundary, so the message shape is validated at runtime: a
2129
+ * malformed message throws (the bridge treats a rejection as "room does
966
2130
  * not claim this" and delivers directly) instead of poisoning the journal.
967
- * `from`/`to` arrive as roster names OR member child session ids (the
968
- * bridge cannot speak names for the sender — only room owns the roster);
969
- * both are normalized to roster names when resolvable, so the pending card
970
- * and the delivery text (`{from} 给你的通知: …`) read as names. Phase 1's
971
- * gate is ALWAYS human confirmation: the relay is journaled pending and
972
- * the sender receives 'pending-confirm' (never 'sent' — the bridge keeps
973
- * the member's conclusion honest). A non-room parent session throws.
974
- * @param request - sender, recipient, content, the shared parent session, provenance.
975
- * @returns the gate receipt.
2131
+ * A roster sender (resolved by child session id + provider, never
2132
+ * self-reported) goes through {@link messageMember}: delivered at once or
2133
+ * held by the room's settings. The receipt tells the model what happened
2134
+ * (`sent #m12 …` / `pending-confirm …`). A sender the roster does not know
2135
+ * keeps the legacy pending relay.
2136
+ * @param request - sender, recipient, content, the shared parent session, provenance, kind.
2137
+ * @returns the receipt quoted back to the sending model.
976
2138
  */
977
2139
  async receiveMemberMessage(request) {
978
2140
  if (typeof request?.from !== 'string' || typeof request.to !== 'string'
@@ -983,14 +2145,15 @@ let RoomService = (() => {
983
2145
  if (!loaded.ok)
984
2146
  throw new Error(`room: not a room session (${loaded.error.code})`);
985
2147
  const sender = loaded.state.members.find(member => member.childSessionId === request.from && member.provider === request.provenance?.provider);
986
- if (sender !== undefined && this.isCurrentCoordinator(loaded.session, request.from)) {
987
- const target = loaded.state.members.find(member => member.name === request.to || member.childSessionId === request.to);
988
- if (target === undefined)
989
- throw new Error('Unknown room member');
990
- const result = await this.messageMember({ sessionId: loaded.session.id, member: target.name, text: request.content }, request.from);
2148
+ if (sender !== undefined) {
2149
+ const kind = request.kind === 'notify' ? 'notify' : 'task';
2150
+ const result = await this.messageMember({ sessionId: loaded.session.id, member: request.to, text: request.content, kind,
2151
+ ...typeof request.inReplyTo === 'string' ? { inReplyTo: request.inReplyTo } : {},
2152
+ ...typeof request.note === 'string' ? { note: request.note } : {},
2153
+ ...typeof request.task === 'string' ? { task: request.task } : {} }, request.from);
991
2154
  if (!result.ok)
992
2155
  throw new Error(result.error.code);
993
- return 'sent';
2156
+ return messageReceipt(result.value, kind);
994
2157
  }
995
2158
  const resolveName = (endpoint) => {
996
2159
  const byName = loaded.state.members.find(member => member.name === endpoint);
@@ -1028,6 +2191,11 @@ let RoomService = (() => {
1028
2191
  if (!loaded.state.members.some(member => member.name === relay.to)) {
1029
2192
  return { ok: false, error: { code: 'member-not-found' } };
1030
2193
  }
2194
+ if (relay.intent !== undefined) {
2195
+ this.release(loaded.session, relay, true);
2196
+ await this.ctx.sessions.flush(loaded.session);
2197
+ return { ok: true, value: { relayId: relay.id } };
2198
+ }
1031
2199
  loaded.session.append('room/relay-resolved', { id: relay.id, state: 'confirmed' });
1032
2200
  await this.ctx.sessions.flush(loaded.session);
1033
2201
  this.engine.dispatch(loaded.session, relay.to, `${relay.from} 给你的通知: ${relay.content}`, {
@@ -1036,109 +2204,168 @@ let RoomService = (() => {
1036
2204
  return { ok: true, value: { relayId: relay.id } };
1037
2205
  }
1038
2206
  /**
1039
- * Dismiss a pending relay: the notification never reaches anyone.
1040
- * @param request - room session, relay id.
1041
- * @returns the resolution receipt, or a rejection.
2207
+ * Deliver a held message as the dispatch it would have been. `force`
2208
+ * (the human's confirm) skips the hold rules; otherwise (resuming a paused
2209
+ * room) they are re-checked, so a message may be held again for another
2210
+ * reason — the old hold is then superseded (dismissed) by the new one.
2211
+ * The caller flushes.
1042
2212
  */
1043
- async dismissRelay(request) {
2213
+ release(session, relay, force) {
2214
+ const intent = releaseIntent(relay);
2215
+ const target = replay(session.snapshotEvents()).members.find(member => member.name === relay.to);
2216
+ if (intent === undefined || target === undefined)
2217
+ return;
2218
+ const sent = sendOrHold(session, { to: target, text: relay.content, fromName: relay.from, intent }, force);
2219
+ session.append('room/relay-resolved', { id: relay.id, state: sent.delivered ? 'sent' : 'dismissed' });
2220
+ if (sent.delivered)
2221
+ this.engine.dispatch(session, target.name, relay.content, { dispatchSeq: sent.seq });
2222
+ }
2223
+ /**
2224
+ * Hand a member the reports the gate holds for it now (the room's
2225
+ * 「现在交给」 button).
2226
+ * @param request - room session, member name.
2227
+ * @returns whether anything was held.
2228
+ */
2229
+ async releaseReports(request) {
1044
2230
  const loaded = await this.ensureLive(request.sessionId);
1045
2231
  if (!loaded.ok)
1046
2232
  return { ok: false, error: loaded.error };
1047
- const relay = loaded.state.relays.find(entry => entry.id === request.relayId);
1048
- if (relay === undefined)
1049
- return { ok: false, error: { code: 'relay-not-found' } };
1050
- if (relay.state !== 'pending')
1051
- return { ok: false, error: { code: 'relay-not-pending' } };
1052
- loaded.session.append('room/relay-resolved', { id: relay.id, state: 'dismissed' });
1053
- await this.ctx.sessions.flush(loaded.session);
1054
- return { ok: true, value: { relayId: relay.id } };
2233
+ const member = loaded.state.members.find(entry => entry.name === request.name);
2234
+ if (member === undefined)
2235
+ return { ok: false, error: { code: 'member-not-found' } };
2236
+ const released = await this.engine.releaseHeld(loaded.session, memberId(loaded.session.snapshotEvents(), member), 'manual');
2237
+ return { ok: true, value: { released } };
1055
2238
  }
1056
2239
  /**
1057
- * Add a task to a member's board lane (the human's management entry; @-
1058
- * dispatches auto-open in_progress tasks, this opens a pending one).
1059
- * `blockedBy` names the member the task waits on (display only — it never
1060
- * dispatches anything); it must resolve against the roster.
1061
- * @param request - room session, member, title, optional blockedBy.
1062
- * @returns the new task's id, or a rejection.
2240
+ * Change the room's collaboration settings (any subset). Resuming a paused
2241
+ * room releases everything the pause held, in order.
2242
+ * @param request - room session and the fields to change.
2243
+ * @returns the settings after the change, or a rejection.
1063
2244
  */
1064
- async addTask(request) {
2245
+ async setSettings(request) {
1065
2246
  const loaded = await this.ensureLive(request.sessionId);
1066
2247
  if (!loaded.ok)
1067
2248
  return { ok: false, error: loaded.error };
1068
- if (!loaded.state.members.some(member => member.name === request.member)) {
1069
- return { ok: false, error: { code: 'member-not-found' } };
2249
+ if (request.hopLimit !== undefined && (!Number.isInteger(request.hopLimit) || request.hopLimit < 2 || request.hopLimit > 50)) {
2250
+ return { ok: false, error: { code: 'invalid-settings' } };
1070
2251
  }
1071
- if (request.title.trim() === '')
1072
- return { ok: false, error: { code: 'empty-text' } };
1073
- if (request.blockedBy !== undefined
1074
- && !loaded.state.members.some(member => member.name === request.blockedBy)) {
1075
- return { ok: false, error: { code: 'member-not-found' } };
2252
+ if (request.memberMessages !== undefined && request.memberMessages !== 'free' && request.memberMessages !== 'confirm') {
2253
+ return { ok: false, error: { code: 'invalid-settings' } };
2254
+ }
2255
+ if (request.reportTiming !== undefined && request.reportTiming !== 'judge' && request.reportTiming !== 'each') {
2256
+ return { ok: false, error: { code: 'invalid-settings' } };
2257
+ }
2258
+ if (request.autoTaskFiling !== undefined && typeof request.autoTaskFiling !== 'boolean')
2259
+ return { ok: false, error: { code: 'invalid-settings' } };
2260
+ if (request.reportThreshold !== undefined && (typeof request.reportThreshold !== 'number' || !(request.reportThreshold >= 0.1 && request.reportThreshold <= 0.9))) {
2261
+ return { ok: false, error: { code: 'invalid-settings' } };
2262
+ }
2263
+ if (request.reportWaitMinutes !== undefined && (!Number.isInteger(request.reportWaitMinutes) || request.reportWaitMinutes < 1 || request.reportWaitMinutes > 30)) {
2264
+ return { ok: false, error: { code: 'invalid-settings' } };
2265
+ }
2266
+ if (request.helpReminderMinutes !== undefined && (!Number.isInteger(request.helpReminderMinutes) || request.helpReminderMinutes < 10 || request.helpReminderMinutes > 120)) {
2267
+ return { ok: false, error: { code: 'invalid-settings' } };
2268
+ }
2269
+ const change = {
2270
+ ...request.helpReminderMinutes === undefined ? {} : { helpReminderMinutes: request.helpReminderMinutes },
2271
+ ...request.memberMessages === undefined ? {} : { memberMessages: request.memberMessages },
2272
+ ...request.hopLimit === undefined ? {} : { hopLimit: request.hopLimit },
2273
+ ...request.paused === undefined ? {} : { paused: request.paused },
2274
+ ...request.reportTiming === undefined ? {} : { reportTiming: request.reportTiming },
2275
+ ...request.autoTaskFiling === undefined ? {} : { autoTaskFiling: request.autoTaskFiling },
2276
+ ...request.reportThreshold === undefined ? {} : { reportThreshold: Math.round(request.reportThreshold * 100) / 100 },
2277
+ ...request.reportWaitMinutes === undefined ? {} : { reportWaitMinutes: request.reportWaitMinutes },
2278
+ };
2279
+ if (Object.keys(change).length === 0)
2280
+ return { ok: false, error: { code: 'nothing-to-update' } };
2281
+ const wasPaused = loaded.state.settings?.paused === true;
2282
+ loaded.session.append('room/settings', change);
2283
+ if (wasPaused && request.paused === false) {
2284
+ for (const relay of loaded.state.relays) {
2285
+ if (relay.state === 'pending' && relay.reason === 'paused')
2286
+ this.release(loaded.session, relay, false);
2287
+ }
1076
2288
  }
1077
- const id = randomUUID();
1078
- loaded.session.append('room/task-added', {
1079
- id, member: request.member, title: request.title.trim(), status: 'pending',
1080
- ...request.blockedBy === undefined ? {} : { blockedBy: request.blockedBy },
1081
- });
1082
2289
  await this.ctx.sessions.flush(loaded.session);
1083
- return { ok: true, value: { id } };
2290
+ return { ok: true, value: replay(loaded.session.snapshotEvents()).settings ?? DEFAULT_ROOM_SETTINGS };
1084
2291
  }
1085
2292
  /**
1086
- * Close an open task (done by default, or cancelled). A failed task is
1087
- * still open for closing — that is the human's dismiss path after a
1088
- * failed run (the board's [关闭] passes 'cancelled').
1089
- * @param request - room session, task id, optional closing status.
1090
- * @returns the closed task's id, or a rejection.
2293
+ * Keep an outside session on the member list (「加入房间成员」): only a
2294
+ * shortcut for asking it — it stays in its own session, asks still go
2295
+ * through room_help, and its usage stays its own.
2296
+ * @param request - room session, the outside session and its title.
2297
+ * @returns ok, or a rejection.
1091
2298
  */
1092
- async closeTask(request) {
2299
+ async linkSession(request) {
1093
2300
  const loaded = await this.ensureLive(request.sessionId);
1094
2301
  if (!loaded.ok)
1095
2302
  return { ok: false, error: loaded.error };
1096
- const task = loaded.state.tasks.find(entry => entry.id === request.taskId);
1097
- if (task === undefined)
1098
- return { ok: false, error: { code: 'task-not-found' } };
1099
- if (task.status === 'done' || task.status === 'cancelled') {
1100
- return { ok: false, error: { code: 'task-closed' } };
2303
+ const title = request.title?.trim();
2304
+ if (request.target === request.sessionId || title === undefined || title === '' || loaded.state.members.some(member => member.childSessionId === request.target)) {
2305
+ return { ok: false, error: { code: 'invalid-settings' } };
1101
2306
  }
1102
- loaded.session.append('room/task-updated', { id: task.id, status: request.status ?? 'done' });
2307
+ if (loaded.state.linked?.some(entry => entry.sessionId === request.target) === true)
2308
+ return { ok: true };
2309
+ loaded.session.append('room/help-link', { sessionId: request.target, title });
1103
2310
  await this.ctx.sessions.flush(loaded.session);
1104
- return { ok: true, value: { id: task.id } };
2311
+ return { ok: true };
1105
2312
  }
1106
2313
  /**
1107
- * Edit an open task's title and/or blockedBy (the `room_task` tool's update
1108
- * action; journaled as `room/task-edited`, status changes stay on
1109
- * closeTask). Host method only — the capsule UI edits nothing but the goal.
1110
- * `blockedBy: null` clears the wait; a string must resolve against the
1111
- * roster. A closed task takes no edits.
1112
- * @param request - room session, task id, and at least one edited field.
1113
- * @returns the edited task's id, or a rejection.
2314
+ * Take an outside session off the member list (its own session is untouched).
2315
+ * @param request - room session, the outside session.
2316
+ * @returns ok, or `member-not-found` when it was not on the list.
1114
2317
  */
1115
- async updateTask(request) {
2318
+ async unlinkSession(request) {
1116
2319
  const loaded = await this.ensureLive(request.sessionId);
1117
2320
  if (!loaded.ok)
1118
2321
  return { ok: false, error: loaded.error };
1119
- const task = loaded.state.tasks.find(entry => entry.id === request.taskId);
1120
- if (task === undefined)
1121
- return { ok: false, error: { code: 'task-not-found' } };
1122
- if (task.status === 'done' || task.status === 'cancelled') {
1123
- return { ok: false, error: { code: 'task-closed' } };
1124
- }
1125
- if (request.title === undefined && request.blockedBy === undefined) {
1126
- return { ok: false, error: { code: 'nothing-to-update' } };
1127
- }
1128
- if (request.title !== undefined && request.title.trim() === '') {
1129
- return { ok: false, error: { code: 'empty-text' } };
1130
- }
1131
- if (typeof request.blockedBy === 'string'
1132
- && !loaded.state.members.some(member => member.name === request.blockedBy)) {
2322
+ if (loaded.state.linked?.some(entry => entry.sessionId === request.target) !== true)
1133
2323
  return { ok: false, error: { code: 'member-not-found' } };
1134
- }
1135
- loaded.session.append('room/task-edited', {
1136
- id: task.id,
1137
- ...request.title === undefined ? {} : { title: request.title.trim() },
1138
- ...request.blockedBy === undefined ? {} : { blockedBy: request.blockedBy },
1139
- });
2324
+ loaded.session.append('room/help-unlink', { sessionId: request.target });
2325
+ await this.ctx.sessions.flush(loaded.session);
2326
+ return { ok: true };
2327
+ }
2328
+ /**
2329
+ * Dismiss the 加入房间成员 hint for one outside session (it never shows again).
2330
+ * @param request - room session, the outside session.
2331
+ * @returns ok.
2332
+ */
2333
+ async dismissHelpNudge(request) {
2334
+ const loaded = await this.ensureLive(request.sessionId);
2335
+ if (!loaded.ok)
2336
+ return { ok: false, error: loaded.error };
2337
+ if (loaded.state.helpNudgesDismissed?.includes(request.target) === true)
2338
+ return { ok: true };
2339
+ loaded.session.append('room/help-nudge-dismissed', { sessionId: request.target });
2340
+ await this.ctx.sessions.flush(loaded.session);
2341
+ return { ok: true };
2342
+ }
2343
+ /**
2344
+ * Dismiss a pending relay: the notification never reaches anyone.
2345
+ * @param request - room session, relay id.
2346
+ * @returns the resolution receipt, or a rejection.
2347
+ */
2348
+ async dismissRelay(request) {
2349
+ const loaded = await this.ensureLive(request.sessionId);
2350
+ if (!loaded.ok)
2351
+ return { ok: false, error: loaded.error };
2352
+ const relay = loaded.state.relays.find(entry => entry.id === request.relayId);
2353
+ if (relay === undefined)
2354
+ return { ok: false, error: { code: 'relay-not-found' } };
2355
+ if (relay.state !== 'pending')
2356
+ return { ok: false, error: { code: 'relay-not-pending' } };
2357
+ loaded.session.append('room/relay-resolved', { id: relay.id, state: 'dismissed' });
1140
2358
  await this.ctx.sessions.flush(loaded.session);
1141
- return { ok: true, value: { id: task.id } };
2359
+ // A dismissed help request from another room ends that room's thread.
2360
+ const asked = relay.intent?.help;
2361
+ if (asked !== undefined) {
2362
+ const asking = await this.ensureLive(SessionId(asked.room));
2363
+ if (asking.ok && replayHelps(asking.session.snapshotEvents()).some(entry => entry.id === asked.id)) {
2364
+ asking.session.append('room/help-state', { help: asked.id, state: 'failed', error: '对方房间没有接这条求助' });
2365
+ await this.ctx.sessions.flush(asking.session);
2366
+ }
2367
+ }
2368
+ return { ok: true, value: { relayId: relay.id } };
1142
2369
  }
1143
2370
  /**
1144
2371
  * Set (or, with a blank text, clear) the room's goal. The goal is one
@@ -1179,7 +2406,9 @@ let RoomService = (() => {
1179
2406
  return { ok: true, value: { cancelled: false } };
1180
2407
  }
1181
2408
  const facade = probeLocalAgent(this.ctx);
1182
- const native = member.kind === 'main-agent' && expected !== undefined ? this.ctx.agents.get(loaded.session.id) : undefined;
2409
+ const native = expected === undefined ? undefined
2410
+ : member.kind === 'main-agent' ? this.ctx.agents.get(loaded.session.id)
2411
+ : member.kind === 'native' && member.childSessionId !== undefined ? this.ctx.agents.get(member.childSessionId) : undefined;
1183
2412
  if (native !== undefined)
1184
2413
  native.cancel({ kind: 'user' });
1185
2414
  const hit = native !== undefined || (facade !== undefined && member.childSessionId !== undefined
@@ -1193,15 +2422,8 @@ let RoomService = (() => {
1193
2422
  member: request.name, state: 'cancelled', startedAt: running.startedAt,
1194
2423
  elapsedMs: Math.max(0, Date.now() - running.startedAt),
1195
2424
  ...running.runId === undefined ? {} : { runId: running.runId },
2425
+ ...this.terminalRunStateFields(loaded.session, loaded.state, request.name),
1196
2426
  });
1197
- // The engine's settle no-ops behind this edge, so the task closing the
1198
- // settle would have done happens here: the dispatch-opened in_progress
1199
- // task cancels with its run.
1200
- for (const task of loaded.state.tasks) {
1201
- if (task.member === request.name && task.status === 'in_progress' && (running.runId === undefined || task.deliveryId === running.runId)) {
1202
- loaded.session.append('room/task-updated', { id: task.id, status: 'cancelled' });
1203
- }
1204
- }
1205
2427
  await this.ctx.sessions.flush(loaded.session);
1206
2428
  return { ok: true, value: { cancelled: true } };
1207
2429
  }