windows-development-sandbox 1.4.0 → 1.4.1

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 (73) hide show
  1. package/.sandbox-config.example.json +2 -1
  2. package/config.example.json +1 -0
  3. package/dist/cli/boot-progress.d.ts +22 -0
  4. package/dist/cli/boot-progress.js +59 -0
  5. package/dist/cli/commands/launch.d.ts +5 -2
  6. package/dist/cli/commands/launch.js +77 -22
  7. package/dist/cli/commands/maintenance.js +36 -3
  8. package/dist/cli/commands/project.d.ts +10 -6
  9. package/dist/cli/commands/project.js +42 -3
  10. package/dist/cli/commands/restart.js +17 -7
  11. package/dist/cli/commands/sessions.js +2 -1
  12. package/dist/cli/manifest.js +1 -1
  13. package/dist/cli/progress.d.ts +15 -0
  14. package/dist/cli/progress.js +52 -0
  15. package/dist/core/config.js +1 -1
  16. package/dist/core/local-config.d.ts +16 -1
  17. package/dist/core/local-config.js +15 -9
  18. package/dist/core/project-policy.d.ts +50 -3
  19. package/dist/core/project-policy.js +90 -4
  20. package/dist/core/schema/config.d.ts +3 -2
  21. package/dist/core/schema/config.js +1 -1
  22. package/dist/core/schema/projects.d.ts +1 -0
  23. package/dist/core/schema/projects.js +1 -0
  24. package/dist/core/schema/sandbox.d.ts +1 -1
  25. package/dist/core/schema/sandbox.js +1 -1
  26. package/dist/core/types.d.ts +18 -1
  27. package/dist/core/workspace.d.ts +21 -4
  28. package/dist/core/workspace.js +87 -26
  29. package/dist/guest/env.d.ts +6 -2
  30. package/dist/guest/env.js +15 -3
  31. package/dist/guest/main.js +5 -1
  32. package/dist/guest/toolchains/index.js +29 -3
  33. package/dist/host/channel.d.ts +2 -2
  34. package/dist/host/channel.js +3 -2
  35. package/dist/host/generate.d.ts +1 -1
  36. package/dist/host/generate.js +13 -6
  37. package/dist/host/join.d.ts +7 -0
  38. package/dist/host/join.js +23 -9
  39. package/dist/host/launch/mappings.d.ts +7 -2
  40. package/dist/host/launch/mappings.js +64 -18
  41. package/dist/host/launch/projects.d.ts +2 -0
  42. package/dist/host/launch/projects.js +11 -5
  43. package/dist/host/projects.d.ts +25 -2
  44. package/dist/host/projects.js +54 -5
  45. package/dist/runtime/daemon.js +71 -9
  46. package/dist/runtime/db.js +33 -3
  47. package/dist/runtime/main.js +191 -30
  48. package/dist/runtime/netproxy.js +15 -3
  49. package/dist/runtime/view.js +262 -128
  50. package/dist/selftest/channel.test.js +38 -2
  51. package/dist/selftest/fixtures.d.ts +2 -1
  52. package/dist/selftest/index.js +2 -1
  53. package/dist/selftest/live-terminal.test.d.ts +2 -0
  54. package/dist/selftest/live-terminal.test.js +169 -0
  55. package/dist/selftest/local-config.test.js +28 -3
  56. package/dist/selftest/mirror.test.js +36 -7
  57. package/dist/selftest/projects.test.js +76 -7
  58. package/dist/shared/console-input.d.ts +11 -0
  59. package/dist/shared/console-input.js +61 -0
  60. package/dist/shared/download.d.ts +3 -0
  61. package/dist/shared/download.js +25 -2
  62. package/dist/shared/guest-paths.d.ts +27 -0
  63. package/dist/shared/guest-paths.js +68 -0
  64. package/dist/shared/projects.d.ts +27 -4
  65. package/dist/shared/projects.js +72 -13
  66. package/dist/shared/pty-client.d.ts +17 -0
  67. package/dist/shared/pty-client.js +65 -8
  68. package/dist/shared/schema.d.ts +24 -0
  69. package/dist/shared/schema.js +12 -0
  70. package/package.json +1 -1
  71. package/sandbox/ptyhost.cs +60 -3
  72. package/schemas/config.schema.json +15 -1
  73. package/schemas/sandbox-config.schema.json +4 -3
@@ -17,6 +17,9 @@ export interface Checksums {
17
17
  }
18
18
  /** downloads.requireChecksum: from then on download() refuses anything it has no expected hash for. The guest sets it from session.json. */
19
19
  export declare const setChecksumRequired: (on: boolean) => void;
20
+ /** Who wants coarse download progress: called as a download starts (in the caller's async context), returns its reporter. */
21
+ export type DownloadWatcher = () => ((fraction: number) => void) | null;
22
+ export declare const watchDownloads: (watcher: DownloadWatcher | null) => void;
20
23
  /**
21
24
  * Streams a download to disk, hashing as it goes: big archives (PostgreSQL is ~330 MB) never sit in memory, and the
22
25
  * event loop stays free so other toolchains keep downloading meanwhile. A mismatch (or any failure) leaves no file behind.
@@ -46,6 +46,8 @@ export const fetchText = async (url) => await (await fetchRetry(url)).text();
46
46
  let checksumRequired = false;
47
47
  /** downloads.requireChecksum: from then on download() refuses anything it has no expected hash for. The guest sets it from session.json. */
