@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.
@@ -5,13 +5,11 @@ import path from "node:path";
5
5
  import process from "node:process";
6
6
  import os from "node:os";
7
7
  import { DatabaseSync } from "node:sqlite";
8
- import pty from "node-pty";
9
8
  import { SessionLogger } from "./session-logger.js";
10
9
  import { ClaudePtyBridge } from "./claude-pty-bridge.js";
11
10
  import { truncateMessagesForTransport } from "./message-truncator.js";
12
11
  import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
13
12
  import { buildChildEnv, isRunningAsRoot } from "./env-utils.js";
14
- import { ensureNodePtyHelperExecutable } from "./ensure-node-pty-helper.js";
15
13
  import { buildLanguageDirective, buildManagedAutonomyDirective } from "./language-prompt.js";
16
14
  import { prepareSessionWorktree } from "./git-worktree.js";
17
15
  import { getProviderCommandSessionId, getProviderResumeCommandSessionId } from "./resume-policy.js";
@@ -21,7 +19,8 @@ import { getErrorMessage } from "./error-utils.js";
21
19
  import { resolveSystemAiContext } from "./session-ai-context.js";
22
20
  import { resolveSessionCwd } from "./session-cwd.js";
23
21
  import { PtyTerminalState } from "./pty-terminal-state.js";
24
- import { buildPtyShellLaunchPlan } from "./pty-shell-launch.js";
22
+ import { buildPtyShellLaunchPlan, PtyCliExitMarker } from "./pty-shell-launch.js";
23
+ import { InProcessTerminalHost, } from "./terminal-host.js";
25
24
  import { ProviderHistoryScanner, } from "./provider-history-scanner.js";
26
25
  function resolveProviderFromCommand(command) {
27
26
  if (/^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command.trim()))
