@cjhyy/code-shell-web 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ import { loadUILanguage } from "../lib/uiLanguage.js";
2
+ import { mobile } from "./mobile.js";
3
+ /** Web-package dictionary: just the `mobile` namespace (zh is source of truth). */
4
+ export const webMessages = mobile;
5
+ /** Walk a dotted key path (`"a.b.c"`) into a nested object; return the string leaf or undefined. */
6
+ function lookup(tree, key) {
7
+ let node = tree;
8
+ for (const part of key.split(".")) {
9
+ if (node == null || typeof node !== "object")
10
+ return undefined;
11
+ node = node[part];
12
+ }
13
+ return typeof node === "string" ? node : undefined;
14
+ }
15
+ /** Replace `{name}` placeholders with values from `params`; leaves unknown placeholders intact. */
16
+ function interpolate(text, params) {
17
+ if (!params)
18
+ return text;
19
+ return text.replace(/\{(\w+)\}/g, (whole, name) => name in params ? String(params[name]) : whole);
20
+ }
21
+ /**
22
+ * Resolve a translation key for `lang`. Fallback chain:
23
+ * 1. the requested language,
24
+ * 2. Chinese (zh) — the source-of-truth tree,
25
+ * 3. the raw key string itself (so missing keys are visible, never blank).
26
+ * Then `{name}`-style placeholders are interpolated.
27
+ */
28
+ export function translate(lang, key, params) {
29
+ const hit = lookup(webMessages[lang], key) ?? lookup(webMessages.zh, key) ?? key;
30
+ return interpolate(hit, params);
31
+ }
32
+ /** Translate with the persisted UI language (localStorage; defaults to zh). */
33
+ export function t(key, params) {
34
+ return translate(loadUILanguage(), key, params);
35
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @cjhyy/code-shell-web — the browser-side logic layer of the CodeShell web
3
+ * client (extracted from packages/desktop src/mobile). Zero Electron, zero
4
+ * preload-bridge globals, zero @cjhyy/code-shell-core runtime imports:
5
+ * everything here runs in a plain browser (localStorage/WebSocket/navigator).
6
+ * Protocol types come from @cjhyy/code-shell-core (type-only, erased).
7
+ *
8
+ * The desktop mobile UI (packages/desktop/src/mobile) renders on top of this;
9
+ * a future standalone browser client is meant to grow from this package.
10
+ */
11
+ export { useRemoteApp, type CcCliKind, type PendingApproval, type RemoteApp, } from "./hooks/useRemoteApp.js";
12
+ export { useRemoteSocket, type ConnStatus, type ResyncReason, type RemoteSocket, type RemoteSocketHandlers, } from "./hooks/useRemoteSocket.js";
13
+ export * from "./hooks/remoteAppSync.js";
14
+ export * from "./lib/streamReducer.js";
15
+ export * from "./lib/messageMappers.js";
16
+ export * from "./lib/riskClassify.js";
17
+ export * from "./lib/format.js";
18
+ export * from "./lib/storage.js";
19
+ export * from "./lib/pairing.js";
20
+ export * from "./lib/deviceCredential.js";
21
+ export * from "./lib/mobileAttachments.js";
22
+ export * from "./lib/uiLanguage.js";
23
+ export { mobile } from "./i18n/mobile.js";
24
+ export { translate, t, webMessages, type WebTranslationKey, type WebTranslateParams, } from "./i18n/translate.js";
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @cjhyy/code-shell-web — the browser-side logic layer of the CodeShell web
3
+ * client (extracted from packages/desktop src/mobile). Zero Electron, zero
4
+ * preload-bridge globals, zero @cjhyy/code-shell-core runtime imports:
5
+ * everything here runs in a plain browser (localStorage/WebSocket/navigator).
6
+ * Protocol types come from @cjhyy/code-shell-core (type-only, erased).
7
+ *
8
+ * The desktop mobile UI (packages/desktop/src/mobile) renders on top of this;
9
+ * a future standalone browser client is meant to grow from this package.
10
+ */
11
+ // ── Hooks: remote-app state machine + reconnecting socket ───────────────────
12
+ export { useRemoteApp, } from "./hooks/useRemoteApp.js";
13
+ export { useRemoteSocket, } from "./hooks/useRemoteSocket.js";
14
+ export * from "./hooks/remoteAppSync.js";
15
+ // ── Stream folding (shared with the desktop renderer's CC room view) ────────
16
+ export * from "./lib/streamReducer.js";
17
+ export * from "./lib/messageMappers.js";
18
+ // ── Pure helpers ─────────────────────────────────────────────────────────────
19
+ export * from "./lib/riskClassify.js";
20
+ export * from "./lib/format.js";
21
+ export * from "./lib/storage.js";
22
+ export * from "./lib/pairing.js";
23
+ export * from "./lib/deviceCredential.js";
24
+ export * from "./lib/mobileAttachments.js";
25
+ export * from "./lib/uiLanguage.js";
26
+ // ── i18n: mobile namespace dict + zh-fallback translate ─────────────────────
27
+ export { mobile } from "./i18n/mobile.js";
28
+ export { translate, t, webMessages, } from "./i18n/translate.js";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Device credential for the mobile remote.
3
+ *
4
+ * CONTRACT (do not "improve" by hashing): the main process
5
+ * (trusted-device-store.ts) stores and compares the `secretHash` field
6
+ * VERBATIM — `d.secretHash === input.secretHash`. There is no SHA-256 on
7
+ * either side. The field is named "secretHash" historically but is really an
8
+ * opaque shared secret. The phone therefore generates a stable random secret
9
+ * per browser and sends it raw as `secretHash` for both pair.complete and
10
+ * auth.device. Hashing it here would send main a value it never recorded →
11
+ * auth.failed. (See also [[project_beta1_feedback_batch_fixes]]: addDevice
12
+ * is get-or-create BY secretHash, so the secret must be stable per browser.)
13
+ */
14
+ export declare function generateSecret(randomBytes?: (n: number) => Uint8Array): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Device credential for the mobile remote.
3
+ *
4
+ * CONTRACT (do not "improve" by hashing): the main process
5
+ * (trusted-device-store.ts) stores and compares the `secretHash` field
6
+ * VERBATIM — `d.secretHash === input.secretHash`. There is no SHA-256 on
7
+ * either side. The field is named "secretHash" historically but is really an
8
+ * opaque shared secret. The phone therefore generates a stable random secret
9
+ * per browser and sends it raw as `secretHash` for both pair.complete and
10
+ * auth.device. Hashing it here would send main a value it never recorded →
11
+ * auth.failed. (See also [[project_beta1_feedback_batch_fixes]]: addDevice
12
+ * is get-or-create BY secretHash, so the secret must be stable per browser.)
13
+ */
14
+ export function generateSecret(randomBytes = (n) => crypto.getRandomValues(new Uint8Array(n))) {
15
+ const b = randomBytes(32);
16
+ return Array.from(b)
17
+ .map((x) => x.toString(16).padStart(2, "0"))
18
+ .join("");
19
+ }
@@ -0,0 +1,26 @@
1
+ import { type UILanguage } from "./uiLanguage.js";
2
+ /** Last path segment of a cwd ("/Users/x/proj" → "proj"). Empty for ""/root. */
3
+ export declare function basename(p: string): string;
4
+ /** Return the desktop project whose root owns `cwd` (longest prefix wins). */
5
+ export declare function projectForCwd<T extends {
6
+ path: string;
7
+ name: string;
8
+ }>(cwd: string | null | undefined, projects?: T[]): T | undefined;
9
+ /** Group items by their cwd (project). When desktop project order is provided
10
+ * it wins; unknown projects fall back to newest-first. Empty cwd → "无项目"
11
+ * bucket, sorted last. Generic over any item carrying `cwd` + `updatedAt`. */
12
+ export declare function groupByProject<T extends {
13
+ cwd: string;
14
+ updatedAt: number;
15
+ }>(items: T[], projects?: Array<{
16
+ path: string;
17
+ name: string;
18
+ }>, lang?: UILanguage): {
19
+ cwd: string;
20
+ name: string;
21
+ items: T[];
22
+ updatedAt: number;
23
+ }[];
24
+ /** Coarse relative time ("刚刚" / "5 分钟前" / "3 小时前" / "2 天前").
25
+ * `now` is injectable for deterministic tests. */
26
+ export declare function relativeTime(ts: number, now?: number, lang?: UILanguage): string;
@@ -0,0 +1,96 @@
1
+ /** Small display formatters shared by the session/room lists. */
2
+ import { translate } from "../i18n/translate.js";
3
+ import { loadUILanguage } from "./uiLanguage.js";
4
+ /** Last path segment of a cwd ("/Users/x/proj" → "proj"). Empty for ""/root. */
5
+ export function basename(p) {
6
+ if (!p)
7
+ return "";
8
+ const parts = p.replace(/[/\\]+$/, "").split(/[/\\]/);
9
+ return parts[parts.length - 1] ?? "";
10
+ }
11
+ function normalizePath(p) {
12
+ const normalized = p.replace(/\\/g, "/").replace(/\/+$/, "");
13
+ return normalized === "" ? "/" : normalized;
14
+ }
15
+ function isSameOrInside(cwd, root) {
16
+ const target = normalizePath(cwd).toLowerCase();
17
+ const base = normalizePath(root).toLowerCase();
18
+ return target === base || target.startsWith(`${base}/`);
19
+ }
20
+ /** Return the desktop project whose root owns `cwd` (longest prefix wins). */
21
+ export function projectForCwd(cwd, projects = []) {
22
+ if (!cwd)
23
+ return undefined;
24
+ let best;
25
+ let bestLen = -1;
26
+ for (const p of projects) {
27
+ if (!p.path || !isSameOrInside(cwd, p.path))
28
+ continue;
29
+ const len = normalizePath(p.path).length;
30
+ if (len > bestLen) {
31
+ best = p;
32
+ bestLen = len;
33
+ }
34
+ }
35
+ return best;
36
+ }
37
+ /** Group items by their cwd (project). When desktop project order is provided
38
+ * it wins; unknown projects fall back to newest-first. Empty cwd → "无项目"
39
+ * bucket, sorted last. Generic over any item carrying `cwd` + `updatedAt`. */
40
+ export function groupByProject(items, projects = [], lang = loadUILanguage()) {
41
+ const map = new Map();
42
+ for (const it of items) {
43
+ const key = projectForCwd(it.cwd, projects)?.path ?? it.cwd ?? "";
44
+ const arr = map.get(key);
45
+ if (arr)
46
+ arr.push(it);
47
+ else
48
+ map.set(key, [it]);
49
+ }
50
+ const projectByPath = new Map(projects.map((p, i) => [p.path, { ...p, order: i }]));
51
+ const groups = [...map.entries()].map(([cwd, list]) => ({
52
+ cwd,
53
+ name: cwd
54
+ ? projectByPath.get(cwd)?.name || basename(cwd) || cwd
55
+ : translate(lang, "mobile.format.noProject"),
56
+ items: list,
57
+ updatedAt: Math.max(...list.map((i) => i.updatedAt)),
58
+ }));
59
+ groups.sort((a, b) => {
60
+ if (!a.cwd && b.cwd)
61
+ return 1; // 无项目 sinks to bottom
62
+ if (a.cwd && !b.cwd)
63
+ return -1;
64
+ const ao = projectByPath.get(a.cwd)?.order;
65
+ const bo = projectByPath.get(b.cwd)?.order;
66
+ if (ao !== undefined && bo !== undefined && ao !== bo)
67
+ return ao - bo;
68
+ if (ao !== undefined && bo === undefined)
69
+ return -1;
70
+ if (ao === undefined && bo !== undefined)
71
+ return 1;
72
+ return b.updatedAt - a.updatedAt; // newest project first
73
+ });
74
+ return groups;
75
+ }
76
+ /** Coarse relative time ("刚刚" / "5 分钟前" / "3 小时前" / "2 天前").
77
+ * `now` is injectable for deterministic tests. */
78
+ export function relativeTime(ts, now = Date.now(), lang = loadUILanguage()) {
79
+ const diff = Math.max(0, now - ts);
80
+ const sec = Math.floor(diff / 1000);
81
+ if (sec < 60)
82
+ return translate(lang, "mobile.format.justNow");
83
+ const min = Math.floor(sec / 60);
84
+ if (min < 60)
85
+ return translate(lang, "mobile.format.minutesAgo", { n: min });
86
+ const hr = Math.floor(min / 60);
87
+ if (hr < 24)
88
+ return translate(lang, "mobile.format.hoursAgo", { n: hr });
89
+ const day = Math.floor(hr / 24);
90
+ if (day < 30)
91
+ return translate(lang, "mobile.format.daysAgo", { n: day });
92
+ const mon = Math.floor(day / 30);
93
+ if (mon < 12)
94
+ return translate(lang, "mobile.format.monthsAgo", { n: mon });
95
+ return translate(lang, "mobile.format.yearsAgo", { n: Math.floor(mon / 12) });
96
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure mappers between server payloads and the shapes the phone UI uses:
3
+ * - room messages (RoomManager.messages.jsonl) → streamReducer events
4
+ * - AskUser approval args → option labels
5
+ * Extracted from useRemoteApp so they're unit-testable without a DOM.
6
+ */
7
+ /**
8
+ * Map a room message (one messages.jsonl line) into a reducer event.
9
+ *
10
+ * The shapes MUST match what RoomManager actually persists (room-manager.ts
11
+ * onAgentEvent/send): `{from:'agent',type:'text'}`, `{type:'tool',tool,summary}`,
12
+ * `{type:'tool_result',summary,isError}`, `{type:'turn_end',reason}`,
13
+ * `{from:'system',type:'error',text}`, `{from:'system',type:'agent_exit',reason}`,
14
+ * and `{from:'system',type:'room_created',text}`. Anything unmapped → `_noop`.
15
+ */
16
+ export declare function roomMsgToEvent(msg: unknown): unknown;
17
+ /**
18
+ * Map a `room.history.ok` payload's `messages` into replay events. The payload
19
+ * comes off the WebSocket from the (untrusted) host — `messages` may be missing
20
+ * or, on a malformed/hostile message, not an array. `(x ?? []).map` only guards
21
+ * null/undefined, so `messages: 123` would throw a TypeError and white-screen
22
+ * the phone. Guard with Array.isArray → non-arrays yield an empty replay.
23
+ */
24
+ export declare function roomHistoryToEvents(messages: unknown): unknown[];
25
+ /**
26
+ * Map a `ccRoom.readHistory.ok` payload's `messages` (core HistoryMessage shape:
27
+ * `{role, text, tools?}`) into replay events for the chat reducer. Like
28
+ * roomHistoryToEvents, the payload is untrusted off the WebSocket, so guard the
29
+ * array and each entry. A message expands to its tool starts first (so the tools
30
+ * render under the turn) followed by the prose bubble.
31
+ */
32
+ export declare function ccHistoryToEvents(messages: unknown): unknown[];
33
+ /** Detect an AskUser-style approval and pull its option labels + optionsOnly. */
34
+ export declare function extractAskUserOptions(args: Record<string, unknown> | undefined): {
35
+ options: string[];
36
+ optionsOnly: boolean;
37
+ } | undefined;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Pure mappers between server payloads and the shapes the phone UI uses:
3
+ * - room messages (RoomManager.messages.jsonl) → streamReducer events
4
+ * - AskUser approval args → option labels
5
+ * Extracted from useRemoteApp so they're unit-testable without a DOM.
6
+ */
7
+ /**
8
+ * Map a room message (one messages.jsonl line) into a reducer event.
9
+ *
10
+ * The shapes MUST match what RoomManager actually persists (room-manager.ts
11
+ * onAgentEvent/send): `{from:'agent',type:'text'}`, `{type:'tool',tool,summary}`,
12
+ * `{type:'tool_result',summary,isError}`, `{type:'turn_end',reason}`,
13
+ * `{from:'system',type:'error',text}`, `{from:'system',type:'agent_exit',reason}`,
14
+ * and `{from:'system',type:'room_created',text}`. Anything unmapped → `_noop`.
15
+ */
16
+ export function roomMsgToEvent(msg) {
17
+ const m = msg;
18
+ const from = m.from;
19
+ const type = m.type;
20
+ // A user line: the agent's resident transcript records the prompt verbatim.
21
+ if (from === "user") {
22
+ return {
23
+ type: "user_message",
24
+ text: m.text,
25
+ ...(Array.isArray(m.attachments) ? { attachments: m.attachments } : {}),
26
+ };
27
+ }
28
+ // RoomManager writes agent prose as type:"text" — each line is a COMPLETE
29
+ // chunk claude emitted between tool calls, not a token delta. Map to
30
+ // `assistant_text` (its own finished bubble) so consecutive chunks render as
31
+ // separate bubbles interleaved with tools ("说一句 → 干活 → 再说一句"),
32
+ // instead of text_delta folding them all into one bubble.
33
+ if (type === "text")
34
+ return { type: "assistant_text", text: m.text };
35
+ // Tool start carries a human `summary` (not structured args). Surface it via
36
+ // the tool item's summary field. Prefer the real claude tool_use id (toolId)
37
+ // so the result can be paired by id; fall back to the seq-derived id for
38
+ // legacy messages that predate id threading.
39
+ if (type === "tool") {
40
+ const toolId = typeof m.toolId === "string" ? m.toolId : undefined;
41
+ // `args` carries the FULL tool_use input (e.g. a sub-agent's `prompt`), so
42
+ // the card can show real parameters rather than the lossy one-field summary.
43
+ // Omit the key when the message predates args persistence (legacy lines).
44
+ const args = m.args && typeof m.args === "object" ? m.args : undefined;
45
+ return {
46
+ type: "tool_use_start",
47
+ toolCall: { id: toolId ?? `room-tool-${m.seq ?? ""}`, toolName: m.tool, summary: m.summary, ...(args ? { args } : {}) },
48
+ };
49
+ }
50
+ // Tool result: if we have the real id, emit an id-paired `tool_result` so the
51
+ // reducer seals exactly the matching card (correct under parallel tools).
52
+ // Without an id (legacy), fall back to `room_tool_result` (seal the last open
53
+ // tool) — the old, best-effort behavior.
54
+ if (type === "tool_result") {
55
+ const toolId = typeof m.toolId === "string" ? m.toolId : undefined;
56
+ if (toolId) {
57
+ return {
58
+ type: "tool_result",
59
+ result: { id: toolId, result: m.summary, isError: m.isError },
60
+ };
61
+ }
62
+ return { type: "room_tool_result", summary: m.summary, isError: m.isError };
63
+ }
64
+ if (type === "turn_end")
65
+ return { type: "turn_complete", reason: m.reason ?? "completed" };
66
+ if (type === "error")
67
+ return { type: "error", error: m.text };
68
+ if (type === "agent_exit")
69
+ return { type: "error", error: `agent 退出 (code ${m.reason ?? "?"})` };
70
+ // room_created (audit anchor) and anything else have no visible rendering.
71
+ return { type: "_noop" };
72
+ }
73
+ /**
74
+ * Map a `room.history.ok` payload's `messages` into replay events. The payload
75
+ * comes off the WebSocket from the (untrusted) host — `messages` may be missing
76
+ * or, on a malformed/hostile message, not an array. `(x ?? []).map` only guards
77
+ * null/undefined, so `messages: 123` would throw a TypeError and white-screen
78
+ * the phone. Guard with Array.isArray → non-arrays yield an empty replay.
79
+ */
80
+ export function roomHistoryToEvents(messages) {
81
+ return Array.isArray(messages) ? messages.map(roomMsgToEvent) : [];
82
+ }
83
+ /**
84
+ * Map a `ccRoom.readHistory.ok` payload's `messages` (core HistoryMessage shape:
85
+ * `{role, text, tools?}`) into replay events for the chat reducer. Like
86
+ * roomHistoryToEvents, the payload is untrusted off the WebSocket, so guard the
87
+ * array and each entry. A message expands to its tool starts first (so the tools
88
+ * render under the turn) followed by the prose bubble.
89
+ */
90
+ export function ccHistoryToEvents(messages) {
91
+ if (!Array.isArray(messages))
92
+ return [];
93
+ const out = [];
94
+ for (let i = 0; i < messages.length; i++) {
95
+ const m = messages[i];
96
+ if (!m || typeof m !== "object")
97
+ continue;
98
+ const role = m.role === "assistant" ? "assistant" : "user";
99
+ if (role === "user") {
100
+ out.push({ type: "user_message", text: typeof m.text === "string" ? m.text : "" });
101
+ continue;
102
+ }
103
+ // Assistant: prose first (its own finished bubble via assistant_text), then
104
+ // any tool calls — matching how claude actually emits a turn ("说一句 → 干
105
+ // 活"). In practice each CC transcript message is single-type (pure text OR
106
+ // pure tools, never mixed), so only one branch fires per message; ordering
107
+ // across messages is preserved by the loop. Each text chunk is a SEPARATE
108
+ // bubble (assistant_text), not folded.
109
+ const text = typeof m.text === "string" ? m.text : "";
110
+ if (text)
111
+ out.push({ type: "assistant_text", text });
112
+ const tools = Array.isArray(m.tools) ? m.tools : [];
113
+ for (let t = 0; t < tools.length; t++) {
114
+ const tool = tools[t];
115
+ if (!tool || typeof tool !== "object")
116
+ continue;
117
+ const args = tool.args && typeof tool.args === "object" ? tool.args : undefined;
118
+ out.push({
119
+ type: "tool_use_start",
120
+ toolCall: {
121
+ id: `cc-hist-${i}-${t}`,
122
+ toolName: typeof tool.name === "string" ? tool.name : "tool",
123
+ summary: typeof tool.summary === "string" ? tool.summary : undefined,
124
+ ...(args ? { args } : {}),
125
+ },
126
+ });
127
+ }
128
+ out.push({ type: "turn_complete", reason: "completed" });
129
+ }
130
+ return out;
131
+ }
132
+ /** Detect an AskUser-style approval and pull its option labels + optionsOnly. */
133
+ export function extractAskUserOptions(args) {
134
+ if (!args)
135
+ return undefined;
136
+ const opts = args.options;
137
+ if (Array.isArray(opts)) {
138
+ const labels = opts
139
+ .map((o) => (typeof o === "string" ? o : o?.label))
140
+ .filter((l) => typeof l === "string");
141
+ if (labels.length)
142
+ return { options: labels, optionsOnly: args.optionsOnly === true };
143
+ }
144
+ return undefined;
145
+ }
@@ -0,0 +1,23 @@
1
+ import type { MobileImageAttachment, MobileImageBase } from "@cjhyy/code-shell-core";
2
+ export declare const MOBILE_INLINE_IMAGE_BYTES: number;
3
+ export declare const MOBILE_INLINE_TOTAL_BYTES: number;
4
+ export declare const MOBILE_MAX_IMAGE_BYTES: number;
5
+ export declare const MOBILE_MAX_ATTACHMENT_TOTAL_BYTES: number;
6
+ export declare const MOBILE_MAX_ATTACHMENTS = 4;
7
+ export interface MobileComposerAttachment {
8
+ clientId: string;
9
+ file: File;
10
+ }
11
+ export interface MobileUploadTicket {
12
+ clientId: string;
13
+ uploadId: string;
14
+ putUrl: string;
15
+ expiresAt: number;
16
+ }
17
+ interface PrepareOptions {
18
+ beginUpload: (metadata: MobileImageBase) => Promise<MobileUploadTicket>;
19
+ fetch: typeof fetch;
20
+ }
21
+ export declare function normalizeMobileImage(file: File): Promise<File>;
22
+ export declare function prepareMobileAttachments(attachments: MobileComposerAttachment[], options: PrepareOptions): Promise<MobileImageAttachment[]>;
23
+ export {};
@@ -0,0 +1,121 @@
1
+ export const MOBILE_INLINE_IMAGE_BYTES = 256 * 1024;
2
+ export const MOBILE_INLINE_TOTAL_BYTES = 512 * 1024;
3
+ export const MOBILE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
4
+ export const MOBILE_MAX_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024;
5
+ export const MOBILE_MAX_ATTACHMENTS = 4;
6
+ const SUPPORTED_MIMES = new Set([
7
+ "image/png",
8
+ "image/jpeg",
9
+ "image/webp",
10
+ "image/gif",
11
+ ]);
12
+ function jpegName(name) {
13
+ return /\.[^.]+$/.test(name) ? name.replace(/\.[^.]+$/, ".jpg") : `${name}.jpg`;
14
+ }
15
+ async function canvasJpeg(file) {
16
+ if (typeof createImageBitmap !== "function" || typeof document === "undefined") {
17
+ throw new Error(`Unsupported image format: ${file.type || file.name}`);
18
+ }
19
+ let bitmap;
20
+ try {
21
+ bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
22
+ }
23
+ catch {
24
+ throw new Error(`Unsupported image format: ${file.type || file.name}`);
25
+ }
26
+ try {
27
+ const maxEdge = 4096;
28
+ const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
29
+ const canvas = document.createElement("canvas");
30
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
31
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
32
+ const context = canvas.getContext("2d");
33
+ if (!context)
34
+ throw new Error("Image conversion is unavailable");
35
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
36
+ const blob = await new Promise((resolve, reject) => canvas.toBlob((next) => (next ? resolve(next) : reject(new Error("Image conversion failed"))), "image/jpeg", 0.88));
37
+ return new File([blob], jpegName(file.name), {
38
+ type: "image/jpeg",
39
+ lastModified: file.lastModified,
40
+ });
41
+ }
42
+ finally {
43
+ bitmap.close();
44
+ }
45
+ }
46
+ export async function normalizeMobileImage(file) {
47
+ const mime = file.type.toLowerCase();
48
+ if (mime === "image/jpg") {
49
+ return new File([await file.arrayBuffer()], file.name, {
50
+ type: "image/jpeg",
51
+ lastModified: file.lastModified,
52
+ });
53
+ }
54
+ if (SUPPORTED_MIMES.has(mime) && file.size <= MOBILE_MAX_IMAGE_BYTES) {
55
+ return file;
56
+ }
57
+ // Preserve supported animated GIFs rather than silently flattening them.
58
+ if (mime === "image/gif")
59
+ throw new Error("Image exceeds the 10 MiB limit");
60
+ const converted = await canvasJpeg(file);
61
+ if (converted.size > MOBILE_MAX_IMAGE_BYTES)
62
+ throw new Error("Image exceeds the 10 MiB limit");
63
+ return converted;
64
+ }
65
+ function bytesToBase64(bytes) {
66
+ let binary = "";
67
+ const chunkSize = 0x8000;
68
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
69
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
70
+ }
71
+ return btoa(binary);
72
+ }
73
+ async function dataUrl(file) {
74
+ const bytes = new Uint8Array(await file.arrayBuffer());
75
+ return `data:${file.type};base64,${bytesToBase64(bytes)}`;
76
+ }
77
+ export async function prepareMobileAttachments(attachments, options) {
78
+ if (attachments.length > MOBILE_MAX_ATTACHMENTS) {
79
+ throw new Error(`At most ${MOBILE_MAX_ATTACHMENTS} images can be sent`);
80
+ }
81
+ const normalized = [];
82
+ let total = 0;
83
+ for (const attachment of attachments) {
84
+ const file = await normalizeMobileImage(attachment.file);
85
+ const mime = file.type.toLowerCase();
86
+ if (!SUPPORTED_MIMES.has(mime))
87
+ throw new Error(`Unsupported image format: ${file.type}`);
88
+ total += file.size;
89
+ normalized.push({ clientId: attachment.clientId, file, mime });
90
+ }
91
+ if (total > MOBILE_MAX_ATTACHMENT_TOTAL_BYTES) {
92
+ throw new Error("Image attachments exceed the 20 MiB message limit");
93
+ }
94
+ const descriptors = [];
95
+ let inlineBytes = 0;
96
+ for (const { clientId, file, mime } of normalized) {
97
+ const base = {
98
+ clientId,
99
+ name: file.name,
100
+ mime,
101
+ size: file.size,
102
+ };
103
+ if (file.size <= MOBILE_INLINE_IMAGE_BYTES &&
104
+ inlineBytes + file.size <= MOBILE_INLINE_TOTAL_BYTES) {
105
+ descriptors.push({ ...base, transport: "inline", dataUrl: await dataUrl(file) });
106
+ inlineBytes += file.size;
107
+ continue;
108
+ }
109
+ const ticket = await options.beginUpload(base);
110
+ const response = await options.fetch(ticket.putUrl, {
111
+ method: "PUT",
112
+ headers: { "Content-Type": mime },
113
+ body: file,
114
+ credentials: "same-origin",
115
+ });
116
+ if (!response.ok)
117
+ throw new Error(`Image upload failed (${response.status})`);
118
+ descriptors.push({ ...base, transport: "upload", uploadId: ticket.uploadId });
119
+ }
120
+ return descriptors;
121
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Pairing-token helpers. The desktop QR encodes `…/mobile?pairing=<token>`;
3
+ * the phone reads the token off the URL to send pair.complete. One-use,
4
+ * 10-minute tokens are enforced server-side (PairingTokenManager).
5
+ */
6
+ export declare function parsePairingToken(search: string): string | null;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Pairing-token helpers. The desktop QR encodes `…/mobile?pairing=<token>`;
3
+ * the phone reads the token off the URL to send pair.complete. One-use,
4
+ * 10-minute tokens are enforced server-side (PairingTokenManager).
5
+ */
6
+ export function parsePairingToken(search) {
7
+ return new URLSearchParams(search).get("pairing");
8
+ }
@@ -0,0 +1,5 @@
1
+ export type Risk = "low" | "medium" | "high";
2
+ export declare function summarizeApproval(args: Record<string, unknown> | undefined, risk?: string, toolName?: string): {
3
+ summary: string;
4
+ risk: Risk;
5
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Approval-card summary + risk normalization. Extracted from the old inline
3
+ * mobile-ui (the `ks=['command','file_path',…]` scan + medium fallback) so it
4
+ * is testable. The phone shows this on every approval.request /
5
+ * agent/approvalRequest. Decisions still go through the core permission engine
6
+ * (the phone never bypasses it — design §6).
7
+ */
8
+ import { t } from "../i18n/translate.js";
9
+ /** Arg keys to surface as the one-line summary, in priority order. */
10
+ const SUMMARY_KEYS = ["command", "file_path", "path", "url", "pattern", "query"];
11
+ export function summarizeApproval(args, risk, toolName) {
12
+ let summary = "";
13
+ // Match on the tool NAME, never on arg shape: a third-party/MCP tool whose
14
+ // args happen to carry source/scope/resource must not masquerade as a
15
+ // data-source read (nor shadow its real `command`/`file_path` summary).
16
+ const source = args?.source;
17
+ const scope = args?.scope;
18
+ const resource = args?.resource;
19
+ if (toolName === "ReadSource" &&
20
+ typeof source === "string" &&
21
+ source.length > 0 &&
22
+ typeof scope === "string" &&
23
+ scope.length > 0 &&
24
+ typeof resource === "string" &&
25
+ resource.length > 0) {
26
+ summary = t("mobile.approval.readSourceSummary", { source, scope, resource });
27
+ }
28
+ else {
29
+ for (const k of SUMMARY_KEYS) {
30
+ const v = args?.[k];
31
+ if (typeof v === "string" && v.length > 0) {
32
+ summary = v;
33
+ break;
34
+ }
35
+ }
36
+ }
37
+ if (!summary)
38
+ summary = JSON.stringify(args ?? {});
39
+ // Normalize the server-supplied risk. The three known levels pass through;
40
+ // anything else (absent → fall through to medium default; but an UNKNOWN
41
+ // non-empty value like "critical" from a corrupted/malicious server) must NOT
42
+ // silently downgrade to medium — fail SAFE to "high" so the badge can't
43
+ // under-state a dangerous op and nudge an approve. The core permission engine
44
+ // is still authoritative; this only governs the user-visible badge.
45
+ let r;
46
+ if (risk === "low" || risk === "medium" || risk === "high") {
47
+ r = risk;
48
+ }
49
+ else if (risk === undefined || risk === "") {
50
+ r = "medium"; // unspecified → the historical neutral default
51
+ }
52
+ else {
53
+ r = "high"; // unrecognized non-empty value → fail safe (don't under-state)
54
+ }
55
+ return { summary, risk: r };
56
+ }