@co0ontty/wand 4.3.0 → 4.4.0-beta.gdcdccb2

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.
package/dist/server.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import crypto from "node:crypto";
2
- import { compareApkInstallOrder, compareSemver, extractSemver } from "./version-utils.js";
3
2
  import compression from "compression";
4
3
  import express from "express";
5
4
  import { existsSync, readFileSync } from "node:fs";
6
- import { mkdir, readdir, readFile, stat } from "node:fs/promises";
5
+ import { stat } from "node:fs/promises";
7
6
  import { createServer as createHttpServer } from "node:http";
8
7
  import { createServer as createHttpsServer } from "node:https";
9
8
  import { spawn } from "node:child_process";
@@ -36,6 +35,7 @@ import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
36
35
  import { resolveDatabasePath, WandStorage } from "./storage.js";
37
36
  import { DEFAULT_BROWSER_EXTENSION_BASE_URL, buildPasswordSecurityReport, generatePassword, generateTotpCode, normalizePasswordItemType, } from "./password-manager.js";
38
37
  import { deepRepairRuntimePath, formatPathRepairSummary, repairRuntimePath } from "./path-repair.js";
38
+ import { DistributionManager } from "./distribution-manager.js";
39
39
  import { isLogBusActive, wandTuiLog } from "./tui/log-bus.js";
40
40
  import { EMBEDDED_WEB_ASSETS } from "./web-ui/embedded-assets.js";
41
41
  import { renderApp } from "./web-ui/index.js";
