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
@@ -4,13 +4,14 @@
4
4
  // as folders join);
5
5
  // - known-projects.json: every folder this tool has started a project in (for projects.all and `sandbox projects`).
6
6
  // Everything about HOW a folder becomes visible is behind ProjectMapper, so the mechanisms can change independently.
7
+ import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { z } from 'zod';
9
10
  import { assertSafeWorkspace } from '../core/safe-paths.js';
10
11
  import { dirExists, fail } from '../core/util.js';
11
12
  import { Id } from '../shared/schema.js';
12
13
  import { readValid, writeJsonAtomic } from '../shared/rundir.js';
13
- import { guestDirFor, hubName, isWithin, PROJECTS_GUEST, projectForHostDir } from '../shared/projects.js';
14
+ import { guestDirFor, hubName, isWithin, ownGuestFolder, PROJECTS_GUEST, projectForHostDir } from '../shared/projects.js';
14
15
  import { detectWsb, runningSandboxId, shareFolder } from './wsb.js';
15
16
  export { PROJECTS_GUEST };
16
17
  const AbsolutePath = z.string().max(1024).refine((p) => path.isAbsolute(p), 'must be an absolute path');
@@ -24,6 +25,13 @@ const Registry = z.strictObject({
24
25
  protections: z.record(z.string(), z.union([z.boolean(), z.string()])),
25
26
  roots: z.array(z.strictObject({ host: AbsolutePath, guest: z.string().max(1024) })).max(64),
26
27
  projects: z.array(Entry).max(1024),
28
+ /** What the sandbox booted with that a joining project may also want (missing in registries of 1.4.0 and older). */
29
+ atBoot: z.strictObject({
30
+ memoryMb: z.number().int().positive().nullable(),
31
+ mappings: z.array(z.strictObject({ host: z.string().max(1024), guest: z.string().max(1024), readOnly: z.boolean() })).max(1024),
32
+ /** Boot settings turned on for a project mapped at boot (setting -> project): other projects share them. */
33
+ widened: z.record(z.string().max(100), z.string().max(100)),
34
+ }).optional(),
27
35
  });
28
36
  const registryFile = (storageRoot) => path.join(storageRoot, 'run', 'projects.json');
29
37
  export const readRegistry = (storageRoot) => readValid(Registry, registryFile(storageRoot));
@@ -34,17 +42,57 @@ export const startRegistry = (storageRoot, o) => {
34
42
  const primary = { name: hubName(o.primary.host, []), host: o.primary.host, guest: o.primary.guest, primary: true, mechanism: 'launch', registeredAt: at };
35
43
  const names = [primary.name];
36
44
  const boot = o.boot.map((b) => { const name = hubName(b.host, names); names.push(name); return { name, host: b.host, guest: b.guest, primary: false, mechanism: 'boot', registeredAt: at }; });
37
- saveRegistry(storageRoot, { launchId: o.launchId, protections: o.protections, roots: o.roots, projects: [primary, ...boot] });
45
+ saveRegistry(storageRoot, { launchId: o.launchId, protections: o.protections, roots: o.roots, projects: [primary, ...boot], ...(o.atBoot ? { atBoot: o.atBoot } : {}) });
38
46
  return primary;
39
47
  };