@@ -464,6 +463,17 @@ function recoverClaudeSessionIdFromHistory(snapshot) {
464
463
  function snapshotMessages(record) {
465
464
  return record.ptyBridge?.getMessages() ?? record.messages;
466
465
  }
466
+ function restoreTerminalState(state) {
467
+ const snapshot = state.terminalSnapshot;
468
+ const terminal = new PtyTerminalState(snapshot?.cols ?? state.cols, snapshot?.rows ?? state.rows, snapshot?.data ?? state.output);
469
+ for (const operation of snapshot?.pending ?? []) {
470
+ if (operation.type === "data")
471
+ terminal.write(operation.data);
472
+ else
473
+ terminal.resize(operation.cols, operation.rows);
474
+ }
475
+ return terminal;
476
+ }
467
477
  const MAX_SESSIONS = 200;
468
478
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
469
479
  const CONFIRM_WINDOW_SIZE = 800;
@@ -535,10 +545,12 @@ export class ProcessManager extends EventEmitter {
535
545
  orphanRecoveredCount = 0;
536
546
  topicCoordinator = new SessionTopicCoordinator();
537
547
  disposed = false;
538
- constructor(config, storage, configDir) {
548
+ terminalHost;
549
+ constructor(config, storage, configDir, terminalHost) {
539
550
  super();
540
551
  this.config = config;
541
552
  this.storage = storage;
553
+ this.terminalHost = terminalHost ?? new InProcessTerminalHost();
542
554
  this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"), config.shortcutLogMaxBytes);
543
555
  let startupCodexHistory = null;
544
556
  const getStartupCodexHistory = () => {
@@ -578,116 +590,64 @@ export class ProcessManager extends EventEmitter {
578
590
  })?.id ?? null
579
591
  : null;
580
592
  const restoredSessionId = resumeCommandSessionId ?? snapshot.claudeSessionId ?? sessionIdFromHistory;
581
- // Sessions restored from storage have ptyProcess: null — the old server's PTY
582
- // belongs to a dead process. Mark running sessions as exited so the UI
583
- // reflects reality and users can start fresh sessions.
584
- if (snapshot.status === "running") {
585
- const recoveredMessages = recoverMessagesFromSnapshot(snapshot);
586
- const updated = {
593
+ const attached = snapshot.status === "running"
594
+ ? this.terminalHost.attach(snapshot.id, snapshot.ptyOutputSeq ?? 0)
595
+ : null;
596
+ const live = attached?.process && attached.state.status === "running" ? attached : null;
597
+ const daemonEnded = attached && attached.state.status === "exited" ? attached : null;
598
+ const recoveredMessages = recoverMessagesFromSnapshot(snapshot);
599
+ const updated = live
600
+ ? {
587
601
  ...snapshot,
588
602
  sessionSource: snapshot.sessionSource ?? "interactive",
589
- status: "exited",
590
- endedAt: orphanEndedAt,
603
+ status: "running",
604
+ exitCode: null,
605
+ endedAt: null,
606
+ output: live.state.output,
607
+ ptyOutputSeq: live.state.seq,
608
+ ptyLaunchMarkerToken: live.state.launchMarkerToken,
609
+ ptyCols: live.state.cols,
610
+ ptyRows: live.state.rows,
591
611
  claudeSessionId: restoredSessionId ?? null,
592
- messages: recoveredMessages.length > 0 ? recoveredMessages : snapshot.messages,
593
- };
594
- this.storage.saveSession(updated);
595
- if (restoredSessionId && restoredSessionId !== snapshot.claudeSessionId) {
596
- const label = isCodexCmd ? "Codex thread" : isOpenCodeCmd ? "OpenCode session" : "Claude session";
597
- process.stderr.write(`[wand] Recovered ${label} ID for orphan PTY ${snapshot.id}: ${restoredSessionId}\n`);
598
612
  }
599
- this.sessions.set(snapshot.id, {
600
- ...updated,
601
- sessionSource: snapshot.sessionSource ?? "interactive",
602
- provider,
603
- processId: null,
604
- ptyProcess: null,
605
- terminalState: null,
606
- providerShellMarker: null,
607
- providerCliActive: false,
608
- providerCliExitCode: snapshot.providerCliExitCode ?? null,
609
- stopRequested: false,
610
- confirmWindow: "",
611
- ptyPermissionBlocked: false,
612
- lastAutoConfirmAt: 0,
613
- // Preserve a user-toggled auto-approve setting across server restarts
614
- // instead of recomputing it from the command/mode pair.
615
- autoApprovePermissions: snapshot.autoApprovePermissions
616
- ?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
617
- pendingEscalation: snapshot.pendingEscalation ?? null,
618
- lastEscalationResult: snapshot.lastEscalationResult ?? null,
619
- autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
620
- approvalPolicy: snapshot.approvalPolicy ?? "ask-every-time",
621
- allowedScopes: snapshot.allowedScopes ?? [],
622
- rememberedEscalationScopes: new Set(),
623
- rememberedEscalationTargets: new Set(),
624
- storedOutput: snapshot.output,
625
- messages: snapshot.messages ?? [],
626
- childProcess: null,
627
- ptyBridge: null,
628
- knownClaudeTaskIds: undefined,
629
- claudeTaskDiscoveryTimer: null,
630
- knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
631
- knownCodexSessionMtimes: isCodexCmd ? listCodexSessionMtimes(getStartupCodexHistory()) : undefined,
632
- codexSessionDiscoveryTimer: null,
633
- knownOpenCodeSessionMtimes: isOpenCodeCmd ? listOpenCodeSessionMtimes() : undefined,
634
- openCodeSessionDiscoveryTimer: null,
635
- claudeSessionId: restoredSessionId ?? updated.claudeSessionId,
636
- approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
637
- titleGenerating: false,
638
- ptyCols: snapshot.ptyCols ?? 120,
639
- ptyRows: snapshot.ptyRows ?? 36,
640
- });
641
- this.orphanRecoveredCount += 1;
642
- }
643
- else {
644
- const updated = restoredSessionId && restoredSessionId !== snapshot.claudeSessionId
645
- ? { ...snapshot, claudeSessionId: restoredSessionId }
646
- : snapshot;
647
- if (updated !== snapshot) {
613
+ : snapshot.status === "running"
614
+ ? {
615
+ ...snapshot,
616
+ sessionSource: snapshot.sessionSource ?? "interactive",
617
+ status: daemonEnded?.state.exitCode === 0 ? "exited" : daemonEnded ? "failed" : "exited",
618
+ exitCode: daemonEnded?.state.exitCode ?? null,
619
+ endedAt: orphanEndedAt,
620
+ output: daemonEnded?.state.output ?? snapshot.output,
621
+ ptyOutputSeq: daemonEnded?.state.seq ?? snapshot.ptyOutputSeq,
622
+ claudeSessionId: restoredSessionId ?? null,
623
+ messages: recoveredMessages.length > 0 ? recoveredMessages : snapshot.messages,
624
+ }
625
+ : restoredSessionId && restoredSessionId !== snapshot.claudeSessionId
626
+ ? { ...snapshot, claudeSessionId: restoredSessionId }
627
+ : snapshot;
628
+ if (updated !== snapshot || live) {
629
+ if (snapshot.status === "running")
630
+ this.storage.saveSession(updated);
631
+ else
648
632
  this.storage.saveSessionMetadata(updated);
649
- const label = isCodexCmd ? "Codex thread" : isOpenCodeCmd ? "OpenCode session" : "Claude session";
650
- process.stderr.write(`[wand] Recovered ${label} ID for saved PTY ${snapshot.id}: ${restoredSessionId}\n`);
651
- }
652
- this.sessions.set(snapshot.id, {
653
- ...updated,
654
- provider,
655
- processId: null,
656
- ptyProcess: null,
657
- terminalState: null,
658
- providerShellMarker: null,
659
- providerCliActive: false,
660
- providerCliExitCode: snapshot.providerCliExitCode ?? null,
661
- stopRequested: false,
662
- confirmWindow: "",
663
- ptyPermissionBlocked: false,
664
- lastAutoConfirmAt: 0,
665
- autoApprovePermissions: snapshot.autoApprovePermissions
666
- ?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
667
- pendingEscalation: snapshot.pendingEscalation ?? null,
668
- lastEscalationResult: snapshot.lastEscalationResult ?? null,
669
- autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
670
- approvalPolicy: snapshot.approvalPolicy ?? "ask-every-time",
671
- allowedScopes: snapshot.allowedScopes ?? [],
672
- rememberedEscalationScopes: new Set(),
673
- rememberedEscalationTargets: new Set(),
674
- storedOutput: snapshot.output,
675
- messages: snapshot.messages ?? [],
676
- childProcess: null,
677
- ptyBridge: null,
678
- knownClaudeTaskIds: undefined,
679
- claudeTaskDiscoveryTimer: null,
680
- knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
681
- knownCodexSessionMtimes: isCodexCmd ? listCodexSessionMtimes(getStartupCodexHistory()) : undefined,
682
- codexSessionDiscoveryTimer: null,
683
- knownOpenCodeSessionMtimes: isOpenCodeCmd ? listOpenCodeSessionMtimes() : undefined,
684
- openCodeSessionDiscoveryTimer: null,
685
- claudeSessionId: restoredSessionId ?? updated.claudeSessionId,
686
- approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
687
- titleGenerating: false,
688
- ptyCols: snapshot.ptyCols ?? 120,
689
- ptyRows: snapshot.ptyRows ?? 36,
690
- });
633
+ }
634
+ if (restoredSessionId && restoredSessionId !== snapshot.claudeSessionId) {
635
+ const label = isCodexCmd ? "Codex thread" : isOpenCodeCmd ? "OpenCode session" : "Claude session";
636
+ process.stderr.write(`[wand] Recovered ${label} ID for ${live ? "reattached" : "saved"} PTY ${snapshot.id}: ${restoredSessionId}\n`);
637
+ }
638
+ const record = this.makeRestoredRecord(updated, provider, live?.process ?? null, live?.state ?? null, {
639
+ isClaudeCmd,
640
+ isCodexCmd,
641
+ isOpenCodeCmd,
642
+ startupCodexHistory: getStartupCodexHistory,
643
+ });
644
+ this.sessions.set(snapshot.id, record);
645
+ if (live?.process) {
646
+ this.bindRestoredTerminal(record, live);
647
+ process.stderr.write(`[wand] Reattached live terminal ${snapshot.id} (pid ${live.process.pid}).\n`);
648
+ }
649
+ else if (snapshot.status === "running") {
650
+ this.orphanRecoveredCount += 1;
691
651
  }
692
652
  }
693
653
  this.archiveExpiredSessions();
@@ -701,6 +661,202 @@ export class ProcessManager extends EventEmitter {
701
661
  }, 60 * 1000);
702
662
  this.archiveTimer.unref?.();
703
663
  }
664
+ makeRestoredRecord(snapshot, provider, ptyProcess, terminal, history) {
665
+ return {
666
+ ...snapshot,
667
+ sessionSource: snapshot.sessionSource ?? "interactive",
668
+ provider,
669
+ processId: ptyProcess?.pid ?? null,
670
+ ptyProcess,
671
+ terminalState: terminal ? restoreTerminalState(terminal) : null,
672
+ providerShellMarker: terminal?.launchMarkerToken ? new PtyCliExitMarker(terminal.launchMarkerToken) : null,
673
+ providerCliActive: ptyProcess ? snapshot.providerCliActive ?? provider !== undefined : false,
674
+ providerCliExitCode: snapshot.providerCliExitCode ?? null,
675
+ stopRequested: false,
676
+ confirmWindow: "",
677
+ ptyPermissionBlocked: false,
678
+ lastAutoConfirmAt: 0,
679
+ autoApprovePermissions: snapshot.autoApprovePermissions
680
+ ?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
681
+ pendingEscalation: snapshot.pendingEscalation ?? null,
682
+ lastEscalationResult: snapshot.lastEscalationResult ?? null,
683
+ autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
684
+ approvalPolicy: snapshot.approvalPolicy ?? "ask-every-time",
685
+ allowedScopes: snapshot.allowedScopes ?? [],
686
+ rememberedEscalationScopes: new Set(),
687
+ rememberedEscalationTargets: new Set(),
688
+ storedOutput: terminal?.output ?? snapshot.output,
689
+ output: terminal?.output ?? snapshot.output,
690
+ messages: snapshot.messages ?? [],
691
+ childProcess: null,
692
+ ptyBridge: null,
693
+ knownClaudeTaskIds: undefined,
694
+ claudeTaskDiscoveryTimer: null,
695
+ knownClaudeProjectMtimes: history.isClaudeCmd ? listClaudeProjectSessionMtimes(snapshot.cwd) : undefined,
696
+ knownCodexSessionMtimes: history.isCodexCmd ? listCodexSessionMtimes(history.startupCodexHistory()) : undefined,
697
+ codexSessionDiscoveryTimer: null,
698
+ knownOpenCodeSessionMtimes: history.isOpenCodeCmd ? listOpenCodeSessionMtimes() : undefined,
699
+ openCodeSessionDiscoveryTimer: null,
700
+ claudeSessionId: snapshot.claudeSessionId ?? null,
701
+ approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
702
+ titleGenerating: false,
703
+ ptyCols: terminal?.cols ?? snapshot.ptyCols ?? 120,
704
+ ptyRows: terminal?.rows ?? snapshot.ptyRows ?? 36,
705
+ ptyOutputSeq: terminal?.seq ?? snapshot.ptyOutputSeq ?? 0,
706
+ ptyLaunchMarkerToken: terminal?.launchMarkerToken ?? snapshot.ptyLaunchMarkerToken ?? null,
707
+ };
708
+ }
709
+ bindRestoredTerminal(record, attached) {
710
+ const child = attached.process;
711
+ if (!child)
712
+ return;
713
+ this.initializeClaudeBridge(record, record.output);
714
+ this.bindTerminalProcess(record, child);
715
+ // The daemon snapshot already contains replayed bytes. Consume only the
716
+ // private provider boundary marker here so semantic CLI state catches up
717
+ // without duplicating terminal output or bridge transcripts.
718
+ for (const event of attached.replay) {
719
+ const boundary = record.providerShellMarker?.consume(event.data);
720
+ record.ptyOutputSeq = Math.max(record.ptyOutputSeq ?? 0, event.seq);
721
+ if (boundary?.exitCode !== null && boundary?.exitCode !== undefined) {
722
+ this.finishProviderCli(record, boundary.exitCode);
723
+ }
724
+ }
725
+ }
726
+ initializeClaudeBridge(record, initialOutput) {
727
+ if (record.provider !== "claude" || !record.providerCliActive)
728
+ return;
729
+ record.ptyBridge?.removeAllListeners();
730
+ record.ptyBridge = new ClaudePtyBridge({
731
+ sessionId: record.id,
732
+ isClaudeCommand: true,
733
+ autoApprove: record.autoApprovePermissions,
734
+ initialMessages: record.messages,
735
+ initialOutput,
736
+ });
737
+ record.ptyBridge.on("event", (event) => {
738
+ if (this.sessions.get(record.id) !== record)
739
+ return;
740
+ this.handleBridgeEvent(record, event);
741
+ });
742
+ }
743
+ bindTerminalProcess(record, child, onVisibleChunk) {
744
+ child.onExit(({ exitCode }) => this.handleTerminalExit(record, child, exitCode));
745
+ child.onData((event) => this.handleTerminalData(record, child, event, onVisibleChunk));
746
+ if (record.ptyBridge) {
747
+ record.ptyBridge.setPtyWrite((input) => {
748
+ if (this.sessions.get(record.id) !== record || record.ptyProcess !== child)
749
+ return;
750
+ child.write(input);
751
+ });
752
+ }
753
+ }
754
+ handleTerminalExit(record, child, exitCode) {
755
+ const current = this.sessions.get(record.id);
756
+ // A stopped session can be resumed under the same public id before the old
757
+ // PTY emits its asynchronous exit event. Ignore that stale incarnation.
758
+ if (current !== record || current.ptyProcess !== child)
759
+ return;
760
+ if (current.claudeTaskDiscoveryTimer) {
761
+ clearTimeout(current.claudeTaskDiscoveryTimer);
762
+ current.claudeTaskDiscoveryTimer = null;
763
+ }
764
+ if (current.codexSessionDiscoveryTimer) {
765
+ clearTimeout(current.codexSessionDiscoveryTimer);
766
+ current.codexSessionDiscoveryTimer = null;
767
+ }
768
+ if (current.openCodeSessionDiscoveryTimer) {
769
+ clearTimeout(current.openCodeSessionDiscoveryTimer);
770
+ current.openCodeSessionDiscoveryTimer = null;
771
+ }
772
+ if (current.initialInputTimer) {
773
+ clearTimeout(current.initialInputTimer);
774
+ current.initialInputTimer = null;
775
+ }
776
+ if (current.ptyBridge) {
777
+ current.ptyBridge.onExit(exitCode);
778
+ current.messages = current.ptyBridge.getMessages();
779
+ current.ptyBridge.removeAllListeners();
780
+ current.ptyBridge = null;
781
+ }
782
+ current.providerShellMarker = null;
783
+ current.ptyLaunchMarkerToken = null;
784
+ if (current.providerCliActive) {
785
+ current.providerCliActive = false;
786
+ current.providerCliExitCode = exitCode;
787
+ }
788
+ current.pendingEscalation = null;
789
+ current.ptyPermissionBlocked = false;
790
+ this.captureClaudeSessionId(current, { allowTimeWindowFallback: true });
791
+ this.captureCodexSessionId(current, { allowTimeWindowFallback: true });
792
+ this.captureOpenCodeSessionId(current, { allowTimeWindowFallback: true });
793
+ current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
794
+ current.exitCode = current.stopRequested ? null : exitCode;
795
+ current.endedAt = new Date().toISOString();
796
+ current.ptyProcess = null;
797
+ this.flushPersist(current, true);
798
+ this.emitEvent({ type: "ended", sessionId: record.id, data: this.snapshot(current) });
799
+ }
800
+ handleTerminalData(record, child, event, onVisibleChunk) {
801
+ const current = this.sessions.get(record.id);
802
+ if (current !== record || current.ptyProcess !== child)
803
+ return;
804
+ current.ptyOutputSeq = Math.max(current.ptyOutputSeq ?? 0, event.seq);
805
+ const boundary = current.providerShellMarker?.consume(event.data) ?? { data: event.data, exitCode: null };
806
+ const chunk = boundary.data;
807
+ if (chunk)
808
+ current.terminalState?.write(chunk);
809
+ if (chunk && current.ptyBridge) {
810
+ current.ptyBridge.processChunk(chunk);
811
+ current.output = current.ptyBridge.getRawOutput();
812
+ }
813
+ else if (chunk) {
814
+ current.output = appendWindow(current.output, chunk, PTY_OUTPUT_MAX_SIZE);
815
+ }
816
+ if (chunk)
817
+ this.logger.appendPtyOutput(record.id, chunk);
818
+ if (chunk && !current.ptyBridge) {
819
+ this.emitEvent({
820
+ type: "output",
821
+ sessionId: record.id,
822
+ data: {
823
+ incremental: true,
824
+ chunk,
825
+ permissionBlocked: this.isPermissionBlocked(current),
826
+ },
827
+ });
828
+ }
829
+ const bridgeSessionId = current.ptyBridge?.getClaudeSessionId();
830
+ if (bridgeSessionId && bridgeSessionId !== current.claudeSessionId) {
831
+ current.claudeSessionId = bridgeSessionId;
832
+ this.markDirty(current.id, { metadata: true });
833
+ this.providerHistory.invalidate("claude");
834
+ process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
835
+ }
836
+ if (current.providerCliActive && boundary.exitCode === null && !current.claudeSessionId && current.knownClaudeTaskIds) {
837
+ current.messages = snapshotMessages(current);
838
+ const discoveredTaskId = getLatestClaudeProjectSessionId({
839
+ cwd: current.cwd,
840
+ startedAt: current.startedAt,
841
+ knownClaudeProjectMtimes: current.knownClaudeProjectMtimes,
842
+ messages: current.messages,
843
+ });
844
+ if (discoveredTaskId) {
845
+ current.claudeSessionId = discoveredTaskId;
846
+ this.markDirty(current.id, { metadata: true });
847
+ current.knownClaudeTaskIds.add(discoveredTaskId);
848
+ process.stderr.write(`[wand] Captured Claude project session ID: ${discoveredTaskId}\n`);
849
+ }
850
+ }
851
+ if (chunk && current.providerCliActive && current.autoApprovePermissions && !current.ptyBridge && current.provider === "claude") {
852
+ this.autoConfirmWithRecord(current, chunk, child);
853
+ }
854
+ if (chunk)
855
+ onVisibleChunk?.(chunk);
856
+ if (boundary.exitCode !== null)
857
+ this.finishProviderCli(current, boundary.exitCode);
858
+ this.schedulePersist(current);
859
+ }
704
860
  on(_event, listener) {
705
861
  return super.on("process", listener);
706
862
  }
@@ -726,8 +882,8 @@ export class ProcessManager extends EventEmitter {
726
882
  if (record.ptyBridge) {
727
883
  record.messages = record.ptyBridge.getMessages();
728
884
  }
729
- this.cleanupRecord(record);
730
- if (wasRunning) {
885
+ this.cleanupRecord(record, !this.terminalHost.persistent);
886
+ if (wasRunning && !this.terminalHost.persistent) {
731
887
  record.stopRequested = true;
732
888
  record.status = "stopped";
733
889
  record.exitCode = null;
@@ -745,6 +901,7 @@ export class ProcessManager extends EventEmitter {
745
901
  this.topicCoordinator.clear();
746
902
  this.removeAllListeners("process");
747
903
  this.logger.dispose();
904
+ this.terminalHost.disconnect();
748
905
  }
749
906
  emitEvent(event) {
750
907
  if (this.disposed)
@@ -787,6 +944,7 @@ export class ProcessManager extends EventEmitter {
787
944
  }
788
945
  }
789
946
  this.sessions.delete(id);
947
+ this.terminalHost.forget(id);
790
948
  this.lastPersistedMessageState.delete(id);
791
949
  this.dirtySessions.delete(id);
792
950
  this.storage.deleteSession(id);
@@ -795,7 +953,7 @@ export class ProcessManager extends EventEmitter {
795
953
  this.providerHistory.invalidate();
796
954
  }
797
955
  }
798
- start(command, cwd, mode, initialInput, opts) {
956
+ async start(command, cwd, mode, initialInput, opts) {
799
957
  if (this.disposed)
800
958
  throw new Error("ProcessManager has been disposed.");
801
959
  if (!opts?.interactiveShell)
@@ -825,6 +983,7 @@ export class ProcessManager extends EventEmitter {
825
983
  inheritedSessionSource = stored?.sessionSource;
826
984
  inheritedAutomationId = stored?.automationId;
827
985
  }
986
+ this.terminalHost.forget(id);
828
987
  }
829
988
  const worktreeSetup = opts?.worktreeEnabled
830
989
  ? prepareSessionWorktree({ cwd: baseCwd, sessionId: id })
@@ -924,33 +1083,21 @@ export class ProcessManager extends EventEmitter {
924
1083
  thinkingEffort: initialThinkingEffort,
925
1084
  ptyCols: opts?.cols !== undefined ? clampDimension(opts.cols, 20, 1000) : 120,
926
1085
  ptyRows: opts?.rows !== undefined ? clampDimension(opts.rows, 5, 500) : 36,
1086
+ ptyOutputSeq: 0,
1087
+ ptyLaunchMarkerToken: launchPlan.cliExitMarker?.token ?? null,
927
1088
  };
928
- if (isClaudeProvider) {
929
- record.ptyBridge = new ClaudePtyBridge({
930
- sessionId: id,
931
- isClaudeCommand: true,
932
- autoApprove: record.autoApprovePermissions,
933
- initialMessages: priorMessages,
934
- });
935
- record.ptyBridge.on("event", (event) => {
936
- if (this.sessions.get(id) !== record)
937
- return;
938
- this.handleBridgeEvent(record, event);
939
- });
940
- }
941
1089
  this.sessions.set(id, record);
942
1090
  this.persist(record, { forceFullSave: true });
943
1091
  if (initialClaudeSessionId && (provider === "claude" || provider === "codex")) {
944
1092
  this.providerHistory.invalidate(provider);
945
1093
  }
946
1094
  this.cleanupOldSessions();
947
- // Self-heal node-pty's spawn-helper +x bit before every spawn: a self-update
948
- // can re-drop it after this server already ran its startup chmod, which would
949
- // otherwise make every PTY launch throw "posix_spawnp failed." until restart.
950
- ensureNodePtyHelperExecutable();
951
1095
  let child;
952
1096
  try {
953
- child = pty.spawn(this.config.shell, launchPlan.shellArgs, {
1097
+ const attached = await this.terminalHost.createOrAttach({
1098
+ sessionId: id,
1099
+ file: this.config.shell,
1100
+ args: launchPlan.shellArgs,
954
1101
  cwd: resolvedCwd,
955
1102
  env: buildChildEnv(this.config.inheritEnv !== false, {
956
1103
  WAND_MODE: effectiveMode,
@@ -961,11 +1108,25 @@ export class ProcessManager extends EventEmitter {
961
1108
  // 使用 record 上由前端协商好的真实尺寸,避免"先 120 列、几百毫秒后再 resize"
962
1109
  // 期间 provider TUI 用错列宽渲染出 \x1b[120G 这类绝对列定位序列。
963
1110
  cols: record.ptyCols,
964
- rows: record.ptyRows
965
- });
1111
+ rows: record.ptyRows,
1112
+ launchMarkerToken: launchPlan.cliExitMarker?.token,
1113
+ }, record.ptyOutputSeq);
1114
+ if (!attached.process || attached.state.status !== "running") {
1115
+ throw new Error("Terminal host did not return a running process");
1116
+ }
1117
+ child = attached.process;
1118
+ record.output = attached.state.output;
1119
+ record.storedOutput = attached.state.output;
1120
+ record.ptyOutputSeq = attached.state.seq;
1121
+ record.ptyLaunchMarkerToken = attached.state.launchMarkerToken;
1122
+ record.ptyCols = attached.state.cols;
1123
+ record.ptyRows = attached.state.rows;
1124
+ record.terminalState?.dispose();
1125
+ record.terminalState = restoreTerminalState(attached.state);
1126
+ this.initializeClaudeBridge(record, attached.state.output);
966
1127
  }
967
1128
  catch (err) {
968
- console.error("[ProcessManager] pty.spawn threw", { sessionId: id, error: String(err) });
1129
+ console.error("[ProcessManager] terminal spawn failed", { sessionId: id, error: String(err) });
969
1130
  record.status = "failed";
970
1131
  record.exitCode = -1;
971
1132
  record.endedAt = new Date().toISOString();
@@ -976,60 +1137,6 @@ export class ProcessManager extends EventEmitter {
976
1137
  record.processId = child.pid;
977
1138
  record.ptyProcess = child;
978
1139
  record.status = "running";
979
- child.onExit(({ exitCode }) => {
980
- const current = this.sessions.get(id);
981
- // A stopped session can be resumed under the same public id before the
982
- // old PTY has emitted its asynchronous exit event. Never let that stale
983
- // callback finalize or clear the replacement run.
984
- if (current !== record || current.ptyProcess !== child)
985
- return;
986
- if (current.claudeTaskDiscoveryTimer) {
987
- clearTimeout(current.claudeTaskDiscoveryTimer);
988
- current.claudeTaskDiscoveryTimer = null;
989
- }
990
- if (current.codexSessionDiscoveryTimer) {
991
- clearTimeout(current.codexSessionDiscoveryTimer);
992
- current.codexSessionDiscoveryTimer = null;
993
- }
994
- if (current.openCodeSessionDiscoveryTimer) {
995
- clearTimeout(current.openCodeSessionDiscoveryTimer);
996
- current.openCodeSessionDiscoveryTimer = null;
997
- }
998
- if (current.initialInputTimer) {
999
- clearTimeout(current.initialInputTimer);
1000
- current.initialInputTimer = null;
1001
- }
1002
- if (current.ptyBridge) {
1003
- current.ptyBridge.onExit(exitCode);
1004
- current.ptyBridge.removeAllListeners();
1005
- }
1006
- current.providerShellMarker = null;
1007
- if (current.providerCliActive) {
1008
- current.providerCliActive = false;
1009
- current.providerCliExitCode = exitCode;
1010
- }
1011
- current.pendingEscalation = null;
1012
- current.ptyPermissionBlocked = false;
1013
- this.captureClaudeSessionId(current, { allowTimeWindowFallback: true });
1014
- this.captureCodexSessionId(current, { allowTimeWindowFallback: true });
1015
- this.captureOpenCodeSessionId(current, { allowTimeWindowFallback: true });
1016
- current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
1017
- current.exitCode = current.stopRequested ? null : exitCode;
1018
- current.endedAt = new Date().toISOString();
1019
- current.ptyProcess = null;
1020
- this.flushPersist(current, true);
1021
- this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
1022
- });
1023
- if (record.ptyBridge) {
1024
- record.ptyBridge.setPtyWrite((input) => {
1025
- if (this.sessions.get(id) !== record || record.ptyProcess !== child)
1026
- return;
1027
- child.write(input);
1028
- });
1029
- }
1030
- this.emitEvent({ type: "started", sessionId: id, data: this.snapshot(record) });
1031
- if (initialInput)
1032
- this.maybeGenerateSessionTopic(id, initialInput);
1033
1140
  let initialInputSent = false;
1034
1141
  const sendInitialInput = () => {
1035
1142
  if (initialInputSent || !initialInput)
@@ -1047,70 +1154,13 @@ export class ProcessManager extends EventEmitter {
1047
1154
  child.write(initialInput);
1048
1155
  child.write("\r");
1049
1156
  };
1050
- child.onData((rawChunk) => {
1051
- const rec = this.sessions.get(id);
1052
- // PTYs may still drain data after kill(). A replacement session can use
1053
- // the same id, so both record identity and the concrete PTY handle must
1054
- // match before accepting the chunk.
1055
- if (rec !== record || rec.ptyProcess !== child)
1056
- return;
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) {
1062
- rec.ptyBridge.processChunk(chunk);
1063
- rec.output = rec.ptyBridge.getRawOutput();
1064
- }
1065
- else if (chunk) {
1066
- rec.output = appendWindow(rec.output, chunk, PTY_OUTPUT_MAX_SIZE);
1067
- }
1068
- if (chunk)
1069
- this.logger.appendPtyOutput(id, chunk);
1070
- if (chunk && !rec.ptyBridge) {
1071
- this.emitEvent({
1072
- type: "output",
1073
- sessionId: id,
1074
- data: {
1075
- incremental: true,
1076
- chunk,
1077
- permissionBlocked: this.isPermissionBlocked(rec),
1078
- },
1079
- });
1080
- }
1081
- const bridgeSessionId = rec.ptyBridge?.getClaudeSessionId();
1082
- if (bridgeSessionId && bridgeSessionId !== rec.claudeSessionId) {
1083
- rec.claudeSessionId = bridgeSessionId;
1084
- this.markDirty(rec.id, { metadata: true });
1085
- this.providerHistory.invalidate("claude");
1086
- process.stderr.write(`[wand] Captured Claude session ID: ${bridgeSessionId}\n`);
1087
- }
1088
- if (rec.providerCliActive && boundary.exitCode === null && !rec.claudeSessionId && rec.knownClaudeTaskIds) {
1089
- rec.messages = snapshotMessages(rec);
1090
- const discoveredTaskId = getLatestClaudeProjectSessionId({
1091
- cwd: rec.cwd,
1092
- startedAt: rec.startedAt,
1093
- knownClaudeProjectMtimes: rec.knownClaudeProjectMtimes,
1094
- messages: rec.messages
1095
- });
1096
- if (discoveredTaskId) {
1097
- rec.claudeSessionId = discoveredTaskId;
1098
- this.markDirty(rec.id, { metadata: true });
1099
- rec.knownClaudeTaskIds.add(discoveredTaskId);
1100
- process.stderr.write(`[wand] Captured Claude project session ID: ${discoveredTaskId}\n`);
1101
- }
1102
- }
1103
- if (chunk && rec.providerCliActive && rec.autoApprovePermissions && !rec.ptyBridge && rec.provider === "claude") {
1104
- this.autoConfirmWithRecord(rec, chunk, child);
1105
- }
1106
- if (chunk && boundary.exitCode === null && initialInput && !initialInputSent && (chunk.includes("❯") || chunk.includes("›"))) {
1157
+ this.bindTerminalProcess(record, child, (chunk) => {
1158
+ if (initialInput && !initialInputSent && (chunk.includes("❯") || chunk.includes("›")))
1107
1159
  sendInitialInput();
1108
- }
1109
- if (boundary.exitCode !== null) {
1110
- this.finishProviderCli(record, boundary.exitCode);
1111
- }
1112
- this.schedulePersist(rec);
1113
1160
  });
1161
+ this.emitEvent({ type: "started", sessionId: id, data: this.snapshot(record) });
1162
+ if (initialInput)
1163
+ this.maybeGenerateSessionTopic(id, initialInput);
1114
1164
  if (launchPlan.commandToWrite) {
1115
1165
  child.write(launchPlan.commandToWrite);
1116
1166
  child.write("\r");
@@ -1505,6 +1555,7 @@ export class ProcessManager extends EventEmitter {
1505
1555
  record.providerCliActive = false;
1506
1556
  record.providerCliExitCode = exitCode;
1507
1557
  record.providerShellMarker = null;
1558
+ record.ptyLaunchMarkerToken = null;
1508
1559
  if (record.claudeTaskDiscoveryTimer) {
1509
1560
  clearTimeout(record.claudeTaskDiscoveryTimer);
1510
1561
  record.claudeTaskDiscoveryTimer = null;
@@ -1570,6 +1621,7 @@ export class ProcessManager extends EventEmitter {
1570
1621
  record.stopRequested = true;
1571
1622
  record.providerCliActive = false;
1572
1623
  record.providerShellMarker = null;
1624
+ record.ptyLaunchMarkerToken = null;
1573
1625
  // Kill any running child process (from JSON chat turns)
1574
1626
  if (record.childProcess) {
1575
1627
  record.childProcess.kill();
@@ -1600,7 +1652,7 @@ export class ProcessManager extends EventEmitter {
1600
1652
  this.flushPersist(record, true);
1601
1653
  return this.snapshot(record);
1602
1654
  }
1603
- cleanupRecord(record) {
1655
+ cleanupRecord(record, terminateTerminal = true) {
1604
1656
  if (record.claudeTaskDiscoveryTimer) {
1605
1657
  clearTimeout(record.claudeTaskDiscoveryTimer);
1606
1658
  record.claudeTaskDiscoveryTimer = null;
@@ -1623,17 +1675,18 @@ export class ProcessManager extends EventEmitter {
1623
1675
  this.persistDebounceTimers.delete(record.id);
1624
1676
  }
1625
1677
  if (record.status === "running") {
1626
- record.stopRequested = true;
1678
+ if (terminateTerminal)
1679
+ record.stopRequested = true;
1627
1680
  if (record.childProcess) {
1628
1681
  const child = record.childProcess;
1629
1682
  record.childProcess = null;
1630
1683
  child.kill();
1631
1684
  }
1632
- if (record.ptyProcess) {
1685
+ if (record.ptyProcess && terminateTerminal) {
1633
1686
  const ptyProcess = record.ptyProcess;
1634
- record.ptyProcess = null;
1635
1687
  ptyProcess.kill();
1636
1688
  }
1689
+ record.ptyProcess = null;
1637
1690
  }
1638
1691
  if (record.ptyBridge) {
1639
1692
  record.ptyBridge.removeAllListeners();
@@ -1692,6 +1745,7 @@ export class ProcessManager extends EventEmitter {
1692
1745
  }
1693
1746
  record.terminalState?.dispose();
1694
1747
  record.terminalState = null;
1748
+ this.terminalHost.forget(id);
1695
1749
  // Delete from persistent storage BEFORE removing from in-memory map,
1696
1750
  // so a storage failure doesn't leave orphan records in the database.
1697
1751
  this.storage.deleteSession(id);
@@ -1737,8 +1791,8 @@ export class ProcessManager extends EventEmitter {
1737
1791
  }
1738
1792
  }
1739
1793
  }
1740
- runStartupCommands() {
1741
- return this.config.startupCommands.map((command) => this.start(command, this.config.defaultCwd, this.config.defaultMode, undefined, { sessionSource: "startup" }));
1794
+ async runStartupCommands() {
1795
+ return Promise.all(this.config.startupCommands.map((command) => this.start(command, this.config.defaultCwd, this.config.defaultMode, undefined, { sessionSource: "startup" })));
1742
1796
  }
1743
1797
  snapshot(record) {
1744
1798
  // Get messages from bridge if available, otherwise use stored messages
@@ -1788,6 +1842,8 @@ export class ProcessManager extends EventEmitter {
1788
1842
  thinkingEffort: record.thinkingEffort ?? null,
1789
1843
  ptyCols: record.ptyCols,
1790
1844
  ptyRows: record.ptyRows,
1845
+ ptyOutputSeq: record.ptyOutputSeq ?? 0,
1846
+ ptyLaunchMarkerToken: record.ptyLaunchMarkerToken ?? null,
1791
1847
  };
1792
1848
  }
1793
1849
  /** Lightweight snapshot for list views — omits output and messages. */
@@ -1961,11 +2017,11 @@ export class ProcessManager extends EventEmitter {
1961
2017
  if (dirty.metadata)
1962
2018
  this.storage.updateSessionRuntimeMetadata(snapshot);
1963
2019
  if (dirty.messages) {
1964
- this.storage.checkpointSessionMessages(record.id, messages, snapshot.structuredState, dirty.output ? snapshot.output : undefined);
2020
+ this.storage.checkpointSessionMessages(record.id, messages, snapshot.structuredState, dirty.output ? snapshot.output : undefined, dirty.output ? snapshot.ptyOutputSeq : undefined);
1965
2021
  dirty.output = false;
1966
2022
  }
1967
2023
  else if (dirty.output) {
1968
- this.storage.checkpointSessionOutput(record.id, snapshot.output);
2024
+ this.storage.checkpointSessionOutput(record.id, snapshot.output, snapshot.ptyOutputSeq);
1969
2025
  }
1970
2026
  }
1971
2027
  if (shouldSaveMessages)
@@ -2240,7 +2296,7 @@ export class ProcessManager extends EventEmitter {
2240
2296
  return record;
2241
2297
  }
2242
2298
  /** Start a bare interactive login shell without a provider CLI. */
2243
- startShell(cwd, mode, opts) {
2299
+ async startShell(cwd, mode, opts) {
2244
2300
  return this.start(this.config.shell, cwd, mode, undefined, {
2245
2301
  ...opts,
2246
2302
  interactiveShell: true,