windows-development-sandbox 1.27.1 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/app/context.d.ts +5 -0
  2. package/dist/app/context.js +13 -0
  3. package/dist/app/features/accounts/accounts.def.d.ts +16 -0
  4. package/dist/app/features/accounts/accounts.run.d.ts +4 -0
  5. package/dist/app/features/accounts/services.def.d.ts +15 -1
  6. package/dist/app/features/accounts/services.def.js +67 -31
  7. package/dist/app/features/accounts/services.run.d.ts +7 -2
  8. package/dist/app/features/accounts/services.run.js +25 -6
  9. package/dist/app/features/defs.d.ts +17 -0
  10. package/dist/app/registry.d.ts +1 -1
  11. package/dist/app/registry.js +15 -2
  12. package/dist/bin/agent-sandbox.js +2 -2
  13. package/dist/bin/{chunk-RSIX7C6D.js → chunk-CB44HTTN.js} +444 -175
  14. package/dist/bin/{chunk-4TLSWPG7.js → chunk-UZ5S3EMB.js} +1 -1
  15. package/dist/bin/{chunk-QUHNXHVG.js → chunk-ZMZI3625.js} +46 -41
  16. package/dist/bin/claude-host.js +2 -2
  17. package/dist/bin/claude-sbx.js +2 -2
  18. package/dist/bin/codex-sbx.js +2 -2
  19. package/dist/bin/gemini-sbx.js +2 -2
  20. package/dist/bin/sandbox.js +2 -2
  21. package/dist/bin/sbx-hide.js +2 -2
  22. package/dist/bin/sbx-show.js +2 -2
  23. package/dist/cli/menu/menu.js +6 -1
  24. package/dist/cli/menu/pages/accounts-page.js +9 -5
  25. package/dist/core/schema/sandbox.js +1 -1
  26. package/dist/guest/apps/app-installer.js +7 -5
  27. package/dist/guest/apps/comet-unpack.js +2 -2
  28. package/dist/guest/daemon/heartbeat.js +1 -0
  29. package/dist/guest/governor/core.d.ts +53 -1
  30. package/dist/guest/governor/core.js +95 -1
  31. package/dist/guest/governor/decide.d.ts +4 -0
  32. package/dist/guest/governor/decide.js +2 -0
  33. package/dist/guest/governor/governor.js +23 -9
  34. package/dist/guest/runtime/pools.d.ts +4 -4
  35. package/dist/guest/runtime/pools.js +4 -15
  36. package/dist/guest/runtime/process.d.ts +5 -2
  37. package/dist/guest/runtime/process.js +23 -3
  38. package/dist/guest/toolchains/engines/epic-launcher.d.ts +6 -0
  39. package/dist/guest/toolchains/engines/epic-launcher.js +19 -1
  40. package/dist/guest/toolchains/engines/unreal-engines.js +2 -1
  41. package/dist/guest/toolchains/installers.d.ts +3 -3
  42. package/dist/guest/toolchains/installers.js +5 -5
  43. package/dist/guest/toolchains/native/unreal.js +22 -3
  44. package/dist/guest/toolchains/native/vcruntime.js +2 -2
  45. package/dist/guest/toolchains/native/vs-persist.d.ts +2 -2
  46. package/dist/guest/toolchains/runtimes.js +7 -6
  47. package/dist/guest/toolchains/signature.d.ts +2 -0
  48. package/dist/guest/toolchains/signature.js +9 -2
  49. package/dist/guest/toolchains/step-scheduler.js +1 -1
  50. package/dist/host/accounts/deliver.js +15 -7
  51. package/dist/host/life/crash-report.d.ts +4 -0
  52. package/dist/host/life/crash-report.js +30 -1
  53. package/dist/host/life/last-end.js +1 -0
  54. package/dist/host/pc-installs/discover.js +5 -2
  55. package/dist/host/pc-installs/unreal.d.ts +3 -1
  56. package/dist/host/pc-installs/unreal.js +27 -1
  57. package/dist/host/tools/windows-terminal.d.ts +4 -1
  58. package/dist/host/tools/windows-terminal.js +25 -9
  59. package/dist/runtime/agent.js +39 -23
  60. package/dist/runtime/daemon.js +279 -121
  61. package/dist/runtime/db.js +37 -17
  62. package/dist/runtime/main.js +340 -109
  63. package/dist/runtime/netproxy.js +31 -13
  64. package/dist/runtime/tool.js +37 -16
  65. package/dist/runtime/view.js +150 -38
  66. package/dist/selftest/agent-paths.test.js +3 -1
  67. package/dist/selftest/app.test.js +20 -2
  68. package/dist/selftest/boot-runner.test.js +15 -1
  69. package/dist/selftest/channel.test.js +33 -0
  70. package/dist/selftest/engines.test.js +22 -1
  71. package/dist/selftest/heavy-load.test.js +116 -4
  72. package/dist/selftest/live-terminal.test.js +103 -0
  73. package/dist/selftest/menu-screens.js +52 -1
  74. package/dist/selftest/menu.test.js +29 -3
  75. package/dist/selftest/personalization.test.js +2 -1
  76. package/dist/selftest/service-accounts.test.js +77 -2
  77. package/dist/selftest/support/engine-golden.js +2 -0
  78. package/dist/selftest/ui-text.test.js +4 -0
  79. package/dist/shared/accounts/kinds.js +9 -9
  80. package/dist/shared/apps/catalogue-gamedev.js +1 -0
  81. package/dist/shared/apps/catalogue.d.ts +2 -0
  82. package/dist/shared/channel/schema.d.ts +2 -0
  83. package/dist/shared/channel/schema.js +4 -0
  84. package/dist/shared/concurrency/pools.js +1 -1
  85. package/dist/shared/download/archive.js +4 -2
  86. package/dist/shared/download/index.d.ts +1 -1
  87. package/dist/shared/download/index.js +1 -1
  88. package/dist/shared/download/spawn.d.ts +7 -1
  89. package/dist/shared/download/spawn.js +16 -1
  90. package/dist/shared/memory/index.d.ts +1 -0
  91. package/dist/shared/memory/index.js +1 -0
  92. package/dist/shared/memory/policy.d.ts +12 -0
  93. package/dist/shared/memory/policy.js +4 -0
  94. package/dist/shared/memory/unreal-build.d.ts +2 -0
  95. package/dist/shared/memory/unreal-build.js +21 -0
  96. package/dist/shared/paths/pc-installs.d.ts +8 -0
  97. package/dist/shared/pty/clipboard-watch.d.ts +4 -0
  98. package/dist/shared/pty/clipboard-watch.js +40 -0
  99. package/dist/shared/pty/ctrl-c.d.ts +13 -7
  100. package/dist/shared/pty/ctrl-c.js +35 -19
  101. package/dist/shared/pty/index.d.ts +1 -0
  102. package/dist/shared/pty/index.js +1 -0
  103. package/dist/shared/pty/terminal.d.ts +1 -1
  104. package/dist/shared/pty/terminal.js +16 -13
  105. package/dist/shared/unreal/launcher-files.d.ts +7 -0
  106. package/dist/shared/unreal/launcher-files.js +32 -1
  107. package/dist/shared/web/ctrl-c.d.ts +26 -0
  108. package/dist/shared/web/ctrl-c.js +64 -0
  109. package/dist/shared/web/index.d.ts +1 -0
  110. package/dist/shared/web/index.js +1 -0
  111. package/dist/web/index.html +1 -1
  112. package/dist/web/{main-VPMYTSUL.js → main-7NY4X6YU.js} +67 -67
  113. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  114. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  115. package/native/csharp/StorageHelper/AssemblyInfo.cs +3 -3
  116. package/package.json +1 -1
  117. package/sandbox/csharp/ClipboardWatch.cs +59 -0
  118. package/sandbox/csharp/MemGov.cs +752 -7
  119. package/sandbox/scripts/watch-clipboard.ps1 +8 -0
  120. package/schemas/config.schema.json +10 -10
  121. package/schemas/sandbox-config.schema.json +9 -9
