@getpaseo/server 0.1.87 → 0.1.89

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.
Files changed (69) hide show
  1. package/dist/server/server/agent/agent-manager.js +4 -1
  2. package/dist/server/server/agent/agent-storage.d.ts +22 -22
  3. package/dist/server/server/agent/create-agent/create.d.ts +2 -0
  4. package/dist/server/server/agent/create-agent/create.js +16 -5
  5. package/dist/server/server/agent/create-agent-lifecycle-dispatch.d.ts +1 -0
  6. package/dist/server/server/agent/create-agent-lifecycle-dispatch.js +4 -0
  7. package/dist/server/server/agent/mcp-server.d.ts +1 -0
  8. package/dist/server/server/agent/mcp-server.js +137 -63
  9. package/dist/server/server/agent/mcp-shared.d.ts +1 -0
  10. package/dist/server/server/agent/providers/pi/agent.js +13 -0
  11. package/dist/server/server/agent/providers/pi/rpc-types.d.ts +3 -0
  12. package/dist/server/server/agent/timeline-projection.d.ts +17 -1
  13. package/dist/server/server/agent/timeline-projection.js +82 -17
  14. package/dist/server/server/auto-archive-on-merge/archive-if-safe.d.ts +1 -0
  15. package/dist/server/server/auto-archive-on-merge/archive-if-safe.js +6 -1
  16. package/dist/server/server/bootstrap.d.ts +7 -2
  17. package/dist/server/server/bootstrap.js +152 -115
  18. package/dist/server/server/config.js +41 -0
  19. package/dist/server/server/loop-service.d.ts +22 -22
  20. package/dist/server/server/package-version.d.ts +2 -2
  21. package/dist/server/server/paseo-worktree-archive-service.d.ts +2 -0
  22. package/dist/server/server/paseo-worktree-archive-service.js +28 -9
  23. package/dist/server/server/persisted-config.d.ts +89 -33
  24. package/dist/server/server/persisted-config.js +17 -0
  25. package/dist/server/server/pid-lock.d.ts +2 -2
  26. package/dist/server/server/schedule/cron.js +52 -5
  27. package/dist/server/server/script-health-monitor.d.ts +4 -4
  28. package/dist/server/server/script-health-monitor.js +6 -6
  29. package/dist/server/server/script-proxy.d.ts +2 -39
  30. package/dist/server/server/script-proxy.js +1 -244
  31. package/dist/server/server/script-route-branch-handler.d.ts +2 -2
  32. package/dist/server/server/script-route-branch-handler.js +3 -37
  33. package/dist/server/server/script-status-projection.d.ts +6 -4
  34. package/dist/server/server/script-status-projection.js +85 -37
  35. package/dist/server/server/service-proxy.d.ts +237 -0
  36. package/dist/server/server/service-proxy.js +714 -0
  37. package/dist/server/server/session.d.ts +11 -4
  38. package/dist/server/server/session.js +96 -99
  39. package/dist/server/server/websocket-server.d.ts +7 -4
  40. package/dist/server/server/websocket-server.js +9 -4
  41. package/dist/server/server/workspace-directory.js +4 -0
  42. package/dist/server/server/workspace-git-service.d.ts +3 -0
  43. package/dist/server/server/workspace-git-service.js +53 -12
  44. package/dist/server/server/workspace-registry.d.ts +2 -2
  45. package/dist/server/server/workspace-service-env.d.ts +1 -0
  46. package/dist/server/server/workspace-service-env.js +23 -18
  47. package/dist/server/server/worktree/commands.d.ts +2 -0
  48. package/dist/server/server/worktree/commands.js +4 -1
  49. package/dist/server/server/worktree-bootstrap.d.ts +4 -3
  50. package/dist/server/server/worktree-bootstrap.js +14 -13
  51. package/dist/server/server/worktree-core.d.ts +1 -0
  52. package/dist/server/server/worktree-core.js +2 -0
  53. package/dist/server/server/worktree-session.d.ts +6 -2
  54. package/dist/server/server/worktree-session.js +3 -0
  55. package/dist/server/services/github-service.d.ts +1 -0
  56. package/dist/server/services/github-service.js +7 -1
  57. package/dist/server/terminal/terminal-manager.js +11 -1
  58. package/dist/server/terminal/terminal-session-controller.d.ts +3 -1
  59. package/dist/server/terminal/terminal-session-controller.js +22 -12
  60. package/dist/server/terminal/terminal.d.ts +1 -0
  61. package/dist/server/terminal/terminal.js +34 -0
  62. package/dist/server/utils/checkout-git.d.ts +6 -2
  63. package/dist/server/utils/checkout-git.js +136 -54
  64. package/dist/server/utils/worktree.d.ts +17 -12
  65. package/dist/server/utils/worktree.js +39 -22
  66. package/dist/src/server/persisted-config.js +17 -0
  67. package/package.json +5 -5
  68. package/dist/server/utils/script-hostname.d.ts +0 -8
  69. package/dist/server/utils/script-hostname.js +0 -14
