windows-development-sandbox 1.1.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 (222) hide show
  1. package/.sandbox-config.example.json +2 -1
  2. package/README.md +3 -2
  3. package/config.example.json +39 -5
  4. package/dist/cli/args.d.ts +3 -0
  5. package/dist/cli/args.js +3 -1
  6. package/dist/cli/boot-progress.d.ts +22 -0
  7. package/dist/cli/boot-progress.js +59 -0
  8. package/dist/cli/commands/accounts.js +2 -2
  9. package/dist/cli/commands/agent-commands.js +1 -1
  10. package/dist/cli/commands/context.js +2 -1
  11. package/dist/cli/commands/data/clean.js +6 -1
  12. package/dist/cli/commands/data/collect.js +3 -3
  13. package/dist/cli/commands/index.d.ts +1 -1
  14. package/dist/cli/commands/index.js +1 -1
  15. package/dist/cli/commands/launch.d.ts +5 -2
  16. package/dist/cli/commands/launch.js +113 -25
  17. package/dist/cli/commands/maintenance.d.ts +2 -0
  18. package/dist/cli/commands/maintenance.js +70 -4
  19. package/dist/cli/commands/project.d.ts +10 -6
  20. package/dist/cli/commands/project.js +42 -3
  21. package/dist/cli/commands/restart.js +17 -7
  22. package/dist/cli/commands/sessions.js +5 -4
  23. package/dist/cli/commands/user-config.js +1 -1
  24. package/dist/cli/index.js +6 -4
  25. package/dist/cli/manifest.js +6 -1
  26. package/dist/cli/menu/apps.d.ts +21 -0
  27. package/dist/cli/menu/apps.js +122 -0
  28. package/dist/cli/menu/command-form.d.ts +28 -0
  29. package/dist/cli/menu/command-form.js +219 -0
  30. package/dist/cli/menu/index.d.ts +26 -0
  31. package/dist/cli/menu/index.js +180 -0
  32. package/dist/cli/menu/settings/categories.d.ts +9 -0
  33. package/dist/cli/menu/settings/categories.js +28 -0
  34. package/dist/cli/menu/settings/config-file.d.ts +40 -0
  35. package/dist/cli/menu/settings/config-file.js +71 -0
  36. package/dist/cli/menu/settings/editors.d.ts +30 -0
  37. package/dist/cli/menu/settings/editors.js +305 -0
  38. package/dist/cli/menu/settings/index.d.ts +32 -0
  39. package/dist/cli/menu/settings/index.js +136 -0
  40. package/dist/cli/menu/settings/schema-node.d.ts +62 -0
  41. package/dist/cli/menu/settings/schema-node.js +104 -0
  42. package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
  43. package/dist/cli/menu/several.js +102 -0
  44. package/dist/cli/progress.d.ts +15 -0
  45. package/dist/cli/progress.js +52 -0
  46. package/dist/cli/tui/app.d.ts +39 -0
  47. package/dist/cli/tui/app.js +52 -0
  48. package/dist/cli/tui/dialogs.d.ts +57 -0
  49. package/dist/cli/tui/dialogs.js +162 -0
  50. package/dist/cli/tui/frame.d.ts +18 -0
  51. package/dist/cli/tui/frame.js +27 -0
  52. package/dist/cli/tui/index.d.ts +7 -0
  53. package/dist/cli/tui/index.js +8 -0
  54. package/dist/cli/tui/list-screen.d.ts +101 -0
  55. package/dist/cli/tui/list-screen.js +299 -0
  56. package/dist/cli/tui/terminal.d.ts +37 -0
  57. package/dist/cli/tui/terminal.js +89 -0
  58. package/dist/cli/tui/text.d.ts +20 -0
  59. package/dist/cli/tui/text.js +105 -0
  60. package/dist/cli/tui/theme.d.ts +45 -0
  61. package/dist/cli/tui/theme.js +30 -0
  62. package/dist/core/config.js +10 -7
  63. package/dist/core/local-config.d.ts +31 -1
  64. package/dist/core/local-config.js +72 -7
  65. package/dist/core/project-policy.d.ts +50 -3
  66. package/dist/core/project-policy.js +90 -4
  67. package/dist/core/schema/apps.d.ts +40 -0
  68. package/dist/core/schema/apps.js +27 -0
  69. package/dist/core/schema/config.d.ts +69 -3
  70. package/dist/core/schema/config.js +18 -6
  71. package/dist/core/schema/profile.d.ts +5 -0
  72. package/dist/core/schema/profile.js +4 -2
  73. package/dist/core/schema/projects.d.ts +1 -0
  74. package/dist/core/schema/projects.js +1 -0
  75. package/dist/core/schema/sandbox.d.ts +29 -1
  76. package/dist/core/schema/sandbox.js +29 -1
  77. package/dist/core/schema/toolchains.d.ts +3 -0
  78. package/dist/core/schema/toolchains.js +6 -1
  79. package/dist/core/types.d.ts +24 -1
  80. package/dist/core/util.d.ts +2 -0
  81. package/dist/core/util.js +2 -0
  82. package/dist/core/workspace.d.ts +21 -4
  83. package/dist/core/workspace.js +99 -21
  84. package/dist/guest/apps/index.d.ts +10 -0
  85. package/dist/guest/apps/index.js +206 -0
  86. package/dist/guest/apps/resolvers.d.ts +8 -0
  87. package/dist/guest/apps/resolvers.js +90 -0
  88. package/dist/guest/audit-parse.d.ts +7 -0
  89. package/dist/guest/audit-parse.js +24 -0
  90. package/dist/guest/audit.d.ts +4 -0
  91. package/dist/guest/audit.js +47 -0
  92. package/dist/guest/daemon/relays.d.ts +1 -0
  93. package/dist/guest/daemon/relays.js +18 -0
  94. package/dist/guest/daemon.js +4 -0
  95. package/dist/guest/env.d.ts +7 -2
  96. package/dist/guest/env.js +17 -3
  97. package/dist/guest/helpers.d.ts +1 -1
  98. package/dist/guest/main.js +20 -3
  99. package/dist/guest/netproxy.d.ts +1 -0
  100. package/dist/guest/netproxy.js +100 -0
  101. package/dist/guest/ptyhost.js +14 -4
  102. package/dist/guest/setup/claude.d.ts +0 -4
  103. package/dist/guest/setup/claude.js +48 -2
  104. package/dist/guest/setup/network.d.ts +9 -0
  105. package/dist/guest/setup/network.js +97 -4
  106. package/dist/guest/setup/user.js +4 -0
  107. package/dist/guest/toolchains/index.js +29 -3
  108. package/dist/guest/toolchains/native/mesa.js +13 -3
  109. package/dist/guest/toolchains/plan.d.ts +3 -1
  110. package/dist/guest/toolchains/plan.js +53 -9
  111. package/dist/guest/toolchains/python.js +26 -2
  112. package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
  113. package/dist/guest/toolchains/vscode-profile.js +69 -0
  114. package/dist/guest/toolchains/vscode.d.ts +17 -0
  115. package/dist/guest/toolchains/vscode.js +87 -1
  116. package/dist/host/attach.js +2 -1
  117. package/dist/host/audit/collect.d.ts +34 -0
  118. package/dist/host/audit/collect.js +126 -0
  119. package/dist/host/audit/log.d.ts +46 -0
  120. package/dist/host/audit/log.js +143 -0
  121. package/dist/host/channel.d.ts +2 -2
  122. package/dist/host/channel.js +3 -2
  123. package/dist/host/generate.d.ts +7 -1
  124. package/dist/host/generate.js +23 -6
  125. package/dist/host/join.d.ts +7 -0
  126. package/dist/host/join.js +23 -9
  127. package/dist/host/launch/mappings.d.ts +7 -2
  128. package/dist/host/launch/mappings.js +68 -18
  129. package/dist/host/launch/projects.d.ts +2 -0
  130. package/dist/host/launch/projects.js +11 -5
  131. package/dist/host/launch/stage.js +1 -1
  132. package/dist/host/motw.d.ts +17 -0
  133. package/dist/host/motw.js +74 -0
  134. package/dist/host/platform.js +3 -0
  135. package/dist/host/port-forward.d.ts +8 -0
  136. package/dist/host/port-forward.js +37 -0
  137. package/dist/host/projects.d.ts +25 -2
  138. package/dist/host/projects.js +54 -5
  139. package/dist/host/repo-guard.d.ts +10 -3
  140. package/dist/host/repo-guard.js +142 -14
  141. package/dist/host/stops.d.ts +67 -0
  142. package/dist/host/stops.js +126 -0
  143. package/dist/host/terminal.d.ts +8 -0
  144. package/dist/host/terminal.js +45 -1
  145. package/dist/host/workspace-view/index.d.ts +20 -0
  146. package/dist/host/workspace-view/index.js +53 -0
  147. package/dist/host/workspace-view/rules.d.ts +26 -0
  148. package/dist/host/workspace-view/rules.js +93 -0
  149. package/dist/host/workspace-view/view.d.ts +50 -0
  150. package/dist/host/workspace-view/view.js +281 -0
  151. package/dist/runtime/daemon.js +1387 -558
  152. package/dist/runtime/db.js +199 -14
  153. package/dist/runtime/main.js +3082 -631
  154. package/dist/runtime/netproxy.js +25948 -0
  155. package/dist/runtime/view.js +531 -148
  156. package/dist/selftest/audit.test.d.ts +2 -0
  157. package/dist/selftest/audit.test.js +57 -0
  158. package/dist/selftest/channel.test.js +45 -2
  159. package/dist/selftest/cli.test.js +47 -17
  160. package/dist/selftest/fixtures.d.ts +47 -1
  161. package/dist/selftest/fixtures.js +3 -1
  162. package/dist/selftest/generate.test.js +7 -2
  163. package/dist/selftest/index.js +6 -2
  164. package/dist/selftest/live-network.test.d.ts +2 -0
  165. package/dist/selftest/live-network.test.js +63 -0
  166. package/dist/selftest/live-terminal.test.d.ts +2 -0
  167. package/dist/selftest/live-terminal.test.js +169 -0
  168. package/dist/selftest/local-config.test.js +42 -4
  169. package/dist/selftest/mirror.test.js +36 -7
  170. package/dist/selftest/network.test.js +12 -0
  171. package/dist/selftest/projects.test.js +77 -8
  172. package/dist/selftest/repo-guard.test.js +59 -1
  173. package/dist/selftest/sync.test.js +50 -10
  174. package/dist/selftest/toolchains.test.js +30 -0
  175. package/dist/selftest/workspace-view.test.d.ts +2 -0
  176. package/dist/selftest/workspace-view.test.js +89 -0
  177. package/dist/shared/apps.d.ts +49 -0
  178. package/dist/shared/apps.js +114 -0
  179. package/dist/shared/console-input.d.ts +11 -0
  180. package/dist/shared/console-input.js +61 -0
  181. package/dist/shared/download.d.ts +3 -0
  182. package/dist/shared/download.js +25 -2
  183. package/dist/shared/guest-paths.d.ts +27 -0
  184. package/dist/shared/guest-paths.js +68 -0
  185. package/dist/shared/netpolicy.d.ts +27 -0
  186. package/dist/shared/netpolicy.js +51 -0
  187. package/dist/shared/projects.d.ts +27 -4
  188. package/dist/shared/projects.js +72 -13
  189. package/dist/shared/pty-client.d.ts +26 -4
  190. package/dist/shared/pty-client.js +204 -25
  191. package/dist/shared/rundir.d.ts +2 -0
  192. package/dist/shared/rundir.js +2 -0
  193. package/dist/shared/schema.d.ts +34 -0
  194. package/dist/shared/schema.js +14 -0
  195. package/dist/shared/system.d.ts +1 -0
  196. package/dist/shared/system.js +1 -0
  197. package/dist/sync/claude-mem.js +3 -2
  198. package/dist/sync/history.d.ts +6 -0
  199. package/dist/sync/history.js +26 -11
  200. package/dist/sync/watcher.js +78 -6
  201. package/package.json +2 -1
  202. package/sandbox/cargoshim.cs +56 -0
  203. package/sandbox/cpucap.cs +33 -0
  204. package/sandbox/cpucap.ps1 +4 -35
  205. package/sandbox/logon.cmd +6 -0
  206. package/sandbox/memlog.ps1 +34 -0
  207. package/sandbox/pagefile.cs +31 -0
  208. package/sandbox/pagefile.ps1 +21 -0
  209. package/sandbox/ptyhost.cs +162 -7
  210. package/schemas/config.schema.json +318 -3
  211. package/schemas/sandbox-config.schema.json +129 -14
  212. package/dist/cli/menu-multi.js +0 -82
  213. package/dist/cli/menu.d.ts +0 -14
  214. package/dist/cli/menu.js +0 -290
  215. package/dist/cli/settings/edit.d.ts +0 -13
  216. package/dist/cli/settings/edit.js +0 -19
  217. package/dist/cli/settings/index.d.ts +0 -8
  218. package/dist/cli/settings/index.js +0 -131
  219. package/dist/cli/settings/prompts.d.ts +0 -7
  220. package/dist/cli/settings/prompts.js +0 -48
  221. package/dist/cli/settings/schema.d.ts +0 -33
  222. package/dist/cli/settings/schema.js +0 -29
