@khorsheed/dsh-room 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.en.md +29 -62
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +28 -61
  5. package/lib/client.js +14620 -5705
  6. package/lib/index.js +4069 -730
  7. package/lib/journal-B4FOGO6S.js +482 -0
  8. package/lib/journal-Rid9AAnD.js +485 -0
  9. package/lib/tool.js +112 -166
  10. package/lib/typert.host.js +2031 -374
  11. package/lib/typert.remote-client.d.ts +34 -10
  12. package/lib/typert.remote-client.js +1599 -210
  13. package/lib/types/agent-setup.d.ts +1 -1
  14. package/lib/types/client/InviteAgentAction.js +4 -16
  15. package/lib/types/client/InviteDialog.d.ts +28 -6
  16. package/lib/types/client/InviteDialog.js +76 -12
  17. package/lib/types/client/MemberCard.d.ts +5 -1
  18. package/lib/types/client/MemberCard.js +4 -4
  19. package/lib/types/client/MembersView.d.ts +1 -1
  20. package/lib/types/client/MembersView.js +58 -22
  21. package/lib/types/client/RemoveMemberConfirm.d.ts +40 -0
  22. package/lib/types/client/RemoveMemberConfirm.js +85 -0
  23. package/lib/types/client/RoomApprovalView.d.ts +11 -0
  24. package/lib/types/client/RoomApprovalView.js +28 -0
  25. package/lib/types/client/RoomComposer.d.ts +1 -1
  26. package/lib/types/client/RoomComposer.js +195 -29
  27. package/lib/types/client/RoomDecisionView.d.ts +22 -0
  28. package/lib/types/client/RoomDecisionView.js +35 -0
  29. package/lib/types/client/RoomHelpView.d.ts +17 -0
  30. package/lib/types/client/RoomHelpView.js +56 -0
  31. package/lib/types/client/RoomInboxView.d.ts +10 -0
  32. package/lib/types/client/RoomInboxView.js +14 -0
  33. package/lib/types/client/RoomQuestionView.d.ts +13 -0
  34. package/lib/types/client/RoomQuestionView.js +61 -0
  35. package/lib/types/client/RoomRelayView.js +1 -1
  36. package/lib/types/client/RoomRunView.d.ts +3 -1
  37. package/lib/types/client/RoomRunView.js +12 -5
  38. package/lib/types/client/RoomSettingsView.d.ts +19 -0
  39. package/lib/types/client/RoomSettingsView.js +30 -0
  40. package/lib/types/client/RoomSpeechView.d.ts +22 -8
  41. package/lib/types/client/RoomSpeechView.js +94 -11
  42. package/lib/types/client/RoomStatsLine.d.ts +26 -5
  43. package/lib/types/client/RoomStatsLine.js +53 -7
  44. package/lib/types/client/RoomTaskCapsule.d.ts +19 -0
  45. package/lib/types/client/RoomTaskCapsule.js +37 -0
  46. package/lib/types/client/TasksTab.d.ts +41 -0
  47. package/lib/types/client/TasksTab.js +43 -0
  48. package/lib/types/client/TasksView.d.ts +44 -0
  49. package/lib/types/client/TasksView.js +149 -0
  50. package/lib/types/client/TeamView.d.ts +33 -0
  51. package/lib/types/client/TeamView.js +210 -0
  52. package/lib/types/client/at-menu.d.ts +127 -0
  53. package/lib/types/client/at-menu.js +173 -0
  54. package/lib/types/client/draft-chips.d.ts +67 -0
  55. package/lib/types/client/draft-chips.js +95 -0
  56. package/lib/types/client/executions.d.ts +0 -30
  57. package/lib/types/client/executions.js +4 -34
  58. package/lib/types/client/icons-local.d.ts +25 -0
  59. package/lib/types/client/icons-local.js +10 -0
  60. package/lib/types/client/icons.d.ts +2 -1
  61. package/lib/types/client/icons.js +1 -0
  62. package/lib/types/client/index.js +276 -72
  63. package/lib/types/client/locales.d.ts +482 -52
  64. package/lib/types/client/locales.js +490 -60
  65. package/lib/types/client/nodes.d.ts +112 -17
  66. package/lib/types/client/nodes.js +197 -26
  67. package/lib/types/client/slots.d.ts +117 -47
  68. package/lib/types/client/task-tag.d.ts +32 -0
  69. package/lib/types/client/task-tag.js +33 -0
  70. package/lib/types/client/tasks.d.ts +50 -0
  71. package/lib/types/client/tasks.js +82 -0
  72. package/lib/types/client/team.d.ts +73 -0
  73. package/lib/types/client/team.js +187 -0
  74. package/lib/types/decisions.d.ts +49 -0
  75. package/lib/types/decisions.js +101 -0
  76. package/lib/types/deliveries.d.ts +17 -1
  77. package/lib/types/deliveries.js +82 -6
  78. package/lib/types/dispatch.d.ts +102 -4
  79. package/lib/types/dispatch.js +498 -109
  80. package/lib/types/help.d.ts +137 -0
  81. package/lib/types/help.js +216 -0
  82. package/lib/types/helps.d.ts +26 -0
  83. package/lib/types/helps.js +47 -0
  84. package/lib/types/index.d.ts +292 -52
  85. package/lib/types/index.js +1421 -199
  86. package/lib/types/jobs.d.ts +22 -0
  87. package/lib/types/jobs.js +77 -0
  88. package/lib/types/journal.d.ts +17 -27
  89. package/lib/types/journal.js +120 -43
  90. package/lib/types/messaging.d.ts +65 -0
  91. package/lib/types/messaging.js +160 -0
  92. package/lib/types/plan-service.js +7 -1
  93. package/lib/types/report-gate.d.ts +81 -0
  94. package/lib/types/report-gate.js +89 -0
  95. package/lib/types/tool-definition.d.ts +43 -20
  96. package/lib/types/tool-definition.js +145 -212
  97. package/lib/types/tool.d.ts +2 -2
  98. package/lib/types/tool.js +3 -2
  99. package/lib/types/types.d.ts +755 -78
  100. package/locale/zh.json +1 -1
  101. package/package.json +3 -3
  102. package/lib/types/client/RoomActivityDock.d.ts +0 -19
  103. package/lib/types/client/RoomActivityDock.js +0 -105
  104. package/lib/types/client/RoomDockCapsules.d.ts +0 -64
  105. package/lib/types/client/RoomDockCapsules.js +0 -277
  106. package/lib/types/client/RoomPlanTab.d.ts +0 -15
  107. package/lib/types/client/RoomPlanTab.js +0 -13
  108. package/lib/types/client/RoomTaskLineView.d.ts +0 -13
  109. package/lib/types/client/RoomTaskLineView.js +0 -21
@@ -8,22 +8,27 @@
8
8
  * the exact dispatch history — but speech/dispatch events are UI projection
9
9
  * and replay ONLY: no prompt ever consumes the room's running log.
10
10
  *
11
- * The prompt every member receives is uniform (the design note's roster +
12
- * notifications contract): role instructions (first dispatch, or an edit
13
- * carried as an update) + the room goal (one line, every dispatch — cheap,
14
- * and it orients the task) + the roster (who exists, with the notification
15
- * protocol) ONLY when the member has never seen it or it changed since their
16
- * last dispatch ({@link rosterStaleSince}) + the member's
17
- * confirmed-undelivered notifications + this dispatch's text. The member's
18
- * own CLI session (resume chain) holds its working memory; room never
19
- * resends what it has seen.
11
+ * The prompt every member receives is uniform: the member's FIRST dispatch
12
+ * opens with one room briefing (identity, public role, private instructions,
13
+ * goal, roster of public roles, the tool-based collaboration contract);
14
+ * every later dispatch carries only what changed since the member's previous
15
+ * one — an instructions edit, one-line room changes ({@link roomChangesSince}),
16
+ * a coordinator handoff — plus the member's confirmed-undelivered
17
+ * notifications and this dispatch's text. The member's own session (resume
18
+ * chain) holds its working memory; room never resends what it has seen, and
19
+ * no member ever receives another member's private instructions.
20
20
  * @module @khorsheed/dsh-room/dispatch
