@co0ontty/wand 4.36.0 → 4.37.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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "dfc9840aeb82784c80bb15e43c8d880dce64437f",
3
- "builtAt": "2026-08-07T05:32:46.753Z",
4
- "version": "4.36.0",
2
+ "commit": "d3936dc2066c7077fe5dca5947e4773f6672b5dc",
3
+ "builtAt": "2026-08-08T00:17:10.949Z",
4
+ "version": "4.37.0",
5
5
  "channel": "stable"
6
6
  }
@@ -271,7 +271,7 @@ const REQUIRED_RUNTIME_FILES = [
271
271
  path.join("dist", "web-ui", "styles.js"),
272
272
  path.join("dist", "web-ui", "content", "scripts.js"),
273
273
  path.join("dist", "web-ui", "content", "styles.css"),
274
- path.join("dist", "web-ui", "content", "vendor", "wterm", "wterm.bundle.js"),
274
+ path.join("dist", "web-ui", "content", "vendor", "xterm", "xterm.bundle.js"),
275
275
  path.join("dist", "web-ui", "content", "vendor", "qrcode", "qrcode.bundle.js"),
276
276
  ];
277
277
  function getGlobalPackageDir() {
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
2
2
  import { WandStorage } from "./storage.js";
3
3
  import { ExecutionMode, ProcessEventHandler, SessionProvider, SessionSnapshot, SessionSource, WandConfig } from "./types.js";
4
4
  import { type PermissionResolution } from "./claude-pty-bridge.js";
5
+ import { type PtyTerminalSnapshot } from "./pty-terminal-state.js";
5
6
  import { type ClaudeHistorySession, type CodexHistorySession, type OpenCodeHistorySession, type QoderHistorySession } from "./provider-history-scanner.js";
6
7
  export type { ClaudeHistorySession, CodexHistorySession, OpenCodeHistorySession, QoderHistorySession, } from "./provider-history-scanner.js";
7
8
  /** Exported for focused policy tests. */
@@ -95,8 +96,16 @@ export declare class ProcessManager extends EventEmitter {
95
96
  * Mirrors setSessionModel/setSessionThinkingEffort.
96
97
  */
97
98
  setSessionMode(id: string, mode: ExecutionMode): SessionSnapshot;
98
- sendInput(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string): SessionSnapshot;
99
+ sendInput(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string, trackUserInput?: boolean): SessionSnapshot;
99
100
  resize(id: string, cols: number, rows: number): SessionSnapshot;
101
+ getTerminalState(id: string): PtyTerminalSnapshot | null;
102
+ pauseOutput(id: string): void;
103
+ resumeOutput(id: string): void;
104
+ /**
105
+ * Finalize provider-only state while leaving the owning PTY shell alive. The
106
+ * session itself remains running so terminal input can continue normally.
107
+ */
108
+ private finishProviderCli;
100
109
  stop(id: string): SessionSnapshot;
101
110
  private cleanupRecord;
102
111
  delete(id: string): void;
@@ -160,7 +169,6 @@ export declare class ProcessManager extends EventEmitter {
160
169
  sessionSource?: SessionSource;
161
170
  automationId?: string;
162
171
  }): SessionSnapshot;
163
- private buildShellArgs;
164
172
  private shouldAutoApprovePermissions;
165
173
  private processCommandForMode;
166
174
  }
@@ -20,6 +20,8 @@ import { SessionTopicCoordinator } from "./session-topic.js";
20
20
  import { getErrorMessage } from "./error-utils.js";
21
21
  import { resolveSystemAiContext } from "./session-ai-context.js";
22
22
  import { resolveSessionCwd } from "./session-cwd.js";
23
+ import { PtyTerminalState } from "./pty-terminal-state.js";
24
+ import { buildPtyShellLaunchPlan } from "./pty-shell-launch.js";
23
25
  import { ProviderHistoryScanner, } from "./provider-history-scanner.js";
24
26
  function resolveProviderFromCommand(command) {
25
27
  if (/^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command.trim()))
