@khorsheed/dsh-room 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +48 -0
  4. package/README.zh.md +48 -0
  5. package/cordis.patch.yml +6 -0
  6. package/lib/client.js +11481 -0
  7. package/lib/index.js +3231 -0
  8. package/lib/invariant.js +26 -0
  9. package/lib/journal-CuRnWk66.js +435 -0
  10. package/lib/tool.js +350 -0
  11. package/lib/typert.host.d.ts +3 -0
  12. package/lib/typert.host.js +2615 -0
  13. package/lib/typert.remote-client.d.ts +55 -0
  14. package/lib/typert.remote-client.js +1711 -0
  15. package/lib/types/adapter.d.ts +42 -0
  16. package/lib/types/adapter.js +41 -0
  17. package/lib/types/agent-setup.d.ts +141 -0
  18. package/lib/types/agent-setup.js +145 -0
  19. package/lib/types/client/InviteAgentAction.d.ts +13 -0
  20. package/lib/types/client/InviteAgentAction.js +45 -0
  21. package/lib/types/client/InviteDialog.d.ts +90 -0
  22. package/lib/types/client/InviteDialog.js +216 -0
  23. package/lib/types/client/MemberCard.d.ts +33 -0
  24. package/lib/types/client/MemberCard.js +32 -0
  25. package/lib/types/client/MembersView.d.ts +5 -0
  26. package/lib/types/client/MembersView.js +168 -0
  27. package/lib/types/client/RoomComposer.d.ts +21 -0
  28. package/lib/types/client/RoomComposer.js +211 -0
  29. package/lib/types/client/RoomDockCapsules.d.ts +64 -0
  30. package/lib/types/client/RoomDockCapsules.js +277 -0
  31. package/lib/types/client/RoomEventView.d.ts +11 -0
  32. package/lib/types/client/RoomEventView.js +17 -0
  33. package/lib/types/client/RoomModelPicker.d.ts +18 -0
  34. package/lib/types/client/RoomModelPicker.js +134 -0
  35. package/lib/types/client/RoomPlanView.d.ts +17 -0
  36. package/lib/types/client/RoomPlanView.js +88 -0
  37. package/lib/types/client/RoomQueueStrip.d.ts +26 -0
  38. package/lib/types/client/RoomQueueStrip.js +28 -0
  39. package/lib/types/client/RoomRecoveryView.d.ts +11 -0
  40. package/lib/types/client/RoomRecoveryView.js +38 -0
  41. package/lib/types/client/RoomRelayView.d.ts +12 -0
  42. package/lib/types/client/RoomRelayView.js +20 -0
  43. package/lib/types/client/RoomRunView.d.ts +15 -0
  44. package/lib/types/client/RoomRunView.js +50 -0
  45. package/lib/types/client/RoomSpeechView.d.ts +18 -0
  46. package/lib/types/client/RoomSpeechView.js +62 -0
  47. package/lib/types/client/RoomStatsLine.d.ts +61 -0
  48. package/lib/types/client/RoomStatsLine.js +138 -0
  49. package/lib/types/client/RoomTaskLineView.d.ts +13 -0
  50. package/lib/types/client/RoomTaskLineView.js +21 -0
  51. package/lib/types/client/RoomTodoStrip.d.ts +24 -0
  52. package/lib/types/client/RoomTodoStrip.js +64 -0
  53. package/lib/types/client/format.d.ts +31 -0
  54. package/lib/types/client/format.js +48 -0
  55. package/lib/types/client/icons.d.ts +27 -0
  56. package/lib/types/client/icons.js +11 -0
  57. package/lib/types/client/index.d.ts +39 -0
  58. package/lib/types/client/index.js +471 -0
  59. package/lib/types/client/locales.d.ts +422 -0
  60. package/lib/types/client/locales.js +413 -0
  61. package/lib/types/client/member-color.d.ts +13 -0
  62. package/lib/types/client/member-color.js +22 -0
  63. package/lib/types/client/name-pool.d.ts +17 -0
  64. package/lib/types/client/name-pool.js +25 -0
  65. package/lib/types/client/nodes.d.ts +136 -0
  66. package/lib/types/client/nodes.js +212 -0
  67. package/lib/types/client/preset-visibility.d.ts +91 -0
  68. package/lib/types/client/preset-visibility.js +113 -0
  69. package/lib/types/client/request-ids.d.ts +7 -0
  70. package/lib/types/client/request-ids.js +21 -0
  71. package/lib/types/client/room-store.d.ts +137 -0
  72. package/lib/types/client/room-store.js +244 -0
  73. package/lib/types/client/slots.d.ts +319 -0
  74. package/lib/types/client/slots.js +1 -0
  75. package/lib/types/deliveries.d.ts +5 -0
  76. package/lib/types/deliveries.js +20 -0
  77. package/lib/types/dispatch.d.ts +105 -0
  78. package/lib/types/dispatch.js +467 -0
  79. package/lib/types/index.d.ts +301 -0
  80. package/lib/types/index.js +1200 -0
  81. package/lib/types/invariant.d.ts +16 -0
  82. package/lib/types/invariant.js +24 -0
  83. package/lib/types/journal.d.ts +117 -0
  84. package/lib/types/journal.js +434 -0
  85. package/lib/types/plan-service.d.ts +34 -0
  86. package/lib/types/plan-service.js +266 -0
  87. package/lib/types/plan.d.ts +168 -0
  88. package/lib/types/plan.js +494 -0
  89. package/lib/types/tool.d.ts +56 -0
  90. package/lib/types/tool.js +389 -0
  91. package/lib/types/types.d.ts +813 -0
  92. package/lib/types/types.js +1 -0
  93. package/locale/en.json +6 -0
  94. package/locale/zh.json +6 -0
  95. package/package.json +131 -0