@@ -93,197 +93,6 @@ function readBuildInfo() {
93
93
  const BUILD_INFO = readBuildInfo();
94
94
  const DISPLAY_VERSION = BUILD_INFO.version || PKG_VERSION;
95
95
  const SERVER_INSTANCE_ID = crypto.randomUUID();
96
- let cachedGitHubApk = null;
97
- let gitHubApkCacheTs = 0;
98
- const GITHUB_APK_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
99
- // 按时间倒序遍历最近的 releases,取第一个带对应产物的。正常 tag release
100
- // 会为每个平台出包;这里保留回退以兼容旧 release 或某次平台构建失败。
101
- async function fetchGitHubReleaseAssetByExt(ext) {
102
- const apiUrl = PKG_REPO_URL.replace("github.com", "api.github.com/repos") + "/releases?per_page=30";
103
- const resp = await fetch(apiUrl, {
104
- headers: { "Accept": "application/vnd.github.v3+json", "User-Agent": "wand-server" },
105
- signal: AbortSignal.timeout(10000),
106
- });
107
- if (!resp.ok)
108
- return null;
109
- const releases = await resp.json();
110
- for (const release of releases) {
111
- if (release.draft || release.prerelease)
112
- continue;
113
- const asset = release.assets.find(a => a.name.toLowerCase().endsWith(ext));
114
- if (asset)
115
- return { tagName: release.tag_name, body: release.body, asset };
116
- }
117
- return null;
118
- }
119
- async function fetchGitHubLatestApk(forceRefresh = false) {
120
- const now = Date.now();
121
- if (!forceRefresh && cachedGitHubApk && (now - gitHubApkCacheTs < GITHUB_APK_CACHE_TTL)) {
122
- return cachedGitHubApk;
123
- }
124
- try {
125
- const hit = await fetchGitHubReleaseAssetByExt(".apk");
126
- if (!hit)
127
- return cachedGitHubApk ?? null;
128
- // 版本号优先从文件名提取;回退到旧 release asset 时不能把当前 release tag
129
- // 误当成产物版本。
130
- const version = extractAndroidApkVersion(hit.asset.name)
131
- ?? extractAndroidApkVersion(hit.tagName)
132
- ?? hit.tagName.replace(/^v/, "");
133
- cachedGitHubApk = {
134
- version,
135
- downloadUrl: hit.asset.browser_download_url,
136
- fileName: hit.asset.name,
137
- size: hit.asset.size,
138
- releaseNotes: hit.body ? hit.body.trim().slice(0, 500) : undefined,
139
- };
140
- gitHubApkCacheTs = now;
141
- return cachedGitHubApk;
142
- }
143
- catch {
144
- return cachedGitHubApk ?? null;
145
- }
146
- }
147
- function parseApkChannel(value) {
148
- return value === "beta" ? "beta" : "stable";
149
- }
150
- function asRecord(value) {
151
- return value && typeof value === "object" ? value : null;
152
- }
153
- async function refreshDistributionConfig(configPath, config) {
154
- let raw;
155
- try {
156
- raw = JSON.parse(await readFile(configPath, "utf8"));
157
- }
158
- catch {
159
- return;
160
- }
161
- const android = asRecord(raw.android);
162
- if (android) {
163
- config.android = { ...(config.android ?? {}) };
164
- if (typeof android.enabled === "boolean")
165
- config.android.enabled = android.enabled;
166
- if (Object.prototype.hasOwnProperty.call(android, "apkDir")) {
167
- config.android.apkDir = typeof android.apkDir === "string" && android.apkDir.trim()
168
- ? android.apkDir.trim()
169
- : "android";
170
- }
171
- if (Object.prototype.hasOwnProperty.call(android, "currentApkFile")) {
172
- config.android.currentApkFile = typeof android.currentApkFile === "string"
173
- ? android.currentApkFile.trim()
174
- : "";
175
- }
176
- }
177
- const macos = asRecord(raw.macos);
178
- if (macos) {
179
- config.macos = { ...(config.macos ?? {}) };
180
- if (typeof macos.enabled === "boolean")
181
- config.macos.enabled = macos.enabled;
182
- if (Object.prototype.hasOwnProperty.call(macos, "dmgDir")) {
183
- config.macos.dmgDir = typeof macos.dmgDir === "string" && macos.dmgDir.trim()
184
- ? macos.dmgDir.trim()
185
- : "macos";
186
- }
187
- if (Object.prototype.hasOwnProperty.call(macos, "currentDmgFile")) {
188
- config.macos.currentDmgFile = typeof macos.currentDmgFile === "string"
189
- ? macos.currentDmgFile.trim()
190
- : "";
191
- }
192
- }
193
- }
194
- /** 版本号带 prerelease 后缀(如 -debug.06121811)即视为 beta 构建。 */
195
- function isPrereleaseApkVersion(version) {
196
- return !!version && version.includes("-");
197
- }
198
- async function resolveLatestApkVersion(configDir, config, channel, configPath) {
199
- // local 与 github 两个来源都看,按安装序取真正更新的那个(持平偏向 local:同源下载更快)。
200
- // 旧逻辑是「local 存在就一票否决」——本地目录留着旧包时,会把线上新版压住不提示。
201
- const localApk = await resolveAndroidApkAsset(configDir, config, channel, configPath);
202
- const local = localApk && localApk.version
203
- ? {
204
- version: localApk.version,
205
- // 下载链接始终带通道参数,保证「提示的版本」和「下载到的文件」出自同一套过滤:
206
- // 裸 /android/download(网页下载页、二维码落地页)默认 beta = 目录里真正最新的包。
207
- downloadUrl: `${localApk.downloadUrl}?channel=${channel}`,
208
- fileName: localApk.fileName,
209
- size: localApk.size,
210
- source: "local",
211
- }
212
- : null;
213
- let github = null;
214
- try {
215
- const ghApk = await fetchGitHubLatestApk();
216
- if (ghApk) {
217
- github = {
218
- version: ghApk.version,
219
- downloadUrl: ghApk.downloadUrl,
220
- fileName: ghApk.fileName,
221
- size: ghApk.size,
222
- source: "github",
223
- releaseNotes: ghApk.releaseNotes,
224
- };
225
- }
226
- }
227
- catch {
228
- // GitHub 不可达时静默回退 local
229
- }
230
- if (local && github) {
231
- return compareApkInstallOrder(github.version, local.version) > 0 ? github : local;
232
- }
233
- return local ?? github;
234
- }
235
- let cachedGitHubDmg = null;
236
- let gitHubDmgCacheTs = 0;
237
- const GITHUB_DMG_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
238
- async function fetchGitHubLatestDmg(forceRefresh = false) {
239
- const now = Date.now();
240
- if (!forceRefresh && cachedGitHubDmg && (now - gitHubDmgCacheTs < GITHUB_DMG_CACHE_TTL)) {
241
- return cachedGitHubDmg;
242
- }
243
- try {
244
- const hit = await fetchGitHubReleaseAssetByExt(".dmg");
245
- if (!hit)
246
- return cachedGitHubDmg ?? null;
247
- // 同 APK:版本号优先从文件名提取,避免回退到旧 asset 时把 release tag 当成新版本。
248
- const version = extractMacosDmgVersion(hit.asset.name)
249
- ?? extractMacosDmgVersion(hit.tagName)
250
- ?? hit.tagName.replace(/^v/, "");
251
- cachedGitHubDmg = {
252
- version,
253
- downloadUrl: hit.asset.browser_download_url,
254
- fileName: hit.asset.name,
255
- size: hit.asset.size,
256
- };
257
- gitHubDmgCacheTs = now;
258
- return cachedGitHubDmg;
259
- }
260
- catch {
261
- return cachedGitHubDmg ?? null;
262
- }
263
- }
264
- async function resolveLatestDmgVersion(configDir, config, configPath) {
265
- const localDmg = await resolveMacosDmgAsset(configDir, config, configPath);
266
- if (localDmg && localDmg.version) {
267
- return {
268
- version: localDmg.version,
269
- downloadUrl: localDmg.downloadUrl,
270
- fileName: localDmg.fileName,
271
- size: localDmg.size,
272
- source: "local",
273
- };
274
- }
275
- const ghDmg = await fetchGitHubLatestDmg();
276
- if (ghDmg) {
277
- return {
278
- version: ghDmg.version,
279
- downloadUrl: ghDmg.downloadUrl,
280
- fileName: ghDmg.fileName,
281
- size: ghDmg.size,
282
- source: "github",
283
- };
284
- }
285
- return null;
286
- }
287
96
  function isExternalAvatarSource(value) {
288
97
  return /^(https?:|data:)/i.test(value);
289
98
  }
@@ -587,181 +396,6 @@ function resolveAppConnectOrigin(origin, config) {
587
396
  return origin;
588
397
  }
589
398
  }
