@co0ontty/wand 4.85.1-beta.g18a596a → 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 +3 -3
  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
package/README.md CHANGED
@@ -269,6 +269,10 @@ wand config:set port 9443
269
269
  | `language` | `""` | Claude 回复语言偏好(偏好项,存 SQLite) |
270
270
  | `publicOrigin` | 未设置 | 客户端应使用的公开访问地址,如 `https://home.example.com:8443`。TLS 在 L4 反代终止时必填 |
271
271
 
272
+ ### 语音识别
273
+
274
+ Web、Android、iOS 可在语音输入设置中选择「服务端识别」或「客户端本地识别」。服务端使用按需安装的 whisper.cpp 多语言模型,支持无 GPU CPU 服务器、Mac mini Metal 与可选 CUDA;模型不随普通构建下载。部署、模型大小、鉴权和隐私边界见 [服务端语音识别](docs/server-speech.md)。
275
+
272
276
  ### 可选本地决策(实验性)
273
277
 
274
278
  Apple Silicon 服务端可共享一份离线 Laya-MLX,为结构化 CLI 提供通用选择、评分和是非判断。它不是聊天模型,也不负责授权或自动派工。
@@ -22,9 +22,6 @@ export function resolveTaskDispatchTarget(deps, task) {
22
22
  export async function dispatchAgentForTask(deps, input) {
23
23
  const { storage, config, structured, processes } = deps;
24
24
  const { agent } = input;
25
- if (agent.engine === "sdk" && !input.employee) {
26
- throw new Error("SDK 执行引擎只能通过员工候选启用。");
27
- }
28
25
  if (input.employee) {
29
26
  if (agent.kind !== "structured")
30
27
  throw new Error("绑定员工只支持结构化会话。");
@@ -65,6 +62,11 @@ export async function dispatchAgentForTask(deps, input) {
65
62
  provider,
66
63
  model: resolvedModel,
67
64
  thinkingEffort: agent.thinkingEffort,
65
+ // 直接派发 Wand Agent(不进员工候选链):先裁决进程内引擎是否可用,
66
+ // 不可用就让这次派发明确失败,不静默退回 CLI 冒充。
67
+ ...(agent.engine === "sdk"
68
+ ? { engine: structured.resolveNewSessionPiEngine("sdk").engine }
69
+ : {}),
68
70
  worktreeEnabled: false,
69
71
  sessionSource: "automation",
70
72
  automationId: input.automationId,
@@ -127,6 +127,7 @@ function chatAuthor(member, sessionId, agent) {
127
127
  avatar: member.avatar && !member.avatar.startsWith("data:") ? member.avatar : undefined,
128
128
  leader: member.isLeader || undefined,
129
129
  provider: used?.provider ?? member.agent.provider,
130
+ engine: used?.engine,
130
131
  model: used?.model?.trim() || undefined,
131
132
  thinkingEffort: used?.thinkingEffort || undefined,
132
133
  sessionId: sessionId ?? undefined,
@@ -106,6 +106,7 @@ export declare const DEFAULT_EMPLOYEE_ID = "e_wand_default";
106
106
  export declare const DEFAULT_EMPLOYEE_NAME = "\u8D5B\u535A\u864E\u599E";
107
107
  export declare const DEFAULT_EMPLOYEE_TAG = "\u9ED8\u8BA4\u7528\u6237";
108
108
  export declare function isDefaultSiliconEmployee(employee: Pick<SiliconEmployee, "systemKey"> | null | undefined): boolean;
109
+ export declare function isDecisionExpertSiliconEmployee(employee: Pick<SiliconEmployee, "systemKey"> | null | undefined): boolean;
109
110
  export declare function isBuiltinSiliconEmployee(employee: Pick<SiliconEmployee, "systemKey"> | null | undefined): boolean;
110
111
  export declare const SILICON_EMPLOYEE_MAX_TAGS = 8;
111
112
  export declare const SILICON_EMPLOYEE_TAG_MAX_CHARS = 20;
@@ -1,3 +1,4 @@
1
+ import { DECISION_EXPERT_KEY } from "./decision-expert-identity.js";
1
2
  export const AI_TEAM_MIN_MEMBERS = 2;
2
3
  export const AI_TEAM_MAX_MEMBERS = 8;
3
4
  export const AI_TEAM_DEFAULT_MAX_STEPS = 30;
@@ -47,14 +48,17 @@ export const DEFAULT_EMPLOYEE_TAG = "默认用户";
47
48
  export function isDefaultSiliconEmployee(employee) {
48
49
  return employee?.systemKey === DEFAULT_EMPLOYEE_KEY;
49
50
  }
51
+ export function isDecisionExpertSiliconEmployee(employee) {
52
+ return employee?.systemKey === DECISION_EXPERT_KEY;
53
+ }
50
54
  export function isBuiltinSiliconEmployee(employee) {
51
- return isSystemSiliconEmployee(employee) || isDefaultSiliconEmployee(employee);
55
+ return isSystemSiliconEmployee(employee) || isDefaultSiliconEmployee(employee) || isDecisionExpertSiliconEmployee(employee);
52
56
  }
53
57
  export const SILICON_EMPLOYEE_MAX_TAGS = 8;
54
58
  export const SILICON_EMPLOYEE_TAG_MAX_CHARS = 20;
55
59
  /** 内置标识只由真实员工身份投影,不能用自定义标签冒充。 */
56
60
  export function siliconEmployeeTags(employee) {
57
- if (isSystemSiliconEmployee(employee))
61
+ if (isSystemSiliconEmployee(employee) || isDecisionExpertSiliconEmployee(employee))
58
62
  return [SYSTEM_EMPLOYEE_TAG];
59
63
  if (isDefaultSiliconEmployee(employee))
60
64
  return [DEFAULT_EMPLOYEE_TAG];
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "18a596a0dddbd8bd978f9e023fa2e163e33ee531",
3
- "builtAt": "2026-10-08T02:06:19.406Z",
4
- "version": "4.85.1-beta.g18a596a",
2
+ "commit": "d2f0f8fd339f2c9b668c596b443fb8d1b0756783",
3
+ "builtAt": "2026-10-09T02:34:16.136Z",
4
+ "version": "4.87.0-beta.gd2f0f8f",
5
5
  "channel": "beta"
6
6
  }
package/dist/cli.js CHANGED
@@ -201,12 +201,18 @@ async function main() {
201
201
  || !existsSync(pythonPath) || !existsSync(join(modelPath, "model.safetensors")))) {
202
202
  throw new Error("用法:wand decision:configure --python <Python绝对路径> --model <已下载模型目录>;或 --disable。");
203
203
  }
204
- const { config } = await loadConfigForCli(configPath);
205
- config.localDecision = disabling
206
- ? { enabled: false, pythonPath: config.localDecision?.pythonPath ?? "", modelPath: config.localDecision?.modelPath ?? "" }
207
- : { enabled: true, pythonPath, modelPath };
208
- await saveConfig(configPath, config);
209
- process.stdout.write("本地决策配置已保存,重启 Wand 服务后生效。未修改系统 Python,也未下载模型。\n");
204
+ const { config, dbPath } = await loadConfigForCli(configPath);
205
+ const { WandStorage } = await import("./storage.js");
206
+ const storage = new WandStorage(dbPath);
207
+ try {
208
+ writePreferenceToStorage(config, storage, "localDecision", disabling
209
+ ? { enabled: false, pythonPath: config.localDecision?.pythonPath ?? "", modelPath: config.localDecision?.modelPath ?? "" }
210
+ : { enabled: true, pythonPath, modelPath });
211
+ }
212
+ finally {
213
+ storage.close();
214
+ }
215
+ process.stdout.write("本地决策配置已保存,CLI 修改需重启 Wand 服务后生效;设置页管理可热更新。未修改系统 Python,也未下载模型。\n");
210
216
  break;
211
217
  }
212
218
  case "decision:skills": {
package/dist/config.d.ts CHANGED
@@ -7,7 +7,7 @@ import type { WandStorage } from "./storage.js";
7
7
  * 升级路径:老 JSON 里仍存有这些字段时,首次启动会被搬到 DB(见 migrateLegacyPreferencesToDb),
8
8
  * 然后下一次 saveConfig 写回 JSON 时它们会被剥离(见 stripPreferenceFields)。
9
9
  */
10
- export declare const PREFERENCE_KEYS: readonly ["defaultProvider", "defaultSessionKind", "defaultTaskWorktree", "defaultMode", "defaultCwd", "defaultModel", "defaultCodexModel", "defaultOpenCodeModel", "defaultGrokModel", "defaultQoderModel", "defaultPiModel", "defaultGeminiModel", "modelGroups", "commitCli", "commitModel", "systemAiCli", "systemAiModel", "defaultThinkingEffort", "language", "cardDefaults", "inheritEnv", "taskRetention", "userProfile"];
10
+ export declare const PREFERENCE_KEYS: readonly ["defaultProvider", "defaultSessionKind", "defaultEngine", "defaultTaskWorktree", "defaultMode", "defaultCwd", "defaultModel", "defaultCodexModel", "defaultOpenCodeModel", "defaultGrokModel", "defaultQoderModel", "defaultPiModel", "defaultGeminiModel", "modelGroups", "commitCli", "commitModel", "systemAiCli", "systemAiModel", "defaultThinkingEffort", "language", "cardDefaults", "inheritEnv", "taskRetention", "userProfile", "localDecision"];
11
11
  export type PreferenceKey = (typeof PREFERENCE_KEYS)[number];
12
12
  /**
13
13
  * 每个 provider 的默认模型偏好字段。新增 provider 时只改这一处:
package/dist/config.js CHANGED
@@ -10,6 +10,7 @@ import { isThinkingEffort } from "./structured-provider-common.js";
10
10
  import { normalizeModelGroups } from "./model-groups.js";
11
11
  import { normalizeUserProfile, parseUserProfile } from "./user-profile.js";
12
12
  import { defaultTaskRetention, normalizeTaskRetention, parseTaskRetention } from "./task-retention.js";
13
+ import { parseLocalDecisionConfig } from "./decision-types.js";
13
14
  const DEFAULT_CONFIG_DIR = ".wand";
14
15
  const DEFAULT_CONFIG_FILE = "config.json";
15
16
  /**
@@ -22,6 +23,7 @@ const DEFAULT_CONFIG_FILE = "config.json";
22
23
  export const PREFERENCE_KEYS = [
23
24
  "defaultProvider",
24
25
  "defaultSessionKind",
26
+ "defaultEngine",
25
27
  "defaultTaskWorktree",
26
28
  "defaultMode",
27
29
  "defaultCwd",
@@ -43,6 +45,7 @@ export const PREFERENCE_KEYS = [
43
45
  "inheritEnv",
44
46
  "taskRetention",
45
47
  "userProfile",
48
+ "localDecision",
46
49
  ];
47
50
  /**
48
51
  * 每个 provider 的默认模型偏好字段。新增 provider 时只改这一处:
@@ -98,6 +101,8 @@ export const defaultConfig = () => ({
98
101
  password: "change-me",
99
102
  defaultProvider: "claude",
100
103
  defaultSessionKind: "structured",
104
+ // 新建会话默认走 CLI;只有用户在新建面板显式选过 Wand Agent 才会改成 sdk。
105
+ defaultEngine: "cli",
101
106
  defaultTaskWorktree: true,
102
107
  // 非 root 启动时才有资格用 Claude 的 permission-bypass(root 会被 Claude CLI 拒绝),
103
108
  // 所以这种环境下把默认执行模式抬到「托管」——开箱即得自动确认权限的全自主体验。
@@ -249,6 +254,7 @@ export async function loadConfigWithStorage(configPath, storage) {
249
254
  });
250
255
  // 默认任务伙伴独立于系统运维;重启保留短期偏好与用户维护的候选。
251
256
  storage.ensureDefaultSiliconEmployee(config.defaultProvider);
257
+ storage.ensureDecisionExpertEmployee();
252
258
  // 如果 JSON 里有偏好字段(说明是老版本配置或刚迁移),重写一次干净版本
253
259
  const hasLegacyPrefs = PREFERENCE_KEYS.some((key) => key in rawInput);
254
260
  const hasLegacySecrets = "password" in rawInput || "appSecret" in rawInput;
@@ -345,6 +351,11 @@ export function applyStoragePreferences(config, storage) {
345
351
  if (v === "pty" || v === "structured")
346
352
  config.defaultSessionKind = v;
347
353
  }
354
+ if (storage.hasPreference(preferenceStorageKey("defaultEngine"))) {
355
+ const v = storage.getPreference(preferenceStorageKey("defaultEngine"), defaults.defaultEngine ?? "cli");
356
+ if (v === "cli" || v === "sdk")
357
+ config.defaultEngine = v;
358
+ }
348
359
  if (storage.hasPreference(preferenceStorageKey("defaultTaskWorktree"))) {
349
360
  const v = storage.getPreference(preferenceStorageKey("defaultTaskWorktree"), defaults.defaultTaskWorktree ?? true);
350
361
  if (typeof v === "boolean")
@@ -415,6 +426,14 @@ export function applyStoragePreferences(config, storage) {
415
426
  if (storage.hasPreference(preferenceStorageKey("userProfile"))) {
416
427
  config.userProfile = normalizeUserProfile(storage.getPreference(preferenceStorageKey("userProfile"), null));
417
428
  }
429
+ if (storage.hasPreference(preferenceStorageKey("localDecision"))) {
430
+ try {
431
+ config.localDecision = parseLocalDecisionConfig(storage.getPreference(preferenceStorageKey("localDecision"), defaults.localDecision));
432
+ }
433
+ catch {
434
+ config.localDecision = { ...defaults.localDecision };
435
+ }
436
+ }
418
437
  return config;
419
438
  }
420
439
  /** Write a single preference value to DB and (in-place) update the live config object. */
@@ -429,6 +448,12 @@ export function writePreferenceToStorage(config, storage, key, value) {
429
448
  return;
430
449
  }
431
450
  switch (key) {
451
+ case "localDecision": {
452
+ const decision = parseLocalDecisionConfig(value);
453
+ storage.setPreference(dbKey, decision);
454
+ config.localDecision = decision;
455
+ break;
456
+ }
432
457
  case "modelGroups": {
433
458
  const groups = normalizeModelGroups(value);
434
459
  storage.setPreference(dbKey, groups);
@@ -449,6 +474,13 @@ export function writePreferenceToStorage(config, storage, key, value) {
449
474
  config.defaultSessionKind = value;
450
475
  break;
451
476
  }
477
+ case "defaultEngine": {
478
+ if (value !== "cli" && value !== "sdk")
479
+ throw new Error(`无效执行引擎: ${String(value)}`);
480
+ storage.setPreference(dbKey, value);
481
+ config.defaultEngine = value;
482
+ break;
483
+ }
452
484
  case "defaultTaskWorktree": {
453
485
  if (typeof value !== "boolean")
454
486
  throw new Error("defaultTaskWorktree 必须是布尔值。");
@@ -772,6 +804,7 @@ function mergeWithDefaults(input) {
772
804
  taskRetention: normalizeTaskRetention(input.taskRetention),
773
805
  defaultProvider: isSessionProvider(input.defaultProvider) ? input.defaultProvider : "claude",
774
806
  defaultSessionKind: input.defaultSessionKind === "pty" ? "pty" : "structured",
807
+ defaultEngine: input.defaultEngine === "sdk" ? "sdk" : "cli",
775
808
  defaultTaskWorktree: typeof input.defaultTaskWorktree === "boolean" ? input.defaultTaskWorktree : defaults.defaultTaskWorktree,
776
809
  ...providerModelOverrides(input, defaults),
777
810
  modelGroups: normalizeModelGroups(input.modelGroups ?? []),
@@ -344,7 +344,7 @@ export class CoreRunner {
344
344
  const session = context.session;
345
345
  if (session.piSettings?.resources || session.piSettings?.codemodeOverride
346
346
  || session.piSettings?.autoResources || session.piSettings?.lockedSkills?.length) {
347
- return "当前 SDK 候选尚不支持会话级 Skills / MCP 选择,没有改用全局资源。";
347
+ return "Wand Agent 尚不支持会话级 Skills / MCP 选择,没有改用全局资源。";
348
348
  }
349
349
  const injectedStreamFn = this.options.streamFn;
350
350
  // 测试环境必须显式注入模型缝隙:避免单测无意中发出真实付费请求(或挂在网络上)。
@@ -0,0 +1,6 @@
1
+ /** Public projection: no daemon credentials, paths, process IDs or raw failures. */
2
+ export interface DaemonMaintenanceStatus {
3
+ pending: boolean;
4
+ phase: "idle" | "waiting" | "updating" | "retrying";
5
+ }
6
+ export declare function daemonMaintenanceMessage(status: DaemonMaintenanceStatus): string;
@@ -0,0 +1,9 @@
1
+ export function daemonMaintenanceMessage(status) {
2
+ if (!status.pending)
3
+ return "";
4
+ if (status.phase === "updating")
5
+ return "底层组件正在自动更新。无需操作。";
6
+ if (status.phase === "retrying")
7
+ return "底层组件更新尚未完成,系统会自动重试。无需操作。";
8
+ return "底层组件更新正在等待执行与队列结束,届时自动更新。无需操作。";
9
+ }
@@ -0,0 +1,20 @@
1
+ import type { DaemonInspection, DaemonMaintenanceTarget } from "./daemon-maintenance.js";
2
+ import { type UpgradeAwareTerminalHost } from "./render-host.js";
3
+ import type { RenderStructuredClient } from "./render-structured-client.js";
4
+ interface OwnerSnapshot extends DaemonInspection {
5
+ pid: number;
6
+ atomicShutdown?: boolean;
7
+ }
8
+ /** Unknown/malformed inventories must not be interpreted as zero live sessions. */
9
+ export declare function countRunningDaemonEntries(value: unknown): number;
10
+ /** Persist the once-only shutdown intent: a second Render drain escalates to kill. */
11
+ export declare function persistentDaemonTarget(options: {
12
+ name: string;
13
+ checkpointPath: string;
14
+ snapshot(includeInventory?: boolean): Promise<OwnerSnapshot>;
15
+ shutdown(snapshot: OwnerSnapshot): Promise<boolean | void>;
16
+ ensure(): Promise<void>;
17
+ reconnect(): Promise<void>;
18
+ }): DaemonMaintenanceTarget;
19
+ export declare function createDaemonMaintenanceTargets(configPath: string, pty: UpgradeAwareTerminalHost, structured: RenderStructuredClient | null, configuredRenderBinary?: string): Promise<DaemonMaintenanceTarget[]>;
20
+ export {};
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { readRenderBinaryVersion, resolveRenderBinaryPath } from "./render-binary.js";
5
+ import { RenderDaemonClient } from "./render-daemon-client.js";
6
+ import { createRenderTerminalHost, shouldUpgradeRenderDaemon } from "./render-host.js";
7
+ import { renderPaths } from "./render-protocol.js";
8
+ import { resolveStructuredRenderBinary, startStructuredRenderHost } from "./render-structured-host.js";
9
+ import { structuredRenderPaths } from "./render-structured-protocol.js";
10
+ import { createTerminalHost } from "./terminal-daemon-client.js";
11
+ import { TERMINAL_DAEMON_BUILD_ID } from "./terminal-daemon-build.js";
12
+ import { terminalDaemonPaths } from "./terminal-daemon-protocol.js";
13
+ function live(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ESRCH")
20
+ return false;
21
+ throw error; // permission/unknown is never evidence of an idle or dead owner
22
+ }
23
+ }
24
+ function readCheckpoint(file) {
25
+ try {
26
+ const value = JSON.parse(readFileSync(file, "utf8"));
27
+ if (!Number.isSafeInteger(value.pid) || value.pid <= 0 || typeof value.identity !== "string") {
28
+ throw new Error("Invalid daemon maintenance checkpoint");
29
+ }
30
+ return value;
31
+ }
32
+ catch (error) {
33
+ if (error.code === "ENOENT")
34
+ return null;
35
+ throw error;
36
+ }
37
+ }
38
+ function identity(pid, tokenPath) {
39
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
40
+ throw new Error("Daemon did not report an authenticated PID");
41
+ const token = readFileSync(tokenPath, "utf8").trim();
42
+ if (!token)
43
+ throw new Error("Daemon credentials are unavailable");
44
+ return { pid, identity: `${pid}:${createHash("sha256").update(token).digest("hex")}` };
45
+ }
46
+ /** Unknown/malformed inventories must not be interpreted as zero live sessions. */
47
+ export function countRunningDaemonEntries(value) {
48
+ if (!Array.isArray(value))
49
+ throw new Error("Daemon inventory is unavailable");
50
+ let running = 0;
51
+ for (const entry of value) {
52
+ if (!entry || (entry.status !== "running" && entry.status !== "exited"))
53
+ throw new Error("Daemon inventory has unknown state");
54
+ if (entry.status === "running")
55
+ running++;
56
+ }
57
+ return running;
58
+ }
59
+ /** Persist the once-only shutdown intent: a second Render drain escalates to kill. */
60
+ export function persistentDaemonTarget(options) {
61
+ const recover = async () => {
62
+ const checkpoint = readCheckpoint(options.checkpointPath);
63
+ if (checkpoint && !live(checkpoint.pid)) {
64
+ await options.ensure();
65
+ await options.reconnect();
66
+ }
67
+ };
68
+ return {
69
+ name: options.name,
70
+ async inspect(includeInventory = true) {
71
+ await recover();
72
+ const snapshot = await options.snapshot(includeInventory);
73
+ if (!snapshot.pending && readCheckpoint(options.checkpointPath))
74
+ unlinkSync(options.checkpointPath);
75
+ return snapshot;
76
+ },
77
+ async restart(expected) {
78
+ const current = await options.snapshot();
79
+ if (current.identity !== expected.identity || !current.pending || current.running !== 0) {
80
+ throw new Error(`${options.name} changed while preparing its update`);
81
+ }
82
+ const checkpoint = readCheckpoint(options.checkpointPath);
83
+ if (checkpoint?.identity !== current.identity) {
84
+ const temporary = `${options.checkpointPath}.${process.pid}.tmp`;
85
+ writeFileSync(temporary, JSON.stringify({ identity: current.identity, pid: current.pid }), { mode: 0o600 });
86
+ renameSync(temporary, options.checkpointPath);
87
+ if (await options.shutdown(current) === false) {
88
+ unlinkSync(options.checkpointPath); // explicit refusal: no shutdown was accepted
89
+ throw new Error(`${options.name} became busy; leaving it alive`);
90
+ }
91
+ }
92
+ const deadline = Date.now() + 5_000;
93
+ while (live(current.pid)) {
94
+ if (Date.now() >= deadline)
95
+ throw new Error(`${options.name} is still draining; leaving it alive`);
96
+ await delay(50);
97
+ }
98
+ await options.ensure();
99
+ // Existing manager/host references remain valid across token rotation.
100
+ await options.reconnect();
101
+ const replacement = await options.snapshot();
102
+ if (replacement.identity === current.identity || replacement.pending)
103
+ throw new Error(`${options.name} replacement is not current`);
104
+ unlinkSync(options.checkpointPath);
105
+ },
106
+ };
107
+ }
108
+ export async function createDaemonMaintenanceTargets(configPath, pty, structured, configuredRenderBinary) {
109
+ const targets = [];
110
+ const render = pty.renderHost;
111
+ if (render instanceof RenderDaemonClient) {
112
+ const binary = configuredRenderBinary?.trim() || resolveRenderBinaryPath(configPath);
113
+ const version = binary ? await readRenderBinaryVersion(binary) : null;
114
+ const paths = renderPaths(configPath);
115
+ targets.push(persistentDaemonTarget({
116
+ name: "Render", checkpointPath: `${paths.tokenPath}.update.json`,
117
+ async snapshot(includeInventory = true) {
118
+ const { hello, sessions } = await render.maintenanceSnapshot(includeInventory);
119
+ return { ...identity(hello.pid, paths.tokenPath),
120
+ pending: shouldUpgradeRenderDaemon(hello.version, version, 0), running: includeInventory ? countRunningDaemonEntries(sessions) : null };
121
+ },
122
+ shutdown: () => render.requestShutdownDrain(),
123
+ async ensure() { const client = await createRenderTerminalHost(configPath, { binaryPath: binary ?? undefined }); client.disconnect(); },
124
+ reconnect: () => render.connect(),
125
+ }));
126
+ }
127
+ const legacy = pty.legacyHost;
128
+ if (legacy) {
129
+ const paths = terminalDaemonPaths(configPath);
130
+ targets.push(persistentDaemonTarget({
131
+ name: "terminald", checkpointPath: `${paths.tokenPath}.update.json`,
132
+ async snapshot(includeInventory = true) {
133
+ const hello = await legacy.request("hello");
134
+ let running = null;
135
+ if (includeInventory) {
136
+ const [sessions, runs] = await Promise.all([legacy.request("list"), legacy.request("structuredList")]);
137
+ running = countRunningDaemonEntries(sessions) + countRunningDaemonEntries(runs);
138
+ }
139
+ return { ...identity(hello.pid, paths.tokenPath), pending: hello.buildId !== TERMINAL_DAEMON_BUILD_ID,
140
+ running, atomicShutdown: hello.shutdownIfIdle === true };
141
+ },
142
+ async shutdown(snapshot) {
143
+ if (snapshot.atomicShutdown) {
144
+ const result = await legacy.request("shutdownIfIdle");
145
+ if (result?.accepted === false)
146
+ return false;
147
+ if (result?.accepted !== true)
148
+ throw new Error("terminald shutdown result is unknown");
149
+ }
150
+ else {
151
+ // One-time migration for pre-capability terminald. Server is the sole
152
+ // execution admission owner; both RPC inventories were checked under
153
+ // its shared barrier. Never signal a guessed pid or escalate to KILL.
154
+ const hello = await legacy.request("hello");
155
+ if (identity(hello.pid, paths.tokenPath).identity !== snapshot.identity)
156
+ throw new Error("terminald owner changed");
157
+ process.kill(snapshot.pid, "SIGTERM");
158
+ }
159
+ },
160
+ async ensure() { const client = await createTerminalHost(configPath); client.disconnect(); },
161
+ reconnect: () => legacy.connect(),
162
+ }));
163
+ }
164
+ if (structured) {
165
+ const binary = resolveStructuredRenderBinary(configPath);
166
+ // v1 and v2 are separately released; never read the sibling PTY sidecar.
167
+ const version = binary ? await readRenderBinaryVersion(binary, `${binary}.version`) : null;
168
+ const paths = structuredRenderPaths(configPath);
169
+ targets.push(persistentDaemonTarget({
170
+ name: "Structured Render", checkpointPath: `${paths.tokenPath}.update.json`,
171
+ async snapshot(includeInventory = true) {
172
+ const hello = await structured.request("hello");
173
+ const result = includeInventory ? await structured.request("list") : null;
174
+ return { ...identity(hello.pid, paths.tokenPath), pending: shouldUpgradeRenderDaemon(hello.version, version, 0),
175
+ running: result ? countRunningDaemonEntries(result.runs) : null };
176
+ },
177
+ async shutdown() { await structured.request("shutdown", { mode: "drain" }); },
178
+ async ensure() { const client = await startStructuredRenderHost(configPath); client.disconnect(); },
179
+ reconnect: () => structured.connect(),
180
+ }));
181
+ }
182
+ return targets;
183
+ }
@@ -0,0 +1,53 @@
1
+ import type { StructuredExecHost } from "./structured-exec-host.js";
2
+ import type { TerminalHost } from "./terminal-host.js";
3
+ import type { DaemonMaintenanceStatus } from "./daemon-maintenance-state.js";
4
+ /** One admission barrier for PTY and structured starts, not for ongoing input/output. */
5
+ export declare class DaemonAdmission {
6
+ private barrier;
7
+ private active;
8
+ private settled;
9
+ run<T>(operation: () => Promise<T>): Promise<T>;
10
+ exclusive<T>(operation: () => Promise<T>): Promise<T>;
11
+ terminal(host: TerminalHost): TerminalHost;
12
+ structured(host: StructuredExecHost): StructuredExecHost;
13
+ }
14
+ export interface DaemonInspection {
15
+ /** Authenticated incarnation, never a pid-file guess or a cached UI count. */
16
+ identity: string;
17
+ pending: boolean;
18
+ /** null = inventory deliberately not fetched while manager-owned work is busy. */
19
+ running: number | null;
20
+ }
21
+ export interface DaemonMaintenanceTarget {
22
+ name: string;
23
+ inspect(includeInventory?: boolean): Promise<DaemonInspection>;
24
+ /** Must recheck identity and use graceful shutdown, never a force-kill. */
25
+ restart(expected: DaemonInspection): Promise<void>;
26
+ }
27
+ export interface DaemonMaintenanceOptions {
28
+ targets: DaemonMaintenanceTarget[];
29
+ admission: DaemonAdmission;
30
+ /** Includes Core, starting runs, final checkpoints and queued input. */
31
+ busy(): boolean;
32
+ beginCoreDrain(): () => void;
33
+ available(): boolean;
34
+ log(error: unknown): void;
35
+ now?: () => number;
36
+ quietMs?: number;
37
+ }
38
+ /** Lifecycle-owned worker. GET only projects its state; it never performs maintenance. */
39
+ export declare class DaemonMaintenance {
40
+ private readonly options;
41
+ private state;
42
+ private timer;
43
+ private flight;
44
+ private stopped;
45
+ private idleSince;
46
+ private failures;
47
+ constructor(options: DaemonMaintenanceOptions);
48
+ status(): DaemonMaintenanceStatus;
49
+ start(): void;
50
+ check(): Promise<void>;
51
+ stop(): Promise<void>;
52
+ private inspectAndUpdate;
53
+ }