40
- /** Why a folder cannot join the running sandbox without a restart (the caller offers one). */
48
+ /** Reads the memory and mapped folders back from a .wsb this tool wrote (host-only launch folder; bounded). */
49
+ export const parseWsbBoot = (xml) => {
50
+ const text = xml.slice(0, 1_000_000);
51
+ const unescape = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, '\'').replace(/&amp;/g, '&');
52
+ const mappings = [...text.matchAll(/<MappedFolder><HostFolder>([^<]{1,1024})<\/HostFolder><SandboxFolder>([^<]{1,1024})<\/SandboxFolder><ReadOnly>(true|false)<\/ReadOnly><\/MappedFolder>/g)]
53
+ .slice(0, 1024).map((m) => ({ host: unescape(m[1]), guest: unescape(m[2]), readOnly: m[3] === 'true' }));
54
+ const memory = /<MemoryInMB>(\d{1,7})<\/MemoryInMB>/.exec(text);
55
+ return { memoryMb: memory ? Number(memory[1]) : null, mappings, widened: {} };
56
+ };
57
+ /**
58
+ * What the running sandbox booted with. A sandbox started by 1.4.0 or older has no record: it is read from the .wsb in
59
+ * its launch folder (host-only storage) and recorded. null when neither is there.
60
+ */
61
+ export const bootRecordOf = (storageRoot, launchPath) => {
62
+ const registry = readRegistry(storageRoot);
63
+ if (!registry)
64
+ return null;
65
+ if (registry.atBoot)
66
+ return registry.atBoot;
67
+ if (!launchPath)
68
+ return null;
69
+ let xml;
70
+ try {
71
+ xml = fs.readFileSync(path.join(launchPath, 'agent.wsb'), 'utf8');
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ const atBoot = parseWsbBoot(xml);
77
+ try {
78
+ saveRegistry(storageRoot, { ...registry, atBoot });
79
+ }
80
+ catch { /* the record is a convenience */ }
81
+ return atBoot;
82
+ };
83
+ /**
84
+ * Why a folder cannot join the running sandbox without a restart (the caller offers one). With `missing`: it could
85
+ * join, but you chose to restart for what it wants at boot (core/project-policy.ts).
86
+ */
41
87
  export class NeedsRestart extends Error {
42
88
  hostDir;
43
89
  reason;
44
- constructor(hostDir, reason) {
90
+ missing;
91
+ constructor(hostDir, reason, missing) {
45
92
  super(reason);
46
93
  this.hostDir = hostDir;
47
94
  this.reason = reason;
95
+ this.missing = missing;
48
96
  }
49
97
  }
50
98
  /** Roots first (instant), then `wsb share` where the Windows Sandbox CLI exists, else NeedsRestart. */
@@ -84,7 +132,8 @@ export const projectMapper = (o) => {
84
132
  const sandbox = runningSandboxId(wsb);
85
133
  if (!sandbox.id)
86
134
  throw new NeedsRestart(host, 'wsb share could not find the running sandbox: ' + sandbox.error);
87
- const guest = path.win32.join(PROJECTS_GUEST, name);
135
+ // Where it is as a launch folder too (C:\Workspace\<name>), unless another project already uses that place.
136
+ const guest = ownGuestFolder(host, false, [...reg.projects.map((p) => p.guest), ...reg.roots.map((r) => r.guest)]);
88
137
  const shared = shareFolder(wsb, sandbox.id, host, guest);
89
138
  if (!shared.ok)
90
139
  throw new NeedsRestart(host, 'wsb share failed (untested on this Windows): ' + shared.error);
@@ -1,12 +1,19 @@
1
- export interface RepoSnapshot {
2
- gitDir: boolean;
1
+ /** A git folder's own state: config lines, hooks, redirect files. */
2
+ export interface GitState {
3
3
  config: string[];
4
4
  hooks: Record<string, string>;
5
5
  redirects: string[];
6
+ }
7
+ export interface RepoSnapshot extends GitState {
8
+ gitDir: boolean;
6
9
  commands: Record<string, string>;
10
+ /** Fingerprints of AGENT_FILES (missing in snapshots of older versions: then they are not checked). */
11
+ agent?: Record<string, string>;
12
+ /** Other git folders: submodules (.git/modules/...) and repositories nested in the project, by relative path. */
13
+ repos?: Record<string, GitState>;
7
14
  }
8
15
  export interface RepoFinding {
9
- kind: 'hook' | 'config' | 'git' | 'command';
16
+ kind: 'hook' | 'config' | 'git' | 'command' | 'agent';
10
17
  detail: string;
11
18
  }
12
19
  export declare const snapshotRepo: (workspace: string) => RepoSnapshot;
@@ -6,17 +6,22 @@
6
6
  // - git: new or changed hooks, and config that makes git run programs (core.fsmonitor, core.sshCommand, filters,
7
7
  // include.path, ...), plus files that redirect where git reads config and hooks (commondir, config.worktree);
8
8
  // - commands: executables dropped in the project root under the name of something you type there (node.exe,
9
- // git.cmd, npm.cmd, ...), because Windows runs a program from the current folder before searching PATH.
9
+ // git.cmd, npm.cmd, ...), because Windows runs a program from the current folder before searching PATH;
10
+ // - agent and editor files: hooks, helper commands and MCP servers in .claude/ and .mcp.json, VS Code folder-open
11
+ // tasks and program paths, .envrc. Submodules and nested repositories get the git checks too.
10
12
  import crypto from 'node:crypto';
11
13
  import fs from 'node:fs';
12
14
  import path from 'node:path';
13
15
  import _ from 'lodash';
14
16
  import { z } from 'zod';
17
+ import { parse as parseJsonc } from 'jsonc-parser';
15
18
  import { readValid, writeFileAtomic, writeJsonAtomic } from '../shared/rundir.js';
16
19
  import { assertTestWritable } from './test-guard.js';
17
20
  import { dirExists, fileExists, isLink } from '../core/util.js';
18
21
  /** Config keys that make git run a program, or load more config. */
19
- const DANGEROUS_KEY = /^(fsmonitor|hookspath|sshcommand|pager|editor|askpass|program|gpgprogram|textconv|command|cmd|clean|smudge|process|driver|helper|uploadpack|receivepack|path|attributesfile|excludesfile|worktree|worktreeconfig|gitproxy|proxycommand|sendemailvalidate|tool)\s*=/i;
22
+ const DANGEROUS_KEY = /^(fsmonitor|hookspath|sshcommand|pager|editor|askpass|program|gpgprogram|textconv|command|cmd|clean|smudge|process|driver|helper|uploadpack|receivepack|path|attributesfile|excludesfile|worktree|worktreeconfig|gitproxy|proxycommand|sendemailvalidate|tool|external|difffilter|defaultkeycommand|alternaterefscommand)\s*=/i;
23
+ /** Sections where any key runs a program ([pager] log = x), or where a "!" value does ([alias] st = !x). */
24
+ const dangerousInSection = (section, keyPart) => (section === 'pager' && keyPart.includes('=')) || (section === 'alias' && /^[^=]+=\s*"?\s*!/.test(keyPart));
20
25
  const SECTION = /^\s*\[([^\]]*)\]\s*/;
21
26
  const MARK = '# [agent-sandbox] disabled, added inside the sandbox: ';
22
27
  /** Files in .git that make git read config/hooks from somewhere else. */
@@ -33,6 +38,41 @@ const COMMANDS = new RegExp('^(' + [
33
38
  'make', 'cmake', 'ninja', 'gcc', 'g\\+\\+', 'clang', 'psql', 'mysql', 'mariadb', 'mongosh', 'redis-cli', 'docker', 'kubectl', 'terraform', 'az', 'aws',
34
39
  'powershell', 'pwsh', 'cmd', 'where', 'tasklist', 'tar', 'curl', 'ssh', 'scp',
35
40
  ].join('|') + ')\\.(exe|com|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc|ps1)$', 'i');
41
+ /**
42
+ * Project files that tools on this PC act on by themselves: Claude Code runs a project's hooks, helpers and MCP servers,
43
+ * VS Code runs folder-open tasks and the programs workspace settings point at, direnv runs .envrc. For each, only the
44
+ * part that can run something is compared (`risky`), so the agent may still edit the rest (permissions, formatting).
45
+ */
46
+ const AGENT_FILES = {
47
+ '.claude/settings.json': (d) => claudeRisk(d),
48
+ '.claude/settings.local.json': (d) => claudeRisk(d),
49
+ '.mcp.json': (_d, text) => text,
50
+ '.vscode/tasks.json': (d) => JSON.stringify(d).match(/"runOn"\s*:\s*"folderOpen"/) ? d : null,
51
+ '.vscode/settings.json': (d) => _.pickBy(_.isPlainObject(d) ? d : {}, (_v, k) => /path|executable|command|interpreter|shell|profile|args|env|exec|program|server|launch|runtime|binary/i.test(k)),
52
+ '.envrc': (_d, text) => text,
53
+ };
54
+ const claudeRisk = (d) => {
55
+ const s = (_.isPlainObject(d) ? d : {});
56
+ const permissions = (_.isPlainObject(s.permissions) ? s.permissions : {});
57
+ return { ..._.pick(s, ['hooks', 'apiKeyHelper', 'awsAuthRefresh', 'awsCredentialExport', 'otelHeadersHelper', 'statusLine', 'env', 'enableAllProjectMcpServers', 'enabledMcpjsonServers', 'disableAllHooks']), permissions: _.pick(permissions, ['defaultMode', 'additionalDirectories']) };
58
+ };
59
+ const MAX_AGENT_FILE_BYTES = 1024 * 1024;
60
+ /** What can run in one agent file, as a short fingerprint (null = the file is not there). */
61
+ const agentRisk = (workspace, rel) => {
62
+ const file = path.join(workspace, ...rel.split('/'));
63
+ if (!fs.existsSync(file))
64
+ return null;
65
+ if (isLink(file) || isLink(path.dirname(file)) || !fileExists(file))
66
+ return 'link-or-not-a-file';
67
+ if (fs.statSync(file).size > MAX_AGENT_FILE_BYTES)
68
+ return 'too-large-' + hash(file);
69
+ const text = fs.readFileSync(file, 'utf8');
70
+ const errors = [];
71
+ const data = parseJsonc(text, errors, { allowTrailingComma: true });
72
+ const risk = AGENT_FILES[rel](rel === '.envrc' ? null : data, text);
73
+ return crypto.createHash('sha256').update(JSON.stringify(risk ?? null) + (errors.length && rel !== '.envrc' && rel !== '.mcp.json' ? hash(file) : '')).digest('hex');
74
+ };
75
+ const agentSnapshot = (workspace) => Object.fromEntries(Object.keys(AGENT_FILES).flatMap((rel) => { const r = agentRisk(workspace, rel); return r ? [[rel, r]] : []; }));
36
76
  /** SHA-256 in small chunks: a sandbox can plant a multi-gigabyte "node.exe" to exhaust this PC's memory. */
37
77
  const hash = (file) => {
38
78
  if (isLink(file))
@@ -68,14 +108,66 @@ const configLines = (dir) => {
68
108
  const file = path.join(dir, 'config');
69
109
  return fileExists(file) && !isLink(file) && fs.statSync(file).size <= MAX_CONFIG_BYTES ? fs.readFileSync(file, 'utf8').split(/\r?\n/) : [];
70
110
  };
111
+ const gitState = (dir) => ({
112
+ config: dir ? configLines(dir) : [],
113
+ hooks: dir ? filesIn(path.join(dir, 'hooks'), (n) => !n.endsWith('.sample')) : {},
114
+ redirects: dir ? REDIRECTS.filter((n) => fs.existsSync(path.join(dir, n))) : [],
115
+ });
116
+ const EMPTY_GIT = { config: [], hooks: {}, redirects: [] };
117
+ const MAX_OTHER_REPOS = 64;
118
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'agent-sandbox-quarantine', 'target', 'dist', 'build', 'bin', 'obj', '.venv', 'venv']);
119
+ const plainDirs = (dir, limit) => {
120
+ try {
121
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.isSymbolicLink()).slice(0, limit).map((e) => path.join(dir, e.name));
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ };
127
+ /**
128
+ * Git folders other than the project's own: submodules (.git/modules/**) and repositories nested up to two levels deep.
129
+ * git runs their hooks and config too (git status in a subfolder, VS Code's repository scan). Bounded: links are never
130
+ * followed and at most MAX_OTHER_REPOS are looked at.
131
+ */
132
+ const otherGitDirs = (workspace) => {
133
+ const found = [];
134
+ const main = plainGitDir(workspace);
135
+ // Submodules: a folder with a config and a HEAD is a git folder, and may have modules of its own.
136
+ const walk = (dir, depth) => {
137
+ for (const d of plainDirs(dir, 200)) {
138
+ if (found.length >= MAX_OTHER_REPOS)
139
+ return;
140
+ if (fileExists(path.join(d, 'config')) && fileExists(path.join(d, 'HEAD'))) {
141
+ found.push(d);
142
+ if (depth < 3)
143
+ walk(path.join(d, 'modules'), depth + 1);
144
+ }
145
+ else if (depth < 3)
146
+ walk(d, depth + 1);
147
+ }
148
+ };
149
+ if (main)
150
+ walk(path.join(main, 'modules'), 0);
151
+ for (const sub of plainDirs(workspace, 500).filter((d) => !SKIP_DIRS.has(path.basename(d).toLowerCase()))) {
152
+ for (const candidate of [sub, ...plainDirs(sub, 200).filter((d) => !SKIP_DIRS.has(path.basename(d).toLowerCase()))]) {
153
+ if (found.length >= MAX_OTHER_REPOS)
154
+ return found;
155
+ const git = plainGitDir(candidate);
156
+ if (git)
157
+ found.push(git);
158
+ }
159
+ }
160
+ return found;
161
+ };
162
+ const relative = (workspace, dir) => path.relative(workspace, dir).split(path.sep).join('/');
71
163
  export const snapshotRepo = (workspace) => {
72
164
  const dir = plainGitDir(workspace);
73
165
  return {
74
166
  gitDir: dir !== null,
75
- config: dir ? configLines(dir) : [],
76
- hooks: dir ? filesIn(path.join(dir, 'hooks'), (n) => !n.endsWith('.sample')) : {},
77
- redirects: dir ? REDIRECTS.filter((n) => fs.existsSync(path.join(dir, n))) : [],
167
+ ...gitState(dir),
78
168
  commands: filesIn(workspace, (n) => COMMANDS.test(n)),
169
+ agent: agentSnapshot(workspace),
170
+ repos: Object.fromEntries(otherGitDirs(workspace).map((d) => [relative(workspace, d), gitState(d)])),
79
171
  };
80
172
  };
81
173
  /** Moves files into <dir>\agent-sandbox-quarantine\<time>\ and reports them. */
@@ -101,16 +193,19 @@ const disableConfig = (dir, before) => {
101
193
  const seen = {};
102
194
  const findings = [];
103
195
  let inInclude = false;
196
+ let section = '';
104
197
  const fixed = lines.map((line) => {
105
198
  const header = SECTION.exec(line);
106
- if (header)
199
+ if (header) {
200
+ section = header[1].trim().toLowerCase().split(/\s/)[0];
107
201
  inInclude = /^include(if\s+"[^"]*")?$/i.test(header[1].trim());
202
+ }
108
203
  seen[line] = (seen[line] ?? 0) + 1;
109
204
  if (line.startsWith(MARK) || seen[line] <= (known[line] ?? 0))
110
205
  return line;
111
206
  // git also accepts "[core] fsmonitor = x" on one line, so look past a leading section header.
112
207
  const keyPart = line.replace(SECTION, '').trim();
113
- if (!(DANGEROUS_KEY.test(keyPart) || (inInclude && keyPart.includes('='))))
208
+ if (!(DANGEROUS_KEY.test(keyPart) || (inInclude && keyPart.includes('=')) || dangerousInSection(section, keyPart)))
114
209
  return line;
115
210
  findings.push({ kind: 'config', detail: line.trim() });
116
211
  return MARK + line.trim();
@@ -133,6 +228,15 @@ export const guardRepo = (workspace, before) => {
133
228
  const findings = [];
134
229
  const commandsNow = filesIn(workspace, (n) => COMMANDS.test(n));
135
230
  findings.push(...quarantine(workspace, Object.keys(commandsNow).filter((n) => before.commands[n] !== commandsNow[n]), workspace, 'command'));
231
+ // Snapshots from before these checks existed have no baseline: checking against nothing would move your own files.
232
+ if (before.agent)
233
+ findings.push(...guardAgentFiles(workspace, before.agent));
234
+ if (before.repos) {
235
+ for (const other of otherGitDirs(workspace)) {
236
+ const rel = relative(workspace, other);
237
+ findings.push(...guardGitDir(other, before.repos[rel] ?? EMPTY_GIT).map((f) => ({ ...f, detail: rel + ': ' + f.detail })));
238
+ }
239
+ }
136
240
  const dir = plainGitDir(workspace);
137
241
  if (!dir) {
138
242
  const replaced = before.gitDir && fs.existsSync(path.join(workspace, '.git'));
@@ -140,26 +244,50 @@ export const guardRepo = (workspace, before) => {
140
244
  findings.push({ kind: 'git', detail: '.git was replaced by a link or a "gitdir:" file inside the sandbox. Do not run git in ' + workspace + ' until you have checked it.' });
141
245
  return findings;
142
246
  }
247
+ findings.push(...guardGitDir(dir, before));
248
+ return findings;
249
+ };
250
+ const guardGitDir = (dir, before) => {
143
251
  const hooksDir = path.join(dir, 'hooks');
144
252
  const hooksNow = filesIn(hooksDir, (n) => !n.endsWith('.sample'));
145
- findings.push(...quarantine(hooksDir, Object.keys(hooksNow).filter((n) => before.hooks[n] !== hooksNow[n]), dir, 'hook'));
146
- findings.push(...quarantine(dir, REDIRECTS.filter((n) => fs.existsSync(path.join(dir, n)) && !before.redirects.includes(n)), dir, 'git'));
147
- findings.push(...disableConfig(dir, before.config));
148
- return findings;
253
+ return [
254
+ ...quarantine(hooksDir, Object.keys(hooksNow).filter((n) => before.hooks[n] !== hooksNow[n]), dir, 'hook'),
255
+ ...quarantine(dir, REDIRECTS.filter((n) => fs.existsSync(path.join(dir, n)) && !before.redirects.includes(n)), dir, 'git'),
256
+ ...disableConfig(dir, before.config),
257
+ ];
258
+ };
259
+ /** Agent files whose runnable part changed are moved to the quarantine folder (the rest of your edits are in there too). */
260
+ const guardAgentFiles = (workspace, before) => {
261
+ const changed = Object.keys(AGENT_FILES).filter((rel) => { const now = agentRisk(workspace, rel); return now !== null && now !== before[rel]; });
262
+ if (!changed.length)
263
+ return [];
264
+ const target = path.join(workspace, 'agent-sandbox-quarantine', String(Date.now()));
265
+ fs.mkdirSync(target, { recursive: true });
266
+ return changed.map((rel) => {
267
+ const what = rel + ' (the sandbox added something it would run on this PC: hooks, MCP servers, helper commands or tasks';
268
+ try {
269
+ fs.renameSync(path.join(workspace, ...rel.split('/')), path.join(target, rel.replace(/\//g, '_')));
270
+ }
271
+ catch (e) {
272
+ return { kind: 'agent', detail: what + '; could not be moved: ' + e.code + '. Check it before you open this folder in Claude Code or VS Code)' };
273
+ }
274
+ return { kind: 'agent', detail: what + '; moved to ' + target + ')' };
275
+ });
149
276
  };
150
277
  // ---- snapshot storage (host-only, next to the sync state) ---------------------------------------------------------
151
278
  const syncDir = (dataPath) => path.join(path.dirname(path.dirname(dataPath)), 'sync');
152
279
  const snapshotFile = (dataPath) => path.join(syncDir(dataPath), path.basename(dataPath) + '-repo.json');
153
280
  const findingsFile = (dataPath) => path.join(syncDir(dataPath), path.basename(dataPath) + '-repo-findings.json');
154
- const Finding = z.object({ kind: z.enum(['hook', 'config', 'git', 'command']), detail: z.string().max(4096), at: z.string().max(64).optional() });
281
+ const Finding = z.object({ kind: z.enum(['hook', 'config', 'git', 'command', 'agent']), detail: z.string().max(4096), at: z.string().max(64).optional() });
282
+ const GitStateSchema = z.object({ config: z.array(z.string()), hooks: z.record(z.string(), z.string()), redirects: z.array(z.string()) });
155
283
  const Saved = z.object({
156
284
  workspace: z.string().max(1024).refine((p) => path.isAbsolute(p)),
157
- snapshot: z.object({ gitDir: z.boolean(), config: z.array(z.string()), hooks: z.record(z.string(), z.string()), redirects: z.array(z.string()), commands: z.record(z.string(), z.string()) }),
285
+ snapshot: GitStateSchema.extend({ gitDir: z.boolean(), commands: z.record(z.string(), z.string()), agent: z.record(z.string(), z.string()).optional(), repos: z.record(z.string(), GitStateSchema).optional() }),
158
286
  });
159
287
  const Store = z.strictObject({ projects: z.array(Saved).max(4096), roots: z.array(z.string().max(1024).refine((p) => path.isAbsolute(p))).max(64) });
160
288
  const StoreAny = z.union([Store, Saved.transform((s) => ({ projects: [s], roots: [] }))]);
161
289
  const readStore = (dataPath) => readValid(StoreAny, snapshotFile(dataPath)) ?? { projects: [], roots: [] };
162
- const EMPTY = { gitDir: false, config: [], hooks: {}, redirects: [], commands: {} };
290
+ const EMPTY = { gitDir: false, config: [], hooks: {}, redirects: [], commands: {}, agent: {}, repos: {} };
163
291
  const MAX_ROOT_PROJECTS = 2000;
164
292
  /** The folders directly inside a project root (plain folders only; links are never followed). */
165
293
  const rootProjects = (root) => {
@@ -0,0 +1,67 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ import { z } from 'zod';
3
+ declare const Life: z.ZodObject<{
4
+ started: z.ZodString;
5
+ lastSeen: z.ZodString;
6
+ keepAwake: z.ZodBoolean;
7
+ ended: z.ZodOptional<z.ZodObject<{
8
+ at: z.ZodString;
9
+ cause: z.ZodNullable<z.ZodObject<{
10
+ kind: z.ZodEnum<{
11
+ restart: "restart";
12
+ updates: "updates";
13
+ crash: "crash";
14
+ sleep: "sleep";
15
+ memory: "memory";
16
+ sandboxCrash: "sandboxCrash";
17
+ }>;
18
+ at: z.ZodString;
19
+ text: z.ZodString;
20
+ }, z.core.$strip>>;
21
+ }, z.core.$strip>>;
22
+ reported: z.ZodOptional<z.ZodBoolean>;
23
+ }, z.core.$strip>;
24
+ type LifeT = z.infer<typeof Life>;
25
+ export declare const readLife: (storageRoot: string, dataPath: string) => LifeT | null;
26
+ /** At launch: a fresh record (the previous one has been reported by then). */
27
+ export declare const startLife: (storageRoot: string, dataPath: string, keepAwake: boolean, now?: Date) => void;
28
+ /** Every minute while the sandbox runs. */
29
+ export declare const beatLife: (storageRoot: string, dataPath: string, now?: Date) => void;
30
+ /** The watcher saw the sandbox close while this PC kept running: closed by you (no cause), or by sleep / low memory. */
31
+ export declare const endLife: (storageRoot: string, dataPath: string, cause: StopCause | null, now?: Date) => void;
32
+ export interface StopEvent {
33
+ at: string;
34
+ id: number;
35
+ provider: string;
36
+ process: string;
37
+ }
38
+ export type StopKind = 'updates' | 'restart' | 'crash' | 'sleep' | 'memory' | 'sandboxCrash';
39
+ export interface StopCause {
40
+ kind: StopKind;
41
+ at: string;
42
+ text: string;
43
+ }
44
+ /**
45
+ * The first event that ends a running sandbox (pure): Windows inside the sandbox crashing (Hyper-V 18590; `process`
46
+ * holds its message with the stop code), a restart or shutdown, a crash or power loss, sleep, low memory. Switching
47
+ * user does not end it (seen on 13 Oct 2026: the sandbox ran on after a switch).
48
+ */
49
+ export declare const explainStop: (events: StopEvent[]) => StopCause | null;
50
+ /** System log and Hyper-V events since `since` that can end a sandbox (Windows only; empty when the logs cannot be read). */
51
+ export declare const readStopEvents: (since: Date) => StopEvent[];
52
+ /** Looking back from the last time the sandbox was seen running (a minute between checks, plus margin). */
53
+ export declare const causeSince: (lastSeen: string) => StopCause | null;
54
+ /**
55
+ * At launch: one warning when the last sandbox ended without anyone closing it (the PC restarted, crashed, slept or ran
56
+ * out of memory), then never again for that run. `watcherAlive` = its watcher is still running (then it did not end).
57
+ */
58
+ export declare const lastStopWarning: (storageRoot: string, dataPath: string, watcherAlive: boolean, find?: (lastSeen: string) => StopCause | null) => string | null;
59
+ /** Windows has installed updates and waits to restart: it may do so on its own, outside your active hours. */
60
+ export declare const updateRestartPending: () => boolean;
61
+ export declare const UPDATE_RESTART_WARNING = "Windows is waiting to restart for updates and may do so on its own (often overnight), which closes the sandbox. Restart first, or pause updates: Settings > Windows Update > Pause updates.";
62
+ /**
63
+ * sandbox.keepAwake: asks Windows not to sleep while the sandbox runs (as a video player does; the screen may still turn
64
+ * off). A small PowerShell holds the request until `ownerPid` exits, so it never outlives the watcher.
65
+ */
66
+ export declare const keepAwake: (ownerPid: number) => ChildProcess | null;
67
+ export {};
@@ -0,0 +1,126 @@
1
+ // Why the last sandbox stopped, when nobody stopped it. The background watcher writes a host-only "life" record (when
2
+ // the sandbox started, when it was last seen running, how it ended) and keeps this PC awake while it runs
3
+ // (sandbox.keepAwake). When the PC restarts, sleeps or crashes, the watcher stops too and the record has no end: the
4
+ // next launch reads Windows' System log from that moment on and says what happened, and what keeps it from happening.
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import { z } from 'zod';
8
+ import { SYSTEM } from '../shared/system.js';
9
+ import { readValid, writeJsonAtomic } from '../shared/rundir.js';
10
+ import { isWin } from '../core/util.js';
11
+ import { refuseInSelfTest } from './test-guard.js';
12
+ const Iso = z.string().max(40);
13
+ const Life = z.object({
14
+ started: Iso, lastSeen: Iso, keepAwake: z.boolean(),
15
+ ended: z.object({ at: Iso, cause: z.object({ kind: z.enum(['updates', 'restart', 'crash', 'sleep', 'memory', 'sandboxCrash']), at: Iso, text: z.string().max(400) }).nullable() }).optional(),
16
+ reported: z.boolean().optional(),
17
+ });
18
+ const lifeFile = (storageRoot, dataPath) => path.join(storageRoot, 'sync', path.basename(dataPath) + '-life.json');
19
+ export const readLife = (storageRoot, dataPath) => readValid(Life, lifeFile(storageRoot, dataPath));
20
+ const writeLife = (storageRoot, dataPath, life) => writeJsonAtomic(lifeFile(storageRoot, dataPath), life);
21
+ /** At launch: a fresh record (the previous one has been reported by then). */
22
+ export const startLife = (storageRoot, dataPath, keepAwake, now = new Date()) => writeLife(storageRoot, dataPath, { started: now.toISOString(), lastSeen: now.toISOString(), keepAwake });
23
+ /** Every minute while the sandbox runs. */
24
+ export const beatLife = (storageRoot, dataPath, now = new Date()) => {
25
+ const life = readLife(storageRoot, dataPath);
26
+ if (life && !life.ended)
27
+ writeLife(storageRoot, dataPath, { ...life, lastSeen: now.toISOString() });
28
+ };
29
+ /** The watcher saw the sandbox close while this PC kept running: closed by you (no cause), or by sleep / low memory. */
30
+ export const endLife = (storageRoot, dataPath, cause, now = new Date()) => {
31
+ const life = readLife(storageRoot, dataPath);
32
+ if (life && !life.ended)
33
+ writeLife(storageRoot, dataPath, { ...life, ended: { at: now.toISOString(), cause } });
34
+ };
35
+ const StopEvents = z.array(z.object({ at: Iso, id: z.number().int(), provider: z.string().max(200), process: z.string().max(400).nullable().transform((p) => p ?? '') })).max(100);
36
+ const UPDATERS = /^(trustedinstaller|mousocoreworker|musnotification(ux)?|usoclient|wuauclt|tiworker|updateorchestrator)\.exe$/i;
37
+ /**
38
+ * The first event that ends a running sandbox (pure): Windows inside the sandbox crashing (Hyper-V 18590; `process`
39
+ * holds its message with the stop code), a restart or shutdown, a crash or power loss, sleep, low memory. Switching
40
+ * user does not end it (seen on 13 Oct 2026: the sandbox ran on after a switch).
41
+ */
42
+ export const explainStop = (events) => {
43
+ const causes = events.flatMap((e) => {
44
+ const p = e.provider.toLowerCase();
45
+ if (e.id === 1074 && p.includes('user32')) {
46
+ const exe = e.process.split(/[\\/]/).pop()?.replace(/\s*\(.*$/, '') ?? '';
47
+ return UPDATERS.test(exe)
48
+ ? [{ kind: 'updates', at: e.at, text: 'Windows restarted this PC to install updates' }]
49
+ : [{ kind: 'restart', at: e.at, text: 'this PC was restarted or shut down' + (exe ? ' (by ' + exe + ')' : '') }];
50
+ }
51
+ if ((e.id === 41 && p.includes('kernel-power')) || (e.id === 6008 && p.includes('eventlog')))
52
+ return [{ kind: 'crash', at: e.at, text: 'Windows stopped unexpectedly (a crash, a power cut, or the power button held down)' }];
53
+ if (e.id === 42 && p.includes('kernel-power'))
54
+ return [{ kind: 'sleep', at: e.at, text: 'this PC went to sleep or hibernated' }];
55
+ if (e.id === 2004 && p.includes('resource-exhaustion'))
56
+ return [{ kind: 'memory', at: e.at, text: 'this PC ran out of memory' }];
57
+ if (e.id === 18590 && p.includes('hyper-v-worker')) {
58
+ const code = /ErrorCode0:\s*(0x[0-9a-f]{1,8})/i.exec(e.process)?.[1];
59
+ const sub = /ErrorCode1:\s*(0x[0-9a-f]{1,16})/i.exec(e.process)?.[1];
60
+ return [{ kind: 'sandboxCrash', at: e.at, text: 'Windows inside the sandbox crashed' + (code ? ' (stop code ' + code + (sub ? ', ' + sub : '') + ')' : '') }];
61
+ }
62
+ return [];
63
+ });
64
+ return causes.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))[0] ?? null;
65
+ };
66
+ const ADVICE = {
67
+ updates: 'Before leaving agents overnight, restart for pending updates first or pause them (Settings > Windows Update > Pause updates).',
68
+ restart: 'If that was not you, check Settings > Windows Update and Event Viewer > Windows Logs > System.',
69
+ crash: 'Event Viewer > Windows Logs > System has the details; a laptop also needs to stay plugged in.',
70
+ sleep: 'sandbox.keepAwake keeps this PC awake while a sandbox runs (closing a laptop lid still sleeps it). Check it is on, and that sleep is not forced by a policy.',
71
+ sandboxCrash: 'C:\\AgentData\\logs\\memory-*.log shows what used memory just before. Fewer builds at once inside the sandbox (e.g. cargo build.jobs) makes it less likely.',
72
+ memory: 'Give the sandbox less RAM (sandbox.maxMemoryMb, sandbox.reserveHostMb) or run fewer builds at once inside it.',
73
+ };
74
+ /** System log and Hyper-V events since `since` that can end a sandbox (Windows only; empty when the logs cannot be read). */
75
+ export const readStopEvents = (since) => {
76
+ if (!isWin)
77
+ return [];
78
+ refuseInSelfTest('read the Windows event log');
79
+ const script = "$ErrorActionPreference='SilentlyContinue'; $t=[datetime]::Parse('" + since.toISOString() + "').ToLocalTime(); $e = Get-WinEvent -FilterHashtable @(@{LogName='System'; Id=1074,6008,41,42,2004; StartTime=$t}, @{LogName='Microsoft-Windows-Hyper-V-Worker-Admin'; Id=18590; StartTime=$t}) -MaxEvents 100 | ForEach-Object { [pscustomobject]@{ at=$_.TimeCreated.ToUniversalTime().ToString('o'); id=$_.Id; provider=$_.ProviderName; process=$(if ($_.Id -eq 1074) { [string]$_.Properties[0].Value } elseif ($_.Id -eq 18590) { ([string]$_.Message).Substring(0, [math]::Min(390, ([string]$_.Message).Length)) } else { '' }) } }; ConvertTo-Json -Compress -InputObject @($e)";
80
+ const result = spawnSync(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { encoding: 'utf8', windowsHide: true, timeout: 60000 });
81
+ try {
82
+ return StopEvents.parse(JSON.parse(result.stdout || '[]'));
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ };
88
+ /** Looking back from the last time the sandbox was seen running (a minute between checks, plus margin). */
89
+ export const causeSince = (lastSeen) => explainStop(readStopEvents(new Date(Date.parse(lastSeen) - 2 * 60 * 1000)));
90
+ const when = (iso) => new Date(iso).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' });
91
+ /**
92
+ * At launch: one warning when the last sandbox ended without anyone closing it (the PC restarted, crashed, slept or ran
93
+ * out of memory), then never again for that run. `watcherAlive` = its watcher is still running (then it did not end).
94
+ */
95
+ export const lastStopWarning = (storageRoot, dataPath, watcherAlive, find = causeSince) => {
96
+ const life = readLife(storageRoot, dataPath);
97
+ if (!life || life.reported || watcherAlive)
98
+ return null;
99
+ writeLife(storageRoot, dataPath, { ...life, reported: true });
100
+ if (life.ended && !life.ended.cause)
101
+ return null;
102
+ const cause = life.ended ? life.ended.cause : find(life.lastSeen);
103
+ const what = cause ? cause.text + ' at ' + when(cause.at) + '. ' + ADVICE[cause.kind] : 'this PC stopped (Windows logged no reason). ' + ADVICE.crash;
104
+ return 'The last sandbox did not close normally: it was last seen running at ' + when(life.lastSeen) + ', and ' + what + ' Agent conversations are kept: /resume in Claude picks one up.';
105
+ };
106
+ // ---- Windows Update and keeping awake ----------------------------------------------------------------------------
107
+ /** Windows has installed updates and waits to restart: it may do so on its own, outside your active hours. */
108
+ export const updateRestartPending = () => isWin && [
109
+ 'HKLM\\SOFTWARE\\Microsoft\\WindowsUpdate\\Auto Update\\RebootRequired',
110
+ 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing\\RebootPending',
111
+ ].some((key) => spawnSync(SYSTEM.reg(), ['query', key], { windowsHide: true, stdio: 'ignore', timeout: 15000 }).status === 0);
112
+ export const UPDATE_RESTART_WARNING = 'Windows is waiting to restart for updates and may do so on its own (often overnight), which closes the sandbox. Restart first, or pause updates: Settings > Windows Update > Pause updates.';
113
+ /**
114
+ * sandbox.keepAwake: asks Windows not to sleep while the sandbox runs (as a video player does; the screen may still turn
115
+ * off). A small PowerShell holds the request until `ownerPid` exits, so it never outlives the watcher.
116
+ */
117
+ export const keepAwake = (ownerPid) => {
118
+ if (!isWin)
119
+ return null;
120
+ refuseInSelfTest('keep this PC awake');
121
+ const script = "Add-Type -Name Power -Namespace AgentSandbox -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern uint SetThreadExecutionState(uint f);'; "
122
+ + '[void][AgentSandbox.Power]::SetThreadExecutionState([uint32]2147483649); Wait-Process -Id ' + Math.trunc(ownerPid) + ' -ErrorAction SilentlyContinue';
123
+ const child = spawn(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'ignore', windowsHide: true });
124
+ child.on('error', () => undefined);
125
+ return child;
126
+ };
@@ -29,4 +29,12 @@ export declare const ensureTerminal: (dataPath: string, refresh: boolean, option
29
29
  exe: string | null;
30
30
  message: string;
31
31
  }>;
32
+ /** Where the sandbox finds conpty.dll + OpenConsole.exe (guest/ptyhost.ts points ptyhost at it). */
33
+ export declare const conptyDir: (dataPath: string) => string;
34
+ /**
35
+ * Microsoft's newer pseudo console (the Microsoft.Windows.Console.ConPTY package: conpty.dll and OpenConsole.exe, the
36
+ * same one Windows Terminal and VS Code use), downloaded once and verified against the SHA-512 NuGet publishes. Agent
37
+ * sessions use it instead of the one built into Windows, which re-renders the screen and garbles it after resizes.
38
+ */
39
+ export declare const ensureConpty: (dataPath: string, refresh: boolean) => Promise<string>;
32
40
  export {};
@@ -2,7 +2,7 @@
2
2
  // logon.cmd can open it the moment the sandbox logs in (no PowerShell window first).
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { githubAsset, installArchive } from '../shared/download.js';
5
+ import { download, extractArchive, fetchJson, githubAsset, installArchive, moveIntoPlace, stagingFolder } from '../shared/download.js';
6
6
  import { safeRemove } from '../shared/safe-remove.js';
7
7
  import { assertTestWritable } from './test-guard.js';
8
8
  import { z } from 'zod';
@@ -88,6 +88,50 @@ export const ensureTerminal = async (dataPath, refresh, options = {}) => {
88
88
  }
89
89
  return result;
90
90
  };
91
+ // ---- conpty.dll ------------------------------------------------------------------------------------------------------
92
+ /** Where the sandbox finds conpty.dll + OpenConsole.exe (guest/ptyhost.ts points ptyhost at it). */
93
+ export const conptyDir = (dataPath) => path.join(dataPath, 'tools', 'conpty');
94
+ const NUGET_CONPTY = 'https://api.nuget.org/v3-flatcontainer/microsoft.windows.console.conpty/';
95
+ const NugetVersions = z.object({ versions: z.array(z.string()) });
96
+ const NugetLeaf = z.object({ catalogEntry: z.string().url() });
97
+ const NugetCatalog = z.object({ packageHash: z.string(), packageHashAlgorithm: z.literal('SHA512') });
98
+ /**
99
+ * Microsoft's newer pseudo console (the Microsoft.Windows.Console.ConPTY package: conpty.dll and OpenConsole.exe, the
100
+ * same one Windows Terminal and VS Code use), downloaded once and verified against the SHA-512 NuGet publishes. Agent
101
+ * sessions use it instead of the one built into Windows, which re-renders the screen and garbles it after resizes.
102
+ */
103
+ export const ensureConpty = async (dataPath, refresh) => {
104
+ const dir = conptyDir(dataPath);
105
+ assertTestWritable(dir);
106
+ assertNoLinks(dataPath, dir);
107
+ if (fileExists(path.join(dir, 'conpty.dll')) && fileExists(path.join(dir, 'OpenConsole.exe')) && !refresh)
108
+ return 'cached';
109
+ const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
110
+ const work = stagingFolder(dir);
111
+ try {
112
+ const version = NugetVersions.parse(await fetchJson(NUGET_CONPTY + 'index.json')).versions.filter((v) => !v.includes('-')).pop();
113
+ if (!version)
114
+ throw new Error('no release found');
115
+ const leaf = NugetLeaf.parse(await fetchJson('https://api.nuget.org/v3/registration5-gz-semver2/microsoft.windows.console.conpty/' + version + '.json'));
116
+ const sha512 = Buffer.from(NugetCatalog.parse(await fetchJson(leaf.catalogEntry)).packageHash, 'base64').toString('hex');
117
+ const pkg = path.join(work, 'conpty.nupkg');
118
+ await download(NUGET_CONPTY + version + '/microsoft.windows.console.conpty.' + version + '.nupkg', pkg, { sha512 });
119
+ const files = { 'conpty.dll': 'runtimes/win-' + arch + '/native/conpty.dll', 'OpenConsole.exe': 'build/native/runtimes/' + arch + '/OpenConsole.exe' };
120
+ await extractArchive(pkg, path.join(work, 'x'), Object.values(files));
121
+ fs.mkdirSync(path.join(work, 'out'));
122
+ for (const [name, member] of Object.entries(files))
123
+ fs.copyFileSync(path.join(work, 'x', ...member.split('/')), path.join(work, 'out', name));
124
+ fs.writeFileSync(path.join(work, 'out', 'VERSION'), version);
125
+ moveIntoPlace(path.join(work, 'out'), dir);
126
+ return 'downloaded ' + version;
127
+ }
128
+ catch (e) {
129
+ return 'not downloaded, sessions use Windows\' own pseudo console (' + errMsg(e) + ')';
130
+ }
131
+ finally {
132
+ safeRemove(work, path.dirname(work));
133
+ }
134
+ };
91
135
  const installTerminal = async (dataPath, refresh) => {
92
136
  const exe = terminalExe(dataPath);
93
137
  const dir = terminalDir(dataPath);