@@ -600,6 +602,10 @@ export class ProcessManager extends EventEmitter {
600
602
  provider,
601
603
  processId: null,
602
604
  ptyProcess: null,
605
+ terminalState: null,
606
+ providerShellMarker: null,
607
+ providerCliActive: false,
608
+ providerCliExitCode: snapshot.providerCliExitCode ?? null,
603
609
  stopRequested: false,
604
610
  confirmWindow: "",
605
611
  ptyPermissionBlocked: false,
@@ -648,6 +654,10 @@ export class ProcessManager extends EventEmitter {
648
654
  provider,
649
655
  processId: null,
650
656
  ptyProcess: null,
657
+ terminalState: null,
658
+ providerShellMarker: null,
659
+ providerCliActive: false,
660
+ providerCliExitCode: snapshot.providerCliExitCode ?? null,
651
661
  stopRequested: false,
652
662
  confirmWindow: "",
653
663
  ptyPermissionBlocked: false,
@@ -853,6 +863,12 @@ export class ProcessManager extends EventEmitter {
853
863
  : null;
854
864
  const initialClaudeSessionId = existingProviderSessionId ?? assignedProviderSessionId;
855
865
  const startedAt = new Date().toISOString();
866
+ const launchPlan = buildPtyShellLaunchPlan({
867
+ shell: this.config.shell,
868
+ command: processedCommand,
869
+ bareShell: opts?.interactiveShell === true,
870
+ providerCommand: provider !== undefined,
871
+ });
856
872
  const record = {
857
873
  id,
858
874
  sessionSource: opts?.sessionSource ?? inheritedSessionSource ?? "interactive",
@@ -879,6 +895,10 @@ export class ProcessManager extends EventEmitter {
879
895
  claudeSessionId: initialClaudeSessionId,
880
896
  processId: null,
881
897
  ptyProcess: null,
898
+ terminalState: new PtyTerminalState(opts?.cols !== undefined ? clampDimension(opts.cols, 20, 1000) : 120, opts?.rows !== undefined ? clampDimension(opts.rows, 5, 500) : 36),
899
+ providerShellMarker: launchPlan.cliExitMarker,
900
+ providerCliActive: provider !== undefined,
901
+ providerCliExitCode: null,
882
902
  stopRequested: false,
883
903
  confirmWindow: "",
884
904
  ptyPermissionBlocked: false,
@@ -902,11 +922,8 @@ export class ProcessManager extends EventEmitter {
902
922
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
903
923
  selectedModel: selectedModel ?? null,
904
924
  thinkingEffort: initialThinkingEffort,
905
- // cols 上限 256:与 @wterm/dom WASM grid maxCols 硬编码一致,
906
- // 防止服务端按 >256 cols Claude CSI 绝对列定位写到 wterm 实际
907
- // 渲染不到的列上(表现为"内容神奇复制下行")。
908
- ptyCols: opts?.cols !== undefined ? clampDimension(opts.cols, 20, 256) : 120,
909
- ptyRows: opts?.rows !== undefined ? clampDimension(opts.rows, 10, 160) : 36,
925
+ ptyCols: opts?.cols !== undefined ? clampDimension(opts.cols, 20, 1000) : 120,
926
+ ptyRows: opts?.rows !== undefined ? clampDimension(opts.rows, 5, 500) : 36,
910
927
  };
911
928
  if (isClaudeProvider) {
912
929
  record.ptyBridge = new ClaudePtyBridge({
@@ -927,21 +944,20 @@ export class ProcessManager extends EventEmitter {
927
944
  this.providerHistory.invalidate(provider);
928
945
  }
929
946
  this.cleanupOldSessions();
930
- const shellArgs = this.buildShellArgs(processedCommand, opts?.interactiveShell === true);
931
947
  // Self-heal node-pty's spawn-helper +x bit before every spawn: a self-update
932
948
  // can re-drop it after this server already ran its startup chmod, which would
933
949
  // otherwise make every PTY launch throw "posix_spawnp failed." until restart.
934
950
  ensureNodePtyHelperExecutable();
935
951
  let child;
936
952
  try {
937
- child = pty.spawn(this.config.shell, shellArgs, {
953
+ child = pty.spawn(this.config.shell, launchPlan.shellArgs, {
938
954
  cwd: resolvedCwd,
939
955
  env: buildChildEnv(this.config.inheritEnv !== false, {
940
956
  WAND_MODE: effectiveMode,
941
957
  WAND_AUTO_CONFIRM: record.autoApprovePermissions ? "1" : "0",
942
958
  WAND_AUTO_EDIT: effectiveMode === "auto-edit" ? "1" : "0"
943
959
  }),
944
- name: "xterm-color",
960
+ name: "xterm-256color",
945
961
  // 使用 record 上由前端协商好的真实尺寸,避免"先 120 列、几百毫秒后再 resize"
946
962
  // 期间 provider TUI 用错列宽渲染出 \x1b[120G 这类绝对列定位序列。
947
963
  cols: record.ptyCols,
@@ -987,6 +1003,11 @@ export class ProcessManager extends EventEmitter {
987
1003
  current.ptyBridge.onExit(exitCode);
988
1004
  current.ptyBridge.removeAllListeners();
989
1005
  }
1006
+ current.providerShellMarker = null;
1007
+ if (current.providerCliActive) {
1008
+ current.providerCliActive = false;
1009
+ current.providerCliExitCode = exitCode;
1010
+ }
990
1011
  current.pendingEscalation = null;
991
1012
  current.ptyPermissionBlocked = false;
992
1013
  this.captureClaudeSessionId(current, { allowTimeWindowFallback: true });
@@ -1026,22 +1047,27 @@ export class ProcessManager extends EventEmitter {
1026
1047
  child.write(initialInput);
1027
1048
  child.write("\r");
1028
1049
  };
1029
- child.onData((chunk) => {
1050
+ child.onData((rawChunk) => {
1030
1051
  const rec = this.sessions.get(id);
1031
1052
  // PTYs may still drain data after kill(). A replacement session can use
1032
1053
  // the same id, so both record identity and the concrete PTY handle must
1033
1054
  // match before accepting the chunk.
1034
1055
  if (rec !== record || rec.ptyProcess !== child)
1035
1056
  return;
1036
- if (rec.ptyBridge) {
1057
+ const boundary = rec.providerShellMarker?.consume(rawChunk) ?? { data: rawChunk, exitCode: null };
1058
+ const chunk = boundary.data;
1059
+ if (chunk)
1060
+ rec.terminalState?.write(chunk);
1061
+ if (chunk && rec.ptyBridge) {
1037
1062
  rec.ptyBridge.processChunk(chunk);
1038
1063
  rec.output = rec.ptyBridge.getRawOutput();
1039
1064
  }
1040
- else {
1065
+ else if (chunk) {
1041
1066
  rec.output = appendWindow(rec.output, chunk, PTY_OUTPUT_MAX_SIZE);
1042
1067
  }
1043
- this.logger.appendPtyOutput(id, chunk);
1044
- if (!rec.ptyBridge) {
1068
+ if (chunk)
1069
+ this.logger.appendPtyOutput(id, chunk);
1070
+ if (chunk && !rec.ptyBridge) {
1045
1071
  this.emitEvent({
1046
1072
  type: "output",
1047
1073
  sessionId: id,
@@ -1059,7 +1085,7 @@ export class ProcessManager extends EventEmitter {
1059
1085
  this.providerHistory.invalidate("claude");
1060
1086
  process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
1061
1087
  }
1062
- if (!rec.claudeSessionId && rec.knownClaudeTaskIds) {
1088
+ if (rec.providerCliActive && boundary.exitCode === null && !rec.claudeSessionId && rec.knownClaudeTaskIds) {
1063
1089
  rec.messages = snapshotMessages(rec);
1064
1090
  const discoveredTaskId = getLatestClaudeProjectSessionId({
1065
1091
  cwd: rec.cwd,
@@ -1074,14 +1100,21 @@ export class ProcessManager extends EventEmitter {
1074
1100
  process.stderr.write(`[wand] Captured Claude project session ID: ${discoveredTaskId}\n`);
1075
1101
  }
1076
1102
  }
1077
- if (rec.autoApprovePermissions && !rec.ptyBridge && rec.provider === "claude") {
1103
+ if (chunk && rec.providerCliActive && rec.autoApprovePermissions && !rec.ptyBridge && rec.provider === "claude") {
1078
1104
  this.autoConfirmWithRecord(rec, chunk, child);
1079
1105
  }
1080
- if (initialInput && !initialInputSent && (chunk.includes("❯") || chunk.includes("›"))) {
1106
+ if (chunk && boundary.exitCode === null && initialInput && !initialInputSent && (chunk.includes("❯") || chunk.includes("›"))) {
1081
1107
  sendInitialInput();
1082
1108
  }
1109
+ if (boundary.exitCode !== null) {
1110
+ this.finishProviderCli(record, boundary.exitCode);
1111
+ }
1083
1112
  this.schedulePersist(rec);
1084
1113
  });
1114
+ if (launchPlan.commandToWrite) {
1115
+ child.write(launchPlan.commandToWrite);
1116
+ child.write("\r");
1117
+ }
1085
1118
  if (initialInput) {
1086
1119
  record.initialInputTimer = setTimeout(() => {
1087
1120
  record.initialInputTimer = null;
@@ -1339,7 +1372,7 @@ export class ProcessManager extends EventEmitter {
1339
1372
  const record = this.mustGet(id);
1340
1373
  const normalized = model?.trim() || null;
1341
1374
  record.selectedModel = normalized;
1342
- if (record.provider === "claude" && record.status === "running" && record.ptyProcess) {
1375
+ if (record.providerCliActive && record.provider === "claude" && record.status === "running" && record.ptyProcess) {
1343
1376
  const value = normalized && normalized !== "default" ? normalized : "default";
1344
1377
  record.ptyProcess.write(`/model ${value}\r`);
1345
1378
  }
@@ -1355,7 +1388,7 @@ export class ProcessManager extends EventEmitter {
1355
1388
  const record = this.mustGet(id);
1356
1389
  const normalized = normalizeThinkingEffort(effort);
1357
1390
  record.thinkingEffort = normalized;
1358
- if (record.provider === "claude" && record.status === "running" && record.ptyProcess) {
1391
+ if (record.providerCliActive && record.provider === "claude" && record.status === "running" && record.ptyProcess) {
1359
1392
  record.ptyProcess.write(`/effort ${thinkingEffortToClaudeSlashEffort(normalized)}\r`);
1360
1393
  }
1361
1394
  this.persist(record);
@@ -1384,7 +1417,7 @@ export class ProcessManager extends EventEmitter {
1384
1417
  });
1385
1418
  return this.snapshot(record);
1386
1419
  }
1387
- sendInput(id, input, view, shortcutKey) {
1420
+ sendInput(id, input, view, shortcutKey, trackUserInput = true) {
1388
1421
  if (this.disposed)
1389
1422
  throw new Error("ProcessManager has been disposed.");
1390
1423
  const record = this.mustGet(id);
@@ -1412,7 +1445,7 @@ export class ProcessManager extends EventEmitter {
1412
1445
  this.logger.appendShortcutLog(id, shortcutKey, tailLines, ctx);
1413
1446
  }
1414
1447
  // Track user input via bridge for Chat mode
1415
- if (record.ptyBridge) {
1448
+ if (record.ptyBridge && trackUserInput) {
1416
1449
  record.ptyBridge.onUserInput(input);
1417
1450
  }
1418
1451
  record.ptyProcess.write(input);
@@ -1424,24 +1457,95 @@ export class ProcessManager extends EventEmitter {
1424
1457
  if (!record.ptyProcess || record.status !== "running") {
1425
1458
  return this.snapshot(record);
1426
1459
  }
1427
- const safeCols = clampDimension(cols, 20, 256);
1428
- const safeRows = clampDimension(rows, 10, 160);
1460
+ const safeCols = clampDimension(cols, 20, 1000);
1461
+ const safeRows = clampDimension(rows, 5, 500);
1429
1462
  const changed = safeCols !== record.ptyCols || safeRows !== record.ptyRows;
1463
+ if (!changed)
1464
+ return this.snapshot(record);
1430
1465
  record.ptyProcess.resize(safeCols, safeRows);
1466
+ record.terminalState?.resize(safeCols, safeRows);
1431
1467
  record.ptyCols = safeCols;
1432
1468
  record.ptyRows = safeRows;
1433
- if (changed) {
1434
- // Notify every subscribed client of the new authoritative dimensions so
1435
- // any other tab/device can re-fit its terminal instead of rendering
1436
- // wrap-broken output sized for someone else's viewport.
1437
- this.emitEvent({
1438
- type: "status",
1439
- sessionId: id,
1440
- data: { ptyCols: safeCols, ptyRows: safeRows },
1441
- });
1442
- }
1469
+ // Notify every subscribed client of the new authoritative dimensions so
1470
+ // any other tab/device can re-fit its terminal instead of rendering
1471
+ // wrap-broken output sized for someone else's viewport.
1472
+ this.emitEvent({
1473
+ type: "status",
1474
+ sessionId: id,
1475
+ data: { ptyCols: safeCols, ptyRows: safeRows },
1476
+ });
1443
1477
  return this.snapshot(record);
1444
1478
  }
1479
+ getTerminalState(id) {
1480
+ const record = this.sessions.get(id);
1481
+ if (!record)
1482
+ return null;
1483
+ if (!record.terminalState) {
1484
+ record.terminalState = new PtyTerminalState(record.ptyCols, record.ptyRows, record.output || record.storedOutput);
1485
+ }
1486
+ return record.terminalState.snapshot();
1487
+ }
1488
+ pauseOutput(id) {
1489
+ const record = this.sessions.get(id);
1490
+ if (record?.ptyProcess && record.status === "running")
1491
+ record.ptyProcess.pause();
1492
+ }
1493
+ resumeOutput(id) {
1494
+ const record = this.sessions.get(id);
1495
+ if (record?.ptyProcess && record.status === "running")
1496
+ record.ptyProcess.resume();
1497
+ }
1498
+ /**
1499
+ * Finalize provider-only state while leaving the owning PTY shell alive. The
1500
+ * session itself remains running so terminal input can continue normally.
1501
+ */
1502
+ finishProviderCli(record, exitCode) {
1503
+ if (!record.providerCliActive)
1504
+ return;
1505
+ record.providerCliActive = false;
1506
+ record.providerCliExitCode = exitCode;
1507
+ record.providerShellMarker = null;
1508
+ if (record.claudeTaskDiscoveryTimer) {
1509
+ clearTimeout(record.claudeTaskDiscoveryTimer);
1510
+ record.claudeTaskDiscoveryTimer = null;
1511
+ }
1512
+ if (record.codexSessionDiscoveryTimer) {
1513
+ clearTimeout(record.codexSessionDiscoveryTimer);
1514
+ record.codexSessionDiscoveryTimer = null;
1515
+ }
1516
+ if (record.openCodeSessionDiscoveryTimer) {
1517
+ clearTimeout(record.openCodeSessionDiscoveryTimer);
1518
+ record.openCodeSessionDiscoveryTimer = null;
1519
+ }
1520
+ if (record.initialInputTimer) {
1521
+ clearTimeout(record.initialInputTimer);
1522
+ record.initialInputTimer = null;
1523
+ }
1524
+ if (record.ptyBridge) {
1525
+ record.ptyBridge.onExit(exitCode);
1526
+ record.messages = record.ptyBridge.getMessages();
1527
+ }
1528
+ record.pendingEscalation = null;
1529
+ record.ptyPermissionBlocked = false;
1530
+ this.captureClaudeSessionId(record, { allowTimeWindowFallback: true });
1531
+ this.captureCodexSessionId(record, { allowTimeWindowFallback: true });
1532
+ this.captureOpenCodeSessionId(record, { allowTimeWindowFallback: true });
1533
+ if (record.ptyBridge) {
1534
+ record.output = record.ptyBridge.getRawOutput();
1535
+ record.ptyBridge.removeAllListeners();
1536
+ record.ptyBridge = null;
1537
+ }
1538
+ this.flushPersist(record);
1539
+ this.emitEvent({
1540
+ type: "status",
1541
+ sessionId: record.id,
1542
+ data: {
1543
+ providerCliActive: false,
1544
+ providerCliExitCode: exitCode,
1545
+ permissionBlocked: false,
1546
+ },
1547
+ });
1548
+ }
1445
1549
  stop(id) {
1446
1550
  const record = this.mustGet(id);
1447
1551
  if (record.status !== "running") {
@@ -1464,6 +1568,8 @@ export class ProcessManager extends EventEmitter {
1464
1568
  record.initialInputTimer = null;
1465
1569
  }
1466
1570
  record.stopRequested = true;
1571
+ record.providerCliActive = false;
1572
+ record.providerShellMarker = null;
1467
1573
  // Kill any running child process (from JSON chat turns)
1468
1574
  if (record.childProcess) {
1469
1575
  record.childProcess.kill();
@@ -1533,6 +1639,8 @@ export class ProcessManager extends EventEmitter {
1533
1639
  record.ptyBridge.removeAllListeners();
1534
1640
  record.ptyBridge = null;
1535
1641
  }
1642
+ record.terminalState?.dispose();
1643
+ record.terminalState = null;
1536
1644
  }
1537
1645
  delete(id) {
1538
1646
  const record = this.mustGet(id);
@@ -1582,6 +1690,8 @@ export class ProcessManager extends EventEmitter {
1582
1690
  record.ptyBridge.removeAllListeners();
1583
1691
  record.ptyBridge = null;
1584
1692
  }
1693
+ record.terminalState?.dispose();
1694
+ record.terminalState = null;
1585
1695
  // Delete from persistent storage BEFORE removing from in-memory map,
1586
1696
  // so a storage failure doesn't leave orphan records in the database.
1587
1697
  this.storage.deleteSession(id);
@@ -1639,6 +1749,8 @@ export class ProcessManager extends EventEmitter {
1639
1749
  sessionSource: record.sessionSource ?? "interactive",
1640
1750
  automationId: record.automationId,
1641
1751
  provider: record.provider,
1752
+ providerCliActive: record.providerCliActive,
1753
+ providerCliExitCode: record.providerCliExitCode,
1642
1754
  runner: "pty",
1643
1755
  command: record.command,
1644
1756
  cwd: record.cwd,
@@ -2134,21 +2246,6 @@ export class ProcessManager extends EventEmitter {
2134
2246
  interactiveShell: true,
2135
2247
  });
2136
2248
  }
2137
- buildShellArgs(command, interactiveShell = false) {
2138
- if (interactiveShell) {
2139
- return os.platform() === "win32" ? [] : ["-l"];
2140
- }
2141
- if (os.platform() === "win32") {
2142
- return ["/d", "/s", "/c", command];
2143
- }
2144
- // -l: login shell — sources ~/.bash_profile, ~/.profile, etc., ensuring PATH
2145
- // and other env vars set by profile files are available.
2146
- // -c: run the following command.
2147
- // Using -ic (interactive + command) skips login-shell initialization on many
2148
- // platforms, which causes commands that depend on profile-set env vars to fail
2149
- // immediately with "command not found" — a silent exit before onExit is ready.
2150
- return ["-lc", command];
2151
- }
2152
2249
  shouldAutoApprovePermissions(command, mode, provider) {
2153
2250
  if (provider !== "claude") {
2154
2251
  return false;
@@ -0,0 +1,36 @@
1
+ export interface PtyShellLaunchPlan {
2
+ shellArgs: string[];
3
+ /** Fallback for shells whose command language Wand cannot safely wrap. */
4
+ commandToWrite?: string;
5
+ cliExitMarker: PtyCliExitMarker | null;
6
+ }
7
+ export interface PtyCliExitChunk {
8
+ data: string;
9
+ exitCode: number | null;
10
+ }
11
+ /**
12
+ * Removes Wand's private CLI/shell boundary marker even when a PTY splits it
13
+ * across chunks. The marker never reaches terminal rendering, transcripts, or
14
+ * Claude's PTY parser.
15
+ */
16
+ export declare class PtyCliExitMarker {
17
+ readonly token: string;
18
+ private readonly prefix;
19
+ private pending;
20
+ private completed;
21
+ constructor(token?: string);
22
+ consume(chunk: string): PtyCliExitChunk;
23
+ }
24
+ /**
25
+ * Build the PTY process shape without conflating three different lifecycles:
26
+ * a bare interactive shell, a provider CLI that falls back to a shell, and a
27
+ * one-shot non-provider command.
28
+ */
29
+ export declare function buildPtyShellLaunchPlan(options: {
30
+ shell: string;
31
+ command: string;
32
+ bareShell: boolean;
33
+ providerCommand: boolean;
34
+ platform?: NodeJS.Platform;
35
+ markerToken?: string;
36
+ }): PtyShellLaunchPlan;
@@ -0,0 +1,116 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const CLI_EXIT_MARKER_START = "\x1eWAND_CLI_EXIT:";
5
+ const CLI_EXIT_MARKER_END = "\x1f";
6
+ const POSIX_SHELLS = new Set(["ash", "bash", "dash", "ksh", "ksh93", "mksh", "sh", "zsh"]);
7
+ function quotePosixShell(value) {
8
+ return `'${value.replace(/'/g, `'\\''`)}'`;
9
+ }
10
+ function isKnownPosixShell(shell) {
11
+ return POSIX_SHELLS.has(path.basename(shell).toLowerCase());
12
+ }
13
+ function longestMarkerPrefixSuffix(value, markerPrefix) {
14
+ const maxLength = Math.min(value.length, markerPrefix.length - 1);
15
+ for (let length = maxLength; length > 0; length -= 1) {
16
+ if (value.endsWith(markerPrefix.slice(0, length)))
17
+ return length;
18
+ }
19
+ return 0;
20
+ }
21
+ /**
22
+ * Removes Wand's private CLI/shell boundary marker even when a PTY splits it
23
+ * across chunks. The marker never reaches terminal rendering, transcripts, or
24
+ * Claude's PTY parser.
25
+ */
26
+ export class PtyCliExitMarker {
27
+ token;
28
+ prefix;
29
+ pending = "";
30
+ completed = false;
31
+ constructor(token = randomUUID()) {
32
+ this.token = token;
33
+ this.prefix = `${CLI_EXIT_MARKER_START}${token}:`;
34
+ }
35
+ consume(chunk) {
36
+ if (this.completed)
37
+ return { data: chunk, exitCode: null };
38
+ const combined = this.pending + chunk;
39
+ this.pending = "";
40
+ const markerStart = combined.indexOf(this.prefix);
41
+ if (markerStart >= 0) {
42
+ const markerEnd = combined.indexOf(CLI_EXIT_MARKER_END, markerStart + this.prefix.length);
43
+ if (markerEnd < 0) {
44
+ this.pending = combined.slice(markerStart);
45
+ return { data: combined.slice(0, markerStart), exitCode: null };
46
+ }
47
+ const statusText = combined.slice(markerStart + this.prefix.length, markerEnd);
48
+ if (/^\d{1,3}$/.test(statusText)) {
49
+ this.completed = true;
50
+ return {
51
+ data: combined.slice(0, markerStart) + combined.slice(markerEnd + CLI_EXIT_MARKER_END.length),
52
+ exitCode: Number(statusText),
53
+ };
54
+ }
55
+ }
56
+ const partialLength = longestMarkerPrefixSuffix(combined, this.prefix);
57
+ if (partialLength > 0) {
58
+ this.pending = combined.slice(-partialLength);
59
+ return { data: combined.slice(0, -partialLength), exitCode: null };
60
+ }
61
+ return { data: combined, exitCode: null };
62
+ }
63
+ }
64
+ function buildPosixProviderShellCommand(command, shell, marker) {
65
+ // The non-default SIGINT trap keeps the launcher shell alive when Ctrl+C
66
+ // terminates its foreground CLI. External commands reset caught traps to the
67
+ // default disposition, so the provider still receives SIGINT normally.
68
+ // Running the CLI as an `if` condition also prevents a user-set `errexit`
69
+ // option from skipping the marker and fallback shell after a non-zero exit.
70
+ return [
71
+ "trap ':' INT",
72
+ `if ${command}; then __wand_cli_status=0; else __wand_cli_status=$?; fi`,
73
+ `printf '\\036WAND_CLI_EXIT:${marker.token}:%s\\037' "$__wand_cli_status"`,
74
+ `exec ${quotePosixShell(shell)} -l`,
75
+ ].join("; ");
76
+ }
77
+ /**
78
+ * Build the PTY process shape without conflating three different lifecycles:
79
+ * a bare interactive shell, a provider CLI that falls back to a shell, and a
80
+ * one-shot non-provider command.
81
+ */
82
+ export function buildPtyShellLaunchPlan(options) {
83
+ const platform = options.platform ?? os.platform();
84
+ if (options.bareShell) {
85
+ return {
86
+ shellArgs: platform === "win32" ? [] : ["-l"],
87
+ cliExitMarker: null,
88
+ };
89
+ }
90
+ if (!options.providerCommand) {
91
+ return {
92
+ shellArgs: platform === "win32"
93
+ ? ["/d", "/s", "/c", options.command]
94
+ : ["-lc", options.command],
95
+ cliExitMarker: null,
96
+ };
97
+ }
98
+ if (platform !== "win32" && isKnownPosixShell(options.shell)) {
99
+ const marker = new PtyCliExitMarker(options.markerToken);
100
+ return {
101
+ // Interactive + login initialization matches a real terminal before the
102
+ // provider starts; `exec <shell> -l` becomes the persistent prompt after it exits.
103
+ shellArgs: ["-lic", buildPosixProviderShellCommand(options.command, options.shell, marker)],
104
+ cliExitMarker: marker,
105
+ };
106
+ }
107
+ // cmd.exe/PowerShell and uncommon Unix shells have incompatible scripting
108
+ // syntaxes. Starting them interactively and typing the command still provides
109
+ // the requested Ctrl+C-to-shell behavior; only the precise CLI-exit marker is
110
+ // unavailable on this compatibility path.
111
+ return {
112
+ shellArgs: platform === "win32" ? [] : ["-l"],
113
+ commandToWrite: options.command,
114
+ cliExitMarker: null,
115
+ };
116
+ }
@@ -0,0 +1,39 @@
1
+ export type PtyTerminalOperation = {
2
+ type: "data";
3
+ data: string;
4
+ } | {
5
+ type: "resize";
6
+ cols: number;
7
+ rows: number;
8
+ };
9
+ export interface PtyTerminalSnapshot {
10
+ version: 1;
11
+ data: string;
12
+ cols: number;
13
+ rows: number;
14
+ pending: PtyTerminalOperation[];
15
+ }
16
+ /**
17
+ * Server-side xterm screen model used only for reconnect snapshots. Live PTY
18
+ * bytes still travel unchanged from node-pty to the browser terminal.
19
+ */
20
+ export declare class PtyTerminalState {
21
+ private readonly terminal;
22
+ private readonly serializer;
23
+ private readonly unicode;
24
+ private pending;
25
+ private tail;
26
+ private checkpointTimer?;
27
+ private nextId;
28
+ private committedData;
29
+ private committedCols;
30
+ private committedRows;
31
+ private disposed;
32
+ constructor(cols: number, rows: number, initialData?: string);
33
+ write(data: string): void;
34
+ resize(cols: number, rows: number): void;
35
+ snapshot(): PtyTerminalSnapshot;
36
+ dispose(): void;
37
+ private addPending;
38
+ private scheduleCheckpoint;
39
+ }