@co0ontty/wand 4.85.1 → 4.87.0-beta.gd2f0f8f

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 (108) hide show
  1. package/README.md +4 -0
  2. package/dist/agent-dispatch.js +5 -3
  3. package/dist/ai-team-runner.js +1 -0
  4. package/dist/ai-team-types.d.ts +1 -0
  5. package/dist/ai-team-types.js +6 -2
  6. package/dist/build-info.json +4 -4
  7. package/dist/cli.js +12 -6
  8. package/dist/config.d.ts +1 -1
  9. package/dist/config.js +33 -0
  10. package/dist/core-runner.js +1 -1
  11. package/dist/daemon-maintenance-state.d.ts +6 -0
  12. package/dist/daemon-maintenance-state.js +9 -0
  13. package/dist/daemon-maintenance-targets.d.ts +20 -0
  14. package/dist/daemon-maintenance-targets.js +183 -0
  15. package/dist/daemon-maintenance.d.ts +53 -0
  16. package/dist/daemon-maintenance.js +164 -0
  17. package/dist/decision-expert-employee.d.ts +6 -0
  18. package/dist/decision-expert-employee.js +22 -0
  19. package/dist/decision-expert-identity.d.ts +7 -0
  20. package/dist/decision-expert-identity.js +7 -0
  21. package/dist/decision-hardware.d.ts +19 -0
  22. package/dist/decision-hardware.js +19 -0
  23. package/dist/decision-service.d.ts +6 -1
  24. package/dist/decision-service.js +60 -1
  25. package/dist/decision-types.d.ts +1 -0
  26. package/dist/decision-types.js +12 -0
  27. package/dist/git-quick-commit.js +1 -1
  28. package/dist/laya-model-manifest.d.ts +11 -0
  29. package/dist/laya-model-manifest.js +18 -0
  30. package/dist/local-model-setup.d.ts +46 -0
  31. package/dist/local-model-setup.js +248 -0
  32. package/dist/local-model-types.d.ts +34 -0
  33. package/dist/local-model-types.js +1 -0
  34. package/dist/message-truncator.d.ts +4 -2
  35. package/dist/message-truncator.js +20 -6
  36. package/dist/missions.js +6 -3
  37. package/dist/model-file-store.d.ts +13 -0
  38. package/dist/model-file-store.js +108 -0
  39. package/dist/model-groups.js +2 -2
  40. package/dist/model-setup-process.d.ts +2 -0
  41. package/dist/model-setup-process.js +80 -0
  42. package/dist/models.js +3 -3
  43. package/dist/process-manager.js +21 -8
  44. package/dist/provider-catalog.d.ts +13 -1
  45. package/dist/provider-catalog.js +14 -2
  46. package/dist/render-binary.d.ts +1 -1
  47. package/dist/render-binary.js +4 -4
  48. package/dist/render-daemon-client.d.ts +12 -1
  49. package/dist/render-daemon-client.js +72 -4
  50. package/dist/render-host.d.ts +1 -0
  51. package/dist/render-host.js +9 -56
  52. package/dist/render-structured-host.d.ts +1 -0
  53. package/dist/render-structured-host.js +2 -2
  54. package/dist/server-employee-routes.js +3 -3
  55. package/dist/server-local-model-routes.d.ts +8 -0
  56. package/dist/server-local-model-routes.js +62 -0
  57. package/dist/server-resume-routes.js +1 -1
  58. package/dist/server-session-routes.js +34 -8
  59. package/dist/server-settings-routes.js +2 -0
  60. package/dist/server-speech-routes.d.ts +8 -0
  61. package/dist/server-speech-routes.js +87 -0
  62. package/dist/server-task-routes.js +3 -1
  63. package/dist/server-workspace-routes.js +2 -0
  64. package/dist/server.js +63 -18
  65. package/dist/session-completion-state.d.ts +1 -0
  66. package/dist/session-completion-state.js +1 -0
  67. package/dist/session-transport.js +1 -0
  68. package/dist/silicon-employee-dispatch.js +5 -2
  69. package/dist/silicon-employee-draft.js +1 -1
  70. package/dist/speech-models.d.ts +24 -0
  71. package/dist/speech-models.js +16 -0
  72. package/dist/speech-service.d.ts +57 -0
  73. package/dist/speech-service.js +399 -0
  74. package/dist/speech-types.d.ts +47 -0
  75. package/dist/speech-types.js +3 -0
  76. package/dist/storage.d.ts +2 -0
  77. package/dist/storage.js +13 -0
  78. package/dist/structured-failure.js +1 -1
  79. package/dist/structured-session-manager.d.ts +15 -2
  80. package/dist/structured-session-manager.js +58 -19
  81. package/dist/terminal-daemon-build.d.ts +4 -0
  82. package/dist/terminal-daemon-build.js +54 -0
  83. package/dist/terminal-daemon-protocol.d.ts +1 -1
  84. package/dist/terminal-daemon-server.js +20 -1
  85. package/dist/terminal-host.d.ts +4 -0
  86. package/dist/terminal-host.js +7 -4
  87. package/dist/tui/commands.js +1 -1
  88. package/dist/types.d.ts +14 -2
  89. package/dist/web-ui/chat-history-window.d.ts +23 -0
  90. package/dist/web-ui/chat-history-window.js +29 -0
  91. package/dist/web-ui/content/ai-teams.js +12 -12
  92. package/dist/web-ui/content/scripts.js +316 -233
  93. package/dist/web-ui/content/styles.css +1 -1
  94. package/dist/web-ui/content/tailwind.css +1 -1
  95. package/dist/web-ui/embedded-assets.js +3 -3
  96. package/dist/web-ui/index.js +3 -3
  97. package/dist/web-ui/provider-identity.d.ts +28 -1
  98. package/dist/web-ui/provider-identity.js +31 -2
  99. package/dist/web-ui/session-activity.d.ts +1 -0
  100. package/dist/web-ui/session-activity.js +5 -2
  101. package/dist/web-ui/speech-audio.d.ts +2 -0
  102. package/dist/web-ui/speech-audio.js +41 -0
  103. package/dist/web-ui/vendor-loader.d.ts +2 -0
  104. package/dist/web-ui/vendor-loader.js +50 -0
  105. package/dist/ws-broadcast.js +10 -4
  106. package/package.json +7 -3
  107. package/scripts/install-laya-runtime.js +67 -0
  108. package/scripts/install-speech-runtime.js +82 -0
