@co0ontty/wand 4.52.0-beta.gdd3cb02 → 4.53.0-beta.gdd763b2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "dd3cb0268ff9c3c9944421414592a3c568597b82",
3
- "builtAt": "2026-09-06T03:23:46.370Z",
4
- "version": "4.52.0-beta.gdd3cb02",
2
+ "commit": "dd763b29e0d0810f30a090302594ebe40a2e0c78",
3
+ "builtAt": "2026-09-06T07:57:52.183Z",
4
+ "version": "4.53.0-beta.gdd763b2",
5
5
  "channel": "beta"
6
6
  }
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { mkdir, readdir, readFile, stat, unlink } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { compareApkInstallOrder, compareSemver, extractSemver } from "./version-utils.js";
5
+ import { compareApkInstallOrder, compareSemver, compareWandInstallOrder, extractSemver } from "./version-utils.js";
6
6
  const GITHUB_CACHE_TTL_MS = 10 * 60 * 1000;
7
7
  /**
8
8
  * GitHub Release 正文还包含 Android/macOS/iOS 的安装指引;它们属于发布页,
@@ -262,7 +262,7 @@ export class DistributionManager {
262
262
  extension: ".ipa",
263
263
  configuredFile: config.ios.currentIpaFile,
264
264
  downloadUrl: "/ios/download",
265
- compareVersions: compareSemver,
265
+ compareVersions: compareWandInstallOrder,
266
266
  });
267
267
  }
268
268
  async getSettings() {
@@ -0,0 +1,42 @@
1
+ export interface IosIpaMetadata {
2
+ bundleId: string;
3
+ bundleVersion: string;
4
+ bundleBuild: string;
5
+ title: string;
6
+ signed: boolean;
7
+ }
8
+ export type IosOtaBlocker = "unsigned-ipa" | "not-https" | "remote-ipa";
9
+ export interface IosOtaManifestInput {
10
+ ipaUrl: string;
11
+ bundleId: string;
12
+ bundleVersion: string;
13
+ title: string;
14
+ displayImageUrl?: string;
15
+ fullSizeImageUrl?: string;
16
+ }
17
+ export declare function publicOriginFromRequest(req: {
18
+ protocol?: string;
19
+ headers: Record<string, string | string[] | undefined>;
20
+ }): string | null;
21
+ export declare function normalizePublicOrigin(value: string | undefined | null): string | null;
22
+ export declare function absoluteUrl(origin: string, pathOrUrl: string): string;
23
+ export declare function buildItmsServicesUrl(manifestUrl: string): string;
24
+ export declare function collectIosOtaBlockers(options: {
25
+ signed: boolean;
26
+ origin: string | null;
27
+ source: "local" | "github";
28
+ }): IosOtaBlocker[];
29
+ export declare function buildIosOtaManifest(input: IosOtaManifestInput): string;
30
+ export declare function buildIosInstallPage(options: {
31
+ title: string;
32
+ version: string;
33
+ sizeLabel: string;
34
+ installUrl: string;
35
+ downloadUrl: string;
36
+ signed: boolean;
37
+ https: boolean;
38
+ }): string;
39
+ export declare function inspectIpa(filePath: string, fallback?: {
40
+ version?: string | null;
41
+ }): Promise<IosIpaMetadata>;
42
+ export declare function formatIosAssetSize(size: number): string;
@@ -0,0 +1,440 @@
1
+ import { open } from "node:fs/promises";
2
+ import { inflateRawSync } from "node:zlib";
3
+ const DEFAULT_BUNDLE_ID = "com.wand.app";
4
+ const DEFAULT_TITLE = "Wand";
5
+ export function publicOriginFromRequest(req) {
6
+ const forwardedProto = firstHeader(req.headers["x-forwarded-proto"])
7
+ ?? forwardedParam(firstHeader(req.headers.forwarded), "proto");
8
+ const proto = normalizeProtocol(forwardedProto)
9
+ ?? (firstHeader(req.headers["x-forwarded-ssl"])?.toLowerCase() === "on" ? "https" : undefined)
10
+ ?? (firstHeader(req.headers["x-forwarded-scheme"])?.toLowerCase() === "https" ? "https" : undefined)
11
+ ?? normalizeProtocol(req.protocol);
12
+ const host = firstHeader(req.headers["x-forwarded-host"])
13
+ ?? forwardedParam(firstHeader(req.headers.forwarded), "host")
14
+ ?? firstHeader(req.headers.host);
15
+ if (!proto || !host)
16
+ return null;
17
+ return `${proto}://${host}`;
18
+ }
19
+ export function normalizePublicOrigin(value) {
20
+ const raw = value?.trim();
21
+ if (!raw)
22
+ return null;
23
+ try {
24
+ const parsed = new URL(raw);
25
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
26
+ return null;
27
+ if (!parsed.hostname || parsed.username || parsed.password)
28
+ return null;
29
+ return parsed.origin;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function absoluteUrl(origin, pathOrUrl) {
36
+ if (/^https?:\/\//i.test(pathOrUrl))
37
+ return pathOrUrl;
38
+ const base = origin.endsWith("/") ? origin : `${origin}/`;
39
+ return new URL(pathOrUrl.startsWith("/") ? pathOrUrl.slice(1) : pathOrUrl, base).href;
40
+ }
41
+ export function buildItmsServicesUrl(manifestUrl) {
42
+ return `itms-services://?action=download-manifest&url=${encodeURIComponent(manifestUrl)}`;
43
+ }
44
+ export function collectIosOtaBlockers(options) {
45
+ const blockers = [];
46
+ if (options.source !== "local")
47
+ blockers.push("remote-ipa");
48
+ if (!options.signed)
49
+ blockers.push("unsigned-ipa");
50
+ if (!options.origin?.startsWith("https://"))
51
+ blockers.push("not-https");
52
+ return blockers;
53
+ }
54
+ export function buildIosOtaManifest(input) {
55
+ const assets = [
56
+ dictEntries([
57
+ ["kind", "software-package"],
58
+ ["url", input.ipaUrl],
59
+ ]),
60
+ ];
61
+ if (input.displayImageUrl) {
62
+ assets.push(dictEntries([
63
+ ["kind", "display-image"],
64
+ ["url", input.displayImageUrl],
65
+ ]));
66
+ }
67
+ if (input.fullSizeImageUrl) {
68
+ assets.push(dictEntries([
69
+ ["kind", "full-size-image"],
70
+ ["url", input.fullSizeImageUrl],
71
+ ]));
72
+ }
73
+ return [
74
+ `<?xml version="1.0" encoding="UTF-8"?>`,
75
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
76
+ `<plist version="1.0">`,
77
+ `<dict>`,
78
+ `<key>items</key>`,
79
+ `<array>`,
80
+ `<dict>`,
81
+ `<key>assets</key>`,
82
+ `<array>`,
83
+ assets.join("\n"),
84
+ `</array>`,
85
+ `<key>metadata</key>`,
86
+ dictEntries([
87
+ ["bundle-identifier", input.bundleId],
88
+ ["bundle-version", input.bundleVersion],
89
+ ["kind", "software"],
90
+ ["title", input.title],
91
+ ]),
92
+ `</dict>`,
93
+ `</array>`,
94
+ `</dict>`,
95
+ `</plist>`,
96
+ ``,
97
+ ].join("\n");
98
+ }
99
+ export function buildIosInstallPage(options) {
100
+ const warnings = [];
101
+ if (!options.https) {
102
+ warnings.push("当前页面不是 HTTPS。iOS 系统安装器会拒绝安装,请用受信任证书的公开地址打开。");
103
+ }
104
+ if (!options.signed) {
105
+ warnings.push("当前 IPA 未签名。系统安装会失败;签发后再放到更新目录即可 OTA 覆盖安装。");
106
+ }
107
+ const warningHtml = warnings.length
108
+ ? `<div class="warn">${warnings.map((item) => `<p>${escapeHtml(item)}</p>`).join("")}</div>`
109
+ : "";
110
+ return `<!DOCTYPE html>
111
+ <html lang="zh-CN">
112
+ <head>
113
+ <meta charset="utf-8">
114
+ <meta name="viewport" content="width=device-width, initial-scale=1">
115
+ <title>安装 ${escapeHtml(options.title)}</title>
116
+ <style>
117
+ :root { color-scheme: light dark; }
118
+ body { font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 32px 20px; }
119
+ main { max-width: 420px; margin: 0 auto; }
120
+ h1 { font-size: 22px; margin: 0 0 8px; }
121
+ p { margin: 0 0 12px; color: #5c564f; }
122
+ .meta { color: #7a7168; font-size: 14px; margin-bottom: 20px; }
123
+ a.install { display: block; text-align: center; background: #c5653d; color: #fff; text-decoration: none; padding: 14px 16px; border-radius: 12px; font-weight: 650; }
124
+ a.secondary { display: block; text-align: center; margin-top: 12px; color: #c5653d; }
125
+ .warn { background: rgba(197,101,61,.12); border-radius: 12px; padding: 12px 14px; margin-bottom: 18px; }
126
+ .hint { font-size: 13px; color: #7a7168; margin-top: 18px; }
127
+ </style>
128
+ </head>
129
+ <body>
130
+ <main>
131
+ <h1>安装 ${escapeHtml(options.title)}</h1>
132
+ <div class="meta">版本 ${escapeHtml(options.version)} · ${escapeHtml(options.sizeLabel)}</div>
133
+ ${warningHtml}
134
+ <a class="install" href="${escapeHtml(options.installUrl)}">在 iPhone 上安装</a>
135
+ <a class="secondary" href="${escapeHtml(options.downloadUrl)}">下载 IPA</a>
136
+ <p class="hint">请用 Safari 打开本页。安装后如提示未受信任,到 设置 → 通用 → VPN 与设备管理 中信任,iOS 18 企业包可能需要重启。</p>
137
+ </main>
138
+ </body>
139
+ </html>
140
+ `;
141
+ }
142
+ export async function inspectIpa(filePath, fallback = {}) {
143
+ const entries = await listZipEntries(filePath);
144
+ const signed = entries.some((entry) => entry.name.endsWith("embedded.mobileprovision")
145
+ || entry.name.includes("_CodeSignature/"));
146
+ const infoEntry = entries.find((entry) => /^Payload\/[^/]+\.app\/Info\.plist$/i.test(entry.name));
147
+ let bundleId = DEFAULT_BUNDLE_ID;
148
+ let bundleVersion = fallback.version?.trim() || "";
149
+ let bundleBuild = bundleVersion;
150
+ let title = DEFAULT_TITLE;
151
+ if (infoEntry) {
152
+ try {
153
+ const plist = parseIosInfoPlist(await readZipEntry(filePath, infoEntry));
154
+ bundleId = stringValue(plist.CFBundleIdentifier) || bundleId;
155
+ bundleVersion = stringValue(plist.CFBundleShortVersionString) || bundleVersion;
156
+ bundleBuild = stringValue(plist.CFBundleVersion) || bundleVersion;
157
+ title = stringValue(plist.CFBundleDisplayName) || stringValue(plist.CFBundleName) || title;
158
+ }
159
+ catch {
160
+ // 解析失败时用文件名版本和默认 bundle id,仍允许检查更新。
161
+ }
162
+ }
163
+ if (!bundleVersion)
164
+ bundleVersion = fallback.version?.trim() || "0.0.0";
165
+ if (!bundleBuild)
166
+ bundleBuild = bundleVersion;
167
+ return { bundleId, bundleVersion, bundleBuild, title, signed };
168
+ }
169
+ async function listZipEntries(filePath) {
170
+ const handle = await open(filePath, "r");
171
+ try {
172
+ const stat = await handle.stat();
173
+ const tailSize = Math.min(stat.size, 64 * 1024);
174
+ const tail = Buffer.alloc(tailSize);
175
+ await handle.read(tail, 0, tailSize, stat.size - tailSize);
176
+ const eocd = findEocd(tail);
177
+ if (!eocd)
178
+ throw new Error("IPA 不是有效的 zip。");
179
+ const cd = Buffer.alloc(eocd.cdSize);
180
+ await handle.read(cd, 0, eocd.cdSize, eocd.cdOffset);
181
+ const entries = [];
182
+ let offset = 0;
183
+ while (offset + 46 <= cd.length) {
184
+ if (cd.readUInt32LE(offset) !== 0x02014b50)
185
+ break;
186
+ const compression = cd.readUInt16LE(offset + 10);
187
+ const compressedSize = cd.readUInt32LE(offset + 20);
188
+ const uncompressedSize = cd.readUInt32LE(offset + 24);
189
+ const nameLength = cd.readUInt16LE(offset + 28);
190
+ const extraLength = cd.readUInt16LE(offset + 30);
191
+ const commentLength = cd.readUInt16LE(offset + 32);
192
+ const localHeaderOffset = cd.readUInt32LE(offset + 42);
193
+ const nameStart = offset + 46;
194
+ const nameEnd = nameStart + nameLength;
195
+ if (nameEnd > cd.length)
196
+ break;
197
+ entries.push({
198
+ name: cd.subarray(nameStart, nameEnd).toString("utf8"),
199
+ compression,
200
+ compressedSize,
201
+ uncompressedSize,
202
+ localHeaderOffset,
203
+ });
204
+ offset = nameEnd + extraLength + commentLength;
205
+ }
206
+ return entries;
207
+ }
208
+ finally {
209
+ await handle.close();
210
+ }
211
+ }
212
+ async function readZipEntry(filePath, entry) {
213
+ const handle = await open(filePath, "r");
214
+ try {
215
+ const header = Buffer.alloc(30);
216
+ await handle.read(header, 0, 30, entry.localHeaderOffset);
217
+ if (header.readUInt32LE(0) !== 0x04034b50)
218
+ throw new Error("IPA 本地文件头无效。");
219
+ const nameLength = header.readUInt16LE(26);
220
+ const extraLength = header.readUInt16LE(28);
221
+ const dataOffset = entry.localHeaderOffset + 30 + nameLength + extraLength;
222
+ const compressed = Buffer.alloc(entry.compressedSize);
223
+ if (entry.compressedSize > 0) {
224
+ await handle.read(compressed, 0, entry.compressedSize, dataOffset);
225
+ }
226
+ if (entry.compression === 0)
227
+ return compressed;
228
+ if (entry.compression === 8)
229
+ return inflateRawSync(compressed);
230
+ throw new Error(`不支持的 IPA 压缩方式:${entry.compression}`);
231
+ }
232
+ finally {
233
+ await handle.close();
234
+ }
235
+ }
236
+ function findEocd(tail) {
237
+ for (let i = tail.length - 22; i >= 0; i -= 1) {
238
+ if (tail.readUInt32LE(i) !== 0x06054b50)
239
+ continue;
240
+ const commentLength = tail.readUInt16LE(i + 20);
241
+ if (i + 22 + commentLength !== tail.length)
242
+ continue;
243
+ return {
244
+ cdSize: tail.readUInt32LE(i + 12),
245
+ cdOffset: tail.readUInt32LE(i + 16),
246
+ };
247
+ }
248
+ return null;
249
+ }
250
+ function parseIosInfoPlist(buffer) {
251
+ if (buffer.subarray(0, 8).toString("ascii") === "bplist00") {
252
+ const parsed = parseBinaryPlist(buffer);
253
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
254
+ ? parsed
255
+ : {};
256
+ }
257
+ return parseXmlPlistStrings(buffer.toString("utf8"));
258
+ }
259
+ function parseXmlPlistStrings(xml) {
260
+ const result = {};
261
+ const pattern = /<key>([^<]+)<\/key>\s*<(string|integer)>([^<]*)<\/\2>/g;
262
+ for (const match of xml.matchAll(pattern)) {
263
+ result[match[1]] = match[3];
264
+ }
265
+ return result;
266
+ }
267
+ function parseBinaryPlist(buffer) {
268
+ const trailer = buffer.subarray(buffer.length - 32);
269
+ const offsetSize = trailer[6];
270
+ const objectRefSize = trailer[7];
271
+ const objectCount = Number(trailer.readBigUInt64BE(8));
272
+ const topObject = Number(trailer.readBigUInt64BE(16));
273
+ const offsetTableOffset = Number(trailer.readBigUInt64BE(24));
274
+ const offsetTable = buffer.subarray(offsetTableOffset);
275
+ const readOffset = (index) => readSizedInt(offsetTable, index * offsetSize, offsetSize);
276
+ const cache = new Array(objectCount);
277
+ const seen = new Set();
278
+ const parseObject = (index) => {
279
+ if (index < 0 || index >= objectCount)
280
+ throw new Error("bplist 对象引用越界。");
281
+ if (seen.has(index))
282
+ return cache[index];
283
+ seen.add(index);
284
+ let cursor = readOffset(index);
285
+ const marker = buffer[cursor];
286
+ cursor += 1;
287
+ const nibble = marker & 0x0f;
288
+ const readLength = () => {
289
+ if (nibble !== 0x0f)
290
+ return nibble;
291
+ const lengthMarker = buffer[cursor];
292
+ cursor += 1;
293
+ const lengthSize = 1 << (lengthMarker & 0x0f);
294
+ const length = readSizedInt(buffer, cursor, lengthSize);
295
+ cursor += lengthSize;
296
+ return length;
297
+ };
298
+ switch (marker & 0xf0) {
299
+ case 0x00:
300
+ cache[index] = marker === 0x08 ? false : marker === 0x09 ? true : null;
301
+ return cache[index];
302
+ case 0x10: {
303
+ const size = 1 << nibble;
304
+ cache[index] = readSizedInt(buffer, cursor, size);
305
+ return cache[index];
306
+ }
307
+ case 0x20: {
308
+ const size = 1 << nibble;
309
+ cache[index] = size === 4 ? buffer.readFloatBE(cursor) : buffer.readDoubleBE(cursor);
310
+ return cache[index];
311
+ }
312
+ case 0x30:
313
+ cache[index] = new Date(978307200000 + buffer.readDoubleBE(cursor) * 1000);
314
+ return cache[index];
315
+ case 0x40: {
316
+ const length = readLength();
317
+ cache[index] = buffer.subarray(cursor, cursor + length);
318
+ return cache[index];
319
+ }
320
+ case 0x50: {
321
+ const length = readLength();
322
+ cache[index] = buffer.subarray(cursor, cursor + length).toString("ascii");
323
+ return cache[index];
324
+ }
325
+ case 0x60: {
326
+ const length = readLength();
327
+ const utf16 = buffer.subarray(cursor, cursor + length * 2);
328
+ const swapped = Buffer.alloc(utf16.length);
329
+ for (let i = 0; i + 1 < utf16.length; i += 2) {
330
+ swapped[i] = utf16[i + 1];
331
+ swapped[i + 1] = utf16[i];
332
+ }
333
+ cache[index] = swapped.toString("utf16le");
334
+ return cache[index];
335
+ }
336
+ case 0xa0: {
337
+ const length = readLength();
338
+ const values = [];
339
+ for (let i = 0; i < length; i += 1) {
340
+ values.push(parseObject(readSizedInt(buffer, cursor + i * objectRefSize, objectRefSize)));
341
+ }
342
+ cache[index] = values;
343
+ return cache[index];
344
+ }
345
+ case 0xd0: {
346
+ const length = readLength();
347
+ const record = {};
348
+ for (let i = 0; i < length; i += 1) {
349
+ const key = parseObject(readSizedInt(buffer, cursor + i * objectRefSize, objectRefSize));
350
+ const value = parseObject(readSizedInt(buffer, cursor + (length + i) * objectRefSize, objectRefSize));
351
+ record[String(key)] = value;
352
+ }
353
+ cache[index] = record;
354
+ return cache[index];
355
+ }
356
+ default:
357
+ cache[index] = null;
358
+ return null;
359
+ }
360
+ };
361
+ return parseObject(topObject);
362
+ }
363
+ function readSizedInt(buffer, offset, size) {
364
+ if (size === 1)
365
+ return buffer.readUInt8(offset);
366
+ if (size === 2)
367
+ return buffer.readUInt16BE(offset);
368
+ if (size === 4)
369
+ return buffer.readUInt32BE(offset);
370
+ if (size === 8)
371
+ return Number(buffer.readBigUInt64BE(offset));
372
+ let value = 0;
373
+ for (let i = 0; i < size; i += 1)
374
+ value = value * 256 + buffer[offset + i];
375
+ return value;
376
+ }
377
+ function dictEntries(entries) {
378
+ return [
379
+ `<dict>`,
380
+ ...entries.flatMap(([key, value]) => [`<key>${escapeXml(key)}</key>`, `<string>${escapeXml(value)}</string>`]),
381
+ `</dict>`,
382
+ ].join("\n");
383
+ }
384
+ function firstHeader(value) {
385
+ const raw = Array.isArray(value) ? value[0] : value;
386
+ return raw?.split(",")[0]?.trim();
387
+ }
388
+ function forwardedParam(header, key) {
389
+ if (!header)
390
+ return undefined;
391
+ const target = `${key}=`;
392
+ for (const part of header.split(";")) {
393
+ const trimmed = part.trim();
394
+ if (!trimmed.toLowerCase().startsWith(target))
395
+ continue;
396
+ return trimmed.slice(target.length).replace(/^"|"$/g, "");
397
+ }
398
+ return undefined;
399
+ }
400
+ function normalizeProtocol(value) {
401
+ const proto = value?.trim().toLowerCase();
402
+ if (proto === "http" || proto === "https")
403
+ return proto;
404
+ return undefined;
405
+ }
406
+ function stringValue(value) {
407
+ if (typeof value === "string" && value.trim())
408
+ return value.trim();
409
+ if (typeof value === "number" && Number.isFinite(value))
410
+ return String(value);
411
+ return "";
412
+ }
413
+ function escapeXml(value) {
414
+ return value
415
+ .replaceAll("&", "&amp;")
416
+ .replaceAll("<", "&lt;")
417
+ .replaceAll(">", "&gt;")
418
+ .replaceAll('"', "&quot;")
419
+ .replaceAll("'", "&apos;");
420
+ }
421
+ function escapeHtml(value) {
422
+ return escapeXml(value);
423
+ }
424
+ function formatBytes(size) {
425
+ if (!Number.isFinite(size) || size <= 0)
426
+ return "未知大小";
427
+ if (size < 1024)
428
+ return `${size} B`;
429
+ const units = ["KB", "MB", "GB"];
430
+ let value = size / 1024;
431
+ let unit = 0;
432
+ while (value >= 1024 && unit < units.length - 1) {
433
+ value /= 1024;
434
+ unit += 1;
435
+ }
436
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
437
+ }
438
+ export function formatIosAssetSize(size) {
439
+ return formatBytes(size);
440
+ }
package/dist/models.d.ts CHANGED
@@ -79,6 +79,14 @@ export declare function parseGrokModels(stdout: string): ClaudeModelInfo[];
79
79
  * safe ID below the `MODEL` header.
80
80
  */
