@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.
- package/dist/build-info.json +3 -3
- package/dist/npm-update-utils.js +1 -1
- package/dist/process-manager.d.ts +10 -2
- package/dist/process-manager.js +144 -47
- package/dist/pty-shell-launch.d.ts +36 -0
- package/dist/pty-shell-launch.js +116 -0
- package/dist/pty-terminal-state.d.ts +39 -0
- package/dist/pty-terminal-state.js +93 -0
- package/dist/server.js +14 -3
- package/dist/session-transport.js +2 -0
- package/dist/types.d.ts +4 -0
- package/dist/web-ui/content/scripts.js +85 -64
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/vendor/xterm/xterm.bundle.js +80 -0
- package/dist/web-ui/content/vendor/xterm/xterm.css +285 -0
- package/dist/web-ui/embedded-assets.d.ts +5 -5
- package/dist/web-ui/embedded-assets.js +9 -9
- package/dist/web-ui/index.js +4 -4
- package/dist/web-ui/provider-identity.d.ts +2 -1
- package/dist/web-ui/provider-identity.js +5 -2
- package/dist/ws-broadcast.d.ts +15 -1
- package/dist/ws-broadcast.js +109 -13
- package/package.json +9 -5
- package/dist/web-ui/content/vendor/wterm/terminal.css +0 -162
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +0 -1
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-08-
|
|
4
|
-
"version": "4.
|
|
2
|
+
"commit": "d3936dc2066c7077fe5dca5947e4773f6672b5dc",
|
|
3
|
+
"builtAt": "2026-08-08T00:17:10.949Z",
|
|
4
|
+
"version": "4.37.0",
|
|
5
5
|
"channel": "stable"
|
|
6
6
|
}
|
package/dist/npm-update-utils.js
CHANGED
|
@@ -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", "
|
|
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
|
}
|
package/dist/process-manager.js
CHANGED
|
@@ -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
|
-
|
|
906
|
-
|
|
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-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
1044
|
-
|
|
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,
|
|
1428
|
-
const safeRows = clampDimension(rows,
|
|
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
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
+
}
|