590
- /** Match a semver-looking token in a file name (with optional pre-release / build metadata). */
591
- function resolveAndroidApkDir(configDir, config) {
592
- const configuredDir = config.android?.apkDir?.trim();
593
- if (!configuredDir) {
594
- return path.join(configDir, "android");
595
- }
596
- return path.isAbsolute(configuredDir) ? configuredDir : path.resolve(configDir, configuredDir);
597
- }
598
- function extractAndroidApkVersion(fileName) {
599
- return extractSemver(fileName.replace(/\.apk$/i, ""));
600
- }
601
- async function resolveAndroidApkAsset(configDir, config, channel = "beta", configPath) {
602
- if (configPath)
603
- await refreshDistributionConfig(configPath, config);
604
- if (config.android?.enabled !== true)
605
- return null;
606
- const apkDir = resolveAndroidApkDir(configDir, config);
607
- await mkdir(apkDir, { recursive: true });
608
- const configuredFile = config.android?.currentApkFile?.trim();
609
- // Beta is the local development channel: every check should pick the newest
610
- // APK in apkDir, so dropping a new debug build into the directory is enough.
611
- // currentApkFile remains a stable/manual pin and backward-compatible fallback.
612
- if (configuredFile && channel !== "beta") {
613
- const filePath = path.join(apkDir, path.basename(configuredFile));
614
- try {
615
- const fileStat = await stat(filePath);
616
- if (!fileStat.isFile())
617
- return null;
618
- const fileName = path.basename(filePath);
619
- const version = extractAndroidApkVersion(fileName);
620
- if (channel === "stable" && isPrereleaseApkVersion(version))
621
- return null;
622
- return {
623
- fileName,
624
- filePath,
625
- size: fileStat.size,
626
- updatedAt: fileStat.mtime.toISOString(),
627
- version,
628
- downloadUrl: "/android/download",
629
- source: "local",
630
- };
631
- }
632
- catch {
633
- return null;
634
- }
635
- }
636
- const entries = await readdir(apkDir, { withFileTypes: true });
637
- const apkFiles = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".apk"));
638
- if (apkFiles.length === 0)
639
- return null;
640
- const allCandidates = await Promise.all(apkFiles.map(async (entry) => {
641
- const filePath = path.join(apkDir, entry.name);
642
- const fileStat = await stat(filePath);
643
- return {
644
- entry,
645
- filePath,
646
- fileStat,
647
- };
648
- }));
649
- // 通道过滤:stable 只看正式版文件(无 prerelease 后缀的版本号),beta 全量。
650
- // 无版本号的文件两个通道都保留(排序时本来就垫底,仅在只有它时兜底可下载)。
651
- const candidates = channel === "beta"
652
- ? allCandidates
653
- : allCandidates.filter((c) => !isPrereleaseApkVersion(extractAndroidApkVersion(c.entry.name)));
654
- if (candidates.length === 0)
655
- return null;
656
- // 按版本号选"最新", 而非修改时间 —— cp/rsync/解压/checkout 都可能让低版本号文件的
657
- // mtime 更新, 用 mtime 会把旧版本号当成 latest 上报。版本相同或都无版本号时退回 mtime。
658
- // 注意用安装序比较(同三段时 debug > release,镜像 versionCode),不是标准 semver:
659
- // wand-v1.55.0.apk 与 wand-v1.55.0-debug.x.apk 并存时,debug 才是装得上的更新包。
660
- candidates.sort((a, b) => {
661
- const va = extractAndroidApkVersion(a.entry.name);
662
- const vb = extractAndroidApkVersion(b.entry.name);
663
- if (va && vb) {
664
- const cmp = compareApkInstallOrder(vb, va);
665
- if (cmp !== 0)
666
- return cmp;
667
- }
668
- else if (va && !vb) {
669
- return -1;
670
- }
671
- else if (!va && vb) {
672
- return 1;
673
- }
674
- return b.fileStat.mtimeMs - a.fileStat.mtimeMs;
675
- });
676
- const selected = candidates[0];
677
- return {
678
- fileName: selected.entry.name,
679
- filePath: selected.filePath,
680
- size: selected.fileStat.size,
681
- updatedAt: selected.fileStat.mtime.toISOString(),
682
- version: extractAndroidApkVersion(selected.entry.name),
683
- downloadUrl: "/android/download",
684
- source: "local",
685
- };
686
- }
687
- function resolveMacosDmgDir(configDir, config) {
688
- const configuredDir = config.macos?.dmgDir?.trim();
689
- if (!configuredDir) {
690
- return path.join(configDir, "macos");
691
- }
692
- return path.isAbsolute(configuredDir) ? configuredDir : path.resolve(configDir, configuredDir);
693
- }
694
- function extractMacosDmgVersion(fileName) {
695
- return extractSemver(fileName.replace(/\.dmg$/i, ""));
696
- }
697
- async function resolveMacosDmgAsset(configDir, config, configPath) {
698
- if (configPath)
699
- await refreshDistributionConfig(configPath, config);
700
- if (config.macos?.enabled !== true)
701
- return null;
702
- const dmgDir = resolveMacosDmgDir(configDir, config);
703
- await mkdir(dmgDir, { recursive: true });
704
- const configuredFile = config.macos?.currentDmgFile?.trim();
705
- if (configuredFile) {
706
- const filePath = path.join(dmgDir, path.basename(configuredFile));
707
- try {
708
- const fileStat = await stat(filePath);
709
- if (!fileStat.isFile())
710
- return null;
711
- return {
712
- fileName: path.basename(filePath),
713
- filePath,
714
- size: fileStat.size,
715
- updatedAt: fileStat.mtime.toISOString(),
716
- version: extractMacosDmgVersion(path.basename(filePath)),
717
- downloadUrl: "/macos/download",
718
- source: "local",
719
- };
720
- }
721
- catch {
722
- return null;
723
- }
724
- }
725
- const entries = await readdir(dmgDir, { withFileTypes: true });
726
- const dmgFiles = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".dmg"));
727
- if (dmgFiles.length === 0)
728
- return null;
729
- const candidates = await Promise.all(dmgFiles.map(async (entry) => {
730
- const filePath = path.join(dmgDir, entry.name);
731
- const fileStat = await stat(filePath);
732
- return {
733
- entry,
734
- filePath,
735
- fileStat,
736
- };
737
- }));
738
- candidates.sort((a, b) => {
739
- const va = extractMacosDmgVersion(a.entry.name);
740
- const vb = extractMacosDmgVersion(b.entry.name);
741
- if (va && vb) {
742
- const cmp = compareSemver(vb, va);
743
- if (cmp !== 0)
744
- return cmp;
745
- }
746
- else if (va && !vb) {
747
- return -1;
748
- }
749
- else if (!va && vb) {
750
- return 1;
751
- }
752
- return b.fileStat.mtimeMs - a.fileStat.mtimeMs;
753
- });
754
- const selected = candidates[0];
755
- return {
756
- fileName: selected.entry.name,
757
- filePath: selected.filePath,
758
- size: selected.fileStat.size,
759
- updatedAt: selected.fileStat.mtime.toISOString(),
760
- version: extractMacosDmgVersion(selected.entry.name),
761
- downloadUrl: "/macos/download",
762
- source: "local",
763
- };
764
- }
765
399
  // ── Startup error handling ──