@@ -55,7 +55,7 @@ export const SERVICE_SPECS = {
55
55
  },
56
56
  steam: {
57
57
  kind: 'steam',
58
- label: 'Steam (SteamCMD)',
58
+ label: 'Steam',
59
59
  usedBy: ['steam'],
60
60
  sources: ['file', 'sandbox'],
61
61
  singleHolder: true,
@@ -91,7 +91,7 @@ export const SERVICE_SPECS = {
91
91
  },
92
92
  cfx: {
93
93
  kind: 'cfx',
94
- label: 'Cfx.re (FiveM / RedM server key)',
94
+ label: 'FiveM key',
95
95
  usedBy: ['fivem'],
96
96
  sources: ['typed', 'file'],
97
97
  singleHolder: false,
@@ -120,7 +120,7 @@ export const SERVICE_SPECS = {
120
120
  },
121
121
  keystore: {
122
122
  kind: 'keystore',
123
- label: 'Android upload keystore',
123
+ label: 'Android keystore',
124
124
  usedBy: ['android'],
125
125
  sources: ['file'],
126
126
  singleHolder: false,
@@ -135,7 +135,7 @@ export const SERVICE_SPECS = {
135
135
  },
136
136
  play: {
137
137
  kind: 'play',
138
- label: 'Google Play (service account)',
138
+ label: 'Google Play',
139
139
  usedBy: ['android'],
140
140
  sources: ['file'],
141
141
  singleHolder: false,
@@ -146,7 +146,7 @@ export const SERVICE_SPECS = {
146
146
  },
147
147
  expo: {
148
148
  kind: 'expo',
149
- label: 'Expo (EAS)',
149
+ label: 'Expo',
150
150
  usedBy: ['reactNative'],
151
151
  sources: ['typed'],
152
152
  singleHolder: false,
@@ -157,7 +157,7 @@ export const SERVICE_SPECS = {
157
157
  },
158
158
  appstore: {
159
159
  kind: 'appstore',
160
- label: 'App Store Connect API key',
160
+ label: 'App Store Connect',
161
161
  usedBy: ['reactNative', 'flutter'],
162
162
  sources: ['file', 'typed'],
163
163
  singleHolder: false,
@@ -171,7 +171,7 @@ export const SERVICE_SPECS = {
171
171
  },
172
172
  perplexity: {
173
173
  kind: 'perplexity',
174
- label: 'Perplexity API key',
174
+ label: 'Perplexity API',
175
175
  usedBy: ['perplexity'],
176
176
  sources: ['pc', 'file', 'typed'],
177
177
  singleHolder: false,
@@ -195,7 +195,7 @@ export const SERVICE_SPECS = {
195
195
  },
196
196
  comet: {
197
197
  kind: 'comet',
198
- label: 'Comet (Perplexity browser)',
198
+ label: 'Comet',
199
199
  usedBy: ['comet'],
200
200
  sources: ['sandbox'],
201
201
  singleHolder: false,
@@ -212,7 +212,7 @@ export const SERVICE_SPECS = {
212
212
  },
213
213
  perplexityapp: {
214
214
  kind: 'perplexityapp',
215
- label: 'Perplexity desktop app',
215
+ label: 'Perplexity app',
216
216
  usedBy: ['perplexityapp'],
217
217
  sources: ['sandbox'],
218
218
  singleHolder: false,
@@ -49,6 +49,7 @@ export const GAMEDEV_APPS = [
49
49
  size: '100 MB',
50
50
  source: { github: 'f3d-app/f3d', asset: '^F3D-[\\d.]+-Windows-x86_64\\.zip$' },
51
51
  exe: 'f3d.exe',
52
+ onPath: true,
52
53
  needs: ['mesa'],
53
54
  },
54
55
  {
@@ -73,6 +73,8 @@ export interface AppEntry {
73
73
  exe: string;
74
74
  /** A command-line tool: its folder goes on PATH (no desktop shortcut). */
75
75
  cli?: boolean;
76
+ /** A desktop program an agent also runs by name (F3D renders previews): its folder goes on PATH, shortcut kept. */
77
+ onPath?: boolean;
76
78
  /** Needs these first: 7-Zip to unpack an installer, Mesa for OpenGL without a GPU. */
77
79
  needs?: ('7zip' | 'mesa')[];
78
80
  /**
@@ -853,6 +853,7 @@ export declare const PressureStatusSchema: z.ZodObject<{
853
853
  pid: z.ZodNumber;
854
854
  seconds: z.ZodNumber;
855
855
  }, z.core.$strip>>;
856
+ held: z.ZodOptional<z.ZodNumber>;
856
857
  cpuPercent: z.ZodOptional<z.ZodNumber>;
857
858
  pcLow: z.ZodBoolean;
858
859
  lastAction: z.ZodString;
@@ -875,6 +876,7 @@ export declare const HeartbeatSchema: z.ZodObject<{
875
876
  privateMb: z.ZodNumber;
876
877
  }, z.core.$strip>>;
877
878
  diskQueueReported: z.ZodOptional<z.ZodNumber>;
879
+ held: z.ZodOptional<z.ZodNumber>;
878
880
  phase: z.ZodEnum<{
879
881
  ready: "ready";
880
882
  working: "working";
@@ -133,6 +133,8 @@ export const PressureStatusSchema = z.object({
133
133
  lowered: z.number().int().min(0).max(1024),
134
134
  /** Compilers waiting their turn right now (name, process id, seconds until they may go on). */
135
135
  paused: z.array(z.object({ name: Text(64), pid: z.number().int(), seconds: z.number().int().min(0).max(60) })).max(8),
136
+ /** Build programs the memory gate holds right now (let go one at a time). */
137
+ held: z.number().int().min(0).max(1024).optional(),
136
138
  /** The CPU share the compilers get together (percent; 100 = no cap). */
137
139
  cpuPercent: z.number().int().min(0).max(100).optional(),
138
140
  /** The PC itself asked for slowing down (its memory or CPU is low). */
@@ -157,6 +159,8 @@ export const HeartbeatSchema = z.object({
157
159
  topProcesses: z.array(z.object({ name: Text(64), privateMb: z.number().int().min(0).max(1e7) })).max(5),
158
160
  /** The disk queue this PC last reported while asking the sandbox to slow down. */
159
161
  diskQueueReported: z.number().min(0).max(100000).optional(),
162
+ /** Build programs the memory gate holds right now (let go one at a time). */
163
+ held: z.number().int().min(0).max(1024).optional(),
160
164
  /** "ending": the sandbox is shutting down in order (idle shutdown, sandbox stop, Windows shutting down). */
161
165
  phase: z.enum(['ready', 'working', 'ending']),
162
166
  });
@@ -2,7 +2,7 @@
2
2
  // them to the downloader and extractor (guest/runtime/pools.ts installPools).
3
3
  import { Limiter } from './limiter.js';
4
4
  /** HTTP bodies. */
5
- export const net = new Limiter(4, 'net');
5
+ export const net = new Limiter(3, 'net');
6
6
  /** extractArchive, 7-Zip/NSIS unpack, npm ci/install, uv/pip, Steam, robocopy of big layouts, rustup/cargo. */
7
7
  export const heavy = new Limiter(2, 'heavy');
8
8
  /** Every msiexec, vcredist, DXSETUP and the Visual Studio bootstrapper: Windows runs one install at a time. */
@@ -34,12 +34,14 @@ export function extractArchive(archive, destination, members = []) {
34
34
  }
35
35
  async function extract(archive, destination, members) {
36
36
  fs.mkdirSync(destination, { recursive: true });
37
- let status = await spawnAsync(SYSTEM.tar(), ['-xf', archive, '-C', destination, ...members]);
37
+ let status = await spawnAsync(SYSTEM.tar(), ['-xf', archive, '-C', destination, ...members], {
38
+ lowPriority: true,
39
+ });
38
40
  if (status !== 0 && /\.(zip|nupkg)$/i.test(archive)) {
39
41
  const zip = archive.endsWith('.zip') ? archive : archive + '.zip';
40
42
  if (zip !== archive)
41
43
  fs.copyFileSync(archive, zip);
42
- status = await spawnAsync(SYSTEM.powershell(), scriptArgs('expand-archive.ps1', ['-Archive', zip, '-Destination', destination]));
44
+ status = await spawnAsync(SYSTEM.powershell(), scriptArgs('expand-archive.ps1', ['-Archive', zip, '-Destination', destination]), { lowPriority: true });
43
45
  }
44
46
  if (status !== 0)
45
47
  throw new Error('Could not extract ' + archive);
@@ -2,5 +2,5 @@ export { fetchRetry, fetchJson, fetchText } from './http.js';
2
2
  export { fetchBounded, fetchBoundedResponse, type BoundedResponse, type Fetcher, type RequestFetcher, } from './bounded-fetch.js';
3
3
  export { stagingFolder, moveIntoPlace, extractArchive, findFile, limitExtraction } from './archive.js';
4
4
  export { type ArchiveOptions, type Checksums, type DownloadWatcher, Downloader, downloader } from './downloader.js';
5
- export { spawnAsync } from './spawn.js';
5
+ export { spawnAsync, lowerPriority, type SpawnAsyncOptions } from './spawn.js';
6
6
  export { type ReleaseAsset, githubAsset, parseReleasePage, latestGithubTag, parseReleaseTags } from './github.js';
@@ -2,5 +2,5 @@ export { fetchRetry, fetchJson, fetchText } from './http.js';
2
2
  export { fetchBounded, fetchBoundedResponse, } from './bounded-fetch.js';
3
3
  export { stagingFolder, moveIntoPlace, extractArchive, findFile, limitExtraction } from './archive.js';
4
4
  export { Downloader, downloader } from './downloader.js';
5
- export { spawnAsync } from './spawn.js';
5
+ export { spawnAsync, lowerPriority } from './spawn.js';
6
6
  export { githubAsset, parseReleasePage, latestGithubTag, parseReleaseTags } from './github.js';
@@ -1,3 +1,9 @@
1
1
  import { type SpawnOptions } from 'node:child_process';
2
+ export interface SpawnAsyncOptions extends SpawnOptions {
3
+ /** Below-normal priority for the program and what it starts: unpacking must not make the desktop sluggish. */
4
+ readonly lowPriority?: boolean;
5
+ }
6
+ /** Lowers a process to below-normal priority; best effort (it may have ended already). */
7
+ export declare function lowerPriority(pid: number | undefined): void;
2
8
  /** Runs a program without blocking the event loop (parallel downloads keep going); resolves with its exit code. */
3
- export declare function spawnAsync(file: string, args: string[], options?: SpawnOptions): Promise<number>;
9
+ export declare function spawnAsync(file: string, args: string[], options?: SpawnAsyncOptions): Promise<number>;
@@ -1,8 +1,23 @@
1
+ import os from 'node:os';
1
2
  import { spawn } from 'node:child_process';
3
+ /** Lowers a process to below-normal priority; best effort (it may have ended already). */
4
+ export function lowerPriority(pid) {
5
+ if (pid === undefined)
6
+ return;
7
+ try {
8
+ os.setPriority(pid, os.constants.priority.PRIORITY_BELOW_NORMAL);
9
+ }
10
+ catch {
11
+ /* a normal priority only makes it a little slower to share the machine */
12
+ }
13
+ }
2
14
  /** Runs a program without blocking the event loop (parallel downloads keep going); resolves with its exit code. */
3
15
  export function spawnAsync(file, args, options = {}) {
16
+ const { lowPriority, ...spawnOptions } = options;
4
17
  return new Promise((resolve, reject) => {
5
- const child = spawn(file, args, { stdio: 'ignore', windowsHide: true, ...options });
18
+ const child = spawn(file, args, { stdio: 'ignore', windowsHide: true, ...spawnOptions });
19
+ if (lowPriority)
20
+ lowerPriority(child.pid);
6
21
  child.once('error', reject);
7
22
  child.once('close', (code) => resolve(code ?? -1));
8
23
  });
@@ -1 +1,2 @@
1
1
  export * from './policy.js';
2
+ export * from './unreal-build.js';
@@ -1 +1,2 @@
1
1
  export * from './policy.js';
2
+ export * from './unreal-build.js';
@@ -72,3 +72,15 @@ export declare function maxCompilers(cpus: number, profile: LoadProfile): number
72
72
  * `CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER`).
73
73
  */
74
74
  export declare function cargoLinkerVar(host: string): string;
75
+ /** The memory gate in memgov: new build programs wait when the agents' programs grow faster than this. */
76
+ export interface BurstGate {
77
+ /** Growth (MB of committed memory) that counts as a burst... */
78
+ readonly growthMb: number;
79
+ /** ...within this many ms. */
80
+ readonly windowMs: number;
81
+ /** Held programs go on one at a time, this far apart (oldest first). */
82
+ readonly releaseMs: number;
83
+ /** No program waits longer than this (memgov's own cap is 30000); never killed. */
84
+ readonly maxHoldMs: number;
85
+ }
86
+ export declare const BURST_GATE: Readonly<Record<LoadProfile, BurstGate>>;
@@ -115,3 +115,7 @@ export function maxCompilers(cpus, profile) {
115
115
  export function cargoLinkerVar(host) {
116
116
  return 'CARGO_TARGET_' + host.toUpperCase().replace(/[^A-Z0-9]/g, '_') + '_LINKER';
117
117
  }
118
+ export const BURST_GATE = {
119
+ normal: { growthMb: 4096, windowMs: 3000, releaseMs: 1500, maxHoldMs: 30000 },
120
+ gentle: { growthMb: 3072, windowMs: 3000, releaseMs: 2000, maxHoldMs: 30000 },
121
+ };
@@ -0,0 +1,2 @@
1
+ /** BuildConfiguration.xml with at most `jobs` compiles and links at once (pure). */
2
+ export declare function ubtBuildConfiguration(jobs: number): string;
@@ -0,0 +1,21 @@
1
+ // UnrealBuildTool's parallel compiles in the sandbox. Left alone it starts one compiler per core whenever the sandbox
2
+ // looks free (free RAM / 1.5 GB), and each cl.exe with Unreal's precompiled headers commits about 2.6 GB: a build start
3
+ // asks for 50 GB within seconds, faster than the governor can step in, and Windows closes the sandbox.
4
+ // MaxParallelActions caps it before the build starts; UnrealBuildTool runs one build per engine at a time, so the cap
5
+ // is the whole budget.
6
+ /** The XML namespace UnrealBuildTool reads (XmlConfigFile.SchemaNamespaceURI). */
7
+ const NAMESPACE = 'https://www.unrealengine.com/BuildConfiguration';
8
+ /** BuildConfiguration.xml with at most `jobs` compiles and links at once (pure). */
9
+ export function ubtBuildConfiguration(jobs) {
10
+ const max = Math.max(1, Math.floor(jobs));
11
+ return [
12
+ '<?xml version="1.0" encoding="utf-8"?>',
13
+ '<!-- Written by the sandbox at boot: compiles at once from the build budget (run\\budget.json, buildJobs). -->',
14
+ '<Configuration xmlns="' + NAMESPACE + '">',
15
+ ' <BuildConfiguration>',
16
+ ' <MaxParallelActions>' + max + '</MaxParallelActions>',
17
+ ' </BuildConfiguration>',
18
+ '</Configuration>',
19
+ '',
20
+ ].join('\r\n');
21
+ }
@@ -22,6 +22,14 @@ export interface PcInstall {
22
22
  readonly artifactId: string;
23
23
  readonly appVersion: string;
24
24
  };
25
+ /**
26
+ * The launcher's manifest of an engine (its Data\Manifests\<name>.item on this PC): without one the sandbox's
27
+ * launcher drops the engine from its library. Its paths name the sandbox's folder by the time it is in session.json.
28
+ */
29
+ readonly launcherItem?: {
30
+ readonly name: string;
31
+ readonly text: string;
32
+ };
25
33
  }
26
34
  /** What session.json says about them: no host paths. */
27
35
  export interface PcInstallManifest {
@@ -0,0 +1,4 @@
1
+ /** Calls `onChange` for each clipboard change from now on. No-op off Windows. */
2
+ export declare function watchClipboard(onChange: () => void): {
3
+ stop: () => void;
4
+ };
@@ -0,0 +1,40 @@
1
+ // Windows: tells the terminal client when the clipboard changes, e.g. you just copied text in the sandbox window (which
2
+ // copies on select). sandbox\scripts\watch-clipboard.ps1 prints a line per change and ends with this process.
3
+ // Only built-ins here: view.js bundles this.
4
+ import { spawn } from 'node:child_process';
5
+ import { SYSTEM } from '../system.js';
6
+ import { scriptArgs } from '../scripts.js';
7
+ /** The most output kept while waiting for the end of a line. */
8
+ const MAX_PENDING = 1024;
9
+ /** Calls `onChange` for each clipboard change from now on. No-op off Windows. */
10
+ export function watchClipboard(onChange) {
11
+ if (process.platform !== 'win32')
12
+ return { stop: () => undefined };
13
+ let child = null;
14
+ try {
15
+ child = spawn(SYSTEM.powershell(), scriptArgs('watch-clipboard.ps1', ['-ProcessId', String(process.pid)]), {
16
+ stdio: ['ignore', 'pipe', 'ignore'],
17
+ windowsHide: true,
18
+ });
19
+ }
20
+ catch {
21
+ // Without the watcher a copy is only recognised from the program's own selection: nothing else is lost.
22
+ return { stop: () => undefined };
23
+ }
24
+ let pending = '';
25
+ child.stdout?.setEncoding('latin1');
26
+ child.stdout?.on('data', (text) => {
27
+ const lines = (pending + text).split('\n');
28
+ pending = (lines.pop() ?? '').slice(-MAX_PENDING);
29
+ for (const line of lines)
30
+ if (line.trim() === 'changed')
31
+ onChange();
32
+ });
33
+ child.on('error', () => undefined);
34
+ return {
35
+ stop: () => {
36
+ if (child && child.exitCode === null && child.signalCode === null)
37
+ child.kill();
38
+ },
39
+ };
40
+ }
@@ -1,18 +1,24 @@
1
- /** A second Ctrl+C within this long sends it. */
2
- export declare const CTRL_C_CONFIRM_MS = 1500;
3
- /** What the title shows while a Ctrl+C is held back. */
4
- export declare const CTRL_C_HINT = "Ctrl+C again to send it to the program (Esc stops an agent)";
1
+ import { CTRL_C_CONFIRM_MS, CTRL_C_COPY_HINT, CTRL_C_HINT } from '../web/index.js';
2
+ export { CTRL_C_CONFIRM_MS, CTRL_C_COPY_HINT, CTRL_C_HINT };
5
3
  export declare class CtrlCGuard {
6
- private armedAt;
4
+ private readonly decider;
7
5
  private lastTitle;
8
- /** What to send for one read from the keyboard: a lone Ctrl+C only when another came just before. */
6
+ constructor(options?: {
7
+ confirm?: boolean;
8
+ });
9
+ /** What to send for one read from the keyboard: a lone Ctrl+C only when it is not held back or taken as "copy". */
9
10
  push(chunk: Buffer, nowMs: number): {
10
11
  send: Buffer;
11
12
  held: boolean;
13
+ copied: boolean;
12
14
  };
15
+ /** The clipboard changed since the last key: you just copied. */
16
+ clipboardChanged(): void;
17
+ /** The program wrote to the clipboard: that is a selection too. */
18
+ watchClipboard(raw: string): void;
13
19
  /** Remembers the title the program last set (from its output), to put it back after the hint. */
14
20
  watchOutput(text: string): void;
15
21
  /** The escape sequence that shows the hint, and the one that puts the program's title back. */
16
- hintTitle(): string;
22
+ hintTitle(kind?: 'hold' | 'copy'): string;
17
23
  restoreTitle(): string;
18
24
  }
@@ -1,25 +1,41 @@
1
- // A lone Ctrl+C is easy to send by accident: it is "copy" everywhere else, and a terminal sends it to the program when
2
- // the selection it was meant for has just been cleared by a redraw (an agent's spinner redraws all the time). With
3
- // attach.confirmCtrlC the terminal client holds a lone Ctrl+C back until it is pressed again within a moment; Esc still
4
- // stops an agent at once. Pure, so it is tested without a terminal.
5
- /** A second Ctrl+C within this long sends it. */
6
- export const CTRL_C_CONFIRM_MS = 1500;
7
- /** What the title shows while a Ctrl+C is held back. */
8
- export const CTRL_C_HINT = 'Ctrl+C again to send it to the program (Esc stops an agent)';
1
+ // A Ctrl+C is easy to send by accident: it is "copy" everywhere else. Two rules keep it from stopping an agent.
2
+ // 1. After you select text in the program (a mouse drag it received, or it wrote the clipboard with OSC 52), or the
3
+ // clipboard changed (you copied: the sandbox window copies on select), every Ctrl+C is "copy" and is never sent,
4
+ // until you press another key or click.
5
+ // 2. With attach.confirmCtrlC a lone Ctrl+C is held back until it is pressed again within a moment.
6
+ // Esc still stops an agent at once. The rules live in CtrlCDecider (pure, shared with the web terminal).
7
+ import { CTRL_C_CONFIRM_MS, CTRL_C_COPY_HINT, CTRL_C_HINT, CtrlCDecider, classifyInput, hasClipboardWrite, } from '../web/index.js';
8
+ export { CTRL_C_CONFIRM_MS, CTRL_C_COPY_HINT, CTRL_C_HINT };
9
9
  const OSC_TITLE = /\x1b\][02];([^\x07\x1b]{0,256})(?:\x07|\x1b\\)/g;
10
10
  export class CtrlCGuard {
11
- armedAt = Number.NEGATIVE_INFINITY;
11
+ decider;
12
12
  lastTitle = '';
13
- /** What to send for one read from the keyboard: a lone Ctrl+C only when another came just before. */
13
+ constructor(options = {}) {
14
+ this.decider = new CtrlCDecider(options.confirm !== false);
15
+ }
16
+ /** What to send for one read from the keyboard: a lone Ctrl+C only when it is not held back or taken as "copy". */
14
17
  push(chunk, nowMs) {
15
- if (chunk.length !== 1 || chunk[0] !== 0x03)
16
- return { send: chunk, held: false };
17
- if (nowMs - this.armedAt <= CTRL_C_CONFIRM_MS) {
18
- this.armedAt = Number.NEGATIVE_INFINITY;
19
- return { send: chunk, held: false };
18
+ if (chunk.length === 1 && chunk[0] === 0x03) {
19
+ const action = this.decider.ctrlC(nowMs);
20
+ if (action === 'send')
21
+ return { send: chunk, held: false, copied: false };
22
+ return { send: Buffer.alloc(0), held: action === 'hold', copied: action === 'copy' };
20
23
  }
21
- this.armedAt = nowMs;
22
- return { send: Buffer.alloc(0), held: true };
24
+ const c = classifyInput(chunk.toString('latin1'));
25
+ if (c.keys || c.mouse === 'click')
26
+ this.decider.otherInput();
27
+ else if (c.mouse === 'drag')
28
+ this.decider.selection();
29
+ return { send: chunk, held: false, copied: false };
30
+ }
31
+ /** The clipboard changed since the last key: you just copied. */
32
+ clipboardChanged() {
33
+ this.decider.selection();
34
+ }
35
+ /** The program wrote to the clipboard: that is a selection too. */
36
+ watchClipboard(raw) {
37
+ if (hasClipboardWrite(raw))
38
+ this.decider.selection();
23
39
  }
24
40
  /** Remembers the title the program last set (from its output), to put it back after the hint. */
25
41
  watchOutput(text) {
@@ -27,8 +43,8 @@ export class CtrlCGuard {
27
43
  this.lastTitle = m[1];
28
44
  }
29
45
  /** The escape sequence that shows the hint, and the one that puts the program's title back. */
30
- hintTitle() {
31
- return '\x1b]0;' + CTRL_C_HINT + '\x07';
46
+ hintTitle(kind = 'hold') {
47
+ return '\x1b]0;' + (kind === 'copy' ? CTRL_C_COPY_HINT : CTRL_C_HINT) + '\x07';
32
48
  }
33
49
  restoreTitle() {
34
50
  return '\x1b]0;' + this.lastTitle.replace(/[\x00-\x1f\x7f]/g, '') + '\x07';
@@ -6,4 +6,5 @@ export { MouseModes } from './mouse-modes.js';
6
6
  export { JOIN_MS, InputJoiner } from './input-joiner.js';
7
7
  export { CTRL_C_CONFIRM_MS, CTRL_C_HINT, CtrlCGuard } from './ctrl-c.js';
8
8
  export { type VtInput, enableVtInput } from './console-input.js';
9
+ export { watchClipboard } from './clipboard-watch.js';
9
10
  export { OutputFilter } from './output-filter.js';
@@ -6,4 +6,5 @@ export { MouseModes } from './mouse-modes.js';
6
6
  export { JOIN_MS, InputJoiner } from './input-joiner.js';
7
7
  export { CTRL_C_CONFIRM_MS, CTRL_C_HINT, CtrlCGuard } from './ctrl-c.js';
8
8
  export { enableVtInput } from './console-input.js';
9
+ export { watchClipboard } from './clipboard-watch.js';
9
10
  export { OutputFilter } from './output-filter.js';
@@ -19,7 +19,7 @@ export declare const MOUSE_OFF = "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[
19
19
  */
20
20
  export declare function runTerminalClient(socket: net.Socket, token: string,
21
21
  /** clipboardWrites: the agent's "copy" (OSC 52) reaches the clipboard; see OutputFilterOptions. */
22
- /** confirmCtrlC: a lone Ctrl+C is held back until it is pressed again (CtrlCGuard, attach.confirmCtrlC). */
22
+ /** confirmCtrlC (default true): a lone Ctrl+C is held back until pressed again; after a selection it is "copy". */
23
23
  options?: {
24
24
  detachKey?: number;
25
25
  clipboardWrites?: boolean;
@@ -1,5 +1,6 @@
1
1
  import { FrameReader, encodeFrame, FrameType, resizeFrame, PING_INTERVAL_MS, helloFrame, } from './protocol.js';
2
2
  import { OutputFilter } from './output-filter.js';
3
+ import { watchClipboard } from './clipboard-watch.js';
3
4
  import { enableVtInput } from './console-input.js';
4
5
  import { InputJoiner, JOIN_MS } from './input-joiner.js';
5
6
  import { CTRL_C_CONFIRM_MS, CtrlCGuard } from './ctrl-c.js';
@@ -40,7 +41,7 @@ const VT_WAIT_MS = 10000;
40
41
  */
41
42
  export function runTerminalClient(socket, token,
42
43
  /** clipboardWrites: the agent's "copy" (OSC 52) reaches the clipboard; see OutputFilterOptions. */
43
- /** confirmCtrlC: a lone Ctrl+C is held back until it is pressed again (CtrlCGuard, attach.confirmCtrlC). */
44
+ /** confirmCtrlC (default true): a lone Ctrl+C is held back until pressed again; after a selection it is "copy". */
44
45
  options = {}) {
45
46
  const stdin = process.stdin;
46
47
  const stdout = process.stdout;
@@ -75,6 +76,7 @@ options = {}) {
75
76
  clearTimeout(joinTimer);
76
77
  clearTimeout(hintTimer);
77
78
  clearTimeout(vtTimer);
79
+ clipboard.stop();
78
80
  socket.destroy();
79
81
  // The agent's mouse tracking ends with it: your terminal's wheel scrolls again (and clicks select text). Reports
80
82
  // the terminal sent before it read that are dropped here, or they would reach your shell as text.
@@ -98,24 +100,24 @@ options = {}) {
98
100
  };
99
101
  // Mouse reports the agent has not asked for (motion without 1002/1003 ...) are dropped: see MouseModes.
100
102
  const sendInput = (data) => write(keys.push(passthrough ? mouse.filter(data) : data));
101
- const guard = options.confirmCtrlC ? new CtrlCGuard() : null;
103
+ const guard = new CtrlCGuard({ confirm: options.confirmCtrlC !== false });
102
104
  let hintTimer;
105
+ // Copying in your terminal (select) changes the clipboard: the Ctrl+C that follows is "copy", not an interrupt.
106
+ const clipboard = watchClipboard(() => guard.clipboardChanged());
103
107
  const onInput = (chunk) => {
104
108
  if (options.detachKey !== undefined && chunk.includes(options.detachKey)) {
105
109
  finish({ reason: 'detach' });
106
110
  return;
107
111
  }
108
- if (guard) {
109
- const decided = guard.push(chunk, Date.now());
110
- clearTimeout(hintTimer);
111
- if (decided.held) {
112
- stdout.write(guard.hintTitle());
113
- hintTimer = setTimeout(() => stdout.write(guard.restoreTitle()), CTRL_C_CONFIRM_MS);
114
- return;
115
- }
116
- if (decided.send.length === 1 && decided.send[0] === 0x03)
117
- stdout.write(guard.restoreTitle());
112
+ const decided = guard.push(chunk, Date.now());
113
+ clearTimeout(hintTimer);
114
+ if (decided.held || decided.copied) {
115
+ stdout.write(guard.hintTitle(decided.copied ? 'copy' : 'hold'));
116
+ hintTimer = setTimeout(() => stdout.write(guard.restoreTitle()), CTRL_C_CONFIRM_MS);
117
+ return;
118
118
  }
119
+ if (decided.send.length === 1 && decided.send[0] === 0x03)
120
+ stdout.write(guard.restoreTitle());
119
121
  clearTimeout(joinTimer);
120
122
  sendInput(joiner.push(chunk));
121
123
  // A lone Esc key press is a "sequence" that never completes: send it after a moment.
@@ -159,9 +161,10 @@ options = {}) {
159
161
  }
160
162
  for (const frame of frames) {
161
163
  if (frame.type === FrameType.Output) {
164
+ guard.watchClipboard(frame.payload.toString('latin1'));
162
165
  const out = filter.push(frame.payload);
163
166
  mouse.watch(out);
164
- guard?.watchOutput(out.toString('latin1'));
167
+ guard.watchOutput(out.toString('latin1'));
165
168
  stdout.write(out);
166
169
  }
167
170
  else if (frame.type === FrameType.Console)
@@ -71,6 +71,13 @@ export declare function itemInfo(text: string): {
71
71
  export declare function incompleteDirs(...manifestDirs: string[]): Set<string>;
72
72
  /** The sizes the saved manifests give their engine folders (lower-case folder -> bytes). */
73
73
  export declare function itemSizes(saved: string): Map<string, number>;
74
+ /** A launcher manifest's file name (<hex id>.item). */
75
+ export declare const ITEM_NAME: RegExp;
76
+ /**
77
+ * A launcher manifest moved from folder `from` to `to`: every path in it under `from` (either slash) is under `to`, and
78
+ * a path elsewhere is dropped (it would only name this PC). Null when the text is not a manifest of `from`.
79
+ */
80
+ export declare function relocateItem(text: string, from: string, to: string): string | null;
74
81
  /** Whether the text is a launcher manifest of an app installed under `root`. */
75
82
  export declare function isItemUnder(text: string, root: string): boolean;
76
83
  /** Copies the launcher's manifests of apps under `root` into `saved` (changed ones only). Returns the file names. */
@@ -191,6 +191,37 @@ export function itemSizes(saved) {
191
191
  }
192
192
  return sizes;
193
193
  }
194
+ /** A launcher manifest's file name (<hex id>.item). */
195
+ export const ITEM_NAME = /^[A-Za-z0-9_-]{1,80}\.item$/;
196
+ /**
197
+ * A launcher manifest moved from folder `from` to `to`: every path in it under `from` (either slash) is under `to`, and
198
+ * a path elsewhere is dropped (it would only name this PC). Null when the text is not a manifest of `from`.
199
+ */
200
+ export function relocateItem(text, from, to) {
201
+ let item;
202
+ try {
203
+ item = JSON.parse(stripBom(text));
204
+ }
205
+ catch {
206
+ return null; // Not JSON: not a manifest.
207
+ }
208
+ if (!item || typeof item !== 'object' || Array.isArray(item))
209
+ return null;
210
+ const base = win.resolve(from).toLowerCase();
211
+ const out = {};
212
+ for (const [key, value] of Object.entries(item)) {
213
+ if (typeof value !== 'string' || !win.isAbsolute(value)) {
214
+ out[key] = value;
215
+ continue;
216
+ }
217
+ const full = win.resolve(value);
218
+ const low = full.toLowerCase();
219
+ if (low === base || low.startsWith(base + '\\'))
220
+ out[key] = to + full.slice(base.length);
221
+ }
222
+ const at = typeof out.InstallLocation === 'string' ? out.InstallLocation : '';
223
+ return at.toLowerCase() === to.toLowerCase() ? JSON.stringify(out, null, '\t') : null;
224
+ }
194
225
  /** Whether the text is a launcher manifest of an app installed under `root`. */
195
226
  export function isItemUnder(text, root) {
196
227
  const at = itemInstallLocation(text);
@@ -198,7 +229,7 @@ export function isItemUnder(text, root) {
198
229
  }
199
230
  function itemFiles(dir) {
200
231
  try {
201
- return fs.readdirSync(dir).filter((f) => /^[A-Za-z0-9_-]{1,80}\.item$/.test(f));
232
+ return fs.readdirSync(dir).filter((f) => ITEM_NAME.test(f));
202
233
  }
203
234
  catch {
204
235
  return [];
@@ -0,0 +1,26 @@
1
+ /** A second Ctrl+C within this long sends it. */
2
+ export declare const CTRL_C_CONFIRM_MS = 1500;
3
+ /** What the title shows while a Ctrl+C is held back. */
4
+ export declare const CTRL_C_HINT = "Ctrl+C again to send it to the program (Esc stops an agent)";
5
+ /** What the title shows when a Ctrl+C was taken as "copy". */
6
+ export declare const CTRL_C_COPY_HINT = "Copied: Ctrl+C after selecting text is not sent to the program (Esc stops an agent)";
7
+ export type CtrlCAction = 'send' | 'hold' | 'copy';
8
+ export declare class CtrlCDecider {
9
+ private readonly confirm;
10
+ private armedAt;
11
+ private selected;
12
+ constructor(confirm?: boolean);
13
+ /** Text was selected in the program: Ctrl+C is "copy" from now on. */
14
+ selection(): void;
15
+ /** Any other key or a click: Ctrl+C is an interrupt again. */
16
+ otherInput(): void;
17
+ /** What to do with a lone Ctrl+C pressed at `nowMs`. */
18
+ ctrlC(nowMs: number): CtrlCAction;
19
+ }
20
+ /** What typed input holds: the strongest mouse report (a drag, else a click), and whether anything else is in it. */
21
+ export declare function classifyInput(text: string): {
22
+ readonly mouse: 'drag' | 'click' | null;
23
+ readonly keys: boolean;
24
+ };
25
+ /** True when program output asks the terminal to write the clipboard (OSC 52). */
26
+ export declare function hasClipboardWrite(text: string): boolean;