@khorsheed/dsh-room 0.2.3 → 0.2.5

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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/lib/client.js +2309 -1127
  3. package/lib/index.js +572 -36
  4. package/lib/typert.host.js +249 -65
  5. package/lib/typert.remote-client.d.ts +4 -1
  6. package/lib/typert.remote-client.js +208 -53
  7. package/lib/types/client/InviteDialog.js +11 -4
  8. package/lib/types/client/RoomAttachments.d.ts +20 -0
  9. package/lib/types/client/RoomAttachments.js +36 -0
  10. package/lib/types/client/RoomComposer.d.ts +6 -12
  11. package/lib/types/client/RoomComposer.js +116 -12
  12. package/lib/types/client/RoomQuestionView.d.ts +10 -2
  13. package/lib/types/client/RoomQuestionView.js +18 -5
  14. package/lib/types/client/RoomRunView.d.ts +1 -1
  15. package/lib/types/client/RoomRunView.js +15 -5
  16. package/lib/types/client/RoomSpeechView.d.ts +24 -1
  17. package/lib/types/client/RoomSpeechView.js +39 -3
  18. package/lib/types/client/RoomStatsLine.d.ts +3 -1
  19. package/lib/types/client/RoomStatsLine.js +3 -1
  20. package/lib/types/client/RoomTaskCapsule.d.ts +6 -1
  21. package/lib/types/client/RoomTaskCapsule.js +2 -2
  22. package/lib/types/client/TasksView.js +1 -1
  23. package/lib/types/client/TeamView.js +1 -1
  24. package/lib/types/client/icons.d.ts +2 -1
  25. package/lib/types/client/icons.js +1 -0
  26. package/lib/types/client/index.js +94 -15
  27. package/lib/types/client/locales.d.ts +44 -0
  28. package/lib/types/client/locales.js +44 -0
  29. package/lib/types/client/nodes.d.ts +22 -3
  30. package/lib/types/client/nodes.js +28 -5
  31. package/lib/types/client/path-mentions.d.ts +34 -0
  32. package/lib/types/client/path-mentions.js +39 -0
  33. package/lib/types/client/slots.d.ts +51 -1
  34. package/lib/types/dispatch.d.ts +60 -0
  35. package/lib/types/dispatch.js +136 -1
  36. package/lib/types/index.d.ts +69 -1
  37. package/lib/types/index.js +368 -20
  38. package/lib/types/jobs.js +7 -3
  39. package/lib/types/journal.d.ts +1 -1
  40. package/lib/types/journal.js +18 -2
  41. package/lib/types/types.d.ts +77 -0
  42. package/package.json +10 -6
  43. package/lib/journal-B4FOGO6S.js +0 -482
  44. package/lib/journal-BWL3-i0s.js +0 -462
  45. package/lib/journal-Rid9AAnD.js +0 -485
@@ -11,7 +11,7 @@
11
11
  * RoomService.postMessage) and renders through the official user node.
12
12
  * @module @khorsheed/dsh-room/client/nodes
13
13
  */
14
- import type { RoomDecisionEvent, RoomHelp, RoomQuestionAnswer, RoomQuestionItem } from '../types.ts';
14
+ import type { RoomAsk, RoomDecisionEvent, RoomHelp, RoomQuestionAnswer, RoomQuestionItem } from '../types.ts';
15
15
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
16
16
  import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client';
17
17
  /** Chat node data of one member speech row. */
