@co0ontty/wand 4.38.0 → 4.39.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.
@@ -14,6 +14,8 @@ import { whichSync } from "../path-repair.js";
14
14
  import { computeRelaunch } from "../relaunch.js";
15
15
  import { ensureDatabaseFile, resolveDatabasePath, WandStorage } from "../storage.js";
16
16
  import { getErrorMessage } from "../error-utils.js";
17
+ import { readTerminalDaemonPid } from "../terminal-daemon-server.js";
18
+ import { isPidAlive } from "../pidfile.js";
17
19
  // ─── 重启 ────────────────────────────────────────────────────────────────
18
20
  /**
19
21
  * 重启当前进程。
@@ -146,6 +148,7 @@ function clipboardCandidates() {
146
148
  }
147
149
  // ─── 系统服务(systemd system / user / launchd) ─────────────────────────
148
150
  const LAUNCHD_LABEL = "com.wand.web";
151
+ const LAUNCHD_TERMINAL_LABEL = "com.wand.terminald";
149
152
  export const DEFAULT_SERVICE_SCOPE = "system";
150
153
  /** 当前 process 是不是 root(POSIX)。Windows 永远返回 false。 */
151
154
  function isRoot() {
@@ -389,8 +392,8 @@ function launchdRestart(scope) {
389
392
  function launchdDomain(scope) {
390
393
  return scope === "user" ? `gui/${currentUid()}` : "system";
391
394
  }
392
- function launchdTarget(scope) {
393
- return `${launchdDomain(scope)}/${LAUNCHD_LABEL}`;
395
+ function launchdTarget(scope, label = LAUNCHD_LABEL) {
396
+ return `${launchdDomain(scope)}/${label}`;
394
397
  }
395
398
  function currentUid() {
396
399
  const fn = process.getuid;
@@ -409,39 +412,105 @@ function matchLaunchdField(text, field) {
409
412
  const match = text.match(new RegExp(`^\\s*${escaped} = (.+)$`, "m"));
410
413
  return match?.[1]?.trim() ?? null;
411
414
  }
412
- function launchdBootstrap(scope, plist, successMessage) {
415
+ const LAUNCHD_POLL_INTERVAL_MS = 100;
416
+ function sleepSync(ms) {
417
+ const signal = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
418
+ Atomics.wait(signal, 0, 0, ms);
419
+ }
420
+ function waitForLaunchdCondition(check, timeoutMs) {
421
+ const deadline = Date.now() + timeoutMs;
422
+ while (true) {
423
+ if (check())
424
+ return true;
425
+ if (Date.now() >= deadline)
426
+ return false;
427
+ sleepSync(Math.min(LAUNCHD_POLL_INTERVAL_MS, Math.max(1, deadline - Date.now())));
428
+ }
429
+ }
430
+ function launchdTargetIsLoaded(scope, label = LAUNCHD_LABEL) {
431
+ const printed = spawnSync("launchctl", ["print", launchdTarget(scope, label)], {
432
+ encoding: "utf8",
433
+ timeout: 10_000,
434
+ });
435
+ return printed.status === 0;
436
+ }
437
+ function waitForLaunchdTarget(scope, loaded, timeoutMs, label = LAUNCHD_LABEL) {
438
+ return waitForLaunchdCondition(() => launchdTargetIsLoaded(scope, label) === loaded, timeoutMs);
439
+ }
440
+ function waitForLaunchdActive(scope, timeoutMs, label = LAUNCHD_LABEL) {
441
+ return waitForLaunchdCondition(() => {
442
+ const printed = spawnSync("launchctl", ["print", launchdTarget(scope, label)], {
443
+ encoding: "utf8",
444
+ timeout: 10_000,
445
+ });
446
+ if (printed.status !== 0)
447
+ return false;
448
+ const text = printed.stdout || "";
449
+ return Number(matchLaunchdField(text, "pid") || "0") > 0
450
+ || matchLaunchdField(text, "state") === "running";
451
+ }, timeoutMs);
452
+ }
453
+ function launchdBootstrap(scope, plist, successMessage, label = LAUNCHD_LABEL) {
413
454
  const domain = launchdDomain(scope);
414
- const target = launchdTarget(scope);
455
+ const target = launchdTarget(scope, label);
415
456
  const bootout = spawnSync("launchctl", ["bootout", domain, plist], { encoding: "utf8", timeout: 10_000 });
416
- const bootstrap = spawnSync("launchctl", ["bootstrap", domain, plist], { encoding: "utf8", timeout: 10_000 });
417
- const enable = spawnSync("launchctl", ["enable", target], { encoding: "utf8", timeout: 10_000 });
418
- const kickstart = spawnSync("launchctl", ["kickstart", "-k", target], { encoding: "utf8", timeout: 10_000 });
419
457
  const detail = [
420
458
  `target: ${target}`,
421
459
  `bootout: ${formatSpawnResult(bootout)}`,
422
- `bootstrap: ${formatSpawnResult(bootstrap)}`,
423
- `enable: ${formatSpawnResult(enable)}`,
424
- `kickstart: ${formatSpawnResult(kickstart)}`,
425
- ].join("\n");
426
- if (bootstrap.status !== 0 && !launchdIsAlreadyBootstrapped(bootstrap)) {
427
- return { ok: false, message: `launchctl bootstrap 失败 (${target})`, detail };
460
+ ];
461
+ // launchctl can return before the old job has fully disappeared. Starting a
462
+ // new job in that window often produces the unhelpful "Bootstrap failed: 5"
463
+ // error even though the plist is valid.
464
+ if (!waitForLaunchdTarget(scope, false, 2_000, label)) {
465
+ return {
466
+ ok: false,
467
+ message: `launchctl bootout 未生效 (${target})`,
468
+ detail: detail.join("\n"),
469
+ };
428
470
  }
471
+ const bootstrapAttempts = [];
472
+ let loaded = false;
473
+ for (let attempt = 0; attempt < 3; attempt++) {
474
+ const bootstrap = spawnSync("launchctl", ["bootstrap", domain, plist], {
475
+ encoding: "utf8",
476
+ timeout: 10_000,
477
+ });
478
+ bootstrapAttempts.push(bootstrap);
479
+ loaded = waitForLaunchdTarget(scope, true, 750, label);
480
+ if (loaded)
481
+ break;
482
+ if (!launchdIsTransientBootstrapFailure(bootstrap))
483
+ break;
484
+ sleepSync(250);
485
+ }
486
+ detail.push(...bootstrapAttempts.map((result, index) => `bootstrap${bootstrapAttempts.length > 1 ? ` #${index + 1}` : ""}: ${formatSpawnResult(result)}`));
487
+ if (!loaded) {
488
+ return { ok: false, message: `launchctl bootstrap 失败 (${target})`, detail: detail.join("\n") };
489
+ }
490
+ const enable = spawnSync("launchctl", ["enable", target], { encoding: "utf8", timeout: 10_000 });
491
+ detail.push(`enable: ${formatSpawnResult(enable)}`);
429
492
  if (enable.status !== 0) {
430
- return { ok: false, message: `launchctl enable 失败 (${target})`, detail };
493
+ return { ok: false, message: `launchctl enable 失败 (${target})`, detail: detail.join("\n") };
431
494
  }
432
- if (kickstart.status !== 0) {
433
- const status = launchdStatus(scope);
434
- if (status.state !== "active") {
435
- return { ok: false, message: `launchctl kickstart 失败 (${target})`, detail };
436
- }
495
+ // RunAtLoad normally starts a freshly bootstrapped job. Avoid immediately
496
+ // killing that still-starting process with kickstart -k; only kick an idle
497
+ // job after giving launchd a short settling window.
498
+ if (waitForLaunchdActive(scope, 2_000, label)) {
499
+ detail.push("kickstart: skipped (already active)");
500
+ return { ok: true, message: successMessage, detail: detail.join("\n") };
501
+ }
502
+ const kickstart = spawnSync("launchctl", ["kickstart", target], { encoding: "utf8", timeout: 10_000 });
503
+ detail.push(`kickstart: ${formatSpawnResult(kickstart)}`);
504
+ if (!waitForLaunchdActive(scope, 2_000, label)) {
505
+ return { ok: false, message: `launchctl kickstart 失败 (${target})`, detail: detail.join("\n") };
437
506
  }
438
- return { ok: true, message: successMessage, detail };
507
+ return { ok: true, message: successMessage, detail: detail.join("\n") };
439
508
  }
440
- function launchdBootout(scope, plist, successMessage) {
509
+ function launchdBootout(scope, plist, successMessage, label = LAUNCHD_LABEL) {
441
510
  const domain = launchdDomain(scope);
442
- const target = launchdTarget(scope);
511
+ const target = launchdTarget(scope, label);
443
512
  const bootout = spawnSync("launchctl", ["bootout", domain, plist], { encoding: "utf8", timeout: 10_000 });
444
- if (bootout.status === 0 || launchdIsNotBootstrapped(bootout)) {
513
+ if (waitForLaunchdTarget(scope, false, 2_000, label)) {
445
514
  return {
446
515
  ok: true,
447
516
  message: successMessage,
@@ -454,13 +523,9 @@ function launchdBootout(scope, plist, successMessage) {
454
523
  detail: formatSpawnResult(bootout),
455
524
  };
456
525
  }
457
- function launchdIsAlreadyBootstrapped(result) {
526
+ function launchdIsTransientBootstrapFailure(result) {
458
527
  const text = `${result.stdout || ""}\n${result.stderr || ""}`;
459
- return /already bootstrapped|service already loaded|Bootstrap failed:\s*5/i.test(text);
460
- }
461
- function launchdIsNotBootstrapped(result) {
462
- const text = `${result.stdout || ""}\n${result.stderr || ""}`;
463
- return /No such process|service is not loaded|Bootstrap failed:\s*3/i.test(text);
528
+ return /Bootstrap failed:\s*(?:5|37)|Input\/output error|resource busy|operation (?:now |in )?progress/i.test(text);
464
529
  }
465
530
  function formatSpawnResult(result) {
466
531
  const text = ((result.stdout || "") + "\n" + (result.stderr || "")).trim();
@@ -525,6 +590,23 @@ function servicePathFor(scope) {
525
590
  }
526
591
  return "";
527
592
  }
593
+ function terminalServicePathFor(scope) {
594
+ if (process.platform === "linux") {
595
+ return scope === "user"
596
+ ? path.join(os.homedir(), ".config/systemd/user/wand-terminald.service")
597
+ : "/etc/systemd/system/wand-terminald.service";
598
+ }
599
+ if (process.platform === "darwin") {
600
+ return scope === "user"
601
+ ? path.join(os.homedir(), "Library/LaunchAgents/com.wand.terminald.plist")
602
+ : "/Library/LaunchDaemons/com.wand.terminald.plist";
603
+ }
604
+ return "";
605
+ }
606
+ function hasLiveTerminalDaemon(configPath) {
607
+ const pid = readTerminalDaemonPid(configPath);
608
+ return pid !== null && isPidAlive(pid);
609
+ }
528
610
  function readServiceEntrypointForUpdate(scope) {
529
611
  const servicePath = servicePathFor(scope);
530
612
  if (process.platform === "darwin") {
@@ -678,6 +760,7 @@ function readFileStat(targetPath) {
678
760
  }
679
761
  function installSystemdService(ctx, scope) {
680
762
  const unitPath = servicePathFor(scope);
763
+ const terminalUnitPath = terminalServicePathFor(scope);
681
764
  const wandBin = resolveWandBin(ctx);
682
765
  const nodeBin = process.execPath;
683
766
  const nodeBinDir = path.dirname(nodeBin);
@@ -693,7 +776,7 @@ function installSystemdService(ctx, scope) {
693
776
  const commonExec = [
694
777
  "[Unit]",
695
778
  "Description=wand web console",
696
- "After=network-online.target",
779
+ "After=network-online.target wand-terminald.service",
697
780
  "Wants=network-online.target",
698
781
  "",
699
782
  "[Service]",
@@ -733,8 +816,40 @@ function installSystemdService(ctx, scope) {
733
816
  ];
734
817
  }
735
818
  const unit = unitLines.join("\n");
819
+ const terminalCommon = [
820
+ "[Unit]",
821
+ "Description=wand persistent terminal host",
822
+ "After=network-online.target",
823
+ "Wants=network-online.target",
824
+ "",
825
+ "[Service]",
826
+ "Type=simple",
827
+ `ExecStart=${nodeBin} ${wandBin} terminald -c ${ctx.configPath}`,
828
+ `Environment=PATH=${servicePath}`,
829
+ "Restart=always",
830
+ "RestartSec=3",
831
+ "StandardOutput=journal",
832
+ "StandardError=journal",
833
+ "SyslogIdentifier=wand-terminald",
834
+ ];
835
+ const terminalUnit = [
836
+ ...terminalCommon,
837
+ ...(scope === "system"
838
+ ? [
839
+ `User=${runUser}`,
840
+ `WorkingDirectory=${runHome}`,
841
+ `Environment=HOME=${runHome}`,
842
+ "OOMScoreAdjust=-500",
843
+ "",
844
+ "[Install]",
845
+ "WantedBy=multi-user.target",
846
+ ]
847
+ : ["", "[Install]", "WantedBy=default.target"]),
848
+ "",
849
+ ].join("\n");
736
850
  try {
737
851
  mkdirSync(path.dirname(unitPath), { recursive: true });
852
+ writeFileSync(terminalUnitPath, terminalUnit, "utf8");
738
853
  writeFileSync(unitPath, unit, "utf8");
739
854
  }
740
855
  catch (err) {
@@ -742,6 +857,8 @@ function installSystemdService(ctx, scope) {
742
857
  }
743
858
  const base = systemctlBaseArgs(scope);
744
859
  const reload = spawnSync("systemctl", [...base, "daemon-reload"], { encoding: "utf8" });
860
+ const terminalAlreadyLive = hasLiveTerminalDaemon(ctx.configPath);
861
+ const enableTerminal = spawnSync("systemctl", [...base, "enable", ...(terminalAlreadyLive ? [] : ["--now"]), "wand-terminald.service"], { encoding: "utf8" });
745
862
  const enable = spawnSync("systemctl", [...base, "enable", "--now", "wand.service"], { encoding: "utf8" });
746
863
  const hints = scope === "user"
747
864
  ? "提示: 若需登出后保持运行,请运行 `loginctl enable-linger $USER`"
@@ -749,12 +866,14 @@ function installSystemdService(ctx, scope) {
749
866
  const detail = [
750
867
  `scope: ${scope}`,
751
868
  `unit: ${unitPath}`,
869
+ `terminal unit: ${terminalUnitPath}`,
752
870
  `daemon-reload: ${reload.status === 0 ? "ok" : `failed (${reload.stderr.trim()})`}`,
871
+ `terminal enable${terminalAlreadyLive ? " (kept existing daemon)" : " --now"}: ${enableTerminal.status === 0 ? "ok" : `failed (${enableTerminal.stderr.trim()})`}`,
753
872
  `enable --now: ${enable.status === 0 ? "ok" : `failed (${enable.stderr.trim()})`}`,
754
873
  "",
755
874
  hints,
756
875
  ].join("\n");
757
- if (enable.status !== 0) {
876
+ if (enableTerminal.status !== 0 || enable.status !== 0) {
758
877
  return {
759
878
  ok: false,
760
879
  message: `已写入 unit,但 systemctl ${scope === "user" ? "--user " : ""}启用失败`,
@@ -769,13 +888,17 @@ function installSystemdService(ctx, scope) {
769
888
  }
770
889
  function uninstallSystemdService(scope) {
771
890
  const unitPath = servicePathFor(scope);
891
+ const terminalUnitPath = terminalServicePathFor(scope);
772
892
  if (!existsSync(unitPath)) {
773
893
  return { ok: false, message: `未检测到已安装的 systemd ${scope} 服务` };
774
894
  }
775
895
  const base = systemctlBaseArgs(scope);
776
896
  const stop = spawnSync("systemctl", [...base, "disable", "--now", "wand.service"], { encoding: "utf8" });
897
+ const stopTerminal = spawnSync("systemctl", [...base, "disable", "--now", "wand-terminald.service"], { encoding: "utf8" });
777
898
  try {
778
899
  unlinkSync(unitPath);
900
+ if (existsSync(terminalUnitPath))
901
+ unlinkSync(terminalUnitPath);
779
902
  }
780
903
  catch (err) {
781
904
  return { ok: false, message: `删除 unit 失败: ${getErrorMessage(err)}` };
@@ -784,11 +907,15 @@ function uninstallSystemdService(scope) {
784
907
  return {
785
908
  ok: true,
786
909
  message: `已卸载 systemd ${scope === "user" ? "用户" : "系统"}服务`,
787
- detail: stop.status === 0 ? "disable --now: ok" : `disable --now: ${stop.stderr.trim()}`,
910
+ detail: [
911
+ stop.status === 0 ? "web disable --now: ok" : `web disable --now: ${stop.stderr.trim()}`,
912
+ stopTerminal.status === 0 ? "terminal disable --now: ok" : `terminal disable --now: ${stopTerminal.stderr.trim()}`,
913
+ ].join("\n"),
788
914
  };
789
915
  }
790
916
  function installLaunchdService(ctx, scope) {
791
917
  const plistPath = servicePathFor(scope);
918
+ const terminalPlistPath = terminalServicePathFor(scope);
792
919
  const wandBin = resolveWandBin(ctx);
793
920
  const nodeBin = process.execPath;
794
921
  const nodeBinDir = path.dirname(nodeBin);
@@ -824,14 +951,67 @@ ${userNameField} <key>WorkingDirectory</key><string>${runHome}</string>
824
951
  <key>KeepAlive</key><true/>
825
952
  </dict>
826
953
  </plist>
954
+ `;
955
+ const terminalPlist = `<?xml version="1.0" encoding="UTF-8"?>
956
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
957
+ <plist version="1.0">
958
+ <dict>
959
+ <key>Label</key><string>${LAUNCHD_TERMINAL_LABEL}</string>
960
+ ${userNameField} <key>WorkingDirectory</key><string>${runHome}</string>
961
+ <key>ProgramArguments</key>
962
+ <array>
963
+ <string>${nodeBin}</string>
964
+ <string>${wandBin}</string>
965
+ <string>terminald</string>
966
+ <string>-c</string>
967
+ <string>${ctx.configPath}</string>
968
+ </array>
969
+ <key>EnvironmentVariables</key>
970
+ <dict>
971
+ <key>PATH</key><string>${servicePath}</string>
972
+ <key>HOME</key><string>${runHome}</string>
973
+ </dict>
974
+ <key>RunAtLoad</key><true/>
975
+ <key>KeepAlive</key><true/>
976
+ </dict>
977
+ </plist>
827
978
  `;
828
979
  try {
829
980
  mkdirSync(path.dirname(plistPath), { recursive: true });
981
+ writeFileSync(terminalPlistPath, terminalPlist, "utf8");
830
982
  writeFileSync(plistPath, plist, "utf8");
831
983
  }
832
984
  catch (err) {
833
985
  return { ok: false, message: `写入 plist 失败: ${getErrorMessage(err)}` };
834
986
  }
987
+ const terminalEnable = spawnSync("launchctl", ["enable", launchdTarget(scope, LAUNCHD_TERMINAL_LABEL)], { encoding: "utf8", timeout: 10_000 });
988
+ if (terminalEnable.status !== 0) {
989
+ return {
990
+ ok: false,
991
+ message: "已写入 plist,但持久终端宿主 enable 失败",
992
+ detail: formatSpawnResult(terminalEnable),
993
+ };
994
+ }
995
+ let terminalDetail;
996
+ if (launchdTargetIsLoaded(scope, LAUNCHD_TERMINAL_LABEL)) {
997
+ terminalDetail = `terminal: kept running (${launchdTarget(scope, LAUNCHD_TERMINAL_LABEL)})`;
998
+ }
999
+ else if (hasLiveTerminalDaemon(ctx.configPath)) {
1000
+ // A daemon started by an older/manual web process already owns live PTYs.
1001
+ // Keep it untouched; the new plist will take ownership on the next boot.
1002
+ terminalDetail = "terminal: existing standalone daemon kept running; plist enabled for next boot";
1003
+ }
1004
+ else {
1005
+ const terminalStarted = launchdBootstrap(scope, terminalPlistPath, `已启动持久终端宿主: ${terminalPlistPath}`, LAUNCHD_TERMINAL_LABEL);
1006
+ if (!terminalStarted.ok) {
1007
+ return {
1008
+ ok: false,
1009
+ message: "已写入 plist,但持久终端宿主启动失败",
1010
+ detail: terminalStarted.detail,
1011
+ };
1012
+ }
1013
+ terminalDetail = terminalStarted.detail ?? terminalStarted.message;
1014
+ }
835
1015
  const started = launchdBootstrap(scope, plistPath, `已注册 launchd ${scope === "user" ? "用户代理" : "系统守护"}: ${plistPath}`);
836
1016
  if (!started.ok) {
837
1017
  return {
@@ -843,18 +1023,23 @@ ${userNameField} <key>WorkingDirectory</key><string>${runHome}</string>
843
1023
  return {
844
1024
  ok: true,
845
1025
  message: `已注册 launchd ${scope === "user" ? "用户代理" : "系统守护"}: ${plistPath}`,
846
- detail: started.detail,
1026
+ detail: [terminalDetail, started.detail].filter(Boolean).join("\n"),
847
1027
  };
848
1028
  }
849
1029
  function uninstallLaunchdService(scope) {
850
1030
  const plistPath = servicePathFor(scope);
1031
+ const terminalPlistPath = terminalServicePathFor(scope);
851
1032
  if (!existsSync(plistPath)) {
852
1033
  return { ok: false, message: `未检测到已安装的 launchd ${scope} 服务` };
853
1034
  }
854
1035
  const stopped = launchdBootout(scope, plistPath, `已停止 launchd ${scope}`);
1036
+ const stoppedTerminal = launchdBootout(scope, terminalPlistPath, `已停止持久终端宿主 ${scope}`, LAUNCHD_TERMINAL_LABEL);
855
1037
  const disabled = spawnSync("launchctl", ["disable", launchdTarget(scope)], { encoding: "utf8", timeout: 10_000 });
1038
+ const disabledTerminal = spawnSync("launchctl", ["disable", launchdTarget(scope, LAUNCHD_TERMINAL_LABEL)], { encoding: "utf8", timeout: 10_000 });
856
1039
  try {
857
1040
  unlinkSync(plistPath);
1041
+ if (existsSync(terminalPlistPath))
1042
+ unlinkSync(terminalPlistPath);
858
1043
  }
859
1044
  catch (err) {
860
1045
  return { ok: false, message: `删除 plist 失败: ${getErrorMessage(err)}` };
@@ -864,7 +1049,9 @@ function uninstallLaunchdService(scope) {
864
1049
  message: `已卸载 launchd ${scope === "user" ? "用户代理" : "系统守护"}`,
865
1050
  detail: [
866
1051
  stopped.detail ?? stopped.message,
1052
+ stoppedTerminal.detail ?? stoppedTerminal.message,
867
1053
  `disable: ${formatSpawnResult(disabled)}`,
1054
+ `terminal disable: ${formatSpawnResult(disabledTerminal)}`,
868
1055
  ].join("\n"),
869
1056
  };
870
1057
  }
package/dist/types.d.ts CHANGED
@@ -569,6 +569,10 @@ export interface SessionSnapshot {
569
569
  ptyCols?: number;
570
570
  /** 当前 PTY 行数,由最近一次 resize 决定。 */
571
571
  ptyRows?: number;
572
+ /** terminal host 已处理并原子持久化的最后一个输出块序号;不进入客户端传输。 */
573
+ ptyOutputSeq?: number;
574
+ /** Internal shell-wrapper marker needed to keep parsing a daemon-owned PTY after reattach. */
575
+ ptyLaunchMarkerToken?: string | null;
572
576
  }
573
577
  /** Unified event type emitted by ClaudePtyBridge for WebSocket broadcast */
574
578
  export type SessionEventType = "output.raw" | "output.chat" | "chat.turn" | "permission.prompt" | "permission.resolved" | "session.id" | "task" | "ended";