@@ -0,0 +1,1200 @@
1
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
2
+ var useValue = arguments.length > 2;
3
+ for (var i = 0; i < initializers.length; i++) {
4
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
5
+ }
6
+ return useValue ? value : void 0;
7
+ };
8
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
9
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
10
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
11
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
12
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
13
+ var _, done = false;
14
+ for (var i = decorators.length - 1; i >= 0; i--) {
15
+ var context = {};
16
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
17
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
18
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
19
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
20
+ if (kind === "accessor") {
21
+ if (result === void 0) continue;
22
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
23
+ if (_ = accept(result.get)) descriptor.get = _;
24
+ if (_ = accept(result.set)) descriptor.set = _;
25
+ if (_ = accept(result.init)) initializers.unshift(_);
26
+ }
27
+ else if (_ = accept(result)) {
28
+ if (kind === "field") initializers.unshift(_);
29
+ else descriptor[key] = _;
30
+ }
31
+ }
32
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
+ done = true;
34
+ };
35
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
36
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
37
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
38
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
39
+ });
40
+ }
41
+ return path;
42
+ };
43
+ /**
44
+ * room host half: the `room` Typert Remote service. (The `room_invite` /
45
+ * `room_task` / `room_message` model tools left this package's profile-root
46
+ * registration in the M4' tool-row split — their definition factories live
47
+ * in ./tool.ts and the companion `@khorsheed/dsh-room-tool` mounts them
48
+ * inside agent-preset compositions.) The room's entire state is the session's `room/*` custom-event
49
+ * journal (log-only events — persistence and reload-replay come free, the
50
+ * model never sees them, and harnesses without this plugin replay the session
51
+ * safely); every read folds the journal through the pure replay, and every
52
+ * mutating Remote appends and flushes. Dispatch records and first tasks are
53
+ * executed by the DispatchEngine: main-agent members get a producer-sourced
54
+ * followup on the room's own agent, CLI members go through the probed
55
+ * local-agent delegation facade (absent facade = degraded CLI capability,
56
+ * never a boot failure). The member-notification gate lives here too: the
57
+ * local-agent bridge duck-type-calls `receiveMemberMessage` (service face via
58
+ * ctx.provide, off the wire), the human confirms/dismisses through the
59
+ * confirmRelay/dismissRelay Remotes.
60
+ * @module @khorsheed/dsh-room
61
+ */
62
+ import { planCommandContract } from './plan.js';
63
+ import { PlanService } from './plan-service.js';
64
+ import { createHash, randomUUID } from 'node:crypto';
65
+ import { SessionId } from '@deepseek-ai/dsh-session';
66
+ // The persistence read path refuses logs carrying event types outside the
67
+ // KNOWN_SESSION_EVENT_TYPES catalog; registering the room vocabulary declares
68
+ // that a room-mounted build understands them (see ROOM_EVENT_TYPES in
69
+ // journal.ts). The registration must land in the TOOLCHAIN's module instance:
70
+ // the toolchain loads dsh-session from src (tsx path mapping) while a
71
+ // profile-installed plugin resolves the root export to lib — two instances,
72
+ // two catalog Sets, and a registration that never reaches the reader (the
73
+ // prod 3080 failure mode). Importing the source file through the package's
74
+ // `./src/*` export lands both sides on the same file URL, but no static
75
+ // specifier expresses it (the exact-file exports map needs tsx's extension
76
+ // probing; the `.ts` form trips TS2877) — so the specifier is computed and
77
+ // resolved at runtime, and the top-level await lets the plugin loader block
78
+ // boot on the registration completing. Test environments (vite) cannot
79
+ // resolve the `./src/*` export and take the root fallback — there the root
80
+ // and the toolchain already share one instance.
81
+ const catalogSpecifier = ['@deepseek-ai/dsh-session', 'src', 'known-event-types'].join('/');
82
+ const catalogModule = await import(__rewriteRelativeImportExtension(catalogSpecifier))
83
+ .catch(() => import('@deepseek-ai/dsh-session'));
84
+ for (const type of ROOM_EVENT_TYPES)
85
+ catalogModule.KNOWN_SESSION_EVENT_TYPES.add(type);
86
+ import { composeRoomAgent, deriveSessionPreset, inspectCold, preloadPresetDerivationHost, presetDerivationHost, roomSessionPreset } from './agent-setup.js';
87
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
88
+ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
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';
92
+ export { MAIN_AGENT_MEMBER, ROOM_EVENT_TYPES } from './journal.js';
93
+ /** Addressing/display names must stay parseable by the composer @-grammar. */
94
+ function validName(name) {
95
+ return name !== '' && !/\s/.test(name) && !name.includes('@');
96
+ }
97
+ /** A task title is the dispatch text's first line, truncated. */
98
+ function taskTitle(text) {
99
+ const first = text.trim().split('\n', 1)[0] ?? '';
100
+ return first.length > 60 ? `${first.slice(0, 60)}…` : first;
101
+ }
102
+ /**
103
+ * room Remote service: promotion, roster management, the human @-message
104
+ * intake, the notification gate, the task board, and run cancellation. A
105
+ * room IS a normal session; the journal is its only state. A room is never
106
+ * CREATED apart from a session — inviting an agent into any session promotes
107
+ * it (see ensureRoom; the room-session-promotion proposal).
108
+ */
109
+ let RoomService = (() => {
110
+ let _classSuper = TypertRemoteService;
111
+ let _instanceExtraInitializers = [];
112
+ let _isRoom_decorators;
113
+ let _listProviders_decorators;
114
+ let _getState_decorators;
115
+ let _invite_decorators;
116
+ let _updateMember_decorators;
117
+ let _prepareMember_decorators;
118
+ let _reconcileDelivery_decorators;
119
+ let _setCoordinator_decorators;
120
+ let _removeMember_decorators;
121
+ let _postMessage_decorators;
122
+ let _planCommand_decorators;
123
+ let _confirmRelay_decorators;
124
+ let _dismissRelay_decorators;
125
+ let _addTask_decorators;
126
+ let _closeTask_decorators;
127
+ let _setGoal_decorators;
128
+ let _cancel_decorators;
129
+ return class RoomService extends _classSuper {
130
+ static {
131
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
132
+ _isRoom_decorators = [Remote('isRoom')];
133
+ _listProviders_decorators = [Remote('listProviders')];
134
+ _getState_decorators = [Remote('getState')];
135
+ _invite_decorators = [Remote('invite')];
136
+ _updateMember_decorators = [Remote('updateMember')];
137
+ _prepareMember_decorators = [Remote('prepareMember')];
138
+ _reconcileDelivery_decorators = [Remote('reconcileDelivery')];
139
+ _setCoordinator_decorators = [Remote('setCoordinator')];
140
+ _removeMember_decorators = [Remote('removeMember')];
141
+ _postMessage_decorators = [Remote('postMessage')];
142
+ _planCommand_decorators = [Remote('planCommand')];
143
+ _confirmRelay_decorators = [Remote('confirmRelay')];
144
+ _dismissRelay_decorators = [Remote('dismissRelay')];
145
+ _addTask_decorators = [Remote('addTask')];
146
+ _closeTask_decorators = [Remote('closeTask')];
147
+ _setGoal_decorators = [Remote('setGoal')];
148
+ _cancel_decorators = [Remote('cancel')];
149
+ __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);
150
+ __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);
151
+ __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);
152
+ __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);
153
+ __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);
154
+ __esDecorate(this, null, _prepareMember_decorators, { kind: "method", name: "prepareMember", static: false, private: false, access: { has: obj => "prepareMember" in obj, get: obj => obj.prepareMember }, metadata: _metadata }, null, _instanceExtraInitializers);
155
+ __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);
156
+ __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);
157
+ __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);
158
+ __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);
159
+ __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);
160
+ __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);
161
+ __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);
162
+ __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);
163
+ __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);
164
+ __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);
165
+ __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);
166
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
167
+ }
168
+ static inject = ['sessions', 'agents'];
169
+ /** The dispatch engine executing this service's dispatch records. */
170
+ plans = __runInitializers(this, _instanceExtraInitializers);
171
+ engine;
172
+ handoffs = new Set();
173
+ /** sessionId → in-flight cold resume (mutations on a cold room dedupe). */
174
+ resumes = new Map();
175
+ /**
176
+ * @param ctx - host context carrying the session store.
177
+ */
178
+ constructor(ctx) {
179
+ super(ctx, 'room');
180
+ // Resolve the preset-registry module before any resume/create path
181
+ // derives a session preset (the 0.1.7-rc.1 host renamed the package; the
182
+ // probe falls back to the old name, then to the no-preset degrade).
183
+ ctx.effect(async () => {
184
+ await preloadPresetDerivationHost();
185
+ return () => { };
186
+ }, 'room: preset-registry probe');
187
+ ctx.effect(async () => {
188
+ const loader = ctx.get('loader');
189
+ if (loader?.import === undefined)
190
+ return () => { };
191
+ for (const specifier of ['@deepseek-ai/dsh-session', catalogSpecifier]) {
192
+ try {
193
+ const catalog = await loader.import(specifier);
194
+ for (const type of ROOM_EVENT_TYPES)
195
+ catalog.KNOWN_SESSION_EVENT_TYPES?.add(type);
196
+ }
197
+ catch { /* Optional source resolution; static catalog registration remains. */ }
198
+ }
199
+ return () => { };
200
+ }, 'room: runtime event vocabulary');
201
+ this.plans = new PlanService(ctx, () => this.engine);
202
+ this.engine = new DispatchEngine(ctx, {
203
+ allows: (room, seq) => this.plans.allows(room, seq),
204
+ admitted: (room, seq) => this.plans.admitted(room, seq),
205
+ settled: (room, seq, outcome) => this.plans.settled(room, seq, outcome),
206
+ });
207
+ ctx.effect(() => () => this.plans.dispose());
208
+ // A stale client or another official input surface must not wake a DSH
209
+ // model behind an external coordinator. Public pre-step admits an empty
210
+ // initial step without a model call after room has durably accepted it.
211
+ const nativeTurns = new WeakMap();
212
+ ctx.on('agent/pre-step', async ({ agent, messages, turn, step }, next) => {
213
+ const events = agent.session.snapshotEvents();
214
+ if (!isRoomLog(events) || coordinatorMember(replay(events), events)?.kind !== 'cli')
215
+ return next();
216
+ if (nativeTurns.get(agent) === turn)
217
+ return next();
218
+ if (step !== 1)
219
+ throw new Error('Room native turn has no explicit member dispatch');
220
+ if (messages.some(message => isRoomSource(message.source))) {
221
+ nativeTurns.set(agent, turn);
222
+ return next();
223
+ }
224
+ for (const message of messages) {
225
+ if (message.source.kind !== 'user' || message.content.some(block => block.type !== 'text')) {
226
+ throw new Error('Use the room input route for this coordinator; this input cannot be forwarded losslessly');
227
+ }
228
+ const result = await this.postMessage({ sessionId: agent.session.id, requestId: String(message.id), text: message.content.map(block => block.type === 'text' ? block.text : '').join('\n') });
229
+ if (!result.ok)
230
+ throw new Error(`Room input routing failed: ${result.error.code}`);
231
+ }
232
+ return { kind: 'enter', messages: [] };
233
+ });
234
+ // The model-facing room tools are deliberately NOT registered here: they
235
+ // moved to the companion `@khorsheed/dsh-room-tool`, which mounts the
236
+ // tool row inside agent-preset compositions (session-granted). The
237
+ // definition factories stay exported from ./tool.ts for that companion.
238
+ }
239
+ /** Resolve a LIVE session as a room: presence, marker, replayed state. */
240
+ load(sessionId) {
241
+ const session = this.ctx.sessions.get(sessionId);
242
+ if (session === undefined)
243
+ return { ok: false, error: { code: 'session-not-found' } };
244
+ if (!isRoomLog(session.snapshotEvents()))
245
+ return { ok: false, error: { code: 'not-a-room' } };
246
+ return { ok: true, session, state: replay(session.snapshotEvents()) };
247
+ }
248
+ /**
249
+ * Read a session as a room without side effects: the live store first, then
250
+ * a persistence inspection (a cold room after a host restart answers from
251
+ * its durable log; nothing is attached or resumed on a read).
252
+ */
253
+ async loadCold(sessionId) {
254
+ const live = this.ctx.sessions.get(sessionId);
255
+ if (live !== undefined) {
256
+ return isRoomLog(live.snapshotEvents())
257
+ ? { ok: true, state: replay(live.snapshotEvents()), preset: roomSessionPreset(live) }
258
+ : { ok: false, error: { code: 'not-a-room' } };
259
+ }
260
+ const inspected = await inspectCold(this.ctx, sessionId);
261
+ if (inspected === undefined)
262
+ return { ok: false, error: { code: 'session-not-found' } };
263
+ if (!isRoomLog(inspected.events))
264
+ return { ok: false, error: { code: 'not-a-room' } };
265
+ return {
266
+ ok: true,
267
+ state: replay(inspected.events),
268
+ preset: deriveSessionPreset(presetDerivationHost(), { header: inspected.meta, events: inspected.events }),
269
+ };
270
+ }
271
+ /**
272
+ * Resolve a room for a MUTATION: the session must be live to take appends,
273
+ * and dispatch needs its agent live (the delegation facade resolves the
274
+ * parent through `ctx.agents.get` — live agents only). A cold room is
275
+ * cold-resumed through the agent factory, which republishes session +
276
+ * agent under the preset the log records; resumes dedupe per session.
277
+ */
278
+ ensureLive(sessionId) {
279
+ const hit = this.load(sessionId);
280
+ if (hit.ok)
281
+ return (async () => {
282
+ await this.plans.recover(hit.session);
283
+ await this.engine.recover(hit.session);
284
+ return this.load(sessionId);
285
+ })();
286
+ if (hit.error.code !== 'session-not-found')
287
+ return Promise.resolve(hit);
288
+ let pending = this.resumes.get(sessionId);
289
+ if (pending === undefined) {
290
+ pending = this.resumeRoom(sessionId).finally(() => this.resumes.delete(sessionId));
291
+ this.resumes.set(sessionId, pending);
292
+ }
293
+ return pending;
294
+ }
295
+ /** One cold resume: refuse non-rooms before paying an agent composition. */
296
+ async resumeRoom(sessionId) {
297
+ const cold = await this.loadCold(sessionId);
298
+ if (!cold.ok)
299
+ return cold;
300
+ try {
301
+ const composition = await composeRoomAgent(this.ctx, cold.preset);
302
+ await this.ctx.agents.resume({
303
+ resumeSessionId: sessionId,
304
+ ...composition.setup === undefined ? {} : { setup: composition.setup },
305
+ });
306
+ }
307
+ catch (error) {
308
+ return { ok: false, error: { code: 'resume-failed', message: String(error) } };
309
+ }
310
+ const loaded = this.load(sessionId);
311
+ if (loaded.ok) {
312
+ await this.plans.recover(loaded.session);
313
+ await this.engine.recover(loaded.session);
314
+ }
315
+ return loaded;
316
+ }
317
+ /**
318
+ * Probe whether a session is a room: replay its event log looking for the
319
+ * `room/created` marker. Side-effect-free: a cold session answers from a
320
+ * persistence inspection, so opening any session never resumes its agent.
321
+ * @param request - session identity.
322
+ * @returns true when the session carries the marker (false for unknown sessions too).
323
+ */
324
+ async isRoom(request) {
325
+ const session = this.ctx.sessions.get(request.sessionId);
326
+ if (session !== undefined)
327
+ return isRoomLog(session.snapshotEvents());
328
+ const inspected = await inspectCold(this.ctx, request.sessionId);
329
+ return inspected !== undefined && isRoomLog(inspected.events);
330
+ }
331
+ /**
332
+ * Resolve a session for a room WRITE, promoting it when it isn't a room
333
+ * yet: a live plain session gains the `room/created` identity marker and
334
+ * the main agent's roster seat (the promotion IS the entry model — the
335
+ * room-session-promotion proposal: any session an agent is invited into
336
+ * becomes a room). Idempotent — an existing room passes through untouched,
337
+ * and concurrent promotions converge on the log (the first `room/created`
338
+ * wins identity). A cold EXISTING room resumes through the normal path;
339
+ * a cold PLAIN session cannot be promoted sight-unseen (promotion writes
340
+ * on the live session, and the surfaces that promote — the open session's
341
+ * invite dialog, the room tools running in their own session — always hold
342
+ * it live), so it answers `not-a-room`. NOT a Remote — the surfaces reach
343
+ * it through the write paths (invite/messageMember); public so tests and a
344
+ * future manual "promote" surface share the one entry.
345
+ * @param sessionId - the session to write.
346
+ * @returns the live session plus replayed state, or the rejection.
347
+ */
348
+ async ensureRoom(sessionId) {
349
+ const live = this.ctx.sessions.get(sessionId);
350
+ if (live === undefined) {
351
+ const cold = await this.loadCold(sessionId);
352
+ if (!cold.ok)
353
+ return { ok: false, error: cold.error };
354
+ return this.ensureLive(sessionId);
355
+ }
356
+ if (!isRoomLog(live.snapshotEvents())) {
357
+ live.append('room/created', { version: 1 });
358
+ live.append('room/member-added', { name: MAIN_AGENT_MEMBER, kind: 'main-agent', invitedBy: 'human' });
359
+ await this.ctx.sessions.flush(live);
360
+ }
361
+ return { ok: true, session: live, state: replay(live.snapshotEvents()) };
362
+ }
363
+ /**
364
+ * List the invitable CLI providers: the local-agent roster with each
365
+ * harness's auth state, plus the delegation-facade verdict (absent facade =
366
+ * CLI members undispatchable; the dialog greys the section instead of
367
+ * failing). Harnesses without a delegation provider are record-only and
368
+ * are not invitable.
369
+ * @param _request - no parameters.
370
+ * @returns the provider list and the facade verdict.
371
+ */
372
+ async listProviders(_request) {
373
+ const localAgentAvailable = probeLocalAgent(this.ctx) !== undefined;
374
+ const roster = probeLocalAgentRoster(this.ctx);
375
+ if (roster === undefined)
376
+ return { localAgentAvailable, providers: [] };
377
+ const providers = [];
378
+ for (const row of roster.roster()) {
379
+ const status = await roster.statusOf(row.name);
380
+ // A harness without a delegation provider cannot carry a CLI member.
381
+ if (status.delegationProvider === undefined)
382
+ continue;
383
+ providers.push({
384
+ provider: status.delegationProvider,
385
+ displayName: row.displayName,
386
+ // The roster name is the localAgentGateway harnessModel lookup key
387
+ // (the invite dialog's model picker).
388
+ harness: row.name,
389
+ authenticated: status.authenticated,
390
+ });
391
+ }
392
+ return { localAgentAvailable, providers };
393
+ }
394
+ /**
395
+ * Replay the room's journal into the derived state (roster, notification
396
+ * relays, task board, run states).
397
+ * @param request - room session identity.
398
+ * @returns the replayed state, or a rejection from the closed failure union.
399
+ */
400
+ async getState(request) {
401
+ // Side-effect-free: a cold room answers from its durable log (see loadCold).
402
+ const loaded = await this.loadCold(request.sessionId);
403
+ if (!loaded.ok)
404
+ return { ok: false, error: loaded.error };
405
+ return { ok: true, value: { ...this.engine.view(request.sessionId, loaded.state), ...loaded.state.plan === undefined ? {} : { plan: this.plans.view(request.sessionId, loaded.state.plan) } } };
406
+ }
407
+ /**
408
+ * Validate, PROMOTE (a plain session becomes a room — see ensureRoom), and
409
+ * journal an invitation: the name must be parseable by the
410
+ * composer @-grammar and unique, the provider non-blank AND a registered
411
+ * delegation provider (the classic slip is the harness name `kimi` where
412
+ * the family registered `kimi-cli` — validated against the roster's
413
+ * `delegationProvider` set, the same probe listProviders serves; a roster-
414
+ * less core skips the check, degrading to the old accept-anything), and
415
+ * (CLI members are undispatchable without it) the local-agent facade must
416
+ * be probed. A first task is journaled as a `room/dispatch` (auto-opening
417
+ * the member's in_progress task) and handed to the engine immediately —
418
+ * the receipt's `pendingFirstTask` means "dispatched". Shared by the
419
+ * Remote surface (`invitedBy: 'human'`) and the room_invite tool
420
+ * (`invitedBy: 'agent'`).
421
+ * @param request - room session, provider, name, optional cwd, instructions and first task.
422
+ * @param invitedBy - the invitation's origin.
423
+ * @returns the invitation receipt, or a rejection.
424
+ */
425
+ async inviteMember(request, invitedBy, actorChildSessionId) {
426
+ // Invite PROMOTES: inviting an agent into a plain session makes it a room.
427
+ const loaded = await this.ensureRoom(request.sessionId);
428
+ if (!loaded.ok)
429
+ return { ok: false, error: loaded.error };
430
+ if (this.handoffs.has(request.sessionId))
431
+ return { ok: false, error: { code: 'coordinator-busy' } };
432
+ if (invitedBy === 'agent' && !this.isCurrentCoordinator(loaded.session, actorChildSessionId))
433
+ return { ok: false, error: { code: 'not-coordinator' } };
434
+ if (!validName(request.name))
435
+ return { ok: false, error: { code: 'invalid-name' } };
436
+ if (loaded.state.members.some(member => member.name === request.name)) {
437
+ return { ok: false, error: { code: 'duplicate-name' } };
438
+ }
439
+ if (request.provider.trim() === '')
440
+ return { ok: false, error: { code: 'empty-provider' } };
441
+ if (request.instructions !== undefined && request.instructions.trim() === '') {
442
+ return { ok: false, error: { code: 'empty-text' } };
443
+ }
444
+ if (request.firstTask !== undefined && request.firstTask.trim() === '') {
445
+ return { ok: false, error: { code: 'empty-text' } };
446
+ }
447
+ if (request.model !== undefined && request.model.trim() === '') {
448
+ return { ok: false, error: { code: 'empty-text' } };
449
+ }
450
+ if (probeLocalAgent(this.ctx) === undefined) {
451
+ return { ok: false, error: { code: 'local-agent-unavailable' } };
452
+ }
453
+ const roster = probeLocalAgentRoster(this.ctx);
454
+ if (roster !== undefined) {
455
+ // The same delegationProvider set listProviders serves: invite accepts
456
+ // exactly what dispatch can resolve, and the rejection carries the
457
+ // legal set so a model caller can self-correct (rename and retry).
458
+ const available = [];
459
+ for (const row of roster.roster()) {
460
+ const status = await roster.statusOf(row.name);
461
+ if (status.delegationProvider !== undefined)
462
+ available.push(status.delegationProvider);
463
+ }
464
+ if (!available.includes(request.provider)) {
465
+ return { ok: false, error: { code: 'unknown-provider', provider: request.provider, available } };
466
+ }
467
+ }
468
+ if (this.handoffs.has(request.sessionId))
469
+ return { ok: false, error: { code: 'coordinator-busy' } };
470
+ if (invitedBy === 'agent' && !this.isCurrentCoordinator(loaded.session, actorChildSessionId))
471
+ return { ok: false, error: { code: 'not-coordinator' } };
472
+ if (replay(loaded.session.snapshotEvents()).members.some(member => member.name === request.name))
473
+ return { ok: false, error: { code: 'duplicate-name' } };
474
+ loaded.session.append('room/member-added', {
475
+ id: randomUUID(),
476
+ name: request.name,
477
+ kind: 'cli',
478
+ provider: request.provider,
479
+ invitedBy,
480
+ ...request.instructions === undefined ? {} : { instructions: request.instructions },
481
+ ...request.cwd === undefined || request.cwd.trim() === '' ? {} : { cwd: request.cwd.trim() },
482
+ ...request.model === undefined || request.model.trim() === '' ? {} : { model: request.model.trim() },
483
+ });
484
+ let firstTaskSeq;
485
+ if (request.firstTask !== undefined) {
486
+ firstTaskSeq = loaded.session.append('room/dispatch', {
487
+ id: randomUUID(), origin: invitedBy === 'human' ? 'human' : 'coordinator',
488
+ ...invitedBy === 'human' ? {} : { replyTo: memberId(loaded.session.snapshotEvents(), coordinatorMember(replay(loaded.session.snapshotEvents()), loaded.session.snapshotEvents())) },
489
+ targetIds: [memberId(loaded.session.snapshotEvents(), replay(loaded.session.snapshotEvents()).members.find(member => member.name === request.name))],
490
+ targets: [request.name], text: request.firstTask,
491
+ }).seq;
492
+ loaded.session.append('room/task-added', {
493
+ id: randomUUID(), member: request.name, title: taskTitle(request.firstTask), status: 'in_progress',
494
+ deliveryId: `${firstTaskSeq}:${memberId(loaded.session.snapshotEvents(), replay(loaded.session.snapshotEvents()).members.find(member => member.name === request.name))}`,
495
+ });
496
+ }
497
+ await this.ctx.sessions.flush(loaded.session);
498
+ if (request.firstTask !== undefined && firstTaskSeq !== undefined) {
499
+ this.engine.dispatch(loaded.session, request.name, request.firstTask, { dispatchSeq: firstTaskSeq });
500
+ }
501
+ return { ok: true, value: { name: request.name, pendingFirstTask: request.firstTask !== undefined } };
502
+ }
503
+ /**
504
+ * Invite a CLI member (the human path; see inviteMember).
505
+ * @param request - room session, provider, name, optional cwd, instructions and first task.
506
+ * @returns the invitation receipt, or a rejection.
507
+ */
508
+ invite(request) {
509
+ return this.inviteMember(request, 'human');
510
+ }
511
+ /**
512
+ * Update a member's editable fields: rename (validated like an invite name —
513
+ * unique, parseable; the main agent is the room itself and cannot be
514
+ * renamed), role instructions (null or a blank text CLEARS them — later
515
+ * dispatches inject none), the member-level cwd override (null or a
516
+ * blank text clears back to inheriting the room cwd; still roster-recorded
517
+ * only — the delegation facade's per-call cwd override is the family's open
518
+ * R2), and the intended model (null or a blank text clears it back to the
519
+ * harness default; roster-recorded intent — the first dispatch binds it as
520
+ * the facade start's `model` option, and the live member's immediate switch
521
+ * is the client's localAgentGateway `setMemberModel` call, not this one).
522
+ * An instructions edit rides the member's next dispatch as a context
523
+ * update (the CLI session itself is never rewritten); a rename migrates
524
+ * every name-keyed projection at replay (the journal fold moves the roster
525
+ * key, tasks' member/blockedBy, relays' from/to, and the runs key).
526
+ * @param request - room session, member name, and the fields to change.
527
+ * @returns the update receipt (the CURRENT name), or a rejection.
528
+ */
529
+ async updateMember(request) {
530
+ const loaded = await this.ensureLive(request.sessionId);
531
+ if (!loaded.ok)
532
+ return { ok: false, error: loaded.error };
533
+ if (this.handoffs.has(request.sessionId))
534
+ return { ok: false, error: { code: 'coordinator-busy' } };
535
+ const member = loaded.state.members.find(entry => entry.name === request.name);
536
+ if (member === undefined) {
537
+ return { ok: false, error: { code: 'member-not-found' } };
538
+ }
539
+ if (request.cwd !== undefined && (member.childSessionId !== undefined || this.engine.hasPending(loaded.session, member.name)))
540
+ return { ok: false, error: { code: 'member-cwd-bound' } };
541
+ if (request.model !== undefined && member.childSessionId !== undefined)
542
+ return { ok: false, error: { code: 'configuration-owned-by-core' } };
543
+ if (request.rename === undefined && request.instructions === undefined
544
+ && request.cwd === undefined && request.model === undefined) {
545
+ return { ok: false, error: { code: 'nothing-to-update' } };
546
+ }
547
+ if (request.rename !== undefined && request.rename !== request.name) {
548
+ if (member.kind === 'main-agent')
549
+ return { ok: false, error: { code: 'main-member' } };
550
+ if (!validName(request.rename))
551
+ return { ok: false, error: { code: 'invalid-name' } };
552
+ if (loaded.state.members.some(entry => entry.name === request.rename)) {
553
+ return { ok: false, error: { code: 'duplicate-name' } };
554
+ }
555
+ }
556
+ // A blank string is a clear, never a stored value (the wire keeps null
557
+ // and blank distinct, the journal stores one form: null).
558
+ const instructions = typeof request.instructions === 'string' && request.instructions.trim() === ''
559
+ ? null
560
+ : request.instructions;
561
+ const cwd = typeof request.cwd === 'string'
562
+ ? (request.cwd.trim() === '' ? null : request.cwd.trim())
563
+ : request.cwd;
564
+ const model = typeof request.model === 'string'
565
+ ? (request.model.trim() === '' ? null : request.model.trim())
566
+ : request.model;
567
+ loaded.session.append('room/member-updated', {
568
+ name: request.name,
569
+ ...request.rename === undefined ? {} : { rename: request.rename },
570
+ ...instructions === undefined ? {} : { instructions },
571
+ ...cwd === undefined ? {} : { cwd },
572
+ ...model === undefined ? {} : { model },
573
+ });
574
+ await this.ctx.sessions.flush(loaded.session);
575
+ return { ok: true, value: { name: request.name } };
576
+ }
577
+ /** Native preparation creates no unrelated conversation turn and leaves the role unchanged. */
578
+ async prepareMember(request) {
579
+ if (this.handoffs.has(request.sessionId))
580
+ return { ok: false, error: { code: 'coordinator-busy' } };
581
+ this.handoffs.add(request.sessionId);
582
+ try {
583
+ return await this.prepareMemberInRoom(request);
584
+ }
585
+ finally {
586
+ this.handoffs.delete(request.sessionId);
587
+ }
588
+ }
589
+ async prepareMemberInRoom(request) {
590
+ const loaded = await this.ensureLive(request.sessionId);
591
+ if (!loaded.ok)
592
+ return { ok: false, error: loaded.error };
593
+ const member = loaded.state.members.find(member => member.name === request.name);
594
+ if (member?.kind !== 'cli' || member.provider === undefined)
595
+ return { ok: false, error: { code: 'member-not-found' } };
596
+ const family = probeLocalAgent(this.ctx);
597
+ if (family?.prepareMember === undefined)
598
+ return { ok: false, error: { code: 'coordinator-not-ready', message: 'Native preparation is unavailable in this family core' } };
599
+ try {
600
+ const childSessionId = await family.prepareMember(request.sessionId, member.provider, member.childSessionId ?? memberId(loaded.session.snapshotEvents(), member), {
601
+ ...member.cwd === undefined ? {} : { cwd: member.cwd }, ...member.model === undefined ? {} : { model: member.model },
602
+ });
603
+ loaded.session.append('room/member-updated', { name: member.name, childSessionId: SessionId(childSessionId) });
604
+ await this.ctx.sessions.flush(loaded.session);
605
+ return { ok: true, value: { childSessionId } };
606
+ }
607
+ catch (error) {
608
+ return { ok: false, error: { code: 'coordinator-not-ready', message: String(error) } };
609
+ }
610
+ }
611
+ /** A human records the known result before releasing a crashed member's queued work. */
612
+ async reconcileDelivery(request) {
613
+ const loaded = await this.ensureLive(request.sessionId);
614
+ if (!loaded.ok)
615
+ return { ok: false, error: loaded.error };
616
+ const delivery = loaded.state.deliveries?.find(row => row.id === request.deliveryId);
617
+ if (delivery?.status !== 'uncertain')
618
+ return { ok: false, error: { code: 'delivery-not-uncertain' } };
619
+ if (delivery.plan?.taskId !== undefined)
620
+ return { ok: false, error: { code: 'plan-reconciliation-required' } };
621
+ if (request.evidence.trim() === '')
622
+ return { ok: false, error: { code: 'empty-text' } };
623
+ loaded.session.append('room/delivery-state', { id: delivery.id, dispatchSeq: delivery.dispatchSeq, memberId: delivery.memberId, state: request.outcome, text: request.evidence.trim() });
624
+ const run = loaded.state.runs.find(run => run.runId === delivery.id);
625
+ if (run !== undefined)
626
+ loaded.session.append('room/run-state', { member: run.member, runId: delivery.id, startedAt: run.startedAt, state: request.outcome });
627
+ await this.ctx.sessions.flush(loaded.session);
628
+ await this.engine.recover(loaded.session);
629
+ return { ok: true };
630
+ }
631
+ /** Explicit human role change; state is committed only after readiness and idle checks. */
632
+ async setCoordinator(request) {
633
+ if (this.handoffs.has(request.sessionId))
634
+ return { ok: false, error: { code: 'coordinator-busy' } };
635
+ this.handoffs.add(request.sessionId);
636
+ try {
637
+ const loaded = await this.ensureLive(request.sessionId);
638
+ if (!loaded.ok)
639
+ return { ok: false, error: loaded.error };
640
+ const events = loaded.session.snapshotEvents();
641
+ let candidate = loaded.state.members.find(member => memberId(events, member) === request.memberId);
642
+ const previous = coordinatorMember(loaded.state, events);
643
+ if (candidate === undefined || previous === undefined)
644
+ return { ok: false, error: { code: 'member-not-found' } };
645
+ if ((loaded.state.coordinator?.revision ?? 0) !== request.expectedRevision)
646
+ return { ok: false, error: { code: 'coordinator-conflict' } };
647
+ const agent = this.ctx.agents.get(request.sessionId);
648
+ if (agent?.status === 'running' || (agent?.inbox?.nextTurn.length ?? 0) > 0 || (agent?.inbox?.nextStep.length ?? 0) > 0 || this.engine.hasPending(loaded.session, previous.name) || this.engine.hasPending(loaded.session, candidate.name)) {
649
+ return { ok: false, error: { code: 'coordinator-busy' } };
650
+ }
651
+ if (candidate.kind === 'cli' && candidate.childSessionId === undefined) {
652
+ const prepared = await this.prepareMemberInRoom({ sessionId: request.sessionId, name: candidate.name });
653
+ if (!prepared.ok)
654
+ return { ok: false, error: prepared.error };
655
+ candidate = { ...candidate, childSessionId: SessionId(prepared.value.childSessionId) };
656
+ }
657
+ const refreshedEvents = loaded.session.snapshotEvents();
658
+ const refreshed = replay(refreshedEvents);
659
+ candidate = refreshed.members.find(member => memberId(refreshedEvents, member) === request.memberId);
660
+ if (candidate === undefined)
661
+ return { ok: false, error: { code: 'member-not-found' } };
662
+ if ((refreshed.coordinator?.revision ?? 0) !== request.expectedRevision)
663
+ return { ok: false, error: { code: 'coordinator-conflict' } };
664
+ const refreshedAgent = this.ctx.agents.get(request.sessionId);
665
+ if (refreshedAgent?.status === 'running' || (refreshedAgent?.inbox?.nextTurn.length ?? 0) > 0 || (refreshedAgent?.inbox?.nextStep.length ?? 0) > 0 || this.engine.hasPending(loaded.session, previous.name) || this.engine.hasPending(loaded.session, candidate.name))
666
+ return { ok: false, error: { code: 'coordinator-busy' } };
667
+ if (candidate.kind === 'cli') {
668
+ const family = this.ctx.get('localAgent');
669
+ if (candidate.childSessionId === undefined || family?.canCoordinateRoom?.(candidate.childSessionId) !== true)
670
+ return { ok: false, error: { code: 'coordinator-not-ready', message: 'The member coordination tool channel is unavailable' } };
671
+ }
672
+ for (const participant of [previous, candidate]) {
673
+ if (participant.kind !== 'cli')
674
+ continue;
675
+ const family = this.ctx.get('localAgent');
676
+ if (participant.childSessionId === undefined || family?.memberConfiguration === undefined) {
677
+ return { ok: false, error: { code: 'coordinator-not-ready', message: 'Member native session preparation is required before promotion' } };
678
+ }
679
+ const inbox = family.isPreparedMember?.(participant.childSessionId) === true ? undefined : family.readMemberInbox?.(participant.childSessionId);
680
+ if ((family.queuedMemberRounds?.(participant.childSessionId) ?? 0) > 0 || inbox?.messages.some(row => ['queued', 'running', 'uncertain'].includes(row.status)))
681
+ return { ok: false, error: { code: 'coordinator-busy' } };
682
+ const control = family.memberConfiguration(participant.childSessionId);
683
+ if (control.status !== 'idle' || control.round !== undefined || control.pending !== undefined || control.lockedReason !== undefined) {
684
+ return { ok: false, error: { code: 'coordinator-not-ready', message: 'Member configuration has not converged or is locked' } };
685
+ }
686
+ }
687
+ const handoff = [
688
+ `Coordinator handoff from ${previous.name} to ${candidate.name}.`,
689
+ `Room session: ${request.sessionId}. Earlier native conversations remain available by their session IDs.`,
690
+ loaded.state.goal === undefined ? '' : `Goal: ${loaded.state.goal}`,
691
+ 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.`,
692
+ ...loaded.state.tasks.filter(task => !['done', 'cancelled'].includes(task.status)).map(task => `Open task ${task.id}: ${task.member}: ${task.title} (${task.status})`),
693
+ ...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)}`
694
+ : event.type === 'room/dispatch' ? `To ${event.data.targets.join(', ')}: ${event.data.text.slice(0, 1500)}` : ''),
695
+ ].filter(Boolean).join('\n').slice(0, 16000);
696
+ const value = { version: 1, memberId: request.memberId, previousMemberId: memberId(events, previous), revision: request.expectedRevision + 1, handoff };
697
+ loaded.session.append('room/coordinator', value);
698
+ await this.ctx.sessions.flush(loaded.session);
699
+ return { ok: true, value };
700
+ }
701
+ catch (error) {
702
+ return { ok: false, error: { code: 'coordinator-not-ready', message: String(error) } };
703
+ }
704
+ finally {
705
+ this.handoffs.delete(request.sessionId);
706
+ }
707
+ }
708
+ /**
709
+ * Remove a member from the roster. An in-flight run is NOT interrupted
710
+ * here — cancel() first when the member is running.
711
+ * @param request - room session, member name.
712
+ * @returns the removal receipt, or a rejection.
713
+ */
714
+ async removeMember(request) {
715
+ const loaded = await this.ensureLive(request.sessionId);
716
+ if (!loaded.ok)
717
+ return { ok: false, error: loaded.error };
718
+ if (this.handoffs.has(request.sessionId))
719
+ return { ok: false, error: { code: 'coordinator-busy' } };
720
+ if (!loaded.state.members.some(member => member.name === request.name)) {
721
+ return { ok: false, error: { code: 'member-not-found' } };
722
+ }
723
+ if (coordinatorMember(loaded.state, loaded.session.snapshotEvents())?.name === request.name)
724
+ return { ok: false, error: { code: 'active-coordinator' } };
725
+ if (loaded.state.members.find(member => member.name === request.name)?.kind === 'main-agent')
726
+ return { ok: false, error: { code: 'main-member' } };
727
+ loaded.session.append('room/member-removed', { name: request.name });
728
+ await this.ctx.sessions.flush(loaded.session);
729
+ return { ok: true, value: { name: request.name } };
730
+ }
731
+ /**
732
+ * Post a human @-message into the room: leading `@name` tokens address
733
+ * members, and so does the request's `targets` list (the composer's
734
+ * mention-menu picks — explicit addressing wherever the `@name` sits); the
735
+ * two union before the roster check (a `room/dispatch` journal record,
736
+ * executed by the engine, and one auto-opened in_progress task per target).
737
+ * The human's raw text is
738
+ * FIRST appended as a standard `user/message` (source kind 'user' — the
739
+ * human typed it; the official messageDefinition classifies any other kind
740
+ * as a collapsed context-injection row, not the user bubble), so the words
741
+ * render as the official user bubble and enter the main agent's
742
+ * model-visible history. The append wakes nothing: turns start from
743
+ * prompt/followup calls, never from log appends (the agent is the log's
744
+ * writer, not a watcher). The `room/dispatch` record stays as pure
745
+ * bookkeeping (task board, dispatch cursors, replay) — it no longer drives
746
+ * any chat node. A BARE message is a structured `no-targets` rejection —
747
+ * defense only: the room composer releases bare messages to the official
748
+ * submit path (a normal main-agent turn) and never calls this Remote
749
+ * without addressing.
750
+ * @param request - room session, raw composer text, and the menu-picked addressees.
751
+ * @returns the parse receipt, or a rejection.
752
+ */
753
+ async postMessage(request) {
754
+ const loaded = await this.ensureLive(request.sessionId);
755
+ if (!loaded.ok)
756
+ return { ok: false, error: loaded.error };
757
+ if (request.text.trim() === '')
758
+ return { ok: false, error: { code: 'empty-text' } };
759
+ const requestSignature = createHash('sha256').update(JSON.stringify({ text: request.text.trim(), targets: [...new Set(request.targets ?? [])].sort() })).digest('hex');
760
+ if (request.requestId !== undefined) {
761
+ if (request.requestId.trim() === '' || request.requestId.length > 128)
762
+ return { ok: false, error: { code: 'request-conflict' } };
763
+ const previous = loaded.session.snapshotEvents().find(event => event.type === 'room/dispatch' && event.data.id === request.requestId);
764
+ if (previous?.type === 'room/dispatch') {
765
+ const original = previous.data;
766
+ const parsedRetry = parseMentions(request.text);
767
+ const retryTargets = [...new Set([...parsedRetry.targets, ...request.targets ?? []])].sort();
768
+ const legacyMatch = original.origin === 'human' && original.text === (parsedRetry.targets.length > 0 ? parsedRetry.text : request.text.trim())
769
+ && (retryTargets.length === 0 || JSON.stringify(retryTargets) === JSON.stringify([...original.targets].sort()));
770
+ if (original.requestSignature === undefined ? !legacyMatch : original.requestSignature !== requestSignature)
771
+ return { ok: false, error: { code: 'request-conflict' } };
772
+ // An earlier flush may have failed after append; acceptance retries storage before acknowledging.
773
+ await this.ctx.sessions.flush(loaded.session);
774
+ for (const target of original.targetIds ?? []) {
775
+ const member = replay(loaded.session.snapshotEvents()).members.find(member => memberId(loaded.session.snapshotEvents(), member) === target);
776
+ if (member !== undefined)
777
+ this.engine.dispatch(loaded.session, member.name, original.text, { dispatchSeq: previous.seq, targetId: target });
778
+ }
779
+ return { ok: true, value: { parsed: { targets: original.targets, text: original.text }, seq: previous.seq } };
780
+ }
781
+ }
782
+ const parsed = parseMentions(request.text);
783
+ // Menu-picked addressees union with the parsed leading tokens: a menu
784
+ // pick is explicit addressing wherever the `@name` sits in the sentence.
785
+ const targets = [...new Set([...parsed.targets, ...request.targets ?? []])];
786
+ if (this.handoffs.has(request.sessionId))
787
+ return { ok: false, error: { code: 'coordinator-busy' } };
788
+ if (targets.length === 0) {
789
+ const selected = coordinatorMember(loaded.state, loaded.session.snapshotEvents());
790
+ if (selected === undefined)
791
+ return { ok: false, error: { code: 'member-not-found' } };
792
+ targets.push(selected.name);
793
+ }
794
+ // Leading tokens are stripped from the dispatched text; a picked mid-
795
+ // sentence mention stays — the sentence is dispatched verbatim.
796
+ const text = parsed.targets.length > 0 ? parsed.text : request.text.trim();
797
+ if (text === '')
798
+ return { ok: false, error: { code: 'empty-text' } };
799
+ const roster = new Set(loaded.state.members.map(member => member.name));
800
+ const unknown = targets.filter(target => !roster.has(target));
801
+ if (unknown.length > 0)
802
+ return { ok: false, error: { code: 'unknown-targets', names: unknown } };
803
+ loaded.session.append('user/message', createUserMessage({
804
+ content: [{ type: 'text', text: request.text.trim() }],
805
+ source: { kind: 'user' },
806
+ }), { surfaceOp: 'append' });
807
+ const dispatch = loaded.session.append('room/dispatch', {
808
+ id: request.requestId ?? randomUUID(), requestSignature, targets, text, origin: 'human',
809
+ targetIds: targets.map(name => memberId(loaded.session.snapshotEvents(), loaded.state.members.find(member => member.name === name))),
810
+ });
811
+ for (const target of targets) {
812
+ loaded.session.append('room/task-added', {
813
+ id: randomUUID(), member: target, title: taskTitle(text), status: 'in_progress',
814
+ deliveryId: `${dispatch.seq}:${memberId(loaded.session.snapshotEvents(), loaded.state.members.find(member => member.name === target))}`,
815
+ });
816
+ }
817
+ await this.ctx.sessions.flush(loaded.session);
818
+ for (const target of targets) {
819
+ this.engine.dispatch(loaded.session, target, text, { dispatchSeq: dispatch.seq });
820
+ }
821
+ return { ok: true, value: { parsed: { targets, text }, seq: dispatch.seq } };
822
+ }
823
+ /**
824
+ * Dispatch one message to one member (host-only — the `room_message` tool's
825
+ * path; the model-facing equivalent of the human's `@member text`). Same
826
+ * dispatch internals as postMessage minus the user/message bubble: the
827
+ * caller is the room's own main agent, so the text must NOT wear the human
828
+ * bubble (source kind 'user' would misattribute it). The dispatch record
829
+ * and the auto-opened in_progress task journal as usual.
830
+ * @param request - room session, addressee, text.
831
+ * @returns the dispatch receipt, or a rejection.
832
+ */
833
+ async messageMember(request, actorChildSessionId) {
834
+ // Messaging PROMOTES too (the room_message tool's gate): the main agent
835
+ // answering "把 kimi 拉进来问一下…" promotes its own session, then the
836
+ // roster check runs against the promoted state.
837
+ const loaded = await this.ensureRoom(request.sessionId);
838
+ if (!loaded.ok)
839
+ return { ok: false, error: loaded.error };
840
+ if (!this.isCurrentCoordinator(loaded.session, actorChildSessionId))
841
+ return { ok: false, error: { code: 'not-coordinator' } };
842
+ if (this.handoffs.has(request.sessionId))
843
+ return { ok: false, error: { code: 'coordinator-busy' } };
844
+ if (!loaded.state.members.some(member => member.name === request.member)) {
845
+ return { ok: false, error: { code: 'member-not-found' } };
846
+ }
847
+ const text = request.text.trim();
848
+ if (text === '')
849
+ return { ok: false, error: { code: 'empty-text' } };
850
+ const coordinator = coordinatorMember(loaded.state, loaded.session.snapshotEvents());
851
+ const target = loaded.state.members.find(member => member.name === request.member);
852
+ const dispatch = loaded.session.append('room/dispatch', {
853
+ id: randomUUID(), targets: [request.member], targetIds: [memberId(loaded.session.snapshotEvents(), target)], text, origin: 'coordinator',
854
+ ...coordinator === undefined || coordinator.name === request.member ? {} : { replyTo: memberId(loaded.session.snapshotEvents(), coordinator) },
855
+ });
856
+ loaded.session.append('room/task-added', {
857
+ id: randomUUID(), member: request.member, title: taskTitle(text), status: 'in_progress',
858
+ deliveryId: `${dispatch.seq}:${memberId(loaded.session.snapshotEvents(), target)}`,
859
+ });
860
+ await this.ctx.sessions.flush(loaded.session);
861
+ this.engine.dispatch(loaded.session, request.member, text, { dispatchSeq: dispatch.seq });
862
+ return { ok: true, value: { member: request.member } };
863
+ }
864
+ /** Host-authenticated actor; model tool arguments never provide the room or sender. */
865
+ isCurrentCoordinator(session, actorChildSessionId) {
866
+ const events = session.snapshotEvents();
867
+ const coordinator = coordinatorMember(replay(events), events);
868
+ return actorChildSessionId === undefined ? coordinator?.kind === 'main-agent' : coordinator?.childSessionId === actorChildSessionId;
869
+ }
870
+ /** Bounded shared room context for native and external model tools; reading never wakes an agent. */
871
+ async readRoomContext(sessionId) {
872
+ const state = await this.getState({ sessionId: SessionId(sessionId) });
873
+ if (!state.ok)
874
+ throw new Error(`Room unavailable: ${state.error.code}`);
875
+ const events = this.ctx.sessions.get(SessionId(sessionId))?.snapshotEvents() ?? [];
876
+ 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);
877
+ return JSON.stringify({ planCommands: planCommandContract(), coordinator: coordinatorMember(state.value)?.name,
878
+ state: { ...state.value, deliveries: state.value.deliveries?.slice(-30), tasks: state.value.tasks.slice(-100), relays: state.value.relays.slice(-30) },
879
+ providers: await this.listProviders({}), recent });
880
+ }
881
+ /** Human UI and native/external tools share the same durable transition service. */
882
+ async planCommand(request) {
883
+ try {
884
+ const loaded = await this.ensureLive(request.sessionId);
885
+ if (!loaded.ok)
886
+ return { ok: false, message: loaded.error.code };
887
+ if (this.handoffs.has(request.sessionId))
888
+ return { ok: false, message: 'Coordinator handoff is in progress' };
889
+ await this.plans.command(loaded.session, request.command, { kind: 'human', memberId: 'human' });
890
+ return { ok: true };
891
+ }
892
+ catch (error) {
893
+ return { ok: false, message: String(error) };
894
+ }
895
+ }
896
+ /** The actor is derived from the calling native agent or authenticated bridge. */
897
+ async commandPlan(sessionId, command, actorChildSessionId) {
898
+ const loaded = await this.ensureLive(SessionId(sessionId));
899
+ if (!loaded.ok)
900
+ throw new Error(loaded.error.code);
901
+ if (this.handoffs.has(sessionId))
902
+ throw new Error('Coordinator handoff is in progress');
903
+ const events = loaded.session.snapshotEvents();
904
+ const state = replay(events);
905
+ const member = state.members.find(member => actorChildSessionId === undefined ? member.kind === 'main-agent' : member.childSessionId === actorChildSessionId);
906
+ if (member === undefined)
907
+ throw new Error('Calling member is not in this room');
908
+ const id = memberId(events, member);
909
+ const plan = await this.plans.command(loaded.session, command, { kind: coordinatorMember(state, events)?.name === member.name ? 'coordinator' : 'worker', memberId: id });
910
+ const { requests: _requests, ...view } = plan;
911
+ return JSON.stringify(view);
912
+ }
913
+ /** Shared backend for external MCP room tools. Not exposed as a browser Remote. */
914
+ async receiveMemberCommand(actor, command) {
915
+ try {
916
+ const loaded = await this.ensureLive(SessionId(actor.parentSessionId));
917
+ if (!loaded.ok)
918
+ return { ok: false, error: `Room unavailable: ${loaded.error.code}` };
919
+ const sender = loaded.state.members.find(member => member.childSessionId === actor.childSessionId && member.provider === actor.provider);
920
+ if (sender === undefined)
921
+ return { ok: false, error: 'The authenticated member is not in this room roster' };
922
+ if (command === null || typeof command !== 'object' || command.arguments === null || typeof command.arguments !== 'object' || Array.isArray(command.arguments))
923
+ return { ok: false, error: 'Malformed room command' };
924
+ const args = command.arguments;
925
+ const allowed = { room_plan: ['command'], room_read: [], room_invite: ['provider', 'name', 'instructions', 'cwd', 'model', 'firstTask'], room_message: ['member', 'text'] };
926
+ const keys = allowed[command.name];
927
+ if (keys === undefined || Object.keys(args).some(key => !keys.includes(key)))
928
+ return { ok: false, error: 'Unknown room command or argument; room and actor identity are host-owned' };
929
+ const required = (key) => {
930
+ const value = args[key];
931
+ if (typeof value !== 'string' || value.trim() === '')
932
+ throw new Error(`A non-empty ${key} is required`);
933
+ return value;
934
+ };
935
+ const optional = (key) => args[key] === undefined ? undefined : required(key);
936
+ if (command.name === 'room_read') {
937
+ return { ok: true, receipt: JSON.stringify({ self: sender.name, ...JSON.parse(await this.readRoomContext(loaded.session.id)) }) };
938
+ }
939
+ if (command.name === 'room_plan')
940
+ return { ok: true, receipt: await this.commandPlan(loaded.session.id, required('command'), actor.childSessionId) };
941
+ if (!this.isCurrentCoordinator(loaded.session, actor.childSessionId))
942
+ return { ok: false, error: 'Only the current coordinator may invite members or dispatch work' };
943
+ const result = command.name === 'room_invite'
944
+ ? await this.inviteMember({ sessionId: loaded.session.id, provider: required('provider'), name: required('name'),
945
+ ...optional('instructions') === undefined ? {} : { instructions: optional('instructions') },
946
+ ...optional('cwd') === undefined ? {} : { cwd: optional('cwd') },
947
+ ...optional('model') === undefined ? {} : { model: optional('model') },
948
+ ...optional('firstTask') === undefined ? {} : { firstTask: optional('firstTask') },
949
+ }, 'agent', actor.childSessionId)
950
+ : await this.messageMember({ sessionId: loaded.session.id, member: required('member'), text: required('text') }, actor.childSessionId);
951
+ return result.ok ? { ok: true, receipt: JSON.stringify(result.value) } : { ok: false, error: result.error.code };
952
+ }
953
+ catch (error) {
954
+ return { ok: false, error: String(error) };
955
+ }
956
+ }
957
+ /**
958
+ * The notification gate entry, verbatim-frozen contract for the local-agent
959
+ * family bridge (`ctx.get('room')` + duck-typed call). The call crosses an
960
+ * untyped package boundary, so the message shape is validated at runtime:
961
+ * a malformed message throws (the bridge treats a rejection as "room does
962
+ * not claim this" and delivers directly) instead of poisoning the journal.
963
+ * `from`/`to` arrive as roster names OR member child session ids (the
964
+ * bridge cannot speak names for the sender — only room owns the roster);
965
+ * both are normalized to roster names when resolvable, so the pending card
966
+ * and the delivery text (`{from} 给你的通知: …`) read as names. Phase 1's
967
+ * gate is ALWAYS human confirmation: the relay is journaled pending and
968
+ * the sender receives 'pending-confirm' (never 'sent' — the bridge keeps
969
+ * the member's conclusion honest). A non-room parent session throws.
970
+ * @param request - sender, recipient, content, the shared parent session, provenance.
971
+ * @returns the gate receipt.
972
+ */
973
+ async receiveMemberMessage(request) {
974
+ if (typeof request?.from !== 'string' || typeof request.to !== 'string'
975
+ || typeof request.content !== 'string' || typeof request.parentSessionId !== 'string') {
976
+ throw new Error('room: malformed member message (from/to/content/parentSessionId must be strings)');
977
+ }
978
+ const loaded = await this.ensureLive(request.parentSessionId);
979
+ if (!loaded.ok)
980
+ throw new Error(`room: not a room session (${loaded.error.code})`);
981
+ const sender = loaded.state.members.find(member => member.childSessionId === request.from && member.provider === request.provenance?.provider);
982
+ if (sender !== undefined && this.isCurrentCoordinator(loaded.session, request.from)) {
983
+ const target = loaded.state.members.find(member => member.name === request.to || member.childSessionId === request.to);
984
+ if (target === undefined)
985
+ throw new Error('Unknown room member');
986
+ const result = await this.messageMember({ sessionId: loaded.session.id, member: target.name, text: request.content }, request.from);
987
+ if (!result.ok)
988
+ throw new Error(result.error.code);
989
+ return 'sent';
990
+ }
991
+ const resolveName = (endpoint) => {
992
+ const byName = loaded.state.members.find(member => member.name === endpoint);
993
+ if (byName !== undefined)
994
+ return byName.name;
995
+ return loaded.state.members.find(member => member.childSessionId === endpoint)?.name ?? endpoint;
996
+ };
997
+ loaded.session.append('room/relay', {
998
+ id: randomUUID(),
999
+ from: resolveName(request.from),
1000
+ to: resolveName(request.to),
1001
+ content: request.content,
1002
+ ...request.provenance === undefined ? {} : { provenance: request.provenance },
1003
+ });
1004
+ await this.ctx.sessions.flush(loaded.session);
1005
+ return 'pending-confirm';
1006
+ }
1007
+ /**
1008
+ * Confirm a pending relay: journal the confirmed edge and dispatch the
1009
+ * notification to the recipient as a continuation (`{from} 给你的通知:
1010
+ * {content}`); the engine marks the relay sent once the recipient's own
1011
+ * session receives the prompt.
1012
+ * @param request - room session, relay id.
1013
+ * @returns the resolution receipt, or a rejection.
1014
+ */
1015
+ async confirmRelay(request) {
1016
+ const loaded = await this.ensureLive(request.sessionId);
1017
+ if (!loaded.ok)
1018
+ return { ok: false, error: loaded.error };
1019
+ const relay = loaded.state.relays.find(entry => entry.id === request.relayId);
1020
+ if (relay === undefined)
1021
+ return { ok: false, error: { code: 'relay-not-found' } };
1022
+ if (relay.state !== 'pending')
1023
+ return { ok: false, error: { code: 'relay-not-pending' } };
1024
+ if (!loaded.state.members.some(member => member.name === relay.to)) {
1025
+ return { ok: false, error: { code: 'member-not-found' } };
1026
+ }
1027
+ loaded.session.append('room/relay-resolved', { id: relay.id, state: 'confirmed' });
1028
+ await this.ctx.sessions.flush(loaded.session);
1029
+ this.engine.dispatch(loaded.session, relay.to, `${relay.from} 给你的通知: ${relay.content}`, {
1030
+ relayIds: [relay.id],
1031
+ });
1032
+ return { ok: true, value: { relayId: relay.id } };
1033
+ }
1034
+ /**
1035
+ * Dismiss a pending relay: the notification never reaches anyone.
1036
+ * @param request - room session, relay id.
1037
+ * @returns the resolution receipt, or a rejection.
1038
+ */
1039
+ async dismissRelay(request) {
1040
+ const loaded = await this.ensureLive(request.sessionId);
1041
+ if (!loaded.ok)
1042
+ return { ok: false, error: loaded.error };
1043
+ const relay = loaded.state.relays.find(entry => entry.id === request.relayId);
1044
+ if (relay === undefined)
1045
+ return { ok: false, error: { code: 'relay-not-found' } };
1046
+ if (relay.state !== 'pending')
1047
+ return { ok: false, error: { code: 'relay-not-pending' } };
1048
+ loaded.session.append('room/relay-resolved', { id: relay.id, state: 'dismissed' });
1049
+ await this.ctx.sessions.flush(loaded.session);
1050
+ return { ok: true, value: { relayId: relay.id } };
1051
+ }
1052
+ /**
1053
+ * Add a task to a member's board lane (the human's management entry; @-
1054
+ * dispatches auto-open in_progress tasks, this opens a pending one).
1055
+ * `blockedBy` names the member the task waits on (display only — it never
1056
+ * dispatches anything); it must resolve against the roster.
1057
+ * @param request - room session, member, title, optional blockedBy.
1058
+ * @returns the new task's id, or a rejection.
1059
+ */
1060
+ async addTask(request) {
1061
+ const loaded = await this.ensureLive(request.sessionId);
1062
+ if (!loaded.ok)
1063
+ return { ok: false, error: loaded.error };
1064
+ if (!loaded.state.members.some(member => member.name === request.member)) {
1065
+ return { ok: false, error: { code: 'member-not-found' } };
1066
+ }
1067
+ if (request.title.trim() === '')
1068
+ return { ok: false, error: { code: 'empty-text' } };
1069
+ if (request.blockedBy !== undefined
1070
+ && !loaded.state.members.some(member => member.name === request.blockedBy)) {
1071
+ return { ok: false, error: { code: 'member-not-found' } };
1072
+ }
1073
+ const id = randomUUID();
1074
+ loaded.session.append('room/task-added', {
1075
+ id, member: request.member, title: request.title.trim(), status: 'pending',
1076
+ ...request.blockedBy === undefined ? {} : { blockedBy: request.blockedBy },
1077
+ });
1078
+ await this.ctx.sessions.flush(loaded.session);
1079
+ return { ok: true, value: { id } };
1080
+ }
1081
+ /**
1082
+ * Close an open task (done by default, or cancelled). A failed task is
1083
+ * still open for closing — that is the human's dismiss path after a
1084
+ * failed run (the board's [关闭] passes 'cancelled').
1085
+ * @param request - room session, task id, optional closing status.
1086
+ * @returns the closed task's id, or a rejection.
1087
+ */
1088
+ async closeTask(request) {
1089
+ const loaded = await this.ensureLive(request.sessionId);
1090
+ if (!loaded.ok)
1091
+ return { ok: false, error: loaded.error };
1092
+ const task = loaded.state.tasks.find(entry => entry.id === request.taskId);
1093
+ if (task === undefined)
1094
+ return { ok: false, error: { code: 'task-not-found' } };
1095
+ if (task.status === 'done' || task.status === 'cancelled') {
1096
+ return { ok: false, error: { code: 'task-closed' } };
1097
+ }
1098
+ loaded.session.append('room/task-updated', { id: task.id, status: request.status ?? 'done' });
1099
+ await this.ctx.sessions.flush(loaded.session);
1100
+ return { ok: true, value: { id: task.id } };
1101
+ }
1102
+ /**
1103
+ * Edit an open task's title and/or blockedBy (the `room_task` tool's update
1104
+ * action; journaled as `room/task-edited`, status changes stay on
1105
+ * closeTask). Host method only — the capsule UI edits nothing but the goal.
1106
+ * `blockedBy: null` clears the wait; a string must resolve against the
1107
+ * roster. A closed task takes no edits.
1108
+ * @param request - room session, task id, and at least one edited field.
1109
+ * @returns the edited task's id, or a rejection.
1110
+ */
1111
+ async updateTask(request) {
1112
+ const loaded = await this.ensureLive(request.sessionId);
1113
+ if (!loaded.ok)
1114
+ return { ok: false, error: loaded.error };
1115
+ const task = loaded.state.tasks.find(entry => entry.id === request.taskId);
1116
+ if (task === undefined)
1117
+ return { ok: false, error: { code: 'task-not-found' } };
1118
+ if (task.status === 'done' || task.status === 'cancelled') {
1119
+ return { ok: false, error: { code: 'task-closed' } };
1120
+ }
1121
+ if (request.title === undefined && request.blockedBy === undefined) {
1122
+ return { ok: false, error: { code: 'nothing-to-update' } };
1123
+ }
1124
+ if (request.title !== undefined && request.title.trim() === '') {
1125
+ return { ok: false, error: { code: 'empty-text' } };
1126
+ }
1127
+ if (typeof request.blockedBy === 'string'
1128
+ && !loaded.state.members.some(member => member.name === request.blockedBy)) {
1129
+ return { ok: false, error: { code: 'member-not-found' } };
1130
+ }
1131
+ loaded.session.append('room/task-edited', {
1132
+ id: task.id,
1133
+ ...request.title === undefined ? {} : { title: request.title.trim() },
1134
+ ...request.blockedBy === undefined ? {} : { blockedBy: request.blockedBy },
1135
+ });
1136
+ await this.ctx.sessions.flush(loaded.session);
1137
+ return { ok: true, value: { id: task.id } };
1138
+ }
1139
+ /**
1140
+ * Set (or, with a blank text, clear) the room's goal. The goal is one
1141
+ * journaled line — the latest `room/goal` event wins; it rides the top of
1142
+ * the roster section in every member prompt and drives the dock's goal
1143
+ * capsule. Pure display/coordination text: it never dispatches anything.
1144
+ * @param request - room session and goal text.
1145
+ * @returns the now-current goal (absent after a clear), or a rejection.
1146
+ */
1147
+ async setGoal(request) {
1148
+ const loaded = await this.ensureLive(request.sessionId);
1149
+ if (!loaded.ok)
1150
+ return { ok: false, error: loaded.error };
1151
+ const text = request.text.trim();
1152
+ loaded.session.append('room/goal', { text });
1153
+ await this.ctx.sessions.flush(loaded.session);
1154
+ return { ok: true, value: text === '' ? {} : { goal: text } };
1155
+ }
1156
+ /**
1157
+ * Cancel a member's in-flight run through the local-agent facade. A hit
1158
+ * journals the terminal `cancelled` edge immediately (the engine's own
1159
+ * settle for that run then no-ops); a miss — no facade, no delegation
1160
+ * handle, or nothing in flight — journals nothing and reports
1161
+ * `cancelled: false`.
1162
+ * @param request - room session, member name.
1163
+ * @returns the cancellation receipt, or a rejection.
1164
+ */
1165
+ async cancel(request) {
1166
+ const loaded = await this.ensureLive(request.sessionId);
1167
+ if (!loaded.ok)
1168
+ return { ok: false, error: loaded.error };
1169
+ const member = loaded.state.members.find(entry => entry.name === request.name);
1170
+ if (member === undefined)
1171
+ return { ok: false, error: { code: 'member-not-found' } };
1172
+ const facade = probeLocalAgent(this.ctx);
1173
+ const hit = facade !== undefined && member.childSessionId !== undefined
1174
+ ? facade.cancel(member.childSessionId)
1175
+ : false;
1176
+ if (hit) {
1177
+ const running = loaded.state.runs.find(entry => entry.member === request.name && entry.state === 'running');
1178
+ if (running === undefined)
1179
+ return { ok: true, value: { cancelled: true } };
1180
+ loaded.session.append('room/run-state', {
1181
+ member: request.name, state: 'cancelled', startedAt: running.startedAt,
1182
+ ...running.runId === undefined ? {} : { runId: running.runId },
1183
+ });
1184
+ // The engine's settle no-ops behind this edge, so the task closing the
1185
+ // settle would have done happens here: the dispatch-opened in_progress
1186
+ // task cancels with its run.
1187
+ for (const task of loaded.state.tasks) {
1188
+ if (task.member === request.name && task.status === 'in_progress' && (running.runId === undefined || task.deliveryId === running.runId)) {
1189
+ loaded.session.append('room/task-updated', { id: task.id, status: 'cancelled' });
1190
+ }
1191
+ }
1192
+ await this.ctx.sessions.flush(loaded.session);
1193
+ return { ok: true, value: { cancelled: true } };
1194
+ }
1195
+ return { ok: true, value: { cancelled: false } };
1196
+ }
1197
+ };
1198
+ })();
1199
+ export { RoomService };
1200
+ export default RoomService;