@co0ontty/wand 4.91.1-beta.357.1.g3bfbf35 → 4.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +26 -5
  2. package/dist/build-info.json +4 -4
  3. package/dist/cli.js +6 -0
  4. package/dist/core-runner.js +0 -7
  5. package/dist/core-status-cli.d.ts +2 -0
  6. package/dist/core-status-cli.js +13 -16
  7. package/dist/daemon-connection.d.ts +5 -0
  8. package/dist/daemon-connection.js +8 -0
  9. package/dist/decision-tool.js +2 -8
  10. package/dist/middleware/api-scope.js +1 -0
  11. package/dist/models/shared.d.ts +1 -1
  12. package/dist/models/shared.js +1 -3
  13. package/dist/npm-update-utils.d.ts +18 -1
  14. package/dist/npm-update-utils.js +153 -11
  15. package/dist/object-utils.d.ts +2 -0
  16. package/dist/object-utils.js +4 -0
  17. package/dist/pi-event-projection.d.ts +8 -0
  18. package/dist/pi-event-projection.js +28 -0
  19. package/dist/pi-model-discovery.js +1 -3
  20. package/dist/pi-native-event-bridge.d.ts +2 -0
  21. package/dist/pi-native-event-bridge.js +63 -0
  22. package/dist/pi-output-file.d.ts +44 -0
  23. package/dist/pi-output-file.js +159 -0
  24. package/dist/pi-protocol-reader.d.ts +33 -0
  25. package/dist/pi-protocol-reader.js +277 -0
  26. package/dist/pi-tmux-host.d.ts +44 -0
  27. package/dist/pi-tmux-host.js +385 -0
  28. package/dist/pi-tmux-worker.d.ts +1 -0
  29. package/dist/pi-tmux-worker.js +71 -0
  30. package/dist/render-daemon-client.js +2 -6
  31. package/dist/render-structured-client.js +2 -6
  32. package/dist/server-ai-team-routes.js +4 -14
  33. package/dist/server-employee-routes.js +5 -14
  34. package/dist/server-request.js +2 -1
  35. package/dist/server-session-context.js +1 -1
  36. package/dist/server-session-input-routes.js +1 -1
  37. package/dist/server-session-lifecycle-routes.js +2 -2
  38. package/dist/server-session-list-routes.js +3 -3
  39. package/dist/server-session-worktree-routes.js +3 -3
  40. package/dist/server-update-routes.d.ts +6 -1
  41. package/dist/server-update-routes.js +51 -26
  42. package/dist/server.js +12 -67
  43. package/dist/session-logger.d.ts +4 -0
  44. package/dist/session-logger.js +9 -0
  45. package/dist/session-service.js +4 -4
  46. package/dist/storage/mappers.d.ts +2 -1
  47. package/dist/storage/mappers.js +2 -3
  48. package/dist/storage/missions-repository.d.ts +1 -2
  49. package/dist/storage/missions-repository.js +1 -3
  50. package/dist/storage/preferences-repository.d.ts +1 -2
  51. package/dist/storage/preferences-repository.js +1 -3
  52. package/dist/structured-content.js +2 -3
  53. package/dist/structured-exec-host.d.ts +8 -0
  54. package/dist/structured-exec-pump.d.ts +10 -0
  55. package/dist/structured-exec-pump.js +88 -18
  56. package/dist/structured-pi-adapter.d.ts +5 -1
  57. package/dist/structured-pi-adapter.js +20 -2
  58. package/dist/structured-session-manager-replay.js +8 -0
  59. package/dist/structured-session-manager.d.ts +1 -5
  60. package/dist/structured-session-manager.js +103 -29
  61. package/dist/task-agent-parsing.d.ts +4 -0
  62. package/dist/task-agent-parsing.js +20 -0
  63. package/dist/team-dispatch.js +1 -4
  64. package/dist/terminal-daemon-client.js +2 -6
  65. package/dist/text-utils.d.ts +2 -0
  66. package/dist/text-utils.js +14 -0
  67. package/dist/tui/commands.js +0 -1
  68. package/dist/update-health.d.ts +7 -0
  69. package/dist/update-health.js +77 -0
  70. package/dist/update-helper.d.ts +17 -0
  71. package/dist/update-helper.js +222 -34
  72. package/dist/update-legacy.d.ts +5 -0
  73. package/dist/update-legacy.js +198 -0
  74. package/dist/update-repair-cli.d.ts +2 -0
  75. package/dist/update-repair-cli.js +17 -0
  76. package/dist/web-ui/content/module-manifest.json +1 -1
  77. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chat-render-QXOBJZYF.js → 2548dbe0d0783577/chat-render-EODYMQF6.js} +12 -12
  78. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-NRYBDZJQ.js → 2548dbe0d0783577/chunk-3TCUG766.js} +1 -1
  79. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-AN34B6OX.js → 2548dbe0d0783577/chunk-47EVOSJS.js} +1 -1
  80. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-A3PREDLN.js → 2548dbe0d0783577/chunk-A33YPCY6.js} +1 -1
  81. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-LKQKEUHR.js → 2548dbe0d0783577/chunk-I3UYDQLS.js} +1 -1
  82. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-4TWMU7ZU.js → 2548dbe0d0783577/chunk-QNUWUMKW.js} +1 -1
  83. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-2SZRSM2C.js → 2548dbe0d0783577/chunk-WGADUEIT.js} +1 -1
  84. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-4RVBPCZ2.js → 2548dbe0d0783577/chunk-ZTEQSXPA.js} +1 -1
  85. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/employee-card-JRCEGKOW.js → 2548dbe0d0783577/employee-card-LJ3TX72B.js} +1 -1
  86. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-HSG7HCKW.js → 2548dbe0d0783577/host-2PU6TV2I.js} +1 -1
  87. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-XTI24R46.js → 2548dbe0d0783577/host-4E4H4JZJ.js} +1 -1
  88. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-PULYRKUO.js → 2548dbe0d0783577/host-BPYNMKFC.js} +1 -1
  89. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-ZTK4NJBZ.js → 2548dbe0d0783577/host-CXDAFMXX.js} +1 -1
  90. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-O3FQO2WF.js → 2548dbe0d0783577/host-E3SCPOIU.js} +1 -1
  91. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-VQKHZ4XY.js → 2548dbe0d0783577/host-HKXE2DOT.js} +1 -1
  92. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-UHPSRXOS.js → 2548dbe0d0783577/host-HZIKPWYY.js} +1 -1
  93. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-QKLN6BQT.js → 2548dbe0d0783577/host-ITV2QZ4A.js} +1 -1
  94. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-EAFCE2FW.js → 2548dbe0d0783577/host-J5XRAQFP.js} +1 -1
  95. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-TFWV4EL3.js → 2548dbe0d0783577/host-OGD6AWHL.js} +1 -1
  96. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-3YSTASO7.js → 2548dbe0d0783577/host-OWD32FZ3.js} +1 -1
  97. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-VSUTKITC.js → 2548dbe0d0783577/host-XNWC7KPR.js} +10 -10
  98. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-TOKT2KEL.js → 2548dbe0d0783577/host-YELXZID4.js} +1 -1
  99. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-bridge-YU4CDKM6.js → 2548dbe0d0783577/host-bridge-G5HEVADT.js} +1 -1
  100. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/library-bridge-E4G3TTAM.js → 2548dbe0d0783577/library-bridge-Y3Y24CPQ.js} +1 -1
  101. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/page-EL25SU2L.js → 2548dbe0d0783577/page-GQBUU7T7.js} +1 -1
  102. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/panel-5FP5SRNS.js → 2548dbe0d0783577/panel-PG662OC5.js} +1 -1
  103. package/dist/web-ui/content/modules/2548dbe0d0783577/runtime.js +946 -0
  104. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/task-board-host-EYEPEKBJ.js → 2548dbe0d0783577/task-board-host-RAN2Z2KR.js} +1 -1
  105. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/workspace-agent-picker-TA2VJ2QW.js → 2548dbe0d0783577/workspace-agent-picker-FXLH6N5K.js} +1 -1
  106. package/dist/web-ui/content/modules/{d70a22b9c0aa5244/workspace-window-QDI7YABJ.js → 2548dbe0d0783577/workspace-window-HV4T3WKR.js} +1 -1
  107. package/dist/web-ui/embedded-assets.d.ts +27 -27
  108. package/dist/web-ui/embedded-assets.js +2 -2
  109. package/package.json +7 -1
  110. package/scripts/recover-web-update.mjs +262 -0
  111. package/scripts/recover-web-update.sh +123 -0
  112. package/scripts/update-node.sh +65 -0
  113. package/scripts/update-target.js +36 -0
  114. package/dist/web-ui/content/modules/d70a22b9c0aa5244/runtime.js +0 -923
  115. /package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-LROINIFZ.js → 2548dbe0d0783577/chunk-C3K34WJD.js} +0 -0
  116. /package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-PFPY4NTX.js → 2548dbe0d0783577/chunk-HK7RBU3M.js} +0 -0
  117. /package/dist/web-ui/content/modules/{d70a22b9c0aa5244/chunk-4OECFENI.js → 2548dbe0d0783577/chunk-NP35GDQ3.js} +0 -0
  118. /package/dist/web-ui/content/modules/{d70a22b9c0aa5244 → 2548dbe0d0783577}/chunk-XGVT4ZWT.js +0 -0
  119. /package/dist/web-ui/content/modules/{d70a22b9c0aa5244 → 2548dbe0d0783577}/chunk-YOXXNXOX.js +0 -0
  120. /package/dist/web-ui/content/modules/{d70a22b9c0aa5244 → 2548dbe0d0783577}/chunk-YX475LXO.js +0 -0
  121. /package/dist/web-ui/content/modules/{d70a22b9c0aa5244/host-5QOHKHNC.js → 2548dbe0d0783577/host-J6HBDK4A.js} +0 -0
  122. /package/dist/web-ui/content/modules/{d70a22b9c0aa5244 → 2548dbe0d0783577}/sha256-ANXV72UC.js +0 -0