21
21
  */
22
22
  import { replayDeliveries } from './deliveries.js';
23
23
  import { randomUUID } from 'node:crypto';
24
+ import { SessionId } from '@deepseek-ai/dsh-session';
24
25
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
25
26
  import { probeLocalAgent, runOutputText } from './adapter.js';
26
- import { coordinatorMember, memberId, parseRelayDirective, pendingInstructions, previousCursor, replay, rosterStaleSince } from './journal.js';
27
+ import { composeRoomAgent } from './agent-setup.js';
28
+ import { coordinatorMember, memberId, pendingInstructions, previousCursor, replay, roomChangesSince } from './journal.js';
29
+ import { envelopeLine, messageId, sendOrHold } from './messaging.js';
30
+ import { gateState, judgeReport, outstandingFor } from './report-gate.js';
31
+ import { decisionRecord } from './decisions.js';
27
32
  /**
28
33
  * The room producer's durable identity in its two historical forms: the
29
34
  * released V3 wrapper's `plugin` value (`@khorsheed/dsh-room` — the official
@@ -38,6 +43,38 @@ export const ROOM_KIND = 'room';
38
43
  export function roomSource() {
39
44
  return Object.freeze({ kind: ROOM_KIND });
40
45
  }
46
+ /**
47
+ * A run-state edge's child-session identity: the main agent runs on the room
48
+ * itself, external members on their child session. Optional — a fresh CLI
49
+ * member's early running edge predates the child handle's publication (the
50
+ * terminal edge and the journal backfill cover it).
51
+ */
52
+ export function runChildSessionId(roomId, member) {
53
+ if (member?.kind === 'main-agent')
54
+ return roomId;
55
+ return member?.childSessionId;
56
+ }
57
+ /**
58
+ * The child session round a run reached: the last turn/start the child wrote.
59
+ * Live sessions only — a reconcile path whose child is not in memory omits it.
60
+ */
61
+ /**
62
+ * The id of the last assistant message a session's log holds after `afterSeq`:
63
+ * the message a member's round closed with, which per-message tools (Context
64
+ * Lens) address in the member's own session.
65
+ * @param session - the member's session.
66
+ * @param afterSeq - only messages after this seq count.
67
+ * @returns the message id, or undefined when the round produced none.
68
+ */
69
+ export function lastAssistantMessageId(session, afterSeq = -1) {
70
+ const event = session?.snapshotEvents().findLast(entry => entry.type === 'assistant/message' && entry.seq > afterSeq);
71
+ const id = event?.data?.message?.id;
72
+ return typeof id === 'string' ? id : undefined;
73
+ }
74
+ export function runChildTurn(session) {
75
+ const last = session?.snapshotEvents().filter(event => event.type === 'turn/start').at(-1);
76
+ return last?.data?.turn;
77
+ }
41
78
  /**
42
79
  * Whether a durable source belongs to the room producer, in every form the
43
80
  * log can hold: the current producer-owned kind (`room`), the V3→V4 migrated
@@ -59,27 +96,37 @@ function closedGoal(state, goalId) {
59
96
  && (state.plan.id !== goalId || ['completed', 'cancelled'].includes(state.plan.status));
60
97
  }
61
98
  /**
62
- * The roster section: one line per OTHER member (name, provider, one-line
63
- * role) plus the notification protocol — the fallback channel's trailing
64
- * own-line `@name <content>` format lives here so a member can reach others
65
- * without any bridge tooling. Carried only when the member has never seen
66
- * the roster or it changed since their last dispatch ({@link rosterStaleSince});
67
- * the member's own session holds it in between. The room GOAL is NOT part of
68
- * this section: it rides every dispatch on its own line (cheap, and it
69
- * orients the task).
99
+ * The member's first-dispatch briefing: who it is (name + public role), its
100
+ * private instructions, the goal, everyone else by PUBLIC role only (never
101
+ * their instructions — those read like orders addressed to the reader), and
102
+ * the collaboration contract. Members reach each other through tools —
103
+ * `member_message` on the local-agent bridge for CLI members, `room_message`
104
+ * for the room's own agent — and read the live roster with `room_read`; a
105
+ * trailing `@name` line in a reply is plain text and reaches nobody.
70
106
  */
