wave-code 1.5.1 → 1.6.1

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 (39) hide show
  1. package/README.md +3 -2
  2. package/bin/wave-code.js +2 -0
  3. package/dist/bundle/wave.mjs +735 -686
  4. package/package.json +8 -10
  5. package/src/cli.tsx +59 -3
  6. package/src/components/App.tsx +13 -13
  7. package/src/components/ChatInterface.tsx +4 -2
  8. package/src/components/InputBox.tsx +6 -3
  9. package/src/components/McpManager.tsx +26 -0
  10. package/src/components/MessageBlockItem.tsx +14 -2
  11. package/src/components/Notifications.tsx +6 -3
  12. package/src/components/RemoteControlView.tsx +1 -1
  13. package/src/contexts/useChat.tsx +88 -43
  14. package/src/daemon/commands.ts +266 -44
  15. package/src/daemon/daemonLog.ts +132 -0
  16. package/src/daemon/socketPath.ts +123 -0
  17. package/src/daemon-cli.ts +19 -6
  18. package/src/hooks/useExitSignals.ts +83 -0
  19. package/src/hooks/useRemoteControl.ts +16 -4
  20. package/src/index.ts +6 -0
  21. package/src/print-cli.ts +20 -10
  22. package/src/remote/hostIdentity.ts +1 -0
  23. package/src/remote/imagePreview.ts +182 -0
  24. package/src/remote/messageWindow.ts +5 -4
  25. package/src/remote/notificationCoalescer.ts +2 -2
  26. package/src/remote/relayClient.ts +1 -1
  27. package/src/remote/remoteControlCliHost.ts +46 -8
  28. package/src/remote/remoteControlService.ts +75 -18
  29. package/src/remote/remoteNotifications.ts +31 -9
  30. package/src/remote/textPreview.ts +155 -0
  31. package/src/stdio/agentBridge.ts +407 -72
  32. package/src/stdio/daemonServer.ts +49 -33
  33. package/src/stdio/hostSlashCommands.ts +9 -4
  34. package/src/stdio/startupStage.ts +22 -0
  35. package/src/stdio/uiPayload.ts +49 -0
  36. package/src/stdio-cli.ts +9 -1
  37. package/src/utils/argvCompat.ts +23 -0
  38. package/src/utils/printOutput.ts +1 -1
  39. package/src/utils/worktree.ts +222 -16
@@ -10,7 +10,7 @@
10
10
  * white list and the page need no special case.
11
11
  *
12
12
  * Two consequences of "one session, and it is the one on screen", both
13
- * deliberate (docs/specs/ui/remote-control.md, 前台 CLI 故事):
13
+ * deliberate (docs/specs/remote/remote-web.md, 前台 CLI 故事):
14
14
  *
15
15
  * - Both ways of asking for the session list (`listDaemonSessions`, which the
16
16
  * page reads, and `listSessions`) answer with exactly this session. The
@@ -42,6 +42,8 @@ import {
42
42
  hostSlashCommandsFor,
43
43
  toSlashCommandInfo,
44
44
  } from "../stdio/hostSlashCommands.js";
45
+ import { readImagePreview } from "./imagePreview.js";
46
+ import { readTextPreview } from "./textPreview.js";
45
47
  import { messagesForUi } from "../stdio/uiPayload.js";