package/README.md CHANGED
@@ -68,6 +68,13 @@ wand config:password
68
68
  - **Update channels** — built-in stable/beta update checks and upgrade prompts
69
69
  - **CLI updates** — check and quickly update Claude Code, Codex, OpenCode, Qoder CLI, Pi, and Gemini CLI from server settings, with optional automatic updates
70
70
 
71
+ Stable subscribers receive npm `latest`; Beta subscribers receive the newer of `latest`
72
+ and `beta`, including a stable release that supersedes its prerelease. Installing stable
73
+ keeps the Beta subscription enabled. Updates pin the checked version, wait for Core turns,
74
+ and restart only the Web service. If an old updater cannot run, rerun `install.sh` for a
75
+ staged recovery that preserves the previous installation and service scope. Node.js must
76
+ meet the new package's requirement. See [Web update and recovery](docs/web-updates.md).
77
+
71
78
  ### Configuration
72
79
 
73
80
  The default config file is `~/.wand/config.json`; it is created by `wand init`.
@@ -251,16 +258,30 @@ npm install -g @google/gemini-cli@latest
251
258
  - **HTTPS** — 可选自签证书,适合远程或移动端访问
252
259
  - **版本管理** — 内置更新检查与升级提示,支持 stable/beta 双通道
253
260
 
