@khorsheed/dsh-room 0.1.0 → 0.2.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 (40) hide show
  1. package/CHANGELOG.md +18 -2
  2. package/README.en.md +84 -0
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +62 -26
  5. package/lib/client.js +952 -285
  6. package/lib/index.js +88 -12
  7. package/lib/{journal-CuRnWk66.js → journal-BWL3-i0s.js} +27 -0
  8. package/lib/tool.js +63 -11
  9. package/lib/typert.host.js +184 -37
  10. package/lib/typert.remote-client.d.ts +2 -0
  11. package/lib/typert.remote-client.js +151 -31
  12. package/lib/types/client/RoomActivityDock.d.ts +19 -0
  13. package/lib/types/client/RoomActivityDock.js +105 -0
  14. package/lib/types/client/RoomComposer.d.ts +1 -1
  15. package/lib/types/client/RoomComposer.js +3 -3
  16. package/lib/types/client/RoomPlanTab.d.ts +15 -0
  17. package/lib/types/client/RoomPlanTab.js +13 -0
  18. package/lib/types/client/RoomPlanView.d.ts +3 -2
  19. package/lib/types/client/RoomPlanView.js +5 -6
  20. package/lib/types/client/executions.d.ts +50 -0
  21. package/lib/types/client/executions.js +70 -0
  22. package/lib/types/client/index.d.ts +1 -1
  23. package/lib/types/client/index.js +33 -1
  24. package/lib/types/client/locales.d.ts +50 -0
  25. package/lib/types/client/locales.js +50 -0
  26. package/lib/types/client/preset-visibility.d.ts +3 -3
  27. package/lib/types/client/preset-visibility.js +2 -2
  28. package/lib/types/client/slots.d.ts +6 -1
  29. package/lib/types/dispatch.js +30 -6
  30. package/lib/types/index.d.ts +5 -0
  31. package/lib/types/index.js +42 -7
  32. package/lib/types/journal.d.ts +1 -1
  33. package/lib/types/journal.js +27 -1
  34. package/lib/types/tool-definition.d.ts +56 -0
  35. package/lib/types/tool-definition.js +393 -0
  36. package/lib/types/tool.d.ts +37 -49
  37. package/lib/types/tool.js +47 -381
  38. package/lib/types/types.d.ts +28 -0
  39. package/package.json +10 -9
  40. package/README.zh.md +0 -48
@@ -44,8 +44,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
44
44
  * room host half: the `room` Typert Remote service. (The `room_invite` /
45
45
  * `room_task` / `room_message` model tools left this package's profile-root
46
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
47
+ * in ./tool-definition.ts and the package's own `./tool` composition entry
48
+ * mounts them inside agent-preset compositions.) The room's entire state is the session's `room/*` custom-event
49
49
  * journal (log-only events — persistence and reload-replay come free, the
50
50
  * model never sees them, and harnesses without this plugin replay the session
51
51
  * safely); every read folds the journal through the pure replay, and every