48
48
  export const setChecksumRequired = (on) => { checksumRequired = on; };
49
+ let downloadWatcher = null;
50
+ export const watchDownloads = (watcher) => { downloadWatcher = watcher; };
49
51
  /**
50
52
  * Streams a download to disk, hashing as it goes: big archives (PostgreSQL is ~330 MB) never sit in memory, and the
51
53
  * event loop stays free so other toolchains keep downloading meanwhile. A mismatch (or any failure) leaves no file behind.
@@ -64,8 +66,29 @@ export const download = async (url, destination, expected = null) => {
64
66
  const want = sums[kind]?.trim().toLowerCase();
65
67
  return want ? [{ want, hash: crypto.createHash(kind) }] : [];
66
68
  });
67
- const tap = new Transform({ transform(chunk, _encoding, done) { for (const c of checks)
68
- c.hash.update(chunk); done(null, chunk); } });
69
+ // Progress for the boot screen: every 10% or 5 s at most, never per chunk; called here so it knows who is downloading.
70
+ const size = Number(response.headers.get('content-length')) || 0;
71
+ const report = size > 0 ? downloadWatcher?.() ?? null : null;
72
+ let received = 0;
73
+ let tenth = 0;
74
+ let reportedAt = Date.now();
75
+ const tap = new Transform({ transform(chunk, _encoding, done) {
76
+ for (const c of checks)
77
+ c.hash.update(chunk);
78
+ if (report) {
79
+ received += chunk.length;
80
+ const now = Math.floor((received * 10) / size);
81
+ if (now !== tenth || Date.now() - reportedAt > 5000) {
82
+ tenth = now;
83
+ reportedAt = Date.now();
84
+ try {
85
+ report(Math.min(1, received / size));
86
+ }
87
+ catch { /* progress never breaks a download */ }
88
+ }
89
+ }
90
+ done(null, chunk);
91
+ } });
69
92
  // Written to <destination>.part and moved into place only once complete and verified, so an interrupted download never
70
93
  // leaves a partial file under the real name.
71
94
  const partial = destination + '.part';
@@ -11,6 +11,33 @@ export declare const guestDrive: (guest: string) => string | null;
11
11
  * no '..' or empty segments, no characters Windows rejects in paths.
12
12
  */
13
13
  export declare const isValidGuestPath: (guest: string) => boolean;
14
+ /** Where a mapping lands when the path it asks for (or the PC path) cannot be used inside the sandbox. */
15
+ export declare const MAPPED_ROOT = "C:\\Mapped";
16
+ /** A guest path as typed, tidied: '/' -> '\', no doubled or trailing backslashes, upper-case drive letter. */
17
+ export declare const tidyGuestPath: (guest: string) => string;
18
+ /** Why a guest path cannot be used as it is (null: it can). Short, plain English. */
19
+ export declare const guestPathProblem: (guest: string) => string | null;
20
+ /** C:\Mapped\<drive>\<rest of path>: a safe place for a folder whose own path cannot be used (odd characters made safe). */
21
+ export declare const mappedFallback: (path: string) => string;
22
+ /** Two guest folders overlap where they really are (F:\x is C:\Mirror\F\x): the same, or one inside the other. */
23
+ export declare const guestOverlaps: (a: string, b: string) => boolean;
24
+ /**
25
+ * Where an extra folder appears in the sandbox. `wanted` is the guest path asked for (or the one derived from the PC
26
+ * path); when the sandbox cannot use it (reserved, invalid, or overlapping a folder in `taken`), the folder goes to
27
+ * C:\Mapped\<drive>\<path> instead (made unique with -2, -3 ...), and `note` says where and why.
28
+ */
29
+ export declare const placeGuest: (host: string, wanted: string, taken: {
30
+ guest: string;
31
+ what: string;
32
+ }[]) => {
33
+ guest: string;
34
+ note: string | null;
35
+ };
36
+ /**
37
+ * The guest path a mapping asks for when it gives none: a folder inside the project at the same place in the project's
38
+ * guest folder, any other folder at the same path as on this PC (D: to Z: through C:\Mirror and subst).
39
+ */
40
+ export declare const deriveGuest: (host: string, projectHost: string, projectGuest: string) => string;
14
41
  /** Where a guest path really is inside the sandbox (the .wsb SandboxFolder): C: paths as-is, F:\x -> C:\Mirror\F\x. */
15
42
  export declare const sandboxFolder: (guest: string) => string;