254
- Web 设置中的「Beta 通道」决定服务端 npm 更新来源:关闭时仅检查
255
- `@co0ontty/wand@latest`,开启时检查 `@co0ontty/wand@beta`;「自动更新 Web 服务」
256
- 独立决定是否自动安装。检查后安装锁定具体版本,避免 dist-tag 在安装期间移动。
257
- 关闭 Beta 不会自动降级;需要立即返回较旧正式版时,点击「更新或重新安装」。
261
+ Web 设置中的「Beta 通道」决定服务端 npm 更新候选:关闭时仅检查
262
+ `@co0ontty/wand@latest`,开启时同时检查 `latest` 与 `beta`,选择版本较新的一个;
263
+ 同号正式版高于其 beta。「自动更新 Web 服务」独立决定是否自动安装。
264
+ 检查后安装锁定具体版本,避免 dist-tag 在安装期间移动。Beta 用户安装正式版后
265
+ 仍保留 Beta 订阅,下一次有更新的 beta 时继续收到更新。关闭 Beta 不会自动降级;
266
+ 需要立即返回较旧正式版时,点击「更新或重新安装」。
267
+
268
+ 手动与自动更新共用独立 helper:等待 Core 回合完成后安装,失败保留旧服务;
269
+ 成功后只重启 Web 服务,Render/terminald 的会话继续运行。Web 更新不能升级 Node.js;
270
+ Node 版本不足会在改动安装前拒绝。页面同时核对新服务实例和目标版本后才刷新。
271
+ 旧版本无法自更新时,重新执行本文的 `install.sh`:恢复流程先下载验证完整包,
272
+ 保留旧安装,等待 Core 排空,再修复全局入口与原 service scope,支持残缺安装。
273
+ 默认保留已保存的 Beta 订阅;切换 Node 后已有配置/数据库仍进入升级流程。
274
+ 入口会复用兼容的已有 Node,必要时使用已配置 nvm 安装;npm 目录不可写时迁入用户可写的运行时目录,
275
+ 后续更新始终绑定这份实际安装。新版也附带 `wand update:repair` 恢复命令,避免依赖 GitHub 可达。
276
+ 可用 `WAND_UPDATE_CHANNEL=stable` 或 `beta` 指定本次恢复目标,
277
+ `WAND_CONFIG_PATH` 指定自定义配置路径。详细流程、失败原因与补救见 [Web 更新说明](docs/web-updates.md)。
258
278
 
259
279
  正式 npm 发布由 `vX.Y.Z` Git tag 触发,只写入 `latest`,拒绝预发布版本。
260
280
  master 的 Beta 发布只写入 `beta`,版本为下一补丁号加
261
281
  `-beta.<构建序号>.<重试序号>.g<提交>`,例如 `4.89.5-beta.100.1.gabcdef0`。
262
282
  版本基数同时检查正式 Git tags 和 npm latest,兼容未推送 Git 的本地正式发布。
263
- Beta 尚未发布、通道版本无效或 npm 暂不可达时,不自动安装其他通道。
283
+ Beta 标签缺失或无效时,Beta 订阅仍可接收有效正式版;正式通道始终不选择 beta。
284
+ 两个候选都无效或 npm 不可达时不安装。
264
285
  - **CLI 更新** — 在服务端设置中检查并快速更新 Claude Code、Codex、OpenCode、Qoder CLI、Pi 和 Gemini CLI,也可开启自动更新
265
286
 
266
287
  ### 配置
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "3bfbf35960418829557a3ceebd4651536c75158d",
3
- "builtAt": "2026-10-14T00:02:21.131Z",
4
- "version": "4.91.1-beta.357.1.g3bfbf35",
5
- "channel": "beta"
2
+ "commit": "f9fff36feba5836645f1eff3c7605c2d3c794755",
3
+ "builtAt": "2026-10-14T02:19:26.662Z",
4
+ "version": "4.92.0",
5
+ "channel": "stable"
6
6
  }
package/dist/cli.js CHANGED
@@ -180,6 +180,11 @@ async function main() {
180
180
  await runAgentCliCommand(command, args, configPath);
181
181
  break;
182
182
  }
183
+ case "update:repair": {
184
+ const { repairInstalledUpdate } = await import("./update-repair-cli.js");
185
+ process.exitCode = await repairInstalledUpdate(configPath);
186
+ break;
187
+ }
183
188
  case "core:status": {
184
189
  const { checkCoreStatus } = await import("./core-status-cli.js");
185
190
  await checkCoreStatus(configPath);
@@ -297,6 +302,7 @@ Agent runtime:
297
302
 
298
303
  System service (default = system-wide; pass --user for user-level):
299
304
  wand service:install Register and start the background service (needs sudo for system)
305
+ wand update:repair Recover an old/broken installation while retaining data and update channel
300
306
  wand service:uninstall Stop and remove the service
301
307
  wand service:start Start the service
302
308
  wand service:stop Stop the service
@@ -632,13 +632,6 @@ export function compactionSettings(config, defaults) {
632
632
  keepRecentTokens: override?.keepRecentTokens ?? defaults.keepRecentTokens,
633
633
  };
634
634
  }
635
- /** 一条只含文本的用户消息才算“真正的用户输入”:只在该边界切,保证 tool_use / tool_result 不被切开。 */
636
- function currentUserTurnIndex(session, prompt) {
637
- const turns = session.messages ?? [];
638
- const last = turns.at(-1);
639
- const text = last?.content?.filter((block) => block.type === "text").map((block) => block.text).join("\n");
640
- return last?.role === "user" && text === prompt ? turns.length - 1 : turns.length;
641
- }
642
635
  function isUserTextTurn(turn) {
643
636
  if (turn.role !== "user")
644
637
  return false;
@@ -4,6 +4,8 @@ export interface CoreStatus {
4
4
  activeTurnCount: number;
5
5
  activeTurnIds?: string[];
6
6
  }
7
+ /** A single request spends only the original overall deadline, never a fresh timeout. */
8
+ export declare function remainingCoreRequestBudget(deadline: number): number;
7
9
  export declare function checkCoreStatus(configPath: string): Promise<void>;
8
10
  /** Zero means wait indefinitely. A timeout/error NEVER runs the stop command. */
9
11
  export declare function waitForCoreTurnsComplete(configPath: string, timeoutSeconds?: number, stopCommand?: string[], allowStopped?: boolean): Promise<number>;
@@ -141,17 +141,18 @@ async function readLegacyStatus(configPath, timeoutMs = 5000) {
141
141
  });