@@ -126,6 +126,7 @@ let RoomService = (() => {
126
126
  let _closeTask_decorators;
127
127
  let _setGoal_decorators;
128
128
  let _cancel_decorators;
129
+ let _cancelChild_decorators;
129
130
  return class RoomService extends _classSuper {
130
131
  static {
131
132
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
@@ -146,6 +147,7 @@ let RoomService = (() => {
146
147
  _closeTask_decorators = [Remote('closeTask')];
147
148
  _setGoal_decorators = [Remote('setGoal')];
148
149
  _cancel_decorators = [Remote('cancel')];
150
+ _cancelChild_decorators = [Remote('cancelChild')];
149
151
  __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
152
  __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
153
  __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);
@@ -163,6 +165,7 @@ let RoomService = (() => {
163
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);
164
166
  __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
167
  __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
+ __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);
166
169
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
167
170
  }
168
171
  static inject = ['sessions', 'agents'];
@@ -232,9 +235,10 @@ let RoomService = (() => {
232
235
  return { kind: 'enter', messages: [] };
233
236
  });
234
237
  // 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
+ // belong to this package's `./tool` composition entry (src/tool.ts),
239
+ // which mounts the tool row inside agent-preset compositions
240
+ // (session-granted). The definition factories live in
241
+ // ./tool-definition.ts for that row.
238
242
  }
239
243
  /** Resolve a LIVE session as a room: presence, marker, replayed state. */
240
244
  load(sessionId) {
@@ -1169,16 +1173,25 @@ let RoomService = (() => {
1169
1173
  const member = loaded.state.members.find(entry => entry.name === request.name);
1170
1174
  if (member === undefined)
1171
1175
  return { ok: false, error: { code: 'member-not-found' } };
1176
+ const expected = loaded.state.runs.find(entry => entry.member === request.name && entry.state === 'running');
1177
+ if ((request.expectedRunId !== undefined && expected?.runId !== request.expectedRunId)
1178
+ || (request.expectedStartedAt !== undefined && expected?.startedAt !== request.expectedStartedAt)) {
1179
+ return { ok: true, value: { cancelled: false } };
1180
+ }
1172
1181
  const facade = probeLocalAgent(this.ctx);
1173
- const hit = facade !== undefined && member.childSessionId !== undefined
1182
+ const native = member.kind === 'main-agent' && expected !== undefined ? this.ctx.agents.get(loaded.session.id) : undefined;
1183
+ if (native !== undefined)
1184
+ native.cancel({ kind: 'user' });
1185
+ const hit = native !== undefined || (facade !== undefined && member.childSessionId !== undefined
1174
1186
  ? facade.cancel(member.childSessionId)
1175
- : false;
1187
+ : false);
1176
1188
  if (hit) {
1177
1189
  const running = loaded.state.runs.find(entry => entry.member === request.name && entry.state === 'running');
1178
1190
  if (running === undefined)
1179
1191
  return { ok: true, value: { cancelled: true } };
1180
1192
  loaded.session.append('room/run-state', {
1181
1193
  member: request.name, state: 'cancelled', startedAt: running.startedAt,
1194
+ elapsedMs: Math.max(0, Date.now() - running.startedAt),
1182
1195
  ...running.runId === undefined ? {} : { runId: running.runId },
1183
1196
  });
1184
1197
  // The engine's settle no-ops behind this edge, so the task closing the
@@ -1194,6 +1207,28 @@ let RoomService = (() => {
1194
1207
  }
1195
1208
  return { ok: true, value: { cancelled: false } };
1196
1209
  }
1210
+ /** Native descendant controls share the host's continuation and cancellation channels. */
1211
+ async cancelChild(request) {
1212
+ const loaded = await this.ensureLive(request.sessionId);
1213
+ if (!loaded.ok)
1214
+ return { ok: false, error: loaded.error };
1215
+ const target = this.ctx.sessions.get(request.childSessionId);
1216
+ const seen = new Set([request.childSessionId]);
1217
+ let parent = target?.header.parentSession;
1218
+ while (parent !== undefined && parent !== request.sessionId && !seen.has(parent)) {
1219
+ seen.add(parent);
1220
+ parent = this.ctx.sessions.get(parent)?.header.parentSession;
1221
+ }
1222
+ if (!target || target.id === request.sessionId || parent !== request.sessionId)
1223
+ return { ok: true, value: { cancelled: false } };
1224
+ const facadeHit = probeLocalAgent(this.ctx)?.cancel(target.id) ?? false;
1225
+ const agent = this.ctx.agents.get(target.id);
1226
+ if (agent) {
1227
+ this.ctx.get('subagents')?.interrupt(target.id, { kind: 'user', parentSessionId: target.header.parentSession });
1228
+ agent.cancel({ kind: 'user' });
1229
+ }
1230
+ return { ok: true, value: { cancelled: facadeHit || agent !== undefined } };
1231
+ }
1197
1232
  };
1198
1233
  })();
1199
1234
  export { RoomService };
@@ -18,7 +18,7 @@ export declare const MAIN_AGENT_MEMBER = "dsh";
18
18
  * in-place add is that surface's temporary form — migrate to the official
19
19
  * one when it lands upstream.
20
20
  */
21
- export declare const ROOM_EVENT_TYPES: readonly ['room/created', 'room/plan-state', 'room/coordinator', 'room/delivery-state', 'room/member-added', 'room/member-updated', 'room/member-removed', 'room/dispatch', 'room/speech', 'room/run-state', 'room/relay', 'room/relay-resolved', 'room/task-added', 'room/task-updated', 'room/task-edited', 'room/goal'];
21
+ export declare const ROOM_EVENT_TYPES: readonly ['room/created', 'room/plan-state', 'room/coordinator', 'room/delivery-state', 'room/member-added', 'room/member-updated', 'room/member-removed', 'room/dispatch', 'room/speech', 'room/run-state', 'room/execution-metadata', 'room/relay', 'room/relay-resolved', 'room/task-added', 'room/task-updated', 'room/task-edited', 'room/goal'];
22
22
  /** Whether an event log carries the room identity marker. */
23
23
  export declare function isRoomLog(events: readonly SessionEvent[]): boolean;
24
24
  /**
@@ -40,6 +40,7 @@ export const ROOM_EVENT_TYPES = [
40
40
  'room/dispatch',
41
41
  'room/speech',
42
42
  'room/run-state',
43
+ 'room/execution-metadata',
43
44
  'room/relay',
44
45
  'room/relay-resolved',
45
46
  'room/task-added',
@@ -114,6 +115,7 @@ export function replay(events) {
114
115
  const tasks = [];
115
116
  const taskById = new Map();
116
117
  const runs = new Map();
118
+ const executions = new Map();
117
119
  let goal;
118
120
  let coordinator;
119
121
  for (const event of events) {
@@ -160,6 +162,12 @@ export function replay(events) {
160
162
  ...withoutModel,
161
163
  ...event.data.childSessionId === undefined ? {} : { childSessionId: event.data.childSessionId },
162
164
  };
165
+ // First admission can precede publication of the native child handle.
166
+ for (const [id, execution] of executions) {
167
+ if (execution.memberId === member.id && execution.childSessionId === undefined && patched.childSessionId !== undefined) {
168
+ executions.set(id, { ...execution, childSessionId: patched.childSessionId });
169
+ }
170
+ }
163
171
  // A rename migrates every name-keyed projection: the roster key, the
164
172
  // tasks' member (and blockedBy), the relays' from/to, and the runs
165
173
  // key. The child session id rides the member record and stays. A
@@ -288,7 +296,25 @@ export function replay(events) {
288
296
  goal = text === '' ? undefined : text;
289
297
  break;
290
298
  }
299
+ case 'room/execution-metadata': {
300
+ const previous = executions.get(event.data.runId);
301
+ if (previous)
302
+ executions.set(previous.id, { ...previous, ...event.data });
303
+ break;
304
+ }
291
305
  case 'room/run-state': {
306
+ const member = byName.get(event.data.member);
307
+ const id = event.data.runId ?? `legacy:${member?.id ?? event.data.member}:${event.data.startedAt}`;
308
+ const previous = executions.get(id);
309
+ executions.set(id, {
310
+ id,
311
+ ...member?.id === undefined ? {} : { memberId: member.id },
312
+ ...member?.childSessionId === undefined ? {} : { childSessionId: member.childSessionId },
313
+ ...member?.provider === undefined ? {} : { provider: member.provider },
314
+ ...previous,
315
+ ...event.data,
316
+ ...event.data.state === 'running' || event.data.elapsedMs !== undefined ? {} : { elapsedMs: Math.max(0, event.time - event.data.startedAt) },
317
+ });
292
318
  const current = runs.get(event.data.member);
293
319
  // An older turn may finish journaling after core admits its successor.
294
320
  // Its delivery outcome remains durable, but it cannot replace that successor.
@@ -311,7 +337,7 @@ export function replay(events) {
311
337
  const planEvent = events.filter(event => event.type === 'room/plan-state').at(-1);
312
338
  const plan = planEvent?.type === 'room/plan-state' ? (({ requests: _requests, ...view }) => view)(planEvent.data) : undefined;
313
339
  const deliveries = replayDeliveries(events);
314
- return { members, relays, tasks, ...plan === undefined ? {} : { plan }, ...deliveries.length === 0 ? {} : { deliveries }, runs: [...runs.values()], ...coordinator === undefined ? {} : { coordinator }, ...goal === undefined ? {} : { goal } };
340
+ return { members, relays, tasks, ...executions.size === 0 ? {} : { executions: [...executions.values()] }, ...plan === undefined ? {} : { plan }, ...deliveries.length === 0 ? {} : { deliveries }, runs: [...runs.values()], ...coordinator === undefined ? {} : { coordinator }, ...goal === undefined ? {} : { goal } };
315
341
  }
316
342
  /**
317
343
  * The goal progress pair: done tasks over the COUNTABLE total — cancelled
@@ -0,0 +1,56 @@
1
+ import type { RoomAddTaskRequest, RoomAddTaskResult, RoomCloseTaskRequest, RoomCloseTaskResult, RoomGetStateRequest, RoomGetStateResult, RoomInviteRequest, RoomInviteResult, RoomMessageRequest, RoomMessageResult, RoomUpdateTaskRequest, RoomUpdateTaskResult } from './types.ts';
2
+ /** The slice of RoomService the tool drives. */
3
+ export interface RoomInviteToolBackend {
4
+ /** Validate and journal an invitation (see RoomService.inviteMember). */
5
+ inviteMember(request: RoomInviteRequest, invitedBy: 'human' | 'agent'): Promise<RoomInviteResult>;
6
+ }
7
+ /**
8
+ * Build the room_invite tool definition bound to the room service.
9
+ * @param backend - the room service.
10
+ * @returns a registry-ready tool definition.
11
+ */
12
+ export declare function roomInviteTool(backend: RoomInviteToolBackend): import("@deepseek-ai/dsh-tools").ToolDefinition;
13
+ /** The slice of RoomService the room_task tool drives. */
14
+ export interface RoomTaskToolBackend {
15
+ /** Open a pending task on a member's lane (see RoomService.addTask). */
16
+ addTask(request: RoomAddTaskRequest): Promise<RoomAddTaskResult>;
17
+ /** Close an open task (see RoomService.closeTask). */
18
+ closeTask(request: RoomCloseTaskRequest): Promise<RoomCloseTaskResult>;
19
+ /** Edit an open task's title/blockedBy (see RoomService.updateTask). */
20
+ updateTask(request: RoomUpdateTaskRequest): Promise<RoomUpdateTaskResult>;
21
+ /** Replay the room state (the error texts read the roster/open tasks from it). */
22
+ getState(request: RoomGetStateRequest): Promise<RoomGetStateResult>;
23
+ }
24
+ /**
25
+ * Build the room_task tool definition bound to the room service. Every write
26
+ * goes through the same host functions as the capsule UI, so a task the agent
27
+ * opens is a public commitment indistinguishable from a human-added one.
28
+ * @param backend - the room service.
29
+ * @returns a registry-ready tool definition.
30
+ */
31
+ export declare function roomTaskTool(backend: RoomTaskToolBackend): import("@deepseek-ai/dsh-tools").ToolDefinition;
32
+ /** The slice of RoomService the room_message tool drives. */
33
+ export interface RoomMessageToolBackend {
34
+ /** Dispatch one message to one member (see RoomService.messageMember). */
35
+ messageMember(request: RoomMessageRequest): Promise<RoomMessageResult>;
36
+ /** Replay the room state (the error text reads the live roster from it). */
37
+ getState(request: RoomGetStateRequest): Promise<RoomGetStateResult>;
38
+ }
39
+ /**
40
+ * Build the room_message tool definition bound to the room service: the main
41
+ * agent's way to hand a member a message/task — the model-facing equivalent
42
+ * of the human's `@member text`, minus the user bubble (the caller is the
43
+ * room's own agent, not the human).
44
+ * @param backend - the room service.
45
+ * @returns a registry-ready tool definition.
46
+ */
47
+ export declare function roomMessageTool(backend: RoomMessageToolBackend): import("@deepseek-ai/dsh-tools").ToolDefinition;
48
+ /** Native room tools use the same bounded context reader as the external MCP bridge. */
49
+ export declare function roomReadTool(backend: {
50
+ readRoomContext(sessionId: string): Promise<string>;
51
+ }): import("@deepseek-ai/dsh-tools").ToolDefinition;
52
+ /** Formal planning uses the same strict command grammar exposed by room_read. */
53
+ export declare function roomPlanTool(backend: {
54
+ commandPlan(sessionId: string, command: string): Promise<string>;
55
+ }): import("@deepseek-ai/dsh-tools").ToolDefinition;
56
+ //# sourceMappingURL=tool-definition.d.ts.map
@@ -0,0 +1,393 @@
1
+ /**
2
+ * The room's model-facing tool DEFINITIONS, `room_invite`, `room_task`, and
3
+ * `room_message`: the main
4
+ * agent's paths into the room it belongs to. `room_invite` and
5
+ * `room_message` PROMOTE: calling them in a plain session turns it into a
6
+ * room (ensureRoom) instead of rejecting. `room_task` stays gated at execute
7
+ * time — the calling agent's session must be a room (the journal carries
8
+ * `room/created`); anywhere else the tool answers with readable error text
9
+ * instead of throwing, so the model can see the rejection and self-correct.
10
+ * `room_invite` lands in the same invite path as the human dialog
11
+ * (`invitedBy: 'agent'`), producing an identical member record; `room_task`
12
+ * writes the SHARED task board through the very host functions the capsule UI
13
+ * uses (addTask/closeTask, plus the tool-only updateTask), so an agent-written
14
+ * task is indistinguishable from a human-added one; `room_message` dispatches
15
+ * a message to a member through the same engine path as the human's
16
+ * `@member text`, minus the user bubble (the caller is the room's own agent).
17
+ *
18
+ * Since the tool-row split (M4') the core never REGISTERS these tools: the
19
+ * factories are consumed by the package's `./tool` subpath row
20
+ * ({@link file://./tool.ts}), which mounts them inside agent-preset
21
+ * compositions (session-granted, never at the profile root). The definitions
22
+ * are therefore returned UNTAGGED — the registering row applies its own
23
+ * tool-origin tag (AGENTS.md § Tool origin tagging — the owner is whichever
24
+ * package mounts the row).
25
+ *
26
+ * Internal module: not on the package's exports map (consumers reach it only
27
+ * through `./src/*`).
28
+ *
29
+ * @module @khorsheed/dsh-room
30
+ */
31
+ import { defineTool } from '@deepseek-ai/dsh-tools';
32
+ import { coordinatorMember, isRoomLog } from './journal.js';
33
+ /**
34
+ * Build the room_invite tool definition bound to the room service.
35
+ * @param backend - the room service.
36
+ * @returns a registry-ready tool definition.
37
+ */
38
+ export function roomInviteTool(backend) {
39
+ return defineTool({
40
+ name: 'room_invite',
41
+ description: 'Invite a CLI agent member into the current session (any session works — inviting promotes it '
42
+ + 'into a room: the multi-agent group conversation surface wakes up on it). '
43
+ + 'The member becomes @-addressable by the human and by other members: pick a short unique '
44
+ + 'name (no whitespace, no "@", e.g. ada/bill/cathy) — it is the addressing name, decoupled '
45
+ + 'from the provider so two instances of one provider can coexist; prefer a function-style '
46
+ + 'name (e.g. design-review / restart-test) when the member has a standing role, so the human '
47
+ + 'and the other members can tell what it is for. The instructions are the '
48
+ + 'member\'s role briefing, prepended to its first dispatch and persisted in its own CLI '
49
+ + 'session from then on. Give a firstTask to put the member to work immediately, or omit it '
50
+ + 'to have the member join idle. A member keeps its own CLI session and accumulates context '
51
+ + 'across rounds, so prefer inviting over a one-shot subagent_* delegation whenever the work '
52
+ + 'expects follow-ups with the same agent — reserve one-shot delegations for self-contained '
53
+ + 'tasks with no continuation. The provider must be a registered delegation provider id — '
54
+ + 'an unknown one is rejected with the list of available ids, so retry with one of those.',
55
+ parameters: {
56
+ provider: {
57
+ type: 'string',
58
+ required: true,
59
+ description: 'The local-agent delegation provider id (e.g. kimi-cli / codex-cli / claude-code) — the '
60
+ + 'delegation id the family registered, which may differ from the harness\'s display '
61
+ + 'name. When unsure, call without firstTask first: an unknown id is rejected with the '
62
+ + 'list of available providers to retry with.',
63
+ },
64
+ name: {
65
+ type: 'string',
66
+ required: true,
67
+ description: 'The member\'s @-addressing name: unique in this room, no whitespace, no "@".',
68
+ },
69
+ instructions: {
70
+ type: 'string',
71
+ required: true,
72
+ description: 'The member\'s role instructions (responsibilities, constraints, output expectations).',
73
+ },
74
+ firstTask: {
75
+ type: 'string',
76
+ description: 'Optional first task, dispatched to the member as soon as it joins.',
77
+ },
78
+ cwd: {
79
+ type: 'string',
80
+ description: 'Optional working directory for the member (empty: inherits the room session\'s cwd).',
81
+ },
82
+ },
83
+ output: {
84
+ schema: {
85
+ type: 'object',
86
+ additionalProperties: false,
87
+ properties: {
88
+ text: { type: 'string', required: true },
89
+ },
90
+ },
91
+ render: (_args, value) => [{ type: 'text', text: value.text }],
92
+ },
93
+ // Invitations mutate only the room journal; two invites never conflict
94
+ // beyond a duplicate name, which the result text reports back.
95
+ isConcurrencySafe: () => true,
96
+ async execute(args, exec) {
97
+ const agent = exec.agent;
98
+ if (agent === undefined)
99
+ return { text: 'room_invite requires a calling agent (exec.agent was undefined).' };
100
+ // No room gate: inviteMember PROMOTES the calling session into a room.
101
+ const result = await backend.inviteMember({
102
+ sessionId: agent.session.id,
103
+ provider: args.provider,
104
+ name: args.name,
105
+ instructions: args.instructions,
106
+ ...args.firstTask === undefined ? {} : { firstTask: args.firstTask },
107
+ ...args.cwd === undefined ? {} : { cwd: args.cwd },
108
+ }, 'agent');
109
+ if (!result.ok) {
110
+ const hint = result.error.code === 'duplicate-name'
111
+ ? ' Pick a different name and retry.'
112
+ : result.error.code === 'invalid-name'
113
+ ? ' Names must be non-empty and free of whitespace and "@"; retry with a valid name.'
114
+ : result.error.code === 'local-agent-unavailable'
115
+ ? ' The local-agent delegation facade is not mounted; CLI members cannot join right now.'
116
+ : result.error.code === 'unknown-provider'
117
+ ? ` Unknown delegation provider "${result.error.provider}" (it must be the family's delegation provider id, not the harness's display name). Available: ${result.error.available.length === 0
118
+ ? 'none (no harness with a delegation provider is registered)'
119
+ : result.error.available.join(', ')}. Retry with one of the available providers.`
120
+ : '';
121
+ return { text: `Could not invite "${args.name}": ${result.error.code}.${hint}` };
122
+ }
123
+ return {
124
+ text: result.value.pendingFirstTask
125
+ ? `Member ${result.value.name} joined the room and its first task was dispatched.`
126
+ : `Member ${result.value.name} joined the room (idle; @-address it to dispatch work).`,
127
+ };
128
+ },
129
+ });
130
+ }
131
+ /**
132
+ * Build the room_task tool definition bound to the room service. Every write
133
+ * goes through the same host functions as the capsule UI, so a task the agent
134
+ * opens is a public commitment indistinguishable from a human-added one.
135
+ * @param backend - the room service.
136
+ * @returns a registry-ready tool definition.
137
+ */
138
+ export function roomTaskTool(backend) {
139
+ return defineTool({
140
+ name: 'room_task',
141
+ description: 'Manage the current room\'s SHARED task board (only usable inside a room session). This '
142
+ + 'board is public — every room member and the human sees these tasks, so writing here is '
143
+ + 'a public commitment the room coordinates around; for your private work plan use your own '
144
+ + 'todo tool instead. Actions: "add" (title + member — the roster name owning the task, '
145
+ + 'use room_read to find your own or another member\'s name; optional blockedBy names the member the task waits '
146
+ + 'on, display only) opens a pending task and returns its id; "close" (taskId) marks an '
147
+ + 'open task done; "update" (taskId + a new title and/or blockedBy — null clears the wait) '
148
+ + 'edits an open task. Errors answer with the roster or the open-task list so you can retry '
149
+ + 'with a valid member name or task id.',
150
+ parameters: {
151
+ action: {
152
+ type: 'string',
153
+ enum: ['add', 'close', 'update'],
154
+ required: true,
155
+ description: 'add: open a task (title + member). close: mark a task done (taskId). update: edit title/blockedBy (taskId).',
156
+ },
157
+ title: {
158
+ type: 'string',
159
+ description: 'Task title. Required for add; optional new title for update.',
160
+ },
161
+ member: {
162
+ type: 'string',
163
+ description: 'The roster name owning the task (required for add; read room_read for current names).',
164
+ },
165
+ taskId: {
166
+ type: 'string',
167
+ description: 'The task id (required for close/update; add\'s success text returns the new id).',
168
+ },
169
+ blockedBy: {
170
+ oneOf: [{ type: 'string' }, { type: 'null' }],
171
+ description: 'The member this task waits on (must be on the roster; display only, never an automatic '
172
+ + 'dispatch). Optional on add; on update, a string re-targets the wait and null clears it.',
173
+ },
174
+ },
175
+ output: {
176
+ schema: {
177
+ type: 'object',
178
+ additionalProperties: false,
179
+ properties: {
180
+ text: { type: 'string', required: true },
181
+ },
182
+ },
183
+ render: (_args, value) => [{ type: 'text', text: value.text }],
184
+ },
185
+ // Board writes mutate only the room journal; two writes never conflict
186
+ // beyond the validations the result text reports back.
187
+ isConcurrencySafe: () => true,
188
+ async execute(args, exec) {
189
+ const agent = exec.agent;
190
+ if (agent === undefined)
191
+ return { text: 'room_task requires a calling agent (exec.agent was undefined).' };
192
+ if (!isRoomLog(agent.session.snapshotEvents())) {
193
+ return { text: 'The current session is not a room; room_task is only usable inside a room session.' };
194
+ }
195
+ const sessionId = agent.session.id;
196
+ const ownership = await backend.getState({ sessionId });
197
+ if (ownership.ok && coordinatorMember(ownership.value)?.kind !== 'main-agent')
198
+ return { text: 'Only the current coordinator may manage the shared room task board.' };
199
+ /** The self-correcting roster suffix: the legal member names, for member-not-found. */
200
+ const rosterHint = async () => {
201
+ const state = await backend.getState({ sessionId });
202
+ if (!state.ok)
203
+ return '';
204
+ const names = state.value.members.map(member => member.name);
205
+ return names.length === 0 ? '' : ` The room roster is: ${names.join(', ')}. Retry with one of these names.`;
206
+ };
207
+ /** The self-correcting board suffix: the open tasks (id — title), for task-not-found. */
208
+ const openTasksHint = async () => {
209
+ const state = await backend.getState({ sessionId });
210
+ if (!state.ok)
211
+ return '';
212
+ const open = state.value.tasks.filter(task => task.status === 'pending' || task.status === 'in_progress');
213
+ if (open.length === 0)
214
+ return ' The board has no open tasks right now.';
215
+ return ` Open tasks: ${open.map(task => `${task.id} — "${task.title}" (${task.member}, ${task.status})`).join('; ')}. Retry with one of these ids.`;
216
+ };
217
+ const failureText = async (code) => {
218
+ switch (code) {
219
+ case 'member-not-found':
220
+ return `Unknown member.${await rosterHint()}`;
221
+ case 'empty-text':
222
+ return 'The title must be non-blank; retry with a real title.';
223
+ case 'task-not-found':
224
+ return `No task with that id.${await openTasksHint()}`;
225
+ case 'task-closed':
226
+ return 'That task is already closed (done/cancelled); closed tasks take no further changes.';
227
+ case 'nothing-to-update':
228
+ return 'update needs at least one field to change: a new title and/or blockedBy (null clears the wait).';
229
+ default:
230
+ return `The room rejected the write (${code}).`;
231
+ }
232
+ };
233
+ switch (args.action) {
234
+ case 'add': {
235
+ if (args.title === undefined || args.member === undefined) {
236
+ return { text: 'room_task add requires both title and member (the roster name owning the task; read room_read for current names).' };
237
+ }
238
+ if (args.blockedBy === null) {
239
+ return { text: 'add takes no null blockedBy — simply omit blockedBy for an unblocked task.' };
240
+ }
241
+ const result = await backend.addTask({
242
+ sessionId,
243
+ member: args.member,
244
+ title: args.title,
245
+ ...args.blockedBy === undefined ? {} : { blockedBy: args.blockedBy },
246
+ });
247
+ if (!result.ok)
248
+ return { text: `Could not add the task: ${result.error.code}. ${await failureText(result.error.code)}` };
249
+ return {
250
+ text: `Task "${args.title}" is now on ${args.member}'s lane of the shared room board `
251
+ + `(id: ${result.value.id}) — visible to every member and the human. `
252
+ + 'Keep the id to update or close the task later.',
253
+ };
254
+ }
255
+ case 'close': {
256
+ if (args.taskId === undefined) {
257
+ return { text: `room_task close requires taskId.${await openTasksHint()}` };
258
+ }
259
+ const result = await backend.closeTask({ sessionId, taskId: args.taskId });
260
+ if (!result.ok)
261
+ return { text: `Could not close the task: ${result.error.code}. ${await failureText(result.error.code)}` };
262
+ return { text: `Task ${result.value.id} is done — the shared board shows it closed.` };
263
+ }
264
+ case 'update': {
265
+ if (args.taskId === undefined) {
266
+ return { text: `room_task update requires taskId.${await openTasksHint()}` };
267
+ }
268
+ const result = await backend.updateTask({
269
+ sessionId,
270
+ taskId: args.taskId,
271
+ ...args.title === undefined ? {} : { title: args.title },
272
+ ...args.blockedBy === undefined ? {} : { blockedBy: args.blockedBy },
273
+ });
274
+ if (!result.ok)
275
+ return { text: `Could not update the task: ${result.error.code}. ${await failureText(result.error.code)}` };
276
+ return { text: `Task ${result.value.id} updated on the shared room board.` };
277
+ }
278
+ }
279
+ },
280
+ });
281
+ }
282
+ /**
283
+ * Build the room_message tool definition bound to the room service: the main
284
+ * agent's way to hand a member a message/task — the model-facing equivalent
285
+ * of the human's `@member text`, minus the user bubble (the caller is the
286
+ * room's own agent, not the human).
287
+ * @param backend - the room service.
288
+ * @returns a registry-ready tool definition.
289
+ */
290
+ export function roomMessageTool(backend) {
291
+ return defineTool({
292
+ name: 'room_message',
293
+ description: 'Dispatch a message to a member of the current room (works in any session — it promotes the '
294
+ + 'session into a room if it is not one yet). '
295
+ + 'A member is an independent CLI session: the message runs asynchronously, and the member\'s '
296
+ + 'reply appears in the room as member speech (the human sees it; you read it through the '
297
+ + 'room\'s flow). Use this to ask a member something or hand it work — the equivalent of the '
298
+ + 'human typing "@member <text>" in the room composer. The member argument is a roster name '
299
+ + '(read room_read for current names); an unknown name is rejected with the live roster, so retry with one '
300
+ + 'of those.',
301
+ parameters: {
302
+ member: {
303
+ type: 'string',
304
+ required: true,
305
+ description: 'The addressee\'s roster name (read room_read for current names; new rooms name the native member "dsh").',
306
+ },
307
+ text: {
308
+ type: 'string',
309
+ required: true,
310
+ description: 'The message text the member receives as its task.',
311
+ },
312
+ },
313
+ output: {
314
+ schema: {
315
+ type: 'object',
316
+ additionalProperties: false,
317
+ properties: {
318
+ text: { type: 'string', required: true },
319
+ },
320
+ },
321
+ render: (_args, value) => [{ type: 'text', text: value.text }],
322
+ },
323
+ // A dispatch mutates only the room journal; two messages never conflict
324
+ // beyond the roster validation the result text reports back.
325
+ isConcurrencySafe: () => true,
326
+ async execute(args, exec) {
327
+ const agent = exec.agent;
328
+ if (agent === undefined)
329
+ return { text: 'room_message requires a calling agent (exec.agent was undefined).' };
330
+ // No room gate: messageMember PROMOTES the calling session into a room.
331
+ const sessionId = agent.session.id;
332
+ const result = await backend.messageMember({ sessionId, member: args.member, text: args.text });
333
+ if (!result.ok) {
334
+ if (result.error.code === 'member-not-found') {
335
+ const state = await backend.getState({ sessionId });
336
+ const names = state.ok ? state.value.members.map(member => member.name) : [];
337
+ const hint = names.length === 0 ? '' : ` The room roster is: ${names.join(', ')}. Retry with one of these names.`;
338
+ return { text: `Could not message "${args.member}": unknown member.${hint}` };
339
+ }
340
+ if (result.error.code === 'empty-text') {
341
+ return { text: 'The message text must be non-blank; retry with real content.' };
342
+ }
343
+ return { text: `Could not message "${args.member}": ${result.error.code}.` };
344
+ }
345
+ return {
346
+ text: `Dispatched to ${result.value.member} — the member runs it asynchronously and its reply `
347
+ + 'appears in the room as member speech. Do not wait on it; the human watches the room.',
348
+ };
349
+ },
350
+ });
351
+ }
352
+ /** Native room tools use the same bounded context reader as the external MCP bridge. */
353
+ export function roomReadTool(backend) {
354
+ return defineTool({
355
+ name: 'room_read',
356
+ description: 'Read the current room roster, coordinator, deliveries, recent outcomes and available harness providers. Reading does not wake any member.',
357
+ parameters: {},
358
+ output: { schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
359
+ render: (_args, value) => [{ type: 'text', text: value.text }] },
360
+ isConcurrencySafe: () => true,
361
+ async execute(_args, exec) {
362
+ if (exec.agent === undefined)
363
+ return { text: 'room_read requires a calling agent.' };
364
+ try {
365
+ return { text: await backend.readRoomContext(exec.agent.session.id) };
366
+ }
367
+ catch (error) {
368
+ return { text: String(error) };
369
+ }
370
+ },
371
+ });
372
+ }
373
+ /** Formal planning uses the same strict command grammar exposed by room_read. */
374
+ export function roomPlanTool(backend) {
375
+ return defineTool({
376
+ name: 'room_plan',
377
+ description: 'Organize a requested goal into stages and dependent tasks, submit evidence, review/rework results, and pause/resume. Read room_read for current plan and planCommands JSON schema. Ordinary small tasks use room_message without a formal goal. A native run finishing only submits a result; acceptance requires evidence review.',
378
+ parameters: { command: { type: 'string', required: true, description: 'JSON command matching room_read planCommands: action, requestId, expectedRevision and action fields. Create mode execute is for authorized execution; draft saves a proposal without dispatch.' } },
379
+ output: { schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
380
+ render: (_args, value) => [{ type: 'text', text: value.text }] },
381
+ isConcurrencySafe: () => true,
382
+ async execute(args, exec) {
383
+ if (exec.agent === undefined)
384
+ return { text: 'room_plan requires a calling agent.' };
385
+ try {
386
+ return { text: await backend.commandPlan(exec.agent.session.id, args.command) };
387
+ }
388
+ catch (error) {
389
+ return { text: String(error) };
390
+ }
391
+ },
392
+ });
393
+ }