@@ -29,12 +29,15 @@ export class TerminalSessionController {
29
29
  this.hasBinaryChannel = options.hasBinaryChannel;
30
30
  this.isPathWithinRoot = options.isPathWithinRoot;
31
31
  this.sessionLogger = options.sessionLogger;
32
+ this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
32
33
  }
33
34
  start() {
34
35
  if (!this.terminalManager) {
35
36
  return;
36
37
  }
37
- this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => this.handleTerminalsChanged(event));
38
+ this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => {
39
+ void this.handleTerminalsChanged(event);
40
+ });
38
41
  }
39
42
  getMetrics() {
40
43
  return {
@@ -173,23 +176,25 @@ export class TerminalSessionController {
173
176
  ...(title ? { title } : {}),
174
177
  };
175
178
  }
176
- handleTerminalsChanged(event) {
177
- if (!this.subscribedDirectories.has(event.cwd)) {
178
- return;
179
+ async handleTerminalsChanged(event) {
180
+ // A terminal can live in a subdirectory of a subscribed workspace root (an
181
+ // agent can open one there). Deliver the change to every subscribed root at
182
+ // or above the terminal's cwd, keyed by that root, carrying the full
183
+ // aggregated list — so the client's cache replacement doesn't drop the
184
+ // terminals that live directly at the root.
185
+ const matchingRoots = Array.from(this.subscribedDirectories).filter((root) => this.isPathWithinRoot(root, event.cwd));
186
+ for (const root of matchingRoots) {
187
+ await this.emitTerminalsSnapshotForRoot(root);
179
188
  }
180
- this.emitTerminalsChangedSnapshot({
181
- cwd: event.cwd,
182
- terminals: event.terminals.map((terminal) => Object.assign({ id: terminal.id, name: terminal.name }, terminal.title ? { title: terminal.title } : {})),
183
- });
184
189
  }
185
190
  handleSubscribeTerminalsRequest(msg) {
186
191
  this.subscribedDirectories.add(msg.cwd);
187
- void this.emitInitialTerminalsChangedSnapshot(msg.cwd);
192
+ void this.emitTerminalsSnapshotForRoot(msg.cwd);
188
193
  }
189
194
  handleUnsubscribeTerminalsRequest(msg) {
190
195
  this.subscribedDirectories.delete(msg.cwd);
191
196
  }
192
- async emitInitialTerminalsChangedSnapshot(cwd) {
197
+ async emitTerminalsSnapshotForRoot(cwd) {
193
198
  if (!this.terminalManager || !this.subscribedDirectories.has(cwd)) {
194
199
  return;
195
200
  }
@@ -618,7 +623,9 @@ export class TerminalSessionController {
618
623
  }
619
624
  }
620
625
  async emitLegacySnapshot(activeStream, terminalManager) {
621
- const snapshot = await terminalManager.getTerminalState(activeStream.terminalId);
626
+ const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
627
+ includeWrapFlags: this.clientSupportsWrapReflow(),
628
+ });
622
629
  if (this.activeStreams.get(activeStream.slot) !== activeStream) {
623
630
  return { shouldContinue: false };
624
631
  }
@@ -637,7 +644,10 @@ export class TerminalSessionController {
637
644
  if (snapshotOptions === null) {
638
645
  return { shouldContinue: true };
639
646
  }
640
- const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, snapshotOptions);
647
+ const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
648
+ ...snapshotOptions,
649
+ includeWrapFlags: this.clientSupportsWrapReflow(),
650
+ });
641
651
  if (this.activeStreams.get(activeStream.slot) !== activeStream) {
642
652
  return { shouldContinue: false };
643
653
  }
@@ -13,6 +13,7 @@ export interface TerminalStateSnapshot {
13
13
  }
14
14
  export interface TerminalStateSnapshotOptions {
15
15
  scrollbackLines?: number;
16
+ includeWrapFlags?: boolean;
16
17
  }
17
18
  export interface TerminalSubscribeOptions {
18
19
  initialSnapshot?: "state" | "ready";
@@ -232,6 +232,32 @@ function extractScrollback(terminal, options) {
232
232
  }
233
233
  return scrollback;
234
234
  }
235
+ // xterm marks a line `isWrapped` when it is a continuation of the PREVIOUS line.
236
+ // The snapshot carries the inverse, tmux-style flag — "this row continues onto the
237
+ // next row" — so the client can rejoin and reflow logical lines. So row y's flag is
238
+ // whether line y+1 is a wrapped continuation.
239
+ function lineContinuesToNext(terminal, absoluteRow) {
240
+ return terminal.buffer.active.getLine(absoluteRow + 1)?.isWrapped === true;
241
+ }
242
+ function extractGridWrapped(terminal) {
243
+ const baseY = terminal.buffer.active.baseY;
244
+ const wrapped = [];
245
+ for (let row = 0; row < terminal.rows; row++) {
246
+ wrapped.push(lineContinuesToNext(terminal, baseY + row));
247
+ }
248
+ return wrapped;
249
+ }
250
+ function extractScrollbackWrapped(terminal, options) {
251
+ const scrollbackLines = terminal.buffer.active.baseY;
252
+ const startRow = typeof options?.scrollbackLines === "number"
253
+ ? Math.max(0, scrollbackLines - options.scrollbackLines)
254
+ : 0;
255
+ const wrapped = [];
256
+ for (let row = startRow; row < scrollbackLines; row++) {
257
+ wrapped.push(lineContinuesToNext(terminal, row));
258
+ }
259
+ return wrapped;
260
+ }
235
261
  function extractCursorState(terminal) {
236
262
  const coreService = terminal
237
263
  ._core?.coreService;
@@ -659,6 +685,14 @@ export async function createTerminal(options) {
659
685
  }),
660
686
  cursor: extractCursorState(terminal),
661
687
  ...(title ? { title } : {}),
688
+ ...(snapshotOptions?.includeWrapFlags
689
+ ? {
690
+ gridWrapped: extractGridWrapped(terminal),
691
+ scrollbackWrapped: extractScrollbackWrapped(terminal, {
692
+ scrollbackLines: snapshotOptions?.scrollbackLines,
693
+ }),
694
+ }
695
+ : {}),
662
696
  };