142
142
  return parseStatus(result);
143
143
  }
144
+ /** A single request spends only the original overall deadline, never a fresh timeout. */
145
+ export function remainingCoreRequestBudget(deadline) {
146
+ const remaining = deadline - Date.now();
147
+ if (remaining <= 0)
148
+ throw new Error("等待 Core 回合超时,服务未停止,已取消重启。");
149
+ return Math.min(5000, remaining);
150
+ }
144
151
  async function openControl(configPath, deadline = Infinity) {
145
152
  const control = new CoreControl(configPath);
146
- const budget = () => {
147
- const remaining = deadline - Date.now();
148
- if (remaining <= 0)
149
- throw new Error("等待 Core 回合超时,服务未停止,已取消重启。");
150
- return Math.min(5000, remaining);
151
- };
152
153
  try {
153
- await control.connect(budget());
154
- await control.request("core-status", budget());
154
+ await control.connect(remainingCoreRequestBudget(deadline));
155
+ await control.request("core-status", remainingCoreRequestBudget(deadline));
155
156
  return control;
156
157
  }
157
158
  catch (error) {
@@ -183,12 +184,6 @@ export async function waitForCoreTurnsComplete(configPath, timeoutSeconds = 0, s
183
184
  if (stopCommand && !stopCommand.length)
184
185
  throw new Error("缺少等待完成后要执行的停止命令。");
185
186
  const deadline = timeoutSeconds > 0 ? Date.now() + timeoutSeconds * 1000 : Infinity;
186
- const budget = () => {
187
- const remaining = deadline - Date.now();
188
- if (remaining <= 0)
189
- throw new Error("等待 Core 回合超时,服务未停止,已取消重启。");
190
- return Math.min(5000, remaining);
191
- };
192
187
  const control = await openControl(configPath, deadline);
193
188
  try {
194
189
  const readStatus = async (budget = 5000) => {
@@ -222,7 +217,9 @@ export async function waitForCoreTurnsComplete(configPath, timeoutSeconds = 0, s
222
217
  return { hasActiveTurns: false, activeTurnCount: 0 };
223
218
  }
224
219
  };
225
- let status = control && stopCommand ? await control.request("core-drain", budget()) : await readStatus(budget());
220
+ let status = control && stopCommand
221
+ ? await control.request("core-drain", remainingCoreRequestBudget(deadline))
222
+ : await readStatus(remainingCoreRequestBudget(deadline));
226
223
  if (!control && stopCommand)
227
224
  console.error("[wand] 旧服务尚不支持重启排空;正在读取真实服务状态。首次升级等待期间请勿启动新任务。");
228
225
  let lastCount = -1;
@@ -235,7 +232,7 @@ export async function waitForCoreTurnsComplete(configPath, timeoutSeconds = 0, s
235
232
  if (remaining <= 0)
236
233
  throw new Error("等待 Core 回合超时,服务未停止,已取消重启。");
237
234
  await new Promise((resolve) => setTimeout(resolve, Math.min(1000, remaining)));
238
- status = await readStatus(budget());
235
+ status = await readStatus(remainingCoreRequestBudget(deadline));
239
236
  }
240
237
  // The draining connection stays open until the stop command finishes.
241
238
  // New Pi inputs are persisted in the queue, not started in the check/stop gap.
@@ -1,4 +1,9 @@
1
1
  import type net from "node:net";
2
+ /** Reject this client's pending generation without changing its connection lifecycle. */
3
+ export declare function rejectPendingRequests<T extends {
4
+ timer: NodeJS.Timeout;
5
+ reject(error: Error): void;
6
+ }>(pending: Map<number, T>, error: Error): void;
2
7
  /** Bound connect attempts, including sockets that close before the handshake. */
3
8
  export declare function waitForDaemonSocket(socket: net.Socket): Promise<void>;
4
9
  /** Probe the application, not just the fd: a half-open socket may still be writable. */
@@ -2,6 +2,14 @@ import { lstatSync, utimesSync } from "node:fs";
2
2
  const CONNECT_TIMEOUT_MS = 10_000;
3
3
  const HEARTBEAT_INTERVAL_MS = 15_000;
4
4
  const HEARTBEAT_TIMEOUT_MS = 5_000;
5
+ /** Reject this client's pending generation without changing its connection lifecycle. */
6
+ export function rejectPendingRequests(pending, error) {
7
+ for (const request of pending.values()) {
8
+ clearTimeout(request.timer);
9
+ request.reject(error);
10
+ }
11
+ pending.clear();
12
+ }
5
13
  /** Bound connect attempts, including sockets that close before the handshake. */
6
14
  export function waitForDaemonSocket(socket) {
7
15
  return new Promise((resolve, reject) => {
@@ -1,3 +1,5 @@
1
+ import { isRecord } from "./object-utils.js";
2
+ import { collapseWhitespaceAndClip as collapse } from "./text-utils.js";
1
3
  /** core 会话里决策是进程内工具(`src/core-runner.ts`);CLI 会话是命令行调用。 */
2
4
  export const DECISION_TOOL_NAME = "decision_evaluate";
3
5
  /** Small shell lexer: quoted examples are one argument, not executable commands; heredoc bodies are data. */
@@ -116,14 +118,6 @@ const MODE_HINTS = {
116
118
  choice: "选择", score: "评分", noul: "是非判断", mixed: "混合判断",
117
119
  };
118
120
  const GENERIC_HINT = "选择 / 评分 / 是非判断";
119
- function isRecord(value) {
120
- return !!value && typeof value === "object" && !Array.isArray(value);
121
- }
122
- /** 折叠空白并截断:摘要永远单行、有界。 */
123
- function collapse(text, max) {
124
- const flat = text.replace(/\s+/g, " ").trim();
125
- return flat.length > max ? `${flat.slice(0, Math.max(1, max - 1))}…` : flat;
126
- }
127
121
  function modeOf(types) {
128
122
  const unique = new Set(types);
129
123
  if (!unique.size)
@@ -18,6 +18,7 @@ export const API_ROUTE_RULES = [
18
18
  { prefix: "/api/connectors", scope: "admin" },
19
19
  { prefix: "/api/github", scope: "admin" },
20
20
  { prefix: "/api/update", scope: "admin" },
21
+ { prefix: "/api/update-status", scope: "admin" },
21
22
  { prefix: "/api/update-channel", scope: "admin" },
22
23
  { prefix: "/api/check-update", scope: "admin" },
23
24
  { prefix: "/api/auto-update", scope: "admin" },
@@ -15,4 +15,4 @@ export declare const PI_FALLBACK_MODELS: ClaudeModelInfo[];
15
15
  */
16
16
  export declare const GEMINI_FALLBACK_MODELS: ClaudeModelInfo[];
17
17
  export declare function cloneModels(models: readonly ClaudeModelInfo[]): ClaudeModelInfo[];
18
- export declare function isRecord(value: unknown): value is Record<string, unknown>;
18
+ export { isRecord } from "../object-utils.js";
@@ -58,6 +58,4 @@ export function cloneModels(models) {
58
58
  : {}),
59
59
  }));
60
60
  }
61
- export function isRecord(value) {
62
- return !!value && typeof value === "object" && !Array.isArray(value);
63
- }
61
+ export { isRecord } from "../object-utils.js";
@@ -20,18 +20,31 @@ export interface PackageUpdateInfo {
20
20
  updateAvailable: boolean;
21
21
  distTag: "latest" | "beta";
22
22
  installSpec: string;
23
+ /** Candidate metadata is distinct from the user's persistent subscription. */
24
+ stableVersion?: string | null;
25
+ betaVersion?: string | null;
23
26
  }
24
27
  export declare function normalizeUpdateChannel(value: unknown): UpdateChannel;
25
28
  export declare function getInstallSpecForChannel(channel: UpdateChannel): string;
26
29
  export declare function buildPackageUpdateInfo(currentVersion: string, channel: UpdateChannel, latestVersion: string | null): PackageUpdateInfo;
30
+ export declare function selectPackageUpdateInfo(currentVersion: string, channel: UpdateChannel, stableVersion: string | null, betaVersion?: string | null): PackageUpdateInfo;
27
31
  export declare function checkPackageUpdateAsync(currentVersion: string, channel: UpdateChannel, timeoutMs?: number): Promise<PackageUpdateInfo>;
28
32
  export declare function checkPackageUpdateSync(currentVersion: string, channel: UpdateChannel, timeoutMs?: number): PackageUpdateInfo;
33
+ export declare function resolveInstalledNpmPrefix(moduleUrl?: string, platform?: NodeJS.Platform): {
34
+ root: string;
35
+ prefix: string;
36
+ } | null;
29
37
  /**
30
38
  * 更新时同时存在旧包备份、npm 正在解包的新版本和下载/回滚余量。
31
39
  * 旧包大小按三倍计(安全备份、新包、回滚 staging),再保留 512 MiB,
32
40
  * 避免 ENOSPC 把全局 CLI 拆成半包。
33
41
  */
34
42
  export declare function requiredUpdateFreeBytes(currentInstallBytes: number): number;
43
+ export declare function assertNodeVersionForUpdate(range: string, current?: string): void;
44
+ export declare function checkGlobalUpdateAccess(): boolean;
45
+ export declare function isGlobalUpdateLocked(): boolean;
46
+ /** Only used in the independent helper process; holds the install lock through handoff. */
47
+ export declare function withGlobalUpdateLock<T>(action: () => Promise<T>): Promise<T>;
35
48
  /**
36
49
  * 异步版本的全局安装:
37
50
  * 1. 清理残留
@@ -44,7 +57,11 @@ export declare function requiredUpdateFreeBytes(currentInstallBytes: number): nu
44
57
  * @param log 可选 logger,用来把过程写入控制台或前端日志
45
58
  * @returns 与本次安装使用同一个 npm root 校验出的 dist/cli.js 绝对路径
46
59
  */
47
- export declare function installPackageGloballyAsync(pkg: string, timeoutMs: number, log?: (line: string) => void): Promise<string>;
60
+ export declare function installPackageGloballyAsync(pkg: string, timeoutMs: number, log?: (line: string) => void, options?: {
61
+ backupReceiptPath?: string;
62
+ }): Promise<string>;
63
+ /** Keep the old runtime until the new instance actually responds. */
64
+ export declare function finalizeDeferredGlobalUpdate(receiptPath: string, rollback?: boolean, log?: (line: string) => void): boolean;
48
65
  /**
49
66
  * 同步版本,给 TUI installUpdate 用。
50
67
  *
@@ -13,14 +13,15 @@
13
13
  */
14
14
  import { execFile, spawnSync } from "node:child_process";
15
15
  import { randomUUID } from "node:crypto";
16
- import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, readdirSync, renameSync, rmSync, statfsSync, statSync, symlinkSync, } from "node:fs";
16
+ import { accessSync, constants as fsConstants, chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, readdirSync, renameSync, rmSync, statfsSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
17
17
  import path from "node:path";
18
18
  import process from "node:process";
19
19
  import { promisify } from "node:util";
20
+ import { fileURLToPath } from "node:url";
20
21
  import { buildChildEnv } from "./env-utils.js";
21
22
  import { whichSync } from "./path-repair.js";
22
23
  import { getErrorMessage } from "./error-utils.js";
23
- import { compareWandInstallOrder, extractSemver } from "./version-utils.js";
24
+ import { compareSemver, compareWandInstallOrder, extractSemver } from "./version-utils.js";
24
25
  import { acquireDeploymentLock, releaseDeploymentLock } from "./deployment-lock.js";
25
26
  const execFileAsync = promisify(execFile);
26
27
  export const PACKAGE_NAME = "@co0ontty/wand";
@@ -95,14 +96,48 @@ function viewPackageVersionSync(channel, timeoutMs = NPM_VIEW_TIMEOUT_MS) {
95
96
  const version = res.stdout.trim();
96
97
  return version || null;
97
98
  }
99
+ export function selectPackageUpdateInfo(currentVersion, channel, stableVersion, betaVersion = null) {
100
+ const stable = acceptedVersion(stableVersion, "stable");
101
+ const beta = channel === "beta" ? acceptedVersion(betaVersion, "beta") : null;
102
+ const useBeta = beta !== null && (stable === null || compareWandInstallOrder(beta, stable) > 0);
103
+ const info = buildPackageUpdateInfo(currentVersion, useBeta ? "beta" : "stable", useBeta ? beta : stable);
104
+ return { ...info, channel, stableVersion: stable, betaVersion: beta };
105
+ }
98
106
  export async function checkPackageUpdateAsync(currentVersion, channel, timeoutMs = NPM_VIEW_TIMEOUT_MS) {
99
- const latest = await viewPackageVersionAsync(channel, timeoutMs);
100
- return buildPackageUpdateInfo(currentVersion, channel, latest);
107
+ const [stable, beta] = await Promise.all([
108
+ viewPackageVersionAsync("stable", timeoutMs),
109
+ channel === "beta" ? viewPackageVersionAsync("beta", timeoutMs) : null,
110
+ ]);
111
+ return selectPackageUpdateInfo(currentVersion, channel, stable, beta);
101
112
  }
102
113
  export function checkPackageUpdateSync(currentVersion, channel, timeoutMs = NPM_VIEW_TIMEOUT_MS) {
103
- const latest = viewPackageVersionSync(channel, timeoutMs);
104
- return buildPackageUpdateInfo(currentVersion, channel, latest);
114
+ const stable = viewPackageVersionSync("stable", timeoutMs);
115
+ const beta = channel === "beta" ? viewPackageVersionSync("beta", timeoutMs) : null;
116
+ return selectPackageUpdateInfo(currentVersion, channel, stable, beta);
117
+ }
118
+ export function resolveInstalledNpmPrefix(moduleUrl = import.meta.url, platform = process.platform) {
119
+ try {
120
+ const packageDir = path.dirname(path.dirname(fileURLToPath(moduleUrl)));
121
+ let scope = path.dirname(packageDir);
122
+ // Rollback may briefly boot through the recovery shim. Its retained backup
123
+ // still belongs to the original npm prefix, not the shell's default prefix.
124
+ if (/^\.wand-safe-backup-[A-Za-z0-9]+$/.test(path.basename(scope)))
125
+ scope = path.dirname(scope);
126
+ const root = path.dirname(scope);
127
+ if (path.basename(packageDir) !== PACKAGE_BASENAME || path.basename(scope) !== PACKAGE_SCOPE
128
+ || path.basename(root) !== "node_modules"
129
+ || JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")).name !== PACKAGE_NAME)
130
+ return null;
131
+ const parent = path.dirname(root);
132
+ if (platform !== "win32" && path.basename(parent) !== "lib")
133
+ return null;
134
+ return { root, prefix: platform === "win32" ? parent : path.dirname(parent) };
135
+ }
136
+ catch {
137
+ return null;
138
+ }
105
139
  }
140
+ const INSTALLED_NPM_BINDING = resolveInstalledNpmPrefix();
106
141
  function getChildEnv() {
107
142
  const entries = [
108
143
  path.dirname(process.execPath),
@@ -120,6 +155,7 @@ function getChildEnv() {
120
155
  return {
121
156
  ...buildChildEnv(true),
122
157
  PATH: pathEntries.join(path.delimiter),
158
+ ...(INSTALLED_NPM_BINDING ? { npm_config_prefix: INSTALLED_NPM_BINDING.prefix } : {}),
123
159
  };
124
160
  }
125
161
  function runNpmSync(args, timeoutMs) {
@@ -143,6 +179,8 @@ async function runNpmAsync(args, timeoutMs) {
143
179
  * 解析当前 `npm root -g` 的目录。失败返回 null。
144
180
  */
145
181
  function getNpmGlobalRoot() {
182
+ if (INSTALLED_NPM_BINDING)
183
+ return INSTALLED_NPM_BINDING.root;
146
184
  try {
147
185
  const res = runNpmSync(["root", "-g"], 10_000);
148
186
  if (res.status !== 0)
@@ -155,6 +193,8 @@ function getNpmGlobalRoot() {
155
193
  }
156
194
  }
157
195
  function getNpmGlobalPrefix() {
196
+ if (INSTALLED_NPM_BINDING)
197
+ return INSTALLED_NPM_BINDING.prefix;
158
198
  try {
159
199
  const res = runNpmSync(["prefix", "-g"], 10_000);
160
200
  if (res.status !== 0)
@@ -584,18 +624,71 @@ function restoreGlobalInstallBackup(backup, note) {
584
624
  return false;
585
625
  }
586
626
  }
627
+ export function assertNodeVersionForUpdate(range, current = process.versions.node) {
628
+ const minimum = range.trim().match(/^>=(\d+\.\d+\.\d+)$/)?.[1];
629
+ if (minimum && compareSemver(current, minimum) < 0) {
630
+ throw new Error(`新版本要求 Node.js >= ${minimum}(当前 ${current})。请先升级 Node.js,再执行 README 的 install.sh 恢复更新;旧安装未修改。`);
631
+ }
632
+ }
633
+ async function checkUpdateNodeVersion(pkg, timeoutMs) {
634
+ // Registry metadata is checked before creating a backup or replacing any files.
635
+ // npm --engine-strict remains the authority for other engine range formats.
636
+ const { stdout } = await execFileAsync(npmBin(), ["view", pkg, "engines.node"], {
637
+ timeout: Math.min(timeoutMs, NPM_VIEW_TIMEOUT_MS), env: getChildEnv(), maxBuffer: INSTALL_MAX_BUFFER,
638
+ });
639
+ assertNodeVersionForUpdate(String(stdout));
640
+ }
587
641
  async function npmInstallGlobalAsync(pkg, timeoutMs, extra = []) {
588
- await runNpmAsync(["install", "-g", ...extra, pkg], timeoutMs);
642
+ await runNpmAsync(["install", "-g", "--engine-strict", ...extra, pkg], timeoutMs);
589
643
  }
590
644
  function isRecoverableInstallError(message) {
591
645
  return /ENOTEMPTY|EEXIST|全局 wand 安装不完整|无法解析 npm 全局安装目录|全局 wand CLI 无法设置执行权限|全局 wand 命令入口缺失/.test(message);
592
646
  }
647
+ export function checkGlobalUpdateAccess() {
648
+ const root = getNpmGlobalRoot();
649
+ const prefix = getNpmGlobalPrefix();
650
+ if (!root || !prefix)
651
+ return false;
652
+ try {
653
+ for (const dir of [root, path.join(root, PACKAGE_SCOPE), path.join(prefix, "bin")]) {
654
+ accessSync(nearestExistingAncestor(dir), fsConstants.W_OK);
655
+ }
656
+ return true;
657
+ }
658
+ catch {
659
+ return false;
660
+ }
661
+ }
662
+ export function isGlobalUpdateLocked() {
663
+ const root = getNpmGlobalRoot();
664
+ return !!root && existsSync(path.join(root, ".wand-update-lock"));
665
+ }
593
666
  function acquireGlobalUpdateLock() {
594
667
  const root = getNpmGlobalRoot();
595
668
  if (!root)
596
669
  throw new Error("无法解析 npm 全局安装目录,不能建立更新锁。");
597
670
  mkdirSync(path.join(root, PACKAGE_SCOPE), { recursive: true });
598
- return acquireDeploymentLock(path.join(root, ".wand-update-lock"));
671
+ const inherited = process.env.WAND_DEPLOYMENT_LOCKS
672
+ ? JSON.parse(process.env.WAND_DEPLOYMENT_LOCKS) : [];
673
+ if (!Array.isArray(inherited))
674
+ throw new Error("无效的部署锁回执。");
675
+ return acquireDeploymentLock(path.join(root, ".wand-update-lock"), { inherited });
676
+ }
677
+ /** Only used in the independent helper process; holds the install lock through handoff. */
678
+ export async function withGlobalUpdateLock(action) {
679
+ const lock = acquireGlobalUpdateLock();
680
+ const previous = process.env.WAND_DEPLOYMENT_LOCKS;
681
+ try {
682
+ process.env.WAND_DEPLOYMENT_LOCKS = JSON.stringify([lock]);
683
+ return await action();
684
+ }
685
+ finally {
686
+ if (previous === undefined)
687
+ delete process.env.WAND_DEPLOYMENT_LOCKS;
688
+ else
689
+ process.env.WAND_DEPLOYMENT_LOCKS = previous;
690
+ releaseGlobalUpdateLock(lock);
691
+ }
599
692
  }
600
693
  function releaseGlobalUpdateLock(lock) {
601
694
  releaseDeploymentLock(lock);
@@ -612,11 +705,12 @@ function releaseGlobalUpdateLock(lock) {
612
705
  * @param log 可选 logger,用来把过程写入控制台或前端日志
613
706
  * @returns 与本次安装使用同一个 npm root 校验出的 dist/cli.js 绝对路径
614
707
  */
615
- export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
708
+ export async function installPackageGloballyAsync(pkg, timeoutMs, log, options = {}) {
616
709
  const note = (line) => {
617
710
  if (log)
618
711
  log(line);
619
712
  };
713
+ await checkUpdateNodeVersion(pkg, timeoutMs);
620
714
  const updateLock = acquireGlobalUpdateLock();
621
715
  let backup = null;
622
716
  let success = false;
@@ -683,7 +777,11 @@ export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
683
777
  }
684
778
  }
685
779
  // 恢复失败时绝不能再删唯一完整备份,留给人工恢复。
686
- if (backup && (success || restored))
780
+ if (backup && success && options.backupReceiptPath) {
781
+ writeFileSync(options.backupReceiptPath, JSON.stringify(backup), { mode: 0o600 });
782
+ note(`[wand] 旧安装备份保留到启动验证完成: ${backup.backupDir ?? "首次安装"}`);
783
+ }
784
+ else if (backup && (success || restored))
687
785
  cleanupGlobalInstallBackup(backup);
688
786
  }
689
787
  finally {
@@ -691,6 +789,46 @@ export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
691
789
  }
692
790
  }
693
791
  }
792
+ function readDeferredGlobalInstallBackup(receiptPath) {
793
+ const value = JSON.parse(readFileSync(receiptPath, "utf8"));
794
+ const packageDir = getGlobalPackageDir();
795
+ if (!packageDir || value.packageDir !== packageDir || !Array.isArray(value.binEntries)) {
796
+ throw new Error("更新备份回执与当前安装不一致。");
797
+ }
798
+ if (value.backupRoot === null && value.backupDir === null && value.binEntries.length === 0)
799
+ return value;
800
+ if (typeof value.backupRoot !== "string" || path.dirname(value.backupRoot) !== path.dirname(packageDir)
801
+ || !/^\.wand-safe-backup-[A-Za-z0-9]+$/.test(path.basename(value.backupRoot))
802
+ || value.backupDir !== path.join(value.backupRoot, PACKAGE_BASENAME)
803
+ || !lstatSync(value.backupRoot).isDirectory() || lstatSync(value.backupRoot).isSymbolicLink()) {
804
+ throw new Error("更新备份回执无效。");
805
+ }
806
+ const expectedBins = new Set(getGlobalWandBinPaths());
807
+ for (const entry of value.binEntries) {
808
+ if (!expectedBins.has(entry.originalPath) || path.dirname(entry.backupPath) !== path.join(value.backupRoot, "bin")) {
809
+ throw new Error("更新入口备份回执无效。");
810
+ }
811
+ }
812
+ return value;
813
+ }
814
+ /** Keep the old runtime until the new instance actually responds. */
815
+ export function finalizeDeferredGlobalUpdate(receiptPath, rollback = false, log) {
816
+ const lock = acquireGlobalUpdateLock();
817
+ try {
818
+ const backup = readDeferredGlobalInstallBackup(receiptPath);
819
+ if (rollback && !restoreGlobalInstallBackup(backup, log))
820
+ return false;
821
+ // A manager may already be starting the old runtime through the recovery
822
+ // shim. Retain that backup so its late imports remain available.
823
+ if (!rollback)
824
+ cleanupGlobalInstallBackup(backup);
825
+ rmSync(receiptPath, { force: true });
826
+ return true;
827
+ }
828
+ finally {
829
+ releaseGlobalUpdateLock(lock);
830
+ }
831
+ }
694
832
  /**
695
833
  * 同步版本,给 TUI installUpdate 用。
696
834
  *
@@ -701,6 +839,10 @@ export function installPackageGloballySync(pkg, timeoutMs) {
701
839
  const backupNotes = [];
702
840
  let updateLock;
703
841
  try {
842
+ const metadata = runNpmSync(["view", pkg, "engines.node"], Math.min(timeoutMs, NPM_VIEW_TIMEOUT_MS));
843
+ if (metadata.status === 0)
844
+ assertNodeVersionForUpdate(metadata.stdout);
845
+ // Fixtures/old registries may not expose engines; --engine-strict still checks installation.
704
846
  updateLock = acquireGlobalUpdateLock();
705
847
  }
706
848
  catch (err) {
@@ -732,7 +874,7 @@ export function installPackageGloballySync(pkg, timeoutMs) {
732
874
  };
733
875
  };
734
876
  const tryInstall = (extra) => {
735
- const args = ["install", "-g", ...extra, pkg];
877
+ const args = ["install", "-g", "--engine-strict", ...extra, pkg];
736
878
  attempts.push(`npm ${args.join(" ")}`);
737
879
  const r = runNpmSync(args, timeoutMs);
738
880
  return withValidation({
@@ -0,0 +1,2 @@
1
+ /** Non-null objects excluding arrays; retains the existing Date/class-instance behavior. */
2
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
@@ -0,0 +1,4 @@
1
+ /** Non-null objects excluding arrays; retains the existing Date/class-instance behavior. */
2
+ export function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
@@ -0,0 +1,8 @@
1
+ /** Fields consumed by Wand's Pi reducer. Context/entries are CLI-owned history,
2
+ * not new output. Never journal repeated copies of that history in native mode. */
3
+ export declare const PI_PROTOCOL_FIELDS: Set<string>;
4
+ /** agent_end is a reconciliation snapshot, not a second replay of every turn.
5
+ * The latest assistant owns the final answer, usage and stop/error facts. This
6
+ * also supports CLIs that emit only the final snapshot (without deltas). */
7
+ export declare function lastPiAssistantMessage(event: Record<string, unknown>): Record<string, unknown> | undefined;
8
+ export declare function projectPiProtocolEvent(event: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,28 @@
1
+ /** Fields consumed by Wand's Pi reducer. Context/entries are CLI-owned history,
2
+ * not new output. Never journal repeated copies of that history in native mode. */
3
+ export const PI_PROTOCOL_FIELDS = new Set([
4
+ "type", "id", "message", "assistantMessageEvent", "toolCallId", "toolName",
5
+ "args", "result", "partialResult", "isError", "messages",
6
+ ]);
7
+ /** agent_end is a reconciliation snapshot, not a second replay of every turn.
8
+ * The latest assistant owns the final answer, usage and stop/error facts. This
9
+ * also supports CLIs that emit only the final snapshot (without deltas). */
10
+ export function lastPiAssistantMessage(event) {
11
+ const messages = event.type === "agent_end" && Array.isArray(event.messages) ? event.messages
12
+ : event.type === "message_end" || event.type === "turn_end" ? [event.message] : [];
13
+ for (let index = messages.length - 1; index >= 0; index--) {
14
+ const message = messages[index];
15
+ if (message && typeof message === "object" && !Array.isArray(message)
16
+ && message.role === "assistant")
17
+ return message;
18
+ }
19
+ return undefined;
20
+ }
21
+ export function projectPiProtocolEvent(event) {
22
+ const projected = Object.fromEntries(Object.entries(event).filter(([key]) => PI_PROTOCOL_FIELDS.has(key)));
23
+ if (event.type === "agent_end" && Array.isArray(event.messages)) {
24
+ const last = lastPiAssistantMessage(event);
25
+ projected.messages = last ? [last] : [];
26
+ }
27
+ return projected;
28
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
+ import { isRecord } from "./object-utils.js";
4
5
  const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
5
6
  const SUPPORTED_APIS = new Set([
6
7
  "anthropic-messages",
@@ -12,9 +13,6 @@ const MAX_PAGES_PER_ENDPOINT = 40;
12
13
  const MAX_MODELS_PER_ENDPOINT = 2000;
13
14
  const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
14
15
  const REQUEST_TIMEOUT_MS = 10_000;
15
- function isRecord(value) {
16
- return !!value && typeof value === "object" && !Array.isArray(value);
17
- }
18
16
  function resolveConfigValue(value, env) {
19
17
  if (typeof value !== "string")
20
18
  return null;
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export default function wandNativeEvents(pi: ExtensionAPI): void;