@cjhyy/code-shell-web 0.9.29 → 0.9.30

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.
@@ -0,0 +1,37 @@
1
+ import type { MobileClientEvent, MobileServerEvent } from "@cjhyy/code-shell-core";
2
+ import { recoverMobileOutput } from "../lib/mobileOutputRecovery.js";
3
+ type Result = NonNullable<Awaited<ReturnType<typeof recoverMobileOutput>>>;
4
+ /** One bounded recovery request per selected conversation, tied to this authenticated socket. */
5
+ export declare class MobileOutputClient {
6
+ private readonly deps;
7
+ private enabled;
8
+ private auth;
9
+ private sequence;
10
+ private selection?;
11
+ constructor(deps: {
12
+ send: (event: MobileClientEvent) => boolean;
13
+ current: () => {
14
+ sessionId?: string;
15
+ roomId?: string;
16
+ revision: number;
17
+ };
18
+ commit: (sessionId: string, result: Result) => void;
19
+ legacy: (sessionId: string) => void;
20
+ failed: (sessionId: string) => void;
21
+ awaitingJournal?: (sessionId: string) => void;
22
+ });
23
+ cancel(notify?: boolean): void;
24
+ begin(sessionId: string): boolean;
25
+ private current;
26
+ owns(sessionId: string): boolean;
27
+ rejectUnsequenced(sessionId: string): boolean;
28
+ private fail;
29
+ observe(event: MobileServerEvent): boolean;
30
+ private recover;
31
+ /** Live output is withheld until it is covered by the frozen candidate. */
32
+ hold(sessionId: string, raw: unknown): false | "pending" | "covered";
33
+ applied(sessionId: string, raw: unknown): void;
34
+ /** A coalesced committed head requests pages; it never invents visible output. */
35
+ advanceSnapshot(sessionId: string, outputCursor: string | undefined): void;
36
+ }
37
+ export {};
@@ -0,0 +1,351 @@
1
+ import { recoverMobileOutput, MobilePendingInput, MobileOutputCatchupPending, MobileAwaitingJournal, } from "../lib/mobileOutputRecovery.js";
2
+ import { compareOutputCursors } from "../lib/outputJournalRecovery.js";
3
+ /** One bounded recovery request per selected conversation, tied to this authenticated socket. */
4
+ export class MobileOutputClient {
5
+ deps;
6
+ enabled = false;
7
+ auth = 0;
8
+ sequence = 0;
9
+ selection;
10
+ constructor(deps) {
11
+ this.deps = deps;
12
+ }
13
+ cancel(notify = true) {
14
+ const old = this.selection;
15
+ this.selection = undefined;
16
+ if (old?.readyTimer)
17
+ clearTimeout(old.readyTimer);
18
+ if (old?.capacityTimer)
19
+ clearTimeout(old.capacityTimer);
20
+ if (old?.waiter) {
21
+ clearTimeout(old.waiter.timer);
22
+ old.waiter.reject(new Error("Recovery cancelled"));
23
+ }
24
+ if (notify && old)
25
+ this.deps.send({ type: "session.recovery.cancel" });
26
+ }
27
+ begin(sessionId) {
28
+ if (!this.enabled)
29
+ return false;
30
+ this.cancel();
31
+ const selection = {
32
+ id: `recovery-${this.auth}-${++this.sequence}`,
33
+ sessionId,
34
+ revision: this.deps.current().revision,
35
+ auth: this.auth,
36
+ recovering: true,
37
+ failed: false,
38
+ };
39
+ this.selection = selection;
40
+ selection.readyTimer = setTimeout(() => this.fail(selection), 10_000);
41
+ if (!this.deps.send({ type: "session.select", sessionId, recoveryId: selection.id }))
42
+ this.fail(selection);
43
+ return true;
44
+ }
45
+ current(selection) {
46
+ const current = this.deps.current();
47
+ return (this.selection === selection &&
48
+ selection.auth === this.auth &&
49
+ current.sessionId === selection.sessionId &&
50
+ !current.roomId &&
51
+ current.revision === selection.revision);
52
+ }
53
+ owns(sessionId) {
54
+ return this.selection?.sessionId === sessionId;
55
+ }
56
+ rejectUnsequenced(sessionId) {
57
+ const selection = this.selection;
58
+ if (!selection || selection.sessionId !== sessionId)
59
+ return false;
60
+ this.fail(selection);
61
+ return true;
62
+ }
63
+ fail(selection) {
64
+ if (!this.current(selection))
65
+ return;
66
+ if (selection.readyTimer)
67
+ clearTimeout(selection.readyTimer);
68
+ if (selection.capacityTimer)
69
+ clearTimeout(selection.capacityTimer);
70
+ selection.capacityTimer = undefined;
71
+ selection.capacityPending = false;
72
+ selection.readyTimer = undefined;
73
+ selection.recovering = false;
74
+ selection.failed = true;
75
+ this.deps.failed(selection.sessionId);
76
+ }
77
+ observe(event) {
78
+ if (event.type === "auth.ok" || event.type === "pair.ok") {
79
+ this.cancel(false);
80
+ this.auth++;
81
+ this.enabled = event.capabilities?.outputJournal === 1;
82
+ return false;
83
+ }
84
+ if (event.type !== "session.recovery.ready" && event.type !== "session.outputJournal")
85
+ return false;
86
+ const selection = this.selection;
87
+ if (!selection ||
88
+ !this.current(selection) ||
89
+ event.sessionId !== selection.sessionId ||
90
+ event.recoveryId !== selection.id)
91
+ return true;
92
+ if (event.type === "session.recovery.ready") {
93
+ // A later Main revocation also invalidates an already joined live stream.
94
+ if (!event.ok) {
95
+ this.fail(selection);
96
+ return true;
97
+ }
98
+ if (selection.started || selection.failed)
99
+ return true;
100
+ selection.started = true;
101
+ selection.journalRequired = event.journalRequired === true;
102
+ if (selection.readyTimer)
103
+ clearTimeout(selection.readyTimer);
104
+ selection.readyTimer = undefined;
105
+ if (!event.ok)
106
+ this.fail(selection);
107
+ else
108
+ void this.recover(selection);
109
+ }
110
+ else if (selection.waiter?.id === event.requestId) {
111
+ const waiter = selection.waiter;
112
+ selection.waiter = undefined;
113
+ clearTimeout(waiter.timer);
114
+ waiter.resolve(event);
115
+ }
116
+ return true;
117
+ }
118
+ async recover(selection, resume) {
119
+ try {
120
+ const result = await (resume
121
+ ? resume()
122
+ : recoverMobileOutput({
123
+ requireJournal: selection.journalRequired,
124
+ canContinue: () => this.current(selection) && !selection.failed,
125
+ latestCursor: () => selection.latest,
126
+ read: (options) => new Promise((resolve, reject) => {
127
+ if (!this.current(selection) || selection.waiter)
128
+ return reject(new Error("Recovery cancelled"));
129
+ const id = `page-${this.auth}-${++this.sequence}`;
130
+ const timer = setTimeout(() => {
131
+ selection.waiter = undefined;
132
+ reject(new Error("Recovery timed out"));
133
+ }, 10_000);
134
+ selection.waiter = { id, resolve, reject, timer };
135
+ if (!this.deps.send({
136
+ type: "session.outputJournal",
137
+ sessionId: selection.sessionId,
138
+ recoveryId: selection.id,
139
+ requestId: id,
140
+ after: options.after,
141
+ through: options.through,
142
+ })) {
143
+ clearTimeout(timer);
144
+ selection.waiter = undefined;
145
+ reject(new Error("Recovery disconnected"));
146
+ }
147
+ }),
148
+ }));
149
+ if (!this.current(selection))
150
+ return;
151
+ if (!result) {
152
+ this.selection = undefined;
153
+ this.deps.send({ type: "session.recovery.cancel" });
154
+ this.deps.legacy(selection.sessionId);
155
+ return;
156
+ }
157
+ if (selection.pendingInputIds &&
158
+ [...selection.pendingInputIds].some((id) => !result.chat.items.some((item) => item.kind === "user" && item.clientMessageId === id))) {
159
+ // An input can arrive after Main captured this reply. Keep the older
160
+ // display until its matching recorded boundary starts a fresh join.
161
+ if (!selection.readyTimer)
162
+ selection.readyTimer = setTimeout(() => this.fail(selection), 10_000);
163
+ return;
164
+ }
165
+ selection.recovering = false;
166
+ selection.capacityPending = false;
167
+ if (selection.readyTimer)
168
+ clearTimeout(selection.readyTimer);
169
+ selection.readyTimer = undefined;
170
+ selection.pendingInputIds = undefined;
171
+ selection.pendingInputBytes = undefined;
172
+ selection.applied = result.outputCursor;
173
+ selection.latest = result.outputCursor;
174
+ this.deps.commit(selection.sessionId, result);
175
+ }
176
+ catch (error) {
177
+ if (error instanceof MobileAwaitingJournal) {
178
+ if (!this.current(selection) || selection.failed)
179
+ return;
180
+ selection.recovering = false;
181
+ selection.awaitingJournal = true;
182
+ // No prefix exists yet. Retain the selected producer without polling,
183
+ // publishing an empty success, or retiring its independent command selection.
184
+ this.deps.awaitingJournal?.(selection.sessionId);
185
+ return;
186
+ }
187
+ if (error instanceof MobileOutputCatchupPending) {
188
+ if (!this.current(selection) || selection.failed)
189
+ return;
190
+ if (selection.capacityCursor &&
191
+ compareOutputCursors(error.cursor, selection.capacityCursor) !== 1) {
192
+ this.fail(selection);
193
+ return;
194
+ }
195
+ selection.capacityCursor = error.cursor;
196
+ // Keep the old display behind its barrier. One delayed follow-up also
197
+ // consumes a terminal/head already observed during the last round; it
198
+ // never waits forever for an event that has already arrived.
199
+ selection.recovering = false;
200
+ selection.capacityPending = true;
201
+ this.deps.failed(selection.sessionId);
202
+ selection.capacityTimer = setTimeout(() => {
203
+ selection.capacityTimer = undefined;
204
+ if (!this.current(selection) || selection.failed)
205
+ return;
206
+ selection.capacityPending = false;
207
+ selection.recovering = true;
208
+ void this.recover(selection, error.resume);
209
+ }, 25);
210
+ return;
211
+ }
212
+ if (error instanceof MobilePendingInput &&
213
+ error.ids.every((id) => selection.pendingInputIds?.has(id))) {
214
+ if (!this.current(selection) || selection.failed)
215
+ return;
216
+ if (!selection.readyTimer)
217
+ selection.readyTimer = setTimeout(() => this.fail(selection), 10_000);
218
+ return;
219
+ }
220
+ this.fail(selection);
221
+ }
222
+ }
223
+ /** Live output is withheld until it is covered by the frozen candidate. */
224
+ hold(sessionId, raw) {
225
+ const selection = this.selection;
226
+ if (!selection || selection.sessionId !== sessionId)
227
+ return false;
228
+ const event = raw;
229
+ const priorCursor = selection.latest ?? selection.applied;
230
+ if (typeof event?.outputCursor === "string") {
231
+ const order = selection.latest
232
+ ? compareOutputCursors(event.outputCursor, selection.latest)
233
+ : 1;
234
+ if (order === undefined)
235
+ this.fail(selection);
236
+ else if (order > 0)
237
+ selection.latest = event.outputCursor;
238
+ }
239
+ if (selection.failed)
240
+ return "pending";
241
+ if (selection.awaitingJournal && typeof event?.outputCursor === "string") {
242
+ selection.awaitingJournal = false;
243
+ if (selection.readyTimer)
244
+ clearTimeout(selection.readyTimer);
245
+ selection.readyTimer = undefined;
246
+ selection.recovering = true;
247
+ void this.recover(selection);
248
+ return "pending";
249
+ }
250
+ if (event?.type === "session_user_message" &&
251
+ !event.outputCursor &&
252
+ !event.agentId &&
253
+ event.injected !== true &&
254
+ !["agent", "system", "policy"].includes(String(event.authority))) {
255
+ const ids = (selection.pendingInputIds ??= new Set());
256
+ if (typeof event.clientMessageId !== "string" ||
257
+ !event.clientMessageId.trim() ||
258
+ event.clientMessageId.length > 512)
259
+ this.fail(selection);
260
+ else {
261
+ if (!ids.has(event.clientMessageId)) {
262
+ ids.add(event.clientMessageId);
263
+ selection.pendingInputBytes =
264
+ (selection.pendingInputBytes ?? 0) +
265
+ new TextEncoder().encode(event.clientMessageId).length;
266
+ }
267
+ if (ids.size > 128 || (selection.pendingInputBytes ?? 0) > 32 * 1024)
268
+ this.fail(selection);
269
+ if (!selection.failed && !selection.recovering && !selection.capacityPending) {
270
+ const latest = selection.latest;
271
+ this.begin(sessionId);
272
+ if (this.selection) {
273
+ this.selection.pendingInputIds = ids;
274
+ this.selection.pendingInputBytes = selection.pendingInputBytes;
275
+ this.selection.latest = latest;
276
+ }
277
+ return "pending";
278
+ }
279
+ if (!selection.readyTimer)
280
+ selection.readyTimer = setTimeout(() => this.fail(selection), 10_000);
281
+ }
282
+ return "pending";
283
+ }
284
+ if (selection.pendingInputIds?.size &&
285
+ event?.type === "session_started" &&
286
+ !event.agentId &&
287
+ event.outputCursor &&
288
+ typeof event.clientMessageId === "string" &&
289
+ selection.pendingInputIds.has(event.clientMessageId) &&
290
+ (!priorCursor || compareOutputCursors(event.outputCursor, priorCursor) === 1)) {
291
+ // The real protocol emits preliminary input before the Engine owns a
292
+ // durable Run. Retry only at its recorded boundary, never on arbitrary
293
+ // suffix text; Main proves every pending submit ID in the candidate.
294
+ const latest = selection.latest;
295
+ this.begin(sessionId);
296
+ if (this.selection) {
297
+ this.selection.pendingInputIds = selection.pendingInputIds;
298
+ this.selection.pendingInputBytes = selection.pendingInputBytes;
299
+ this.selection.latest = latest;
300
+ }
301
+ return "pending";
302
+ }
303
+ if (selection.pendingInputIds?.size)
304
+ return "pending";
305
+ if (selection.failed || selection.recovering || selection.capacityPending)
306
+ return "pending";
307
+ if (event?.type === "session_title")
308
+ return false;
309
+ const order = event?.outputCursor && selection.applied
310
+ ? compareOutputCursors(event.outputCursor, selection.applied)
311
+ : undefined;
312
+ if (order === undefined) {
313
+ this.fail(selection);
314
+ return "pending";
315
+ }
316
+ return order <= 0 ? "covered" : false;
317
+ }
318
+ applied(sessionId, raw) {
319
+ const event = raw;
320
+ if (this.selection?.sessionId === sessionId && typeof event?.outputCursor === "string")
321
+ this.selection.applied = event.outputCursor;
322
+ }
323
+ /** A coalesced committed head requests pages; it never invents visible output. */
324
+ advanceSnapshot(sessionId, outputCursor) {
325
+ const selection = this.selection;
326
+ if (!selection || selection.sessionId !== sessionId || !outputCursor || selection.failed)
327
+ return;
328
+ const previous = selection.latest ?? selection.applied;
329
+ const order = previous ? compareOutputCursors(outputCursor, previous) : 1;
330
+ if (order === undefined) {
331
+ this.fail(selection);
332
+ return;
333
+ }
334
+ if (order <= 0)
335
+ return;
336
+ selection.latest = outputCursor;
337
+ if (selection.awaitingJournal) {
338
+ selection.awaitingJournal = false;
339
+ if (selection.readyTimer)
340
+ clearTimeout(selection.readyTimer);
341
+ selection.readyTimer = undefined;
342
+ selection.recovering = true;
343
+ void this.recover(selection);
344
+ }
345
+ else if (!selection.recovering && !selection.capacityPending) {
346
+ this.begin(sessionId);
347
+ if (this.selection)
348
+ this.selection.latest = outputCursor;
349
+ }
350
+ }
351
+ }
@@ -4,6 +4,7 @@ import { t } from "../i18n/translate.js";
4
4
  import { summarizeApproval } from "../lib/riskClassify.js";
