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