@@ -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
- export const FrameType = { Hello: 1, Input: 2, Resize: 3, Restart: 4, Ping: 5, Output: 16, Exit: 17, Error: 18, Restarting: 19, Status: 20 };
4
+ import { enableVtInput } from './console-input.js';
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)) => {
@@ -47,23 +50,140 @@ export class FrameReader {
47
50
  return frames;
48
51
  }
49
52
  }
53
+ /** OSC commands that may reach your terminal: window title (0, 1, 2), links (8), progress (9;4), shell marks (133). */
54
+ const KEPT_OSC = (body) => {
55
+ const number = /^(\d{1,4})(;|$)/.exec(body);
56
+ if (!number)
57
+ return false;
58
+ const n = Number(number[1]);
59
+ return n <= 2 || n === 8 || n === 133 || (n === 9 && body.startsWith(number[1] + ';4;'));
60
+ };
61
+ /** Longest OSC kept (a title or a link); anything longer is dropped whole. */
62
+ const MAX_KEPT_STRING = 4096;
50
63
  /**
51
- * Removes OSC 52 (clipboard write) from the sandbox's output before it reaches your terminal: an untrusted program
52
- * must not be able to silently put text into your clipboard. Everything else (colours, titles, links) passes.
53
- * Stateful, so a sequence split across chunks is still caught.
64
+ * Removes the terminal "strings" an untrusted program could abuse from the sandbox's output before it reaches your
65
+ * terminal: clipboard writes (OSC 52), terminal macros (ConEmu's OSC 9), DCS / APC / PM / SOS, and their C1 forms.
66
+ * Titles, links, progress and colours pass. A byte-level state machine, so no split across chunks, leading zero
67
+ * (ESC]052) or huge payload slips through: a dropped string stays dropped until its terminator, however long it is.
54
68
  */
