@khorsheed/dsh-room 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.en.md +29 -62
- package/README.i18n.yaml +2 -2
- package/README.md +28 -61
- package/lib/client.js +13097 -4182
- package/lib/index.js +4045 -730
- package/lib/journal-B4FOGO6S.js +482 -0
- package/lib/journal-Rid9AAnD.js +485 -0
- package/lib/tool.js +112 -166
- package/lib/typert.host.js +2031 -374
- package/lib/typert.remote-client.d.ts +34 -10
- package/lib/typert.remote-client.js +1599 -210
- package/lib/types/agent-setup.d.ts +1 -1
- package/lib/types/client/InviteAgentAction.js +4 -16
- package/lib/types/client/InviteDialog.d.ts +28 -6
- package/lib/types/client/InviteDialog.js +76 -12
- package/lib/types/client/MemberCard.d.ts +5 -1
- package/lib/types/client/MemberCard.js +4 -4
- package/lib/types/client/MembersView.d.ts +1 -1
- package/lib/types/client/MembersView.js +58 -22
- package/lib/types/client/RemoveMemberConfirm.d.ts +40 -0
- package/lib/types/client/RemoveMemberConfirm.js +85 -0
- package/lib/types/client/RoomApprovalView.d.ts +11 -0
- package/lib/types/client/RoomApprovalView.js +28 -0
- package/lib/types/client/RoomComposer.d.ts +1 -1
- package/lib/types/client/RoomComposer.js +195 -29
- package/lib/types/client/RoomDecisionView.d.ts +22 -0
- package/lib/types/client/RoomDecisionView.js +35 -0
- package/lib/types/client/RoomHelpView.d.ts +17 -0
- package/lib/types/client/RoomHelpView.js +56 -0
- package/lib/types/client/RoomInboxView.d.ts +10 -0
- package/lib/types/client/RoomInboxView.js +14 -0
- package/lib/types/client/RoomQuestionView.d.ts +13 -0
- package/lib/types/client/RoomQuestionView.js +61 -0
- package/lib/types/client/RoomRelayView.js +1 -1
- package/lib/types/client/RoomRunView.d.ts +3 -1
- package/lib/types/client/RoomRunView.js +12 -5
- package/lib/types/client/RoomSettingsView.d.ts +19 -0
- package/lib/types/client/RoomSettingsView.js +30 -0
- package/lib/types/client/RoomSpeechView.d.ts +22 -8
- package/lib/types/client/RoomSpeechView.js +94 -11
- package/lib/types/client/RoomStatsLine.d.ts +26 -5
- package/lib/types/client/RoomStatsLine.js +53 -7
- package/lib/types/client/RoomTaskCapsule.d.ts +19 -0
- package/lib/types/client/RoomTaskCapsule.js +37 -0
- package/lib/types/client/TasksTab.d.ts +41 -0
- package/lib/types/client/TasksTab.js +43 -0
- package/lib/types/client/TasksView.d.ts +44 -0
- package/lib/types/client/TasksView.js +149 -0
- package/lib/types/client/TeamView.d.ts +33 -0
- package/lib/types/client/TeamView.js +210 -0
- package/lib/types/client/at-menu.d.ts +127 -0
- package/lib/types/client/at-menu.js +173 -0
- package/lib/types/client/draft-chips.d.ts +67 -0
- package/lib/types/client/draft-chips.js +95 -0
- package/lib/types/client/executions.d.ts +0 -30
- package/lib/types/client/executions.js +4 -34
- package/lib/types/client/icons-local.d.ts +25 -0
- package/lib/types/client/icons-local.js +10 -0
- package/lib/types/client/icons.d.ts +2 -1
- package/lib/types/client/icons.js +1 -0
- package/lib/types/client/index.js +276 -72
- package/lib/types/client/locales.d.ts +482 -52
- package/lib/types/client/locales.js +490 -60
- package/lib/types/client/nodes.d.ts +112 -17
- package/lib/types/client/nodes.js +197 -26
- package/lib/types/client/slots.d.ts +117 -47
- package/lib/types/client/task-tag.d.ts +32 -0
- package/lib/types/client/task-tag.js +33 -0
- package/lib/types/client/tasks.d.ts +50 -0
- package/lib/types/client/tasks.js +82 -0
- package/lib/types/client/team.d.ts +73 -0
- package/lib/types/client/team.js +187 -0
- package/lib/types/decisions.d.ts +49 -0
- package/lib/types/decisions.js +101 -0
- package/lib/types/deliveries.d.ts +17 -1
- package/lib/types/deliveries.js +82 -6
- package/lib/types/dispatch.d.ts +92 -4
- package/lib/types/dispatch.js +477 -109
- package/lib/types/help.d.ts +137 -0
- package/lib/types/help.js +216 -0
- package/lib/types/helps.d.ts +26 -0
- package/lib/types/helps.js +47 -0
- package/lib/types/index.d.ts +292 -52
- package/lib/types/index.js +1415 -199
- package/lib/types/jobs.d.ts +22 -0
- package/lib/types/jobs.js +77 -0
- package/lib/types/journal.d.ts +17 -27
- package/lib/types/journal.js +120 -43
- package/lib/types/messaging.d.ts +65 -0
- package/lib/types/messaging.js +160 -0
- package/lib/types/plan-service.js +7 -1
- package/lib/types/report-gate.d.ts +81 -0
- package/lib/types/report-gate.js +89 -0
- package/lib/types/tool-definition.d.ts +43 -20
- package/lib/types/tool-definition.js +145 -212
- package/lib/types/tool.d.ts +2 -2
- package/lib/types/tool.js +3 -2
- package/lib/types/types.d.ts +755 -78
- package/locale/zh.json +1 -1
- package/package.json +3 -3
- package/lib/types/client/RoomActivityDock.d.ts +0 -19
- package/lib/types/client/RoomActivityDock.js +0 -105
- package/lib/types/client/RoomDockCapsules.d.ts +0 -64
- package/lib/types/client/RoomDockCapsules.js +0 -277
- package/lib/types/client/RoomPlanTab.d.ts +0 -15
- package/lib/types/client/RoomPlanTab.js +0 -13
- package/lib/types/client/RoomTaskLineView.d.ts +0 -13
- package/lib/types/client/RoomTaskLineView.js +0 -21
package/lib/types/index.js
CHANGED
|
@@ -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
|
-
* `
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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,
|
|
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) =>
|
|
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
|
|
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(
|
|
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
|
|
@@ -427,6 +912,9 @@ let RoomService = (() => {
|
|
|
427
912
|
* @returns the invitation receipt, or a rejection.
|
|
428
913
|
*/
|
|
429
914
|
async inviteMember(request, invitedBy, actorChildSessionId) {
|
|
915
|
+
const routed = invitedBy === 'agent' && actorChildSessionId === undefined ? this.nativeRoute(request.sessionId) : undefined;
|
|
916
|
+
if (routed !== undefined)
|
|
917
|
+
return this.inviteMember({ ...request, sessionId: routed.roomId }, invitedBy, routed.actor);
|
|
430
918
|
// Invite PROMOTES: inviting an agent into a plain session makes it a room.
|
|
431
919
|
const loaded = await this.ensureRoom(request.sessionId);
|
|
432
920
|
if (!loaded.ok)
|
|
@@ -448,23 +936,59 @@ let RoomService = (() => {
|
|
|
448
936
|
if (request.firstTask !== undefined && request.firstTask.trim() === '') {
|
|
449
937
|
return { ok: false, error: { code: 'empty-text' } };
|
|
450
938
|
}
|
|
939
|
+
if (request.role !== undefined && request.role.trim().length > ROOM_ROLE_MAX) {
|
|
940
|
+
return { ok: false, error: { code: 'role-too-long' } };
|
|
941
|
+
}
|
|
451
942
|
if (request.model !== undefined && request.model.trim() === '') {
|
|
452
943
|
return { ok: false, error: { code: 'empty-text' } };
|
|
453
944
|
}
|
|
454
|
-
if (
|
|
945
|
+
if (request.effort !== undefined && request.effort.trim() === '') {
|
|
946
|
+
return { ok: false, error: { code: 'empty-text' } };
|
|
947
|
+
}
|
|
948
|
+
const native = request.kind === 'native';
|
|
949
|
+
// The coordinator's default: the member works in the room's own mode.
|
|
950
|
+
const inherited = request.preset === undefined && request.inheritPreset === true ? roomSessionPreset(loaded.session) : undefined;
|
|
951
|
+
if (inherited !== undefined)
|
|
952
|
+
request = { ...request, preset: inherited };
|
|
953
|
+
if (request.preset !== undefined) {
|
|
954
|
+
// A native member composes a host preset; a CLI member reaches that
|
|
955
|
+
// preset's plugin tools through its bridge. Reject an id the host does
|
|
956
|
+
// not declare instead of failing at the member's first round.
|
|
957
|
+
const presets = probeAgentPresets(this.ctx);
|
|
958
|
+
if (presets !== undefined) {
|
|
959
|
+
try {
|
|
960
|
+
await presets.resolve(request.preset);
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
const available = (await presets.list?.())?.map(row => row.id) ?? [];
|
|
964
|
+
return { ok: false, error: { code: 'unknown-preset', preset: request.preset, available } };
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (!native && probeLocalAgent(this.ctx) === undefined) {
|
|
455
969
|
return { ok: false, error: { code: 'local-agent-unavailable' } };
|
|
456
970
|
}
|
|
457
|
-
const roster = probeLocalAgentRoster(this.ctx);
|
|
971
|
+
const roster = native ? undefined : probeLocalAgentRoster(this.ctx);
|
|
458
972
|
if (roster !== undefined) {
|
|
459
973
|
// The same delegationProvider set listProviders serves: invite accepts
|
|
460
974
|
// exactly what dispatch can resolve, and the rejection carries the
|
|
461
975
|
// legal set so a model caller can self-correct (rename and retry).
|
|
462
976
|
const available = [];
|
|
977
|
+
// The names a model reaches for first — the harness name (claude-code)
|
|
978
|
+
// and its display name (Claude Code) — resolve to the provider id.
|
|
979
|
+
const aliases = new Map();
|
|
980
|
+
const key = (name) => name.toLowerCase().replace(/[\s_-]+/g, '');
|
|
463
981
|
for (const row of roster.roster()) {
|
|
464
982
|
const status = await roster.statusOf(row.name);
|
|
465
|
-
if (status.delegationProvider
|
|
466
|
-
|
|
983
|
+
if (status.delegationProvider === undefined)
|
|
984
|
+
continue;
|
|
985
|
+
available.push(status.delegationProvider);
|
|
986
|
+
for (const alias of [row.name, row.displayName, status.delegationProvider])
|
|
987
|
+
aliases.set(key(alias), status.delegationProvider);
|
|
467
988
|
}
|
|
989
|
+
const aliased = aliases.get(key(request.provider));
|
|
990
|
+
if (!available.includes(request.provider) && aliased !== undefined)
|
|
991
|
+
request = { ...request, provider: aliased };
|
|
468
992
|
if (!available.includes(request.provider)) {
|
|
469
993
|
return { ok: false, error: { code: 'unknown-provider', provider: request.provider, available } };
|
|
470
994
|
}
|
|
@@ -478,31 +1002,40 @@ let RoomService = (() => {
|
|
|
478
1002
|
loaded.session.append('room/member-added', {
|
|
479
1003
|
id: randomUUID(),
|
|
480
1004
|
name: request.name,
|
|
481
|
-
kind: 'cli',
|
|
482
|
-
provider: request.provider,
|
|
1005
|
+
kind: native ? 'native' : 'cli',
|
|
1006
|
+
provider: native ? 'dsh' : request.provider,
|
|
1007
|
+
...request.preset !== undefined ? { preset: request.preset } : {},
|
|
483
1008
|
invitedBy,
|
|
484
1009
|
...request.instructions === undefined ? {} : { instructions: request.instructions },
|
|
1010
|
+
...request.role === undefined || request.role.trim() === '' ? {} : { role: request.role.trim() },
|
|
485
1011
|
...request.cwd === undefined || request.cwd.trim() === '' ? {} : { cwd: request.cwd.trim() },
|
|
486
1012
|
...request.model === undefined || request.model.trim() === '' ? {} : { model: request.model.trim() },
|
|
1013
|
+
...request.effort === undefined || request.effort.trim() === '' ? {} : { effort: request.effort.trim() },
|
|
487
1014
|
});
|
|
488
1015
|
let firstTaskSeq;
|
|
489
1016
|
if (request.firstTask !== undefined) {
|
|
1017
|
+
// A human's invite starts a task of its own; an agent's belongs to the task it is working on.
|
|
1018
|
+
const inviter = invitedBy === 'agent'
|
|
1019
|
+
? replay(loaded.session.snapshotEvents()).members.find(member => actorChildSessionId === undefined ? member.kind === 'main-agent' : member.childSessionId === actorChildSessionId)
|
|
1020
|
+
: undefined;
|
|
1021
|
+
const context = inviter === undefined ? {} : this.sendContext(loaded.session, inviter);
|
|
1022
|
+
const dispatchId = randomUUID();
|
|
1023
|
+
const firstTaskContext = { id: dispatchId, task: invitedBy === 'human' ? dispatchId : context.task ?? dispatchId,
|
|
1024
|
+
...context.parent === undefined ? {} : { parent: context.parent } };
|
|
490
1025
|
firstTaskSeq = loaded.session.append('room/dispatch', {
|
|
491
|
-
|
|
1026
|
+
origin: invitedBy === 'human' ? 'human' : 'coordinator',
|
|
492
1027
|
...invitedBy === 'human' ? {} : { replyTo: memberId(loaded.session.snapshotEvents(), coordinatorMember(replay(loaded.session.snapshotEvents()), loaded.session.snapshotEvents())) },
|
|
1028
|
+
...request.note === undefined || request.note.trim() === '' ? {} : { note: request.note.trim().slice(0, 500) },
|
|
1029
|
+
...firstTaskContext,
|
|
493
1030
|
targetIds: [memberId(loaded.session.snapshotEvents(), replay(loaded.session.snapshotEvents()).members.find(member => member.name === request.name))],
|
|
494
1031
|
targets: [request.name], text: request.firstTask,
|
|
495
1032
|
}).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
1033
|
}
|
|
501
1034
|
await this.ctx.sessions.flush(loaded.session);
|
|
502
1035
|
if (request.firstTask !== undefined && firstTaskSeq !== undefined) {
|
|
503
1036
|
this.engine.dispatch(loaded.session, request.name, request.firstTask, { dispatchSeq: firstTaskSeq });
|
|
504
1037
|
}
|
|
505
|
-
return { ok: true, value: { name: request.name, pendingFirstTask: request.firstTask !== undefined } };
|
|
1038
|
+
return { ok: true, value: { name: request.name, pendingFirstTask: request.firstTask !== undefined, ...request.preset === undefined ? {} : { preset: request.preset } } };
|
|
506
1039
|
}
|
|
507
1040
|
/**
|
|
508
1041
|
* Invite a CLI member (the human path; see inviteMember).
|
|
@@ -544,8 +1077,10 @@ let RoomService = (() => {
|
|
|
544
1077
|
return { ok: false, error: { code: 'member-cwd-bound' } };
|
|
545
1078
|
if (request.model !== undefined && member.childSessionId !== undefined)
|
|
546
1079
|
return { ok: false, error: { code: 'configuration-owned-by-core' } };
|
|
547
|
-
if (request.
|
|
548
|
-
|
|
1080
|
+
if (request.effort !== undefined && member.childSessionId !== undefined)
|
|
1081
|
+
return { ok: false, error: { code: 'configuration-owned-by-core' } };
|
|
1082
|
+
if (request.rename === undefined && request.instructions === undefined && request.role === undefined
|
|
1083
|
+
&& request.cwd === undefined && request.model === undefined && request.effort === undefined) {
|
|
549
1084
|
return { ok: false, error: { code: 'nothing-to-update' } };
|
|
550
1085
|
}
|
|
551
1086
|
if (request.rename !== undefined && request.rename !== request.name) {
|
|
@@ -562,18 +1097,28 @@ let RoomService = (() => {
|
|
|
562
1097
|
const instructions = typeof request.instructions === 'string' && request.instructions.trim() === ''
|
|
563
1098
|
? null
|
|
564
1099
|
: request.instructions;
|
|
1100
|
+
const role = typeof request.role === 'string'
|
|
1101
|
+
? (request.role.trim() === '' ? null : request.role.trim())
|
|
1102
|
+
: request.role;
|
|
1103
|
+
if (typeof role === 'string' && role.length > ROOM_ROLE_MAX)
|
|
1104
|
+
return { ok: false, error: { code: 'role-too-long' } };
|
|
565
1105
|
const cwd = typeof request.cwd === 'string'
|
|
566
1106
|
? (request.cwd.trim() === '' ? null : request.cwd.trim())
|
|
567
1107
|
: request.cwd;
|
|
568
1108
|
const model = typeof request.model === 'string'
|
|
569
1109
|
? (request.model.trim() === '' ? null : request.model.trim())
|
|
570
1110
|
: request.model;
|
|
1111
|
+
const effort = typeof request.effort === 'string'
|
|
1112
|
+
? (request.effort.trim() === '' ? null : request.effort.trim())
|
|
1113
|
+
: request.effort;
|
|
571
1114
|
loaded.session.append('room/member-updated', {
|
|
572
1115
|
name: request.name,
|
|
573
1116
|
...request.rename === undefined ? {} : { rename: request.rename },
|
|
574
1117
|
...instructions === undefined ? {} : { instructions },
|
|
1118
|
+
...role === undefined ? {} : { role },
|
|
575
1119
|
...cwd === undefined ? {} : { cwd },
|
|
576
1120
|
...model === undefined ? {} : { model },
|
|
1121
|
+
...effort === undefined ? {} : { effort },
|
|
577
1122
|
});
|
|
578
1123
|
await this.ctx.sessions.flush(loaded.session);
|
|
579
1124
|
return { ok: true, value: { name: request.name } };
|
|
@@ -602,7 +1147,7 @@ let RoomService = (() => {
|
|
|
602
1147
|
return { ok: false, error: { code: 'coordinator-not-ready', message: 'Native preparation is unavailable in this family core' } };
|
|
603
1148
|
try {
|
|
604
1149
|
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 },
|
|
1150
|
+
...member.cwd === undefined ? {} : { cwd: member.cwd }, ...member.model === undefined ? {} : { model: member.model }, ...member.effort === undefined ? {} : { effort: member.effort },
|
|
606
1151
|
});
|
|
607
1152
|
loaded.session.append('room/member-updated', { name: member.name, childSessionId: SessionId(childSessionId) });
|
|
608
1153
|
await this.ctx.sessions.flush(loaded.session);
|
|
@@ -627,7 +1172,7 @@ let RoomService = (() => {
|
|
|
627
1172
|
loaded.session.append('room/delivery-state', { id: delivery.id, dispatchSeq: delivery.dispatchSeq, memberId: delivery.memberId, state: request.outcome, text: request.evidence.trim() });
|
|
628
1173
|
const run = loaded.state.runs.find(run => run.runId === delivery.id);
|
|
629
1174
|
if (run !== undefined)
|
|
630
|
-
loaded.session.append('room/run-state', { member: run.member, runId: delivery.id, startedAt: run.startedAt, state: request.outcome });
|
|
1175
|
+
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
1176
|
await this.ctx.sessions.flush(loaded.session);
|
|
632
1177
|
await this.engine.recover(loaded.session);
|
|
633
1178
|
return { ok: true };
|
|
@@ -646,6 +1191,10 @@ let RoomService = (() => {
|
|
|
646
1191
|
const previous = coordinatorMember(loaded.state, events);
|
|
647
1192
|
if (candidate === undefined || previous === undefined)
|
|
648
1193
|
return { ok: false, error: { code: 'member-not-found' } };
|
|
1194
|
+
// A native member answers inside this host already; making it the
|
|
1195
|
+
// external coordinator would need the CLI coordination contract it does not speak.
|
|
1196
|
+
if (candidate.kind === 'native')
|
|
1197
|
+
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
1198
|
if ((loaded.state.coordinator?.revision ?? 0) !== request.expectedRevision)
|
|
650
1199
|
return { ok: false, error: { code: 'coordinator-conflict' } };
|
|
651
1200
|
const agent = this.ctx.agents.get(request.sessionId);
|
|
@@ -688,15 +1237,7 @@ let RoomService = (() => {
|
|
|
688
1237
|
return { ok: false, error: { code: 'coordinator-not-ready', message: 'Member configuration has not converged or is locked' } };
|
|
689
1238
|
}
|
|
690
1239
|
}
|
|
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);
|
|
1240
|
+
const handoff = handoffText(events, loaded.state, request.sessionId, previous.name, candidate.name);
|
|
700
1241
|
const value = { version: 1, memberId: request.memberId, previousMemberId: memberId(events, previous), revision: request.expectedRevision + 1, handoff };
|
|
701
1242
|
loaded.session.append('room/coordinator', value);
|
|
702
1243
|
await this.ctx.sessions.flush(loaded.session);
|
|
@@ -710,10 +1251,14 @@ let RoomService = (() => {
|
|
|
710
1251
|
}
|
|
711
1252
|
}
|
|
712
1253
|
/**
|
|
713
|
-
* Remove a member
|
|
714
|
-
*
|
|
1254
|
+
* Remove a member and settle everything that depended on it, so the
|
|
1255
|
+
* confirmation's promises hold: a running round is stopped first (the same
|
|
1256
|
+
* cancel the stop button uses), queued messages to it are cancelled, every
|
|
1257
|
+
* member waiting for one of its reports is told it will not come, and only
|
|
1258
|
+
* then does it leave the roster. Its own session and history stay;
|
|
1259
|
+
* {@link restoreMember} brings it back onto them.
|
|
715
1260
|
* @param request - room session, member name.
|
|
716
|
-
* @returns the removal
|
|
1261
|
+
* @returns what the removal did, or a rejection.
|
|
717
1262
|
*/
|
|
718
1263
|
async removeMember(request) {
|
|
719
1264
|
const loaded = await this.ensureLive(request.sessionId);
|
|
@@ -721,16 +1266,71 @@ let RoomService = (() => {
|
|
|
721
1266
|
return { ok: false, error: loaded.error };
|
|
722
1267
|
if (this.handoffs.has(request.sessionId))
|
|
723
1268
|
return { ok: false, error: { code: 'coordinator-busy' } };
|
|
724
|
-
|
|
1269
|
+
const member = loaded.state.members.find(entry => entry.name === request.name);
|
|
1270
|
+
if (member === undefined)
|
|
725
1271
|
return { ok: false, error: { code: 'member-not-found' } };
|
|
726
|
-
}
|
|
727
1272
|
if (coordinatorMember(loaded.state, loaded.session.snapshotEvents())?.name === request.name)
|
|
728
1273
|
return { ok: false, error: { code: 'active-coordinator' } };
|
|
729
|
-
if (
|
|
1274
|
+
if (member.kind === 'main-agent')
|
|
730
1275
|
return { ok: false, error: { code: 'main-member' } };
|
|
731
|
-
loaded.
|
|
1276
|
+
const stopped = loaded.state.runs.some(run => run.member === request.name && run.state === 'running')
|
|
1277
|
+
&& (await this.cancel({ sessionId: request.sessionId, name: request.name })).ok;
|
|
1278
|
+
const session = loaded.session;
|
|
1279
|
+
const events = session.snapshotEvents();
|
|
1280
|
+
const id = memberId(events, member);
|
|
1281
|
+
const queued = replay(events).deliveries?.filter(row => row.memberId === id && row.status === 'queued') ?? [];
|
|
1282
|
+
for (const row of queued) {
|
|
1283
|
+
session.append('room/delivery-state', { id: row.id, dispatchSeq: row.dispatchSeq, memberId: id, state: 'cancelled', error: `${request.name} 已被移除` });
|
|
1284
|
+
}
|
|
1285
|
+
session.append('room/member-removed', { name: request.name });
|
|
1286
|
+
await this.ctx.sessions.flush(session);
|
|
1287
|
+
// Tell whoever waits on a cancelled delivery, now that the roster no
|
|
1288
|
+
// longer holds the member (the report reads its last known name).
|
|
1289
|
+
const before = session.snapshotEvents().filter(event => event.type === 'room/dispatch').length;
|
|
1290
|
+
for (const row of queued) {
|
|
1291
|
+
await this.engine.reportOutcome(session, row.dispatchSeq, row.id, id, 'cancelled', `${request.name} 已被移除,这条没有执行。`);
|
|
1292
|
+
}
|
|
1293
|
+
const reports = session.snapshotEvents().filter(event => event.type === 'room/dispatch').slice(before);
|
|
1294
|
+
const notified = [...new Set(reports.flatMap(event => event.type === 'room/dispatch' ? event.data.targets : []))];
|
|
1295
|
+
return { ok: true, value: { name: request.name, stopped, cancelledDeliveries: queued.length, notified } };
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Undo a removal: the member rejoins under its old identity, provider,
|
|
1299
|
+
* role, instructions and native session, so its memory carries on. Work
|
|
1300
|
+
* the removal cancelled stays cancelled. Rejected when the name was taken
|
|
1301
|
+
* in between or the member was never removed.
|
|
1302
|
+
* @param request - room session, the removed member's name.
|
|
1303
|
+
* @returns the restored name, or a rejection.
|
|
1304
|
+
*/
|
|
1305
|
+
async restoreMember(request) {
|
|
1306
|
+
const loaded = await this.ensureLive(request.sessionId);
|
|
1307
|
+
if (!loaded.ok)
|
|
1308
|
+
return { ok: false, error: loaded.error };
|
|
1309
|
+
if (loaded.state.members.some(entry => entry.name === request.name))
|
|
1310
|
+
return { ok: false, error: { code: 'duplicate-name' } };
|
|
1311
|
+
const events = loaded.session.snapshotEvents();
|
|
1312
|
+
const removal = events.findLast(event => event.type === 'room/member-removed' && event.data.name === request.name);
|
|
1313
|
+
if (removal === undefined)
|
|
1314
|
+
return { ok: false, error: { code: 'member-not-found' } };
|
|
1315
|
+
const member = replay(events.filter(event => event.seq < removal.seq)).members.find(entry => entry.name === request.name);
|
|
1316
|
+
if (member === undefined)
|
|
1317
|
+
return { ok: false, error: { code: 'member-not-found' } };
|
|
1318
|
+
loaded.session.append('room/member-added', {
|
|
1319
|
+
id: memberId(events, member),
|
|
1320
|
+
name: member.name,
|
|
1321
|
+
kind: member.kind,
|
|
1322
|
+
invitedBy: member.invitedBy,
|
|
1323
|
+
...member.provider === undefined ? {} : { provider: member.provider },
|
|
1324
|
+
...member.instructions === undefined ? {} : { instructions: member.instructions },
|
|
1325
|
+
...member.role === undefined ? {} : { role: member.role },
|
|
1326
|
+
...member.preset === undefined ? {} : { preset: member.preset },
|
|
1327
|
+
...member.cwd === undefined ? {} : { cwd: member.cwd },
|
|
1328
|
+
...member.model === undefined ? {} : { model: member.model },
|
|
1329
|
+
...member.effort === undefined ? {} : { effort: member.effort },
|
|
1330
|
+
...member.childSessionId === undefined ? {} : { childSessionId: member.childSessionId },
|
|
1331
|
+
});
|
|
732
1332
|
await this.ctx.sessions.flush(loaded.session);
|
|
733
|
-
return { ok: true, value: { name:
|
|
1333
|
+
return { ok: true, value: { name: member.name } };
|
|
734
1334
|
}
|
|
735
1335
|
/**
|
|
736
1336
|
* Post a human @-message into the room: leading `@name` tokens address
|
|
@@ -797,73 +1397,111 @@ let RoomService = (() => {
|
|
|
797
1397
|
}
|
|
798
1398
|
// Leading tokens are stripped from the dispatched text; a picked mid-
|
|
799
1399
|
// sentence mention stays — the sentence is dispatched verbatim.
|
|
800
|
-
const
|
|
1400
|
+
const said = parsed.targets.length > 0 ? parsed.text : request.text.trim();
|
|
1401
|
+
// The human pointing at a session outside the room is a help request: say
|
|
1402
|
+
// so every time (a member's briefing reaches it once, on its first round).
|
|
1403
|
+
const outside = [...said.matchAll(/@\[((?:\\.|[^\\\]\n])+)\]\((dsh-session:[^)\s]+)\)/gu)];
|
|
1404
|
+
const text = said;
|
|
1405
|
+
// The hint rides the member's prompt only; the journal keeps the human's words.
|
|
1406
|
+
const prompt = outside.length === 0 ? said : [
|
|
1407
|
+
said,
|
|
1408
|
+
'',
|
|
1409
|
+
`(人点名了房间外的会话:${outside.map(match => `「${match[1].replace(/\\(.)/gu, '$1')}」`).join('、')}。用 room_help 去问:session 参数原样传那个 @[…](dsh-session:…) 引用;标签是「成员(房间)」时会交给那个成员。不要转给别的成员代问。)`,
|
|
1410
|
+
].join('\n');
|
|
801
1411
|
if (text === '')
|
|
802
1412
|
return { ok: false, error: { code: 'empty-text' } };
|
|
803
1413
|
const roster = new Set(loaded.state.members.map(member => member.name));
|
|
804
1414
|
const unknown = targets.filter(target => !roster.has(target));
|
|
805
1415
|
if (unknown.length > 0)
|
|
806
1416
|
return { ok: false, error: { code: 'unknown-targets', names: unknown } };
|
|
807
|
-
|
|
1417
|
+
const human = createUserMessage({
|
|
808
1418
|
content: [{ type: 'text', text: request.text.trim() }],
|
|
809
1419
|
source: { kind: 'user' },
|
|
810
|
-
})
|
|
1420
|
+
});
|
|
1421
|
+
loaded.session.append('user/message', human, { surfaceOp: 'append' });
|
|
1422
|
+
const filed = await this.fileHumanMessage(loaded.session, human.id, request.text.trim(), request.task ?? this.consumeTaskHint(request.sessionId));
|
|
1423
|
+
this.moveOnFromLostRounds(loaded.session, targets);
|
|
811
1424
|
const dispatch = loaded.session.append('room/dispatch', {
|
|
812
|
-
id: request.requestId ?? randomUUID(), requestSignature, targets, text, origin: 'human',
|
|
1425
|
+
id: request.requestId ?? randomUUID(), requestSignature, targets, text, origin: 'human', task: filed.task,
|
|
813
1426
|
targetIds: targets.map(name => memberId(loaded.session.snapshotEvents(), loaded.state.members.find(member => member.name === name))),
|
|
814
1427
|
});
|
|
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
1428
|
await this.ctx.sessions.flush(loaded.session);
|
|
822
1429
|
for (const target of targets) {
|
|
823
|
-
this.engine.dispatch(loaded.session, target,
|
|
1430
|
+
this.engine.dispatch(loaded.session, target, prompt, { dispatchSeq: dispatch.seq });
|
|
824
1431
|
}
|
|
825
1432
|
return { ok: true, value: { parsed: { targets, text }, seq: dispatch.seq } };
|
|
826
1433
|
}
|
|
827
1434
|
/**
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
* @
|
|
1435
|
+
* Send one message from a member to another (host-only — the `room_message`
|
|
1436
|
+
* tool and the bridge's `member_message`/`room_message` land here; the
|
|
1437
|
+
* human's path is postMessage). Any member may message any other: the
|
|
1438
|
+
* coordinator's sends journal as `coordinator` dispatches, everyone else's
|
|
1439
|
+
* as `member` ones. A `task` (default) has the recipient's final reply
|
|
1440
|
+
* reported back to the sender; a `notify` is FYI. The room's settings may
|
|
1441
|
+
* hold the message for the human instead (see messaging.ts).
|
|
1442
|
+
* @param request - room session, addressee, text, kind, optional reply reference.
|
|
1443
|
+
* @param actorChildSessionId - the authenticated CLI sender; absent = the room's own agent.
|
|
1444
|
+
* @returns the delivery receipt (with its `m<seq>` id) or the hold, or a rejection.
|
|
836
1445
|
*/
|
|
837
1446
|
async messageMember(request, actorChildSessionId) {
|
|
1447
|
+
const routed = actorChildSessionId === undefined ? this.nativeRoute(request.sessionId) : undefined;
|
|
1448
|
+
if (routed !== undefined)
|
|
1449
|
+
return this.messageMember({ ...request, sessionId: routed.roomId }, routed.actor);
|
|
838
1450
|
// Messaging PROMOTES too (the room_message tool's gate): the main agent
|
|
839
1451
|
// answering "把 kimi 拉进来问一下…" promotes its own session, then the
|
|
840
1452
|
// roster check runs against the promoted state.
|
|
841
1453
|
const loaded = await this.ensureRoom(request.sessionId);
|
|
842
1454
|
if (!loaded.ok)
|
|
843
1455
|
return { ok: false, error: loaded.error };
|
|
844
|
-
if (!this.isCurrentCoordinator(loaded.session, actorChildSessionId))
|
|
845
|
-
return { ok: false, error: { code: 'not-coordinator' } };
|
|
846
1456
|
if (this.handoffs.has(request.sessionId))
|
|
847
1457
|
return { ok: false, error: { code: 'coordinator-busy' } };
|
|
848
|
-
|
|
1458
|
+
const events = loaded.session.snapshotEvents();
|
|
1459
|
+
const sender = loaded.state.members.find(member => actorChildSessionId === undefined ? member.kind === 'main-agent' : member.childSessionId === actorChildSessionId);
|
|
1460
|
+
if (sender === undefined)
|
|
1461
|
+
return { ok: false, error: { code: 'member-not-found' } };
|
|
1462
|
+
const target = loaded.state.members.find(member => member.name === request.member || (member.childSessionId !== undefined && member.childSessionId === request.member));
|
|
1463
|
+
if (target === undefined)
|
|
849
1464
|
return { ok: false, error: { code: 'member-not-found' } };
|
|
850
|
-
}
|
|
851
1465
|
const text = request.text.trim();
|
|
852
1466
|
if (text === '')
|
|
853
1467
|
return { ok: false, error: { code: 'empty-text' } };
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1468
|
+
if (target.name === sender.name)
|
|
1469
|
+
return { ok: false, error: { code: 'self-message' } };
|
|
1470
|
+
const senderId = memberId(events, sender);
|
|
1471
|
+
const kind = request.kind ?? 'task';
|
|
1472
|
+
const inReplyTo = parseMessageRef(request.inReplyTo);
|
|
1473
|
+
const context = this.sendContext(loaded.session, sender);
|
|
1474
|
+
let task = context.task;
|
|
1475
|
+
if (request.task !== undefined && request.task.trim() !== '') {
|
|
1476
|
+
const ref = resolveTaskRef(loaded.state.jobs ?? [], request.task);
|
|
1477
|
+
if (ref === undefined)
|
|
1478
|
+
return { ok: false, error: { code: 'unknown-task' } };
|
|
1479
|
+
// The coordinator's call re-files the human message its turn answers.
|
|
1480
|
+
if (context.trigger !== undefined) {
|
|
1481
|
+
task = ref === 'new' ? context.trigger : ref;
|
|
1482
|
+
loaded.session.append('room/task-link', { message: context.trigger, task, by: 'coordinator' });
|
|
1483
|
+
}
|
|
1484
|
+
else {
|
|
1485
|
+
task = ref === 'new' ? SELF_TASK : ref;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
const sent = sendOrHold(loaded.session, {
|
|
1489
|
+
to: target, text, fromName: sender.name,
|
|
1490
|
+
intent: {
|
|
1491
|
+
origin: this.isCurrentCoordinator(loaded.session, actorChildSessionId) ? 'coordinator' : 'member',
|
|
1492
|
+
from: senderId, kind, hop: workingHop(events, loaded.state, sender) + 1,
|
|
1493
|
+
...kind === 'task' ? { replyTo: senderId } : {},
|
|
1494
|
+
...inReplyTo === undefined ? {} : { inReplyTo },
|
|
1495
|
+
...request.note === undefined || request.note.trim() === '' ? {} : { note: request.note.trim().slice(0, 500) },
|
|
1496
|
+
...task === undefined ? {} : { task },
|
|
1497
|
+
...context.parent === undefined ? {} : { parent: context.parent },
|
|
1498
|
+
},
|
|
863
1499
|
});
|
|
864
1500
|
await this.ctx.sessions.flush(loaded.session);
|
|
865
|
-
|
|
866
|
-
|
|
1501
|
+
if (!sent.delivered)
|
|
1502
|
+
return { ok: true, value: { member: target.name, held: sent.reason } };
|
|
1503
|
+
this.engine.dispatch(loaded.session, target.name, text, { dispatchSeq: sent.seq });
|
|
1504
|
+
return { ok: true, value: { member: target.name, messageId: messageId(sent.seq) } };
|
|
867
1505
|
}
|
|
868
1506
|
/** Host-authenticated actor; model tool arguments never provide the room or sender. */
|
|
869
1507
|
isCurrentCoordinator(session, actorChildSessionId) {
|
|
@@ -871,15 +1509,358 @@ let RoomService = (() => {
|
|
|
871
1509
|
const coordinator = coordinatorMember(replay(events), events);
|
|
872
1510
|
return actorChildSessionId === undefined ? coordinator?.kind === 'main-agent' : coordinator?.childSessionId === actorChildSessionId;
|
|
873
1511
|
}
|
|
1512
|
+
// ── Cross-session help (help.ts) ──────────────────────────────────────────
|
|
1513
|
+
equipped = new WeakSet();
|
|
1514
|
+
/** Register the helper tools on one helper agent (agent-local: they unwind with it). */
|
|
1515
|
+
equipHelper(agent) {
|
|
1516
|
+
if (this.equipped.has(agent))
|
|
1517
|
+
return;
|
|
1518
|
+
const tools = agent.ctx.get('tools');
|
|
1519
|
+
if (tools === undefined)
|
|
1520
|
+
return;
|
|
1521
|
+
this.equipped.add(agent);
|
|
1522
|
+
for (const def of helperTools(this)) {
|
|
1523
|
+
try {
|
|
1524
|
+
tools.register(def);
|
|
1525
|
+
}
|
|
1526
|
+
catch (error) {
|
|
1527
|
+
this.ctx.logger.warn(`room: helper tool on "${agent.session.id}" was refused: ${String(error)}`);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
/** A room's display title (its latest `session/title`), else its id. */
|
|
1532
|
+
roomTitle(session) {
|
|
1533
|
+
const titled = session.snapshotEvents().findLast(event => event.type === 'session/title');
|
|
1534
|
+
return typeof titled?.data?.title === 'string' && titled.data.title !== '' ? titled.data.title : String(session.id);
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Resolve the session a member names: an `@[…](dsh-session:…)` mention, a
|
|
1538
|
+
* session id, or a title (through the host's session-reference discovery).
|
|
1539
|
+
*/
|
|
1540
|
+
async resolveHelpTarget(caller, name) {
|
|
1541
|
+
const resolver = this.ctx.get('sessionReferenceResolver');
|
|
1542
|
+
if (resolver?.listCandidates === undefined)
|
|
1543
|
+
return { error: 'This host cannot look sessions up; room_help is unavailable.' };
|
|
1544
|
+
const mentioned = mentionedSessions(name)[0];
|
|
1545
|
+
const query = (mentioned ?? name.replace(/^@/u, '')).trim();
|
|
1546
|
+
let candidates = await resolver.listCandidates(caller, query, 8);
|
|
1547
|
+
// 「成员(房间)」 typed as text: look the room up by its own title.
|
|
1548
|
+
const inRoom = mentioned === undefined ? /^(.+)((.+))$/u.exec(query) : null;
|
|
1549
|
+
if (candidates.length === 0 && inRoom !== null)
|
|
1550
|
+
candidates = await resolver.listCandidates(caller, inRoom[2], 8);
|
|
1551
|
+
const titleOf = (entry) => entry.displayTitle ?? entry.label;
|
|
1552
|
+
const exact = candidates.find(entry => entry.sessionId === query) ?? candidates.find(entry => titleOf(entry) === query)
|
|
1553
|
+
?? (inRoom === null ? undefined : candidates.find(entry => titleOf(entry) === inRoom[2]));
|
|
1554
|
+
const picked = exact ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
1555
|
+
if (picked !== undefined)
|
|
1556
|
+
return { sessionId: picked.sessionId, title: titleOf(picked) };
|
|
1557
|
+
if (candidates.length === 0)
|
|
1558
|
+
return { error: `No session matches "${name}". Ask the human to @ the session in the room.` };
|
|
1559
|
+
return { error: `"${name}" matches several sessions: ${candidates.map(entry => `"${titleOf(entry)}" (${entry.sessionId})`).join(', ')}. Retry with the exact title or id.` };
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* A member asks another session for help, or writes again on a thread
|
|
1563
|
+
* (the `room_help` tool). The first ask of a session the human never
|
|
1564
|
+
* @-mentioned in this room waits for the human's go-ahead.
|
|
1565
|
+
* @param request - the calling session, the target session (new thread) or help id (existing), the text.
|
|
1566
|
+
* @param caller - the calling agent.
|
|
1567
|
+
* @returns the tool's text result.
|
|
1568
|
+
*/
|
|
1569
|
+
async askHelp(request, caller, actorChildSessionId) {
|
|
1570
|
+
const text = request.text.trim();
|
|
1571
|
+
if (text === '')
|
|
1572
|
+
return 'The help text must be non-blank.';
|
|
1573
|
+
// The asker: a CLI member (authenticated by the bridge), a native member
|
|
1574
|
+
// (its own session routes to the room), else the room's own agent.
|
|
1575
|
+
const routed = actorChildSessionId === undefined ? this.nativeRoute(request.sessionId) : { roomId: SessionId(request.sessionId), actor: actorChildSessionId };
|
|
1576
|
+
const roomId = routed?.roomId ?? SessionId(request.sessionId);
|
|
1577
|
+
const loaded = this.ctx.sessions.get(roomId) !== undefined && isRoomLog(this.ctx.sessions.get(roomId).snapshotEvents())
|
|
1578
|
+
? await this.ensureLive(roomId)
|
|
1579
|
+
: undefined;
|
|
1580
|
+
if (loaded === undefined || !loaded.ok)
|
|
1581
|
+
return 'room_help works from inside a room; this session is not one.';
|
|
1582
|
+
const room = loaded.session;
|
|
1583
|
+
const events = room.snapshotEvents();
|
|
1584
|
+
const asker = loaded.state.members.find(member => routed === undefined ? member.kind === 'main-agent' : member.childSessionId === routed.actor);
|
|
1585
|
+
if (asker === undefined)
|
|
1586
|
+
return 'Only a member of this room can ask for help.';
|
|
1587
|
+
const roomTitle = this.roomTitle(room);
|
|
1588
|
+
const helps = replayHelps(events);
|
|
1589
|
+
const threadId = request.help?.trim();
|
|
1590
|
+
if (threadId !== undefined && threadId !== '') {
|
|
1591
|
+
const help = helps.find(entry => entry.id === threadId);
|
|
1592
|
+
if (help === undefined)
|
|
1593
|
+
return `No help thread "${threadId}" in this room.`;
|
|
1594
|
+
room.append('room/help-message', { help: help.id, kind: 'message', text });
|
|
1595
|
+
await this.ctx.sessions.flush(room);
|
|
1596
|
+
if (help.target.room === true)
|
|
1597
|
+
return this.deliverToRoom(room, help, text, 'message');
|
|
1598
|
+
return this.deliverHelp(room, help, messagePrompt(help, roomTitle, text), 'message', text);
|
|
1599
|
+
}
|
|
1600
|
+
if (request.session === undefined || request.session.trim() === '')
|
|
1601
|
+
return 'Name the session to ask: its title, its id, or the @ mention the human wrote.';
|
|
1602
|
+
// Discovery ranks by the asking agent's workspace; a CLI member has no
|
|
1603
|
+
// agent here, so the room's own stands in.
|
|
1604
|
+
const finder = caller ?? this.ctx.agents.get(room.id);
|
|
1605
|
+
if (finder === undefined)
|
|
1606
|
+
return 'This room has no live agent to look sessions up with; try again in a moment.';
|
|
1607
|
+
const target = await this.resolveHelpTarget(finder, request.session.trim());
|
|
1608
|
+
if ('error' in target)
|
|
1609
|
+
return target.error;
|
|
1610
|
+
if (target.sessionId === room.id || loaded.state.members.some(member => member.childSessionId === target.sessionId)) {
|
|
1611
|
+
return 'That session is this room or one of its members; use room_message instead.';
|
|
1612
|
+
}
|
|
1613
|
+
// The host fences archived sessions off (they are not listed, cannot be
|
|
1614
|
+
// opened, and a waking delivery to their agent is refused): say so.
|
|
1615
|
+
const archived = this.ctx.get('workspaceRegistry');
|
|
1616
|
+
if (archived?.archivedSessionIds?.includes(target.sessionId) === true) {
|
|
1617
|
+
return `"${target.title}" is archived; it cannot be asked. Tell the human to unarchive it first, or pick another session.`;
|
|
1618
|
+
}
|
|
1619
|
+
// A room target goes through that room's own roster: the named member
|
|
1620
|
+
// (the argument, or the 「成员(房间)」 label the @ menu wrote), else its coordinator.
|
|
1621
|
+
const targetRoom = (await this.isRoom({ sessionId: SessionId(target.sessionId) })) === true;
|
|
1622
|
+
const named = request.member?.trim() || (targetRoom ? memberFromLabel(request.session, target.title) : undefined);
|
|
1623
|
+
if (targetRoom) {
|
|
1624
|
+
const other = await this.ensureLive(SessionId(target.sessionId));
|
|
1625
|
+
if (!other.ok)
|
|
1626
|
+
return `Could not open the room "${target.title}".`;
|
|
1627
|
+
if (named !== undefined && !other.state.members.some(member => member.name === named)) {
|
|
1628
|
+
return `The room "${target.title}" has no member "${named}"; its members are ${other.state.members.map(member => member.name).join(', ')}.`;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
const consented = referencedSessions(events).has(target.sessionId) || helps.some(entry => entry.target.sessionId === target.sessionId);
|
|
1632
|
+
if (!consented) {
|
|
1633
|
+
const answers = await this.askQuestions(room, asker, [{
|
|
1634
|
+
id: 'help', header: '求助',
|
|
1635
|
+
question: `${asker.name} 想向会话「${target.title}」求助:${text.length > 80 ? `${text.slice(0, 80)}…` : text}`,
|
|
1636
|
+
options: [{ label: '允许' }, { label: '不允许' }],
|
|
1637
|
+
}], 10 * 60_000);
|
|
1638
|
+
if (answers?.[0]?.selected[0] !== '允许')
|
|
1639
|
+
return `The human did not allow asking "${target.title}". Do not retry unless they @ that session in the room.`;
|
|
1640
|
+
}
|
|
1641
|
+
const help = {
|
|
1642
|
+
id: randomUUID().slice(0, 8), from: memberId(events, asker), fromName: asker.name,
|
|
1643
|
+
target: targetRoom ? { ...target, room: true, ...named === undefined ? {} : { member: named } } : target, text,
|
|
1644
|
+
};
|
|
1645
|
+
room.append('room/help', help);
|
|
1646
|
+
await this.ctx.sessions.flush(room);
|
|
1647
|
+
if (targetRoom)
|
|
1648
|
+
return this.deliverToRoom(room, help, text, 'request');
|
|
1649
|
+
return this.deliverHelp(room, help, requestPrompt(help, roomTitle), 'request', text);
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Hand a help request (or a follow-up on it) to another room: its named
|
|
1653
|
+
* member, else its coordinator, as a `help`-origin dispatch — held like any
|
|
1654
|
+
* agent message while that room is paused — and shown on that room's
|
|
1655
|
+
* timeline. The member's final reply comes back through {@link helpSettled}.
|
|
1656
|
+
*/
|
|
1657
|
+
async deliverToRoom(room, help, text, op) {
|
|
1658
|
+
const fail = async (error) => {
|
|
1659
|
+
room.append('room/help-state', { help: help.id, state: 'failed', error });
|
|
1660
|
+
await this.ctx.sessions.flush(room);
|
|
1661
|
+
return `Could not reach "${help.target.title}": ${error}`;
|
|
1662
|
+
};
|
|
1663
|
+
const other = await this.ensureLive(SessionId(help.target.sessionId));
|
|
1664
|
+
if (!other.ok)
|
|
1665
|
+
return fail('that room is not available');
|
|
1666
|
+
const events = other.session.snapshotEvents();
|
|
1667
|
+
const member = help.target.member === undefined
|
|
1668
|
+
? coordinatorMember(other.state, events)
|
|
1669
|
+
: other.state.members.find(entry => entry.name === help.target.member);
|
|
1670
|
+
if (member === undefined)
|
|
1671
|
+
return fail(help.target.member === undefined ? 'that room has no coordinator' : `that room has no member "${help.target.member}"`);
|
|
1672
|
+
const origin = { id: help.id, room: room.id, roomTitle: this.roomTitle(room), from: help.fromName };
|
|
1673
|
+
// A round a restart left uncertain blocks the member's later work until a
|
|
1674
|
+
// human reconciles it — silently, for a request from outside. Move on
|
|
1675
|
+
// from it, as a human message to that member does.
|
|
1676
|
+
this.moveOnFromLostRounds(other.session, [member.name], '另一个协作间来求助;被重启打断的这一轮按没做完处理');
|
|
1677
|
+
const sent = sendOrHold(other.session, {
|
|
1678
|
+
to: member, text, fromName: `${help.fromName}(${origin.roomTitle})`,
|
|
1679
|
+
intent: { origin: 'help', hop: 1, kind: 'task', help: origin, ...op === 'request' ? { task: SELF_TASK } : {} },
|
|
1680
|
+
});
|
|
1681
|
+
other.session.append('room/help-in', { ...origin, member: member.name, text, op, ...sent.delivered ? {} : { held: true } });
|
|
1682
|
+
await this.ctx.sessions.flush(other.session);
|
|
1683
|
+
room.append('room/help-state', { help: help.id, state: sent.delivered ? 'running' : 'queued' });
|
|
1684
|
+
await this.ctx.sessions.flush(room);
|
|
1685
|
+
if (sent.delivered)
|
|
1686
|
+
this.engine.dispatch(other.session, member.name, text, { dispatchSeq: sent.seq });
|
|
1687
|
+
const who = `${member.name} in the room "${help.target.title}"`;
|
|
1688
|
+
return `${sent.delivered ? `Sent to ${who}.` : `Sent to ${who}; that room is paused, so the request waits until it resumes.`}`
|
|
1689
|
+
+ ` Help id: ${help.id}. The reply reaches you on its own — end your turn now; do not poll.`;
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* A help dispatch in this room settled: its final reply (or its failure)
|
|
1693
|
+
* goes back to the asking room's thread.
|
|
1694
|
+
* @param room - the room that was asked.
|
|
1695
|
+
* @param dispatchSeq - the settled dispatch.
|
|
1696
|
+
* @param outcome - the run's outcome.
|
|
1697
|
+
*/
|
|
1698
|
+
async helpSettled(room, dispatchSeq, outcome) {
|
|
1699
|
+
const source = dispatchSeq === undefined ? undefined : room.snapshotEvents().find(event => event.seq === dispatchSeq);
|
|
1700
|
+
if (source?.type !== 'room/dispatch' || source.data.help === undefined)
|
|
1701
|
+
return;
|
|
1702
|
+
const asked = source.data.help;
|
|
1703
|
+
const loaded = await this.ensureLive(SessionId(asked.room));
|
|
1704
|
+
if (!loaded.ok)
|
|
1705
|
+
return;
|
|
1706
|
+
const thread = replayHelps(loaded.session.snapshotEvents()).find(entry => entry.id === asked.id);
|
|
1707
|
+
if (thread === undefined)
|
|
1708
|
+
return;
|
|
1709
|
+
if (outcome.state === 'done') {
|
|
1710
|
+
const text = outcome.text?.trim();
|
|
1711
|
+
await this.recordHelperMessage(loaded.session, thread, { kind: 'reply', text: text === undefined || text === '' ? '(对方这一轮结束了,没有给出答复)' : text });
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
loaded.session.append('room/help-state', { help: thread.id, state: 'failed', error: outcome.error ?? `对方这一轮${outcome.state === 'cancelled' ? '被取消了' : '没有完成'}` });
|
|
1715
|
+
await this.ctx.sessions.flush(loaded.session);
|
|
1716
|
+
}
|
|
1717
|
+
/** Queue one help message in the helper's session (waking it when cold) and watch its turn. */
|
|
1718
|
+
async deliverHelp(room, help, prompt, op, display) {
|
|
1719
|
+
const fail = async (error) => {
|
|
1720
|
+
room.append('room/help-state', { help: help.id, state: 'failed', error });
|
|
1721
|
+
await this.ctx.sessions.flush(room);
|
|
1722
|
+
return `Could not reach "${help.target.title}": ${error}`;
|
|
1723
|
+
};
|
|
1724
|
+
const controller = this.ctx.get('sessionController');
|
|
1725
|
+
if (controller?.resolveAgent === undefined)
|
|
1726
|
+
return fail('this host cannot wake other sessions');
|
|
1727
|
+
let agent;
|
|
1728
|
+
try {
|
|
1729
|
+
const resolved = await controller.resolveAgent(SessionId(help.target.sessionId));
|
|
1730
|
+
if ('error' in resolved)
|
|
1731
|
+
return await fail(resolved.error.message);
|
|
1732
|
+
agent = resolved.agent;
|
|
1733
|
+
}
|
|
1734
|
+
catch (error) {
|
|
1735
|
+
return fail(String(error));
|
|
1736
|
+
}
|
|
1737
|
+
this.equipHelper(agent);
|
|
1738
|
+
const queued = agent.status === 'running';
|
|
1739
|
+
const message = helpMessage(prompt, { help: help.id, room: room.id, roomTitle: this.roomTitle(room), from: help.fromName, op, text: display.slice(0, 4000) });
|
|
1740
|
+
room.append('room/help-state', { help: help.id, state: queued ? 'queued' : 'running' });
|
|
1741
|
+
await this.ctx.sessions.flush(room);
|
|
1742
|
+
const mark = room.snapshotEvents().at(-1)?.seq ?? -1;
|
|
1743
|
+
agent.followup(message);
|
|
1744
|
+
this.watchHelp(room.id, help.id, agent, String(message.id), mark);
|
|
1745
|
+
return `${queued ? `Sent to "${help.target.title}"; it is busy, so the request waits in its queue.` : `Sent to "${help.target.title}".`}`
|
|
1746
|
+
+ ` Help id: ${help.id}. Its reply reaches you on its own — end your turn now; do not poll.`;
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* After the helper's turn: a turn that never claimed the message failed the
|
|
1750
|
+
* thread; one that ended without the reply or question tool hands over its
|
|
1751
|
+
* last answer as the reply.
|
|
1752
|
+
*/
|
|
1753
|
+
watchHelp(roomId, helpId, agent, messageId, mark) {
|
|
1754
|
+
void (async () => {
|
|
1755
|
+
await agent.whenIdle();
|
|
1756
|
+
const loaded = await this.ensureLive(roomId);
|
|
1757
|
+
if (!loaded.ok)
|
|
1758
|
+
return;
|
|
1759
|
+
const room = loaded.session;
|
|
1760
|
+
const help = replayHelps(room.snapshotEvents()).find(entry => entry.id === helpId);
|
|
1761
|
+
if (help === undefined || help.state === 'failed')
|
|
1762
|
+
return;
|
|
1763
|
+
const answered = room.snapshotEvents().some(event => event.seq > mark && event.type === 'room/help-message' && event.data.help === helpId && event.data.kind !== 'message');
|
|
1764
|
+
if (answered)
|
|
1765
|
+
return;
|
|
1766
|
+
const helperEvents = agent.session.snapshotEvents();
|
|
1767
|
+
if (!claimed(helperEvents, messageId)) {
|
|
1768
|
+
room.append('room/help-state', { help: helpId, state: 'failed', error: 'the request left the helper\'s queue without being handled' });
|
|
1769
|
+
await this.ctx.sessions.flush(room);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
const answer = turnAnswer(helperEvents, messageId);
|
|
1773
|
+
await this.recordHelperMessage(room, help, { kind: 'reply', text: answer === '' ? '(对方这一轮结束了,没有给出答复)' : answer, fallback: true });
|
|
1774
|
+
})().catch((error) => { this.ctx.logger.warn(`room: help ${helpId} watch faulted: ${String(error)}`); });
|
|
1775
|
+
}
|
|
1776
|
+
/** A help request reached the helper's turn: a queued thread is running now. */
|
|
1777
|
+
async markHelpRunning(source) {
|
|
1778
|
+
const room = this.ctx.sessions.get(SessionId(source.room));
|
|
1779
|
+
if (room === undefined)
|
|
1780
|
+
return;
|
|
1781
|
+
const help = replayHelps(room.snapshotEvents()).find(entry => entry.id === source.help);
|
|
1782
|
+
if (help === undefined || help.state === 'running')
|
|
1783
|
+
return;
|
|
1784
|
+
if (help.state !== 'queued' && !(help.state === 'asking' && source.op === 'message') && !(help.state === 'replied' && source.op === 'message'))
|
|
1785
|
+
return;
|
|
1786
|
+
room.append('room/help-state', { help: help.id, state: 'running' });
|
|
1787
|
+
await this.ctx.sessions.flush(room);
|
|
1788
|
+
}
|
|
1789
|
+
/** Journal a helper's question or reply and hand it to the asking member. */
|
|
1790
|
+
async recordHelperMessage(room, help, message) {
|
|
1791
|
+
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 } });
|
|
1792
|
+
room.append('room/help-state', { help: help.id, state: message.kind === 'reply' ? 'replied' : 'asking' });
|
|
1793
|
+
const events = room.snapshotEvents();
|
|
1794
|
+
const state = replay(events);
|
|
1795
|
+
const asker = state.members.find(member => memberId(events, member) === help.from) ?? coordinatorMember(state, events);
|
|
1796
|
+
if (asker === undefined) {
|
|
1797
|
+
await this.ctx.sessions.flush(room);
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
const text = askerPrompt(help, message);
|
|
1801
|
+
const dispatch = room.append('room/dispatch', {
|
|
1802
|
+
id: randomUUID(), origin: 'report', reportFor: `help:${help.id}`, targets: [asker.name], targetIds: [memberId(events, asker)], text,
|
|
1803
|
+
});
|
|
1804
|
+
await this.ctx.sessions.flush(room);
|
|
1805
|
+
this.engine.dispatch(room, asker.name, text, { dispatchSeq: dispatch.seq });
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* The helper's `room_help_reply` / `room_help_ask`: find the thread from
|
|
1809
|
+
* the help requests in the helper's own log, then carry the message back.
|
|
1810
|
+
* @param agent - the helper agent.
|
|
1811
|
+
* @param request - thread (optional when only one), kind, text, handoff.
|
|
1812
|
+
* @returns the tool's text result.
|
|
1813
|
+
*/
|
|
1814
|
+
async helperMessage(agent, request) {
|
|
1815
|
+
const received = receivedHelps(agent.session.snapshotEvents());
|
|
1816
|
+
const wanted = request.help?.trim();
|
|
1817
|
+
const source = wanted === undefined || wanted === '' ? received.at(-1) : received.find(entry => entry.help === wanted);
|
|
1818
|
+
if (source === undefined) {
|
|
1819
|
+
return received.length === 0
|
|
1820
|
+
? 'No help request reached this session.'
|
|
1821
|
+
: `No help "${wanted}" here; the requests this session received: ${received.map(entry => `${entry.help} (from ${entry.from} in "${entry.roomTitle}")`).join(', ')}.`;
|
|
1822
|
+
}
|
|
1823
|
+
const text = request.text.trim();
|
|
1824
|
+
if (text === '')
|
|
1825
|
+
return 'The text must be non-blank.';
|
|
1826
|
+
const loaded = await this.ensureLive(SessionId(source.room));
|
|
1827
|
+
if (!loaded.ok)
|
|
1828
|
+
return `The room "${source.roomTitle}" that asked is no longer available.`;
|
|
1829
|
+
const help = replayHelps(loaded.session.snapshotEvents()).find(entry => entry.id === source.help);
|
|
1830
|
+
if (help === undefined)
|
|
1831
|
+
return 'That help thread is gone.';
|
|
1832
|
+
const handoff = request.handoff?.trim();
|
|
1833
|
+
await this.recordHelperMessage(loaded.session, help, { kind: request.kind, text, ...handoff === undefined || handoff === '' ? {} : { handoff } });
|
|
1834
|
+
return request.kind === 'reply'
|
|
1835
|
+
? `Your reply reached ${help.fromName} in "${source.roomTitle}".`
|
|
1836
|
+
: `Your question went to ${help.fromName} in "${source.roomTitle}"; the answer arrives as a new message. End your turn now.`;
|
|
1837
|
+
}
|
|
874
1838
|
/** Bounded shared room context for native and external model tools; reading never wakes an agent. */
|
|
875
1839
|
async readRoomContext(sessionId) {
|
|
1840
|
+
const routed = this.nativeRoute(sessionId);
|
|
1841
|
+
if (routed !== undefined) {
|
|
1842
|
+
const self = replay(this.ctx.sessions.get(routed.roomId).snapshotEvents()).members.find(member => member.childSessionId === routed.actor)?.name;
|
|
1843
|
+
return JSON.stringify({ self, ...JSON.parse(await this.readRoomContext(routed.roomId)) });
|
|
1844
|
+
}
|
|
876
1845
|
const state = await this.getState({ sessionId: SessionId(sessionId) });
|
|
1846
|
+
if (!state.ok && state.error.code === 'not-a-room') {
|
|
1847
|
+
// A plain session: everything an invite needs, instead of an error that
|
|
1848
|
+
// sends the model guessing provider ids.
|
|
1849
|
+
const offered = await this.listProviders({ sessionId: SessionId(sessionId) });
|
|
1850
|
+
return JSON.stringify({
|
|
1851
|
+
room: false,
|
|
1852
|
+
note: 'This session is not a room yet. room_invite promotes it into one: no other setup is needed.',
|
|
1853
|
+
providers: offered.providers.map(entry => ({ provider: entry.provider, name: entry.displayName, ...entry.authenticated ? {} : { loggedOut: true } })),
|
|
1854
|
+
...offered.presets === undefined ? {} : { modes: offered.presets.map(entry => ({ id: entry.id, ...entry.name === undefined ? {} : { name: entry.name } })) },
|
|
1855
|
+
...offered.sessionPreset === undefined ? {} : { sessionMode: offered.sessionPreset },
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
877
1858
|
if (!state.ok)
|
|
878
1859
|
throw new Error(`Room unavailable: ${state.error.code}`);
|
|
879
1860
|
const events = this.ctx.sessions.get(SessionId(sessionId))?.snapshotEvents() ?? [];
|
|
880
1861
|
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
1862
|
return JSON.stringify({ planCommands: planCommandContract(), coordinator: coordinatorMember(state.value)?.name,
|
|
882
|
-
state: { ...state.value, deliveries: state.value.deliveries?.slice(-30),
|
|
1863
|
+
state: { ...state.value, deliveries: state.value.deliveries?.slice(-30), jobs: state.value.jobs?.slice(0, 20), relays: state.value.relays.slice(-30) },
|
|
883
1864
|
providers: await this.listProviders({}), recent });
|
|
884
1865
|
}
|
|
885
1866
|
/** Human UI and native/external tools share the same durable transition service. */
|
|
@@ -899,6 +1880,9 @@ let RoomService = (() => {
|
|
|
899
1880
|
}
|
|
900
1881
|
/** The actor is derived from the calling native agent or authenticated bridge. */
|
|
901
1882
|
async commandPlan(sessionId, command, actorChildSessionId) {
|
|
1883
|
+
const routed = actorChildSessionId === undefined ? this.nativeRoute(sessionId) : undefined;
|
|
1884
|
+
if (routed !== undefined)
|
|
1885
|
+
return this.commandPlan(routed.roomId, command, routed.actor);
|
|
902
1886
|
const loaded = await this.ensureLive(SessionId(sessionId));
|
|
903
1887
|
if (!loaded.ok)
|
|
904
1888
|
throw new Error(loaded.error.code);
|
|
@@ -914,6 +1898,151 @@ let RoomService = (() => {
|
|
|
914
1898
|
const { requests: _requests, ...view } = plan;
|
|
915
1899
|
return JSON.stringify(view);
|
|
916
1900
|
}
|
|
1901
|
+
/**
|
|
1902
|
+
* The mode of an external member — the host preset whose plugin tools its
|
|
1903
|
+
* bridge offers (local-agent's preset tool host runs them) — with its
|
|
1904
|
+
* working directory. Host method for the member channel, never a Remote.
|
|
1905
|
+
* @param actor - the authenticated member run.
|
|
1906
|
+
* @returns the mode, or undefined when the member has none or is not here.
|
|
1907
|
+
*/
|
|
1908
|
+
async memberMode(actor) {
|
|
1909
|
+
const loaded = await this.ensureLive(SessionId(actor.parentSessionId));
|
|
1910
|
+
if (!loaded.ok)
|
|
1911
|
+
return undefined;
|
|
1912
|
+
const cli = loaded.state.members.filter(entry => entry.kind === 'cli' && entry.provider === actor.provider);
|
|
1913
|
+
// A member's first round asks before the journal knows its child session
|
|
1914
|
+
// (claude lists MCP tools at spawn): the one first start in flight for
|
|
1915
|
+
// this provider is the asker. Two at once stay ambiguous — no mode.
|
|
1916
|
+
const starting = this.engine.startingMembers(actor.parentSessionId);
|
|
1917
|
+
const fresh = cli.filter(entry => entry.childSessionId === undefined && starting.includes(memberId(loaded.session.snapshotEvents(), entry)));
|
|
1918
|
+
const member = cli.find(entry => entry.childSessionId === actor.childSessionId)
|
|
1919
|
+
?? (fresh.length === 1 ? fresh[0] : undefined);
|
|
1920
|
+
if (member?.preset === undefined)
|
|
1921
|
+
return undefined;
|
|
1922
|
+
const cwd = member.cwd ?? loaded.session.header.cwd;
|
|
1923
|
+
return { preset: member.preset, ...cwd === undefined ? {} : { cwd } };
|
|
1924
|
+
}
|
|
1925
|
+
/** Pending member tool approvals this process can still answer: approval id → its waiting call. */
|
|
1926
|
+
approvalWaiters = new Map();
|
|
1927
|
+
/** Close approvals no waiting call stands behind any more (a restart lost it). */
|
|
1928
|
+
expireApprovals(session) {
|
|
1929
|
+
for (const approval of replay(session.snapshotEvents()).approvals ?? []) {
|
|
1930
|
+
if (!this.approvalWaiters.has(approval.id))
|
|
1931
|
+
session.append('room/approval-resolved', { id: approval.id, outcome: 'cancelled' });
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* A CLI member's mode tool needs the human's approval: ask in the room's
|
|
1936
|
+
* conversation (a 允许 / 拒绝 line) and resolve when the human answers,
|
|
1937
|
+
* the call is cancelled, or the room is gone. Host method for the member
|
|
1938
|
+
* channel (local-agent's approval hook), never a Remote.
|
|
1939
|
+
* @param actor - the authenticated member run.
|
|
1940
|
+
* @param request - the tool, an optional reason, and the call's signal.
|
|
1941
|
+
* @returns the approval outcome the tool registry expects.
|
|
1942
|
+
*/
|
|
1943
|
+
async askMemberApproval(actor, request) {
|
|
1944
|
+
const loaded = await this.ensureLive(SessionId(actor.parentSessionId));
|
|
1945
|
+
if (!loaded.ok)
|
|
1946
|
+
return 'unavailable';
|
|
1947
|
+
const events = loaded.session.snapshotEvents();
|
|
1948
|
+
const member = loaded.state.members.find(entry => entry.childSessionId === actor.childSessionId && entry.provider === actor.provider);
|
|
1949
|
+
if (member === undefined)
|
|
1950
|
+
return 'unavailable';
|
|
1951
|
+
if (request.signal?.aborted === true)
|
|
1952
|
+
return 'cancelled';
|
|
1953
|
+
const id = randomUUID();
|
|
1954
|
+
const session = loaded.session;
|
|
1955
|
+
const answered = new Promise((resolve) => {
|
|
1956
|
+
this.approvalWaiters.set(id, resolve);
|
|
1957
|
+
request.signal?.addEventListener('abort', () => { resolve('cancelled'); }, { once: true });
|
|
1958
|
+
});
|
|
1959
|
+
session.append('room/approval', { id, member: member.name, memberId: memberId(events, member), tool: request.toolName,
|
|
1960
|
+
...request.reason === undefined || request.reason.trim() === '' ? {} : { reason: request.reason.trim().slice(0, 500) } });
|
|
1961
|
+
await this.ctx.sessions.flush(session);
|
|
1962
|
+
const outcome = await answered;
|
|
1963
|
+
this.approvalWaiters.delete(id);
|
|
1964
|
+
if (replay(session.snapshotEvents()).approvals?.some(entry => entry.id === id) === true) {
|
|
1965
|
+
session.append('room/approval-resolved', { id, outcome });
|
|
1966
|
+
await this.ctx.sessions.flush(session);
|
|
1967
|
+
}
|
|
1968
|
+
return outcome;
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* The human answers a member's tool approval from the room.
|
|
1972
|
+
* @param request - room session, approval id, allow once or reject.
|
|
1973
|
+
* @returns ok, or `approval-not-pending` when it already ended.
|
|
1974
|
+
*/
|
|
1975
|
+
async answerApproval(request) {
|
|
1976
|
+
const loaded = await this.ensureLive(request.sessionId);
|
|
1977
|
+
if (!loaded.ok)
|
|
1978
|
+
return { ok: false, error: loaded.error };
|
|
1979
|
+
const waiter = this.approvalWaiters.get(request.id);
|
|
1980
|
+
if (waiter === undefined || loaded.state.approvals?.some(entry => entry.id === request.id) !== true)
|
|
1981
|
+
return { ok: false, error: { code: 'approval-not-pending' } };
|
|
1982
|
+
loaded.session.append('room/approval-resolved', { id: request.id, outcome: request.outcome });
|
|
1983
|
+
await this.ctx.sessions.flush(loaded.session);
|
|
1984
|
+
waiter(request.outcome);
|
|
1985
|
+
return { ok: true };
|
|
1986
|
+
}
|
|
1987
|
+
/** Pending member questions this process can still answer: question id → its waiting call. */
|
|
1988
|
+
questionWaiters = new Map();
|
|
1989
|
+
/** Close question cards no waiting call stands behind any more (a restart lost it). */
|
|
1990
|
+
expireQuestions(session) {
|
|
1991
|
+
for (const question of replay(session.snapshotEvents()).questions ?? []) {
|
|
1992
|
+
if (!this.questionWaiters.has(question.id))
|
|
1993
|
+
session.append('room/question-resolved', { id: question.id, outcome: 'cancelled' });
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* A member asks the human questions (its bridge's `ask_user_question`):
|
|
1998
|
+
* journal a pick-to-answer card in the room and wait for the answer, the
|
|
1999
|
+
* timeout, or the room going away.
|
|
2000
|
+
* @param session - the live room session.
|
|
2001
|
+
* @param member - the asking member.
|
|
2002
|
+
* @param questions - the validated questions.
|
|
2003
|
+
* @param timeoutMs - how long the call waits before it gives up.
|
|
2004
|
+
* @returns the answers, or undefined when nobody answered.
|
|
2005
|
+
*/
|
|
2006
|
+
async askQuestions(session, member, questions, timeoutMs) {
|
|
2007
|
+
const id = randomUUID();
|
|
2008
|
+
const answered = new Promise((resolve) => {
|
|
2009
|
+
this.questionWaiters.set(id, resolve);
|
|
2010
|
+
setTimeout(() => { resolve(undefined); }, timeoutMs).unref?.();
|
|
2011
|
+
});
|
|
2012
|
+
session.append('room/question', { id, member: member.name, memberId: memberId(session.snapshotEvents(), member), questions });
|
|
2013
|
+
await this.ctx.sessions.flush(session);
|
|
2014
|
+
const answers = await answered;
|
|
2015
|
+
this.questionWaiters.delete(id);
|
|
2016
|
+
if (answers === undefined && replay(session.snapshotEvents()).questions?.some(entry => entry.id === id) === true) {
|
|
2017
|
+
session.append('room/question-resolved', { id, outcome: 'cancelled' });
|
|
2018
|
+
await this.ctx.sessions.flush(session);
|
|
2019
|
+
}
|
|
2020
|
+
return answers;
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* The human answers a member's question card from the room.
|
|
2024
|
+
* @param request - room session, card id, one answer per question.
|
|
2025
|
+
* @returns ok, or `question-not-pending` when it already ended.
|
|
2026
|
+
*/
|
|
2027
|
+
async answerQuestion(request) {
|
|
2028
|
+
const loaded = await this.ensureLive(request.sessionId);
|
|
2029
|
+
if (!loaded.ok)
|
|
2030
|
+
return { ok: false, error: loaded.error };
|
|
2031
|
+
const waiter = this.questionWaiters.get(request.id);
|
|
2032
|
+
const pending = loaded.state.questions?.find(entry => entry.id === request.id);
|
|
2033
|
+
if (waiter === undefined || pending === undefined)
|
|
2034
|
+
return { ok: false, error: { code: 'question-not-pending' } };
|
|
2035
|
+
// Keep only answers to this card's own questions, in the shape the asker expects.
|
|
2036
|
+
const answers = pending.questions.map((question) => {
|
|
2037
|
+
const given = request.answers.find(answer => answer.id === question.id);
|
|
2038
|
+
const custom = given?.custom?.trim();
|
|
2039
|
+
return { id: question.id, selected: [...given?.selected ?? []].filter(label => typeof label === 'string'), ...custom === undefined || custom === '' ? {} : { custom } };
|
|
2040
|
+
});
|
|
2041
|
+
loaded.session.append('room/question-resolved', { id: request.id, outcome: 'answered', answers });
|
|
2042
|
+
await this.ctx.sessions.flush(loaded.session);
|
|
2043
|
+
waiter(answers);
|
|
2044
|
+
return { ok: true };
|
|
2045
|
+
}
|
|
917
2046
|
/** Shared backend for external MCP room tools. Not exposed as a browser Remote. */
|
|
918
2047
|
async receiveMemberCommand(actor, command) {
|
|
919
2048
|
try {
|
|
@@ -926,7 +2055,7 @@ let RoomService = (() => {
|
|
|
926
2055
|
if (command === null || typeof command !== 'object' || command.arguments === null || typeof command.arguments !== 'object' || Array.isArray(command.arguments))
|
|
927
2056
|
return { ok: false, error: 'Malformed room command' };
|
|
928
2057
|
const args = command.arguments;
|
|
929
|
-
const allowed = { room_plan: ['command'], room_read: [], room_invite: ['provider', 'name', 'instructions', 'cwd', 'model', 'firstTask'], room_message: ['member', 'text'] };
|
|
2058
|
+
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
2059
|
const keys = allowed[command.name];
|
|
931
2060
|
if (keys === undefined || Object.keys(args).some(key => !keys.includes(key)))
|
|
932
2061
|
return { ok: false, error: 'Unknown room command or argument; room and actor identity are host-owned' };
|
|
@@ -937,21 +2066,50 @@ let RoomService = (() => {
|
|
|
937
2066
|
return value;
|
|
938
2067
|
};
|
|
939
2068
|
const optional = (key) => args[key] === undefined ? undefined : required(key);
|
|
2069
|
+
if (command.name === 'ask_user_question') {
|
|
2070
|
+
const questions = parseQuestions(args.questions);
|
|
2071
|
+
if (typeof questions === 'string')
|
|
2072
|
+
return { ok: false, error: questions };
|
|
2073
|
+
const answers = await this.askQuestions(loaded.session, sender, questions, QUESTION_TIMEOUT_MS);
|
|
2074
|
+
return answers === undefined
|
|
2075
|
+
? { 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.' }
|
|
2076
|
+
: { ok: true, receipt: JSON.stringify({ answers }) };
|
|
2077
|
+
}
|
|
2078
|
+
if (command.name === 'room_help') {
|
|
2079
|
+
const text = await this.askHelp({ sessionId: loaded.session.id, text: required('text'),
|
|
2080
|
+
...optional('session') === undefined ? {} : { session: optional('session') },
|
|
2081
|
+
...optional('member') === undefined ? {} : { member: optional('member') },
|
|
2082
|
+
...optional('help') === undefined ? {} : { help: optional('help') } }, undefined, actor.childSessionId);
|
|
2083
|
+
return { ok: true, receipt: text };
|
|
2084
|
+
}
|
|
940
2085
|
if (command.name === 'room_read') {
|
|
941
2086
|
return { ok: true, receipt: JSON.stringify({ self: sender.name, ...JSON.parse(await this.readRoomContext(loaded.session.id)) }) };
|
|
942
2087
|
}
|
|
943
2088
|
if (command.name === 'room_plan')
|
|
944
2089
|
return { ok: true, receipt: await this.commandPlan(loaded.session.id, required('command'), actor.childSessionId) };
|
|
2090
|
+
if (command.name === 'room_message') {
|
|
2091
|
+
const kind = optional('kind');
|
|
2092
|
+
if (kind !== undefined && kind !== 'task' && kind !== 'notify')
|
|
2093
|
+
return { ok: false, error: 'kind must be "task" or "notify"' };
|
|
2094
|
+
const sent = await this.messageMember({ sessionId: loaded.session.id, member: required('member'), text: required('text'),
|
|
2095
|
+
...kind === undefined ? {} : { kind }, ...optional('inReplyTo') === undefined ? {} : { inReplyTo: optional('inReplyTo') },
|
|
2096
|
+
...optional('note') === undefined ? {} : { note: optional('note') },
|
|
2097
|
+
...optional('task') === undefined ? {} : { task: optional('task') } }, actor.childSessionId);
|
|
2098
|
+
return sent.ok ? { ok: true, receipt: messageReceipt(sent.value, kind ?? 'task') } : { ok: false, error: sent.error.code };
|
|
2099
|
+
}
|
|
945
2100
|
if (!this.isCurrentCoordinator(loaded.session, actor.childSessionId))
|
|
946
|
-
return { ok: false, error: 'Only the current coordinator may invite members
|
|
947
|
-
const result =
|
|
948
|
-
?
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
}
|
|
954
|
-
|
|
2101
|
+
return { ok: false, error: 'Only the current coordinator may invite members' };
|
|
2102
|
+
const result = await this.inviteMember({ sessionId: loaded.session.id, provider: required('provider'), name: required('name'),
|
|
2103
|
+
...optional('role') === undefined ? {} : { role: optional('role') },
|
|
2104
|
+
...optional('instructions') === undefined ? {} : { instructions: optional('instructions') },
|
|
2105
|
+
...optional('cwd') === undefined ? {} : { cwd: optional('cwd') },
|
|
2106
|
+
...optional('model') === undefined ? {} : { model: optional('model') },
|
|
2107
|
+
...optional('effort') === undefined ? {} : { effort: optional('effort') },
|
|
2108
|
+
...optional('firstTask') === undefined ? {} : { firstTask: optional('firstTask') },
|
|
2109
|
+
...optional('note') === undefined ? {} : { note: optional('note') },
|
|
2110
|
+
// Omitted keeps the room's own mode; "none" asks for none.
|
|
2111
|
+
...optional('mode') === undefined ? { inheritPreset: true } : optional('mode') === 'none' ? {} : { preset: optional('mode') },
|
|
2112
|
+
}, 'agent', actor.childSessionId);
|
|
955
2113
|
return result.ok ? { ok: true, receipt: JSON.stringify(result.value) } : { ok: false, error: result.error.code };
|
|
956
2114
|
}
|
|
957
2115
|
catch (error) {
|
|
@@ -959,20 +2117,18 @@ let RoomService = (() => {
|
|
|
959
2117
|
}
|
|
960
2118
|
}
|
|
961
2119
|
/**
|
|
962
|
-
* The
|
|
963
|
-
* family
|
|
964
|
-
*
|
|
965
|
-
*
|
|
2120
|
+
* The bridge's `member_message` entry (contract shared with the local-agent
|
|
2121
|
+
* family: `ctx.get('room')` + duck-typed call). The call crosses an untyped
|
|
2122
|
+
* package boundary, so the message shape is validated at runtime: a
|
|
2123
|
+
* malformed message throws (the bridge treats a rejection as "room does
|
|
966
2124
|
* not claim this" and delivers directly) instead of poisoning the journal.
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
* the
|
|
974
|
-
* @param request - sender, recipient, content, the shared parent session, provenance.
|
|
975
|
-
* @returns the gate receipt.
|
|
2125
|
+
* A roster sender (resolved by child session id + provider, never
|
|
2126
|
+
* self-reported) goes through {@link messageMember}: delivered at once or
|
|
2127
|
+
* held by the room's settings. The receipt tells the model what happened
|
|
2128
|
+
* (`sent #m12 …` / `pending-confirm …`). A sender the roster does not know
|
|
2129
|
+
* keeps the legacy pending relay.
|
|
2130
|
+
* @param request - sender, recipient, content, the shared parent session, provenance, kind.
|
|
2131
|
+
* @returns the receipt quoted back to the sending model.
|
|
976
2132
|
*/
|
|
977
2133
|
async receiveMemberMessage(request) {
|
|
978
2134
|
if (typeof request?.from !== 'string' || typeof request.to !== 'string'
|
|
@@ -983,14 +2139,15 @@ let RoomService = (() => {
|
|
|
983
2139
|
if (!loaded.ok)
|
|
984
2140
|
throw new Error(`room: not a room session (${loaded.error.code})`);
|
|
985
2141
|
const sender = loaded.state.members.find(member => member.childSessionId === request.from && member.provider === request.provenance?.provider);
|
|
986
|
-
if (sender !== undefined
|
|
987
|
-
const
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
2142
|
+
if (sender !== undefined) {
|
|
2143
|
+
const kind = request.kind === 'notify' ? 'notify' : 'task';
|
|
2144
|
+
const result = await this.messageMember({ sessionId: loaded.session.id, member: request.to, text: request.content, kind,
|
|
2145
|
+
...typeof request.inReplyTo === 'string' ? { inReplyTo: request.inReplyTo } : {},
|
|
2146
|
+
...typeof request.note === 'string' ? { note: request.note } : {},
|
|
2147
|
+
...typeof request.task === 'string' ? { task: request.task } : {} }, request.from);
|
|
991
2148
|
if (!result.ok)
|
|
992
2149
|
throw new Error(result.error.code);
|
|
993
|
-
return
|
|
2150
|
+
return messageReceipt(result.value, kind);
|
|
994
2151
|
}
|
|
995
2152
|
const resolveName = (endpoint) => {
|
|
996
2153
|
const byName = loaded.state.members.find(member => member.name === endpoint);
|
|
@@ -1028,6 +2185,11 @@ let RoomService = (() => {
|
|
|
1028
2185
|
if (!loaded.state.members.some(member => member.name === relay.to)) {
|
|
1029
2186
|
return { ok: false, error: { code: 'member-not-found' } };
|
|
1030
2187
|
}
|
|
2188
|
+
if (relay.intent !== undefined) {
|
|
2189
|
+
this.release(loaded.session, relay, true);
|
|
2190
|
+
await this.ctx.sessions.flush(loaded.session);
|
|
2191
|
+
return { ok: true, value: { relayId: relay.id } };
|
|
2192
|
+
}
|
|
1031
2193
|
loaded.session.append('room/relay-resolved', { id: relay.id, state: 'confirmed' });
|
|
1032
2194
|
await this.ctx.sessions.flush(loaded.session);
|
|
1033
2195
|
this.engine.dispatch(loaded.session, relay.to, `${relay.from} 给你的通知: ${relay.content}`, {
|
|
@@ -1036,109 +2198,168 @@ let RoomService = (() => {
|
|
|
1036
2198
|
return { ok: true, value: { relayId: relay.id } };
|
|
1037
2199
|
}
|
|
1038
2200
|
/**
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
2201
|
+
* Deliver a held message as the dispatch it would have been. `force`
|
|
2202
|
+
* (the human's confirm) skips the hold rules; otherwise (resuming a paused
|
|
2203
|
+
* room) they are re-checked, so a message may be held again for another
|
|
2204
|
+
* reason — the old hold is then superseded (dismissed) by the new one.
|
|
2205
|
+
* The caller flushes.
|
|
1042
2206
|
*/
|
|
1043
|
-
|
|
2207
|
+
release(session, relay, force) {
|
|
2208
|
+
const intent = releaseIntent(relay);
|
|
2209
|
+
const target = replay(session.snapshotEvents()).members.find(member => member.name === relay.to);
|
|
2210
|
+
if (intent === undefined || target === undefined)
|
|
2211
|
+
return;
|
|
2212
|
+
const sent = sendOrHold(session, { to: target, text: relay.content, fromName: relay.from, intent }, force);
|
|
2213
|
+
session.append('room/relay-resolved', { id: relay.id, state: sent.delivered ? 'sent' : 'dismissed' });
|
|
2214
|
+
if (sent.delivered)
|
|
2215
|
+
this.engine.dispatch(session, target.name, relay.content, { dispatchSeq: sent.seq });
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Hand a member the reports the gate holds for it now (the room's
|
|
2219
|
+
* 「现在交给」 button).
|
|
2220
|
+
* @param request - room session, member name.
|
|
2221
|
+
* @returns whether anything was held.
|
|
2222
|
+
*/
|
|
2223
|
+
async releaseReports(request) {
|
|
1044
2224
|
const loaded = await this.ensureLive(request.sessionId);
|
|
1045
2225
|
if (!loaded.ok)
|
|
1046
2226
|
return { ok: false, error: loaded.error };
|
|
1047
|
-
const
|
|
1048
|
-
if (
|
|
1049
|
-
return { ok: false, error: { code: '
|
|
1050
|
-
|
|
1051
|
-
|
|
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 } };
|
|
2227
|
+
const member = loaded.state.members.find(entry => entry.name === request.name);
|
|
2228
|
+
if (member === undefined)
|
|
2229
|
+
return { ok: false, error: { code: 'member-not-found' } };
|
|
2230
|
+
const released = await this.engine.releaseHeld(loaded.session, memberId(loaded.session.snapshotEvents(), member), 'manual');
|
|
2231
|
+
return { ok: true, value: { released } };
|
|
1055
2232
|
}
|
|
1056
2233
|
/**
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
* @param request - room session, member, title, optional blockedBy.
|
|
1062
|
-
* @returns the new task's id, or a rejection.
|
|
2234
|
+
* Change the room's collaboration settings (any subset). Resuming a paused
|
|
2235
|
+
* room releases everything the pause held, in order.
|
|
2236
|
+
* @param request - room session and the fields to change.
|
|
2237
|
+
* @returns the settings after the change, or a rejection.
|
|
1063
2238
|
*/
|
|
1064
|
-
async
|
|
2239
|
+
async setSettings(request) {
|
|
1065
2240
|
const loaded = await this.ensureLive(request.sessionId);
|
|
1066
2241
|
if (!loaded.ok)
|
|
1067
2242
|
return { ok: false, error: loaded.error };
|
|
1068
|
-
if (!
|
|
1069
|
-
return { ok: false, error: { code: '
|
|
2243
|
+
if (request.hopLimit !== undefined && (!Number.isInteger(request.hopLimit) || request.hopLimit < 2 || request.hopLimit > 50)) {
|
|
2244
|
+
return { ok: false, error: { code: 'invalid-settings' } };
|
|
1070
2245
|
}
|
|
1071
|
-
if (request.
|
|
1072
|
-
return { ok: false, error: { code: '
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
return { ok: false, error: { code: '
|
|
2246
|
+
if (request.memberMessages !== undefined && request.memberMessages !== 'free' && request.memberMessages !== 'confirm') {
|
|
2247
|
+
return { ok: false, error: { code: 'invalid-settings' } };
|
|
2248
|
+
}
|
|
2249
|
+
if (request.reportTiming !== undefined && request.reportTiming !== 'judge' && request.reportTiming !== 'each') {
|
|
2250
|
+
return { ok: false, error: { code: 'invalid-settings' } };
|
|
2251
|
+
}
|
|
2252
|
+
if (request.autoTaskFiling !== undefined && typeof request.autoTaskFiling !== 'boolean')
|
|
2253
|
+
return { ok: false, error: { code: 'invalid-settings' } };
|
|
2254
|
+
if (request.reportThreshold !== undefined && (typeof request.reportThreshold !== 'number' || !(request.reportThreshold >= 0.1 && request.reportThreshold <= 0.9))) {
|
|
2255
|
+
return { ok: false, error: { code: 'invalid-settings' } };
|
|
2256
|
+
}
|
|
2257
|
+
if (request.reportWaitMinutes !== undefined && (!Number.isInteger(request.reportWaitMinutes) || request.reportWaitMinutes < 1 || request.reportWaitMinutes > 30)) {
|
|
2258
|
+
return { ok: false, error: { code: 'invalid-settings' } };
|
|
2259
|
+
}
|
|
2260
|
+
if (request.helpReminderMinutes !== undefined && (!Number.isInteger(request.helpReminderMinutes) || request.helpReminderMinutes < 10 || request.helpReminderMinutes > 120)) {
|
|
2261
|
+
return { ok: false, error: { code: 'invalid-settings' } };
|
|
2262
|
+
}
|
|
2263
|
+
const change = {
|
|
2264
|
+
...request.helpReminderMinutes === undefined ? {} : { helpReminderMinutes: request.helpReminderMinutes },
|
|
2265
|
+
...request.memberMessages === undefined ? {} : { memberMessages: request.memberMessages },
|
|
2266
|
+
...request.hopLimit === undefined ? {} : { hopLimit: request.hopLimit },
|
|
2267
|
+
...request.paused === undefined ? {} : { paused: request.paused },
|
|
2268
|
+
...request.reportTiming === undefined ? {} : { reportTiming: request.reportTiming },
|
|
2269
|
+
...request.autoTaskFiling === undefined ? {} : { autoTaskFiling: request.autoTaskFiling },
|
|
2270
|
+
...request.reportThreshold === undefined ? {} : { reportThreshold: Math.round(request.reportThreshold * 100) / 100 },
|
|
2271
|
+
...request.reportWaitMinutes === undefined ? {} : { reportWaitMinutes: request.reportWaitMinutes },
|
|
2272
|
+
};
|
|
2273
|
+
if (Object.keys(change).length === 0)
|
|
2274
|
+
return { ok: false, error: { code: 'nothing-to-update' } };
|
|
2275
|
+
const wasPaused = loaded.state.settings?.paused === true;
|
|
2276
|
+
loaded.session.append('room/settings', change);
|
|
2277
|
+
if (wasPaused && request.paused === false) {
|
|
2278
|
+
for (const relay of loaded.state.relays) {
|
|
2279
|
+
if (relay.state === 'pending' && relay.reason === 'paused')
|
|
2280
|
+
this.release(loaded.session, relay, false);
|
|
2281
|
+
}
|
|
1076
2282
|
}
|
|
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
2283
|
await this.ctx.sessions.flush(loaded.session);
|
|
1083
|
-
return { ok: true, value:
|
|
2284
|
+
return { ok: true, value: replay(loaded.session.snapshotEvents()).settings ?? DEFAULT_ROOM_SETTINGS };
|
|
1084
2285
|
}
|
|
1085
2286
|
/**
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
* @param request - room session,
|
|
1090
|
-
* @returns
|
|
2287
|
+
* Keep an outside session on the member list (「加入房间成员」): only a
|
|
2288
|
+
* shortcut for asking it — it stays in its own session, asks still go
|
|
2289
|
+
* through room_help, and its usage stays its own.
|
|
2290
|
+
* @param request - room session, the outside session and its title.
|
|
2291
|
+
* @returns ok, or a rejection.
|
|
1091
2292
|
*/
|
|
1092
|
-
async
|
|
2293
|
+
async linkSession(request) {
|
|
1093
2294
|
const loaded = await this.ensureLive(request.sessionId);
|
|
1094
2295
|
if (!loaded.ok)
|
|
1095
2296
|
return { ok: false, error: loaded.error };
|
|
1096
|
-
const
|
|
1097
|
-
if (
|
|
1098
|
-
return { ok: false, error: { code: '
|
|
1099
|
-
if (task.status === 'done' || task.status === 'cancelled') {
|
|
1100
|
-
return { ok: false, error: { code: 'task-closed' } };
|
|
2297
|
+
const title = request.title?.trim();
|
|
2298
|
+
if (request.target === request.sessionId || title === undefined || title === '' || loaded.state.members.some(member => member.childSessionId === request.target)) {
|
|
2299
|
+
return { ok: false, error: { code: 'invalid-settings' } };
|
|
1101
2300
|
}
|
|
1102
|
-
loaded.
|
|
2301
|
+
if (loaded.state.linked?.some(entry => entry.sessionId === request.target) === true)
|
|
2302
|
+
return { ok: true };
|
|
2303
|
+
loaded.session.append('room/help-link', { sessionId: request.target, title });
|
|
1103
2304
|
await this.ctx.sessions.flush(loaded.session);
|
|
1104
|
-
return { ok: true
|
|
2305
|
+
return { ok: true };
|
|
1105
2306
|
}
|
|
1106
2307
|
/**
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
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.
|
|
2308
|
+
* Take an outside session off the member list (its own session is untouched).
|
|
2309
|
+
* @param request - room session, the outside session.
|
|
2310
|
+
* @returns ok, or `member-not-found` when it was not on the list.
|
|
1114
2311
|
*/
|
|
1115
|
-
async
|
|
2312
|
+
async unlinkSession(request) {
|
|
1116
2313
|
const loaded = await this.ensureLive(request.sessionId);
|
|
1117
2314
|
if (!loaded.ok)
|
|
1118
2315
|
return { ok: false, error: loaded.error };
|
|
1119
|
-
|
|
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)) {
|
|
2316
|
+
if (loaded.state.linked?.some(entry => entry.sessionId === request.target) !== true)
|
|
1133
2317
|
return { ok: false, error: { code: 'member-not-found' } };
|
|
1134
|
-
}
|
|
1135
|
-
loaded.session
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
2318
|
+
loaded.session.append('room/help-unlink', { sessionId: request.target });
|
|
2319
|
+
await this.ctx.sessions.flush(loaded.session);
|
|
2320
|
+
return { ok: true };
|
|
2321
|
+
}
|
|
2322
|
+
/**
|
|
2323
|
+
* Dismiss the 加入房间成员 hint for one outside session (it never shows again).
|
|
2324
|
+
* @param request - room session, the outside session.
|
|
2325
|
+
* @returns ok.
|
|
2326
|
+
*/
|
|
2327
|
+
async dismissHelpNudge(request) {
|
|
2328
|
+
const loaded = await this.ensureLive(request.sessionId);
|
|
2329
|
+
if (!loaded.ok)
|
|
2330
|
+
return { ok: false, error: loaded.error };
|
|
2331
|
+
if (loaded.state.helpNudgesDismissed?.includes(request.target) === true)
|
|
2332
|
+
return { ok: true };
|
|
2333
|
+
loaded.session.append('room/help-nudge-dismissed', { sessionId: request.target });
|
|
2334
|
+
await this.ctx.sessions.flush(loaded.session);
|
|
2335
|
+
return { ok: true };
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* Dismiss a pending relay: the notification never reaches anyone.
|
|
2339
|
+
* @param request - room session, relay id.
|
|
2340
|
+
* @returns the resolution receipt, or a rejection.
|
|
2341
|
+
*/
|
|
2342
|
+
async dismissRelay(request) {
|
|
2343
|
+
const loaded = await this.ensureLive(request.sessionId);
|
|
2344
|
+
if (!loaded.ok)
|
|
2345
|
+
return { ok: false, error: loaded.error };
|
|
2346
|
+
const relay = loaded.state.relays.find(entry => entry.id === request.relayId);
|
|
2347
|
+
if (relay === undefined)
|
|
2348
|
+
return { ok: false, error: { code: 'relay-not-found' } };
|
|
2349
|
+
if (relay.state !== 'pending')
|
|
2350
|
+
return { ok: false, error: { code: 'relay-not-pending' } };
|
|
2351
|
+
loaded.session.append('room/relay-resolved', { id: relay.id, state: 'dismissed' });
|
|
1140
2352
|
await this.ctx.sessions.flush(loaded.session);
|
|
1141
|
-
|
|
2353
|
+
// A dismissed help request from another room ends that room's thread.
|
|
2354
|
+
const asked = relay.intent?.help;
|
|
2355
|
+
if (asked !== undefined) {
|
|
2356
|
+
const asking = await this.ensureLive(SessionId(asked.room));
|
|
2357
|
+
if (asking.ok && replayHelps(asking.session.snapshotEvents()).some(entry => entry.id === asked.id)) {
|
|
2358
|
+
asking.session.append('room/help-state', { help: asked.id, state: 'failed', error: '对方房间没有接这条求助' });
|
|
2359
|
+
await this.ctx.sessions.flush(asking.session);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
return { ok: true, value: { relayId: relay.id } };
|
|
1142
2363
|
}
|
|
1143
2364
|
/**
|
|
1144
2365
|
* Set (or, with a blank text, clear) the room's goal. The goal is one
|
|
@@ -1179,7 +2400,9 @@ let RoomService = (() => {
|
|
|
1179
2400
|
return { ok: true, value: { cancelled: false } };
|
|
1180
2401
|
}
|
|
1181
2402
|
const facade = probeLocalAgent(this.ctx);
|
|
1182
|
-
const native =
|
|
2403
|
+
const native = expected === undefined ? undefined
|
|
2404
|
+
: member.kind === 'main-agent' ? this.ctx.agents.get(loaded.session.id)
|
|
2405
|
+
: member.kind === 'native' && member.childSessionId !== undefined ? this.ctx.agents.get(member.childSessionId) : undefined;
|
|
1183
2406
|
if (native !== undefined)
|
|
1184
2407
|
native.cancel({ kind: 'user' });
|
|
1185
2408
|
const hit = native !== undefined || (facade !== undefined && member.childSessionId !== undefined
|
|
@@ -1193,15 +2416,8 @@ let RoomService = (() => {
|
|
|
1193
2416
|
member: request.name, state: 'cancelled', startedAt: running.startedAt,
|
|
1194
2417
|
elapsedMs: Math.max(0, Date.now() - running.startedAt),
|
|
1195
2418
|
...running.runId === undefined ? {} : { runId: running.runId },
|
|
2419
|
+
...this.terminalRunStateFields(loaded.session, loaded.state, request.name),
|
|
1196
2420
|
});
|
|
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
2421
|
await this.ctx.sessions.flush(loaded.session);
|
|
1206
2422
|
return { ok: true, value: { cancelled: true } };
|
|
1207
2423
|
}
|