46
48
  import {
47
49
  InvalidMessageWindowError,
@@ -163,6 +165,17 @@ export class RemoteControlCliHost implements RemoteControlHost {
163
165
  // 会落到「这台电脑上的会话不支持这个操作」——白名单放行 ≠ 宿主实现。
164
166
  case "getRecentWorkdirs":
165
167
  return { dirs: readRecentWorkdirs() };
168
+ // 远控页按需取图(spec「点消息里的图片路径看原图」)。与 daemon 那条
169
+ // 共用 `imagePreview.ts`:判型按解码后的真实容器、压到单帧预算内。
170
+ // 不实现的话页面会落到 hostUnsupported,而「白名单放行 ≠ 宿主实现」
171
+ // 这份规格已经踩过一次(见场景 7)。
172
+ case "getImagePreview":
173
+ return readImagePreview(typeof p.path === "string" ? p.path : "");
174
+ case "getTextPreview":
175
+ // 与取图同一条按需读取通道(spec remote/remote-web.md「点消息里的文件
176
+ // 路径看内容」);前台 CLI 会话同样要能回,见图片故事场景 7 那条
177
+ // 「白名单放行 ≠ 宿主实现」。
178
+ return readTextPreview(typeof p.path === "string" ? p.path : "");
166
179
  case "rememberRecentWorkdir":
167
180
  rememberRecentWorkdir(typeof p.workdir === "string" ? p.workdir : "");
168
181
  return null;
@@ -203,11 +216,20 @@ export class RemoteControlCliHost implements RemoteControlHost {
203
216
  // business travelling over a relay (and would be dropped silently,
204
217
  // leaving the frame's shape to chance). The host-local half comes
205
218
  // from the one table, filtered for this entry (spec
206
- // ui/slash-commands.md「弹窗里的宿主命令只有一份表」) — the same bytes
207
- // the daemon returns, so the phone sees one list either way.
219
+ // commands/slash-commands.md「弹窗里的宿主命令只有一份表」) — the same
220
+ // bytes the daemon returns, with one exception: `/resume` is dropped
221
+ // here, because this host holds exactly one session slot. Restoring a
222
+ // transcript from disk would mean replacing the session the user is
223
+ // watching in the terminal (and `initialize` is not implemented here
224
+ // either), so the row would be one that does nothing when picked —
225
+ // the exact defect the one-table rule forbids. The table declares the
226
+ // entry's ceiling; the host narrows it to what it can actually run
227
+ // (spec commands/slash-commands.md 场景 4).
208
228
  commands: [
209
229
  ...this.session.slashCommands().map(toSlashCommandInfo),
210
- ...hostSlashCommandsFor("remote-page"),
230
+ ...hostSlashCommandsFor("remote-page").filter(
231
+ (command) => command.id !== "resume",
232
+ ),
211
233
  ],
212
234
  };
213
235
  default:
@@ -227,10 +249,19 @@ export class RemoteControlCliHost implements RemoteControlHost {
227
249
  decision?: unknown;
228
250
  };
229
251
  if (typeof p.requestId !== "string" || !p.decision) return;
230
- this.session.respondPermission(
231
- p.requestId,
232
- p.decision as PermissionDecision,
233
- );
252
+ const decision = p.decision as PermissionDecision;
253
+ this.session.respondPermission(p.requestId, decision);
254
+ // 不带反馈的拒绝 = 用户在页面上关掉了确认卡(关闭键 / Esc),本轮就此结束:
255
+ // 与桌面端 / VS Code / JetBrains 的 `denied → abortMessage()`、以及 Claude
256
+ // Code 的 `cancelAndAbort`(无 feedback 直接 abort 整轮)同一条语义。少了这
257
+ // 一步,手机上关掉提问弹窗后模型会拿着「用户拒绝了操作」继续往下做,而同一
258
+ // 张卡在桌面端关掉就停(spec ui/confirm-ui.md 场景 2 / 5、remote-web.md
259
+ // 场景 24)。**带 message 的拒绝不中断**——那是用户给出理由后拒绝这一次工具
260
+ // 调用,模型要照反馈继续或改问。
261
+ const rejectionFeedback = decision.message?.trim();
262
+ if (decision.behavior === "deny" && !rejectionFeedback) {
263
+ this.session.abort();
264
+ }
234
265
  }
235
266
 
236
267
  /**
@@ -366,10 +397,17 @@ export class RemoteControlCliHost implements RemoteControlHost {
366
397
  messages: Message[];
367
398
  contextUsagePercent: number;
368
399
  reset?: boolean;
400
+ anchorLost?: boolean;
401
+ hasMore?: boolean;
369
402
  }> {
370
403
  const contextUsagePercent = this.session.contextUsagePercent();
371
404
  const window: MessageWindow = {};
372
405
  if (typeof p.since === "string") window.since = p.since;
406
+ // 上翻取更早的锚点(spec ui/message-rendering-system.md「首屏一页,上翻按需
407
+ // 取更早」,远控页自 2026-11-06 起也翻)。少这一行,`{before, limit}` 会被当成
408
+ // 「只要一页尾巴」——回的是同一段尾部、`hasMore` 仍是 true,页面每次滚到顶都
409
+ // 白要一次,永远翻不动。
410
+ if (typeof p.before === "string") window.before = p.before;
373
411
  if (p.limit !== undefined) window.limit = Number(p.limit);
374
412
 
375
413
  let normalized: MessageWindow | undefined;
@@ -49,6 +49,7 @@ import {
49
49
  type RelaySocketFactory,
50
50
  } from "./relayClient.js";
51
51
  import type { RemoteControlEndpoint } from "./remoteControlConfig.js";
52
+ import { resultForRemote } from "../stdio/uiPayload.js";
52
53
 
53
54
  /**
54
55
  * Methods the remote channel accepts.
@@ -131,7 +132,7 @@ export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
131
132
  "renameSession",
132
133
  "compact",
133
134
  "getSlashCommands",
134
- // 发起新会话(spec ui/remote-control.md「Web 端也能发起新会话」):白名单
135
+ // 发起新会话(spec remote/remote-web.md「Web 端也能发起新会话」):白名单
135
136
  // 从「只碰已存在的会话」显式扩到「可在选定目录建会话」。initialize 在下方
136
137
  // 收敛入参——放行的是「建一条会话」这个能力,不是它的整张参数表。
137
138
  "initialize",
@@ -151,14 +152,17 @@ export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
151
152
  // 排队消息三动作里的两条(「立即发送」= sendMessage{force} + 删这条)。
152
153
  "updateQueuedMessage",
153
154
  "deleteQueuedMessageById",
154
- // 后台任务与工作流:读输出 / 停止(不毁文件,回执即可)。
155
- // 注意这里没有 `backgroundCurrentTask`(把当前这轮转为后台):页面上唯一的入口
156
- // 是 Ctrl/Cmd+B,手机键盘没有这个键 —— 放行一个页面根本调不到的方法,只是把
157
- // 暴露面放大,与本清单「跟页面能力一致」的判据相反。
155
+ // 后台任务与工作流:读输出 / 停止 / 把本轮转到后台(不毁文件,回执即可)。
156
+ // `backgroundCurrentTask` 曾经因为「页面上唯一的入口是 Ctrl/Cmd+B,手机键盘
157
+ // 没有这个键」而不在此列:放行一个页面根本调不到的方法,只是把暴露面放大,
158
+ // 与本清单「跟页面能力一致」的判据相反。2026-10-30 助手消息上出现了后台按钮
159
+ // (spec: task-background-execution.md「前台工具后台化」场景 3–6),页面上有了
160
+ // 调用方,这条判据随之满足。
158
161
  "getBackgroundTaskOutput",
159
162
  "stopBackgroundTask",
160
163
  "getWorkflowRuns",
161
164
  "stopWorkflowRun",
165
+ "backgroundCurrentTask",
162
166
  // `@` 文件建议与历史提示词:都只读,锚在信封 sessionId 的 workdir 上。
163
167
  "searchFiles",
164
168
  "getPromptHistory",
@@ -184,6 +188,33 @@ export const REMOTE_ALLOWED_METHODS: ReadonlySet<string> = new Set([
184
188
  // 的任何东西(那个能力本来就在 `initialize` 里)。
185
189
  "getRecentWorkdirs",
186
190
  "rememberRecentWorkdir",
191
+ // ── 第六批:点消息里的图片路径看原图(spec「点消息里的图片路径看原图」)──
192
+ // 读:`getImagePreview` 把**一个路径**变成一张压缩到单帧预算内的图。
193
+ // 这是第一个收调用方 path 的读方法,所以边界是显式的三道而不是目录前缀:
194
+ // ① 页面只对图片扩展名给点击入口,路径来自对话内容(没有手输入口);
195
+ // ② 宿主解码后按**真实容器**判型(`imagePreview.ts` 的 ALLOWED_SOURCE_FORMATS),
196
+ // 扩展名只是路由提示,指向文本文件的软链在这一道被挡下;
197
+ // ③ 只读、不写不删,回包体积被压缩档位限住。
198
+ // **不按目录限制**是刻意的:真机截图落在 `/tmp`,限目录等于打掉主场景
199
+ // (见规格边界情况那条「边界画在内容类型上,不画在目录上」)。
200
+ "getImagePreview",
201
+ // 读:`getTextPreview` 是同一个形态的第二条(spec「点消息里的文件路径看内容」)
202
+ // ——把**一个路径**变成它开头那段文本。它**不设扩展名白名单**:判据是「读出来
203
+ // 是不是文本」(含 NUL 或大部分解不出即拒),不是「名字像不像文本」。
204
+ // 为什么这条通道不必也不该加路径限制:`sendMessage` + `permissionResponse` 已在
205
+ // 白名单里,持页面者让模型 Read 同一个路径拿到的就是同一份内容——任何这里加的
206
+ // 目录/扩展名限制都是假防护,只挡住更好用的入口(见规格边界情况那条)。
207
+ "getTextPreview",
208
+ // ── 第七批:`/resume`,接着磁盘上的历史会话聊(spec remote/remote-web.md
209
+ // 「手机端也能接着磁盘上的历史会话聊」)──
210
+ // 读:`listAllSessions` 枚举这台机器**所有项目目录**下的主会话(跨工作目录),
211
+ // 页面按最近活跃时间取前 200 条(帧预算是硬约束,见规格场景 2)。
212
+ // **`restoreSession` 仍然不放行**:它是「原地载入」——把承接进程里某条正在跑的
213
+ // 会话换成另一条(在桌面端就等于换掉用户正看着的那个 pane),与「手机只当控制
214
+ // 面」相冲。页面打开一条磁盘会话走的是**新建**路径
215
+ // (`initialize { workdir, restoreSessionId }`):恢复出来的是一条新会话,没有
216
+ // 任何已有会话被替换,见 `pickInitializeParams` 的注释。
217
+ "listAllSessions",
187
218
  ]);
188
219
 
189
220
  /**
@@ -206,10 +237,22 @@ export const REMOTE_HOST_UNSUPPORTED = -32011;
206
237
 
207
238
  /**
208
239
  * The only `initialize` shape the remote channel may request: create a session
209
- * in a chosen directory, with an optional permission mode. Everything else in
210
- * its parameter surface (model / apiKey / tools / plugins / mcpServers /
211
- * restoreSessionId …) is dropped, not validated — a whitelisted method must
212
- * not smuggle in knobs the whitelist never approved.
240
+ * in a chosen directory, with an optional permission mode, and — since
241
+ * `/resume` (spec remote/remote-web.md「手机端也能接着磁盘上的历史会话聊」) — an
242
+ * optional `restoreSessionId` that seeds that **new** session from a transcript
243
+ * already on disk. Everything else in its parameter surface (model / apiKey /
244
+ * tools / plugins / mcpServers …) is dropped, not validated — a whitelisted
245
+ * method must not smuggle in knobs the whitelist never approved.
246
+ *
247
+ * Why `restoreSessionId` belongs here and not on the old "no re-attach" rule:
248
+ * that rule was about **switching** between live sessions (the page does that
249
+ * with `getMessages` on its own `sessionId`, never by re-binding the host's
250
+ * agent). `/resume` is a different act — the target is a transcript nobody is
251
+ * running, which the process cannot serve until a session exists for it. The
252
+ * write is therefore "start a new session seeded from this history", exactly
253
+ * the same capability as `initialize { workdir }` (which the page has had since
254
+ * 「Web 端也能发起新会话」), and the session it creates is a fresh one: no live
255
+ * session is replaced.
213
256
  *
214
257
  * `entrypoint` is stamped here rather than picked: it describes the CHANNEL, not
215
258
  * the caller's request, and must not be spoofable (the page cannot claim to be
@@ -224,6 +267,9 @@ function pickInitializeParams(params: unknown): Record<string, unknown> {
224
267
  if (typeof p.permissionMode === "string" && p.permissionMode) {
225
268
  picked.permissionMode = p.permissionMode;
226
269
  }
270
+ if (typeof p.restoreSessionId === "string" && p.restoreSessionId) {
271
+ picked.restoreSessionId = p.restoreSessionId;
272
+ }
227
273
  return picked;
228
274
  }
229
275
 
@@ -579,13 +625,15 @@ export class RemoteControlService implements RemoteControlController {
579
625
  }
580
626
 
581
627
  // `initialize` is whitelisted for exactly one shape: create a session in a
582
- // chosen directory (and optionally set its permission mode). Its full
628
+ // chosen directory, optionally set its permission mode, and optionally seed
629
+ // it from a transcript on disk (`restoreSessionId`, `/resume`). Its full
583
630
  // parameter surface — apiKey / baseURL / model / tools / plugins /
584
631
  // mcpServers … — would let the page reshape the machine-side agent, so
585
- // everything outside the two allowed keys is dropped here rather than
586
- // trusted to the caller (spec 「Web 端也能发起新会话」场景 5). Re-attach
587
- // (`restoreSessionId`) is NOT part of the whitelisted shape: the page
588
- // switches sessions via `getMessages`, not by re-binding the host agent.
632
+ // everything outside those three keys is dropped here rather than trusted to
633
+ // the caller (spec 「Web 端也能发起新会话」场景 5、「手机端也能接着磁盘上
634
+ // 的历史会话聊」场景 4). Switching between *live* sessions still goes
635
+ // through `getMessages` on the page's own `sessionId`, never by re-binding
636
+ // an existing host agent.
589
637
  const requestParams =
590
638
  frame.method === "initialize"
591
639
  ? pickInitializeParams(frame.params)
@@ -624,10 +672,13 @@ export class RemoteControlService implements RemoteControlController {
624
672
  method: string,
625
673
  result: unknown,
626
674
  ): void {
675
+ // The page's copy: user image bytes never go over the relay
676
+ // (spec remote/remote-channel.md「远端通道只开一个白名单」场景 10).
677
+ const forRemote = resultForRemote(result);
627
678
  const envelope: RelayEnvelope = {
628
679
  id: replyId,
629
680
  method: "result",
630
- params: { result },
681
+ params: { result: forRemote },
631
682
  };
632
683
  const bytes = frameBytes(envelope);
633
684
  if (bytes <= REMOTE_MAX_FRAME_BYTES) {
@@ -635,7 +686,7 @@ export class RemoteControlService implements RemoteControlController {
635
686
  return;
636
687
  }
637
688
 
638
- const page = asMessagePage(result);
689
+ const page = asMessagePage(forRemote);
639
690
  if (page) {
640
691
  const kept = trailingCountThatFits(
641
692
  page.messages.length,
@@ -645,7 +696,7 @@ export class RemoteControlService implements RemoteControlController {
645
696
  // Not even one message fits: there is nothing to send that the phone
646
697
  // could render, and an empty list would only make it re-ask forever.
647
698
  if (kept > 0) {
648
- this.send(pageFrame(replyId, page, kept));
699
+ this.send(pageFrame(replyId, page, kept, kept < page.messages.length));
649
700
  return;
650
701
  }
651
702
  }
@@ -808,13 +859,19 @@ function pageFrame(
808
859
  replyId: number | string,
809
860
  page: MessagePage,
810
861
  count: number,
862
+ droppedEarlier = false,
811
863
  ): RelayEnvelope {
864
+ // 裁掉的是**最早**那几条,所以只要真裁过,「更早的还在」就必然成立——哪怕
865
+ // 宿主那一页说的 `hasMore: false`(上翻取到这条会话最早那一页时正是如此)。
866
+ // 不在这里改这个字段,页面会照着 false 把上翻开关关掉,被裁掉的几条就再也
867
+ // 翻不回来了;页大小因此只能是「大概几条」,**条数永远不是终止判据**。
868
+ const rest = droppedEarlier ? { ...page.rest, hasMore: true } : page.rest;
812
869
  return {
813
870
  id: replyId,
814
871
  method: "result",
815
872
  params: {
816
873
  result: {
817
- ...page.rest,
874
+ ...rest,
818
875
  messages: page.messages.slice(-count),
819
876
  },
820
877
  },
@@ -23,10 +23,12 @@ import type {
23
23
  AgentCallbacks,
24
24
  BackgroundTask,
25
25
  BackgroundTaskSummary,
26
+ NoticeLevel,
26
27
  PermissionMode,
27
28
  ToolBlockUpdateCallbackParams,
28
29
  ToolPermissionContext,
29
30
  } from "wave-agent-sdk";
31
+ import { getAutoCompactThreshold } from "wave-agent-sdk";
30
32
  import { toolBlockUpdateForUi } from "../stdio/uiPayload.js";
31
33
 
32
34
  /**
@@ -44,9 +46,25 @@ type StreamDelta = Parameters<
44
46
  * push and its `getSessionInfo` read). 0 when the cap is unknown, so a caller
45
47
  * that has one reads a number rather than a special case.
46
48
  */
47
- export function percentOf(tokens: number, maxInputTokens: number): number {
48
- if (!maxInputTokens || maxInputTokens <= 0) return 0;
49
- return Math.min(100, Math.round((tokens / maxInputTokens) * 100));
49
+ export function percentOf(
50
+ tokens: number,
51
+ contextWindow: number,
52
+ maxOutputTokens = 32_000,
53
+ ): number {
54
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return 0;
55
+ const calculatedThreshold = getAutoCompactThreshold(
56
+ contextWindow,
57
+ Number.isFinite(maxOutputTokens) && maxOutputTokens > 0
58
+ ? maxOutputTokens
59
+ : 32_000,
60
+ );
61
+ // Some host tests auto-mock the SDK barrel wholesale. Keep the wire helper
62
+ // usable in that environment without changing production's shared formula.
63
+ const threshold =
64
+ Number.isFinite(calculatedThreshold) && calculatedThreshold > 0
65
+ ? calculatedThreshold
66
+ : contextWindow;
67
+ return Math.min(100, Math.round((tokens / threshold) * 100));
50
68
  }
51
69
 
52
70
  /** The shapes both hosts put on the wire, keyed by the method they ride under. */
@@ -87,15 +105,19 @@ export const remoteWire = {
87
105
  return rest;
88
106
  },
89
107
 
90
- errorBlockAdded: (error: string) => ({ error }),
108
+ noticeAdded: (notice: { level: NoticeLevel; content: string }) => notice,
91
109
 
92
110
  loadingChange: (loading: boolean, latestTotalTokens?: number) => ({
93
111
  loading,
94
112
  ...(latestTotalTokens === undefined ? {} : { latestTotalTokens }),
95
113
  }),
96
114
 
97
- contextUsage: (latestTotalTokens: number, maxInputTokens: number) => ({
98
- percent: percentOf(latestTotalTokens, maxInputTokens),
115
+ contextUsage: (
116
+ latestTotalTokens: number,
117
+ contextWindow: number,
118
+ maxOutputTokens = 32_000,
119
+ ) => ({
120
+ percent: percentOf(latestTotalTokens, contextWindow, maxOutputTokens),
99
121
  }),
100
122
 
101
123
  permissionModeChange: (mode: PermissionMode) => ({ mode }),
@@ -174,7 +196,7 @@ export function remoteNotifications(
174
196
  | "onAssistantContentUpdated"
175
197
  | "onAssistantReasoningUpdated"
176
198
  | "onToolBlockUpdated"
177
- | "onErrorBlockAdded"
199
+ | "onNoticeAdded"
178
200
  >
179
201
  > {
180
202
  return {
@@ -193,8 +215,8 @@ export function remoteNotifications(
193
215
  onToolBlockUpdated: (params) => {
194
216
  notify("toolBlockUpdated", remoteWire.toolBlockUpdated(params));
195
217
  },
196
- onErrorBlockAdded: (error: string) => {
197
- notify("errorBlockAdded", remoteWire.errorBlockAdded(error));
218
+ onNoticeAdded: (notice) => {
219
+ notify("noticeAdded", remoteWire.noticeAdded(notice));
198
220
  },
199
221
  };
200
222
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * On-demand text preview for the remote-control page.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Same missing channel as {@link readImagePreview}: the phone has no file
7
+ * system, so "the agent wrote it to `/tmp/build.log`" is a path the user can
8
+ * read nowhere. The image channel solved that for pictures; this one solves it
9
+ * for everything else (spec `remote/remote-web.md`「点消息里的文件路径看内容」).
10
+ *
11
+ * ## The one gate
12
+ *
13
+ * The image channel is authorised by decoding: only a real raster container is
14
+ * served, so a `/tmp/x.png` that is really a text file dies before any bytes
15
+ * leave. Text has no equivalent — "it is text" is close to "anything can be
16
+ * read" — so by decision (2026-11-01) this channel does **not** filter by
17
+ * extension. What is left is: judge by *content* (a NUL byte or a mostly
18
+ * undecodable payload means binary), never write, never delete, and cap what
19
+ * one reply can carry. The consequences are spelled out in the spec's
20
+ * 「文本预览不设扩展名白名单」 boundary note — read that before widening this.
21
+ *
22
+ * ## Why the cap is not the frame budget
23
+ *
24
+ * {@link TEXT_PREVIEW_MAX_BYTES} is 256 KiB: a single frame could carry roughly
25
+ * twice that, but one screen of phone text is a few KiB, so anything past this
26
+ * only makes the overlay slower to render. The cap is applied as "read this
27
+ * much, then say you truncated", never as "refuse the file".
28
+ */
29
+ import { open, stat } from "node:fs/promises";
30
+
31
+ /** Never read more than this for one preview; the rest is reported as cut. */
32
+ export const TEXT_PREVIEW_MAX_BYTES = 256 * 1024;
33
+
34
+ /** The bytes are not text (NUL byte, or mostly undecodable as UTF-8). */
35
+ export const TEXT_PREVIEW_NOT_TEXT = -32016;
36
+ /** The path could not be read (missing, permission, a directory). */
37
+ export const TEXT_PREVIEW_READ_FAILED = -32017;
38
+
39
+ /**
40
+ * Share of U+FFFD above which the payload is treated as binary rather than as
41
+ * text with a bad encoding. A GBK log that is otherwise readable stays under
42
+ * this; a JPEG/ELF renamed to `.log` lands far above it.
43
+ */
44
+ const MAX_REPLACEMENT_RATIO = 0.1;
45
+
46
+ export interface TextPreviewResult {
47
+ /** The path that was asked for, echoed for the page's header. */
48
+ path: string;
49
+ content: string;
50
+ /** Size of the file on disk, for the "只显示前 X" line. */
51
+ bytes: number;
52
+ /** Bytes actually decoded (≤ {@link TEXT_PREVIEW_MAX_BYTES}). */
53
+ returnedBytes: number;
54
+ /** True when the file is larger than what was returned. */
55
+ truncated: boolean;
56
+ /** True when the decoded text contains replacement characters. */
57
+ encodingIssue: boolean;
58
+ }
59
+
60
+ function fail(code: number, message: string): Error {
61
+ const error = new Error(message) as Error & { code?: number };
62
+ error.code = code;
63
+ return error;
64
+ }
65
+
66
+ /**
67
+ * Read the head of one file from **this machine** and decode it as UTF-8.
68
+ *
69
+ * Throws an `Error` carrying one of the `TEXT_PREVIEW_*` codes; the remote
70
+ * error reply turns it into one visible line on the page.
71
+ */
72
+ export async function readTextPreview(
73
+ filePath: string,
74
+ ): Promise<TextPreviewResult> {
75
+ if (typeof filePath !== "string" || filePath.trim() === "") {
76
+ throw fail(TEXT_PREVIEW_READ_FAILED, "getTextPreview requires a path");
77
+ }
78
+
79
+ let size: number;
80
+ try {
81
+ const info = await stat(filePath);
82
+ if (!info.isFile()) {
83
+ throw fail(TEXT_PREVIEW_READ_FAILED, `Not a file: ${filePath}`);
84
+ }
85
+ size = info.size;
86
+ } catch (error) {
87
+ if (
88
+ error instanceof Error &&
89
+ "code" in error &&
90
+ typeof error.code === "number"
91
+ ) {
92
+ throw error;
93
+ }
94
+ throw fail(TEXT_PREVIEW_READ_FAILED, `Could not read ${filePath}`);
95
+ }
96
+
97
+ const readBytes = Math.min(size, TEXT_PREVIEW_MAX_BYTES);
98
+ let source: Buffer;
99
+ try {
100
+ const handle = await open(filePath, "r");
101
+ try {
102
+ const buffer = Buffer.allocUnsafe(readBytes);
103
+ let filled = 0;
104
+ // `read` may return short; loop until the head we asked for is in hand.
105
+ while (filled < readBytes) {
106
+ const { bytesRead } = await handle.read(
107
+ buffer,
108
+ filled,
109
+ readBytes - filled,
110
+ filled,
111
+ );
112
+ if (bytesRead === 0) break;
113
+ filled += bytesRead;
114
+ }
115
+ source = buffer.subarray(0, filled);
116
+ } finally {
117
+ await handle.close();
118
+ }
119
+ } catch {
120
+ throw fail(TEXT_PREVIEW_READ_FAILED, `Could not read ${filePath}`);
121
+ }
122
+
123
+ // The gate: content, not the name. UTF-16 files carry NUL bytes and are
124
+ // rejected here too — this channel promises UTF-8, and saying so is better
125
+ // than showing a screen of interleaved NULs.
126
+ if (source.includes(0)) {
127
+ throw fail(TEXT_PREVIEW_NOT_TEXT, `${filePath} is not a text file`);
128
+ }
129
+
130
+ const truncated = size > source.length;
131
+ // With `stream: true` the decoder holds back an incomplete trailing sequence
132
+ // instead of turning it into U+FFFD, so a cut never produces a broken last
133
+ // character (场景 5:按字节截断、退回最近的字符边界).
134
+ const content = new TextDecoder("utf-8").decode(source, {
135
+ stream: truncated,
136
+ });
137
+
138
+ let replacements = 0;
139
+ for (const char of content) {
140
+ if (char === "\uFFFD") replacements++;
141
+ }
142
+ const length = content.length;
143
+ if (length > 0 && replacements / length > MAX_REPLACEMENT_RATIO) {
144
+ throw fail(TEXT_PREVIEW_NOT_TEXT, `${filePath} is not a text file`);
145
+ }
146
+
147
+ return {
148
+ path: filePath,
149
+ content,
150
+ bytes: size,
151
+ returnedBytes: source.length,
152
+ truncated,
153
+ encodingIssue: replacements > 0,
154
+ };
155
+ }