55
69
  export class OutputFilter {
56
- carry = '';
70
+ /** text: normal output; esc: after ESC; c2: after a 0xC2 lead byte; string: inside an OSC/DCS/...; stringEsc / stringC2: a possible terminator inside one. */
71
+ state = 'text';
72
+ /** The string being read: an OSC worth keeping is collected until its terminator; null = being dropped. */
73
+ body = null;
57
74
  push(chunk) {
58
- let text = (this.carry + chunk.toString('latin1')).replace(/\x1b\]52;[^\x07\x1b]*(\x07|\x1b\\)/g, '');
59
- this.carry = '';
60
- const open = text.lastIndexOf('\x1b');
61
- const tail = open === -1 ? '' : text.slice(open);
62
- if (tail.length < 1 << 16 && /^\x1b(\](5(2(;[^\x07\x1b]*)?)?)?)?$/.test(tail)) {
63
- this.carry = tail;
64
- text = text.slice(0, open);
75
+ const out = Buffer.allocUnsafe(chunk.length + MAX_KEPT_STRING + 8);
76
+ let n = 0;
77
+ const put = (...bytes) => { for (const b of bytes)
78
+ out[n++] = b; };
79
+ /** After ESC (or the C1 form): ] opens an OSC (maybe kept), P X ^ _ open DCS / SOS / PM / APC (dropped). */
80
+ const opens = (b, c1) => {
81
+ if (b === (c1 ? 0x9d : 0x5d)) {
82
+ this.body = c1 ? null : [];
83
+ this.state = 'string';
84
+ return true;
85
+ }
86
+ if ((c1 ? [0x90, 0x98, 0x9e, 0x9f] : [0x50, 0x58, 0x5e, 0x5f]).includes(b)) {
87
+ this.body = null;
88
+ this.state = 'string';
89
+ return true;
90
+ }
91
+ return false;
92
+ };
93
+ const endString = (terminator) => {
94
+ const body = this.body && Buffer.from(this.body).toString('latin1');
95
+ if (body !== null && KEPT_OSC(body)) {
96
+ put(0x1b, 0x5d);
97
+ out.write(body, n, 'latin1');
98
+ n += body.length;
99
+ put(...terminator);
100
+ }
101
+ this.body = null;
102
+ this.state = 'text';
103
+ };
104
+ const collect = (b) => { if (this.body && this.body.push(b) > MAX_KEPT_STRING)
105
+ this.body = null; };
106
+ for (const b of chunk) {
107
+ switch (this.state) {
108
+ case 'text':
109
+ if (b === 0x1b)
110
+ this.state = 'esc';
111
+ else if (b === 0xc2)
112
+ this.state = 'c2';
113
+ else
114
+ put(b);
115
+ break;
116
+ case 'esc':
117
+ if (opens(b, false))
118
+ break;
119
+ if (b !== 0x1b) {
120
+ put(0x1b, b);
121
+ this.state = 'text';
122
+ }
123
+ else
124
+ put(0x1b);
125
+ break;
126
+ case 'c2':
127
+ if (opens(b, true))
128
+ break;
129
+ if (b === 0x1b) {
130
+ put(0xc2);
131
+ this.state = 'esc';
132
+ }
133
+ else if (b === 0xc2)
134
+ put(0xc2);
135
+ else {
136
+ put(0xc2, b);
137
+ this.state = 'text';
138
+ }
139
+ break;
140
+ case 'string':
141
+ if (b === 0x07)
142
+ endString([0x07]);
143
+ else if (b === 0x1b)
144
+ this.state = 'stringEsc';
145
+ else if (b === 0xc2)
146
+ this.state = 'stringC2';
147
+ else if (b === 0x18 || b === 0x1a) {
148
+ this.body = null;
149
+ this.state = 'text';
150
+ }
151
+ else
152
+ collect(b);
153
+ break;
154
+ case 'stringC2':
155
+ // U+009C is the C1 string terminator; any other character is part of the string.
156
+ if (b === 0x9c)
157
+ endString([0x1b, 0x5c]);
158
+ else {
159
+ collect(0xc2);
160
+ this.state = 'string';
161
+ if (b === 0x07)
162
+ endString([0x07]);
163
+ else if (b === 0x1b)
164
+ this.state = 'stringEsc';
165
+ else if (b !== 0xc2)
166
+ collect(b);
167
+ else
168
+ this.state = 'stringC2';
169
+ }
170
+ break;
171
+ case 'stringEsc':
172
+ if (b === 0x5c) {
173
+ endString([0x1b, 0x5c]);
174
+ break;
175
+ }
176
+ // ESC inside a string ends it (dropped) and starts a new sequence.
177
+ this.body = null;
178
+ this.state = 'esc';
179
+ if (!opens(b, false) && b !== 0x1b) {
180
+ put(0x1b, b);
181
+ this.state = 'text';
182
+ }
183
+ break;
184
+ }
65
185
  }
66
- return Buffer.from(text, 'latin1');
186
+ return out.subarray(0, n);
67
187
  }