663
697
  }
664
698
  function getStateSnapshot(snapshotOptions) {
@@ -130,6 +130,7 @@ export interface MergeFromBaseOptions {
130
130
  }
131
131
  export interface CheckoutContext {
132
132
  paseoHome?: string;
133
+ worktreesRoot?: string;
133
134
  logger?: Pick<Logger, "trace">;
134
135
  facts?: CheckoutSnapshotFacts | null;
135
136
  }
@@ -159,8 +160,11 @@ export interface GitWorktreeEntry {
159
160
  branchRef?: string;
160
161
  isBare?: boolean;
161
162
  }
162
- /** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
163
- export declare function isPaseoWorktreePath(p: string): boolean;
163
+ /** Check whether a path is under Paseo's worktree root. */
164
+ export declare function isPaseoWorktreePath(p: string, options?: {
165
+ paseoHome?: string;
166
+ worktreesRoot?: string;
167
+ }): boolean;
164
168
  /** True when `child` is strictly inside `parent` (handles both `/` and `\`). */
165
169
  export declare function isDescendantPath(child: string, parent: string): boolean;
166
170
  export declare function parseWorktreeList(output: string): GitWorktreeEntry[];
@@ -7,7 +7,7 @@ import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js";
7
7
  import { GitHubAuthenticationError, GitHubCliMissingError, GitHubCommandError, createGitHubService, resolveGitHubRepo, } from "../services/github-service.js";
8
8
  import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
9
9
  import { runGitCommand } from "./run-git-command.js";
10
- import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
10
+ import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
11
11
  import { readPaseoWorktreeMetadata } from "./worktree-metadata.js";
12
12
  const READ_ONLY_GIT_ENV = {
13
13
  GIT_OPTIONAL_LOCKS: "0",
@@ -375,7 +375,7 @@ function buildGitDiffArgs(args) {
375
375
  return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra];
376
376
  }
377
377
  const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
378
- const TRACKED_MAX_CHANGED_LINES = 40000;
378
+ const TRACKED_DIFF_PER_FILE_MAX_CHARS = 1024 * 1024;
379
379
  const EMPTY_TREE_OBJECT_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
380
380
  function isUnbornHeadDiffError(error) {
381
381
  return (error instanceof Error &&
@@ -423,11 +423,56 @@ async function getTrackedNumstatByPath(cwd, refs, ignoreWhitespace = false) {
423
423
  }
424
424
  return stats;
425
425
  }
426
- function isTrackedDiffTooLarge(stat) {
427
- if (!stat || stat.isBinary) {
428
- return false;
426
+ function extractTrackedDiffMetadataPath(section, prefix) {
427
+ const line = section.split("\n").find((candidate) => candidate.startsWith(prefix));
428
+ if (!line) {
429
+ return null;
430
+ }
431
+ const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd();
432
+ if (path === "/dev/null") {
433
+ return null;
429
434
  }
430
- return stat.additions + stat.deletions > TRACKED_MAX_CHANGED_LINES;
435
+ return path.startsWith("a/") || path.startsWith("b/") ? path.slice(2) : path;
436
+ }
437
+ function extractTrackedDiffSectionPath(section) {
438
+ const firstLineEnd = section.indexOf("\n");
439
+ const firstLine = firstLineEnd === -1 ? section : section.slice(0, firstLineEnd);
440
+ const header = firstLine.startsWith("diff --git ") ? firstLine.slice("diff --git ".length) : "";
441
+ const prefixedPathMatch = header.match(/^a\/(.+) b\/(.+)$/);
442
+ if (prefixedPathMatch) {
443
+ return prefixedPathMatch[2] ?? null;
444
+ }
445
+ const metadataPath = extractTrackedDiffMetadataPath(section, "+++ ") ??
446
+ extractTrackedDiffMetadataPath(section, "--- ");
447
+ if (metadataPath) {
448
+ return metadataPath;
449
+ }
450
+ const pathMatch = header.match(/^(\S+)\s+(\S+)$/);
451
+ return pathMatch?.[2] ?? null;
452
+ }
453
+ function splitTrackedDiffSections(diffText) {
454
+ const starts = [];
455
+ const diffHeaderPattern = /^diff --git /gm;
456
+ let match;
457
+ while ((match = diffHeaderPattern.exec(diffText))) {
458
+ starts.push(match.index);
459
+ }
460
+ const sections = [];
461
+ for (let index = 0; index < starts.length; index += 1) {
462
+ const start = starts[index];
463
+ const end = starts[index + 1] ?? diffText.length;
464
+ const text = diffText.slice(start, end);
465
+ const path = extractTrackedDiffSectionPath(text);
466
+ if (!path) {
467
+ continue;
468
+ }
469
+ sections.push({
470
+ path,
471
+ text,
472
+ isTooLarge: text.length > TRACKED_DIFF_PER_FILE_MAX_CHARS,
473
+ });
474
+ }
475
+ return sections;
431
476
  }
432
477
  export class NotGitRepoError extends Error {
433
478
  constructor(cwd) {
@@ -525,7 +570,7 @@ export async function getMainRepoRoot(cwd) {
525
570
  });
526
571
  return getMainRepoRootFromCommonDir(cwd, resolveGitRevParsePath(cwd, commonDirOut));
527
572
  }
528
- async function getMainRepoRootFromCommonDir(cwd, commonDir) {
573
+ async function getMainRepoRootFromCommonDir(cwd, commonDir, context) {
529
574
  if (!commonDir) {
530
575
  throw new Error("Not in a git repository");
531
576
  }
@@ -538,13 +583,20 @@ async function getMainRepoRootFromCommonDir(cwd, commonDir) {
538
583
  envOverlay: READ_ONLY_GIT_ENV,
539
584
  });
540
585
  const worktrees = parseWorktreeList(worktreeOut);
541
- const nonBareNonPaseo = worktrees.filter((wt) => !wt.isBare && !isPaseoWorktreePath(wt.path));
586
+ const nonBareNonPaseo = worktrees.filter((wt) => !wt.isBare &&
587
+ !isPaseoWorktreePath(wt.path, {
588
+ paseoHome: context?.paseoHome,
589
+ worktreesRoot: context?.worktreesRoot,
590
+ }));
542
591
  const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
543
592
  const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
544
593
  return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
545
594
  }
546
- /** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
547
- export function isPaseoWorktreePath(p) {
595
+ /** Check whether a path is under Paseo's worktree root. */
596
+ export function isPaseoWorktreePath(p, options) {
597
+ if (options?.worktreesRoot || options?.paseoHome) {
598
+ return isDescendantPath(p, resolvePaseoWorktreesBaseRoot(options));
599
+ }
548
600
  return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p);
549
601
  }
550
602
  /** True when `child` is strictly inside `parent` (handles both `/` and `\`). */
@@ -624,7 +676,10 @@ async function getPaseoWorktreeForCwd(cwd, context, knownWorktreeRoot) {
624
676
  if (!/[\\/]worktrees[\\/]/.test(cwd)) {
625
677
  return { isPaseoOwnedWorktree: false };
626
678
  }
627
- const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome });
679
+ const ownership = await isPaseoOwnedWorktreeCwd(cwd, {
680
+ paseoHome: context?.paseoHome,
681
+ worktreesRoot: context?.worktreesRoot,
682
+ });
628
683
  if (!ownership.allowed) {
629
684
  return { isPaseoOwnedWorktree: false };
630
685
  }
@@ -1031,7 +1086,7 @@ export async function getCheckoutSnapshotFacts(cwd, context) {
1031
1086
  ? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot)
1032
1087
  : null;
1033
1088
  const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
1034
- const mainRepoRoot = await getMainRepoRootFromCommonDir(cwd, inspected.gitCommonDir).catch(() => null);
1089
+ const mainRepoRoot = await getMainRepoRootFromCommonDir(cwd, inspected.gitCommonDir, context).catch(() => null);
1035
1090
  let comparisonBaseRef = null;
1036
1091
  if (resolvedBaseRef &&
1037
1092
  inspected.currentBranch &&
@@ -1500,6 +1555,62 @@ async function processUntrackedChange(input) {
1500
1555
  status: "ok",
1501
1556
  });
1502
1557
  }
1558
+ async function processTrackedChanges(input) {
1559
+ const { cwd, refsForDiff, trackedChanges, ignoreWhitespace, appendDiff } = input;
1560
+ const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change]));
1561
+ const trackedNumstatByPath = trackedChanges.length > 0
1562
+ ? await getTrackedNumstatByPath(cwd, refsForDiff, ignoreWhitespace)
1563
+ : new Map();
1564
+ const trackedDiffPaths = [];
1565
+ const trackedPlaceholderByPath = new Map();
1566
+ for (const change of trackedChanges) {
1567
+ const stat = trackedNumstatByPath.get(change.path) ?? null;
1568
+ if (stat?.isBinary) {
1569
+ trackedPlaceholderByPath.set(change.path, { status: "binary", stat });
1570
+ continue;
1571
+ }
1572
+ trackedDiffPaths.push(change.path);
1573
+ }
1574
+ let trackedDiffText = "";
1575
+ let trackedDiffTruncated = false;
1576
+ if (trackedDiffPaths.length > 0) {
1577
+ const trackedDiffResult = await runGitCommand(buildGitDiffArgs({
1578
+ ignoreWhitespace,
1579
+ extra: [...getCheckoutDiffRefArgs(refsForDiff), "--", ...trackedDiffPaths],
1580
+ }), {
1581
+ cwd,
1582
+ envOverlay: READ_ONLY_GIT_ENV,
1583
+ maxOutputBytes: TOTAL_DIFF_MAX_BYTES,
1584
+ });
1585
+ trackedDiffTruncated = trackedDiffResult.truncated;
1586
+ const visibleTrackedDiffs = [];
1587
+ const sections = splitTrackedDiffSections(trackedDiffResult.stdout);
1588
+ for (let index = 0; index < sections.length; index += 1) {
1589
+ const section = sections[index];
1590
+ const isTruncatedTail = trackedDiffTruncated && index === sections.length - 1;
1591
+ if (section.isTooLarge || isTruncatedTail) {
1592
+ trackedPlaceholderByPath.set(section.path, {
1593
+ status: "too_large",
1594
+ stat: trackedNumstatByPath.get(section.path) ?? null,
1595
+ });
1596
+ continue;
1597
+ }
1598
+ visibleTrackedDiffs.push(section.text);
1599
+ }
1600
+ trackedDiffText = visibleTrackedDiffs.join("");
1601
+ appendDiff(trackedDiffText);
1602
+ if (trackedDiffTruncated) {
1603
+ appendDiff("# tracked diff truncated\n");
1604
+ }
1605
+ }
1606
+ return {
1607
+ trackedChangeByPath,
1608
+ trackedNumstatByPath,
1609
+ trackedPlaceholderByPath,
1610
+ trackedDiffText,
1611
+ trackedDiffTruncated,
1612
+ };
1613
+ }
1503
1614
  async function resolveCheckoutDiffRefs(cwd, compare, context) {
1504
1615
  if (compare.mode === "uncommitted") {
1505
1616
  return { baseRef: "HEAD", includeUntracked: true };
@@ -1565,42 +1676,13 @@ export async function getCheckoutDiff(cwd, compare, context) {
1565
1676
  };
1566
1677
  const trackedChanges = changes.filter((change) => !change.isUntracked);
1567
1678
  const untrackedChanges = changes.filter((change) => change.isUntracked === true);
1568
- const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change]));
1569
- const trackedNumstatByPath = trackedChanges.length > 0
1570
- ? await getTrackedNumstatByPath(cwd, effectiveRefsForDiff, ignoreWhitespace)
1571
- : new Map();
1572
- const trackedDiffPaths = [];
1573
- const trackedPlaceholderByPath = new Map();
1574
- for (const change of trackedChanges) {
1575
- const stat = trackedNumstatByPath.get(change.path) ?? null;
1576
- if (stat?.isBinary) {
1577
- trackedPlaceholderByPath.set(change.path, { status: "binary", stat });
1578
- continue;
1579
- }
1580
- if (isTrackedDiffTooLarge(stat)) {
1581
- trackedPlaceholderByPath.set(change.path, { status: "too_large", stat });
1582
- continue;
1583
- }
1584
- trackedDiffPaths.push(change.path);
1585
- }
1586
- let trackedDiffText = "";
1587
- let trackedDiffTruncated = false;
1588
- if (trackedDiffPaths.length > 0) {
1589
- const trackedDiffResult = await runGitCommand(buildGitDiffArgs({
1590
- ignoreWhitespace,
1591
- extra: [...getCheckoutDiffRefArgs(effectiveRefsForDiff), "--", ...trackedDiffPaths],
1592
- }), {
1593
- cwd,
1594
- envOverlay: READ_ONLY_GIT_ENV,
1595
- maxOutputBytes: TOTAL_DIFF_MAX_BYTES,
1596
- });
1597
- trackedDiffText = trackedDiffResult.stdout;
1598
- trackedDiffTruncated = trackedDiffResult.truncated;
1599
- appendDiff(trackedDiffText);
1600
- if (trackedDiffTruncated) {
1601
- appendDiff("# tracked diff truncated\n");
1602
- }
1603
- }
1679
+ const trackedDiff = await processTrackedChanges({
1680
+ cwd,
1681
+ refsForDiff: effectiveRefsForDiff,
1682
+ trackedChanges,
1683
+ ignoreWhitespace,
1684
+ appendDiff,
1685
+ });
1604
1686
  const appendTrackedPlaceholderComment = (change, status) => {
1605
1687
  if (status === "binary") {
1606
1688
  appendDiff(`# ${change.path}: binary diff omitted\n`);
@@ -1612,11 +1694,11 @@ export async function getCheckoutDiff(cwd, compare, context) {
1612
1694
  await appendStructuredTrackedDiffs({
1613
1695
  cwd,
1614
1696
  trackedChanges,
1615
- trackedChangeByPath,
1616
- trackedNumstatByPath,
1617
- trackedPlaceholderByPath,
1618
- trackedDiffText,
1619
- trackedDiffTruncated,
1697
+ trackedChangeByPath: trackedDiff.trackedChangeByPath,
1698
+ trackedNumstatByPath: trackedDiff.trackedNumstatByPath,
1699
+ trackedPlaceholderByPath: trackedDiff.trackedPlaceholderByPath,
1700
+ trackedDiffText: trackedDiff.trackedDiffText,
1701
+ trackedDiffTruncated: trackedDiff.trackedDiffTruncated,
1620
1702
  refsForDiff: effectiveRefsForDiff,
1621
1703
  ignoreWhitespace,
1622
1704
  structured,
@@ -1626,7 +1708,7 @@ export async function getCheckoutDiff(cwd, compare, context) {
1626
1708
  }
1627
1709
  else {
1628
1710
  for (const change of trackedChanges) {
1629
- const placeholder = trackedPlaceholderByPath.get(change.path);
1711
+ const placeholder = trackedDiff.trackedPlaceholderByPath.get(change.path);
1630
1712
  if (placeholder) {
1631
1713
  appendTrackedPlaceholderComment(change, placeholder.status);
1632
1714
  }
@@ -83,6 +83,10 @@ export interface PaseoWorktreeOwnership {
83
83
  worktreeRoot?: string;
84
84
  worktreePath?: string;
85
85
  }
86
+ export interface WorktreeRootOptions {
87
+ paseoHome?: string;
88
+ worktreesRoot?: string;
89
+ }
86
90
  export type WorktreeSource = {
87
91
  kind: "branch-off";
88
92
  baseBranch: string;
@@ -104,11 +108,13 @@ export interface CreateWorktreeOptions {
104
108
  source: WorktreeSource;
105
109
  runSetup: boolean;
106
110
  paseoHome?: string;
111
+ worktreesRoot?: string;
107
112
  }
108
113
  interface ResolveExistingWorktreeForSlugOptions {
109
114
  slug: string;
110
115
  repoRoot: string;
111
116
  paseoHome?: string;
117
+ worktreesRoot?: string;
112
118
  }
113
119
  export declare class BranchAlreadyCheckedOutError extends Error {
114
120
  readonly branchName: string;
@@ -164,32 +170,31 @@ export declare function runWorktreeTeardownCommands(options: {
164
170
  */
165
171
  export declare function getGitCommonDir(cwd: string): Promise<string>;
166
172
  export declare function deriveWorktreeProjectHash(cwd: string): Promise<string>;
167
- export declare function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string>;
168
- export declare function computeWorktreePath(cwd: string, slug: string, paseoHome?: string): Promise<string>;
169
- export declare function isPaseoOwnedWorktreeCwd(cwd: string, options?: {
170
- paseoHome?: string;
171
- }): Promise<PaseoWorktreeOwnership>;
172
- export declare function listPaseoWorktrees({ cwd, paseoHome, }: {
173
+ export declare function resolvePaseoWorktreesBaseRoot(options?: WorktreeRootOptions): string;
174
+ export declare function getPaseoWorktreesRoot(cwd: string, paseoHome?: string, worktreesRoot?: string): Promise<string>;
175
+ export declare function computeWorktreePath(cwd: string, slug: string, paseoHome?: string, worktreesRoot?: string): Promise<string>;
176
+ export declare function isPaseoOwnedWorktreeCwd(cwd: string, options?: WorktreeRootOptions): Promise<PaseoWorktreeOwnership>;
177
+ export declare function listPaseoWorktrees({ cwd, paseoHome, worktreesRoot, }: {
173
178
  cwd: string;
174
179
  paseoHome?: string;
180
+ worktreesRoot?: string;
175
181
  }): Promise<PaseoWorktreeInfo[]>;
176
- export declare function resolveExistingWorktreeForSlug({ slug, repoRoot, paseoHome, }: ResolveExistingWorktreeForSlugOptions): Promise<WorktreeConfig | null>;
177
- export declare function resolvePaseoWorktreeRootForCwd(cwd: string, options?: {
178
- paseoHome?: string;
179
- }): Promise<{
182
+ export declare function resolveExistingWorktreeForSlug({ slug, repoRoot, paseoHome, worktreesRoot, }: ResolveExistingWorktreeForSlugOptions): Promise<WorktreeConfig | null>;
183
+ export declare function resolvePaseoWorktreeRootForCwd(cwd: string, options?: WorktreeRootOptions): Promise<{
180
184
  repoRoot: string;
181
185
  worktreeRoot: string;
182
186
  worktreePath: string;
183
187
  } | null>;
184
- export declare function deletePaseoWorktree({ cwd, worktreePath, worktreeSlug, worktreesRoot, paseoHome, }: {
188
+ export declare function deletePaseoWorktree({ cwd, worktreePath, worktreeSlug, worktreesRoot, paseoHome, worktreesBaseRoot, }: {
185
189
  cwd: string | null;
186
190
  worktreePath?: string;
187
191
  worktreeSlug?: string;
188
192
  worktreesRoot?: string;
189
193
  paseoHome?: string;
194
+ worktreesBaseRoot?: string;
190
195
  }): Promise<void>;
191
196
  /**
192
197
  * Create a git worktree with proper naming conventions
193
198
  */
194
- export declare const createWorktree: ({ cwd, source, worktreeSlug, runSetup, paseoHome, }: CreateWorktreeOptions) => Promise<WorktreeConfig>;
199
+ export declare const createWorktree: ({ cwd, source, worktreeSlug, runSetup, paseoHome, worktreesRoot, }: CreateWorktreeOptions) => Promise<WorktreeConfig>;
195
200
  //# sourceMappingURL=worktree.d.ts.map
@@ -2,7 +2,7 @@ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
4
4
  import { copyFile, rm, stat } from "fs/promises";
5
- import { join, basename, dirname, resolve, sep } from "path";
5
+ import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
6
6
  import net from "node:net";
7
7
  import { createHash } from "node:crypto";
8
8
  import stripAnsi from "strip-ansi";
@@ -17,6 +17,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
17
17
  import { createExternalProcessEnv } from "../server/paseo-env.js";
18
18
  import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
19
19
  import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
20
+ import { expandTilde } from "./path.js";
20
21
  export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
21
22
  const execFileAsync = promisify(execFile);
22
23
  const READ_ONLY_GIT_ENV = {
@@ -529,14 +530,26 @@ export async function deriveWorktreeProjectHash(cwd) {
529
530
  return deriveShortAlphanumericHash(normalizePathForOwnership(cwd));
530
531
  }
531
532
  }
532
- export async function getPaseoWorktreesRoot(cwd, paseoHome) {
533
- const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
533
+ export function resolvePaseoWorktreesBaseRoot(options) {
534
+ if (options?.worktreesRoot) {
535
+ const expandedRoot = expandTilde(options.worktreesRoot);
536
+ if (isAbsolute(expandedRoot)) {
537
+ return resolve(expandedRoot);
538
+ }
539
+ const home = options.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome();
540
+ return resolve(home, expandedRoot);
541
+ }
542
+ const home = options?.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome();
543
+ return join(home, "worktrees");
544
+ }
545
+ export async function getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot) {
546
+ const baseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot });
534
547
  const projectHash = await deriveWorktreeProjectHash(cwd);
535
- return join(home, "worktrees", projectHash);
548
+ return join(baseRoot, projectHash);
536
549
  }
537
- export async function computeWorktreePath(cwd, slug, paseoHome) {
538
- const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
539
- return join(worktreesRoot, slug);
550
+ export async function computeWorktreePath(cwd, slug, paseoHome, worktreesRoot) {
551
+ const projectWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot);
552
+ return join(projectWorktreesRoot, slug);
540
553
  }
541
554
  function normalizePathForOwnership(input) {
542
555
  try {
@@ -565,9 +578,9 @@ export async function isPaseoOwnedWorktreeCwd(cwd, options) {
565
578
  catch {
566
579
  // ignore
567
580
  }
568
- const paseoHome = options?.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome();
569
- const paseoWorktreesPrefix = normalizePathForOwnership(join(paseoHome, "worktrees")) + sep;
570
- // Ownership is defined by the path living under $PASEO_HOME/worktrees/<hash>/<slug>[/...].
581
+ const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
582
+ const paseoWorktreesPrefix = normalizePathForOwnership(worktreesBaseRoot) + sep;
583
+ // Ownership is defined by the path living under <worktrees-root>/<hash>/<slug>[/...].
571
584
  // The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
572
585
  // path shape alone is sufficient proof of ownership, even when git has already
573
586
  // forgotten about the worktree.
@@ -587,7 +600,7 @@ export async function isPaseoOwnedWorktreeCwd(cwd, options) {
587
600
  worktreePath: resolvedCwd,
588
601
  };
589
602
  }
590
- const worktreesRoot = join(paseoHome, "worktrees", parts[0]);
603
+ const worktreesRoot = join(worktreesBaseRoot, parts[0]);
591
604
  return {
592
605
  allowed: true,
593
606
  ...(repoRoot !== undefined ? { repoRoot } : {}),
@@ -639,22 +652,23 @@ function resolveWorktreeCreatedAtIso(worktreePath) {
639
652
  return new Date(0).toISOString();
640
653
  }
641
654
  }
642
- export async function listPaseoWorktrees({ cwd, paseoHome, }) {
643
- const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
655
+ export async function listPaseoWorktrees({ cwd, paseoHome, worktreesRoot, }) {
656
+ const projectWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot);
644
657
  const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
645
658
  cwd,
646
659
  envOverlay: READ_ONLY_GIT_ENV,
647
660
  });
648
- const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep;
661
+ const rootPrefix = normalizePathForOwnership(projectWorktreesRoot) + sep;
649
662
  return parseWorktreeList(stdout)
650
663
  .map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
651
664
  .filter((entry) => entry.path.startsWith(rootPrefix))
652
665
  .map((entry) => Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }));
653
666
  }
654
- export async function resolveExistingWorktreeForSlug({ slug, repoRoot, paseoHome, }) {
667
+ export async function resolveExistingWorktreeForSlug({ slug, repoRoot, paseoHome, worktreesRoot, }) {
655
668
  const worktrees = await listPaseoWorktrees({
656
669
  cwd: repoRoot,
657
670
  paseoHome,
671
+ worktreesRoot,
658
672
  });
659
673
  const slugSuffix = `${sep}${slug}`;
660
674
  const existingWorktree = worktrees.find((worktree) => worktree.path.endsWith(slugSuffix));
@@ -682,7 +696,7 @@ export async function resolvePaseoWorktreeRootForCwd(cwd, options) {
682
696
  catch {
683
697
  return null;
684
698
  }
685
- const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome);
699
+ const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome, options?.worktreesRoot);
686
700
  const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
687
701
  let worktreeRoot = null;
688
702
  try {
@@ -705,6 +719,7 @@ export async function resolvePaseoWorktreeRootForCwd(cwd, options) {
705
719
  const knownWorktrees = await listPaseoWorktrees({
706
720
  cwd,
707
721
  paseoHome: options?.paseoHome,
722
+ worktreesRoot: options?.worktreesRoot,
708
723
  });
709
724
  const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot);
710
725
  if (!match) {
@@ -716,7 +731,7 @@ export async function resolvePaseoWorktreeRootForCwd(cwd, options) {
716
731
  worktreePath: match.path,
717
732
  };
718
733
  }
719
- export async function deletePaseoWorktree({ cwd, worktreePath, worktreeSlug, worktreesRoot, paseoHome, }) {
734
+ export async function deletePaseoWorktree({ cwd, worktreePath, worktreeSlug, worktreesRoot, paseoHome, worktreesBaseRoot, }) {
720
735
  if (!worktreePath && !worktreeSlug) {
721
736
  throw new Error("worktreePath or worktreeSlug is required");
722
737
  }
@@ -728,7 +743,7 @@ export async function deletePaseoWorktree({ cwd, worktreePath, worktreeSlug, wor
728
743
  resolvedWorktreesRoot = worktreesRoot;
729
744
  }
730
745
  else if (cwd) {
731
- resolvedWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
746
+ resolvedWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesBaseRoot);
732
747
  }
733
748
  else {
734
749
  throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree");
@@ -736,8 +751,10 @@ export async function deletePaseoWorktree({ cwd, worktreePath, worktreeSlug, wor
736
751
  const resolvedRoot = normalizePathForOwnership(resolvedWorktreesRoot) + sep;
737
752
  const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug);
738
753
  const resolvedRequested = normalizePathForOwnership(requestedPath);
739
- const resolvedWorktree = (await resolvePaseoWorktreeRootForCwd(requestedPath, { paseoHome }))?.worktreePath ??
740
- resolvedRequested;
754
+ const resolvedWorktree = (await resolvePaseoWorktreeRootForCwd(requestedPath, {
755
+ paseoHome,
756
+ worktreesRoot: worktreesBaseRoot,
757
+ }))?.worktreePath ?? resolvedRequested;
741
758
  if (!resolvedWorktree.startsWith(resolvedRoot)) {
742
759
  throw new Error("Refusing to delete non-Paseo worktree");
743
760
  }
@@ -812,9 +829,9 @@ async function removeDirectoryWithRetries(path) {
812
829
  /**
813
830
  * Create a git worktree with proper naming conventions
814
831
  */
815
- export const createWorktree = async ({ cwd, source, worktreeSlug, runSetup, paseoHome, }) => {
832
+ export const createWorktree = async ({ cwd, source, worktreeSlug, runSetup, paseoHome, worktreesRoot, }) => {
816
833
  const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
817
- let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), worktreeSlug);
834
+ let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
818
835
  mkdirSync(dirname(worktreePath), { recursive: true });
819
836
  // Also handle worktree path collision
820
837
  let finalWorktreePath = worktreePath;