5
5
  import { roomMsgToEvent, roomHistoryToEvents, ccHistoryToEvents, extractAskUserOptions, } from "../lib/messageMappers.js";
6
6
  import { projectForCwd } from "../lib/format.js";
7
+ import { MobileOutputClient } from "./mobileOutputClient.js";
7
8
  import { useRemoteSocket } from "./useRemoteSocket.js";
8
9
  import { prepareMobileAttachments, } from "../lib/mobileAttachments.js";
9
10
  import { filterNewRoomMessages, clearUnreadSession, markSessionUnread, markRoomSeqApplied, maxRoomSeq, alignSessionEpoch, pruneUnreadSessions, rawApprovalResolvedRequestId, removeResolvedApproval, roomMessageSeq, selectSessionReplayEntries, } from "./remoteAppSync.js";
@@ -27,7 +28,12 @@ function chatReducer(state, action) {
27
28
  case "raw":
28
29
  return reduceStream(state, action.raw);
29
30
  case "user":
30
- return appendUserMessage(state, action.text, action.attachments);
31
+ return appendUserMessage(state, action.text, action.attachments, action.clientMessageId);
32
+ case "hydrate": {
33
+ const ids = new Set(action.chat.items.flatMap((item) => item.kind === "user" && item.clientMessageId ? [item.clientMessageId] : []));
34
+ const pending = state.items.filter((item) => item.kind === "user" && item.clientMessageId && !ids.has(item.clientMessageId));
35
+ return { ...action.chat, items: [...action.chat.items, ...pending] };
36
+ }
31
37
  case "reset":
32
38
  return initialChatState();
33
39
  case "stream_epoch_changed": {
@@ -280,7 +286,47 @@ export function useRemoteApp(options = {}) {
280
286
  }
281
287
  setApprovals((prev) => (prev.some((p) => p.requestId === a.requestId) ? prev : [...prev, a]));
282
288
  }, []);