16
43
  export interface SubstDrive {
@@ -2,6 +2,7 @@
2
2
  // guest path like F:\SteamLibrary\x is mapped to C:\Mirror\F\SteamLibrary\x and, at boot (bootstrap.ps1, before
3
3
  // anything else uses the workspace), `subst F: C:\Mirror\F` makes it appear at F:\SteamLibrary\x.
4
4
  // Pure functions only: used by the schema, the host (.wsb, session.json) and history sync.
5
+ import { win32 } from 'node:path';
5
6
  /** Guest folders no mapping may use: the OS, the sandbox's own folders and the mirror root. */
6
7
  export const RESERVED_GUEST = /^C:\\(?:Windows|Program Files|Program Files \(x86\)|ProgramData|Users|AgentLaunch|AgentData|HostTools|nvm|Mirror)(?:\\|$)/i;
7
8
  /** Where folders for other drive letters really live inside the sandbox: C:\Mirror\<letter>. */
@@ -31,6 +32,73 @@ export const isValidGuestPath = (guest) => {
31
32
  return !RESERVED_GUEST.test('C:\\' + rest);
32
33
  return MIRROR_LETTER.test(letter);
33
34
  };
35
+ /** Where a mapping lands when the path it asks for (or the PC path) cannot be used inside the sandbox. */
36
+ export const MAPPED_ROOT = 'C:\\Mapped';
37
+ /** A guest path as typed, tidied: '/' -> '\', no doubled or trailing backslashes, upper-case drive letter. */
38
+ export const tidyGuestPath = (guest) => {
39
+ const p = guest.trim().replace(/\//g, '\\').replace(/\\{2,}/g, '\\').replace(/\\+$/, '');
40
+ return /^[a-z]:/.test(p) ? p[0].toUpperCase() + p.slice(1) : p;
41
+ };
42
+ /** Why a guest path cannot be used as it is (null: it can). Short, plain English. */
43
+ export const guestPathProblem = (guest) => {
44
+ if (isValidGuestPath(guest))
45
+ return null;
46
+ const letter = guestDrive(guest);
47
+ if (letter === 'C' && RESERVED_GUEST.test(guest))
48
+ return guest + ' is a folder the sandbox keeps for itself (Windows, Program Files, Users, AgentData, ...)';
49
+ if (letter && !MIRROR_LETTER.test(letter) && letter !== 'C')
50
+ return 'the sandbox cannot show drive ' + letter + ':';
51
+ if (letter && /^[A-Za-z]:\\?$/.test(guest))
52
+ return guest + ' is a whole drive';
53
+ return guest + ' is not a folder path the sandbox can use';
54
+ };
55
+ /** C:\Mapped\<drive>\<rest of path>: a safe place for a folder whose own path cannot be used (odd characters made safe). */
56
+ export const mappedFallback = (path) => {
57
+ const m = /^([A-Za-z]):\\?(.*)$/.exec(tidyGuestPath(path)) ?? /^\\\\(.*)$/.exec(path);
58
+ const letter = m && m.length === 3 ? m[1].toUpperCase() : 'UNC';
59
+ const rest = (m ? m[m.length - 1] : path).split(/[\\/]+/).map((s) => s.replace(/[<>:"|?*\x00-\x1f]/g, '_').replace(/[. ]+$/, '')).filter((s) => s !== '' && s !== '..').join('\\');
60
+ return MAPPED_ROOT + '\\' + letter + (rest ? '\\' + rest : '');
61
+ };
62
+ /** Two guest folders overlap where they really are (F:\x is C:\Mirror\F\x): the same, or one inside the other. */
63
+ export const guestOverlaps = (a, b) => {
64
+ const x = sandboxFolder(a).replace(/\\+$/, '').toLowerCase();
65
+ const y = sandboxFolder(b).replace(/\\+$/, '').toLowerCase();
66
+ return x === y || x.startsWith(y + '\\') || y.startsWith(x + '\\');
67
+ };
68
+ /**
69
+ * Where an extra folder appears in the sandbox. `wanted` is the guest path asked for (or the one derived from the PC
70
+ * path); when the sandbox cannot use it (reserved, invalid, or overlapping a folder in `taken`), the folder goes to
71
+ * C:\Mapped\<drive>\<path> instead (made unique with -2, -3 ...), and `note` says where and why.
72
+ */
73
+ export const placeGuest = (host, wanted, taken) => {
74
+ const clash = (g) => taken.find((t) => guestOverlaps(g, t.guest));
75
+ const tidy = tidyGuestPath(wanted);
76
+ const problem = guestPathProblem(tidy);
77
+ const other = problem ? null : clash(tidy);
78
+ if (!problem && !other)
79
+ return { guest: tidy, note: null };
80
+ const why = problem ?? tidy + ' is already used by ' + other.what + (other.guest.toLowerCase() === tidy.toLowerCase() ? '' : ' (' + other.guest + ')');
81
+ const guest = freePlace(mappedFallback(/^[A-Za-z]:/.test(tidy) ? tidy : host), clash);
82
+ return { guest, note: host + ': ' + why + ', so it is at ' + guest + ' in the sandbox.' };
83
+ };
84
+ /**
85
+ * The guest path a mapping asks for when it gives none: a folder inside the project at the same place in the project's
86
+ * guest folder, any other folder at the same path as on this PC (D: to Z: through C:\Mirror and subst).
87
+ */
88
+ export const deriveGuest = (host, projectHost, projectGuest) => {
89
+ const rel = win32.relative(win32.resolve(projectHost), win32.resolve(host));
90
+ const inside = rel === '' || (!rel.startsWith('..') && !win32.isAbsolute(rel));
91
+ return inside ? (rel ? win32.join(projectGuest, rel) : projectGuest) : tidyGuestPath(win32.resolve(host));
92
+ };
93
+ const freePlace = (base, clash) => {
94
+ const ok = (g) => isValidGuestPath(g) && !clash(g);
95
+ if (ok(base))
96
+ return base;
97
+ for (let i = 2; i < 1000; i++)
98
+ if (ok(base + '-' + i))
99
+ return base + '-' + i;
100
+ return base + '-' + Date.now();
101
+ };
34
102
  /** Where a guest path really is inside the sandbox (the .wsb SandboxFolder): C: paths as-is, F:\x -> C:\Mirror\F\x. */
35
103
  export const sandboxFolder = (guest) => {
36
104
  const m = DRIVE_PATH.exec(guest);
@@ -1,5 +1,12 @@
1
- /** Where roots, boot-mapped projects and shared folders appear inside the sandbox. */
1
+ /** Where project roots appear inside the sandbox. */
2
2
  export declare const PROJECTS_GUEST = "C:\\Projects";
3
+ /** Where projects mapped on their own appear (the launch folder with guestWorkspace "auto", boot-mapped and shared ones). */
4
+ export declare const WORKSPACES_GUEST = "C:\\Workspace";
5
+ /**
6
+ * guestWorkspace "auto" for a folder: C:\Workspace\<folder name>. Keeping the real folder name means Claude's
7
+ * per-project history and claude-mem's project name match this PC instead of every project being "Workspace".
8
+ */
9
+ export declare const autoGuestFolder: (hostDir: string) => string;
3
10
  /** How a project got into the sandbox. */
4
11
  export type Mechanism = 'launch' | 'root' | 'boot' | 'wsb';
5
12
  /** One project the running sandbox works on. */
@@ -37,17 +44,33 @@ export declare const hostDirFor: (mapped: {
37
44
  }, guestDir: string) => string;
38
45
  /** Strictly below `parent` (never `parent` itself). */
39
46
  export declare const isBelow: (parent: string, child: string) => boolean;
40
- /** A guest folder some project can be in: below C:\Projects, or inside one of the roots (their guest paths). */
47
+ /**
48
+ * A guest folder some project can be in: below C:\Projects or C:\Workspace (projects mapped on their own, also shared
49
+ * after boot), or inside one of `roots` (the roots' and boot-mapped projects' guest paths).
50
+ */
41
51
  export declare const insideProjects: (guestDir: string, roots?: string[], projectsDir?: string) => boolean;
42
52
  export interface RootMapping {
43
53
  host: string;
44
54
  guest: string;
45
55
  }
56
+ /** The project roots that are mapped, in order: existing folders, none inside a root listed before it. */
57
+ export declare const usableRoots: (roots: string[], exists: (dir: string) => boolean) => string[];
46
58
  /**
47
- * Guest folders for the project roots and the projects mapped at boot: C:\Projects\<name>, unique; with
48
- * guestWorkspace "mirror" a root appears at its PC path when the sandbox can show it there.
59
+ * Where a project mapped on its own appears, the same place it has as the launch folder: with "mirror" its PC path
60
+ * when the sandbox can show it there, else C:\Workspace\<name>. -2, -3, ... when that overlaps a folder in `taken`.
61
+ */
62
+ export declare const ownGuestFolder: (hostDir: string, mirror: boolean, taken?: string[]) => string;
63
+ /**
64
+ * Guest folders for the project roots (C:\Projects\<name>, unique; with guestWorkspace "mirror" at its PC path when
65
+ * the sandbox can show it there) and the projects mapped at boot (ownGuestFolder, unique against `taken` and each
66
+ * other). A root's folder depends only on the roots, so every launch with the same roots shows them at the same place.
49
67
  */
50
68
  export declare const planProjectMappings: (roots: string[], projects: string[], mirror: boolean, taken?: string[]) => {
51
69
  roots: RootMapping[];
52
70
  projects: RootMapping[];
53
71
  };
72
+ /**
73
+ * Where a folder inside one of the (usable) roots appears: under the root's guest folder, whichever project started
74
+ * the sandbox. Null when it is in no root.
75
+ */
76
+ export declare const rootGuestDir: (roots: string[], mirror: boolean, hostDir: string) => string | null;
@@ -1,16 +1,30 @@
1
1
  // Several projects in one running sandbox. Windows Sandbox fixes its mapped folders at boot, so a project can only
2
2
  // join a running sandbox when it is already visible inside:
3
- // - the launch folder (mapped directly, as always);
3
+ // - the launch folder (mapped directly, or shown by the project root it is in);
4
4
  // - a folder inside a project root (projects.roots: whole folders of projects, mapped at every start), including
5
5
  // projects created after boot;
6
6
  // - projects mapped at boot because they were in use when the sandbox was restarted to add them;
7
7
  // - a folder shared into the running sandbox with `wsb share` (Windows 11 24H2+, untested).
8
- // Roots, boot-mapped projects and shares appear under C:\Projects (roots at their PC path with guestWorkspace "mirror").
8
+ // A project is at the same place inside the sandbox whichever project started it (git worktrees and Claude's project
9
+ // folders store absolute paths):
10
+ // - a project inside a root is always under the root's folder, C:\Projects\<root>\<name> (also as the launch folder);
11
+ // - any other project, mapped on its own, is always at C:\Workspace\<name> (also when it is mapped at boot or shared).
12
+ // With guestWorkspace "mirror" both appear at their PC paths when the sandbox can show them there.
9
13
  // Pure functions only: used by the host (registration, commands), the guest (daemon checks) and the tests.
10
14
  import path from 'node:path';
11
- import { isValidGuestPath } from './guest-paths.js';
12
- /** Where roots, boot-mapped projects and shared folders appear inside the sandbox. */
15
+ import { isValidGuestPath, planMirrorWorkspace } from './guest-paths.js';
16
+ /** Where project roots appear inside the sandbox. */
13
17
  export const PROJECTS_GUEST = 'C:\\Projects';
18
+ /** Where projects mapped on their own appear (the launch folder with guestWorkspace "auto", boot-mapped and shared ones). */
19
+ export const WORKSPACES_GUEST = 'C:\\Workspace';
20
+ /**
21
+ * guestWorkspace "auto" for a folder: C:\Workspace\<folder name>. Keeping the real folder name means Claude's
22
+ * per-project history and claude-mem's project name match this PC instead of every project being "Workspace".
23
+ */
24
+ export const autoGuestFolder = (hostDir) => {
25
+ const name = path.win32.basename(hostDir.replace(/[\\/]+$/, '')).replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[. ]+|[. ]+$/g, '');
26
+ return WORKSPACES_GUEST + '\\' + (name || 'project');
27
+ };
14
28
  const norm = (p) => path.win32.resolve(p).replace(/[\\/]+$/, '').toLowerCase();
15
29
  /** `child` is `parent` or somewhere below it (Windows paths, case-insensitive). */
16
30
  export const isWithin = (parent, child) => {
@@ -47,21 +61,66 @@ export const hostDirFor = (mapped, guestDir) => {
47
61
  };
48
62
  /** Strictly below `parent` (never `parent` itself). */
49
63
  export const isBelow = (parent, child) => isWithin(parent, child) && norm(parent) !== norm(child);
50
- /** A guest folder some project can be in: below C:\Projects, or inside one of the roots (their guest paths). */
51
- export const insideProjects = (guestDir, roots = [], projectsDir = PROJECTS_GUEST) => isBelow(projectsDir, guestDir) || roots.some((r) => isWithin(r, guestDir));
52
64
  /**
53
- * Guest folders for the project roots and the projects mapped at boot: C:\Projects\<name>, unique; with
54
- * guestWorkspace "mirror" a root appears at its PC path when the sandbox can show it there.
65
+ * A guest folder some project can be in: below C:\Projects or C:\Workspace (projects mapped on their own, also shared
66
+ * after boot), or inside one of `roots` (the roots' and boot-mapped projects' guest paths).
67
+ */
68
+ export const insideProjects = (guestDir, roots = [], projectsDir = PROJECTS_GUEST) => isBelow(projectsDir, guestDir) || isBelow(WORKSPACES_GUEST, guestDir) || roots.some((r) => isWithin(r, guestDir));
69
+ const resolveDir = (dir) => path.win32.resolve(dir).replace(/[\\/]+$/, '');
70
+ /** The project roots that are mapped, in order: existing folders, none inside a root listed before it. */
71
+ export const usableRoots = (roots, exists) => {
72
+ const out = [];
73
+ for (const root of roots.map(resolveDir))
74
+ if (exists(root) && !out.some((r) => isWithin(r, root)))
75
+ out.push(root);
76
+ return out;
77
+ };
78
+ /**
79
+ * Where a project mapped on its own appears, the same place it has as the launch folder: with "mirror" its PC path
80
+ * when the sandbox can show it there, else C:\Workspace\<name>. -2, -3, ... when that overlaps a folder in `taken`.
81
+ */
82
+ export const ownGuestFolder = (hostDir, mirror, taken = []) => {
83
+ const host = resolveDir(hostDir);
84
+ const free = (guest) => !taken.some((t) => isWithin(t, guest) || isWithin(guest, t));
85
+ const mirrored = mirror ? planMirrorWorkspace(host, '').guest : '';
86
+ if (mirrored && !isWithin(PROJECTS_GUEST, mirrored) && !isWithin(WORKSPACES_GUEST, mirrored) && free(mirrored))
87
+ return mirrored;
88
+ const base = autoGuestFolder(host);
89
+ if (free(base))
90
+ return base;
91
+ for (let i = 2;; i++)
92
+ if (free(base + '-' + i))
93
+ return base + '-' + i;
94
+ };
95
+ /**
96
+ * Guest folders for the project roots (C:\Projects\<name>, unique; with guestWorkspace "mirror" at its PC path when
97
+ * the sandbox can show it there) and the projects mapped at boot (ownGuestFolder, unique against `taken` and each
98
+ * other). A root's folder depends only on the roots, so every launch with the same roots shows them at the same place.
55
99
  */
56
100
  export const planProjectMappings = (roots, projects, mirror, taken = []) => {
57
- const names = [...taken];
58
- const place = (host, tryMirror) => {
59
- const resolved = path.win32.resolve(host).replace(/[\\/]+$/, '');
60
- if (tryMirror && isValidGuestPath(resolved) && !isWithin(PROJECTS_GUEST, resolved))
101
+ const names = [];
102
+ const placeRoot = (host) => {
103
+ const resolved = resolveDir(host);
104
+ if (mirror && isValidGuestPath(resolved) && !isWithin(PROJECTS_GUEST, resolved))
61
105
  return { host: resolved, guest: resolved };
62
106
  const name = hubName(resolved, names);
63
107
  names.push(name);
64
108
  return { host: resolved, guest: path.win32.join(PROJECTS_GUEST, name) };
65
109
  };
66
- return { roots: roots.map((r) => place(r, mirror)), projects: projects.map((p) => place(p, false)) };
110
+ const planned = roots.map(placeRoot);
111
+ const used = [...taken, ...planned.map((r) => r.guest)];
112
+ const placed = projects.map((p) => {
113
+ const guest = ownGuestFolder(p, mirror, used);
114
+ used.push(guest);
115
+ return { host: resolveDir(p), guest };
116
+ });
117
+ return { roots: planned, projects: placed };
118
+ };
119
+ /**
120
+ * Where a folder inside one of the (usable) roots appears: under the root's guest folder, whichever project started
121
+ * the sandbox. Null when it is in no root.
122
+ */
123
+ export const rootGuestDir = (roots, mirror, hostDir) => {
124
+ const root = projectForHostDir(planProjectMappings(roots, [], mirror).roots, hostDir);
125
+ return root ? guestDirFor(root, hostDir) : null;
67
126
  };
@@ -37,6 +37,8 @@ export type ClientResult = {
37
37
  reason: 'error';
38
38
  message: string;
39
39
  };
40
+ /** Turns off every mouse tracking mode a program may have turned on in your terminal (normal, button, any, UTF-8, SGR, urxvt). */
41
+ export declare const MOUSE_OFF = "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l";
40
42
  /** Ctrl+] — the same detach key telnet uses; Claude Code does not bind it. */
41
43
  export declare const DETACH_KEY = 29;
42
44
  export declare const encodeFrame: (type: number, payload?: Buffer) => Buffer;
@@ -82,6 +84,21 @@ export declare const queryViewers: (endpoint: Endpoint, timeoutMs?: number) => P
82
84
  * what terminals send. ConPTY reads ^H as Ctrl+Backspace (delete a word), so swap them back.
83
85
  */
84
86
  export declare const fixWindowsKeys: (chunk: Buffer) => Buffer;
87
+ /** How long an unfinished escape sequence is held back waiting for the rest (a lone Esc key press waits this long). */
88
+ export declare const JOIN_MS = 30;
89
+ /**
90
+ * Keeps each escape sequence typed into your terminal (keys, mouse reports, the terminal's replies) in one INPUT frame.
91
+ * The console can hand one over in pieces; ConPTY reads a lone "ESC[" as Alt+[ and the rest as typed text, so a split
92
+ * Right arrow (ESC[C) left a stray "C" in the agent's prompt. An unfinished sequence at the end of a chunk is held
93
+ * until the rest arrives (or JOIN_MS passes).
94
+ */
95
+ export declare class InputJoiner {
96
+ private held;
97
+ /** The bytes to send now; an unfinished sequence at the end is kept for the next chunk. */
98
+ push(chunk: Buffer): Buffer;
99
+ holding(): boolean;
100
+ flush(): Buffer;
101
+ }
85
102
  /**
86
103
  * Bridges this process's terminal to the remote program until it exits, the connection drops, or the user presses
87
104
  * the detach key. Restores the terminal before resolving.
@@ -1,11 +1,14 @@
1
1
  // Client side of the ptyhost protocol (see sandbox/ptyhost.cs). Used by the window inside the sandbox and by
2
2
  // `--terminal` / `attach` on the host, so it only depends on Node built-ins.
3
3
  import net from 'node:net';
4
+ import { enableVtInput } from './console-input.js';
4
5
  export const FrameType = { Hello: 1, Input: 2, Resize: 3, Restart: 4, Ping: 5, Output: 16, Exit: 17, Error: 18, Restarting: 19, Status: 20, Console: 21 };
5
6
  /** HELLO flags: control-only connections get no output and never size the console; a query only asks for STATUS. */
6
7
  export const HelloFlag = { ControlOnly: 1, Query: 2 };
7
8
  /** Viewers ping this often; ptyhost counts a viewer as gone after 15 s of silence (e.g. its terminal tab was closed). */
8
9
  export const PING_INTERVAL_MS = 5000;
10
+ /** Turns off every mouse tracking mode a program may have turned on in your terminal (normal, button, any, UTF-8, SGR, urxvt). */
11
+ export const MOUSE_OFF = '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l';
9
12
  /** Ctrl+] — the same detach key telnet uses; Claude Code does not bind it. */
10
13
  export const DETACH_KEY = 0x1d;
11
14
  export const encodeFrame = (type, payload = Buffer.alloc(0)) => {
@@ -255,6 +258,41 @@ export const fixWindowsKeys = (chunk) => {
255
258
  return chunk;
256
259
  return Buffer.from(chunk.map((b) => (b === 0x08 ? 0x7f : b === 0x7f ? 0x08 : b)));
257
260
  };
261
+ /** How long an unfinished escape sequence is held back waiting for the rest (a lone Esc key press waits this long). */
262
+ export const JOIN_MS = 30;
263
+ /** Longest sequence held back; anything longer is not a key, mouse report or terminal reply, and is sent as it is. */
264
+ const MAX_HELD = 64;
265
+ /**
266
+ * Keeps each escape sequence typed into your terminal (keys, mouse reports, the terminal's replies) in one INPUT frame.
267
+ * The console can hand one over in pieces; ConPTY reads a lone "ESC[" as Alt+[ and the rest as typed text, so a split
268
+ * Right arrow (ESC[C) left a stray "C" in the agent's prompt. An unfinished sequence at the end of a chunk is held
269
+ * until the rest arrives (or JOIN_MS passes).
270
+ */
271
+ export class InputJoiner {
272
+ held = Buffer.alloc(0);
273
+ /** The bytes to send now; an unfinished sequence at the end is kept for the next chunk. */
274
+ push(chunk) {
275
+ const data = this.held.length ? Buffer.concat([this.held, chunk]) : chunk;
276
+ const cut = unfinishedSequence(data);
277
+ this.held = Buffer.from(data.subarray(cut));
278
+ return data.subarray(0, cut);
279
+ }
280
+ holding() { return this.held.length > 0; }
281
+ flush() { const out = this.held; this.held = Buffer.alloc(0); return out; }
282
+ }
283
+ /** Where an escape sequence that has not ended yet starts at the end of `data` (data.length if there is none). */
284
+ const unfinishedSequence = (data) => {
285
+ const start = data.lastIndexOf(0x1b, -1);
286
+ if (start === -1 || data.length - start > MAX_HELD)
287
+ return data.length;
288
+ const rest = data.subarray(start + 1);
289
+ if (rest.length === 0)
290
+ return start;
291
+ // ESC [ or ESC O (SS3) followed only by parameter / intermediate bytes: the final byte has not arrived yet.
292
+ if (rest[0] === 0x5b || rest[0] === 0x4f)
293
+ return rest.subarray(1).every((b) => b >= 0x20 && b <= 0x3f) && (rest[0] === 0x5b || rest.length === 1) ? start : data.length;
294
+ return data.length;
295
+ };
258
296
  /**
259
297
  * Bridges this process's terminal to the remote program until it exits, the connection drops, or the user presses
260
298
  * the detach key. Restores the terminal before resolving.
@@ -264,13 +302,15 @@ export const runTerminalClient = (socket, token, options = {}) => {
264
302
  const stdout = process.stdout;
265
303
  // getWindowSize asks the console every time; columns/rows only change when Node noticed a resize.
266
304
  const size = () => { const [c, r] = stdout.isTTY ? stdout.getWindowSize() : [0, 0]; return [c || 120, r || 30]; };
267
- const swapKeys = process.platform === 'win32' && stdin.isTTY;
305
+ // Until the console passes the terminal's input through as it is (enableVtInput), Node turns Backspace into ^H.
306
+ let swapKeys = process.platform === 'win32' && stdin.isTTY;
268
307
  return new Promise((resolve) => {
269
308
  let done = false;
270
309
  const reader = new FrameReader();
271
310
  const filter = new OutputFilter();
272
311
  let sent = size();
273
312
  let resizeTimer;
313
+ let vtInput;
274
314
  const finish = (result) => {
275
315
  if (done)
276
316
  return;
@@ -280,19 +320,32 @@ export const runTerminalClient = (socket, token, options = {}) => {
280
320
  clearTimeout(resizeTimer);
281
321
  clearInterval(heartbeat);
282
322
  clearInterval(sizeWatch);
283
- if (stdin.isTTY)
284
- stdin.setRawMode(false);
285
- stdin.pause();
323
+ clearTimeout(joinTimer);
286
324
  socket.destroy();
287
- stdout.write('\x1b[0m\x1b[?25h\r\n');
288
- resolve(result);
325
+ // The agent's mouse tracking ends with it: your terminal's wheel scrolls again (and clicks select text).
326
+ stdout.write('\x1b[0m\x1b[?25h' + MOUSE_OFF + '\r\n');
327
+ // Wait for a console-mode change still in progress, then give the console back in its normal mode.
328
+ void (vtInput?.stop() ?? Promise.resolve()).then(() => {
329
+ if (stdin.isTTY)
330
+ stdin.setRawMode(false);
331
+ stdin.pause();
332
+ resolve(result);
333
+ });
289
334
  };
335
+ const joiner = new InputJoiner();
336
+ let joinTimer;
337
+ const sendInput = (data) => { if (data.length && !done)
338
+ socket.write(encodeFrame(FrameType.Input, swapKeys ? fixWindowsKeys(data) : data)); };
290
339
  const onInput = (chunk) => {
291
340
  if (options.detachKey !== undefined && chunk.includes(options.detachKey)) {
292
341
  finish({ reason: 'detach' });
293
342
  return;
294
343
  }
295
- socket.write(encodeFrame(FrameType.Input, swapKeys ? fixWindowsKeys(chunk) : chunk));
344
+ clearTimeout(joinTimer);
345
+ sendInput(joiner.push(chunk));
346
+ // A lone Esc key press is a "sequence" that never completes: send it after a moment.
347
+ if (joiner.holding())
348
+ joinTimer = setTimeout(() => sendInput(joiner.flush()), JOIN_MS);
296
349
  };
297
350
  // Windows' own ConPTY repaints the whole screen on every resize; dragging a window edge fires dozens of them, and
298
351
  // repaints racing each other left the screen garbled. Wait until the size settles, clear what this terminal
@@ -328,7 +381,7 @@ export const runTerminalClient = (socket, token, options = {}) => {
328
381
  else if (frame.type === FrameType.Console)
329
382
  passthrough = frame.payload.toString('latin1') === 'conpty.dll';
330
383
  else if (frame.type === FrameType.Restarting)
331
- stdout.write('\x1b[2J\x1b[3J\x1b[H');
384
+ stdout.write(MOUSE_OFF + '\x1b[2J\x1b[3J\x1b[H');
332
385
  else if (frame.type === FrameType.Exit)
333
386
  finish({ reason: 'exit', exitCode: frame.payload.length >= 4 ? frame.payload.readInt32LE(0) : 1 });
334
387
  else if (frame.type === FrameType.Error)
@@ -342,6 +395,10 @@ export const runTerminalClient = (socket, token, options = {}) => {
342
395
  socket.write(helloFrame(token, c, r));
343
396
  if (stdin.isTTY)
344
397
  stdin.setRawMode(true);
398
+ // After setRawMode (which sets the console mode): mouse reports, focus and keys then arrive as the terminal sent them.
399
+ vtInput = enableVtInput();
400
+ void vtInput.ready.then((on) => { if (on)
401
+ swapKeys = false; });
345
402
  stdin.on('data', onInput);
346
403
  stdin.resume();
347
404
  stdout.on('resize', onResize);
@@ -5,6 +5,18 @@ export declare const AttachEndpoint: z.ZodObject<{
5
5
  port: z.ZodNumber;
6
6
  token: z.ZodString;
7
7
  }, z.core.$strict>;
8
+ /** The boot's progress in a shape the host can show: step n of m, what is being set up now, how many are done. */
9
+ export declare const BootProgress: z.ZodObject<{
10
+ step: z.ZodNumber;
11
+ steps: z.ZodNumber;
12
+ title: z.ZodString;
13
+ active: z.ZodArray<z.ZodObject<{
14
+ name: z.ZodString;
15
+ percent: z.ZodOptional<z.ZodNumber>;
16
+ }, z.core.$strict>>;
17
+ done: z.ZodNumber;
18
+ total: z.ZodNumber;
19
+ }, z.core.$strict>;
8
20
  /** C:\AgentData\run\sandbox.json: boot progress of the sandbox as a whole. */
9
21
  export declare const SandboxStatus: z.ZodObject<{
10
22
  launchId: z.ZodString;
@@ -20,6 +32,17 @@ export declare const SandboxStatus: z.ZodObject<{
20
32
  guestWorkspace: z.ZodString;
21
33
  headless: z.ZodBoolean;
22
34
  addresses: z.ZodOptional<z.ZodArray<z.ZodString>>;
35
+ progress: z.ZodCatch<z.ZodOptional<z.ZodObject<{
36
+ step: z.ZodNumber;
37
+ steps: z.ZodNumber;
38
+ title: z.ZodString;
39
+ active: z.ZodArray<z.ZodObject<{
40
+ name: z.ZodString;
41
+ percent: z.ZodOptional<z.ZodNumber>;
42
+ }, z.core.$strict>>;
43
+ done: z.ZodNumber;
44
+ total: z.ZodNumber;
45
+ }, z.core.$strict>>>;
23
46
  }, z.core.$strip>;
24
47
  /**
25
48
  * A project the host registered after boot (sandbox claude from another folder): everything the guest needs to
@@ -532,6 +555,7 @@ export type ProjectSpecT = z.output<typeof ProjectSpec>;
532
555
  export type ProjectStatusT = z.output<typeof ProjectStatus>;
533
556
  export type AttachEndpointT = z.output<typeof AttachEndpoint>;
534
557
  export type SandboxStatusT = z.output<typeof SandboxStatus>;
558
+ export type BootProgressT = z.output<typeof BootProgress>;
535
559
  export type GuestRequestT = z.output<typeof GuestRequest>;
536
560
  export type GuestResponseT = z.output<typeof GuestResponse>;
537
561
  export type SessionStatusT = z.output<typeof SessionStatus>;
@@ -10,6 +10,16 @@ export const AttachEndpoint = z.strictObject({
10
10
  port: z.number().int().min(1024).max(65535),
11
11
  token: Text(256),
12
12
  });
13
+ /** The boot's progress in a shape the host can show: step n of m, what is being set up now, how many are done. */
14
+ export const BootProgress = z.strictObject({
15
+ step: z.number().int().min(1).max(32),
16
+ steps: z.number().int().min(1).max(32),
17
+ title: Text(120),
18
+ /** Being set up now; percent while it downloads (when the size is known). */
19
+ active: z.array(z.strictObject({ name: Text(64), percent: z.number().int().min(0).max(100).optional() })).max(8),
20
+ done: z.number().int().min(0).max(1000),
21
+ total: z.number().int().min(0).max(1000),
22
+ });
13
23
  /** C:\AgentData\run\sandbox.json: boot progress of the sandbox as a whole. */
14
24
  export const SandboxStatus = z.object({
15
25
  launchId: Text(64),
@@ -21,6 +31,8 @@ export const SandboxStatus = z.object({
21
31
  headless: z.boolean(),
22
32
  /** The sandbox's IPv4 addresses (for network.forwardPorts; the host only uses ones on its Hyper-V network). */
23
33
  addresses: z.array(z.string().max(45)).max(8).optional(),
34
+ /** A malformed progress is dropped, never the status (the phase still counts). */
35
+ progress: BootProgress.optional().catch(undefined),
24
36
  });
25
37
  const Version = z.strictObject({ spec: Text(128), source: Text(256) });
26
38
  const DatabasePlan = z.strictObject({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windows-development-sandbox",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "A disposable Windows Sandbox for development, with or without an AI agent (Claude Code, Codex, Gemini, opencode, ...): the folder you run it from is the workspace, toolchains and databases for your project install themselves, and nothing else on your PC is reachable.",
5
5
  "type": "module",
6
6
  "license": "MIT",