68
188
  }
69
189
  const connectOnce = (host, port, timeoutMs) => {
@@ -138,6 +258,41 @@ export const fixWindowsKeys = (chunk) => {
138
258
  return chunk;
139
259
  return Buffer.from(chunk.map((b) => (b === 0x08 ? 0x7f : b === 0x7f ? 0x08 : b)));
140
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
+ };
141
296
  /**
142
297
  * Bridges this process's terminal to the remote program until it exits, the connection drops, or the user presses
143
298
  * the detach key. Restores the terminal before resolving.
@@ -147,13 +302,15 @@ export const runTerminalClient = (socket, token, options = {}) => {
147
302
  const stdout = process.stdout;
148
303
  // getWindowSize asks the console every time; columns/rows only change when Node noticed a resize.
149
304
  const size = () => { const [c, r] = stdout.isTTY ? stdout.getWindowSize() : [0, 0]; return [c || 120, r || 30]; };
150
- 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;
151
307
  return new Promise((resolve) => {
152
308
  let done = false;
153
309
  const reader = new FrameReader();
154
310
  const filter = new OutputFilter();
155
311
  let sent = size();
156
312
  let resizeTimer;
313
+ let vtInput;
157
314
  const finish = (result) => {
158
315
  if (done)
159
316
  return;
@@ -163,29 +320,45 @@ export const runTerminalClient = (socket, token, options = {}) => {
163
320
  clearTimeout(resizeTimer);
164
321
  clearInterval(heartbeat);
165
322
  clearInterval(sizeWatch);
166
- if (stdin.isTTY)
167
- stdin.setRawMode(false);
168
- stdin.pause();
323
+ clearTimeout(joinTimer);
169
324
  socket.destroy();
170
- stdout.write('\x1b[0m\x1b[?25h\r\n');
171
- 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
+ });
172
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)); };
173
339
  const onInput = (chunk) => {
174
340
  if (options.detachKey !== undefined && chunk.includes(options.detachKey)) {
175
341
  finish({ reason: 'detach' });
176
342
  return;
177
343
  }
178
- 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);
179
349
  };
180
- // Dragging a window edge fires dozens of resizes; each one makes ConPTY reflow and repaint, and repaints racing each
181
- // other are what left the screen garbled. Wait until the size settles, clear what this terminal reflowed on its own,
182
- // then let ptyhost repaint everything once at the new size.
350
+ // Windows' own ConPTY repaints the whole screen on every resize; dragging a window edge fires dozens of them, and
351
+ // repaints racing each other left the screen garbled. Wait until the size settles, clear what this terminal
352
+ // reflowed on its own, then let ptyhost repaint everything once at the new size. With conpty.dll (passthrough)
353
+ // nothing is repainted: this terminal's own reflow and scrollback stay, and the program redraws what it needs.
354
+ let passthrough = false;
183
355
  const sendSize = () => {
184
356
  const [c, r] = size();
185
357
  if (c === sent[0] && r === sent[1])
186
358
  return;
187
359
  sent = [c, r];
188
- stdout.write('\x1b[H\x1b[J');
360
+ if (!passthrough)
361
+ stdout.write('\x1b[H\x1b[J');
189
362
  socket.write(resizeFrame(c, r));
190
363
  };
191
364
  const onResize = () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { resizeTimer = undefined; sendSize(); }, 120); };
@@ -205,8 +378,10 @@ export const runTerminalClient = (socket, token, options = {}) => {
205
378
  for (const frame of frames) {
206
379
  if (frame.type === FrameType.Output)
207
380
  stdout.write(filter.push(frame.payload));
381
+ else if (frame.type === FrameType.Console)
382
+ passthrough = frame.payload.toString('latin1') === 'conpty.dll';
208
383
  else if (frame.type === FrameType.Restarting)
209
- stdout.write('\x1b[2J\x1b[3J\x1b[H');
384
+ stdout.write(MOUSE_OFF + '\x1b[2J\x1b[3J\x1b[H');
210
385
  else if (frame.type === FrameType.Exit)
211
386
  finish({ reason: 'exit', exitCode: frame.payload.length >= 4 ? frame.payload.readInt32LE(0) : 1 });
212
387
  else if (frame.type === FrameType.Error)
@@ -220,6 +395,10 @@ export const runTerminalClient = (socket, token, options = {}) => {
220
395
  socket.write(helloFrame(token, c, r));
221
396
  if (stdin.isTTY)
222
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; });
223
402
  stdin.on('data', onInput);
224
403
  stdin.resume();
225
404
  stdout.on('resize', onResize);
@@ -11,6 +11,8 @@ export declare const runPaths: (dataDir: string) => {
11
11
  responses: string;
12
12
  sessions: string;
13
13
  projects: string;
14
+ /** audit.processes: programs the sandbox started, in small .jsonl files the host takes in and deletes. */
15
+ audit: string;
14
16
  request: (id: string) => string;
15
17
  response: (id: string) => string;
16
18
  session: (id: string) => string;
@@ -19,6 +19,8 @@ export const runPaths = (dataDir) => {
19
19
  responses: path.join(run, 'responses'),
20
20
  sessions: path.join(run, 'sessions'),
21
21
  projects: path.join(run, 'projects'),
22
+ /** audit.processes: programs the sandbox started, in small .jsonl files the host takes in and deletes. */
23
+ audit: path.join(run, 'audit'),
22
24
  request: file('requests'),
23
25
  response: file('responses'),
24
26
  session: file('sessions'),
@@ -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;
@@ -19,6 +31,18 @@ export declare const SandboxStatus: z.ZodObject<{
19
31
  workspaceHost: z.ZodString;
20
32
  guestWorkspace: z.ZodString;
21
33
  headless: z.ZodBoolean;
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>>>;
22
46
  }, z.core.$strip>;
23
47
  /**
24
48
  * A project the host registered after boot (sandbox claude from another folder): everything the guest needs to
@@ -118,6 +142,9 @@ export declare const ProjectSpec: z.ZodObject<{
118
142
  }, z.core.$strict>;
119
143
  vscode: z.ZodObject<{
120
144
  enabled: z.ZodBoolean;
145
+ setup: z.ZodBoolean;
146
+ recommended: z.ZodBoolean;
147
+ extensions: z.ZodArray<z.ZodString>;
121
148
  }, z.core.$strict>;
122
149
  environment: z.ZodRecord<z.ZodString, z.ZodString>;
123
150
  }, z.core.$strict>;
@@ -259,6 +286,9 @@ export declare const GuestRequest: z.ZodDiscriminatedUnion<[z.ZodObject<{
259
286
  }, z.core.$strict>;
260
287
  vscode: z.ZodObject<{
261
288
  enabled: z.ZodBoolean;
289
+ setup: z.ZodBoolean;
290
+ recommended: z.ZodBoolean;
291
+ extensions: z.ZodArray<z.ZodString>;
262
292
  }, z.core.$strict>;
263
293
  environment: z.ZodRecord<z.ZodString, z.ZodString>;
264
294
  }, z.core.$strict>;
@@ -408,6 +438,9 @@ export declare const GuestRequest: z.ZodDiscriminatedUnion<[z.ZodObject<{
408
438
  }, z.core.$strict>;
409
439
  vscode: z.ZodObject<{
410
440
  enabled: z.ZodBoolean;
441
+ setup: z.ZodBoolean;
442
+ recommended: z.ZodBoolean;
443
+ extensions: z.ZodArray<z.ZodString>;
411
444
  }, z.core.$strict>;
412
445
  environment: z.ZodRecord<z.ZodString, z.ZodString>;
413
446
  }, z.core.$strict>;
@@ -522,6 +555,7 @@ export type ProjectSpecT = z.output<typeof ProjectSpec>;
522
555
  export type ProjectStatusT = z.output<typeof ProjectStatus>;
523
556
  export type AttachEndpointT = z.output<typeof AttachEndpoint>;
524
557
  export type SandboxStatusT = z.output<typeof SandboxStatus>;
558
+ export type BootProgressT = z.output<typeof BootProgress>;
525
559
  export type GuestRequestT = z.output<typeof GuestRequest>;
526
560
  export type GuestResponseT = z.output<typeof GuestResponse>;
527
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),
@@ -19,6 +29,10 @@ export const SandboxStatus = z.object({
19
29
  workspaceHost: Text(),
20
30
  guestWorkspace: Text(),
21
31
  headless: z.boolean(),
32
+ /** The sandbox's IPv4 addresses (for network.forwardPorts; the host only uses ones on its Hyper-V network). */
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),
22
36
  });