71
- function rosterSection(state, self) {
72
- const lines = state.members
73
- .filter(member => member.name !== self)
74
- .map((member) => {
75
- const provider = member.kind === 'main-agent' ? '原生 DSH' : member.provider ?? '';
76
- const role = member.instructions?.split('\n', 1)[0]?.trim();
77
- return `- ${member.name}(${provider})${role === undefined || role === '' ? '' : `:${role}`}`;
107
+ function briefingSection(state, member, instructions, coordinator) {
108
+ const others = state.members
109
+ .filter(entry => entry.name !== member.name)
110
+ .map((entry) => {
111
+ const facts = [
112
+ ...coordinator?.name === entry.name ? ['协调者'] : [],
113
+ ...entry.role === undefined ? [] : [entry.role],
114
+ entry.kind === 'main-agent' ? '原生 DSH' : entry.kind === 'native' ? `原生 DSH${entry.preset === undefined ? '' : ` · ${entry.preset}`}` : entry.provider ?? '',
115
+ ].filter(Boolean);
116
+ return `- ${entry.name}(${facts.join(' · ')})`;
78
117
  });
79
118
  return [
80
- '【成员名册】',
81
- ...lines,
82
- '通知协议:要通知某个成员,在回复末尾独占一行写 `@名字 <内容>`;该行会被转交给对方(一阶段需房间主人确认)。',
119
+ '【房间简报】',
120
+ `你是协作间成员「${member.name}」${member.role === undefined ? '' : `,职责:${member.role}`}${coordinator?.name === member.name ? ',也是本房间的协调者' : ''}。`,
121
+ ...instructions === undefined ? [] : [`你的指令:${instructions}`],
122
+ ...state.goal === undefined ? [] : [`房间目标:${state.goal}`],
123
+ ...others.length === 0 ? [] : ['其他成员:', ...others],
124
+ `联系成员、派活用 ${member.kind === 'cli' ? 'member_message' : 'room_message'},看房间现状和任务编号用 room_read,用法见工具说明。成员发来的消息开头标着来自谁和消息编号(#m12),没有标注的是人发的;派给你的活,你这一轮的最终回复会自动回报给派活人,不用另外回复。`,
125
+ '要找房间外的会话帮忙(人在消息里 @ 的会话 `@[…](dsh-session:…)`,包括另一个协作间或它的成员),直接用 room_help,不要转给别的成员代问。',
126
+ // A CLI reaches the room's tools (and its mode's) through an MCP server,
127
+ // which some CLIs keep behind tool discovery rather than in the visible list.
128
+ ...member.kind === 'cli' ? [`这些房间工具由名为 dsh-member 的 MCP 服务提供${member.preset === undefined ? '' : `,你挂的「${member.preset}」模式的插件工具也在这个服务里`};没在工具列表里直接看到时,用工具发现查找 dsh-member 的工具。`, '需要人拍板或补充信息时,用 dsh-member 的 ask_user_question 把问题和选项发给人点选,不要把一串问题写在回复里;它会等人回答后把答案返回给你。'] : [],
129
+ ...coordinator?.name === member.name ? ['作为协调者:普通对话直接回答,小任务派给成员,只在确有需要时建立正式目标。派完活就结束这一轮,回报会自己送到——不要 sleep 等待,也不要用 room_read 查结果。收到回报时用户已经看过成员的原话:需要推进就推进,否则一两句话即可,不要复述或做汇总表。'] : [],
83
130
  ].join('\n');
84
131
  }
85
132
  /** The notifications section: confirmed-undelivered relays addressed to the member. */
@@ -87,33 +134,90 @@ function notificationsSection(relays) {
87
134
  return ['【通知】', ...relays.map(relay => `- ${relay.from} 给你的通知: ${relay.content}`)].join('\n');
88
135
  }
89
136
  /**
90
- * Assemble the uniform member prompt: role-instructions carry, then the room
91
- * goal (every dispatch), then the roster (only when stale for this member),
92
- * then the member's confirmed-undelivered notifications (except the ones
93
- * this dispatch's own text already delivers), then this dispatch's text.
137
+ * Assemble the uniform member prompt: the briefing on the member's first
138
+ * dispatch, otherwise only what changed since its previous one (handoff,
139
+ * instructions edit, room changes); then the member's confirmed-undelivered
140
+ * notifications (except the ones this dispatch's own text already delivers),
141
+ * then this dispatch's text.
94
142
  */
95
- function assemblePrompt(room, member, cursor, text, relayIds) {
143
+ function assemblePrompt(room, member, cursor, text, relayIds, dispatchSeq) {
96
144
  const state = replay(room.snapshotEvents());
97
145
  const carried = state.relays.filter(relay => relay.state === 'confirmed' && relay.to === member.name && !relayIds.includes(relay.id));
98
146
  const sections = [];
99
- const handoff = room.snapshotEvents().filter(event => event.type === 'room/coordinator' && event.data.memberId === memberId(room.snapshotEvents(), member)).at(-1);
100
- if (handoff?.type === 'room/coordinator' && (cursor === undefined || handoff.seq > cursor))
101
- sections.push(handoff.data.handoff);
102
- if (coordinatorMember(state, room.snapshotEvents())?.name === member.name)
103
- sections.push('You are the room coordinator. Handle ordinary chat directly; delegate small work in the background using room tools. Establish a goal plan only when useful for the requested execution. Delegate results arrive as correlated reports; do not poll while waiting.');
104
- const pending = pendingInstructions(room.snapshotEvents(), member.name, cursor);
105
- if (pending?.kind === 'initial')
106
- sections.push(`你的角色指令:${pending.instructions}`);
107
- if (pending?.kind === 'update')
108
- sections.push(`你的角色指令更新为:${pending.instructions}`);
109
- if (state.goal !== undefined)
110
- sections.push(`本房间的目标:${state.goal}`);
111
- if (rosterStaleSince(room.snapshotEvents(), cursor))
112
- sections.push(rosterSection(state, member.name));
147
+ const events = room.snapshotEvents();
148
+ const coordinator = coordinatorMember(state, events);
149
+ const handoff = events.filter(event => event.type === 'room/coordinator' && event.data.memberId === memberId(events, member)).at(-1);
150
+ const pending = pendingInstructions(events, member.name, cursor);
151
+ if (cursor === undefined) {
152
+ sections.push(briefingSection(state, member, pending?.instructions, coordinator));
153
+ if (handoff?.type === 'room/coordinator')
154
+ sections.push(handoff.data.handoff);
155
+ }
156
+ else {
157
+ if (handoff?.type === 'room/coordinator' && handoff.seq > cursor)
158
+ sections.push(`你现在是本房间的协调者。\n${handoff.data.handoff}`);
159
+ if (pending?.kind === 'update')
160
+ sections.push(`你的指令更新为:${pending.instructions}`);
161
+ const changes = roomChangesSince(events, cursor, member.name);
162
+ if (changes.length > 0)
163
+ sections.push(['【房间变动】', ...changes.map(change => `- ${change}`)].join('\n'));
164
+ }
113
165
  if (carried.length > 0)
114
166
  sections.push(notificationsSection(carried));
115
- sections.push(text);
116
- return { prompt: sections.join('\n\n'), carried };
167
+ // Reports the gate held for this member and no inbox took: they ride this round.
168
+ const held = (state.deliveries ?? []).filter(row => row.memberId === memberId(events, member) && row.status === 'held' && row.dispatchSeq !== dispatchSeq);
169
+ for (const row of held)
170
+ sections.push(`${envelopeLine(events, row.dispatchSeq) ?? ''}\n${row.text}`.trim());
171
+ const envelope = dispatchSeq === undefined ? undefined : envelopeLine(events, dispatchSeq);
172
+ sections.push(envelope === undefined ? text : `${envelope}\n${text}`);
173
+ return { prompt: sections.join('\n\n'), carried, heldCarried: held.map(row => row.dispatchSeq) };
174
+ }
175
+ /**
176
+ * Reports held for a member that it has not yet seen in a round: hold
177
+ * verdicts without a later release.
178
+ */
179
+ export function pendingHolds(events, recipientId) {
180
+ const open = new Map();
181
+ for (const event of events) {
182
+ if (event.type !== 'room/report-gate' || event.data.memberId !== recipientId)
183
+ continue;
184
+ if (event.data.verdict === 'hold')
185
+ open.set(event.data.dispatchSeq, event.seq);
186
+ else if (event.data.verdict === 'release')
187
+ open.delete(event.data.dispatchSeq);
188
+ }
189
+ return [...open.keys()];
190
+ }
191
+ /**
192
+ * The held reports of a member as one prompt section (their envelopes and
193
+ * texts), or undefined when nothing is held — what a turn the room did not
194
+ * start (a human waking the agent directly) must still carry.
195
+ */
196
+ export function heldReportsText(events, recipientId) {
197
+ const held = (replay(events).deliveries ?? []).filter(row => row.memberId === recipientId && row.status === 'held');
198
+ if (held.length === 0)
199
+ return undefined;
200
+ return {
201
+ text: held.map(row => `${envelopeLine(events, row.dispatchSeq) ?? ''}\n${row.text}`.trim()).join('\n\n'),
202
+ seqs: held.map(row => row.dispatchSeq),
203
+ };
204
+ }
205
+ /** The text of the last assistant message after `afterSeq` ('' when none). */
206
+ function lastAssistantText(events, afterSeq) {
207
+ const reply = events.filter(event => event.seq > afterSeq && event.type === 'assistant/message').at(-1);
208
+ return reply?.type === 'assistant/message'
209
+ ? reply.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
210
+ : '';
211
+ }
212
+ /** Who a delivery of this dispatch reports to (the coordinator for plan work), if anyone. */
213
+ function reportRecipient(events, dispatchSeq) {
214
+ const source = events.find(event => event.seq === dispatchSeq);
215
+ if (source?.type !== 'room/dispatch' || source.data.replyTo === undefined)
216
+ return undefined;
217
+ const state = replay(events);
218
+ return source.data.plan === undefined
219
+ ? state.members.find(member => memberId(events, member) === source.data.replyTo)
220
+ : coordinatorMember(state, events);
117
221
  }
118
222
  /** The human-readable reason a run failed (Error message, else String()). */
119
223
  function faultMessage(error) {
@@ -131,6 +235,18 @@ export class DispatchEngine {
131
235
  queues = new Map();
132
236
  scheduled = new Set();
133
237
  inFlight = new Map();
238
+ /** room + recipient → the wake-anyway timer for its held reports. */
239
+ holdTimers = new Map();
240
+ /**
241
+ * room id → member ids whose FIRST start is in flight: the member has no
242
+ * child session id in the journal yet, but its process may already be
243
+ * asking the host for its mode tools (claude lists MCP tools at spawn).
244
+ */
245
+ freshStarts = new Map();
246
+ /** Members of this room whose first start is in flight (see {@link freshStarts}). */
247
+ startingMembers(roomId) {
248
+ return [...this.freshStarts.get(roomId) ?? []];
249
+ }
134
250
  /**
135
251
  * @param ctx - host context carrying the session store and agents registry.
136
252
  */
@@ -165,7 +281,12 @@ export class DispatchEngine {
165
281
  return;
166
282
  this.scheduled.add(scopedId);
167
283
  }
168
- const tail = this.queues.get(key) ?? Promise.resolve();
284
+ // A report to the room's own agent never waits behind that agent's
285
+ // running round: it joins the round (runMainAgent steers it in) — queued,
286
+ // it would wake a second turn once the first had already answered.
287
+ const source = options.dispatchSeq === undefined ? undefined : events.find(event => event.seq === options.dispatchSeq);
288
+ const joins = member.kind === 'main-agent' && source?.type === 'room/dispatch' && source.data.origin === 'report';
289
+ const tail = joins ? Promise.resolve() : this.queues.get(key) ?? Promise.resolve();
169
290
  let submitted;
170
291
  const submission = new Promise(resolve => { submitted = resolve; });
171
292
  const next = tail.then(() => this.run(room, memberName, text, { ...options, targetId }, submitted))
@@ -177,6 +298,8 @@ export class DispatchEngine {
177
298
  this.scheduled.delete(`${room.id}:${deliveryId}`);
178
299
  });
179
300
  this.inFlight.set(next, key);
301
+ if (joins)
302
+ return;
180
303
  this.queues.set(key, submission);
181
304
  void submission.then(() => { if (this.queues.get(key) === submission)
182
305
  this.queues.delete(key); });
@@ -214,7 +337,7 @@ export class DispatchEngine {
214
337
  const events = room.snapshotEvents();
215
338
  const state = replay(events);
216
339
  for (const dispatch of events) {
217
- if (dispatch.type !== 'room/dispatch' || dispatch.data.targetIds === undefined)
340
+ if (dispatch.type !== 'room/dispatch' || dispatch.data.targetIds === undefined || dispatch.data.held === true)
218
341
  continue;
219
342
  for (const targetId of dispatch.data.targetIds) {
220
343
  const id = `${dispatch.seq}:${targetId}`;
@@ -233,14 +356,19 @@ export class DispatchEngine {
233
356
  const target = state.members.find(member => memberId(events, member) === targetId);
234
357
  if (target !== undefined) {
235
358
  const run = state.runs.find(run => run.member === target.name);
236
- if (run?.state === 'running' && (run.runId === undefined || run.runId === id))
237
- room.append('room/run-state', { member: target.name, state: 'failed', startedAt: run.startedAt, ...run.runId === undefined ? {} : { runId: run.runId }, error: 'Execution outcome is unknown after restart; reconcile before retrying' });
359
+ if (run?.state === 'running' && (run.runId === undefined || run.runId === id)) {
360
+ const childSessionId = runChildSessionId(room.id, target);
361
+ room.append('room/run-state', { member: target.name, state: 'failed', startedAt: run.startedAt,
362
+ ...run.runId === undefined ? {} : { runId: run.runId },
363
+ ...childSessionId === undefined ? {} : { childSessionId },
364
+ error: 'Execution outcome is unknown after restart; reconcile before retrying' });
365
+ }
238
366
  }
239
367
  await this.ctx.sessions.flush(room);
240
- await this.report(room, dispatch.seq, id, targetId, 'uncertain', 'Execution outcome is unknown after restart; reconcile before retrying');
368
+ await this.reportOutcome(room, dispatch.seq, id, targetId, 'uncertain', 'Execution outcome is unknown after restart; reconcile before retrying');
241
369
  }
242
370
  else {
243
- await this.report(room, dispatch.seq, id, targetId, last.data.state, last.data.text ?? last.data.error ?? '');
371
+ await this.reportOutcome(room, dispatch.seq, id, targetId, last.data.state, last.data.text ?? last.data.error ?? '');
244
372
  }
245
373
  continue;
246
374
  }
@@ -249,6 +377,13 @@ export class DispatchEngine {
249
377
  this.dispatch(room, target.name, dispatch.data.text, { dispatchSeq: dispatch.seq, targetId });
250
378
  }
251
379
  }
380
+ // recover() runs on every live access; only holds whose wake timer this
381
+ // process does not own (a restart lost it) are handed over now.
382
+ for (const member of state.members) {
383
+ const id = memberId(events, member);
384
+ if (!this.holdTimers.has(this.holdKey(room.id, id)) && pendingHolds(events, id).length > 0)
385
+ await this.releaseHeld(room, id, 'restart');
386
+ }
252
387
  }
253
388
  /** Execute one dispatch: run-state running → member turn → settle edges. */
254
389
  async run(room, memberName, text, options, submitted) {
@@ -265,6 +400,10 @@ export class DispatchEngine {
265
400
  memberName = member.name;
266
401
  const identity = memberId(room.snapshotEvents(), member);
267
402
  const deliveryId = options.dispatchSeq === undefined ? undefined : `${options.dispatchSeq}:${identity}`;
403
+ // Settled while it waited in the queue (a removal cancels queued work;
404
+ // an undo must not resurrect it).
405
+ if (deliveryId !== undefined && state.deliveries?.some(delivery => delivery.id === deliveryId && delivery.status !== 'queued'))
406
+ return;
268
407
  const runId = deliveryId ?? randomUUID();
269
408
  const cursor = previousCursor(room.snapshotEvents(), memberName, options.dispatchSeq ?? Number.MAX_SAFE_INTEGER);
270
409
  let startedAt;
@@ -274,9 +413,13 @@ export class DispatchEngine {
274
413
  startedAt = Date.now();
275
414
  if (deliveryId !== undefined)
276
415
  room.append('room/delivery-state', { id: deliveryId, dispatchSeq: options.dispatchSeq, memberId: identity, state: 'running' });
277
- memberName = replay(room.snapshotEvents()).members.find(entry => memberId(room.snapshotEvents(), entry) === identity)?.name ?? memberName;
416
+ // Re-read the roster: the captured member snapshot predates the child
417
+ // handle's publication (member-updated lands between admission and settle).
418
+ const current = replay(room.snapshotEvents()).members.find(entry => memberId(room.snapshotEvents(), entry) === identity);
419
+ memberName = current?.name ?? memberName;
420
+ const childSessionId = runChildSessionId(room.id, current ?? member);
278
421
  room.append('room/run-state', { member: memberName, state: 'running', startedAt, runId,
279
- ...member.kind === 'main-agent' ? { childSessionId: room.id } : member.childSessionId === undefined ? {} : { childSessionId: member.childSessionId },
422
+ ...childSessionId === undefined ? {} : { childSessionId },
280
423
  });
281
424
  await this.hooks.admitted?.(room, options.dispatchSeq);
282
425
  await this.ctx.sessions.flush(room);
@@ -285,8 +428,10 @@ export class DispatchEngine {
285
428
  let outcome;
286
429
  try {
287
430
  outcome = member.kind === 'main-agent'
288
- ? await this.runMainAgent(room, member, cursor, text, await admitted(), options.relayIds ?? [])
289
- : await this.runCliMember(room, member, cursor, text, admitted, options.relayIds ?? [], submitted, runId);
431
+ ? await this.runMainAgent(room, member, cursor, text, await admitted(), options.relayIds ?? [], options.dispatchSeq)
432
+ : member.kind === 'native'
433
+ ? await this.runNativeMember(room, member, cursor, text, admitted, options.relayIds ?? [], options.dispatchSeq, runId)
434
+ : await this.runCliMember(room, member, cursor, text, admitted, options.relayIds ?? [], submitted, runId, options.dispatchSeq);
290
435
  }
291
436
  catch (error) {
292
437
  this.ctx.logger.warn(`room: dispatch to "${memberName}" failed: ${String(error)}`);
@@ -298,30 +443,102 @@ export class DispatchEngine {
298
443
  room.append('room/delivery-state', { id: deliveryId, dispatchSeq: options.dispatchSeq, memberId: identity, state: outcome.state,
299
444
  ...outcome.text === undefined ? {} : { text: outcome.text }, ...outcome.error === undefined ? {} : { error: outcome.error } });
300
445
  await this.ctx.sessions.flush(room);
301
- await this.report(room, options.dispatchSeq, deliveryId, identity, outcome.state, outcome.text ?? outcome.error ?? '');
446
+ await this.reportOutcome(room, options.dispatchSeq, deliveryId, identity, outcome.state, outcome.text ?? outcome.error ?? '');
302
447
  }
303
448
  }
304
- async report(room, dispatchSeq, deliveryId, fromId, outcome, text) {
449
+ /**
450
+ * Report one delivery's outcome to whoever is waiting on it (the
451
+ * coordinator for plan work, else the dispatch's `replyTo`), at most once
452
+ * per delivery. Public so a removal can settle the waits it breaks.
453
+ */
454
+ /** Report-gate decisions per recipient, one at a time (see reportOutcome). */
455
+ gateQueues = new Map();
456
+ /**
457
+ * Gate one arrived report for the member waiting on it. Decisions for the
458
+ * same recipient run one at a time: a decision-model judgement takes a
459
+ * second or two, and a later report deciding meanwhile (the 「最后一个结果」
460
+ * rule) would hand over its batch without the one still being judged —
461
+ * which then got held behind a round that had already answered.
462
+ */
463
+ async reportOutcome(room, dispatchSeq, deliveryId, fromId, outcome, text) {
464
+ const source = room.snapshotEvents().find(event => event.seq === dispatchSeq);
465
+ const key = `${room.id} ${source?.type === 'room/dispatch' ? source.data.replyTo ?? '' : ''}`;
466
+ const next = (this.gateQueues.get(key) ?? Promise.resolve())
467
+ .catch(() => { })
468
+ .then(() => this.gateReport(room, dispatchSeq, deliveryId, fromId, outcome, text));
469
+ this.gateQueues.set(key, next);
470
+ void next.then(() => { if (this.gateQueues.get(key) === next)
471
+ this.gateQueues.delete(key); }, () => { if (this.gateQueues.get(key) === next)
472
+ this.gateQueues.delete(key); });
473
+ return next;
474
+ }
475
+ async gateReport(room, dispatchSeq, deliveryId, fromId, outcome, text) {
305
476
  const events = room.snapshotEvents();
306
477
  const source = events.find(event => event.seq === dispatchSeq);
307
- if (source?.type !== 'room/dispatch' || source.data.origin !== 'coordinator' || source.data.replyTo === undefined)
478
+ if (source?.type !== 'room/dispatch' || (source.data.origin !== 'coordinator' && source.data.origin !== 'member') || source.data.replyTo === undefined)
308
479
  return;
309
480
  if (events.some(event => event.type === 'room/dispatch' && event.data.reportFor === deliveryId))
310
481
  return;
482
+ if (events.some(event => event.type === 'room/relay' && event.data.intent?.reportFor === deliveryId))
483
+ return;
311
484
  const state = replay(events);
312
485
  if (closedGoal(state, source.data.plan?.goalId))
313
486
  return;
314
487
  const recipient = source.data.plan === undefined ? state.members.find(member => memberId(events, member) === source.data.replyTo) : coordinatorMember(state, events);
315
- const sender = state.members.find(member => memberId(events, member) === fromId);
316
488
  if (recipient === undefined)
317
489
  return;
318
- const reportText = `Delegation ${deliveryId} (${sender?.name ?? fromId}) ${outcome}.\n${text.slice(0, 32000)}`;
319
- const report = room.append('room/dispatch', { id: randomUUID(), origin: 'report', reportFor: deliveryId,
320
- targets: [recipient.name], targetIds: [memberId(events, recipient)], text: reportText,
321
- ...source.data.plan === undefined ? {} : { plan: { goalId: source.data.plan.goalId } } });
490
+ // A removed sender is no longer on the roster: read its last known name.
491
+ const senderName = state.members.find(member => memberId(events, member) === fromId)?.name
492
+ ?? events.reduce((name, event) => event.type === 'room/member-added' && event.data.id === fromId ? event.data.name : name, undefined);
493
+ // The envelope (`【#m12 的回报 · 来自 ada · 完成】`) carries who and how;
494
+ // the text is the member's own conclusion.
495
+ const reportText = text.trim() === '' ? `(${outcome === 'done' ? '没有文字输出' : outcome})` : text.slice(0, 32000);
496
+ const sourceTask = (state.deliveries ?? []).find(row => row.id === deliveryId)?.task;
497
+ const intent = { origin: 'report', hop: (source.data.hop ?? 1) + 1, reportFor: deliveryId,
498
+ ...sourceTask === undefined ? {} : { task: sourceTask },
499
+ ...source.data.plan === undefined ? {} : { plan: { goalId: source.data.plan.goalId } } };
500
+ // The gate decides WHEN the recipient gets it; plan work keeps its own cadence.
501
+ const recipientId = memberId(events, recipient);
502
+ const deliveries = state.deliveries ?? [];
503
+ const verdict = source.data.plan !== undefined
504
+ ? { hold: false, reason: 'plan' }
505
+ : await judgeReport(this.ctx, {
506
+ timing: state.settings?.reportTiming ?? 'judge',
507
+ threshold: state.settings?.reportThreshold ?? 0.5,
508
+ outcome,
509
+ outstanding: outstandingFor(deliveries, recipientId, deliveryId).length,
510
+ state: () => gateState(room.snapshotEvents(), recipient, [
511
+ ...deliveries.filter(row => row.memberId === recipientId && row.origin === 'report' && pendingHolds(events, recipientId).includes(row.dispatchSeq))
512
+ .map(row => ({ from: '', outcome: '', text: row.text })),
513
+ { from: senderName ?? fromId, outcome, text: reportText },
514
+ ], Date.now()),
515
+ });
516
+ // The judgement awaited: a concurrent path may have reported meanwhile.
517
+ if (room.snapshotEvents().some(event => event.type === 'room/dispatch' && event.data.reportFor === deliveryId))
518
+ return;
519
+ // Every timing decision the room weighed is recorded (the timeline row,
520
+ // Context Lens); the each-setting and plan cadence decide nothing.
521
+ if (verdict.reason !== 'setting' && verdict.reason !== 'plan') {
522
+ room.append('room/decision', decisionRecord('report-timing', {
523
+ outcome: verdict.hold ? 'later' : 'now',
524
+ ...verdict.reason === 'model' ? {} : { rule: verdict.reason },
525
+ ...verdict.probability === undefined ? {} : { probability: verdict.probability },
526
+ ...verdict.call === undefined ? {} : { call: verdict.call },
527
+ about: { from: senderName ?? fromId, to: recipient.name },
528
+ }));
529
+ }
530
+ if (verdict.hold) {
531
+ await this.holdReport(room, recipient, reportText, intent, verdict);
532
+ return;
533
+ }
534
+ const sent = sendOrHold(room, { to: recipient, text: reportText, fromName: senderName ?? fromId, intent });
535
+ if (sent.delivered) {
536
+ room.append('room/report-gate', { dispatchSeq: sent.seq, memberId: recipientId, member: recipient.name, verdict: 'deliver', reason: verdict.reason,
537
+ ...verdict.probability === undefined ? {} : { probability: verdict.probability } });
538
+ }
322
539
  await this.ctx.sessions.flush(room);
323
- if (this.hooks.allows?.(room, report.seq) !== false)
324
- this.dispatch(room, recipient.name, reportText, { dispatchSeq: report.seq, targetId: memberId(events, recipient) });
540
+ if (sent.delivered && this.hooks.allows?.(room, sent.seq) !== false)
541
+ this.dispatch(room, recipient.name, reportText, { dispatchSeq: sent.seq, targetId: memberId(events, recipient) });
325
542
  }
326
543
  /**
327
544
  * Mark the relays this run delivered as sent. Delivery = the member's own
@@ -329,6 +546,85 @@ export class DispatchEngine {
329
546
  * started): the text lives in the member's private context from then on,
330
547
  * so a later run failure cannot un-deliver it.
331
548
  */
549
+ /**
550
+ * A round of this member started: every report held for it is now in its
551
+ * context — carried by this prompt (`seqs`) or already in its inbox — so
552
+ * close those holds and drop the member's wake timer.
553
+ */
554
+ async markHeldReleased(room, member, seqs) {
555
+ const events = room.snapshotEvents();
556
+ const id = memberId(events, member);
557
+ for (const seq of seqs)
558
+ room.append('room/delivery-state', { id: `${seq}:${id}`, dispatchSeq: seq, memberId: id, state: 'done', text: 'carried' });
559
+ const open = pendingHolds(events, id);
560
+ for (const seq of new Set([...seqs, ...open]))
561
+ room.append('room/report-gate', { dispatchSeq: seq, memberId: id, member: member.name, verdict: 'release', reason: 'carried' });
562
+ this.clearHoldTimer(room.id, id);
563
+ if (seqs.length > 0 || open.length > 0)
564
+ await this.ctx.sessions.flush(room);
565
+ }
566
+ holdKey(roomId, recipientId) { return `${roomId} ${recipientId}`; }
567
+ clearHoldTimer(roomId, recipientId) {
568
+ const key = this.holdKey(roomId, recipientId);
569
+ const timer = this.holdTimers.get(key);
570
+ if (timer !== undefined)
571
+ clearTimeout(timer);
572
+ this.holdTimers.delete(key);
573
+ }
574
+ /**
575
+ * Keep a report for later: journal it as a held dispatch and arm the
576
+ * wake-anyway timer. It is NOT pushed into an agent inbox: the host runs
577
+ * one turn per queued inbox message, so held reports would each wake their
578
+ * own turn. They ride the recipient's next round instead — the room's own
579
+ * prompt, or (a human waking a native agent directly) the turn's first step
580
+ * through {@link heldReportsMessage}.
581
+ */
582
+ async holdReport(room, recipient, text, intent, verdict) {
583
+ const events = room.snapshotEvents();
584
+ const recipientId = memberId(events, recipient);
585
+ const dispatch = room.append('room/dispatch', {
586
+ id: randomUUID(), origin: 'report', targets: [recipient.name], targetIds: [recipientId], text, held: true,
587
+ hop: intent.hop, ...intent.task === undefined ? {} : { task: intent.task }, ...intent.reportFor === undefined ? {} : { reportFor: intent.reportFor },
588
+ ...intent.plan === undefined ? {} : { plan: intent.plan },
589
+ });
590
+ room.append('room/report-gate', { dispatchSeq: dispatch.seq, memberId: recipientId, member: recipient.name, verdict: 'hold', reason: verdict.reason,
591
+ ...verdict.probability === undefined ? {} : { probability: verdict.probability } });
592
+ // Armed before the flush yields: a concurrent recover() must see this
593
+ // process owns the hold.
594
+ const key = this.holdKey(room.id, recipientId);
595
+ if (!this.holdTimers.has(key)) {
596
+ const timer = setTimeout(() => {
597
+ this.holdTimers.delete(key);
598
+ void this.releaseHeld(room, recipientId, 'timeout').catch(error => this.ctx.logger.warn(`room: held report release failed: ${String(error)}`));
599
+ }, (replay(events).settings?.reportWaitMinutes ?? 3) * 60_000);
600
+ timer.unref?.();
601
+ this.holdTimers.set(key, timer);
602
+ }
603
+ await this.ctx.sessions.flush(room);
604
+ }
605
+ /**
606
+ * Wake a member for the reports held for it (the hold timeout, a human's
607
+ * 「现在交给」, or a restart). One short release round: an inbox already
608
+ * holds the reports; a CLI member gets them carried by the round's prompt.
609
+ * A no-op when nothing is held any more.
610
+ * @returns whether a release round was dispatched.
611
+ */
612
+ async releaseHeld(room, recipientId, reason) {
613
+ this.clearHoldTimer(room.id, recipientId);
614
+ const events = room.snapshotEvents();
615
+ const open = pendingHolds(events, recipientId);
616
+ const recipient = replay(events).members.find(member => memberId(events, member) === recipientId);
617
+ if (open.length === 0 || recipient === undefined)
618
+ return false;
619
+ const ids = open.map(seq => `#${messageId(seq)}`).join('、');
620
+ const text = `(以上 ${open.length} 条回报一直等着交给你:${ids}${reason === 'timeout' ? ',已经等了一段时间' : ''}。用户也已在房间里看到,不要复述。)`;
621
+ const releaseTask = replay(events).deliveries?.find(row => row.dispatchSeq === open[0] && row.memberId === recipientId)?.task;
622
+ const release = room.append('room/dispatch', { id: randomUUID(), origin: 'report', targets: [recipient.name], targetIds: [recipientId], text, releases: open, hop: 1,
623
+ ...releaseTask === undefined ? {} : { task: releaseTask } });
624
+ await this.ctx.sessions.flush(room);
625
+ this.dispatch(room, recipient.name, text, { dispatchSeq: release.seq, targetId: recipientId });
626
+ return true;
627
+ }
332
628
  async markSent(room, relays) {
333
629
  if (relays.length === 0)
334
630
  return;
@@ -337,29 +633,104 @@ export class DispatchEngine {
337
633
  await this.ctx.sessions.flush(room);
338
634
  }
339
635
  /** Main-agent member turn: a producer-sourced followup on the room's own agent. */
340
- async runMainAgent(room, member, cursor, text, _startedAt, relayIds) {
636
+ async runMainAgent(room, member, cursor, text, _startedAt, relayIds, dispatchSeq) {
341
637
  const agent = this.ctx.agents.get(room.id);
342
638
  if (agent === undefined) {
343
639
  // Fail loud: the room's own agent is not live, the message goes nowhere.
344
640
  return { state: 'failed', error: 'the room session has no live agent' };
345
641
  }
346
- const { prompt, carried } = assemblePrompt(room, member, cursor, text, relayIds);
347
- agent.followup(createUserMessage({
348
- content: [{ type: 'text', text: prompt }],
349
- source: roomSource(),
350
- }));
642
+ const { prompt, carried, heldCarried } = assemblePrompt(room, member, cursor, text, relayIds, dispatchSeq);
643
+ const before = room.snapshotEvents().at(-1)?.seq ?? -1;
644
+ const message = createUserMessage({ content: [{ type: 'text', text: prompt }], source: roomSource() });
645
+ // A report reaching an agent mid-turn joins that turn at its next step:
646
+ // as its own turn it would make the agent answer a second time, after
647
+ // the turn that was already waiting for it.
648
+ const source = dispatchSeq === undefined ? undefined : room.snapshotEvents().find(event => event.seq === dispatchSeq);
649
+ const report = source?.type === 'room/dispatch' && source.data.origin === 'report';
650
+ const steerable = agent;
651
+ if (report && steerable.status === 'running' && typeof steerable.steer === 'function')
652
+ steerable.steer(message);
653
+ else
654
+ agent.followup(message);
655
+ await this.markHeldReleased(room, member, heldCarried);
351
656
  await this.markSent(room, [
352
657
  ...carried,
353
658
  ...replay(room.snapshotEvents()).relays.filter(relay => relayIds.includes(relay.id)),
354
659
  ]);
355
- // Phase-1 simplicity: the followup's own turn converging to idle IS the
356
- // run; the main agent's reply is its ordinary assistant speech (no
357
- // room/speech projection — speech mirrors CLI members only).
660
+ // The followup's own turn converging to idle IS the run; the main agent's
661
+ // reply is its ordinary assistant speech (no room/speech projection —
662
+ // speech mirrors CLI members only). Its last assistant text is the
663
+ // outcome a waiting sender's report quotes.
358
664
  await agent.whenIdle();
359
- return { state: 'done' };
665
+ const output = lastAssistantText(room.snapshotEvents(), before);
666
+ return { state: 'done', ...output === '' ? {} : { text: output } };
667
+ }
668
+ /**
669
+ * The native member's agent: live, resumed from its session after a
670
+ * restart, or created on its first round — a child session + agent in this
671
+ * host process (owned by the room's agent when live), composing the
672
+ * member's preset at creation. The handle is journaled before any prompt.
673
+ */
674
+ async nativeAgent(room, member) {
675
+ if (member.childSessionId !== undefined) {
676
+ const live = this.ctx.agents.get(member.childSessionId);
677
+ if (live !== undefined)
678
+ return live;
679
+ const composition = await composeRoomAgent(this.ctx, member.preset);
680
+ const handle = await this.ctx.agents.resume({
681
+ resumeSessionId: member.childSessionId,
682
+ ...composition.setup === undefined ? {} : { setup: composition.setup },
683
+ });
684
+ return handle.agent;
685
+ }
686
+ const composition = await composeRoomAgent(this.ctx, member.preset);
687
+ const parent = this.ctx.agents.get(room.id);
688
+ const sessionId = SessionId(randomUUID());
689
+ const cwd = member.cwd ?? room.header.cwd;
690
+ const handle = await this.ctx.agents.create({
691
+ sessionId,
692
+ ...parent === undefined ? {} : { parentAgent: parent },
693
+ meta: {
694
+ parentSession: room.id, origin: 'subagent', delegationDepth: (room.header.delegationDepth ?? 0) + 1,
695
+ ...cwd === undefined ? {} : { cwd },
696
+ ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
697
+ },
698
+ ...composition.setup === undefined ? {} : { setup: composition.setup },
699
+ });
700
+ room.append('room/member-updated', { name: replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name, childSessionId: sessionId });
701
+ await this.ctx.sessions.flush(room);
702
+ return handle.agent;
703
+ }
704
+ /**
705
+ * Native member turn: a room-sourced followup on the member's own agent,
706
+ * mirrored into the room as speech like a CLI member's reply.
707
+ */
708
+ async runNativeMember(room, member, cursor, text, admitted, relayIds, dispatchSeq, runId) {
709
+ const agent = await this.nativeAgent(room, member);
710
+ const startedAt = await admitted();
711
+ const { prompt, carried, heldCarried } = assemblePrompt(room, member, cursor, text, relayIds, dispatchSeq);
712
+ const before = agent.session.snapshotEvents().at(-1)?.seq ?? -1;
713
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: roomSource() }));
714
+ await this.markHeldReleased(room, member, heldCarried);
715
+ await this.markSent(room, [
716
+ ...carried,
717
+ ...replay(room.snapshotEvents()).relays.filter(relay => relayIds.includes(relay.id)),
718
+ ]);
719
+ await agent.whenIdle();
720
+ const output = lastAssistantText(agent.session.snapshotEvents(), before);
721
+ const reportedTo = dispatchSeq === undefined ? undefined : reportRecipient(room.snapshotEvents(), dispatchSeq)?.name;
722
+ room.append('room/speech', {
723
+ member: replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name,
724
+ text: output, childSessionId: agent.session.id, durationMs: Date.now() - startedAt,
725
+ ...runId === undefined ? {} : { runId },
726
+ ...lastAssistantMessageId(agent.session, before) === undefined ? {} : { messageId: lastAssistantMessageId(agent.session, before) },
727
+ ...reportedTo === undefined ? {} : { reportedTo },
728
+ });
729
+ await this.ctx.sessions.flush(room);
730
+ return { state: 'done', ...output === '' ? {} : { text: output } };
360
731
  }
361
732
  /** CLI member turn: facade start (first round) or resume, then the speech mirror. */
362
- async runCliMember(room, member, cursor, text, admitted, relayIds, submitted, runId) {
733
+ async runCliMember(room, member, cursor, text, admitted, relayIds, submitted, runId, dispatchSeq) {
363
734
  const facade = probeLocalAgent(this.ctx);
364
735
  if (facade === undefined) {
365
736
  this.ctx.logger.warn(`room: cannot dispatch to "${member.name}": local-agent facade unavailable`);
@@ -379,32 +750,59 @@ export class DispatchEngine {
379
750
  const previous = room.snapshotEvents().filter(row => row.type === 'room/run-state' && row.data.runId === runId).at(-1);
380
751
  if (previous?.type !== 'room/run-state')
381
752
  return;
382
- const total = event.usage?.totalTokens ?? (event.usage === undefined ? undefined : event.usage.inputTokens + event.usage.outputTokens);
753
+ // TokenUsage counts are disjoint: the billed total is the sum of the
754
+ // buckets, cache reads and writes included. A provider's own
755
+ // totalTokens may leave the cache out (claude reports input+output),
756
+ // so the buckets win whenever they are there.
757
+ const total = event.usage === undefined ? undefined
758
+ : event.usage.inputTokens + (event.usage.cacheReadTokens ?? 0) + (event.usage.cacheWriteTokens ?? 0) + event.usage.outputTokens;
383
759
  room.append('room/execution-metadata', { runId,
384
760
  ...event.observedModel === undefined ? {} : { model: event.observedModel },
385
761
  ...event.observedEffort === undefined ? {} : { effort: event.observedEffort },
386
762
  ...total === undefined || !Number.isFinite(total) || total < 0 ? {} : { tokens: total },
763
+ ...event.usage === undefined ? {} : { usage: {
764
+ inputTokens: event.usage.inputTokens, outputTokens: event.usage.outputTokens,
765
+ ...event.usage.cacheReadTokens === undefined ? {} : { cacheReadTokens: event.usage.cacheReadTokens },
766
+ ...event.usage.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: event.usage.cacheWriteTokens },
767
+ ...event.usage.reasoningTokens === undefined ? {} : { reasoningTokens: event.usage.reasoningTokens },
768
+ } },
387
769
  });
388
770
  void this.ctx.sessions.flush(room).catch(error => this.ctx.logger.warn(`room: execution metadata flush failed: ${String(error)}`));
389
771
  },
390
772
  };
391
773
  if (!controlled)
392
774
  startedAt = await admitted();
393
- const { prompt, carried } = assemblePrompt(room, member, cursor, text, relayIds);
775
+ const { prompt, carried, heldCarried } = assemblePrompt(room, member, cursor, text, relayIds, dispatchSeq);
394
776
  const prepared = member.childSessionId !== undefined && facade.isPreparedMember?.(member.childSessionId) === true;
395
777
  const pendingRun = prepared
396
- ? facade.start(room.id, provider, [{ type: 'text', text: prompt }], { preparedMemberId: member.childSessionId, ...admission })
778
+ ? facade.start(room.id, provider, [{ type: 'text', text: prompt }], { preparedMemberId: member.childSessionId, label: member.name, ...admission })
397
779
  : member.childSessionId === undefined
398
780
  // The invite-time model lands as the delegation's own model: the facade
399
781
  // records it with the first start and every later resume re-requests it
400
- // (providers bind it at spawn for exec and live alike).
401
- ? facade.start(room.id, provider, [{ type: 'text', text: prompt }], { ...admission, ...member.model === undefined ? {} : { model: member.model }, ...member.cwd === undefined ? {} : { cwd: member.cwd } })
782
+ // (providers bind it at spawn for exec and live alike). The invite-time
783
+ // effort rides the same channel.
784
+ ? facade.start(room.id, provider, [{ type: 'text', text: prompt }],
785
+ // The member's name labels its child session in the host's subagent list.
786
+ { ...admission, label: member.name, ...member.model === undefined ? {} : { model: member.model }, ...member.effort === undefined ? {} : { effort: member.effort }, ...member.cwd === undefined ? {} : { cwd: member.cwd } })
402
787
  : facade.resume(room.id, provider, member.childSessionId, [{ type: 'text', text: prompt }], { ...admission, ...member.cwd === undefined ? {} : { cwd: member.cwd } });
403
788
  // Existing members can enter core's queue immediately. A fresh/prepared
404
789
  // identity must publish its first handle before the next submission resumes it.
405
790
  if (controlled && member.childSessionId !== undefined && !prepared)
406
791
  submitted();
407
- const run = await pendingRun;
792
+ const fresh = !prepared && member.childSessionId === undefined;
793
+ const freshId = memberId(room.snapshotEvents(), member);
794
+ if (fresh) {
795
+ const starting = this.freshStarts.get(room.id) ?? new Set();
796
+ this.freshStarts.set(room.id, starting.add(freshId));
797
+ }
798
+ const run = await pendingRun.finally(() => {
799
+ if (!fresh)
800
+ return;
801
+ const starting = this.freshStarts.get(room.id);
802
+ starting?.delete(freshId);
803
+ if (starting?.size === 0)
804
+ this.freshStarts.delete(room.id);
805
+ });
408
806
  if (startedAt === undefined) {
409
807
  await run.dispose();
410
808
  throw new Error('Provider published a room run without admission');
@@ -418,6 +816,7 @@ export class DispatchEngine {
418
816
  ...carried,
419
817
  ...replay(room.snapshotEvents()).relays.filter(relay => relayIds.includes(relay.id)),
420
818
  ]);
819
+ await this.markHeldReleased(room, member, heldCarried);
421
820
  await this.ctx.sessions.flush(room);
422
821
  }
423
822
  catch (error) {
@@ -432,32 +831,22 @@ export class DispatchEngine {
432
831
  const output = runOutputText(result.output);
433
832
  const interrupted = result.stopReason === 'completed' ? undefined
434
833
  : result.stopReason === 'aborted' ? 'cancelled' : 'failed';
834
+ const reportedTo = dispatchSeq === undefined ? undefined : reportRecipient(room.snapshotEvents(), dispatchSeq)?.name;
835
+ // The member's own closing message: per-message tools address it in its session.
836
+ const closingMessageId = lastAssistantMessageId(this.ctx.sessions.get(run.id));
435
837
  const speech = output !== '' || interrupted === undefined ? room.append('room/speech', {
436
838
  member: replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name,
437
839
  text: output,
438
840
  childSessionId: run.id,
841
+ runId,
842
+ ...closingMessageId === undefined ? {} : { messageId: closingMessageId },
439
843
  durationMs: Date.now() - startedAt,
440
844
  ...interrupted === undefined ? {} : { interrupted },
845
+ ...reportedTo === undefined ? {} : { reportedTo },
441
846
  }) : undefined;
442
847
  if (speech !== undefined)
443
848
  await this.ctx.sessions.flush(room);
444
849
  if (result.stopReason === 'completed') {
445
- // The fallback notification channel: a reply whose trailing own line is
446
- // `@name <content>` (the roster-taught format) becomes a pending relay
447
- // at the gate — the same shape the family bridge's member_message
448
- // produces, so downstream handling is identical.
449
- const directive = parseRelayDirective(runOutputText(result.output));
450
- const roster = replay(room.snapshotEvents()).members;
451
- if (directive !== undefined && directive.to !== member.name
452
- && roster.some(entry => entry.name === directive.to)) {
453
- room.append('room/relay', {
454
- id: randomUUID(),
455
- from: member.name,
456
- to: directive.to,
457
- content: directive.content,
458
- provenance: { kind: 'speech-fallback', speechSeq: speech.seq },
459
- });
460
- }
461
850
  await this.ctx.sessions.flush(room);
462
851
  return { state: 'done', text: runOutputText(result.output) };
463
852
  }
@@ -468,23 +857,23 @@ export class DispatchEngine {
468
857
  return { state: 'failed', text: output, error: `the member run ended with stopReason "${result.stopReason}"` };
469
858
  }
470
859
  }
471
- /** Settle only this execution and its own task rows, preserving newer runs. */
860
+ /** Settle only this execution, preserving newer runs. */
472
861
  async settle(room, member, runId, startedAt, outcome) {
473
862
  const events = room.snapshotEvents();
474
- const memberName = replay(events).members.find(entry => memberId(events, entry) === memberId(events, member))?.name ?? member.name;
863
+ const currentMember = replay(events).members.find(entry => memberId(events, entry) === memberId(events, member));
864
+ const memberName = currentMember?.name ?? member.name;
475
865
  const terminal = events.find(event => event.type === 'room/run-state' && event.data.runId === runId && event.data.state !== 'running');
476
866
  if (terminal?.type === 'room/run-state' && terminal.data.state !== 'running') {
477
867
  return { ...outcome, state: terminal.data.state, ...terminal.data.error === undefined ? {} : { error: terminal.data.error } };
478
868
  }
869
+ const childSessionId = runChildSessionId(room.id, currentMember ?? member);
870
+ const childTurn = runChildTurn(childSessionId === undefined ? undefined : this.ctx.sessions.get(childSessionId));
479
871
  room.append('room/run-state', {
480
872
  member: memberName, runId, state: outcome.state, startedAt, elapsedMs: Date.now() - startedAt,
873
+ ...childSessionId === undefined ? {} : { childSessionId },
874
+ ...childTurn === undefined ? {} : { childTurn },
481
875
  ...outcome.error === undefined ? {} : { error: outcome.error },
482
876
  });
483
- for (const task of replay(room.snapshotEvents()).tasks) {
484
- if (task.member === memberName && task.status === 'in_progress' && task.deliveryId === runId) {
485
- room.append('room/task-updated', { id: task.id, status: outcome.state });
486
- }
487
- }
488
877
  await this.ctx.sessions.flush(room);
489
878
  return outcome;
490
879
  }