@@ -26,6 +26,11 @@ export interface RoomSpeechData {
26
26
  readonly time: number;
27
27
  readonly member: string;
28
28
  readonly text: string;
29
+ /** Questions the round asked, at their offsets in `text` (see RoomSpeechEvent.asks). */
30
+ readonly asks?: readonly {
31
+ readonly id: string;
32
+ readonly at: number;
33
+ }[];
29
34
  /** The member's child session (jump target), when recorded. */
30
35
  readonly childSessionId?: SessionId;
31
36
  /** Dispatch→settle milliseconds, when measured. */
@@ -37,7 +42,7 @@ export interface RoomSpeechData {
37
42
  }
38
43
  /** Chat node data of one member run row. */
39
44
  export interface RoomRunData {
40
- /** Seq of the running edge's event. */
45
+ /** Seq the card anchors at: the running edge's event, or its latest move (`room/run-moved`). */
41
46
  readonly seq: number;
42
47
  /** Unix epoch ms from the running edge's event. */
43
48
  readonly time: number;
@@ -112,7 +117,20 @@ export interface RoomQuestionData {
112
117
  readonly questions: readonly RoomQuestionItem[];
113
118
  readonly state: 'pending' | 'answered' | 'cancelled';
114
119
  readonly answers?: readonly RoomQuestionAnswer[];
120
+ /** A consent gate: never answerable once it expired. */
121
+ readonly consent?: true;
122
+ /** Answered after it expired (the answers went to the member as a message). */
123
+ readonly late?: true;
124
+ /** The asker's round: the question renders inside that round (its card, then its reply), not as a row of its own. */
125
+ readonly runId?: string;
115
126
  }
127
+ /**
128
+ * The card data of a question from the room's ask log, for the inline
129
+ * renderings (a round's live card and its reply).
130
+ * @param ask - the logged question.
131
+ * @returns the card data.
132
+ */
133
+ export declare function askCardData(ask: RoomAsk): RoomQuestionData;
116
134
  export interface RoomRelayData {
117
135
  /** Seq of the relay's latest journal event. */
118
136
  readonly seq: number;
@@ -170,7 +188,8 @@ export declare const roomSpeechDefinition: ConversationNodeDefinition<RoomSpeech
170
188
  /**
171
189
  * The member-run Definition: one Context per run (matched on
172
190
  * member+startedAt), started by the running edge and updated by its terminal
173
- * edge. A running run renders the live row; a failed run stays as a dim
191
+ * edge. A `room/run-moved` edge re-anchors a running card (its seq, which the
192
+ * view orders by) below a reply or human message that landed after it. A running run renders the live row; a failed run stays as a dim
174
193
  * error row; done/cancelled hide the row IN PLACE — the assembler's live
175
194
  * incremental flush forbids withdrawing a materialized node with null (the
176
195
  * withdrawal contract is the same key with `visibility: 'hidden'`; a null
@@ -1,4 +1,19 @@
1
1
  import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
2
+ /**
3
+ * The card data of a question from the room's ask log, for the inline
4
+ * renderings (a round's live card and its reply).
5
+ * @param ask - the logged question.
6
+ * @returns the card data.
7
+ */
8
+ export function askCardData(ask) {
9
+ return {
10
+ seq: 0, time: ask.time, questionId: ask.id, member: ask.member, questions: ask.questions, state: ask.state,
11
+ ...ask.answers === undefined ? {} : { answers: ask.answers },
12
+ ...ask.consent === true ? { consent: true } : {},
13
+ ...ask.late === true ? { late: true } : {},
14
+ ...ask.runId === undefined ? {} : { runId: ask.runId },
15
+ };
16
+ }
2
17
  /** The shared final-node materialization (single shape across the three kinds). */
3
18
  function viewNode(context, kind, visibility = 'visible') {
4
19
  if (context.state === undefined)
@@ -33,6 +48,7 @@ export const roomSpeechDefinition = {
33
48
  time: event.time,
34
49
  member: event.data.member,
35
50
  text: event.data.text,
51
+ ...event.data.asks === undefined ? {} : { asks: event.data.asks },
36
52
  ...event.data.interrupted === undefined ? {} : { interrupted: event.data.interrupted },
37
53
  ...event.data.reportedTo === undefined ? {} : { reportedTo: event.data.reportedTo },
38
54
  ...event.data.childSessionId === undefined ? {} : { childSessionId: event.data.childSessionId },
@@ -47,7 +63,8 @@ export const roomSpeechDefinition = {
47
63
  /**
48
64
  * The member-run Definition: one Context per run (matched on
49
65
  * member+startedAt), started by the running edge and updated by its terminal
50
- * edge. A running run renders the live row; a failed run stays as a dim
66
+ * edge. A `room/run-moved` edge re-anchors a running card (its seq, which the
67
+ * view orders by) below a reply or human message that landed after it. A running run renders the live row; a failed run stays as a dim
51
68
  * error row; done/cancelled hide the row IN PLACE — the assembler's live
52
69
  * incremental flush forbids withdrawing a materialized node with null (the
53
70
  * withdrawal contract is the same key with `visibility: 'hidden'`; a null
@@ -62,7 +79,9 @@ export const roomRunDefinition = {
62
79
  id: `${event.data.member}:${event.data.startedAt}`,
63
80
  role: event.data.state === 'running' ? 'start' : 'update',
64
81
  }
65
- : null,
82
+ : event.type === 'room/run-moved'
83
+ ? { id: `${event.data.member}:${event.data.startedAt}`, role: 'update' }
84
+ : null,
66
85
  start: (_context, match) => {
67
86
  const event = match.event;
68
87
  if (event.type !== 'room/run-state')
@@ -77,6 +96,9 @@ export const roomRunDefinition = {
77
96
  },
78
97
  update: (context, match) => {
79
98
  const event = match.event;
99
+ // A move re-anchors a running card below what landed after it (the view orders by seq).
100
+ if (event.type === 'room/run-moved')
101
+ return context.state.state === 'running' ? { ...context.state, seq: event.seq } : context.state;
80
102
  if (event.type !== 'room/run-state')
81
103
  return context.state;
82
104
  const { error: _error, elapsedMs: _elapsed, ...previous } = context.state;
@@ -201,15 +223,16 @@ export const roomQuestionDefinition = {
201
223
  const event = match.event;
202
224
  if (event.type !== 'room/question')
203
225
  throw new Error('room-question start requires a room/question event');
204
- return { seq: event.seq, time: event.time, questionId: event.data.id, member: event.data.member, questions: event.data.questions, state: 'pending' };
226
+ return { seq: event.seq, time: event.time, questionId: event.data.id, member: event.data.member, questions: event.data.questions, state: 'pending', ...event.data.consent === true ? { consent: true } : {}, ...event.data.runId === undefined ? {} : { runId: event.data.runId } };
205
227
  },
206
228
  update: (context, match) => {
207
229
  const event = match.event;
208
230
  if (event.type !== 'room/question-resolved')
209
231
  return context.state;
210
- return { ...context.state, state: event.data.outcome, ...event.data.answers === undefined ? {} : { answers: event.data.answers } };
232
+ return { ...context.state, state: event.data.outcome, ...event.data.answers === undefined ? {} : { answers: event.data.answers }, ...event.data.late === true ? { late: true } : {} };
211
233
  },
212
- buildViewNode: context => viewNode(context, 'room-question'),
234
+ // A question placed in a round renders inside it (RoomRunView, RoomSpeechView): its own row hides.
235
+ buildViewNode: context => viewNode(context, 'room-question', context.state?.runId === undefined ? 'visible' : 'hidden'),
213
236
  };
214
237
  /**
215
238
  * The relay Definition: one Context per relay (matched on the relay id),
@@ -0,0 +1,34 @@
1
+ /**
2
+ * File paths a member writes in inline code (`game-2/design/art.png`) become
3
+ * openers, the way the main conversation links the files a turn produced.
4
+ * The host's own mention vocabulary (ui-deliverables) only knows files a
5
+ * host turn touched; a CLI member's writes happen in its own process, so the
6
+ * room recognizes the path's shape instead: no whitespace, no URL, a common
7
+ * file extension. A token that names no real file opens the viewer's
8
+ * "missing" state — never a broken link.
9
+ * @module @khorsheed/dsh-room/client/path-mentions
10
+ */
11
+ import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives';
12
+ /**
13
+ * Whether an inline-code token reads as a file path.
14
+ * @param value - the token as authored.
15
+ * @returns true for a path-shaped token with a known file extension.
16
+ */
17
+ export declare function looksLikeFilePath(value: string): boolean;
18
+ /**
19
+ * The absolute path a member's token names: as written when absolute, under
20
+ * the member's working directory when it has its own, else left relative (the
21
+ * file address then resolves it against the room session's workspace).
22
+ * @param value - the token.
23
+ * @param cwd - the member's own working directory, when it has one.
24
+ * @returns the path to open.
25
+ */
26
+ export declare function memberPath(value: string, cwd: string | undefined): string;
27
+ /**
28
+ * The file-mention vocabulary for one member's text.
29
+ * @param open - opens one resolved path in the sidebar.
30
+ * @param cwd - the member's own working directory, when it has one.
31
+ * @returns the MarkdownText fileMentions value.
32
+ */
33
+ export declare function pathMentions(open: (path: string) => void, cwd: string | undefined): MarkdownFileMentions;
34
+ //# sourceMappingURL=path-mentions.d.ts.map
@@ -0,0 +1,39 @@
1
+ /** Extensions a member's inline code most often names as a deliverable or source file. */
2
+ const FILE_EXTENSION = /\.(?:png|jpe?g|gif|webp|svg|md|txt|json|ya?ml|toml|csv|html?|css|[cm]?[jt]sx?|py|sh|glb|gltf|blend|fbx|obj|mp3|wav|ogg|mp4|webm|pdf)$/i;
3
+ /**
4
+ * Whether an inline-code token reads as a file path.
5
+ * @param value - the token as authored.
6
+ * @returns true for a path-shaped token with a known file extension.
7
+ */
8
+ export function looksLikeFilePath(value) {
9
+ return value.length <= 300 && !/\s/.test(value) && !value.includes('://') && !value.startsWith('-') && FILE_EXTENSION.test(value);
10
+ }
11
+ /**
12
+ * The absolute path a member's token names: as written when absolute, under
13
+ * the member's working directory when it has its own, else left relative (the
14
+ * file address then resolves it against the room session's workspace).
15
+ * @param value - the token.
16
+ * @param cwd - the member's own working directory, when it has one.
17
+ * @returns the path to open.
18
+ */
19
+ export function memberPath(value, cwd) {
20
+ if (value.startsWith('/') || value.startsWith('~') || cwd === undefined)
21
+ return value;
22
+ return `${cwd.replace(/\/+$/, '')}/${value.replace(/^\.\//, '')}`;
23
+ }
24
+ /**
25
+ * The file-mention vocabulary for one member's text.
26
+ * @param open - opens one resolved path in the sidebar.
27
+ * @param cwd - the member's own working directory, when it has one.
28
+ * @returns the MarkdownText fileMentions value.
29
+ */
30
+ export function pathMentions(open, cwd) {
31
+ return {
32
+ resolve: (value) => {
33
+ if (!looksLikeFilePath(value))
34
+ return undefined;
35
+ const path = memberPath(value, cwd);
36
+ return { open: () => { open(path); }, label: value, title: path };
37
+ },
38
+ };
39
+ }
@@ -160,7 +160,49 @@ export interface RoomModelDirectory {
160
160
  * own main agent (bare messages ARE its turns); undefined on a host without
161
161
  * ui-model-selection — the picker simply does not render.
162
162
  */
163
+ /** One unsent composer attachment (the official conversation service's draft). */
164
+ export type RoomDraftAttachment = {
165
+ readonly kind: 'image';
166
+ readonly id: string;
167
+ readonly file: File;
168
+ readonly previewUrl: string;
169
+ } | {
170
+ readonly kind: 'file';
171
+ readonly id: string;
172
+ readonly file: File;
173
+ };
174
+ /** A file draft's upload state (files upload on pick; images ride the send). */
175
+ export type RoomDraftUpload = {
176
+ readonly status: 'uploading';
177
+ readonly loaded: number;
178
+ readonly total?: number;
179
+ } | {
180
+ readonly status: 'ready';
181
+ } | {
182
+ readonly status: 'error';
183
+ readonly message: string;
184
+ };
185
+ /**
186
+ * The official conversation service's draft-attachment face, re-used so the
187
+ * room's attachments pick, upload, and serialize exactly like the official
188
+ * composer's. Absent on a host without it: the composer offers no attachments.
189
+ */
190
+ export interface RoomAttachmentsFace {
191
+ /** Register picked/pasted/dropped files as drafts of this session (files start uploading). */
192
+ readonly create: (sessionId: SessionId, files: readonly File[]) => readonly RoomDraftAttachment[];
193
+ readonly resolve: (ids: readonly string[]) => readonly RoomDraftAttachment[];
194
+ readonly release: (id: string) => void;
195
+ readonly retry: (sessionId: SessionId, id: string) => void;
196
+ readonly uploads: {
197
+ subscribe(listener: () => void): () => void;
198
+ getSnapshot(): Readonly<Record<string, RoomDraftUpload>>;
199
+ };
200
+ /** The wire form postMessage takes (images encoded, files as their upload receipts). */
201
+ readonly serialize: (ids: readonly string[]) => Promise<readonly import('../types.ts').RoomAttachmentInput[]>;
202
+ }
163
203
  export interface RoomComposerInjected extends RoomGoalInjected, RoomInviteInjected {
204
+ /** Draft attachments; absent without the official conversation service. */
205
+ readonly attachments?: RoomAttachmentsFace | undefined;
164
206
  /** Open the task page (at one task); absent without the right sidebar. */
165
207
  readonly openTasks?: ((task?: string) => void) | undefined;
166
208
  /** The composer's task tag store (see task-tag.ts). */
@@ -203,7 +245,7 @@ export interface RoomComposerInjected extends RoomGoalInjected, RoomInviteInject
203
245
  * `task` is the composer's tag (`new` or a task root id): it files the
204
246
  * message under that task instead of letting dsh decide.
205
247
  */
206
- readonly submit: (sessionId: SessionId, text: string, targets?: readonly string[], task?: string) => Promise<RoomMutationOutcome>;
248
+ readonly submit: (sessionId: SessionId, text: string, targets?: readonly string[], task?: string, attachments?: readonly import('../types.ts').RoomAttachmentInput[]) => Promise<RoomMutationOutcome>;
207
249
  /** Interrupt the room's own main-agent turn (the hidden official bar's Stop). */
208
250
  readonly stop: () => void;
209
251
  /**
@@ -323,18 +365,26 @@ export interface RoomSpeechInjected {
323
365
  * absent when the composition has no Context Lens.
324
366
  */
325
367
  readonly openDetail?: ((sessionId: SessionId, messageId: string) => void) | undefined;
368
+ /** Open a file path a member named in its text (sidebar file viewer); absent without the sidebar. */
369
+ readonly openPath?: ((sessionId: SessionId, path: string) => void) | undefined;
370
+ /** Answer a question asked in this round (an expired one answers late). */
371
+ readonly answerQuestion: RoomQuestionInjected['answerQuestion'];
326
372
  }
327
373
  /** Full props of the 'room-speech' chat-node renderer. */
328
374
  export type RoomSpeechViewProps = PropsRuntime<'conversation.chat.node', 'room-speech'> & InjectFace<RoomSpeechInjected> & PropsLocale<'room'>;
329
375
  /** Injected face of the member-run chat node. */
330
376
  export interface RoomRunInjected {
331
377
  readonly renderMemberOutput?: LocalAgentUi['renderMemberOutput'] | undefined;
378
+ /** Open a file path the member named while it works (sidebar file viewer). */
379
+ readonly openPath?: ((sessionId: SessionId, path: string) => void) | undefined;
332
380
  /** The client-side room state store (the roster carries the jump target). */
333
381
  readonly roomStore: RoomStore;
334
382
  /** Open a session (the whole-row jump into the member's child session). */
335
383
  readonly openSession: (sessionId: SessionId) => void;
336
384
  /** Cancel the named member's in-flight run through the Remote. */
337
385
  readonly cancelMember: (member: string) => Promise<void>;
386
+ /** Answer the question the round is waiting on, right in its live card. */
387
+ readonly answerQuestion: RoomQuestionInjected['answerQuestion'];
338
388
  }
339
389
  /** Full props of the 'room-run' chat-node renderer. */
340
390
  export type RoomRunViewProps = PropsRuntime<'conversation.chat.node', 'room-run'> & InjectFace<RoomRunInjected> & PropsLocale<'room'>;
@@ -29,6 +29,56 @@ export declare function roomSource(): MessageSource;
29
29
  * member's early running edge predates the child handle's publication (the
30
30
  * terminal edge and the journal backfill cover it).
31
31
  */
32
+ /**
33
+ * Keep the running members' cards at the bottom of the room's conversation:
34
+ * after a reply or a human message lands, each member still working gets a
35
+ * `room/run-moved` edge — its card re-anchors below what just landed (a
36
+ * typing-indicator seat), so a member that started first but is still going
37
+ * never reads as if it answered before the later replies. Several running
38
+ * members keep their start order.
39
+ * @param room - the room session.
40
+ * @param except - the member whose own reply just landed (its card ends there).
41
+ */
42
+ /** How much of a round's text a question keeps to find its place in the reply later. */
43
+ export declare const BEFORE_TAIL = 160;
44
+ /**
45
+ * The text a member's current round has said so far in its own session: the
46
+ * assistant text blocks since the latest turn start, in order.
47
+ * @param events - the member's child session log.
48
+ * @returns the round's text so far.
49
+ */
50
+ export declare function roundTextSoFar(events: readonly SessionEvent[]): string;
51
+ /**
52
+ * Where each question a round asked sits in its final reply: just after the
53
+ * text the round had said when it asked (its recorded tail, shortened until
54
+ * found). Asked before saying anything → 0; tail not found → -1 (shown
55
+ * first, marked as asked mid-round). Offsets never go backwards.
56
+ * @param events - the room log.
57
+ * @param runId - the round.
58
+ * @param text - the round's reply.
59
+ * @returns the question ids with their offsets.
60
+ */
61
+ export declare function askOffsets(events: readonly SessionEvent[], runId: string, text: string): {
62
+ id: string;
63
+ at: number;
64
+ }[];
65
+ /** Why a human message's move-on closes a restart-lost round (index.ts moveOnFromLostRounds). */
66
+ export declare const MOVED_ON_TEXT = "\u4F60\u53D1\u4E86\u65B0\u6D88\u606F\uFF1B\u88AB\u91CD\u542F\u6253\u65AD\u7684\u8FD9\u4E00\u8F6E\u6309\u6CA1\u505A\u5B8C\u5904\u7406";
67
+ /** A message that queued behind a lost round and rode along with the move-on message. */
68
+ export declare const MERGED_BACKLOG_TEXT = "\u5DF2\u5E76\u5165\u4E0B\u4E00\u6761\u6D88\u606F\u4E00\u8D77\u9001\u8FBE";
69
+ /** A message that queued behind a lost round an earlier move-on skipped: closed, not replayed. */
70
+ export declare const SUPERSEDED_BACKLOG_TEXT = "\u88AB\u91CD\u542F\u6253\u65AD\u524D\u6392\u961F\uFF0C\u5DF2\u88AB\u540E\u7EED\u6D88\u606F\u53D6\u4EE3";
71
+ /**
72
+ * Whether a queued delivery sat behind a restart-lost round of the same
73
+ * member that a human message has since moved on from (its dispatch lies
74
+ * between the lost round and the move-on edge).
75
+ * @param events - the room journal.
76
+ * @param targetId - the member id.
77
+ * @param dispatchSeq - the queued delivery's dispatch seq.
78
+ * @returns whether the move-on superseded it.
79
+ */
80
+ export declare function supersededByMoveOn(events: readonly SessionEvent[], targetId: string, dispatchSeq: number): boolean;
81
+ export declare function floatRunningCards(room: Session, except?: string): void;
32
82
  export declare function runChildSessionId(roomId: SessionId, member: RoomMember | undefined): SessionId | undefined;
33
83
  /**
34
84
  * The child session round a run reached: the last turn/start the child wrote.
@@ -138,7 +188,17 @@ export declare class DispatchEngine {
138
188
  * coordinator for plan work, else the dispatch's `replyTo`), at most once
139
189
  * per delivery. Public so a removal can settle the waits it breaks.
140
190
  */
191
+ /** Report-gate decisions per recipient, one at a time (see reportOutcome). */
192
+ private readonly gateQueues;
193
+ /**
194
+ * Gate one arrived report for the member waiting on it. Decisions for the
195
+ * same recipient run one at a time: a decision-model judgement takes a
196
+ * second or two, and a later report deciding meanwhile (the 「最后一个结果」
197
+ * rule) would hand over its batch without the one still being judged —
198
+ * which then got held behind a round that had already answered.
199
+ */
141
200
  reportOutcome(room: Session, dispatchSeq: number, deliveryId: string, fromId: string, outcome: string, text: string): Promise<void>;
201
+ private gateReport;
142
202
  /**
143
203
  * Mark the relays this run delivered as sent. Delivery = the member's own
144
204
  * session received the prompt (the followup accepted / the CLI run
@@ -49,6 +49,106 @@ export function roomSource() {
49
49
  * member's early running edge predates the child handle's publication (the
50
50
  * terminal edge and the journal backfill cover it).
51
51
  */
52
+ /**
53
+ * Keep the running members' cards at the bottom of the room's conversation:
54
+ * after a reply or a human message lands, each member still working gets a
55
+ * `room/run-moved` edge — its card re-anchors below what just landed (a
56
+ * typing-indicator seat), so a member that started first but is still going
57
+ * never reads as if it answered before the later replies. Several running
58
+ * members keep their start order.
59
+ * @param room - the room session.
60
+ * @param except - the member whose own reply just landed (its card ends there).
61
+ */
62
+ /** How much of a round's text a question keeps to find its place in the reply later. */
63
+ export const BEFORE_TAIL = 160;
64
+ /**
65
+ * The text a member's current round has said so far in its own session: the
66
+ * assistant text blocks since the latest turn start, in order.
67
+ * @param events - the member's child session log.
68
+ * @returns the round's text so far.
69
+ */
70
+ export function roundTextSoFar(events) {
71
+ let start = -1;
72
+ for (let index = events.length - 1; index >= 0; index -= 1) {
73
+ if (events[index].type === 'turn/start') {
74
+ start = index;
75
+ break;
76
+ }
77
+ }
78
+ const parts = [];
79
+ for (const event of events.slice(start + 1)) {
80
+ if (event.type !== 'assistant/message')
81
+ continue;
82
+ const content = event.data.message?.content ?? [];
83
+ for (const block of content)
84
+ if (block.type === 'text' && typeof block.text === 'string')
85
+ parts.push(block.text);
86
+ }
87
+ return parts.join('');
88
+ }
89
+ /**
90
+ * Where each question a round asked sits in its final reply: just after the
91
+ * text the round had said when it asked (its recorded tail, shortened until
92
+ * found). Asked before saying anything → 0; tail not found → -1 (shown
93
+ * first, marked as asked mid-round). Offsets never go backwards.
94
+ * @param events - the room log.
95
+ * @param runId - the round.
96
+ * @param text - the round's reply.
97
+ * @returns the question ids with their offsets.
98
+ */
99
+ export function askOffsets(events, runId, text) {
100
+ const out = [];
101
+ let floor = 0;
102
+ for (const event of events) {
103
+ if (event.type !== 'room/question' || event.data.runId !== runId)
104
+ continue;
105
+ const tail = (event.data.before ?? '').trimEnd();
106
+ // Nothing said before asking: the question opens the reply.
107
+ let at = tail === '' ? 0 : -1;
108
+ for (const size of [80, 40, 20]) {
109
+ if (tail === '')
110
+ break;
111
+ const probe = tail.slice(-size);
112
+ if (probe.length < Math.min(size, 8))
113
+ break;
114
+ const found = text.indexOf(probe, floor);
115
+ if (found >= 0) {
116
+ at = found + probe.length;
117
+ break;
118
+ }
119
+ }
120
+ if (at > 0)
121
+ floor = at;
122
+ out.push({ id: event.data.id, at });
123
+ }
124
+ return out;
125
+ }
126
+ /** Why a human message's move-on closes a restart-lost round (index.ts moveOnFromLostRounds). */
127
+ export const MOVED_ON_TEXT = '你发了新消息;被重启打断的这一轮按没做完处理';
128
+ /** A message that queued behind a lost round and rode along with the move-on message. */
129
+ export const MERGED_BACKLOG_TEXT = '已并入下一条消息一起送达';
130
+ /** A message that queued behind a lost round an earlier move-on skipped: closed, not replayed. */
131
+ export const SUPERSEDED_BACKLOG_TEXT = '被重启打断前排队,已被后续消息取代';
132
+ /**
133
+ * Whether a queued delivery sat behind a restart-lost round of the same
134
+ * member that a human message has since moved on from (its dispatch lies
135
+ * between the lost round and the move-on edge).
136
+ * @param events - the room journal.
137
+ * @param targetId - the member id.
138
+ * @param dispatchSeq - the queued delivery's dispatch seq.
139
+ * @returns whether the move-on superseded it.
140
+ */
141
+ export function supersededByMoveOn(events, targetId, dispatchSeq) {
142
+ return events.some(event => event.type === 'room/delivery-state' && event.data.memberId === targetId && event.data.state === 'cancelled'
143
+ && event.data.text === MOVED_ON_TEXT && event.data.dispatchSeq < dispatchSeq && dispatchSeq < event.seq);
144
+ }
145
+ export function floatRunningCards(room, except) {
146
+ const running = replay(room.snapshotEvents()).runs
147
+ .filter(run => run.state === 'running' && run.member !== except)
148
+ .sort((left, right) => left.startedAt - right.startedAt);
149
+ for (const run of running)
150
+ room.append('room/run-moved', { member: run.member, startedAt: run.startedAt });
151
+ }
52
152
  export function runChildSessionId(roomId, member) {
53
153
  if (member?.kind === 'main-agent')
54
154
  return roomId;
@@ -372,6 +472,15 @@ export class DispatchEngine {
372
472
  }
373
473
  continue;
374
474
  }
475
+ // Queued behind a restart-lost round the human has since moved on
476
+ // from: that move-on carries its backlog along; a row it left behind
477
+ // (a move-on predating the hand-over) closes as superseded instead of
478
+ // replaying a stale round on this access.
479
+ if (supersededByMoveOn(events, targetId, dispatch.seq)) {
480
+ room.append('room/delivery-state', { id, dispatchSeq: dispatch.seq, memberId: targetId, state: 'cancelled', text: SUPERSEDED_BACKLOG_TEXT });
481
+ await this.ctx.sessions.flush(room);
482
+ continue;
483
+ }
375
484
  const target = state.members.find(member => memberId(events, member) === targetId);
376
485
  if (target !== undefined && !state.deliveries?.some(row => row.memberId === targetId && (row.status === 'running' || row.status === 'uncertain')))
377
486
  this.dispatch(room, target.name, dispatch.data.text, { dispatchSeq: dispatch.seq, targetId });
@@ -451,7 +560,28 @@ export class DispatchEngine {
451
560
  * coordinator for plan work, else the dispatch's `replyTo`), at most once
452
561
  * per delivery. Public so a removal can settle the waits it breaks.
453
562
  */
563
+ /** Report-gate decisions per recipient, one at a time (see reportOutcome). */
564
+ gateQueues = new Map();
565
+ /**
566
+ * Gate one arrived report for the member waiting on it. Decisions for the
567
+ * same recipient run one at a time: a decision-model judgement takes a
568
+ * second or two, and a later report deciding meanwhile (the 「最后一个结果」
569
+ * rule) would hand over its batch without the one still being judged —
570
+ * which then got held behind a round that had already answered.
571
+ */
454
572
  async reportOutcome(room, dispatchSeq, deliveryId, fromId, outcome, text) {
573
+ const source = room.snapshotEvents().find(event => event.seq === dispatchSeq);
574
+ const key = `${room.id} ${source?.type === 'room/dispatch' ? source.data.replyTo ?? '' : ''}`;
575
+ const next = (this.gateQueues.get(key) ?? Promise.resolve())
576
+ .catch(() => { })
577
+ .then(() => this.gateReport(room, dispatchSeq, deliveryId, fromId, outcome, text));
578
+ this.gateQueues.set(key, next);
579
+ void next.then(() => { if (this.gateQueues.get(key) === next)
580
+ this.gateQueues.delete(key); }, () => { if (this.gateQueues.get(key) === next)
581
+ this.gateQueues.delete(key); });
582
+ return next;
583
+ }
584
+ async gateReport(room, dispatchSeq, deliveryId, fromId, outcome, text) {
455
585
  const events = room.snapshotEvents();
456
586
  const source = events.find(event => event.seq === dispatchSeq);
457
587
  if (source?.type !== 'room/dispatch' || (source.data.origin !== 'coordinator' && source.data.origin !== 'member') || source.data.replyTo === undefined)
@@ -705,6 +835,7 @@ export class DispatchEngine {
705
835
  ...lastAssistantMessageId(agent.session, before) === undefined ? {} : { messageId: lastAssistantMessageId(agent.session, before) },
706
836
  ...reportedTo === undefined ? {} : { reportedTo },
707
837
  });
838
+ floatRunningCards(room, replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name);
708
839
  await this.ctx.sessions.flush(room);
709
840
  return { state: 'done', ...output === '' ? {} : { text: output } };
710
841
  }
@@ -813,9 +944,11 @@ export class DispatchEngine {
813
944
  const reportedTo = dispatchSeq === undefined ? undefined : reportRecipient(room.snapshotEvents(), dispatchSeq)?.name;
814
945
  // The member's own closing message: per-message tools address it in its session.
815
946
  const closingMessageId = lastAssistantMessageId(this.ctx.sessions.get(run.id));
947
+ const asks = askOffsets(room.snapshotEvents(), runId, output);
816
948
  const speech = output !== '' || interrupted === undefined ? room.append('room/speech', {
817
949
  member: replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name,
818
950
  text: output,
951
+ ...asks.length === 0 ? {} : { asks },
819
952
  childSessionId: run.id,
820
953
  runId,
821
954
  ...closingMessageId === undefined ? {} : { messageId: closingMessageId },
@@ -823,8 +956,10 @@ export class DispatchEngine {
823
956
  ...interrupted === undefined ? {} : { interrupted },
824
957
  ...reportedTo === undefined ? {} : { reportedTo },
825
958
  }) : undefined;
826
- if (speech !== undefined)
959
+ if (speech !== undefined) {
960
+ floatRunningCards(room, speech.data.member);
827
961
  await this.ctx.sessions.flush(room);
962
+ }
828
963
  if (result.stopReason === 'completed') {
829
964
  await this.ctx.sessions.flush(room);
830
965
  return { state: 'done', text: runOutputText(result.output) };