289
+ const outputClientRef = useRef(null);
290
+ const outputClient = (outputClientRef.current ??= new MobileOutputClient({
291
+ send: (event) => sendRef.current?.(event) ?? false,
292
+ current: () => ({
293
+ sessionId: boundSessionRef.current,
294
+ roomId: activeRoomIdRef.current,
295
+ revision: conversationRevisionRef.current,
296
+ }),
297
+ commit: (sessionId, result) => {
298
+ const cursor = sessionCursor(sessionId);
299
+ cursor.epoch = result.snapshot.epoch;
300
+ cursor.appliedSeq = result.snapshot.nextSeq - 1;
301
+ cursor.observedSeq = cursor.appliedSeq;
302
+ cursor.awaitingSnapshot = false;
303
+ cursor.historyUnpaired = undefined;
304
+ epochHistorySessionRef.current = undefined;
305
+ dispatchChat({ kind: "hydrate", chat: result.chat });
306
+ setLoadingKey("sessionHistory", false);
307
+ setNoticeState(undefined);
308
+ },
309
+ legacy: (sessionId) => {
310
+ epochHistorySessionRef.current = sessionId;
311
+ sessionCursor(sessionId).historyUnpaired = true;
312
+ sendRef.current?.({ type: "session.history", sessionId });
313
+ },
314
+ awaitingJournal: () => {
315
+ // A selected live producer has no prior output. No read/request remains
316
+ // pending while the user composes its first input.
317
+ setLoadingKey("sessionHistory", false);
318
+ },
319
+ failed: (sessionId) => {
320
+ sessionCursor(sessionId).awaitingSnapshot = true;
321
+ dispatchChat({ kind: "stream_epoch_changed" });
322
+ setNotice(t("mobile.notice.sessionRecoveryIncomplete"));
323
+ setLoadingKey("sessionHistory", false);
324
+ },
325
+ }));
326
+ useEffect(() => () => outputClient.cancel(false), [outputClient]);
283
327
  const onServerEvent = useCallback((event) => {
328
+ if (outputClient.observe(event))
329
+ return;
284
330
  switch (event.type) {
285
331
  case "auth.ok":
286
332
  // Pull the world on connect.
@@ -370,6 +416,8 @@ export function useRemoteApp(options = {}) {
370
416
  }
371
417
  break;
372
418
  case "session.history.ok":
419
+ if (outputClient.owns(event.sessionId))
420
+ break;
373
421
  // Only apply if it's the session we're currently viewing.
374
422
  if (event.sessionId === boundSessionRef.current && !activeRoomIdRef.current) {
375
423
  const cursor = sessionCursor(event.sessionId);
@@ -393,11 +441,19 @@ export function useRemoteApp(options = {}) {
393
441
  }
394
442
  break;
395
443
  case "session.snapshot": {
444
+ if (outputClient.owns(event.sessionId)) {
445
+ outputClient.advanceSnapshot(event.sessionId, event.outputCursor);
446
+ break;
447
+ }
396
448
  if (event.sessionId !== boundSessionRef.current || activeRoomIdRef.current)
397
449
  break;
398
450
  if (epochHistorySessionRef.current === event.sessionId)
399
451
  break;
400
452
  const session = sessionCursor(event.sessionId);
453
+ if (event.outputCursor && outputClient.begin(event.sessionId)) {
454
+ session.awaitingSnapshot = true;
455
+ break;
456
+ }
401
457
  const changed = alignSessionEpoch(session, event.epoch);
402
458
  if (session.historyUnpaired)
403
459
  break;
@@ -425,6 +481,15 @@ export function useRemoteApp(options = {}) {
425
481
  setLoadingKey("sessionHistory", false);
426
482
  break;
427
483
  }
484
+ if (event.entries.some((entry, index) => entry.seq > session.appliedSeq &&
485
+ entry.seq !==
486
+ (index > 0 ? event.entries[index - 1].seq + 1 : session.appliedSeq + 1))) {
487
+ session.awaitingSnapshot = true;
488
+ if (!outputClient.begin(event.sessionId))
489
+ setNotice(t("mobile.notice.sessionRecoveryIncomplete"));
490
+ setLoadingKey("sessionHistory", false);
491
+ break;
492
+ }
428
493
  const appliedSeq = session.appliedSeq;
429
494
  const { events, cursor } = selectSessionReplayEntries(event.entries, appliedSeq);
430
495
  session.appliedSeq = cursor;
@@ -461,6 +526,32 @@ export function useRemoteApp(options = {}) {
461
526
  setActiveSessionCwd(sessionCwdById.get(event.sessionId));
462
527
  }
463
528
  clearSessionUnread(event.sessionId);
529
+ const rawOutput = event.event;
530
+ if (changed && outputClient.owns(event.sessionId))
531
+ outputClient.begin(event.sessionId);
532
+ if (!outputClient.owns(event.sessionId) &&
533
+ rawOutput?.outputCursor &&
534
+ outputClient.begin(event.sessionId)) {
535
+ session.awaitingSnapshot = true;
536
+ }
537
+ const held = outputClient.hold(event.sessionId, event.event);
538
+ if (held === "pending")
539
+ break;
540
+ if (held === "covered") {
541
+ if (event.seq === session.appliedSeq + 1)
542
+ session.appliedSeq = event.seq;
543
+ else if (event.seq > session.appliedSeq + 1) {
544
+ session.awaitingSnapshot = true;
545
+ outputClient.begin(event.sessionId);
546
+ }
547
+ break;
548
+ }
549
+ if (!changed && !session.awaitingSnapshot && event.seq > session.appliedSeq + 1) {
550
+ session.awaitingSnapshot = true;
551
+ if (!outputClient.begin(event.sessionId))
552
+ setNotice(t("mobile.notice.sessionRecoveryIncomplete"));
553
+ break;
554
+ }
464
555
  if (changed) {
465
556
  dispatchChat({ kind: "stream_epoch_changed" });
466
557
  setApprovals([]);
@@ -492,6 +583,7 @@ export function useRemoteApp(options = {}) {
492
583
  break;
493
584
  session.appliedSeq = event.seq;
494
585
  appendSessionEvents(event.sessionId, [event.event]);
586
+ outputClient.applied(event.sessionId, event.event);
495
587
  break;
496
588
  }
497
589
  case "permission.mode":
@@ -620,6 +712,7 @@ export function useRemoteApp(options = {}) {
620
712
  setNotice(t("mobile.notice.ccRoomOpenFailed"));
621
713
  break;
622
714
  }
715
+ outputClient.cancel();
623
716
  // An opened cc room behaves like room.open — bind the room feed. But the
624
717
  // resident room only carries messages from open-time onward; the prior
625
718
  // external-CLI transcript lives on disk, so subscribeTranscript returns
@@ -631,6 +724,7 @@ export function useRemoteApp(options = {}) {
631
724
  roomId: activeRoomIdRef.current,
632
725
  });
633
726
  }
727
+ activeRoomIdRef.current = event.roomId;
634
728
  setActiveRoomId(event.roomId);
635
729
  setRooms((previous) => previous.map((room) => room.id === event.roomId
636
730
  ? {
@@ -818,6 +912,8 @@ export function useRemoteApp(options = {}) {
818
912
  else {
819
913
  return;
820
914
  }
915
+ if (outputClient.rejectUnsequenced(boundSessionRef.current))
916
+ return;
821
917
  dispatchChat({ kind: "raw", raw });
822
918
  clearApprovalsIfTerminal([params.event]);
823
919
  }
@@ -853,6 +949,11 @@ export function useRemoteApp(options = {}) {
853
949
  if (!sessionId)
854
950
  return;
855
951
  const cursor = sessionCursor(sessionId);
952
+ if (outputClient.begin(sessionId)) {
953
+ cursor.awaitingSnapshot = true;
954
+ setLoadingKey("sessionHistory", true);
955
+ return;
956
+ }
856
957
  if (epochHistorySessionRef.current === sessionId || cursor.historyUnpaired) {
857
958
  epochHistorySessionRef.current = sessionId;
858
959
  setLoadingKey("sessionHistory", true);
@@ -876,6 +977,7 @@ export function useRemoteApp(options = {}) {
876
977
  useEffect(() => {
877
978
  if (socket.status === "online")
878
979
  return;
980
+ outputClient.cancel(false);
879
981
  submittingAsyncApprovalsRef.current.clear();
880
982
  const error = new Error("Remote connection was lost");
881
983
  for (const waiter of uploadWaitersRef.current.values()) {
@@ -966,7 +1068,7 @@ export function useRemoteApp(options = {}) {
966
1068
  (sessionId === undefined || boundSessionRef.current === sessionId)) {
967
1069
  // Sessions don't echo the user turn back over the stream, so add the
968
1070
  // local bubble only after the server has accepted this exact message.
969
- dispatchChat({ kind: "user", text, attachments: summaries });
1071
+ dispatchChat({ kind: "user", text, attachments: summaries, clientMessageId });
970
1072
  }
971
1073
  return true;
972
1074
  }, [beginMobileUpload, setNotice, settleMessageAck, socket, waitForMessageAck]);
@@ -999,12 +1101,17 @@ export function useRemoteApp(options = {}) {
999
1101
  setApprovals([]);
1000
1102
  setLoadingKey("sessionHistory", true);
1001
1103
  dispatchChat({ kind: "reset" });
1002
- socket.send({ type: "session.select", sessionId: id });
1003
- socket.send({ type: "session.history", sessionId: id });
1104
+ activeRoomIdRef.current = undefined;
1105
+ if (!outputClient.begin(id)) {
1106
+ socket.send({ type: "session.select", sessionId: id });
1107
+ socket.send({ type: "session.history", sessionId: id });
1108
+ }
1004
1109
  }, [clearSessionUnread, socket, sessionCwdById, setLoadingKey, sessionCursor]);
1005
1110
  /** Select a project (like the desktop sidebar): set the one-true-source cwd and
1006
1111
  * pull that project's chat sessions + external cc sessions. */
1007
1112
  const selectProject = useCallback((projectIdOrCwd) => {
1113
+ outputClient.cancel();
1114
+ conversationRevisionRef.current += 1;
1008
1115
  const project = projectsRef.current.find((candidate) => candidate.id === projectIdOrCwd || candidate.path === projectIdOrCwd);
1009
1116
  const cwd = project?.path ?? projectIdOrCwd;
1010
1117
  setActiveProjectId(project?.id ?? null);
@@ -1017,6 +1124,7 @@ export function useRemoteApp(options = {}) {
1017
1124
  // Doing it here too would double-fetch.
1018
1125
  }, [socket, setLoadingKey]);
1019
1126
  const openCcSession = useCallback((sessionId, cwd, mode) => {
1127
+ outputClient.cancel();
1020
1128
  conversationRevisionRef.current += 1;
1021
1129
  // Pin the CLI this session belongs to so its on-disk backlog is read with
1022
1130
  // the right reader even if the user switches the pane's CLI afterward.
@@ -1033,6 +1141,7 @@ export function useRemoteApp(options = {}) {
1033
1141
  setApprovals((prev) => prev.filter((p) => p.requestId !== requestId));
1034
1142
  }, [socket]);
1035
1143
  const newSession = useCallback((target, name) => {
1144
+ outputClient.cancel();
1036
1145
  conversationRevisionRef.current += 1;
1037
1146
  const clientRequestId = globalThis.crypto?.randomUUID?.() ??
1038
1147
  `mobile-create-${Date.now()}-${Math.random().toString(36).slice(2)}`;
@@ -1150,6 +1259,7 @@ export function useRemoteApp(options = {}) {
1150
1259
  // is no user-facing room create/open/close — CC sessions are opened via
1151
1260
  // openCcSession; the room is internal transport.)
1152
1261
  const leaveRoom = useCallback(() => {
1262
+ outputClient.cancel();
1153
1263
  conversationRevisionRef.current += 1;
1154
1264
  if (activeRoomIdRef.current) {
1155
1265
  socket.send({
@@ -0,0 +1,34 @@
1
+ import type { MobileServerEvent } from "@cjhyy/code-shell-core";
2
+ import type { OutputJournalOptions } from "@cjhyy/code-shell-core/internal";
3
+ import { initialChatState } from "./streamReducer.js";
4
+ type Reply = Extract<MobileServerEvent, {
5
+ type: "session.outputJournal";
6
+ }>;
7
+ /** Valid identities may arrive before their new Core Run acquires a durable anchor. */
8
+ export declare class MobilePendingInput extends Error {
9
+ readonly ids: string[];
10
+ constructor(ids: string[]);
11
+ }
12
+ /** No journal has ever been published by this explicitly journal-owned producer. */
13
+ export declare class MobileAwaitingJournal extends Error {
14
+ constructor();
15
+ }
16
+ /** Verified pages progressed, but the observed live head kept moving within the bound. */
17
+ export declare class MobileOutputCatchupPending extends Error {
18
+ readonly cursor: string;
19
+ readonly resume: () => Promise<MobileOutputRecoveryResult>;
20
+ constructor(cursor: string, resume: () => Promise<MobileOutputRecoveryResult>);
21
+ }
22
+ type MobileOutputRecoveryResult = {
23
+ chat: ReturnType<typeof initialChatState>;
24
+ outputCursor: string;
25
+ snapshot: NonNullable<Reply["snapshot"]>;
26
+ };
27
+ /** Build a private candidate, then join its frozen prefix to an actual Main snapshot. */
28
+ export declare function recoverMobileOutput(args: {
29
+ read: (options: OutputJournalOptions) => Promise<Reply>;
30
+ canContinue: () => boolean;
31
+ latestCursor: () => string | undefined;
32
+ requireJournal?: boolean;
33
+ }): Promise<MobileOutputRecoveryResult | null>;
34
+ export {};