@co0ontty/wand 4.3.0 → 4.4.0-beta.g4c9bbce

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": "18f9ba6ad046ce59dc68669d11daf26d39e23f11",
3
- "builtAt": "2026-07-15T07:37:39.130Z",
4
- "version": "4.3.0",
5
- "channel": "stable"
2
+ "commit": "4c9bbce65ef02fdc9b8b5d2891e8839f0f2a74a2",
3
+ "builtAt": "2026-07-15T13:05:18.468Z",
4
+ "version": "4.4.0-beta.g4c9bbce",
5
+ "channel": "beta"
6
6
  }
@@ -0,0 +1,50 @@
1
+ import type { WandConfig } from "./types.js";
2
+ export type ApkUpdateChannel = "stable" | "beta";
3
+ export interface LocalDistributionAsset {
4
+ fileName: string;
5
+ filePath: string;
6
+ size: number;
7
+ updatedAt: string;
8
+ version: string | null;
9
+ downloadUrl: string;
10
+ source: "local";
11
+ }
12
+ export interface ResolvedDistributionAsset {
13
+ version: string;
14
+ downloadUrl: string;
15
+ fileName: string;
16
+ size: number;
17
+ source: "local" | "github";
18
+ releaseNotes?: string;
19
+ }
20
+ export interface DistributionSettings {
21
+ androidApk: Record<string, unknown>;
22
+ macosDmg: Record<string, unknown>;
23
+ }
24
+ export interface DistributionManagerOptions {
25
+ configDir: string;
26
+ configPath: string;
27
+ config: WandConfig;
28
+ repositoryUrl: string;
29
+ fetch?: typeof fetch;
30
+ now?: () => number;
31
+ }
32
+ export declare class DistributionManager {
33
+ private readonly options;
34
+ private readonly fetchImpl;
35
+ private readonly now;
36
+ private readonly githubCache;
37
+ constructor(options: DistributionManagerOptions);
38
+ resolveLatestApk(channel: ApkUpdateChannel): Promise<ResolvedDistributionAsset | null>;
39
+ resolveAndroidDownload(channel?: ApkUpdateChannel): Promise<LocalDistributionAsset | null>;
40
+ resolveLatestDmg(): Promise<ResolvedDistributionAsset | null>;
41
+ resolveMacosDownload(): Promise<LocalDistributionAsset | null>;
42
+ getSettings(): Promise<DistributionSettings>;
43
+ private refreshConfig;
44
+ private resolveLocalAsset;
45
+ private readLocalAsset;
46
+ private fetchGitHubAsset;
47
+ private fetchGitHubReleaseAsset;
48
+ private buildSettings;
49
+ private publicAsset;
50
+ }
@@ -0,0 +1,276 @@
1
+ import { mkdir, readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { compareApkInstallOrder, compareSemver, extractSemver } from "./version-utils.js";
4
+ const GITHUB_CACHE_TTL_MS = 10 * 60 * 1000;
5
+ function asRecord(value) {
6
+ return value && typeof value === "object" ? value : null;
7
+ }
8
+ function resolveConfiguredDir(configDir, configuredDir, fallback) {
9
+ const value = configuredDir?.trim();
10
+ if (!value)
11
+ return path.join(configDir, fallback);
12
+ return path.isAbsolute(value) ? value : path.resolve(configDir, value);
13
+ }
14
+ function extractArtifactVersion(fileName, extension) {
15
+ return extractSemver(fileName.replace(new RegExp(`\\${extension}$`, "i"), ""));
16
+ }
17
+ function isPrerelease(version) {
18
+ return !!version && version.includes("-");
19
+ }
20
+ export class DistributionManager {
21
+ options;
22
+ fetchImpl;
23
+ now;
24
+ githubCache = new Map();
25
+ constructor(options) {
26
+ this.options = options;
27
+ this.fetchImpl = options.fetch ?? fetch;
28
+ this.now = options.now ?? Date.now;
29
+ }
30
+ async resolveLatestApk(channel) {
31
+ const [localApk, githubApk] = await Promise.all([
32
+ this.resolveAndroidDownload(channel),
33
+ this.fetchGitHubAsset(".apk"),
34
+ ]);
35
+ const local = localApk?.version ? {
36
+ version: localApk.version,
37
+ downloadUrl: `${localApk.downloadUrl}?channel=${channel}`,
38
+ fileName: localApk.fileName,
39
+ size: localApk.size,
40
+ source: "local",
41
+ } : null;
42
+ const github = githubApk ? { ...githubApk, source: "github" } : null;
43
+ if (local && github) {
44
+ return compareApkInstallOrder(github.version, local.version) > 0 ? github : local;
45
+ }
46
+ return local ?? github;
47
+ }
48
+ async resolveAndroidDownload(channel = "beta") {
49
+ await this.refreshConfig();
50
+ const { config, configDir } = this.options;
51
+ if (config.android?.enabled !== true)
52
+ return null;
53
+ const directory = resolveConfiguredDir(configDir, config.android.apkDir, "android");
54
+ return this.resolveLocalAsset({
55
+ directory,
56
+ extension: ".apk",
57
+ configuredFile: channel === "beta" ? "" : config.android.currentApkFile,
58
+ downloadUrl: "/android/download",
59
+ compareVersions: compareApkInstallOrder,
60
+ acceptVersion: channel === "stable" ? (version) => !isPrerelease(version) : undefined,
61
+ });
62
+ }
63
+ async resolveLatestDmg() {
64
+ const localDmg = await this.resolveMacosDownload();
65
+ if (localDmg?.version) {
66
+ return {
67
+ version: localDmg.version,
68
+ downloadUrl: localDmg.downloadUrl,
69
+ fileName: localDmg.fileName,
70
+ size: localDmg.size,
71
+ source: "local",
72
+ };
73
+ }
74
+ const github = await this.fetchGitHubAsset(".dmg");
75
+ return github ? { ...github, source: "github" } : null;
76
+ }
77
+ async resolveMacosDownload() {
78
+ await this.refreshConfig();
79
+ const { config, configDir } = this.options;
80
+ if (config.macos?.enabled !== true)
81
+ return null;
82
+ return this.resolveLocalAsset({
83
+ directory: resolveConfiguredDir(configDir, config.macos.dmgDir, "macos"),
84
+ extension: ".dmg",
85
+ configuredFile: config.macos.currentDmgFile,
86
+ downloadUrl: "/macos/download",
87
+ compareVersions: compareSemver,
88
+ });
89
+ }
90
+ async getSettings() {
91
+ const [localApk, githubApk, localDmg, githubDmg] = await Promise.all([
92
+ this.resolveAndroidDownload("beta"),
93
+ this.fetchGitHubAsset(".apk"),
94
+ this.resolveMacosDownload(),
95
+ this.fetchGitHubAsset(".dmg"),
96
+ ]);
97
+ const apkDir = resolveConfiguredDir(this.options.configDir, this.options.config.android?.apkDir, "android");
98
+ const dmgDir = resolveConfiguredDir(this.options.configDir, this.options.config.macos?.dmgDir, "macos");
99
+ return {
100
+ androidApk: this.buildSettings("apk", apkDir, this.options.config.android?.enabled === true, localApk, githubApk),
101
+ macosDmg: this.buildSettings("dmg", dmgDir, this.options.config.macos?.enabled === true, localDmg, githubDmg),
102
+ };
103
+ }
104
+ async refreshConfig() {
105
+ let raw;
106
+ try {
107
+ raw = JSON.parse(await readFile(this.options.configPath, "utf8"));
108
+ }
109
+ catch {
110
+ return;
111
+ }
112
+ const { config } = this.options;
113
+ const android = asRecord(raw.android);
114
+ if (android) {
115
+ config.android = { ...(config.android ?? {}) };
116
+ if (typeof android.enabled === "boolean")
117
+ config.android.enabled = android.enabled;
118
+ if (Object.hasOwn(android, "apkDir")) {
119
+ config.android.apkDir = typeof android.apkDir === "string" && android.apkDir.trim() ? android.apkDir.trim() : "android";
120
+ }
121
+ if (Object.hasOwn(android, "currentApkFile")) {
122
+ config.android.currentApkFile = typeof android.currentApkFile === "string" ? android.currentApkFile.trim() : "";
123
+ }
124
+ }
125
+ const macos = asRecord(raw.macos);
126
+ if (macos) {
127
+ config.macos = { ...(config.macos ?? {}) };
128
+ if (typeof macos.enabled === "boolean")
129
+ config.macos.enabled = macos.enabled;
130
+ if (Object.hasOwn(macos, "dmgDir")) {
131
+ config.macos.dmgDir = typeof macos.dmgDir === "string" && macos.dmgDir.trim() ? macos.dmgDir.trim() : "macos";
132
+ }
133
+ if (Object.hasOwn(macos, "currentDmgFile")) {
134
+ config.macos.currentDmgFile = typeof macos.currentDmgFile === "string" ? macos.currentDmgFile.trim() : "";
135
+ }
136
+ }
137
+ }
138
+ async resolveLocalAsset(options) {
139
+ await mkdir(options.directory, { recursive: true });
140
+ const configuredFile = options.configuredFile?.trim();
141
+ if (configuredFile) {
142
+ return this.readLocalAsset(path.join(options.directory, path.basename(configuredFile)), options);
143
+ }
144
+ const entries = await readdir(options.directory, { withFileTypes: true });
145
+ const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(options.extension));
146
+ const candidates = (await Promise.all(files.map(async (entry) => {
147
+ const filePath = path.join(options.directory, entry.name);
148
+ return { entry, filePath, fileStat: await stat(filePath) };
149
+ }))).filter(({ entry }) => options.acceptVersion?.(extractArtifactVersion(entry.name, options.extension)) ?? true);
150
+ if (candidates.length === 0)
151
+ return null;
152
+ candidates.sort((a, b) => {
153
+ const aVersion = extractArtifactVersion(a.entry.name, options.extension);
154
+ const bVersion = extractArtifactVersion(b.entry.name, options.extension);
155
+ if (aVersion && bVersion) {
156
+ const comparison = options.compareVersions(bVersion, aVersion);
157
+ if (comparison !== 0)
158
+ return comparison;
159
+ }
160
+ else if (aVersion) {
161
+ return -1;
162
+ }
163
+ else if (bVersion) {
164
+ return 1;
165
+ }
166
+ return b.fileStat.mtimeMs - a.fileStat.mtimeMs;
167
+ });
168
+ const selected = candidates[0];
169
+ return {
170
+ fileName: selected.entry.name,
171
+ filePath: selected.filePath,
172
+ size: selected.fileStat.size,
173
+ updatedAt: selected.fileStat.mtime.toISOString(),
174
+ version: extractArtifactVersion(selected.entry.name, options.extension),
175
+ downloadUrl: options.downloadUrl,
176
+ source: "local",
177
+ };
178
+ }
179
+ async readLocalAsset(filePath, options) {
180
+ try {
181
+ const fileStat = await stat(filePath);
182
+ if (!fileStat.isFile())
183
+ return null;
184
+ const fileName = path.basename(filePath);
185
+ const version = extractArtifactVersion(fileName, options.extension);
186
+ if (options.acceptVersion && !options.acceptVersion(version))
187
+ return null;
188
+ return {
189
+ fileName,
190
+ filePath,
191
+ size: fileStat.size,
192
+ updatedAt: fileStat.mtime.toISOString(),
193
+ version,
194
+ downloadUrl: options.downloadUrl,
195
+ source: "local",
196
+ };
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ async fetchGitHubAsset(extension) {
203
+ const cached = this.githubCache.get(extension);
204
+ if (cached && this.now() - cached.timestamp < GITHUB_CACHE_TTL_MS)
205
+ return cached.asset;
206
+ try {
207
+ const hit = await this.fetchGitHubReleaseAsset(extension);
208
+ if (!hit)
209
+ return cached?.asset ?? null;
210
+ const version = extractArtifactVersion(hit.asset.name, extension)
211
+ ?? extractArtifactVersion(hit.tagName, extension)
212
+ ?? hit.tagName.replace(/^v/, "");
213
+ const asset = {
214
+ version,
215
+ downloadUrl: hit.asset.browser_download_url,
216
+ fileName: hit.asset.name,
217
+ size: hit.asset.size,
218
+ ...(extension === ".apk" && hit.body ? { releaseNotes: hit.body.trim().slice(0, 500) } : {}),
219
+ };
220
+ this.githubCache.set(extension, { asset, timestamp: this.now() });
221
+ return asset;
222
+ }
223
+ catch {
224
+ return cached?.asset ?? null;
225
+ }
226
+ }
227
+ async fetchGitHubReleaseAsset(extension) {
228
+ const apiUrl = this.options.repositoryUrl.replace("github.com", "api.github.com/repos") + "/releases?per_page=30";
229
+ const response = await this.fetchImpl(apiUrl, {
230
+ headers: { Accept: "application/vnd.github.v3+json", "User-Agent": "wand-server" },
231
+ signal: AbortSignal.timeout(10000),
232
+ });
233
+ if (!response.ok)
234
+ return null;
235
+ const releases = await response.json();
236
+ for (const release of releases) {
237
+ if (release.draft || release.prerelease)
238
+ continue;
239
+ const asset = release.assets.find((candidate) => candidate.name.toLowerCase().endsWith(extension));
240
+ if (asset)
241
+ return { tagName: release.tag_name, body: release.body, asset };
242
+ }
243
+ return null;
244
+ }
245
+ buildSettings(kind, directory, enabled, local, github) {
246
+ const selected = local
247
+ ? { ...local, source: "local" }
248
+ : github
249
+ ? { ...github, updatedAt: null, source: "github" }
250
+ : null;
251
+ const hasKey = kind === "apk" ? "hasApk" : "hasDmg";
252
+ const dirKey = kind === "apk" ? "apkDir" : "dmgDir";
253
+ return {
254
+ enabled,
255
+ [dirKey]: directory,
256
+ [hasKey]: selected !== null,
257
+ fileName: selected?.fileName ?? null,
258
+ version: selected?.version ?? null,
259
+ size: selected?.size ?? null,
260
+ updatedAt: selected?.updatedAt ?? null,
261
+ downloadUrl: selected?.downloadUrl ?? null,
262
+ source: selected?.source ?? null,
263
+ local: local ? this.publicAsset(local) : null,
264
+ github: github ? this.publicAsset(github) : null,
265
+ };
266
+ }
267
+ publicAsset(asset) {
268
+ return {
269
+ fileName: asset.fileName,
270
+ version: asset.version,
271
+ size: asset.size,
272
+ ...("updatedAt" in asset ? { updatedAt: asset.updatedAt } : {}),
273
+ downloadUrl: asset.downloadUrl,
274
+ };
275
+ }
276
+ }
@@ -959,7 +959,27 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
959
959
  const shortcutKey = body.shortcutKey;
960
960
  try {
961
961
  if (structured.get(sessionId)) {
962
- const snapshot = await structured.sendMessage(sessionId, input);
962
+ const completion = structured.sendMessage(sessionId, input);
963
+ if (body.respondImmediately === true) {
964
+ // sendMessage updates the canonical snapshot synchronously before it
965
+ // starts awaiting the runner. Native clients should not hold this
966
+ // request open for an entire model turn (which can exceed their HTTP
967
+ // timeout, especially while the first-turn title job is also active).
968
+ // The normal structured events continue to carry progress/failure.
969
+ completion.catch((error) => {
970
+ console.error("[wand] Accepted structured input later failed", {
971
+ sessionId,
972
+ error: getInputDebugMeta(error),
973
+ });
974
+ });
975
+ const accepted = structured.get(sessionId);
976
+ if (!accepted) {
977
+ throw new Error("未找到该结构化会话。");
978
+ }
979
+ res.status(202).json(sessionResponseDTO(accepted));
980
+ return;
981
+ }
982
+ const snapshot = await completion;
963
983
  res.json(sessionResponseDTO(snapshot));
964
984
  return;
965
985
  }