@makerbi/remodex 2.4.0 → 3.1.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,9 +1,10 @@
1
1
  // FILE: desktop-ipc-shared.js
2
- // Purpose: Shared primitives for the Codex Desktop IPC modules (framing, socket path, JSON helpers).
2
+ // Purpose: Shared primitives for the Codex Desktop IPC modules (framing, socket path, envelopes, JSON helpers).
3
3
  // Layer: CLI helper
4
- // Exports: FRAME_HEADER_BYTES, MAX_FRAME_BYTES, cloneJSON, normalizeToken, readString, readText, requestIdKey, resolveDefaultIpcSocketPath, safeParseJSON, writeFrame
5
- // Depends on: os, path
4
+ // Exports: CLIENT_STATUS_CHANGED, DESKTOP_IPC_METHOD_VERSIONS, FRAME_HEADER_BYTES, MAX_FRAME_BYTES, buildIpcRequestEnvelope, createFrameReader, resolveDefaultIpcSocketPath, resolveIpcSocketPathCandidates, toSocketPathResolver, writeFrame, plus JSON/text helpers
5
+ // Depends on: crypto, fs, os, path
6
6
 
7
+ const fs = require("fs");
7
8
  const os = require("os");
8
9
  const path = require("path");
9
10
  const { createHash } = require("crypto");