766
400
  process.on("uncaughtException", (err) => {
767
401
  wandError("服务器异常", err.message, "请检查配置是否正确,或尝试重启服务。");
@@ -854,6 +488,12 @@ export async function startServer(config, configPath, options = {}) {
854
488
  };
855
489
  };
856
490
  const configDir = resolveConfigDir(configPath);
491
+ const distributionManager = new DistributionManager({
492
+ configDir,
493
+ configPath,
494
+ config,
495
+ repositoryUrl: PKG_REPO_URL,
496
+ });
857
497
  const processes = new ProcessManager(config, storage, configDir);
858
498
  const structuredLogger = new SessionLogger(configDir, config.shortcutLogMaxBytes);
859
499
  const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger);
@@ -1038,12 +678,7 @@ export async function startServer(config, configPath, options = {}) {
1038
678
  res.json({ ok: true, reauthenticationRequired: true });
1039
679
  });
1040
680
  // ── Android APK update & download (no auth required) ──
1041
- registerPublicUpdateRoutes(app, {
1042
- resolveLatestApk: (channel) => resolveLatestApkVersion(configDir, config, channel, configPath),
1043
- resolveAndroidDownload: (channel) => resolveAndroidApkAsset(configDir, config, channel, configPath),
1044
- resolveLatestDmg: () => resolveLatestDmgVersion(configDir, config, configPath),
1045
- resolveMacosDownload: () => resolveMacosDmgAsset(configDir, config, configPath),
1046
- });
681
+ registerPublicUpdateRoutes(app, distributionManager);
1047
682
  // Public probe so the unauthenticated browser does not log a 401 on /api/config