@@ -723,24 +723,32 @@ export class ProcessManager extends EventEmitter {
723
723
  && record.ptyProcess !== null
724
724
  && !record.ptyBridge
725
725
  && typeof record.processId === "number"
726
- && record.processId > 0));
726
+ && record.processId > 0)).map((record) => ({ record, pid: record.processId, process: record.ptyProcess }));
727
727
  if (candidates.length === 0)
728
728
  return;
729
729
  this.ptyForegroundSampling = true;
730
730
  try {
731
- const samples = await this.probePtyForegrounds(candidates.map((record) => record.processId));
731
+ const samples = await this.probePtyForegrounds(candidates.map(({ pid }) => pid));
732
732
  if (!samples || this.disposed)
733
733
  return;
734
- for (const record of candidates) {
735
- if (this.sessions.get(record.id) !== record)
734
+ for (const { record, pid, process } of candidates) {
735
+ // ps is asynchronous: a stopped/replaced terminal must not inherit an old probe.
736
+ if (this.sessions.get(record.id) !== record || record.status !== "running"
737
+ || record.ptyProcess !== process || record.processId !== pid)
736
738
  continue;
737
- const next = samples.get(record.processId);
739
+ const next = samples.get(pid);
738
740
  if (next === undefined || record.ptyForeground === next)
739
741
  continue;
742
+ const wasForeground = record.ptyForeground === true;
740
743
  record.ptyForeground = next;
741
744
  // CLI 退回提示符:这一轮立刻结束,不必再等静默窗口。
742
745
  if (!next)
743
746
  this.closePtyTurn(record);
747
+ // 空白终端里的手动命令也有运行事实,但没有 provider 回合边界。
748
+ // 不复用 ptyBusy,避免一次前台切换冒充 AI 成功完成;静默命令也保持活动。
749
+ if (!record.provider && wasForeground !== next) {
750
+ this.emitEvent({ type: "status", sessionId: record.id, data: { ptyCommandRunning: next } });
751
+ }
744
752
  }
745
753
  }
746
754
  catch {
@@ -1099,6 +1107,7 @@ export class ProcessManager extends EventEmitter {
1099
1107
  dispose() {
1100
1108
  if (this.disposed)
1101
1109
  return;
1110
+ this.terminalHost.flush?.();
1102
1111
  this.disposed = true;
1103
1112
  // 静默心跳不跨服务关闭存活;每个会话的 timer 也在 cleanupRecord 里单独撤销。
1104
1113
  this.ptyHeartbeat.dispose();
@@ -1195,7 +1204,7 @@ export class ProcessManager extends EventEmitter {
1195
1204
  if (this.disposed)
1196
1205
  throw new Error("ProcessManager has been disposed.");
1197
1206
  if (isOpenRouterFreeSelector(opts?.model))
1198
- throw new Error("免费分组仅支持 one 的 Agent 结构化对话。");
1207
+ throw new Error("免费分组仅支持 Pi 结构化对话。");
1199
1208
  if (!opts?.interactiveShell)
1200
1209
  this.assertCommandAllowed(command);
1201
1210
  const groupProvider = opts?.interactiveShell ? undefined : opts?.provider ?? inferProviderFromCommand(command);
@@ -1239,7 +1248,7 @@ export class ProcessManager extends EventEmitter {
1239
1248
  preferDefault: !requested || requested === "default",
1240
1249
  }) : [requested ?? ""];
1241
1250
  if (preferredModels.some(isOpenRouterFreeSelector))
1242
- throw new Error("免费模型分组仅支持 one 的 Agent 结构化对话。");
1251
+ throw new Error("免费模型分组仅支持 Pi 结构化对话。");
1243
1252
  const baseCwd = resolveSessionCwd(cwd, this.config.defaultCwd);
1244
1253
  const id = opts?.reuseId || randomUUID();
1245
1254
  // When a session is being resumed under the same id, capture its prior
@@ -1729,6 +1738,7 @@ export class ProcessManager extends EventEmitter {
1729
1738
  }
1730
1739
  /** Return only a session owned by this manager, without the SQLite fallback used by get(). */
1731
1740
  getOwned(id) {
1741
+ this.terminalHost.flush?.();
1732
1742
  const record = this.sessions.get(id);
1733
1743
  if (!record)
1734
1744
  return null;
@@ -1754,7 +1764,7 @@ export class ProcessManager extends EventEmitter {
1754
1764
  preferDefault: !normalized || normalized === "default",
1755
1765
  });
1756
1766
  if (members.some(isOpenRouterFreeSelector))
1757
- throw new Error("免费分组仅支持 one 的 Agent 结构化对话。");
1767
+ throw new Error("免费分组仅支持 Pi 结构化对话。");
1758
1768
  record.selectedModel = normalized;
1759
1769
  if (record.providerCliActive && record.provider === "claude" && record.status === "running" && record.ptyProcess) {
1760
1770
  const value = members[0] && members[0] !== "default" ? members[0] : "default";
@@ -1922,6 +1932,7 @@ export class ProcessManager extends EventEmitter {
1922
1932
  return this.snapshot(record);
1923
1933
  }
1924
1934
  getTerminalState(id) {
1935
+ this.terminalHost.flush?.();
1925
1936
  const record = this.sessions.get(id);
1926
1937
  if (!record)
1927
1938
  return null;
@@ -2215,6 +2226,8 @@ export class ProcessManager extends EventEmitter {
2215
2226
  providerCliActive: record.providerCliActive,
2216
2227
  providerCliExitCode: record.providerCliExitCode,
2217
2228
  ptyBusy: record.ptyBridge ? record.ptyBridge.isResponding() : record.ptyBusy === true,
2229
+ ptyCommandRunning: record.status === "running" && record.ptyProcess !== null
2230
+ && !record.provider && record.ptyForeground === true,
2218
2231
  // 与 ptyBusy 同生命周期的本轮锚点。运行时事实:存储没有对应列,重启后为 null。
2219
2232
  ptyTurnStartedAt: record.ptyTurnStartedAt ?? null,
2220
2233
  ptyLastActivityAt: record.ptyLastActivityAt ?? null,
@@ -21,8 +21,20 @@ export declare function isNativeThinkingEffort(value: unknown): boolean;
21
21
  export declare function isSessionProvider(value: unknown): value is SessionProvider;
22
22
  /** provider 对应的 CLI 可执行文件名(Qoder 的 CLI 叫 qodercli)。 */
23
23
  export declare function providerCliCommand(provider: SessionProvider): string;
24
- /** provider 的展示名。缺省不认识的字符串一律回落到 "AI"。 */
24
+ /**
25
+ * provider 的展示名。缺省不认识的字符串一律回落到 "AI"。
26
+ *
27
+ * `pi` 指的是 Pi CLI(结构化 JSON / PTY 终端),与 Wand 自带的 Agent 是两条独立执行路径:
28
+ * 后者走进程内 SDK,展示名用 `WAND_AGENT_LABEL`,不要拿 provider 名字冒充它。
29
+ */
25
30
  export declare const PROVIDER_LABELS: Readonly<Record<SessionProvider, string>>;
31
+ /**
32
+ * Wand 自带 Agent(进程内 `core` harness / SDK)的展示名。
33
+ *
34
+ * 它和 `pi` provider 不是同一个东西:`pi` 起 Pi CLI 进程,Wand Agent 在 Wand 进程内用 SDK 跑 agent loop,
35
+ * 因此能力边界(会话级 Skills / MCP、CodeMode、持久化恢复)也不一样。
36
+ */
37
+ export declare const WAND_AGENT_LABEL = "Wand Agent";
26
38
  /**
27
39
  * provider id 别名表:既接受用户/客户端可能写出的别名(`anthropic`、`open-code`),
28
40
  * 也接受历史 runner 名(`claude-cli-print`、`codex-cli-exec` …),
@@ -28,16 +28,28 @@ export function isSessionProvider(value) {
28
28
  export function providerCliCommand(provider) {
29
29
  return provider === "qoder" ? "qodercli" : provider;
30
30
  }
31
- /** provider 的展示名。缺省不认识的字符串一律回落到 "AI"。 */
31
+ /**
32
+ * provider 的展示名。缺省不认识的字符串一律回落到 "AI"。
33
+ *
34
+ * `pi` 指的是 Pi CLI(结构化 JSON / PTY 终端),与 Wand 自带的 Agent 是两条独立执行路径:
35
+ * 后者走进程内 SDK,展示名用 `WAND_AGENT_LABEL`,不要拿 provider 名字冒充它。
36
+ */
32
37
  export const PROVIDER_LABELS = {
33
38
  claude: "Claude",
34
39
  codex: "Codex",
35
40
  opencode: "OpenCode",
36
41
  grok: "Grok",
37
42
  qoder: "Qoder",
38
- pi: "one 的 Agent",
43
+ pi: "Pi",
39
44
  gemini: "Gemini",
40
45
  };
46
+ /**
47
+ * Wand 自带 Agent(进程内 `core` harness / SDK)的展示名。
48
+ *
49
+ * 它和 `pi` provider 不是同一个东西:`pi` 起 Pi CLI 进程,Wand Agent 在 Wand 进程内用 SDK 跑 agent loop,
50
+ * 因此能力边界(会话级 Skills / MCP、CodeMode、持久化恢复)也不一样。
51
+ */
52
+ export const WAND_AGENT_LABEL = "Wand Agent";
41
53
  /**
42
54
  * provider id 别名表:既接受用户/客户端可能写出的别名(`anthropic`、`open-code`),
43
55
  * 也接受历史 runner 名(`claude-cli-print`、`codex-cli-exec` …),
@@ -56,6 +56,6 @@ export declare function resolveRenderBinaryPath(configPath: string): string | nu
56
56
  * `wand-render --version` —— 启动路径上不该无谓地执行一个进程。
57
57
  * 两种方式都失败时返回 null:调用方只要一个展示用版本号,拿不到不能影响启动。
58
58
  */
59
- export declare function readRenderBinaryVersion(binaryPath: string): Promise<string | null>;
59
+ export declare function readRenderBinaryVersion(binaryPath: string, versionFile?: string): Promise<string | null>;
60
60
  export declare function isExecutableFile(candidate: string): boolean;
61
61
  export {};
@@ -158,8 +158,8 @@ export function resolveRenderBinaryPath(configPath) {
158
158
  * `wand-render --version` —— 启动路径上不该无谓地执行一个进程。
159
159
  * 两种方式都失败时返回 null:调用方只要一个展示用版本号,拿不到不能影响启动。
160
160
  */
161
- export function readRenderBinaryVersion(binaryPath) {
162
- const sidecar = readVersionSidecar(binaryPath);
161
+ export function readRenderBinaryVersion(binaryPath, versionFile = path.join(path.dirname(binaryPath), "wand-render.version")) {
162
+ const sidecar = readVersionSidecar(versionFile);
163
163
  if (sidecar)
164
164
  return Promise.resolve(sidecar);
165
165
  return new Promise((resolve) => {
@@ -174,9 +174,9 @@ export function readRenderBinaryVersion(binaryPath) {
174
174
  });
175
175
  });
176
176
  }
177
- function readVersionSidecar(binaryPath) {
177
+ function readVersionSidecar(versionFile) {
178
178
  try {
179
- const raw = readFileSync(path.join(path.dirname(binaryPath), "wand-render.version"), "utf8").trim();
179
+ const raw = readFileSync(versionFile, "utf8").trim();
180
180
  const version = /^(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?)$/.exec(raw);
181
181
  return version ? version[1] : null;
182
182
  }
@@ -1,4 +1,4 @@
1
- import { type RenderMethod } from "./render-protocol.js";
1
+ import { type RenderHelloResult, type RenderMethod } from "./render-protocol.js";
2
2
  import { type TerminalAttachResult, type TerminalHost, type TerminalSessionState, type TerminalSpawnRequest } from "./terminal-host.js";
3
3
  /**
4
4
  * 鉴权层面的失败(与网络抖动区分)。
@@ -59,6 +59,8 @@ export declare class RenderDaemonClient implements TerminalHost {
59
59
  private bodyParts;
60
60
  private bodyBytes;
61
61
  private frameLength;
62
+ private liveDataBatch;
63
+ private liveFlushTimer;
62
64
  private disposed;
63
65
  private reconnectTimer;
64
66
  private reconnectDelayMs;
@@ -100,6 +102,12 @@ export declare class RenderDaemonClient implements TerminalHost {
100
102
  * 调用它会杀掉用户的 shell。
101
103
  */
102
104
  requestShutdownNow(): Promise<void>;
105
+ /** Fresh, authenticated inventory; never infer idleness from reconnect caches. */
106
+ maintenanceSnapshot(includeInventory?: boolean): Promise<{
107
+ hello: RenderHelloResult;
108
+ sessions: TerminalSessionState[] | null;
109
+ }>;
110
+ requestShutdownDrain(): Promise<void>;
103
111
  connect(): Promise<void>;
104
112
  private open;
105
113
  /**
@@ -142,6 +150,9 @@ export declare class RenderDaemonClient implements TerminalHost {
142
150
  private handleFrame;
143
151
  private handleResponse;
144
152
  private routeEvent;
153
+ private queueLiveData;
154
+ flush(): void;
155
+ private flushLiveData;
145
156
  private queuePendingEvent;
146
157
  /** Repair a bounded event-buffer overflow without replaying a partial stream. */
147
158
  private refreshSession;
@@ -5,7 +5,7 @@ import { startDaemonHeartbeat, waitForDaemonSocket } from "./daemon-connection.j
5
5
  import { getErrorMessage } from "./error-utils.js";
6
6
  import { appendWindow, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
7
7
  import { MAX_FRAME_BYTES, RENDER_PROTOCOL_VERSION, encodeRenderFrame, renderPaths, } from "./render-protocol.js";
8
- import { appendTerminalChunkWindow, } from "./terminal-host.js";
8
+ import { appendTerminalChunkBatch, appendTerminalChunkWindow, } from "./terminal-host.js";
9
9
  const RECONNECT_INITIAL_MS = 500;
10
10
  const RECONNECT_MAX_MS = 10_000;
11
11
  /**
@@ -19,6 +19,10 @@ const AUTH_FAILURE_RETRY_MS = 60_000;
19
19
  const REQUEST_TIMEOUT_MS = 10_000;
20
20
  /** 未挂上 listener 时的缓存上限,与 legacy 客户端保持一致。 */
21
21
  const MAX_PENDING_EVENTS = 512;
22
+ /** Tiny fixed window bounds projection cost even when IPC reads arrive one by one. */
23
+ const LIVE_BATCH_FLUSH_MS = 4;
24
+ const LIVE_BATCH_MAX_CHARS = 64 * 1024;
25
+ const LIVE_BATCH_MAX_EVENTS = 1024;
22
26
  /**
23
27
  * 鉴权层面的失败(与网络抖动区分)。
24
28
  *
@@ -164,6 +168,8 @@ export class RenderDaemonClient {
164
168
  bodyParts = [];
165
169
  bodyBytes = 0;
166
170
  frameLength = null;
171
+ liveDataBatch = null;
172
+ liveFlushTimer = null;
167
173
  disposed = false;
168
174
  reconnectTimer = null;
169
175
  reconnectDelayMs = RECONNECT_INITIAL_MS;
@@ -218,6 +224,15 @@ export class RenderDaemonClient {
218
224
  async requestShutdownNow() {
219
225
  await this.request("shutdown", { mode: "now" });
220
226
  }
227
+ /** Fresh, authenticated inventory; never infer idleness from reconnect caches. */
228
+ async maintenanceSnapshot(includeInventory = true) {
229
+ const hello = await this.request("hello");
230
+ const sessions = includeInventory ? await this.listSessions() : null;
231
+ return { hello, sessions };
232
+ }
233
+ async requestShutdownDrain() {
234
+ await this.request("shutdown", { mode: "drain" });
235
+ }
221
236
  connect() {
222
237
  if (this.disposed)
223
238
  return Promise.reject(new Error("Render client disposed"));
@@ -367,6 +382,7 @@ export class RenderDaemonClient {
367
382
  state.rows = rows;
368
383
  }
369
384
  attach(sessionId, afterSeq = 0) {
385
+ this.flushLiveData();
370
386
  const state = this.inventory.get(sessionId);
371
387
  if (!state)
372
388
  return null;
@@ -409,6 +425,7 @@ export class RenderDaemonClient {
409
425
  * 这是 Server / Render 分离的核心 —— 下一次启动 attach 回来仍拿到同一批会话。
410
426
  */
411
427
  disconnect() {
428
+ this.flushLiveData();
412
429
  this.disposed = true;
413
430
  this.stopHeartbeat?.();
414
431
  this.stopHeartbeat = null;
@@ -618,6 +635,7 @@ export class RenderDaemonClient {
618
635
  this.routeEvent(message);
619
636
  }
620
637
  handleResponse(message) {
638
+ this.flushLiveData();
621
639
  if (typeof message.id !== "number")
622
640
  return;
623
641
  const pending = this.pending.get(message.id);
@@ -634,6 +652,8 @@ export class RenderDaemonClient {
634
652
  pending.reject(new Error(`Render ${pending.method} failed (${code})${detail ? `: ${detail}` : ""}`));
635
653
  }
636
654
  routeEvent(event) {
655
+ if (event.event !== "data")
656
+ this.flushLiveData();
637
657
  if (this.reconciling) {
638
658
  if (event.event === "reconcile") {
639
659
  this.reconcileDuringSync = event;
@@ -670,13 +690,16 @@ export class RenderDaemonClient {
670
690
  if (event.seq <= state.seq)
671
691
  return;
672
692
  if (event.seq > state.seq + 1) {
693
+ this.flushLiveData();
673
694
  this.queuePendingEvent(sessionId, event);
674
695
  void this.refreshSession(sessionId, handle);
675
696
  return;
676
697
  }
677
- state.seq = Math.max(state.seq, event.seq);
678
- state.output = appendWindow(state.output, event.data, PTY_OUTPUT_MAX_SIZE);
679
- state.chunks = appendTerminalChunkWindow(state.chunks, { data: event.data, seq: event.seq });
698
+ // Validate every wire sequence independently. Only local projection is
699
+ // batched; attach/reconnect still retain the original per-frame cursors.
700
+ state.seq = event.seq;
701
+ this.queueLiveData(state, handle, { data: event.data, seq: event.seq });
702
+ return;
680
703
  }
681
704
  else if (event.event === "exit") {
682
705
  state.status = "exited";
@@ -684,6 +707,7 @@ export class RenderDaemonClient {
684
707
  }
685
708
  }
686
709
  if (event.event === "data" && typeof event.data === "string" && typeof event.seq === "number") {
710
+ this.flushLiveData();
687
711
  handle.acceptData({ data: event.data, seq: event.seq });
688
712
  }
689
713
  else if (event.event === "exit") {
@@ -691,6 +715,49 @@ export class RenderDaemonClient {
691
715
  this.handles.delete(sessionId);
692
716
  }
693
717
  }
718
+ queueLiveData(state, handle, event) {
719
+ if (this.liveDataBatch && (this.liveDataBatch.state !== state || this.liveDataBatch.handle !== handle)) {
720
+ this.flushLiveData();
721
+ }
722
+ const batch = this.liveDataBatch ?? { state, handle, events: [], chars: 0 };
723
+ this.liveDataBatch = batch;
724
+ batch.events.push(event);
725
+ batch.chars += event.data.length;
726
+ if (batch.chars >= LIVE_BATCH_MAX_CHARS || batch.events.length >= LIVE_BATCH_MAX_EVENTS) {
727
+ this.flushLiveData();
728
+ }
729
+ else if (!this.liveFlushTimer) {
730
+ // IPC can deliver many tiny reads on successive turns. A check-phase
731
+ // flush alone still projects each read on a busy installed Server. Keep
732
+ // the first read's deadline (never reset on more data): continuous output
733
+ // cannot starve, while idle echo pays at most one 4ms window.
734
+ this.liveFlushTimer = setTimeout(() => {
735
+ this.liveFlushTimer = null;
736
+ this.flushLiveData();
737
+ }, LIVE_BATCH_FLUSH_MS);
738
+ this.liveFlushTimer.unref();
739
+ }
740
+ }
741
+ flush() {
742
+ this.flushLiveData();
743
+ }
744
+ flushLiveData() {
745
+ if (this.liveFlushTimer) {
746
+ clearTimeout(this.liveFlushTimer);
747
+ this.liveFlushTimer = null;
748
+ }
749
+ const batch = this.liveDataBatch;
750
+ if (!batch)
751
+ return;
752
+ this.liveDataBatch = null;
753
+ if (this.disposed || this.handles.get(batch.state.sessionId) !== batch.handle
754
+ || this.inventory.get(batch.state.sessionId) !== batch.state)
755
+ return;
756
+ const data = batch.events.map(event => event.data).join("");
757
+ batch.state.output = appendWindow(batch.state.output, data, PTY_OUTPUT_MAX_SIZE);
758
+ batch.state.chunks = appendTerminalChunkBatch(batch.state.chunks, batch.events);
759
+ batch.handle.acceptData({ data, seq: batch.events[batch.events.length - 1].seq });
760
+ }
694
761
  queuePendingEvent(sessionId, event) {
695
762
  const buffered = this.pendingEvents.get(sessionId) ?? [];
696
763
  buffered.push(event);
@@ -770,6 +837,7 @@ export class RenderDaemonClient {
770
837
  handleDisconnect(origin) {
771
838
  if (this.socket !== origin)
772
839
  return;
840
+ this.flushLiveData();
773
841
  this.socket = null;
774
842
  this.stopHeartbeat?.();
775
843
  this.stopHeartbeat = null;
@@ -47,6 +47,7 @@ export declare class CompositeTerminalHost implements TerminalHost {
47
47
  attach(sessionId: string, afterSeq?: number): TerminalAttachResult | null;
48
48
  createOrAttach(request: TerminalSpawnRequest, afterSeq?: number): Promise<TerminalAttachResult>;
49
49
  forget(sessionId: string): void;
50
+ flush(): void;
50
51
  /**
51
52
  * 两侧都只是解绑:legacy daemon 可能还持有升级前的旧 PTY,Render 更是必须
52
53
  * 跨 Server 重启继续活着。**这里绝不能 kill 任何东西。**
@@ -13,8 +13,6 @@ import { InProcessTerminalHost, } from "./terminal-host.js";
13
13
  const RENDER_READY_TIMEOUT_MS = 5_000;
14
14
  /** 自愈 revive 的最小间隔:重连退避可能很密集,窗口内只 spawn 一次。 */
15
15
  const RENDER_REVIVE_BACKOFF_MS = 5_000;
16
- /** 空闲 daemon 升级时等它退出的上限(超过就当升级失败,交给上层决定)。 */
17
- const RENDER_SHUTDOWN_TIMEOUT_MS = 5_000;
18
16
  /** 陈留 socket 的二次确认间隔(避开「刚 bind、还没 accept」的启动窗口)。 */
19
17
  const RENDER_STALE_PROBE_DELAY_MS = 250;
20
18
  /**
@@ -109,6 +107,10 @@ export class CompositeTerminalHost {
109
107
  this.legacy?.forget(sessionId);
110
108
  this.render.forget(sessionId);
111
109
  }
110
+ flush() {
111
+ this.legacy?.flush?.();
112
+ this.render.flush?.();
113
+ }
112
114
  /**
113
115
  * 两侧都只是解绑:legacy daemon 可能还持有升级前的旧 PTY,Render 更是必须
114
116
  * 跨 Server 重启继续活着。**这里绝不能 kill 任何东西。**
@@ -234,8 +236,8 @@ export async function createUpgradeAwareTerminalHost(configPath, options = {}) {
234
236
  */
235
237
  export async function createRenderTerminalHost(configPath, options = {}) {
236
238
  const binaryPath = options.binaryPath ?? resolveRenderBinaryPath(configPath);
237
- // adopt 到的是已经在跑的 daemon:它可能是 npm 升级前启动的旧二进制。
238
- const adopt = (client) => upgradeRenderDaemonIfIdle(configPath, binaryPath, options.knownSessionIds, client);
239
+ // Adoption never restarts an owner. The server maintenance worker waits for
240
+ // every execution family and closes admission before replacing idle daemons.
239
241
  const paths = renderPaths(configPath);
240
242
  const endpointExists = process.platform !== "win32" && existsSync(paths.socketPath);
241
243
  // 已经 adopt 过的客户端需要能自己把 daemon 拉回来:adopt 完 binaryPath 就丢了,
@@ -244,12 +246,12 @@ export async function createRenderTerminalHost(configPath, options = {}) {
244
246
  if (endpointExists) {
245
247
  const adopted = await connectExistingRenderClient(configPath, options.knownSessionIds, reviveDaemon);
246
248
  if (adopted)
247
- return adopt(adopted);
249
+ return adopted;
248
250
  if (await isSocketListening(paths.socketPath)) {
249
251
  // 有人监听却拒绝 adopt(协议版本不符 / token 不符):这是错误配置,必须报出来而不是绕开。
250
252
  const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds, reviveDaemon);
251
253
  if (starting)
252
- return adopt(starting);
254
+ return starting;
253
255
  throw new Error(`A Render daemon is listening on ${paths.socketPath} but rejected adoption. Refusing to spawn a competing Render; ` +
254
256
  "check the protocol version/token of the running binary, or stop it first.");
255
257
  }
@@ -268,7 +270,7 @@ export async function createRenderTerminalHost(configPath, options = {}) {
268
270
  if (livePid !== null) {
269
271
  const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds, reviveDaemon);
270
272
  if (starting)
271
- return adopt(starting);
273
+ return starting;
272
274
  throw new Error(`Render process ${livePid} is alive but its socket ${paths.socketPath} is unavailable; refusing to spawn a second daemon.`);
273
275
  }
274
276
  if (!binaryPath)
@@ -286,55 +288,6 @@ export function shouldUpgradeRenderDaemon(daemonVersion, binaryVersion, sessionC
286
288
  return false;
287
289
  return sessionCount === 0;
288
290
  }
289
- /**
290
- * 装好的二进制比正在跑的 daemon 新时怎么办。
291
- *
292
- * npm 升级 / `./start.sh` 都不会重启正在跑的 daemon(不能杀别人的 PTY),所以升级后
293
- * 一直在跑旧代码 —— 2026-09-24 的线上事故就是这么来的:端点自愈是 9/23 21:26 补上的,
294
- * 但 terminald 是 9/19 启的老进程、Render 是 9/23 10:49 的老二进制,socket 被临时目录
295
- * 清理器删掉后永远回不来。
296
- *
297
- * 所以:daemon 空闲(没有 PTY)就直接把它换到新二进制上 —— 升级完就好;
298
- * 还有会话就只大声告警,让人在会话结束后跑 `./start.sh --restart-daemons`。
299
- */
300
- async function upgradeRenderDaemonIfIdle(configPath, binaryPath, knownSessionIds, client) {
301
- if (!binaryPath)
302
- return client;
303
- const daemonVersion = client.version;
304
- if (!daemonVersion)
305
- return client;
306
- const binaryVersion = await readRenderBinaryVersion(binaryPath);
307
- if (!binaryVersion || compareSemver(daemonVersion, binaryVersion) >= 0)
308
- return client;
309
- const sessions = client.sessionCount;
310
- if (!shouldUpgradeRenderDaemon(daemonVersion, binaryVersion, sessions)) {
311
- process.stderr.write(`[wand] WARNING: Render daemon ${daemonVersion} is older than the installed binary ${binaryVersion} but still owns ` +
312
- `${sessions} PTY session(s); it keeps running the old code. Restart it when those sessions are done: ./start.sh --restart-daemons\n`);
313
- return client;
314
- }
315
- try {
316
- await client.requestShutdownNow();
317
- client.disconnect();
318
- await waitForRenderProcessExit(renderPaths(configPath).pidPath, RENDER_SHUTDOWN_TIMEOUT_MS);
319
- const upgraded = await spawnDetachedRender(configPath, binaryPath, knownSessionIds);
320
- process.stderr.write(`[wand] Render daemon upgraded ${daemonVersion} -> ${binaryVersion} (it was idle).\n`);
321
- return upgraded;
322
- }
323
- catch (error) {
324
- // 已经让它收摊了:回不去旧 client,报清楚并让上层(auto 模式)回退 legacy。
325
- throw new Error(`Render daemon ${daemonVersion} was stopped for an upgrade to ${binaryVersion} but the replacement did not come up: ` +
326
- getErrorMessage(error));
327
- }
328
- }
329
- /** 等旧 daemon 真的退出(新 daemon 会因为 pid 活着而拒绝启动)。 */
330
- async function waitForRenderProcessExit(pidPath, timeoutMs) {
331
- const deadline = Date.now() + timeoutMs;
332
- while (Date.now() < deadline) {
333
- if (readLiveRenderPid(pidPath) === null)
334
- return;
335
- await new Promise((resolve) => setTimeout(resolve, 50));
336
- }
337
- }
338
291
  /**
339
292
  * detached spawn(stdio 忽略、unref),返回子进程 pid。
340
293
  *
@@ -3,6 +3,7 @@ import type { StructuredExecHost, StructuredExecProcess, StructuredRunState, Str
3
3
  type Owner = "legacy" | "rust";
4
4
  /** Resolve the separate v2 executable; do not confuse it with PTY v1 wand-render. */
5
5
  export declare function resolveStructuredRenderBinary(configPath: string): string | null;
6
+ export declare function startStructuredRenderHost(configPath: string): Promise<RenderStructuredClient>;
6
7
  /**
7
8
  * A stable runId can belong to exactly one persistent daemon. Configuration
8
9
  * chooses only new runs; existing owners are always resolved by inventories.
@@ -81,7 +81,7 @@ async function connectExisting(configPath) {
81
81
  return null;
82
82
  }
83
83
  }
84
- async function startV2(configPath) {
84
+ export async function startStructuredRenderHost(configPath) {
85
85
  const existing = await connectExisting(configPath);
86
86
  if (existing)
87
87
  return existing;
@@ -211,7 +211,7 @@ export async function createUpgradeAwareStructuredHost(configPath, legacy, proce
211
211
  if (process.env.WAND_TEST_MODE === "1" || process.env.NODE_TEST_CONTEXT) {
212
212
  return { host: legacy ?? undefined, rustClient: null };
213
213
  }
214
- const rustClient = processHost === "rust" ? await startV2(configPath) : await connectExisting(configPath);
214
+ const rustClient = processHost === "rust" ? await startStructuredRenderHost(configPath) : await connectExisting(configPath);
215
215
  if (!rustClient && !legacy)
216
216
  return { host: undefined, rustClient: null };
217
217
  return { host: new CompositeStructuredExecHost(legacy, rustClient, processHost), rustClient };
@@ -107,7 +107,7 @@ export function parseSystemEmployeeAgents(value, existing) {
107
107
  const agents = body.agents.map((rawAgent) => {
108
108
  const parsed = parseTaskAgent(rawAgent ?? {});
109
109
  if (!parsed)
110
- throw new Error("请选择有效的 CLI 工具或 SDK 执行引擎。");
110
+ throw new Error("请选择有效的 CLI 工具或 Wand Agent。");
111
111
  if (parsed.kind !== "structured")
112
112
  throw new Error("硅基员工只支持结构化会话。");
113
113
  return parsed;
@@ -136,7 +136,7 @@ export function parseSiliconEmployeeInput(value, existing, now) {
136
136
  agents = body.agents.map((rawAgent) => {
137
137
  const parsed = parseTaskAgent(rawAgent ?? {});
138
138
  if (!parsed)
139
- throw new Error("请选择有效的 CLI 工具或 SDK 执行引擎。");
139
+ throw new Error("请选择有效的 CLI 工具或 Wand Agent。");
140
140
  if (parsed.kind !== "structured")
141
141
  throw new Error("硅基员工只支持结构化会话。");
142
142
  return parsed;
@@ -148,7 +148,7 @@ export function parseSiliconEmployeeInput(value, existing, now) {
148
148
  else if (body.agent !== undefined && body.agent !== null) {
149
149
  const singleAgent = parseTaskAgent(body.agent);
150
150
  if (!singleAgent)
151
- throw new Error("请选择有效的 CLI 工具或 SDK 执行引擎。");
151
+ throw new Error("请选择有效的 CLI 工具或 Wand Agent。");
152
152
  if (singleAgent.kind !== "structured")
153
153
  throw new Error("硅基员工只支持结构化会话。");
154
154
  agents = [singleAgent];
@@ -0,0 +1,8 @@
1
+ import type { Express, RequestHandler } from "express";
2
+ import type { LocalModelSetupService } from "./local-model-setup.js";
3
+ /** Behind generic /api authentication; inference-only capabilities cannot install dependencies. */
4
+ export declare function registerLocalModelRoutes(app: Express, deps: {
5
+ models: LocalModelSetupService;
6
+ requireSessions: RequestHandler;
7
+ requireAdmin: RequestHandler;
8
+ }): void;
@@ -0,0 +1,62 @@
1
+ import { DecisionError } from "./decision-types.js";
2
+ import { SpeechError } from "./speech-service.js";
3
+ /** Behind generic /api authentication; inference-only capabilities cannot install dependencies. */
4
+ export function registerLocalModelRoutes(app, deps) {
5
+ app.use("/api/local-models", deps.requireSessions, (_req, res, next) => { res.set("Cache-Control", "no-store"); next(); });
6
+ const kind = (req, res, next) => {
7
+ if (req.params.kind !== "laya" && req.params.kind !== "speech") {
8
+ res.status(404).json({ error: "未知本地模型。" });
9
+ return;
10
+ }
11
+ next();
12
+ };
13
+ app.get("/api/local-models/status", async (_req, res, next) => {
14
+ try {
15
+ res.json(await deps.models.status());
16
+ }
17
+ catch (error) {
18
+ next(error);
19
+ }
20
+ });
21
+ for (const action of ["download", "initialize"]) {
22
+ app.post(`/api/local-models/:kind/${action}`, deps.requireAdmin, kind, async (req, res, next) => {
23
+ try {
24
+ deps.models.start(req.params.kind, action, req.body ?? {});
25
+ res.status(202).json(await deps.models.status());
26
+ }
27
+ catch (error) {
28
+ next(error);
29
+ }
30
+ });
31
+ }
32
+ app.post("/api/local-models/:kind/cancel", deps.requireAdmin, kind, async (req, res, next) => {
33
+ try {
34
+ if (req.body && Object.keys(req.body).length)
35
+ throw new DecisionError("INVALID_REQUEST", "取消操作不接受命令参数。");
36
+ deps.models.cancel(req.params.kind);
37
+ res.status(202).json(await deps.models.status());
38
+ }
39
+ catch (error) {
40
+ next(error);
41
+ }
42
+ });
43
+ app.patch("/api/local-models/laya/settings", deps.requireAdmin, async (req, res, next) => {
44
+ try {
45
+ if (!req.body || Object.keys(req.body).length !== 1 || typeof req.body.enabled !== "boolean")
46
+ throw new DecisionError("INVALID_REQUEST", "仅允许修改 LAYA 启用状态;路径由受信任安装入口确定。");
47
+ deps.models.setLayaEnabled(req.body.enabled);
48
+ res.json(await deps.models.status());
49
+ }
50
+ catch (error) {
51
+ next(error);
52
+ }
53
+ });
54
+ const errors = (error, _req, res, _next) => {
55
+ if (error instanceof DecisionError || error instanceof SpeechError) {
56
+ res.status(error.status).json({ error: error.message, code: error.code });
57
+ return;
58
+ }
59
+ res.status(500).json({ error: "本地模型管理失败,请刷新状态后重试。", code: "SETUP_FAILED" });
60
+ };
61
+ app.use("/api/local-models", errors);
62
+ }
@@ -65,7 +65,7 @@ const STRUCTURED_RESUME_SPECS = [
65
65
  path: "/api/pi-sessions/:nativeId/resume",
66
66
  param: "nativeId",
67
67
  spec: {
68
- label: "one 的 Agent",
68
+ label: "Pi",
69
69
  provider: "pi",
70
70
  runner: "pi-cli-json",
71
71
  nativeIdKey: "claudeSessionId",