@cjhyy/code-shell-web 0.9.46 → 0.9.47

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.
@@ -5,6 +5,7 @@ type Result = NonNullable<Awaited<ReturnType<typeof recoverMobileOutput>>>;
5
5
  export declare class MobileOutputClient {
6
6
  private readonly deps;
7
7
  private enabled;
8
+ private basePages;
8
9
  private auth;
9
10
  private sequence;
10
11
  private selection?;
@@ -21,12 +22,15 @@ export declare class MobileOutputClient {
21
22
  awaitingJournal?: (sessionId: string) => void;
22
23
  });
23
24
  cancel(notify?: boolean): void;
25
+ private clearPending;
24
26
  begin(sessionId: string): boolean;
25
27
  private current;
26
28
  owns(sessionId: string): boolean;
27
29
  rejectUnsequenced(sessionId: string): boolean;
28
30
  private fail;
29
31
  observe(event: MobileServerEvent): boolean;
32
+ private request;
33
+ private readBase;
30
34
  private recover;
31
35
  /** Live output is withheld until it is covered by the frozen candidate. */
32
36
  hold(sessionId: string, raw: unknown): false | "pending" | "covered";
@@ -4,6 +4,7 @@ import { compareOutputCursors } from "../lib/outputJournalRecovery.js";
4
4
  export class MobileOutputClient {
5
5
  deps;
6
6
  enabled = false;
7
+ basePages = false;
7
8
  auth = 0;
8
9
  sequence = 0;
9
10
  selection;
@@ -17,13 +18,25 @@ export class MobileOutputClient {
17
18
  clearTimeout(old.readyTimer);
18
19
  if (old?.capacityTimer)
19
20
  clearTimeout(old.capacityTimer);
20
- if (old?.waiter) {
21
- clearTimeout(old.waiter.timer);
22
- old.waiter.reject(new Error("Recovery cancelled"));
23
- }
21
+ if (old)
22
+ this.clearPending(old, "Recovery cancelled");
24
23
  if (notify && old)
25
24
  this.deps.send({ type: "session.recovery.cancel" });
26
25
  }
26
+ clearPending(selection, message) {
27
+ if (selection.waiter) {
28
+ const waiter = selection.waiter;
29
+ selection.waiter = undefined;
30
+ clearTimeout(waiter.timer);
31
+ waiter.reject(new Error(message));
32
+ }
33
+ if (selection.retry) {
34
+ const retry = selection.retry;
35
+ selection.retry = undefined;
36
+ clearTimeout(retry.timer);
37
+ retry.reject(new Error(message));
38
+ }
39
+ }
27
40
  begin(sessionId) {
28
41
  if (!this.enabled)
29
42
  return false;
@@ -61,7 +74,7 @@ export class MobileOutputClient {
61
74
  return true;
62
75
  }
63
76
  fail(selection) {
64
- if (!this.current(selection))
77
+ if (!this.current(selection) || selection.failed)
65
78
  return;
66
79
  if (selection.readyTimer)
67
80
  clearTimeout(selection.readyTimer);
@@ -72,6 +85,7 @@ export class MobileOutputClient {
72
85
  selection.readyTimer = undefined;
73
86
  selection.recovering = false;
74
87
  selection.failed = true;
88
+ this.clearPending(selection, "Recovery failed");
75
89
  this.deps.failed(selection.sessionId);
76
90
  }
77
91
  observe(event) {
@@ -79,9 +93,12 @@ export class MobileOutputClient {
79
93
  this.cancel(false);
80
94
  this.auth++;
81
95
  this.enabled = event.capabilities?.outputJournal === 1;
96
+ this.basePages = this.enabled && event.capabilities?.legacyBasePages === 1;
82
97
  return false;
83
98
  }
84
- if (event.type !== "session.recovery.ready" && event.type !== "session.outputJournal")
99
+ if (event.type !== "session.recovery.ready" &&
100
+ event.type !== "session.outputJournal" &&
101
+ event.type !== "session.legacyBase")
85
102
  return false;
86
103
  const selection = this.selection;
87
104
  if (!selection ||
@@ -107,7 +124,7 @@ export class MobileOutputClient {
107
124
  else
108
125
  void this.recover(selection);
109
126
  }
110
- else if (selection.waiter?.id === event.requestId) {
127
+ else if (selection.waiter?.id === event.requestId && selection.waiter.type === event.type) {
111
128
  const waiter = selection.waiter;
112
129
  selection.waiter = undefined;
113
130
  clearTimeout(waiter.timer);
@@ -115,6 +132,60 @@ export class MobileOutputClient {
115
132
  }
116
133
  return true;
117
134
  }
135
+ request(selection, type, options) {
136
+ return new Promise((resolve, reject) => {
137
+ if (!this.current(selection) || selection.failed || selection.waiter || selection.retry)
138
+ return reject(new Error("Recovery cancelled"));
139
+ const id = `page-${this.auth}-${++this.sequence}`;
140
+ const timer = setTimeout(() => {
141
+ selection.waiter = undefined;
142
+ reject(new Error("Recovery timed out"));
143
+ }, 10_000);
144
+ selection.waiter = {
145
+ id,
146
+ type,
147
+ timer,
148
+ reject,
149
+ resolve: (page) => resolve(page),
150
+ };
151
+ try {
152
+ if (!this.deps.send({
153
+ ...options,
154
+ type,
155
+ sessionId: selection.sessionId,
156
+ recoveryId: selection.id,
157
+ requestId: id,
158
+ }))
159
+ throw new Error("Recovery disconnected");
160
+ }
161
+ catch (error) {
162
+ clearTimeout(timer);
163
+ selection.waiter = undefined;
164
+ reject(error);
165
+ }
166
+ });
167
+ }
168
+ async readBase(selection, options) {
169
+ for (let retries = 0;; retries++) {
170
+ const reply = await this.request(selection, "session.legacyBase", options);
171
+ if (new TextEncoder().encode(JSON.stringify(reply)).length > 1024 * 1024 + 4096 ||
172
+ (reply.ok === false &&
173
+ ((reply.reason !== "busy" && reply.reason !== "incomplete") ||
174
+ Object.keys(reply).some((key) => !["type", "sessionId", "recoveryId", "requestId", "ok", "reason"].includes(key)))))
175
+ throw new Error("Invalid recovery base reply");
176
+ if (reply.ok !== false || reply.reason !== "busy" || retries >= 8)
177
+ return reply;
178
+ await new Promise((resolve, reject) => {
179
+ if (!this.current(selection) || selection.failed)
180
+ return reject(new Error("Recovery cancelled"));
181
+ const timer = setTimeout(() => {
182
+ selection.retry = undefined;
183
+ resolve();
184
+ }, 25);
185
+ selection.retry = { timer, reject };
186
+ });
187
+ }
188
+ }
118
189
  async recover(selection, resume) {
119
190
  try {
120
191
  const result = await (resume
@@ -123,28 +194,12 @@ export class MobileOutputClient {
123
194
  requireJournal: selection.journalRequired,
124
195
  canContinue: () => this.current(selection) && !selection.failed,
125
196
  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"));
197
+ read: (options) => this.request(selection, "session.outputJournal", options),
198
+ ...(this.basePages
199
+ ? {
200
+ readBase: (options) => this.readBase(selection, options),
146
201
  }
147
- }),
202
+ : {}),
148
203
  }));
149
204
  if (!this.current(selection))
150
205
  return;
@@ -4,6 +4,16 @@ import { initialChatState } from "./streamReducer.js";
4
4
  type Reply = Extract<MobileServerEvent, {
5
5
  type: "session.outputJournal";
6
6
  }>;
7
+ type BaseReply = Extract<MobileServerEvent, {
8
+ type: "session.legacyBase";
9
+ }>;
10
+ export type MobileLegacyBaseOptions = {
11
+ throughEventId: string;
12
+ cursor?: never;
13
+ } | {
14
+ cursor: string;
15
+ throughEventId?: never;
16
+ };
7
17
  /** Valid identities may arrive before their new Core Run acquires a durable anchor. */
8
18
  export declare class MobilePendingInput extends Error {
9
19
  readonly ids: string[];
@@ -27,6 +37,8 @@ type MobileOutputRecoveryResult = {
27
37
  /** Build a private candidate, then join its frozen prefix to an actual Main snapshot. */
28
38
  export declare function recoverMobileOutput(args: {
29
39
  read: (options: OutputJournalOptions) => Promise<Reply>;
40
+ /** Supplied only after an implementing host explicitly advertises the capability. */
41
+ readBase?: (options: MobileLegacyBaseOptions) => Promise<BaseReply>;
30
42
  canContinue: () => boolean;
31
43
  latestCursor: () => string | undefined;
32
44
  requireJournal?: boolean;
@@ -1,6 +1,87 @@
1
1
  import { applyOutputJournalPage, compareOutputCursors, } from "./outputJournalRecovery.js";
2
2
  import { assertOutputCoverage } from "./outputCoverage.js";
3
3
  import { initialChatState, reduceStream } from "./streamReducer.js";
4
+ const MiB = 1024 * 1024;
5
+ const wireBytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
6
+ function record(value) {
7
+ return !!value && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+ function onlyKeys(value, keys) {
10
+ return Object.keys(value).every((key) => keys.includes(key));
11
+ }
12
+ const BASE_EVENT_KEYS = {
13
+ session_started: ["type", "sessionId"],
14
+ stream_request_start: ["type", "agentId"],
15
+ assistant_message: ["type", "agentId"],
16
+ text_delta: ["type", "text", "agentId"],
17
+ thinking_delta: ["type", "text", "agentId"],
18
+ user_message: ["type", "text", "attachments", "clientMessageId"],
19
+ tool_use_start: ["type", "toolCall", "agentId"],
20
+ tool_use_args_delta: ["type", "toolCallId", "args", "agentId"],
21
+ tool_result: ["type", "result", "agentId"],
22
+ agent_start: ["type", "agentId", "name", "description", "status"],
23
+ error: ["type", "error"],
24
+ turn_complete: ["type", "reason"],
25
+ };
26
+ /** Only the display projection's event schema is accepted on this new wire path. */
27
+ function baseEvent(value, sessionId) {
28
+ if (!record(value) ||
29
+ typeof value.type !== "string" ||
30
+ !Object.hasOwn(BASE_EVENT_KEYS, value.type) ||
31
+ !onlyKeys(value, BASE_EVENT_KEYS[value.type]) ||
32
+ (value.agentId !== undefined && typeof value.agentId !== "string"))
33
+ return false;
34
+ switch (value.type) {
35
+ case "session_started":
36
+ return value.sessionId === sessionId;
37
+ case "stream_request_start":
38
+ case "assistant_message":
39
+ return true;
40
+ case "text_delta":
41
+ case "thinking_delta":
42
+ return typeof value.text === "string";
43
+ case "user_message":
44
+ return (typeof value.text === "string" &&
45
+ (value.clientMessageId === undefined || typeof value.clientMessageId === "string") &&
46
+ (value.attachments === undefined ||
47
+ (Array.isArray(value.attachments) &&
48
+ value.attachments.every((item) => record(item) &&
49
+ onlyKeys(item, ["name", "size", "mime", "path"]) &&
50
+ typeof item.name === "string" &&
51
+ Number.isSafeInteger(item.size) &&
52
+ item.size >= 0 &&
53
+ ["mime", "path"].every((key) => item[key] === undefined || typeof item[key] === "string")))));
54
+ case "tool_use_start":
55
+ return (record(value.toolCall) &&
56
+ onlyKeys(value.toolCall, ["id", "toolName", "args"]) &&
57
+ typeof value.toolCall.id === "string" &&
58
+ !!value.toolCall.id &&
59
+ typeof value.toolCall.toolName === "string" &&
60
+ record(value.toolCall.args));
61
+ case "tool_use_args_delta":
62
+ return typeof value.toolCallId === "string" && !!value.toolCallId && record(value.args);
63
+ case "tool_result":
64
+ return (record(value.result) &&
65
+ onlyKeys(value.result, ["id", "result", "isError", "error"]) &&
66
+ typeof value.result.id === "string" &&
67
+ !!value.result.id &&
68
+ typeof value.result.result === "string" &&
69
+ typeof value.result.isError === "boolean" &&
70
+ (value.result.error === undefined || typeof value.result.error === "string"));
71
+ case "agent_start":
72
+ return (typeof value.agentId === "string" &&
73
+ !!value.agentId &&
74
+ typeof value.name === "string" &&
75
+ typeof value.description === "string" &&
76
+ value.status === "recorded");
77
+ case "error":
78
+ return typeof value.error === "string";
79
+ case "turn_complete":
80
+ return value.reason === "aborted_tools";
81
+ default:
82
+ return false;
83
+ }
84
+ }
4
85
  /** Valid identities may arrive before their new Core Run acquires a durable anchor. */
5
86
  export class MobilePendingInput extends Error {
6
87
  ids;
@@ -63,21 +144,84 @@ export async function recoverMobileOutput(args) {
63
144
  }
64
145
  if (reply.page.status === "unavailable")
65
146
  return null;
66
- if (reply.page.status !== "ok" ||
67
- !reply.legacyBaseComplete ||
68
- !reply.legacyBase ||
69
- !Array.isArray(reply.legacyBase.events) ||
70
- reply.legacyBase.events[0]?.type !== "session_started" ||
71
- reply.legacyBase.throughEventId !== reply.page.legacyBaseThroughEventId ||
72
- new TextEncoder().encode(JSON.stringify(reply.legacyBase)).length > 1024 * 1024 + 4096)
73
- throw new Error("Recovery base is incomplete");
74
147
  let chat = initialChatState();
75
- for (const event of reply.legacyBase.events)
76
- chat = reduceStream(chat, event);
148
+ let pages = 0;
149
+ if (reply.page.status === "ok" && reply.legacyBaseComplete === false && args.readBase) {
150
+ const throughEventId = reply.page.legacyBaseThroughEventId;
151
+ if (typeof throughEventId !== "string" || !throughEventId || throughEventId.length > 512)
152
+ throw new Error("Recovery base is incomplete");
153
+ const cursors = new Set();
154
+ let cursor, index = 0, bytes = 0;
155
+ for (let count = 0;; count++) {
156
+ if (!args.canContinue() || count >= 40 || ++pages > 2048)
157
+ throw new Error("Recovery base cancelled or bounded");
158
+ const base = await args.readBase(cursor ? { cursor } : { throughEventId });
159
+ if (!args.canContinue())
160
+ throw new Error("Recovery cancelled");
161
+ const size = wireBytes(base);
162
+ bytes += size;
163
+ if (!record(base) ||
164
+ !onlyKeys(base, [
165
+ "type",
166
+ "sessionId",
167
+ "recoveryId",
168
+ "requestId",
169
+ "ok",
170
+ "throughEventId",
171
+ "index",
172
+ "events",
173
+ "complete",
174
+ "next",
175
+ ]) ||
176
+ base.type !== "session.legacyBase" ||
177
+ typeof base.requestId !== "string" ||
178
+ !base.requestId ||
179
+ base.requestId.length > 128 ||
180
+ base.ok !== true ||
181
+ base.sessionId !== reply.sessionId ||
182
+ base.recoveryId !== reply.recoveryId ||
183
+ base.throughEventId !== throughEventId ||
184
+ !Number.isSafeInteger(base.index) ||
185
+ base.index !== index ||
186
+ !Array.isArray(base.events) ||
187
+ !base.events.length ||
188
+ !base.events.every((event) => baseEvent(event, reply.sessionId)) ||
189
+ (index === 0 && base.events[0].type !== "session_started") ||
190
+ base.events.some((event, offset) => event.type === "session_started" && index + offset !== 0) ||
191
+ size > MiB + 4096 ||
192
+ bytes > 32 * MiB + 40 * 4096 ||
193
+ typeof base.complete !== "boolean" ||
194
+ (base.complete
195
+ ? base.next !== undefined
196
+ : typeof base.next !== "string" ||
197
+ !base.next ||
198
+ base.next.length > 2048 ||
199
+ cursors.has(base.next)))
200
+ throw new Error("Recovery base is incomplete or exceeds its bound");
201
+ for (const event of base.events)
202
+ chat = reduceStream(chat, event);
203
+ index += base.events.length;
204
+ if (base.complete)
205
+ break;
206
+ cursor = base.next;
207
+ cursors.add(cursor);
208
+ }
209
+ }
210
+ else {
211
+ if (reply.page.status !== "ok" ||
212
+ !reply.legacyBaseComplete ||
213
+ !reply.legacyBase ||
214
+ !Array.isArray(reply.legacyBase.events) ||
215
+ reply.legacyBase.events[0]?.type !== "session_started" ||
216
+ reply.legacyBase.throughEventId !== reply.page.legacyBaseThroughEventId ||
217
+ wireBytes(reply.legacyBase) > MiB + 4096)
218
+ throw new Error("Recovery base is incomplete");
219
+ for (const event of reply.legacyBase.events)
220
+ chat = reduceStream(chat, event);
221
+ }
77
222
  // A legacy transcript establishes display history, never live completion.
78
223
  chat = { ...chat, run: chat.run === "error" ? "error" : "idle", liveByAgent: {} };
79
224
  let recovery = { incomplete: false };
80
- let pages = 0;
81
225
  // A continuation retains the verified private candidate and the same total
82
226
  // page budget. No prefix is published until the eventual frozen join passes.
83
227
  const advance = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-web",
3
- "version": "0.9.46",
3
+ "version": "0.9.47",
4
4
  "description": "CodeShell web client logic layer — remote-app state (chatReducer + server-event fold), reconnecting WebSocket hook, pairing/device-credential/risk helpers, and the mobile i18n namespace. Browser-only (no Electron, no Node); seed of the standalone browser client.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,8 +40,8 @@
40
40
  "test:host-title-browser": "node ../../scripts/run-isolated-node-smoke.mjs ../../scripts/smoke-web-host-operations.mjs --late-title"
41
41
  },
42
42
  "dependencies": {
43
- "@cjhyy/code-shell-core": "0.9.46",
44
- "@cjhyy/code-shell-link": "0.9.46",
43
+ "@cjhyy/code-shell-core": "0.9.47",
44
+ "@cjhyy/code-shell-link": "0.9.47",
45
45
  "@types/react": "^19.2.14",
46
46
  "qrcode": "1.5.4"
47
47
  },