81
81
  export declare function parseQoderModels(stdout: string): ClaudeModelInfo[];
82
+ /**
83
+ * Parse `pi --list-models`.
84
+ *
85
+ * Pi prints a padEnd-aligned table whose selectable `--model` value is
86
+ * `provider/id` (for example `xai/grok-4.6`). The first two columns are the
87
+ * only ones we keep; context / max-out / thinking / images are display-only.
88
+ */
89
+ export declare function parsePiModels(stdout: string): ClaudeModelInfo[];
82
90
  /** Parse `opencode models`, whose stable machine-friendly output is one provider/model id per line. */
83
91
  export declare function parseOpenCodeModels(stdout: string): ClaudeModelInfo[];
84
92
  /** Parse the machine-readable model registry emitted by the installed Codex CLI. */
package/dist/models.js CHANGED
@@ -18,10 +18,11 @@ const CLAUDE_PROBE_TIMEOUT_MS = 15_000;
18
18
  const MAX_CLAUDE_MODEL_PROBES = 12;
19
19
  const CLAUDE_PROBE_CONCURRENCY = 3;
20
20
  const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
21
- // Qoder returns both tier/frontier IDs (for example `glm51`) and custom
22
- // provider IDs (for example `zhipu/glm5.2-cp`). Keep this intentionally
23
- // conservative because these values are later forwarded to `--model`.
24
- const QODER_MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
21
+ // Shared catalog IDs are later forwarded to `--model`. Qoder and Pi both use
22
+ // provider-qualified values (`zhipu/glm5.2-cp`, `xai/grok-4.6`); Pi also emits
23
+ // `@` in some Cloudflare-style ids.
24
+ const QODER_MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
25
+ const PI_MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}\/[A-Za-z0-9@][A-Za-z0-9._:@/-]{0,127}$/;
25
26
  const CLAUDE_BUILTIN_MODELS = [
26
27
  {
27
28
  id: "default",
@@ -309,6 +310,15 @@ async function probeQoderModels(runner, env) {
309
310
  return { ok: false };
310
311
  }
311
312
  }
313
+ async function probePiModels(runner, env) {
314
+ try {
315
+ const { stdout } = await runner("pi", ["--list-models"], { env, timeout: 8000 });
316
+ return { ok: true, value: parsePiModels(stdout) };
317
+ }
318
+ catch {
319
+ return { ok: false };
320
+ }
321
+ }
312
322
  function createOfficialModelsApi(apiKey) {
313
323
  const client = new Anthropic({ apiKey });
314
324
  return {
@@ -453,6 +463,36 @@ export function parseQoderModels(stdout) {
453
463
  }
454
464
  return [...cloneModels(QODER_FALLBACK_MODELS), ...discovered];
455
465
  }
466
+ /**
467
+ * Parse `pi --list-models`.
468
+ *
469
+ * Pi prints a padEnd-aligned table whose selectable `--model` value is
470
+ * `provider/id` (for example `xai/grok-4.6`). The first two columns are the
471
+ * only ones we keep; context / max-out / thinking / images are display-only.
472
+ */
473
+ export function parsePiModels(stdout) {
474
+ const discovered = [];
475
+ const seen = new Set(["default"]);
476
+ for (const rawLine of stdout.split(/\r?\n/)) {
477
+ const line = rawLine.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, "").trim();
478
+ if (!line || /^provider\s+model\b/i.test(line))
479
+ continue;
480
+ const match = line.match(/^(\S+)\s+(\S+)\s+(\S+)/);
481
+ if (!match || !/^\d/.test(match[3]))
482
+ continue;
483
+ const id = `${match[1]}/${match[2]}`;
484
+ if (!PI_MODEL_ID_PATTERN.test(id) || id.length > 128 || seen.has(id))
485
+ continue;
486
+ seen.add(id);
487
+ discovered.push({ id, label: id });
488
+ }
489
+ if (!discovered.length)
490
+ return cloneModels(PI_FALLBACK_MODELS);
491
+ return [
492
+ { id: "default", label: "跟随 Pi 默认", alias: true },
493
+ ...discovered,
494
+ ];
495
+ }
456
496
  /** Parse `opencode models`, whose stable machine-friendly output is one provider/model id per line. */
457
497
  export function parseOpenCodeModels(stdout) {
458
498
  const ids = Array.from(new Set(stdout
@@ -666,12 +706,13 @@ async function discoverModelCache(options, previous) {
666
706
  const now = options.now?.() ?? new Date();
667
707
  const env = resolveProbeEnv(options);
668
708
  const runner = options.commandRunner ?? defaultCommandRunner;
669
- const [claudeVersionProbe, codexProbe, opencodeProbe, grokProbe, qoderProbe, apiProbe] = await Promise.all([
709
+ const [claudeVersionProbe, codexProbe, opencodeProbe, grokProbe, qoderProbe, piProbe, apiProbe] = await Promise.all([
670
710
  probeClaudeVersion(runner, env),
671
711
  probeCodexModels(runner, env),
672
712
  probeOpenCode(runner, env),
673
713
  probeGrokModels(runner, env),
674
714
  probeQoderModels(runner, env),
715
+ probePiModels(runner, env),
675
716
  listClaudeModelsFromApi(options, env),
676
717
  ]);
677
718
  const claudeVersion = claudeVersionProbe.ok ? claudeVersionProbe.value : previous.claudeVersion;
@@ -704,7 +745,7 @@ async function discoverModelCache(options, previous) {
704
745
  opencodeModels: opencodeProbe.models.ok ? opencodeProbe.models.value : cloneModels(previous.opencodeModels),
705
746
  grokModels: grokProbe.ok ? grokProbe.value : cloneModels(previous.grokModels),
706
747
  qoderModels: qoderProbe.ok ? qoderProbe.value : cloneModels(previous.qoderModels),
707
- piModels: cloneModels(previous.piModels),
748
+ piModels: piProbe.ok ? piProbe.value : cloneModels(previous.piModels),
708
749
  claudeVersion,
709
750
  opencodeVersion: opencodeProbe.version.ok ? opencodeProbe.version.value : previous.opencodeVersion,
710
751
  refreshedAt: now.toISOString(),
@@ -1886,7 +1886,9 @@ export class ProcessManager extends EventEmitter {
1886
1886
  record.description = description;
1887
1887
  const snapshot = this.snapshot(record);
1888
1888
  this.storage.updateSessionRuntimeMetadata({ ...snapshot, titleGenerating: undefined });
1889
- this.emitEvent({ type: "output", sessionId: id, data: { title, description, summary: description } });
1889
+ // status, not output: PTY chunks share the output debounce window, and a
1890
+ // merged incremental+chunk frame is treated as chunk-only by the web client.
1891
+ this.emitEvent({ type: "status", sessionId: id, data: { title, description, summary: description } });
1890
1892
  return snapshot;
1891
1893
  }
1892
1894
  setSessionTopicGenerating(id, titleGenerating) {
@@ -1894,7 +1896,7 @@ export class ProcessManager extends EventEmitter {
1894
1896
  if (!record || record.titleGenerating === titleGenerating)
1895
1897
  return;
1896
1898
  record.titleGenerating = titleGenerating;
1897
- this.emitEvent({ type: "output", sessionId: id, data: { titleGenerating } });
1899
+ this.emitEvent({ type: "status", sessionId: id, data: { titleGenerating } });
1898
1900
  }
1899
1901
  /**
1900
1902
  * Persist worktree merge progress through the manager that owns the live
@@ -210,7 +210,14 @@ function buildSessionListEntries(sessions) {
210
210
  });
211
211
  }
212
212
  function sessionListRevision(entries, directoryNames = []) {
213
- const entryState = entries.map((entry) => [entry.key, entry.sortTimestamp]);
213
+ const entryState = entries.map((entry) => [
214
+ entry.key,
215
+ entry.sortTimestamp,
216
+ entry.session.title,
217
+ entry.session.status,
218
+ entry.session.ptyBusy === true,
219
+ entry.session.structuredState?.inFlight === true,
220
+ ]);
214
221
  const revisionState = directoryNames.length === 0
215
222
  ? entryState
216
223
  : {