@co0ontty/wand 1.6.2 → 1.9.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
  import { EventEmitter } from "node:events";
2
2
  import { WandStorage } from "./storage.js";
3
- import { ExecutionMode, ProcessEventHandler, SessionSnapshot, WandConfig } from "./types.js";
3
+ import { ExecutionMode, ProcessEventHandler, SessionProvider, SessionSnapshot, WandConfig } from "./types.js";
4
4
  export type { ProcessEvent, ProcessEventHandler } from "./types.js";
5
5
  /** Human-readable task information for the UI */
6
6
  export interface TaskInfo {
@@ -32,8 +32,8 @@ export declare class ProcessManager extends EventEmitter {
32
32
  private readonly lifecycleManager;
33
33
  /** Per-session debounce timers for throttled persist calls */
34
34
  private readonly persistDebounceTimers;
35
- /** Last persisted message count per session — used to skip redundant file writes */
36
- private readonly lastPersistedMessageCount;
35
+ /** Last persisted message state per session — used to skip redundant message writes */
36
+ private readonly lastPersistedMessageState;
37
37
  constructor(config: WandConfig, storage: WandStorage, configDir?: string);
38
38
  on(event: "process", listener: ProcessEventHandler): this;
39
39
  private emitEvent;
@@ -41,6 +41,8 @@ export declare class ProcessManager extends EventEmitter {
41
41
  start(command: string, cwd: string | undefined, mode: ExecutionMode, initialInput?: string, opts?: {
42
42
  resumedFromSessionId?: string;
43
43
  autoRecovered?: boolean;
44
+ worktreeEnabled?: boolean;
45
+ provider?: SessionProvider;
44
46
  }): SessionSnapshot;
45
47
  list(): SessionSnapshot[];
46
48
  hasClaudeSessionFile(cwd: string, claudeSessionId: string): boolean;
@@ -52,6 +54,7 @@ export declare class ProcessManager extends EventEmitter {
52
54
  cwd: string;
53
55
  }[]): number;
54
56
  get(id: string): SessionSnapshot | null;
57
+ getPtyTranscript(id: string): string | null;
55
58
  sendInput(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string): SessionSnapshot;
56
59
  /** Emit a task event for a session, debounced to avoid flooding */
57
60
  private emitTask;
@@ -9,10 +9,13 @@ import { SessionLogger } from "./session-logger.js";
9
9
  import { SessionLifecycleManager } from "./session-lifecycle.js";
10
10
  import { ClaudePtyBridge } from "./claude-pty-bridge.js";
11
11
  import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText } from "./pty-text-utils.js";
12
+ import { prepareSessionWorktree } from "./git-worktree.js";
12
13
  import { getResumeCommandSessionId, hasRealConversationMessages, } from "./resume-policy.js";
13
- /** Check if the current process is running as root (UID 0). */
14
+ function resolveProviderFromCommand(command) {
15
+ return /^codex\b/.test(command.trim()) ? "codex" : "claude";
16
+ }
14
17
  function isRunningAsRoot() {
15
- return process.getuid?.() === 0 || process.geteuid?.() === 0;
18
+ return typeof process.getuid === "function" && process.getuid() === 0;
16
19
  }
17
20
  export class SessionInputError extends Error {
18
21
  code;
@@ -398,6 +401,32 @@ function getLatestClaudeTaskId(excludeIds) {
398
401
  }
399
402
  }
400
403
  /** Derive a short summary for a session from user messages or current task. */
404
+ function getLastMessageSignature(messages) {
405
+ const last = messages[messages.length - 1];
406
+ if (!last) {
407
+ return "";
408
+ }
409
+ const lastContent = JSON.stringify(last.content ?? []);
410
+ return `${last.role}:${lastContent}`;
411
+ }
412
+ function getPersistedMessageState(messages) {
413
+ return {
414
+ count: messages.length,
415
+ signature: getLastMessageSignature(messages),
416
+ };
417
+ }
418
+ function shouldPersistMessages(lastState, nextMessages) {
419
+ if (nextMessages.length === 0) {
420
+ return false;
421
+ }
422
+ const nextState = getPersistedMessageState(nextMessages);
423
+ return !lastState
424
+ || lastState.count !== nextState.count
425
+ || lastState.signature !== nextState.signature;
426
+ }
427
+ function recoverMessagesFromSnapshot(snapshot) {
428
+ return snapshot.messages ?? [];
429
+ }
401
430
  function deriveSessionSummary(messages, currentTaskTitle) {
402
431
  // Prefer first user message as summary
403
432
  for (const msg of messages) {
@@ -408,9 +437,8 @@ function deriveSessionSummary(messages, currentTaskTitle) {
408
437
  return block.text.trim().slice(0, 120);
409
438
  }
410
439
  }
411
- break; // only check the first user turn
440
+ break;
412
441
  }
413
- // Fallback to current task title
414
442
  if (currentTaskTitle)
415
443
  return currentTaskTitle.slice(0, 120);
416
444
  return undefined;
@@ -423,8 +451,8 @@ export class ProcessManager extends EventEmitter {
423
451
  lifecycleManager;
424
452
  /** Per-session debounce timers for throttled persist calls */
425
453
  persistDebounceTimers = new Map();
426
- /** Last persisted message count per session — used to skip redundant file writes */
427
- lastPersistedMessageCount = new Map();
454
+ /** Last persisted message state per session — used to skip redundant message writes */
455
+ lastPersistedMessageState = new Map();
428
456
  constructor(config, storage, configDir) {
429
457
  super();
430
458
  this.config = config;
@@ -444,23 +472,31 @@ export class ProcessManager extends EventEmitter {
444
472
  if ((snapshot.sessionKind ?? "pty") !== "pty") {
445
473
  continue;
446
474
  }
447
- const isClaudeCmd = /^claude\b/.test(snapshot.command.trim());
448
- const resumeCommandSessionId = getResumeCommandSessionId(snapshot.command);
475
+ const provider = snapshot.provider ?? resolveProviderFromCommand(snapshot.command);
476
+ const isClaudeCmd = provider === "claude";
477
+ const resumeCommandSessionId = isClaudeCmd ? getResumeCommandSessionId(snapshot.command) : null;
449
478
  // Sessions restored from storage have ptyProcess: null — the old server's PTY
450
479
  // belongs to a dead process. Mark running sessions as exited so the UI
451
480
  // reflects reality and users can start fresh sessions.
452
481
  if (snapshot.status === "running") {
453
- const updated = { ...snapshot, status: "exited", endedAt: new Date().toISOString() };
482
+ const recoveredMessages = recoverMessagesFromSnapshot(snapshot);
483
+ const updated = {
484
+ ...snapshot,
485
+ status: "exited",
486
+ endedAt: new Date().toISOString(),
487
+ messages: recoveredMessages.length > 0 ? recoveredMessages : snapshot.messages,
488
+ };
454
489
  this.storage.saveSession(updated);
455
490
  this.sessions.set(snapshot.id, {
456
491
  ...updated,
492
+ provider,
457
493
  processId: null,
458
494
  ptyProcess: null,
459
495
  stopRequested: false,
460
496
  confirmWindow: "",
461
497
  ptyPermissionBlocked: false,
462
498
  lastAutoConfirmAt: 0,
463
- autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode),
499
+ autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
464
500
  pendingEscalation: snapshot.pendingEscalation ?? null,
465
501
  lastEscalationResult: snapshot.lastEscalationResult ?? null,
466
502
  autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
@@ -487,13 +523,14 @@ export class ProcessManager extends EventEmitter {
487
523
  else {
488
524
  this.sessions.set(snapshot.id, {
489
525
  ...snapshot,
526
+ provider,
490
527
  processId: null,
491
528
  ptyProcess: null,
492
529
  stopRequested: false,
493
530
  confirmWindow: "",
494
531
  ptyPermissionBlocked: false,
495
532
  lastAutoConfirmAt: 0,
496
- autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode),
533
+ autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
497
534
  pendingEscalation: snapshot.pendingEscalation ?? null,
498
535
  lastEscalationResult: snapshot.lastEscalationResult ?? null,
499
536
  autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
@@ -565,30 +602,40 @@ export class ProcessManager extends EventEmitter {
565
602
  this.deleteClaudeCache(record);
566
603
  }
567
604
  this.sessions.delete(id);
568
- this.lastPersistedMessageCount.delete(id);
605
+ this.lastPersistedMessageState.delete(id);
569
606
  this.storage.deleteSession(id);
570
607
  }
571
608
  }
572
609
  start(command, cwd, mode, initialInput, opts) {
573
610
  this.assertCommandAllowed(command);
574
- const resolvedCwd = cwd
611
+ const baseCwd = cwd
575
612
  ? path.resolve(process.cwd(), cwd)
576
613
  : path.resolve(process.cwd(), this.config.defaultCwd);
577
- const isClaudeCmd = this.isClaudeCommand(command);
578
- // For full-access mode with claude, add permission flags
579
- const processedCommand = this.processCommandForMode(command, mode);
580
- const resumeCommandSessionId = getResumeCommandSessionId(processedCommand) ?? getResumeCommandSessionId(command);
581
- const knownClaudeTaskIds = isClaudeCmd ? new Set(listRecentClaudeProjectSessionIds(resolvedCwd, new Date().toISOString())) : null;
582
- const knownClaudeProjectMtimes = isClaudeCmd ? listClaudeProjectSessionMtimes(resolvedCwd) : null;
583
- const initialClaudeSessionId = resumeCommandSessionId ?? null;
584
- const startedAt = new Date().toISOString();
585
614
  const id = randomUUID();
615
+ const worktreeSetup = opts?.worktreeEnabled
616
+ ? prepareSessionWorktree({ cwd: baseCwd, sessionId: id })
617
+ : null;
618
+ const resolvedCwd = worktreeSetup?.cwd ?? baseCwd;
619
+ const provider = opts?.provider ?? resolveProviderFromCommand(command);
620
+ const effectiveMode = provider === "codex" ? "full-access" : mode;
621
+ const isClaudeProvider = provider === "claude";
622
+ const processedCommand = this.processCommandForMode(command, effectiveMode, provider);
623
+ const resumeCommandSessionId = isClaudeProvider
624
+ ? getResumeCommandSessionId(processedCommand) ?? getResumeCommandSessionId(command)
625
+ : null;
626
+ const knownClaudeTaskIds = isClaudeProvider ? new Set(listRecentClaudeProjectSessionIds(resolvedCwd, new Date().toISOString())) : null;
627
+ const knownClaudeProjectMtimes = isClaudeProvider ? listClaudeProjectSessionMtimes(resolvedCwd) : null;
628
+ const initialClaudeSessionId = isClaudeProvider ? resumeCommandSessionId ?? null : null;
629
+ const startedAt = new Date().toISOString();
586
630
  const record = {
587
631
  id,
632
+ provider,
588
633
  command,
589
634
  cwd: resolvedCwd,
590
- mode,
591
- autonomyPolicy: this.defaultAutonomyPolicy(mode),
635
+ mode: effectiveMode,
636
+ worktreeEnabled: Boolean(worktreeSetup),
637
+ worktree: worktreeSetup?.worktree ?? null,
638
+ autonomyPolicy: this.defaultAutonomyPolicy(effectiveMode),
592
639
  approvalPolicy: "ask-every-time",
593
640
  allowedScopes: [],
594
641
  status: "running",
@@ -608,7 +655,7 @@ export class ProcessManager extends EventEmitter {
608
655
  confirmWindow: "",
609
656
  ptyPermissionBlocked: false,
610
657
  lastAutoConfirmAt: 0,
611
- autoApprovePermissions: this.shouldAutoApprovePermissions(command, mode),
658
+ autoApprovePermissions: this.shouldAutoApprovePermissions(command, effectiveMode, provider),
612
659
  resumedFromSessionId: opts?.resumedFromSessionId ?? null,
613
660
  autoRecovered: opts?.autoRecovered ?? false,
614
661
  rememberedEscalationScopes: new Set(),
@@ -625,22 +672,21 @@ export class ProcessManager extends EventEmitter {
625
672
  knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
626
673
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
627
674
  };
628
- // Create PTY bridge for this session
629
- record.ptyBridge = new ClaudePtyBridge({
630
- sessionId: id,
631
- isClaudeCommand: isClaudeCmd,
632
- autoApprove: record.autoApprovePermissions,
633
- approvalPolicy: record.approvalPolicy,
634
- });
635
- record.ptyBridge.on("event", (event) => {
636
- this.handleBridgeEvent(record, event);
637
- });
675
+ if (isClaudeProvider) {
676
+ record.ptyBridge = new ClaudePtyBridge({
677
+ sessionId: id,
678
+ isClaudeCommand: true,
679
+ autoApprove: record.autoApprovePermissions,
680
+ approvalPolicy: record.approvalPolicy,
681
+ });
682
+ record.ptyBridge.on("event", (event) => {
683
+ this.handleBridgeEvent(record, event);
684
+ });
685
+ }
638
686
  this.sessions.set(id, record);
639
687
  this.persist(record);
640
688
  this.cleanupOldSessions();
641
- // Register lifecycle
642
689
  this.lifecycleManager.register(id, "initializing");
643
- // All modes use PTY execution — JSON turns are only used for internal recovery
644
690
  const shellArgs = this.buildShellArgs(processedCommand);
645
691
  let child;
646
692
  try {
@@ -648,9 +694,9 @@ export class ProcessManager extends EventEmitter {
648
694
  cwd: resolvedCwd,
649
695
  env: {
650
696
  ...process.env,
651
- WAND_MODE: mode,
652
- WAND_AUTO_CONFIRM: mode === "full-access" ? "1" : "0",
653
- WAND_AUTO_EDIT: mode === "auto-edit" ? "1" : "0"
697
+ WAND_MODE: effectiveMode,
698
+ WAND_AUTO_CONFIRM: effectiveMode === "full-access" ? "1" : "0",
699
+ WAND_AUTO_EDIT: effectiveMode === "auto-edit" ? "1" : "0"
654
700
  },
655
701
  name: "xterm-color",
656
702
  cols: 120,
@@ -671,10 +717,6 @@ export class ProcessManager extends EventEmitter {
671
717
  record.ptyProcess = child;
672
718
  record.status = "running";
673
719
  this.lifecycleManager.setState(id, "running");
674
- // Register exit handler AFTER ptyProcess is assigned — node-pty's EventEmitter
675
- // fires 'exit' synchronously when the child has already exited (e.g. "command
676
- // not found"). If we register first, onExit fires with ptyProcess still null and
677
- // status never updates. By assigning first, onExit always sees a consistent state.
678
720
  child.onExit(({ exitCode }) => {
679
721
  const current = this.sessions.get(id);
680
722
  if (!current)
@@ -699,14 +741,9 @@ export class ProcessManager extends EventEmitter {
699
741
  current.ptyProcess = null;
700
742
  this.lifecycleManager.archive(id, `Session ${current.status}`, current.stopRequested ? "user" : "error");
701
743
  this.flushPersist(current);
702
- // Final full snapshot with messages to SQLite (persist() only saves metadata)
703
744
  this.storage.saveSession(this.snapshot(current));
704
745
  this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
705
746
  });
706
- // Set PTY write function for bridge (for permission approval).
707
- // Write directly to record.ptyProcess — the status guard in sendInput() already
708
- // ensures no input is sent when the session is not running, so we just guard
709
- // the PTY write itself against a null process.
710
747
  if (record.ptyBridge) {
711
748
  record.ptyBridge.setPtyWrite((input) => {
712
749
  if (record.ptyProcess) {
@@ -714,7 +751,6 @@ export class ProcessManager extends EventEmitter {
714
751
  }
715
752
  });
716
753
  }
717
- // Emit started event AFTER PTY is fully set up so clients receive a consistent snapshot.
718
754
  this.emitEvent({ type: "started", sessionId: id, data: this.snapshot(record) });
719
755
  let initialInputSent = false;
720
756
  const sendInitialInput = () => {
@@ -727,27 +763,35 @@ export class ProcessManager extends EventEmitter {
727
763
  return;
728
764
  }
729
765
  process.stderr.write(`[wand] Sending initial input (${initialInput.length} chars)\n`);
730
- // Track initial input via bridge for Chat mode
731
766
  if (current.ptyBridge) {
732
767
  current.ptyBridge.onUserInput(initialInput);
733
768
  }
734
769
  current.ptyProcess.write(initialInput);
735
- // \n advances to a new line so subsequent output doesn't overwrite this input
736
770
  current.ptyProcess.write("\n");
737
771
  };
738
772
  child.onData((chunk) => {
739
773
  const rec = this.sessions.get(id);
740
774
  if (!rec)
741
775
  return;
742
- // Route chunk through PTY bridge
743
776
  if (rec.ptyBridge) {
744
777
  rec.ptyBridge.processChunk(chunk);
778
+ rec.output = rec.ptyBridge.getRawOutput();
779
+ }
780
+ else {
781
+ rec.output = appendWindow(rec.output, chunk, 200_000);
745
782
  }
746
- // Update legacy output field for backward compatibility
747
- rec.output = rec.ptyBridge?.getRawOutput() ?? "";
748
- // Log raw PTY output for analysis
749
783
  this.logger.appendPtyOutput(id, chunk);
750
- // Update Claude session ID from bridge
784
+ if (!rec.ptyBridge) {
785
+ this.emitEvent({
786
+ type: "output",
787
+ sessionId: id,
788
+ data: {
789
+ chunk,
790
+ output: rec.output,
791
+ permissionBlocked: this.isPermissionBlocked(rec),
792
+ },
793
+ });
794
+ }
751
795
  const bridgeSessionId = rec.ptyBridge?.getClaudeSessionId();
752
796
  if (bridgeSessionId && bridgeSessionId !== rec.claudeSessionId) {
753
797
  rec.claudeSessionId = bridgeSessionId;
@@ -767,8 +811,7 @@ export class ProcessManager extends EventEmitter {
767
811
  process.stderr.write(`[wand] Captured Claude project session ID: ${discoveredTaskId}\n`);
768
812
  }
769
813
  }
770
- // Auto-confirm for full-access mode (legacy path for non-Claude sessions without ptyBridge)
771
- if (rec.autoApprovePermissions && !rec.ptyBridge) {
814
+ if (rec.autoApprovePermissions && !rec.ptyBridge && rec.provider === "claude") {
772
815
  this.autoConfirmWithRecord(rec, chunk, child);
773
816
  }
774
817
  if (initialInput && !initialInputSent && chunk.includes("❯")) {
@@ -891,6 +934,9 @@ export class ProcessManager extends EventEmitter {
891
934
  }
892
935
  return this.snapshot(record);
893
936
  }
937
+ getPtyTranscript(id) {
938
+ return this.logger.readPtyOutput(id);
939
+ }
894
940
  sendInput(id, input, view, shortcutKey) {
895
941
  const record = this.mustGet(id);
896
942
  if (record.status !== "running") {
@@ -1059,7 +1105,7 @@ export class ProcessManager extends EventEmitter {
1059
1105
  this.logger.deleteSession(id);
1060
1106
  this.deleteClaudeCache(record);
1061
1107
  this.sessions.delete(id);
1062
- this.lastPersistedMessageCount.delete(id);
1108
+ this.lastPersistedMessageState.delete(id);
1063
1109
  this.lifecycleManager.unregister(id);
1064
1110
  }
1065
1111
  deleteClaudeCache(record) {
@@ -1097,10 +1143,13 @@ export class ProcessManager extends EventEmitter {
1097
1143
  return {
1098
1144
  id: record.id,
1099
1145
  sessionKind: "pty",
1146
+ provider: record.provider,
1100
1147
  runner: "pty",
1101
1148
  command: record.command,
1102
1149
  cwd: record.cwd,
1103
1150
  mode: record.mode,
1151
+ worktreeEnabled: record.worktreeEnabled ?? false,
1152
+ worktree: record.worktree ?? null,
1104
1153
  autonomyPolicy: record.autonomyPolicy,
1105
1154
  approvalPolicy: record.approvalPolicy,
1106
1155
  allowedScopes: record.allowedScopes,
@@ -1198,8 +1247,17 @@ export class ProcessManager extends EventEmitter {
1198
1247
  if (messages !== record.messages) {
1199
1248
  record.messages = messages;
1200
1249
  }
1201
- // Use lightweight metadata-only write (skips large messages JSON)
1202
- this.storage.saveSessionMetadata(this.snapshot(record));
1250
+ const snapshot = this.snapshot(record);
1251
+ const shouldSaveMessages = shouldPersistMessages(this.lastPersistedMessageState.get(record.id), messages);
1252
+ // Persist full messages as soon as the structured conversation changes so
1253
+ // service restarts cannot roll the session back to an older tail message.
1254
+ if (shouldSaveMessages) {
1255
+ this.storage.saveSession(snapshot);
1256
+ this.lastPersistedMessageState.set(record.id, getPersistedMessageState(messages));
1257
+ }
1258
+ else {
1259
+ this.storage.saveSessionMetadata(snapshot);
1260
+ }
1203
1261
  this.logger.saveMetadata(record.id, {
1204
1262
  id: record.id,
1205
1263
  command: record.command,
@@ -1211,13 +1269,8 @@ export class ProcessManager extends EventEmitter {
1211
1269
  resumedToSessionId: record.resumedToSessionId ?? null,
1212
1270
  autoRecovered: record.autoRecovered ?? false,
1213
1271
  });
1214
- // Save structured messages to file only when count changes
1215
- if (messages.length > 0) {
1216
- const lastCount = this.lastPersistedMessageCount.get(record.id) ?? 0;
1217
- if (messages.length !== lastCount) {
1218
- this.lastPersistedMessageCount.set(record.id, messages.length);
1219
- this.logger.saveMessages(record.id, messages);
1220
- }
1272
+ if (shouldSaveMessages) {
1273
+ this.logger.saveMessages(record.id, messages);
1221
1274
  }
1222
1275
  }
1223
1276
  /**
@@ -1526,11 +1579,13 @@ export class ProcessManager extends EventEmitter {
1526
1579
  // immediately with "command not found" — a silent exit before onExit is ready.
1527
1580
  return ["-lc", command];
1528
1581
  }
1529
- shouldAutoApprovePermissions(command, mode) {
1582
+ shouldAutoApprovePermissions(command, mode, provider) {
1583
+ if (provider !== "claude") {
1584
+ return false;
1585
+ }
1530
1586
  if (!/^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command)) {
1531
1587
  return false;
1532
1588
  }
1533
- // Root mode: always auto-approve (Claude CLI refuses --permission-mode bypassPermissions under root)
1534
1589
  if (isRunningAsRoot()) {
1535
1590
  return true;
1536
1591
  }
@@ -1542,26 +1597,29 @@ export class ProcessManager extends EventEmitter {
1542
1597
  }
1543
1598
  return false;
1544
1599
  }
1545
- processCommandForMode(command, mode) {
1600
+ processCommandForMode(command, mode, provider) {
1601
+ if (provider === "codex") {
1602
+ if (mode !== "full-access") {
1603
+ return command;
1604
+ }
1605
+ if (/--dangerously-bypass-approvals-and-sandbox(?:\s|$)/.test(command)) {
1606
+ return command;
1607
+ }
1608
+ return `${command} --dangerously-bypass-approvals-and-sandbox`;
1609
+ }
1546
1610
  const isClaudeCmd = /^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command);
1547
1611
  if (!isClaudeCmd)
1548
1612
  return command;
1549
1613
  let result = command;
1550
- // Skip if command already contains --permission-mode
1551
1614
  const hasPermFlag = /--permission-mode\s/.test(command);
1552
1615
  if (!hasPermFlag) {
1553
1616
  if (isRunningAsRoot()) {
1554
- // Root: Claude CLI refuses --permission-mode bypassPermissions.
1555
- // Use acceptEdits + --allowedTools to auto-approve all tool calls
1556
- // regardless of whether the target path is inside or outside the CWD.
1557
1617
  if (mode === "managed" || mode === "full-access" || mode === "auto-edit") {
1558
1618
  result += " --permission-mode acceptEdits";
1559
1619
  result += " --allowedTools Bash Edit Write Read Glob Grep NotebookEdit WebFetch WebSearch";
1560
1620
  }
1561
1621
  }
1562
1622
  else {
1563
- // Non-root: use bypassPermissions for full-access (skips all prompts),
1564
- // acceptEdits for auto-edit (auto-accepts file writes, prompts for bash).
1565
1623
  if (mode === "full-access" || mode === "managed") {
1566
1624
  result += " --permission-mode bypassPermissions";
1567
1625
  }
@@ -1570,15 +1628,11 @@ export class ProcessManager extends EventEmitter {
1570
1628
  }
1571
1629
  }
1572
1630
  }
1573
- // In managed mode, append a system prompt instructing Claude to act autonomously
1574
- // without asking the user for confirmation, since the user may not be monitoring.
1575
1631
  if (mode === "managed") {
1576
1632
  const autonomousPrompt = "You are running in a fully managed, autonomous mode. The user may not be available to respond to questions or confirmations in a timely manner. You MUST make all decisions independently — choose the best approach yourself instead of asking the user for preferences, confirmations, or clarifications. If multiple approaches are viable, pick the one you judge most appropriate and proceed. Never block on user input unless the task is fundamentally ambiguous and cannot be reasonably inferred. Be decisive and self-directed.";
1577
- // Escape single quotes for shell safety
1578
1633
  const escaped = autonomousPrompt.replace(/'/g, "'\\''");
1579
1634
  result += ` --append-system-prompt '${escaped}'`;
1580
1635
  }
1581
- // Append language preference if configured
1582
1636
  const language = this.config.language?.trim();
1583
1637
  if (language) {
1584
1638
  const langPrompt = `Please respond in ${language}. Use ${language} for all your explanations, comments, and conversational text.`;
@@ -6,64 +6,114 @@
6
6
  /** Strip ANSI escape sequences and control characters from raw PTY output. */
7
7
  export function stripAnsi(text) {
8
8
  return text
9
- .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI sequences
10
- .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "") // OSC sequences
11
- .replace(/\x1b[><=ePX^_]/g, "") // Single-char escapes
9
+ .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "")
10
+ .replace(/\x1b\[(\d+)C/g, (_match, count) => " ".repeat(Number(count) || 1))
11
+ .replace(/\x1b\[[0-9;?]*[AB]/g, "\n")
12
+ .replace(/\x1b\[[0-9;?]*[su]/g, "")
13
+ .replace(/\x1b\[[0-9;?]*[HfJKr]/g, "\n")
14
+ .replace(/\x1bM/g, "\n")
15
+ .replace(/\x1b\[[0-9;?]*[ST]/g, "\n")
16
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "")
17
+ .replace(/\x1b[><=ePX^_]/g, "")
18
+ .replace(/[\u00a0\u200b-\u200d\ufeff]/g, " ")
12
19
  // eslint-disable-next-line no-control-regex
13
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") // Control chars (keep \t \n \r)
14
- .replace(/\r\n?/g, "\n");
20
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
21
+ .replace(/\r\n?/g, "\n")
22
+ .replace(/[\t ]+\n/g, "\n")
23
+ .replace(/\n{3,}/g, "\n\n");
15
24
  }
16
25
  /** Lines considered as UI noise that should be excluded from chat view. */
17
26
  export function isNoiseLine(line) {
18
27
  if (!line)
19
28
  return false;
20
- if (line.startsWith("────"))
29
+ const trimmed = line.trim();
30
+ if (!trimmed)
31
+ return false;
32
+ if (trimmed.startsWith("────"))
33
+ return true;
34
+ if (trimmed === "❯" || trimmed === "›")
35
+ return true;
36
+ if (/^[╭╰│┌└┐┘├┤┬┴┼─═]{2,}$/.test(trimmed))
37
+ return true;
38
+ if (/^[▁▂▃▄▅▆▇█▔▕▏▐]+$/.test(trimmed))
39
+ return true;
40
+ if (trimmed.includes("esc to interrupt"))
41
+ return true;
42
+ if (trimmed.includes("Claude Code v"))
43
+ return true;
44
+ if (/^Sonnet\b/.test(trimmed))
45
+ return true;
46
+ if (trimmed.includes("Failed to install Anthropic"))
47
+ return true;
48
+ if (trimmed.includes("Claude Code has switched"))
49
+ return true;
50
+ if (trimmed.includes("? for shortcuts"))
51
+ return true;
52
+ if (trimmed.includes("Claude is waiting"))
53
+ return true;
54
+ if (trimmed.includes("[wand]"))
55
+ return true;
56
+ if (trimmed.startsWith("0;") || trimmed.startsWith("9;"))
57
+ return true;
58
+ if (trimmed.includes("ctrl+g"))
59
+ return true;
60
+ if (trimmed.includes("/effort"))
61
+ return true;
62
+ if (/^Using .* for .* session/.test(trimmed))
63
+ return true;
64
+ if (trimmed.startsWith("Press ") && trimmed.includes(" for"))
65
+ return true;
66
+ if (trimmed.startsWith("type ") && trimmed.includes(" to "))
67
+ return true;
68
+ if (trimmed.includes("auto mode is unavailable"))
69
+ return true;
70
+ if (/MCP server.*failed/i.test(trimmed))
21
71
  return true;
22
- if (line === "")
72
+ if (trimmed.includes("Germinating") || trimmed.includes("Doodling") || trimmed.includes("Brewing"))
23
73
  return true;
24
- if (line.includes("esc to interrupt"))
74
+ if (trimmed.includes("Permissions") && trimmed.includes("mode"))
25
75
  return true;
26
- if (line.includes("Claude Code v"))
76
+ if (trimmed.startsWith("●") && trimmed.includes("·"))
27
77
  return true;
28
- if (/^Sonnet\b/.test(line))
78
+ if (trimmed.startsWith("[>") || trimmed.startsWith("[<"))
29
79
  return true;
30
- if (line.includes("Failed to install Anthropic"))
80
+ if (trimmed.includes("Captured Claude session ID"))
31
81
  return true;
32
- if (line.includes("Claude Code has switched"))
82
+ if (/^>_\s*OpenAI Codex\b/.test(trimmed))
33
83
  return true;
34
- if (line.includes("? for shortcuts"))
84
+ if (/^OpenAI Codex\b/i.test(trimmed))
35
85
  return true;
36
- if (line.includes("Claude is waiting"))
86
+ if (/^(model|directory):\s+/i.test(trimmed))
37
87
  return true;
38
- if (line.includes("[wand]"))
88
+ if (/^(tip|context):\s+/i.test(trimmed))
39
89
  return true;
40
- if (line.startsWith("0;") || line.startsWith("9;"))
90
+ if (/^work(tree|space):\s+/i.test(trimmed))
41
91
  return true;
42
- if (line.includes("ctrl+g"))
92
+ if (/^(approvals?|sandbox|provider|session id):\s+/i.test(trimmed))
43
93
  return true;
44
- if (line.includes("/effort"))
94
+ if (/^(thinking|working)(\.\.\.|…)?$/i.test(trimmed))
45
95
  return true;
46
- if (/^Using .* for .* session/.test(line))
96
+ if (/^[•◦·]\s+Working\b/i.test(trimmed))
47
97
  return true;
48
- if (line.startsWith("Press ") && line.includes(" for"))
98
+ if (/^[•◦·]\s+(Running|Planning|Applying|Reading|Searching)\b/i.test(trimmed))
49
99
  return true;
50
- if (line.startsWith("type ") && line.includes(" to "))
100
+ if (/^[•◦·]\s+(Inspecting|Reviewing|Summarizing|Editing|Updating|Writing)\b/i.test(trimmed))
51
101
  return true;
52
- if (line.includes("auto mode is unavailable"))
102
+ if (/^[•◦·]\s+Completed\b/i.test(trimmed))
53
103
  return true;
54
- if (/MCP server.*failed/i.test(line))
104
+ if (/^(ctrl|enter|tab|shift|esc|alt)\+/i.test(trimmed))
55
105
  return true;
56
- if (line.includes("Germinating") || line.includes("Doodling") || line.includes("Brewing"))
106
+ if (/\b(open|close|toggle) (chat|terminal)\b/i.test(trimmed))
57
107
  return true;
58
- if (line.includes("Permissions") && line.includes("mode"))
108
+ if (/\b(approve|deny)\b.*\b(permission|approval)\b/i.test(trimmed))
59
109
  return true;
60
- if (line.startsWith("●") && line.includes("·"))
110
+ if (/^(use|press) .* (to|for) .*/i.test(trimmed))
61
111
  return true;
62
- if (line.startsWith("[>") || line.startsWith("[<"))
112
+ if (/^(?:token|context window|remaining context|conversation):\s+/i.test(trimmed))
63
113
  return true;
64
- if (line.includes("Captured Claude session ID"))
114
+ if (/^(?:cwd|path):\s+\//i.test(trimmed))
65
115
  return true;
66
- if (line.includes("/effort"))
116
+ if (/^[<>│┆╎].*[<>│┆╎]$/.test(trimmed) && trimmed.length < 8)
67
117
  return true;
68
118
  return false;
69
119
  }