1048
683
  app.get("/api/session-check", (req, res) => {
1049
684
  res.json({ authed: authService.validateSession(readSessionCookie(req, useHttps)) });
@@ -1225,52 +860,7 @@ export async function startServer(config, configPath, options = {}) {
1225
860
  res.json({ report: buildPasswordSecurityReport(storage.listPasswordItems({ includeArchived: false, limit: 200 })) });
1226
861
  });
1227
862
  // ── Settings endpoints ──
1228
- const getDistributionSettings = async () => {
1229
- const localApk = await resolveAndroidApkAsset(configDir, config, "beta", configPath);
1230
- const ghApk = await fetchGitHubLatestApk();
1231
- const apkDir = resolveAndroidApkDir(configDir, config);
1232
- const resolvedApk = localApk
1233
- ? { hasApk: true, fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl, source: "local" }
1234
- : ghApk
1235
- ? { hasApk: true, fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, updatedAt: null, downloadUrl: ghApk.downloadUrl, source: "github" }
1236
- : null;
1237
- const localDmg = await resolveMacosDmgAsset(configDir, config, configPath);
1238
- const ghDmg = await fetchGitHubLatestDmg();
1239
- const dmgDir = resolveMacosDmgDir(configDir, config);
1240
- const resolvedDmg = localDmg
1241
- ? { hasDmg: true, fileName: localDmg.fileName, version: localDmg.version, size: localDmg.size, updatedAt: localDmg.updatedAt, downloadUrl: localDmg.downloadUrl, source: "local" }
1242
- : ghDmg
1243
- ? { hasDmg: true, fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, updatedAt: null, downloadUrl: ghDmg.downloadUrl, source: "github" }
1244
- : null;
1245
- return {
1246
- androidApk: {
1247
- enabled: config.android?.enabled === true,
1248
- apkDir,
1249
- hasApk: resolvedApk?.hasApk ?? false,
1250
- fileName: resolvedApk?.fileName ?? null,
1251
- version: resolvedApk?.version ?? null,
1252
- size: resolvedApk?.size ?? null,
1253
- updatedAt: resolvedApk?.updatedAt ?? null,
1254
- downloadUrl: resolvedApk?.downloadUrl ?? null,
1255
- source: resolvedApk?.source ?? null,
1256
- local: localApk ? { fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl } : null,
1257
- github: ghApk ? { fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, downloadUrl: ghApk.downloadUrl } : null,
1258
- },
1259
- macosDmg: {
1260
- enabled: config.macos?.enabled === true,
1261
- dmgDir,
1262
- hasDmg: resolvedDmg?.hasDmg ?? false,
1263
- fileName: resolvedDmg?.fileName ?? null,
1264
- version: resolvedDmg?.version ?? null,
1265
- size: resolvedDmg?.size ?? null,
1266
- updatedAt: resolvedDmg?.updatedAt ?? null,
1267
- downloadUrl: resolvedDmg?.downloadUrl ?? null,
1268
- source: resolvedDmg?.source ?? null,
1269
- local: localDmg ? { fileName: localDmg.fileName, version: localDmg.version, size: localDmg.size, updatedAt: localDmg.updatedAt, downloadUrl: localDmg.downloadUrl } : null,
1270
- github: ghDmg ? { fileName: ghDmg.fileName, version: ghDmg.version, size: ghDmg.size, downloadUrl: ghDmg.downloadUrl } : null,
1271
- },
1272
- };
1273
- };
863
+ const getDistributionSettings = () => distributionManager.getSettings();
1274
864
  registerSettingsRoutes(app, {
1275
865
  storage,
1276
866
  config,
@@ -1,3 +1,4 @@
1
+ import type { StructuredRunnerAdapter, StructuredRunnerContext, StructuredRunnerExecution, StructuredRunnerObserver } from "./structured-runner.js";
1
2
  import type { ExecutionMode, SessionSnapshot } from "./types.js";
2
3
  export type WandPermissionMode = "default" | "acceptEdits" | "bypassPermissions";
3
4
  export interface PermissionPolicy {
@@ -17,3 +18,12 @@ export declare function buildClaudeSdkThinking(effort: SessionSnapshot["thinking
17
18
  } | {
18
19
  type: "disabled";
19
20
  };
21
+ export interface ClaudeCliRunnerOptions {
22
+ language?: () => string | undefined;
23
+ }
24
+ /** Owns the Claude print-mode process and its stream-json protocol. */
25
+ export declare class ClaudeCliRunner implements StructuredRunnerAdapter {
26
+ private readonly options;
27
+ constructor(options?: ClaudeCliRunnerOptions);
28
+ start(context: StructuredRunnerContext, observer: StructuredRunnerObserver): StructuredRunnerExecution;
29
+ }
@@ -1,9 +1,11 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { readFileSync, statSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import path from "node:path";
4
5
  import { isRunningAsRoot } from "./env-utils.js";
5
6
  import { buildLanguageDirective, buildManagedAutonomyDirective } from "./language-prompt.js";
6
7
  import { thinkingEffortToClaudeCliEffort, thinkingEffortToSdkBudget } from "./structured-provider-common.js";
8
+ import { ClaudeCliProtocolReducer } from "./structured-claude-protocol.js";
7
9
  const ROOT_FALLBACK_ALLOWED_TOOLS = [
8
10
  "Bash", "Edit", "Write", "Read", "Glob", "Grep", "NotebookEdit", "WebFetch", "WebSearch",
9
11
  ];
@@ -115,3 +117,117 @@ export function buildClaudeSdkThinking(effort) {
115
117
  const budgetTokens = thinkingEffortToSdkBudget(effort);
116
118
  return budgetTokens > 0 ? { type: "enabled", budgetTokens } : { type: "disabled" };
117
119
  }
120
+ /** Owns the Claude print-mode process and its stream-json protocol. */
121
+ export class ClaudeCliRunner {
122
+ options;
123
+ constructor(options = {}) {
124
+ this.options = options;
125
+ }
126
+ start(context, observer) {
127
+ const permissionPolicy = derivePermissionPolicy(context.session.mode, context.session.autoApprovePermissions ?? false, context.session.cwd);
128
+ const args = buildClaudeCliArgs(context.session, {
129
+ permissionPolicy,
130
+ systemPromptParts: buildAppendSystemPromptParts(this.options.language?.(), context.session.mode),
131
+ });
132
+ const spawnedAt = new Date().toISOString();
133
+ const child = spawn("claude", args, {
134
+ cwd: context.session.cwd,
135
+ env: context.env,
136
+ stdio: ["pipe", "pipe", "pipe"],
137
+ });
138
+ child.stdin?.end(context.prompt);
139
+ const reducer = new ClaudeCliProtocolReducer(context.session);
140
+ let lineBuffer = "";
141
+ let stderr = "";
142
+ let stdoutTail = "";
143
+ let settled = false;
144
+ let killedForQuestion = false;
145
+ const result = (exitCode, signal, spawnError) => ({
146
+ state: reducer.state,
147
+ exitCode,
148
+ signal,
149
+ stderr,
150
+ stdoutTail,
151
+ primaryError: null,
152
+ stopReason: killedForQuestion ? "ask-user-question" : undefined,
153
+ spawnError,
154
+ });
155
+ const processLine = (line) => {
156
+ if (!observer.isActive())
157
+ return;
158
+ const trimmed = line.trim();
159
+ if (!trimmed)
160
+ return;
161
+ let event;
162
+ try {
163
+ event = JSON.parse(trimmed);
164
+ }
165
+ catch {
166
+ return;
167
+ }
168
+ if (event && typeof event === "object" && !Array.isArray(event)) {
169
+ observer.onEvent?.(event);
170
+ }
171
+ if (reducer.apply(event, context.session.mode === "managed")) {
172
+ observer.onUpdate(reducer.state);
173
+ }
174
+ if (reducer.askUserQuestionDetected && !killedForQuestion) {
175
+ killedForQuestion = true;
176
+ try {
177
+ child.kill("SIGTERM");
178
+ }
179
+ catch { /* best effort */ }
180
+ }
181
+ };
182
+ const completion = new Promise((resolve) => {
183
+ child.stdout?.on("data", (chunk) => {
184
+ if (!observer.isActive())
185
+ return;
186
+ const text = chunk.toString();
187
+ observer.onStdout?.(text);
188
+ const trimmed = text.trim();
189
+ if (trimmed)
190
+ stdoutTail = trimmed.slice(-1024);
191
+ lineBuffer += text;
192
+ const lines = lineBuffer.split("\n");
193
+ lineBuffer = lines.pop() ?? "";
194
+ for (const line of lines)
195
+ processLine(line);
196
+ });
197
+ child.stderr?.on("data", (chunk) => {
198
+ if (!observer.isActive())
199
+ return;
200
+ const text = chunk.toString();
201
+ observer.onStderr?.(text);
202
+ stderr += text;
203
+ });
204
+ child.on("error", (error) => {
205
+ if (settled)
206
+ return;
207
+ settled = true;
208
+ resolve(result(null, null, error));
209
+ });
210
+ child.on("close", (exitCode, signal) => {
211
+ if (settled)
212
+ return;
213
+ settled = true;
214
+ if (lineBuffer.trim())
215
+ processLine(lineBuffer);
216
+ lineBuffer = "";
217
+ resolve(result(exitCode, signal));
218
+ });
219
+ });
220
+ return {
221
+ args,
222
+ spawnedAt,
223
+ pid: child.pid ?? null,
224
+ completion,
225
+ interrupt: () => {
226
+ try {
227
+ child.kill("SIGTERM");
228
+ }
229
+ catch { /* best effort */ }
230
+ },
231
+ };
232
+ }
233
+ }
@@ -0,0 +1,34 @@
1
+ import type { ContentBlock, ConversationTurn, SessionSnapshot } from "./types.js";
2
+ import type { StructuredRunnerTurnState } from "./structured-runner.js";
3
+ export type TaskMetaMap = Map<string, {
4
+ agentType?: string;
5
+ description?: string;
6
+ }>;
7
+ export declare function captureTaskMeta(blocks: ContentBlock[], registry: TaskMetaMap): void;
8
+ export declare function tagSubagentBlocks(blocks: ContentBlock[], parentToolUseId: string | null | undefined, registry: TaskMetaMap): ContentBlock[];
9
+ export declare function stampSelfTask(blocks: ContentBlock[], registry: TaskMetaMap): ContentBlock[];
10
+ export declare function stampParentTaskResults(blocks: ContentBlock[], registry: TaskMetaMap): ContentBlock[];
11
+ export declare function normalizeClaudeToolInput(name: unknown, input: unknown): Record<string, unknown>;
12
+ export declare function extractClaudeUsage(source: Record<string, unknown> | undefined): ConversationTurn["usage"];
13
+ export declare function extractClaudeModelName(modelUsage: Record<string, unknown> | undefined): string | undefined;
14
+ export declare function extractClaudeAssistantMessage(message: Record<string, unknown>): {
15
+ content: ContentBlock[];
16
+ usage?: ConversationTurn["usage"];
17
+ };
18
+ interface ClaudeCliTurnState extends StructuredRunnerTurnState {
19
+ sessionId: string | null;
20
+ }
21
+ export declare class ClaudeCliProtocolReducer {
22
+ readonly state: ClaudeCliTurnState;
23
+ askUserQuestionDetected: boolean;
24
+ private readonly blocksByKey;
25
+ private readonly keyOrder;
26
+ private readonly taskMetaRegistry;
27
+ private toolResultSequence;
28
+ constructor(session: SessionSnapshot);
29
+ apply(parsed: unknown, managed: boolean): boolean;
30
+ private blockVolume;
31
+ private upsertBlocks;
32
+ private rebuildBlocks;
33
+ }
34
+ export {};