23
37
  const Version = z.strictObject({ spec: Text(128), source: Text(256) });
24
38
  const DatabasePlan = z.strictObject({
@@ -12,4 +12,5 @@ export declare const SYSTEM: {
12
12
  readonly robocopy: () => string;
13
13
  readonly shutdown: () => string;
14
14
  readonly dism: () => string;
15
+ readonly msiexec: () => string;
15
16
  };
@@ -16,4 +16,5 @@ export const SYSTEM = {
16
16
  robocopy: () => system32('Robocopy.exe'),
17
17
  shutdown: () => system32('shutdown.exe'),
18
18
  dism: () => system32('Dism.exe'),
19
+ msiexec: () => system32('msiexec.exe'),
19
20
  };
@@ -1,7 +1,7 @@
1
1
  // claude-mem data (~/.claude-mem) between this PC and the sandbox's persistent store.
2
2
  //
3
3
  // IN (this PC -> sandbox): the whole folder is mirrored; nothing flows to your PC in that direction.
4
- // OUT (sandbox -> this PC): ONLY the SQLite database files come back. Never settings, never the Chroma vector store:
4
+ // OUT (sandbox -> this PC), only with claudeProfile.syncClaudeMemBack: ONLY the SQLite database files come back. Never settings, never the Chroma vector store:
5
5
  // Chroma keeps Python pickle files, and a crafted pickle runs code when loaded, so copying it back would let a
6
6
  // compromised sandbox execute code on your PC. claude-mem rebuilds its vector index from the database by itself.
7
7
  import fs from 'node:fs';
@@ -10,7 +10,8 @@ import _ from 'lodash';
10
10
  import { dirExists, homeDir, isLink } from '../core/util.js';
11
11
  import { safeRemove } from '../shared/safe-remove.js';
12
12
  import { assertTestWritable } from '../host/test-guard.js';
13
- const SKIP_IN = /(^logs$|\.pid$|\.port$|\.lock$|^worker.*\.json$|\.incoming$|\.conflict-\d+$)/i;
13
+ // Never in: its settings (provider API keys), .env files and backups; nor run-time files that mean nothing elsewhere.
14
+ const SKIP_IN = /(^logs$|^backups?$|^settings\.json$|^\.env|\.pid$|\.port$|\.lock$|^worker.*\.json$|\.incoming$|\.conflict-\d+$)/i;
14
15
  /** The only files that may come back from the sandbox. */
15
16
  const DATABASE = /^claude-mem\.db(-wal|-shm)?$/i;
16
17
  const MAX_DB_BYTES = 2 * 1024 * 1024 * 1024;
@@ -7,6 +7,11 @@ declare const SyncStateSchema: z.ZodObject<{
7
7
  guestWorkspace: z.ZodString;
8
8
  syncHistory: z.ZodOptional<z.ZodBoolean>;
9
9
  syncClaudeMem: z.ZodOptional<z.ZodBoolean>;
10
+ syncClaudeMemBack: z.ZodOptional<z.ZodBoolean>;
11
+ historyScope: z.ZodOptional<z.ZodEnum<{
12
+ project: "project";
13
+ all: "all";
14
+ }>>;
10
15
  memHostStamp: z.ZodOptional<z.ZodNumber>;
11
16
  memGuestStamp: z.ZodOptional<z.ZodNumber>;
12
17
  projects: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -28,6 +33,7 @@ export interface SyncPaths extends WorkspacePair {
28
33
  /** Brings this PC's history into the sandbox state (run before the sandbox starts). Returns files copied. */
29
34
  export declare const syncHistoryIn: (p: SyncPaths, options?: {
30
35
  promptHistory?: boolean;
36
+ scope?: "project" | "all";
31
37
  }) => number;
32
38
  /** Copies new sandbox history back to this PC (safe while the sandbox runs: transcripts are append-only). */
33
39
  export declare const syncHistoryOut: (p: SyncPaths) => number;
@@ -7,8 +7,11 @@
7
7
  //
8
8
  // Safety (the sandbox side is untrusted):
9
9
  // - links are never followed or written through, on either side;
10
- // - what comes BACK is limited to transcripts (.jsonl), prompt history and to-do lists (.json), each capped in
11
- // size, and never Claude's memory folders. Nothing executable or instruction-like flows to this PC.
10
+ // - what comes BACK is limited to the sandbox's own projects: their transcripts (.jsonl) and prompt-history lines,
11
+ // plus to-do lists (.json), each capped in size, and never Claude's memory folders. A sandbox cannot plant a
12
+ // conversation for another project on this PC (claude --resume there would load it);
13
+ // - what goes IN is, by default (historyScope "project"), only the sandbox's own projects: other projects'
14
+ // conversations, file backups and prompts are not the agent's business.
12
15
  import fs from 'node:fs';
13
16
  import path from 'node:path';
14
17
  import _ from 'lodash';
@@ -25,7 +28,7 @@ const MAX_FILE_BYTES = 100 * 1024 * 1024;
25
28
  const AbsolutePath = z.string().max(1024).refine((p) => path.isAbsolute(p), 'must be an absolute path');
26
29
  const SyncStateSchema = z.object({
27
30
  hostClaudeDir: AbsolutePath, hostWorkspace: AbsolutePath, guestWorkspace: z.string().max(1024),
28
- syncHistory: z.boolean().optional(), syncClaudeMem: z.boolean().optional(),
31
+ syncHistory: z.boolean().optional(), syncClaudeMem: z.boolean().optional(), syncClaudeMemBack: z.boolean().optional(), historyScope: z.enum(['project', 'all']).optional(),
29
32
  /** Newest claude-mem database time on each side right after the last sync (detects edits on both sides). */
30
33
  memHostStamp: z.number().optional(), memGuestStamp: z.number().optional(),
31
34
  /** Projects registered while the sandbox ran (their history goes back to their own folders). */
@@ -91,6 +94,16 @@ const isJsonObject = (line) => { try {
91
94
  catch {
92
95
  return false;
93
96
  } };
97
+ /** A prompt-history line whose project is one of `workspaces` (on this PC's side). */
98
+ const inProjects = (line, workspaces) => {
99
+ try {
100
+ const project = JSON.parse(line).project;
101
+ return typeof project === 'string' && workspaces.some((w) => path.resolve(w).toLowerCase() === path.resolve(project).toLowerCase());
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ };
94
107
  const lineTime = (line) => { try {
95
108
  return Number(JSON.parse(line).timestamp) || 0;
96
109
  }
@@ -102,22 +115,24 @@ const pairsOf = (p) => _.sortBy([{ hostWorkspace: p.hostWorkspace, guestWorkspac
102
115
  const translateAll = (text, pairs) => pairs.reduce((out, [from, to]) => translatePaths(out, from, to), text);
103
116
  /** Brings this PC's history into the sandbox state (run before the sandbox starts). Returns files copied. */
104
117
  export const syncHistoryIn = (p, options = {}) => {
118
+ const all = options.scope === 'all';
105
119
  assertTestWritable(p.guestClaudeDir);
106
120
  const pairs = pairsOf(p);
107
121
  const byHost = new Map(pairs.map((x) => [projectFolderName(x.hostWorkspace), projectFolderName(x.guestWorkspace)]));
108
122
  const guestNames = new Set(pairs.map((x) => projectFolderName(x.guestWorkspace)));
109
123
  const transform = (t) => translateAll(t, pairs.map((x) => [x.hostWorkspace, x.guestWorkspace]));
110
124
  const hostProjects = path.join(p.hostClaudeDir, 'projects');
111
- const names = dirExists(hostProjects) ? fs.readdirSync(hostProjects).filter((name) => isSafeFileName(name) && (!guestNames.has(name) || byHost.has(name))) : [];
125
+ const names = dirExists(hostProjects) ? fs.readdirSync(hostProjects).filter((name) => isSafeFileName(name) && (all ? !guestNames.has(name) || byHost.has(name) : byHost.has(name))) : [];
112
126
  const projects = _.sumBy(names, (name) => byHost.has(name)
113
127
  ? copyNewer(path.join(hostProjects, name), path.join(p.guestClaudeDir, 'projects', byHost.get(name)), { transform })
114
128
  : copyNewer(path.join(hostProjects, name), path.join(p.guestClaudeDir, 'projects', name)));
115
- const sessions = _.sumBy(SESSION_DIRS, (dir) => copyNewer(path.join(p.hostClaudeDir, dir), path.join(p.guestClaudeDir, dir)));
129
+ // To-do lists, plans and file backups are kept by session, not by project: only with historyScope "all".
130
+ const sessions = all ? _.sumBy(SESSION_DIRS, (dir) => copyNewer(path.join(p.hostClaudeDir, dir), path.join(p.guestClaudeDir, dir))) : 0;
116
131
  if (options.promptHistory === false)
117
132
  return projects + sessions;
118
133
  // Prompt history: rebuild the sandbox copy from this PC's file plus anything only the sandbox has.
119
134
  const target = path.join(p.guestClaudeDir, 'history.jsonl');
120
- const hostLines = readLines(path.join(p.hostClaudeDir, 'history.jsonl')).map(transform);
135
+ const hostLines = readLines(path.join(p.hostClaudeDir, 'history.jsonl')).filter((l) => all || inProjects(l, pairs.map((x) => x.hostWorkspace))).map(transform);
121
136
  const known = new Set(hostLines);
122
137
  const merged = _.sortBy([...hostLines, ...readLines(target).filter((l) => !known.has(l))], lineTime);
123
138
  if (merged.length) {
@@ -136,15 +151,15 @@ export const syncHistoryOut = (p) => {
136
151
  const transform = (t) => translateAll(t, pairs);
137
152
  const guestProjectsDir = path.join(p.guestClaudeDir, 'projects');
138
153
  const names = dirExists(guestProjectsDir) && !isLink(guestProjectsDir) ? fs.readdirSync(guestProjectsDir).filter(isSafeFileName) : [];
139
- const projects = _.sumBy(names, (name) => byGuest.has(name)
140
- ? copyNewer(path.join(guestProjectsDir, name), path.join(p.hostClaudeDir, 'projects', byGuest.get(name)), { accept: transcript, skipDirs: NO_MEMORY, transform })
141
- : copyNewer(path.join(guestProjectsDir, name), path.join(p.hostClaudeDir, 'projects', name), { accept: transcript, skipDirs: NO_MEMORY }));
154
+ // Only this sandbox's projects: a transcript under any other folder name would be planted into that project on this PC.
155
+ const projects = _.sumBy(names.filter((name) => byGuest.has(name)), (name) => copyNewer(path.join(guestProjectsDir, name), path.join(p.hostClaudeDir, 'projects', byGuest.get(name)), { accept: transcript, skipDirs: NO_MEMORY, transform }));
142
156
  // Only to-do lists come back besides transcripts; plans and file backups stay in the sandbox.
143
157
  const sessions = copyNewer(path.join(p.guestClaudeDir, 'todos'), path.join(p.hostClaudeDir, 'todos'), { accept: json });
144
158
  const hostFile = path.join(p.hostClaudeDir, 'history.jsonl');
145
159
  const known = new Set(readLines(hostFile));
146
- // Only prompt-history entries (one JSON object per line) come back, whatever else the sandbox wrote in that file.
147
- const missing = readLines(path.join(p.guestClaudeDir, 'history.jsonl')).filter(isJsonObject).map(transform).filter((l) => !known.has(l));
160
+ // Only prompt-history entries (one JSON object per line) of this sandbox's projects come back.
161
+ const hostWorkspaces = pairsOf(p).map((x) => x.hostWorkspace);
162
+ const missing = readLines(path.join(p.guestClaudeDir, 'history.jsonl')).filter(isJsonObject).map(transform).filter((l) => inProjects(l, hostWorkspaces) && !known.has(l));
148
163
  if (missing.length) {
149
164
  ensureDir(p.hostClaudeDir);
150
165
  fs.appendFileSync(hostFile, missing.join('\n') + '\n', 'utf8');