@@ -21,16 +22,18 @@ const DESKTOP_IPC_METHOD_VERSIONS = new Map([
21
22
  [CLIENT_STATUS_CHANGED, 1],
22
23
  // Desktop pins thread-stream-state-changed at version 11 and drops mismatches.
23
24
  ["thread-stream-state-changed", 11],
25
+ ["thread-stream-following-changed", 1],
26
+ ["thread-stream-following-status-requested", 1],
24
27
  ["thread-archived", 2],
25
28
  ["thread-unarchived", 1],
26
- ["thread-read-state-changed", 1],
29
+ ["thread-read-state-changed", 2],
27
30
  ["thread-queued-followups-changed", 1],
28
31
  ["thread-follower-start-turn", 1],
29
32
  ["thread-follower-load-complete-history", 1],
30
33
  ["thread-follower-update-thread-settings", 1],
31
34
  ["thread-follower-compact-thread", 1],
32
35
  ["thread-follower-steer-turn", 1],
33
- ["thread-follower-interrupt-turn", 2],
36
+ ["thread-follower-interrupt-turn", 3],
34
37
  ["thread-follower-set-model-and-reasoning", 1],
35
38
  ["thread-follower-set-collaboration-mode", 1],
36
39
  ["thread-follower-edit-last-user-turn", 2],
@@ -517,13 +520,121 @@ function writeFrame(socket, payload, callback) {
517
520
  socket.write(Buffer.concat([header, body]), callback);
518
521
  }
519
522
 
520
- function resolveDefaultIpcSocketPath() {
523
+ // Codex moved its IPC bus into the Codex home directory; older desktop and CLI
524
+ // builds still expose it under the temp directory. Both are in the wild, so the
525
+ // bus is looked up in that order instead of being pinned to one location.
526
+ // Every participant on the bus — both clients and the fallback router's per-peer
527
+ // sockets — reads the same length-prefixed framing; only the dispatch differs.
528
+ // `onOverflow` owns the connection, so it decides how to drop a bogus peer.
529
+ function createFrameReader({ onFrame, onOverflow }) {
530
+ let buffer = Buffer.alloc(0);
531
+
532
+ return {
533
+ push(chunk) {
534
+ buffer = Buffer.concat([buffer, chunk]);
535
+ while (buffer.length >= FRAME_HEADER_BYTES) {
536
+ const frameLength = buffer.readUInt32LE(0);
537
+ if (frameLength > MAX_FRAME_BYTES) {
538
+ buffer = Buffer.alloc(0);
539
+ onOverflow?.();
540
+ return;
541
+ }
542
+ if (buffer.length < FRAME_HEADER_BYTES + frameLength) {
543
+ return;
544
+ }
545
+
546
+ const payload = buffer
547
+ .slice(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + frameLength)
548
+ .toString("utf8");
549
+ buffer = buffer.slice(FRAME_HEADER_BYTES + frameLength);
550
+ const envelope = safeParseJSON(payload);
551
+ if (envelope) {
552
+ onFrame(envelope);
553
+ }
554
+ }
555
+ },
556
+ reset() {
557
+ buffer = Buffer.alloc(0);
558
+ },
559
+ };
560
+ }
561
+
562
+ // Desktop validates the method version and refuses requests from an unknown
563
+ // sender, so both clients must build this envelope the same way.
564
+ function buildIpcRequestEnvelope({ requestId, method, params, clientId, initializing = false }) {
565
+ return {
566
+ type: "request",
567
+ requestId,
568
+ sourceClientId: initializing ? "initializing-client" : clientId || "remodex-bridge",
569
+ version: DESKTOP_IPC_METHOD_VERSIONS.get(method) || 1,
570
+ method,
571
+ params: params || {},
572
+ };
573
+ }
574
+
575
+ // Newer app-server builds omit every turn unless thread/read opts in explicitly.
576
+ // Internal hydration callers always need a complete baseline: publishing the
577
+ // metadata-only default as a Desktop snapshot would replace the visible history.
578
+ function buildCompleteThreadReadParams(threadId) {
579
+ return {
580
+ threadId: readString(threadId),
581
+ includeTurns: true,
582
+ };
583
+ }
584
+
585
+ function resolveIpcSocketPathCandidates() {
521
586
  if (process.platform === "win32") {
522
- return "\\\\.\\pipe\\codex-ipc";
587
+ return ["\\\\.\\pipe\\codex-ipc"];
523
588
  }
524
589
 
590
+ const configuredCodexHome = readString(process.env.CODEX_HOME);
591
+ const codexHome = configuredCodexHome
592
+ ? path.resolve(configuredCodexHome)
593
+ : path.join(os.homedir(), ".codex");
525
594
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
526
- return path.join(os.tmpdir(), "codex-ipc", `ipc-${uid}.sock`);
595
+ return [
596
+ path.join(codexHome, "ipc", "ipc.sock"),
597
+ path.join(os.tmpdir(), "codex-ipc", `ipc-${uid}.sock`),
598
+ ];
599
+ }
600
+
601
+ // Compatibility helper for callers that need one path synchronously. Transports
602
+ // use resolveIpcSocketPathCandidates instead because only a connection attempt
603
+ // can distinguish a live listener from a stale socket inode.
604
+ function resolveDefaultIpcSocketPath() {
605
+ const candidates = resolveIpcSocketPathCandidates();
606
+ for (const candidate of candidates) {
607
+ try {
608
+ if (fs.statSync(candidate).isSocket()) {
609
+ return candidate;
610
+ }
611
+ } catch {
612
+ // Missing or unreadable candidate: keep looking.
613
+ }
614
+ }
615
+ return candidates[0];
616
+ }
617
+
618
+ // The bus location is only known at connect time, so transports accept either a
619
+ // fixed path or a resolver and always ask again before reconnecting.
620
+ function toSocketPathResolver(socketPath) {
621
+ return typeof socketPath === "function" ? socketPath : () => socketPath;
622
+ }
623
+
624
+ // A socket inode can outlive its listener. Clients therefore need the full
625
+ // ordered candidate list so they can try the legacy bus after a refused current
626
+ // socket instead of mistaking file existence for liveness.
627
+ function toSocketPathCandidatesResolver(socketPath) {
628
+ const resolveSocketPath = toSocketPathResolver(socketPath);
629
+ return () => {
630
+ const resolved = resolveSocketPath();
631
+ const candidates = Array.isArray(resolved) ? resolved : [resolved];
632
+ return candidates.filter((candidate, index) => (
633
+ typeof candidate === "string"
634
+ && candidate.length > 0
635
+ && candidates.indexOf(candidate) === index
636
+ ));
637
+ };
527
638
  }
528
639
 
529
640
  // A source-neutral alias joins the same assistant prose when rollout events
@@ -556,7 +667,10 @@ function responseItemMessageText(payload) {
556
667
 
557
668
  module.exports = {
558
669
  CLIENT_STATUS_CHANGED,
670
+ buildCompleteThreadReadParams,
671
+ buildIpcRequestEnvelope,
559
672
  buildRemodexSourceItemKey,
673
+ createFrameReader,
560
674
  DESKTOP_IPC_METHOD_VERSIONS,
561
675
  FRAME_HEADER_BYTES,
562
676
  MAX_FRAME_BYTES,
@@ -574,9 +688,12 @@ module.exports = {
574
688
  responseItemMessageText,
575
689
  requestIdKey,
576
690
  resolveDefaultIpcSocketPath,
691
+ resolveIpcSocketPathCandidates,
577
692
  safeParseJSON,
578
693
  sanitizeUserInputEntries,
579
694
  sanitizeUserRoleItem,
695
+ toSocketPathCandidatesResolver,
696
+ toSocketPathResolver,
580
697
  visibleUserPromptText,
581
698
  visibleUserPromptFromInputEntries,
582
699
  writeFrame,
@@ -635,13 +635,11 @@ async function gitCreateBranch(cwd, params) {
635
635
  return { branch: name, status };
636
636
  }
637
637
 
638
- async function gitCreateWorktree(cwd, params) {
639
- const branch = normalizeCreatedBranchName(params.name);
640
- if (!branch) {
641
- throw gitError("missing_branch_name", "Branch name is required.");
642
- }
643
- await assertValidCreatedBranchName(cwd, branch);
644
-
638
+ // Resolves the shared creation context for both worktree flavors and enforces
639
+ // the invariants they have in common: a locally available base branch, and
640
+ // dirty changes that may only travel when the base branch is checked out.
641
+ // `destinationLabel` only shapes the dirty-transfer error message.
642
+ async function resolveWorktreeCreationContext(cwd, params, destinationLabel) {
645
643
  const branchResult = await gitBranches(cwd);
646
644
  const repoRoot = await resolveRepoRoot(cwd);
647
645
  const status = await gitStatus(cwd);
@@ -666,34 +664,27 @@ async function gitCreateWorktree(cwd, params) {
666
664
  const transferVerb = changeTransfer === "copy" ? "copy" : "move";
667
665
  throw gitError(
668
666
  "dirty_worktree_base_mismatch",
669
- `Uncommitted changes can ${transferVerb} into a new worktree only from ${currentBranchLabel}. Switch the base branch to match or clean up local changes first.`
667
+ `Uncommitted changes can ${transferVerb} into ${destinationLabel} only from ${currentBranchLabel}. Switch the base branch to match or clean up local changes first.`
670
668
  );
671
669
  }
672
670
 
673
- const existingWorktreePath = branchResult.worktreePathByBranch[branch];
674
- if (existingWorktreePath) {
675
- if (sameFilePath(existingWorktreePath, cwd)) {
676
- throw gitError(
677
- "branch_already_open_here",
678
- `Branch '${branch}' is already open in this project.`
679
- );
680
- }
681
-
682
- return {
683
- branch,
684
- worktreePath: existingWorktreePath,
685
- alreadyExisted: true,
686
- };
687
- }
688
-
689
- const branchExists = await localBranchExists(cwd, branch);
690
- if (branchExists) {
691
- throw gitError(
692
- "branch_exists",
693
- `Branch '${branch}' already exists locally. Choose another name or open that branch instead.`
694
- );
695
- }
671
+ return {
672
+ branchResult,
673
+ repoRoot,
674
+ projectRelativePath,
675
+ changeScope,
676
+ baseBranch,
677
+ changeTransfer,
678
+ canCarryLocalChanges,
679
+ };
680
+ }
696
681
 
682
+ // Shared creation core: allocates the managed path, carries local changes per
683
+ // the transfer mode, runs `git worktree add`, copies manifest files, and rolls
684
+ // everything back if any step after allocation fails. `branch` switches the
685
+ // checkout mode (`-b branch` vs `--detach`) and scopes cleanup/error mapping.
686
+ async function createWorktreeAtManagedPath(context, { branch = null, failureMessage }) {
687
+ const { repoRoot, changeScope, baseBranch, changeTransfer, canCarryLocalChanges } = context;
697
688
  const worktreeRootPath = allocateManagedWorktreePath(repoRoot);
698
689
  let handoffStashRef = null;
699
690
  let copiedLocalChangesPatch = "";
@@ -708,7 +699,8 @@ async function gitCreateWorktree(cwd, params) {
708
699
  }
709
700
  }
710
701
 
711
- await git(repoRoot, "worktree", "add", "-b", branch, worktreeRootPath, baseBranch);
702
+ const checkoutArgs = branch ? ["-b", branch] : ["--detach"];
703
+ await git(repoRoot, "worktree", "add", ...checkoutArgs, worktreeRootPath, baseBranch);
712
704
  didCreateWorktree = true;
713
705
 
714
706
  if (handoffStashRef) {
@@ -717,6 +709,7 @@ async function gitCreateWorktree(cwd, params) {
717
709
  if (copiedLocalChangesPatch) {
718
710
  await applyCopiedLocalChangesToWorktree(worktreeRootPath, copiedLocalChangesPatch);
719
711
  }
712
+ await copyWorktreeIncludeFiles(repoRoot, worktreeRootPath);
720
713
  } catch (err) {
721
714
  if (didCreateWorktree) {
722
715
  await cleanupManagedWorktree(repoRoot, worktreeRootPath, branch);
@@ -731,102 +724,90 @@ async function gitCreateWorktree(cwd, params) {
731
724
  if (err.message?.includes("invalid reference")) {
732
725
  throw gitError("missing_base_branch", `Base branch '${baseBranch}' does not exist.`);
733
726
  }
734
- if (err.message?.includes("already exists")) {
735
- throw gitError("branch_exists", `Branch '${branch}' already exists.`);
736
- }
737
- if (err.message?.includes("already used by worktree") || err.message?.includes("already checked out at")) {
738
- throw gitError(
739
- "branch_in_other_worktree",
740
- `Branch '${branch}' is already open in another worktree.`
741
- );
727
+ if (branch) {
728
+ if (err.message?.includes("already exists")) {
729
+ throw gitError("branch_exists", `Branch '${branch}' already exists.`);
730
+ }
731
+ if (err.message?.includes("already used by worktree") || err.message?.includes("already checked out at")) {
732
+ throw gitError(
733
+ "branch_in_other_worktree",
734
+ `Branch '${branch}' is already open in another worktree.`
735
+ );
736
+ }
742
737
  }
743
- throw gitError("create_worktree_failed", err.message || "Failed to create worktree.");
738
+ throw gitError("create_worktree_failed", err.message || failureMessage);
744
739
  }
745
740
 
746
- const worktreePath = scopedWorktreePath(worktreeRootPath, projectRelativePath);
747
741
  return {
748
- branch,
749
- worktreePath,
750
- alreadyExisted: false,
742
+ worktreeRootPath,
743
+ transferredChanges: Boolean(handoffStashRef || copiedLocalChangesPatch),
751
744
  };
752
745
  }
753
746
 
754
- async function gitCreateManagedWorktree(cwd, params) {
755
- const branchResult = await gitBranches(cwd);
756
- const repoRoot = await resolveRepoRoot(cwd);
757
- const status = await gitStatus(cwd);
758
- const projectRelativePath = resolveProjectRelativePath(cwd, repoRoot);
759
- const changeScope = await scopedProjectChanges(repoRoot, projectRelativePath);
760
- const baseBranch = resolveBaseBranchName(params.baseBranch, branchResult.defaultBranch);
761
- const changeTransfer = resolveWorktreeChangeTransfer(params.changeTransfer);
762
- if (!baseBranch) {
763
- throw gitError("missing_base_branch", "Base branch is required.");
764
- }
765
- if (!(await localBranchExists(cwd, baseBranch))) {
766
- throw gitError(
767
- "missing_base_branch",
768
- `Base branch '${baseBranch}' is not available locally. Create or check out that branch first.`
769
- );
770
- }
771
-
772
- const currentBranch = typeof status.branch === "string" ? status.branch.trim() : "";
773
- const canCarryLocalChanges = changeScope.dirty && !!currentBranch && currentBranch === baseBranch;
774
- if (changeScope.dirty && changeTransfer !== "none" && !canCarryLocalChanges) {
775
- const currentBranchLabel = currentBranch || "the current branch";
776
- const transferVerb = changeTransfer === "copy" ? "copy" : "move";
777
- throw gitError(
778
- "dirty_worktree_base_mismatch",
779
- `Uncommitted changes can ${transferVerb} into a managed worktree only from ${currentBranchLabel}. Switch the base branch to match or clean up local changes first.`
780
- );
747
+ async function gitCreateWorktree(cwd, params) {
748
+ const branch = normalizeCreatedBranchName(params.name);
749
+ if (!branch) {
750
+ throw gitError("missing_branch_name", "Branch name is required.");
781
751
  }
752
+ await assertValidCreatedBranchName(cwd, branch);
782
753
 
783
- const worktreeRootPath = allocateManagedWorktreePath(repoRoot);
784
- let handoffStashRef = null;
785
- let copiedLocalChangesPatch = "";
786
- let didCreateWorktree = false;
754
+ const context = await resolveWorktreeCreationContext(cwd, params, "a new worktree");
755
+ const { branchResult, repoRoot, projectRelativePath } = context;
787
756
 
788
- try {
789
- if (canCarryLocalChanges) {
790
- if (changeTransfer === "copy") {
791
- copiedLocalChangesPatch = await captureLocalChangesPatch(repoRoot, changeScope.pathspecArgs);
792
- } else if (changeTransfer === "move") {
793
- handoffStashRef = await stashChangesForWorktreeHandoff(repoRoot, changeScope.pathspecArgs);
794
- }
757
+ const existingWorktreePath = branchResult.worktreePathByBranch[branch];
758
+ if (existingWorktreePath) {
759
+ if (sameFilePath(existingWorktreePath, cwd)) {
760
+ throw gitError(
761
+ "branch_already_open_here",
762
+ `Branch '${branch}' is already open in this project.`
763
+ );
795
764
  }
796
765
 
797
- await git(repoRoot, "worktree", "add", "--detach", worktreeRootPath, baseBranch);
798
- didCreateWorktree = true;
799
-
800
- if (handoffStashRef) {
801
- await applyWorktreeHandoffStash(worktreeRootPath, handoffStashRef);
802
- }
803
- if (copiedLocalChangesPatch) {
804
- await applyCopiedLocalChangesToWorktree(worktreeRootPath, copiedLocalChangesPatch);
805
- }
806
- } catch (err) {
807
- if (didCreateWorktree) {
808
- await cleanupManagedWorktree(repoRoot, worktreeRootPath);
809
- } else {
810
- fs.rmSync(path.dirname(worktreeRootPath), { recursive: true, force: true });
766
+ // Backfill manifest files a worktree created before `.worktreeinclude`
767
+ // existed never received; the copy skips files the worktree already has.
768
+ const existingWorktreeRoot = await resolveRepoRoot(existingWorktreePath).catch(() => null);
769
+ if (existingWorktreeRoot) {
770
+ await copyWorktreeIncludeFiles(repoRoot, existingWorktreeRoot);
811
771
  }
812
772
 
813
- if (handoffStashRef) {
814
- await restoreWorktreeHandoffStash(repoRoot, handoffStashRef);
815
- }
773
+ return {
774
+ branch,
775
+ worktreePath: existingWorktreePath,
776
+ alreadyExisted: true,
777
+ };
778
+ }
816
779
 
817
- if (err.message?.includes("invalid reference")) {
818
- throw gitError("missing_base_branch", `Base branch '${baseBranch}' does not exist.`);
819
- }
820
- throw gitError("create_worktree_failed", err.message || "Failed to create managed worktree.");
780
+ if (await localBranchExists(cwd, branch)) {
781
+ throw gitError(
782
+ "branch_exists",
783
+ `Branch '${branch}' already exists locally. Choose another name or open that branch instead.`
784
+ );
821
785
  }
822
786
 
823
- const worktreePath = scopedWorktreePath(worktreeRootPath, projectRelativePath);
787
+ const { worktreeRootPath } = await createWorktreeAtManagedPath(context, {
788
+ branch,
789
+ failureMessage: "Failed to create worktree.",
790
+ });
791
+
824
792
  return {
825
- worktreePath,
793
+ branch,
794
+ worktreePath: scopedWorktreePath(worktreeRootPath, projectRelativePath),
826
795
  alreadyExisted: false,
827
- baseBranch,
796
+ };
797
+ }
798
+
799
+ async function gitCreateManagedWorktree(cwd, params) {
800
+ const context = await resolveWorktreeCreationContext(cwd, params, "a managed worktree");
801
+ const { worktreeRootPath, transferredChanges } = await createWorktreeAtManagedPath(context, {
802
+ failureMessage: "Failed to create managed worktree.",
803
+ });
804
+
805
+ return {
806
+ worktreePath: scopedWorktreePath(worktreeRootPath, context.projectRelativePath),
807
+ alreadyExisted: false,
808
+ baseBranch: context.baseBranch,
828
809
  headMode: "detached",
829
- transferredChanges: Boolean(handoffStashRef || copiedLocalChangesPatch),
810
+ transferredChanges,
830
811
  };
831
812
  }
832
813
 
@@ -2005,6 +1986,106 @@ async function rollbackFailedHandoffTransfer(cwd, pathspecArgs = []) {
2005
1986
  }
2006
1987
  }
2007
1988
 
1989
+ // Mirrors Codex Desktop's `.worktreeinclude`: files Git leaves behind (like an
1990
+ // ignored `.env`) listed in a repository-root manifest are copied into worktrees.
1991
+ // Best effort by design — a stale manifest entry must never fail creation.
1992
+ const WORKTREE_INCLUDE_FILE = ".worktreeinclude";
1993
+ const WORKTREE_INCLUDE_MAX_FILES = 512;
1994
+
1995
+ async function copyWorktreeIncludeFiles(repoRoot, worktreeRootPath) {
1996
+ const manifestPath = path.join(repoRoot, WORKTREE_INCLUDE_FILE);
1997
+ if (!fs.existsSync(manifestPath)) {
1998
+ return;
1999
+ }
2000
+
2001
+ const relativePaths = await listWorktreeIncludePaths(repoRoot, manifestPath);
2002
+ if (relativePaths.length === 0) {
2003
+ return;
2004
+ }
2005
+
2006
+ const canonicalWorktreeRoot = await fs.promises.realpath(worktreeRootPath).catch(() => null);
2007
+ if (!canonicalWorktreeRoot) {
2008
+ return;
2009
+ }
2010
+
2011
+ for (const relativePath of relativePaths) {
2012
+ const sourcePath = path.resolve(repoRoot, relativePath);
2013
+ const destinationPath = path.resolve(worktreeRootPath, relativePath);
2014
+ // `ls-files` output is repo-relative, but keep both sides pinned to their
2015
+ // roots so a hostile manifest entry cannot escape either tree.
2016
+ if (!isPathContainedIn(sourcePath, repoRoot) || !isPathContainedIn(destinationPath, worktreeRootPath)) {
2017
+ continue;
2018
+ }
2019
+ await copyWorktreeIncludeEntry(sourcePath, destinationPath, canonicalWorktreeRoot);
2020
+ }
2021
+ }
2022
+
2023
+ // Resolves the manifest to repo-relative untracked paths. The manifest uses
2024
+ // gitignore syntax, so let git itself do the matching: `--others -i
2025
+ // --exclude-from` lists every untracked file (ignored or not) that the
2026
+ // manifest patterns select, with full gitignore semantics — nested `.env`
2027
+ // matches, anchored `/foo` and `!negations` behave as documented, and a
2028
+ // malformed line cannot abort the listing the way a bad pathspec would.
2029
+ async function listWorktreeIncludePaths(repoRoot, manifestPath) {
2030
+ let listing = "";
2031
+ try {
2032
+ listing = await git(
2033
+ repoRoot,
2034
+ "ls-files",
2035
+ "--others",
2036
+ "-i",
2037
+ "-z",
2038
+ `--exclude-from=${manifestPath}`
2039
+ );
2040
+ } catch (err) {
2041
+ console.error(`[remodex] .worktreeinclude listing failed: ${err.message}`);
2042
+ return [];
2043
+ }
2044
+
2045
+ const relativePaths = listing.split("\0").filter(Boolean);
2046
+ if (relativePaths.length > WORKTREE_INCLUDE_MAX_FILES) {
2047
+ console.error(
2048
+ `[remodex] .worktreeinclude matched ${relativePaths.length} files; copying only the first ${WORKTREE_INCLUDE_MAX_FILES}. Narrow the manifest patterns.`
2049
+ );
2050
+ relativePaths.length = WORKTREE_INCLUDE_MAX_FILES;
2051
+ }
2052
+ return relativePaths;
2053
+ }
2054
+
2055
+ // Copies one manifest match into the worktree, skipping anything unsafe or
2056
+ // already present rather than failing the surrounding creation flow.
2057
+ async function copyWorktreeIncludeEntry(sourcePath, destinationPath, canonicalWorktreeRoot) {
2058
+ try {
2059
+ // Regular files only: copying through a symlinked source would smuggle
2060
+ // out-of-repo content (e.g. `.env -> ~/.ssh/key`) into every worktree.
2061
+ const sourceStats = await fs.promises.lstat(sourcePath);
2062
+ if (!sourceStats.isFile()) {
2063
+ return;
2064
+ }
2065
+ const destinationDirectory = path.dirname(destinationPath);
2066
+ await fs.promises.mkdir(destinationDirectory, { recursive: true });
2067
+ // Re-check containment on the real path: a checked-out symlinked parent
2068
+ // directory would otherwise let the copy write outside the worktree.
2069
+ const canonicalDestinationDirectory = await fs.promises.realpath(destinationDirectory);
2070
+ if (!isPathContainedIn(canonicalDestinationDirectory, canonicalWorktreeRoot)) {
2071
+ return;
2072
+ }
2073
+ // COPYFILE_EXCL keeps existing files intact: change transfer may already
2074
+ // have carried a dirty copy, and reused worktrees keep their own state.
2075
+ await fs.promises.copyFile(
2076
+ sourcePath,
2077
+ path.join(canonicalDestinationDirectory, path.basename(destinationPath)),
2078
+ fs.constants.COPYFILE_EXCL
2079
+ );
2080
+ } catch {
2081
+ // Skip unreadable or already-present entries; the worktree stays usable.
2082
+ }
2083
+ }
2084
+
2085
+ function isPathContainedIn(candidatePath, rootPath) {
2086
+ return candidatePath === rootPath || candidatePath.startsWith(rootPath + path.sep);
2087
+ }
2088
+
2008
2089
  async function cleanupManagedWorktree(repoRoot, worktreeRootPath, branchName = null) {
2009
2090
  try {
2010
2091
  await git(repoRoot, "worktree", "remove", "--force", worktreeRootPath);
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ const {
18
18
  runMacOSBridgeService,
19
19
  startMacOSBridgeService,
20
20
  stopMacOSBridgeService,
21
+ uninstallMacOSBridgeService,
21
22
  } = require("./macos-launch-agent");
22
23
 
23
24
  module.exports = {
@@ -32,6 +33,7 @@ module.exports = {
32
33
  runMacOSBridgeService,
33
34
  startMacOSBridgeService,
34
35
  stopMacOSBridgeService,
36
+ uninstallMacOSBridgeService,
35
37
  resetBridgePairing: resetBridgeTrustState,
36
38
  openLastActiveThread,
37
39
  watchThreadRollout,