windows-development-sandbox 1.11.3 → 1.11.4

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.
@@ -113,7 +113,7 @@ export declare const pruneStorage: import("../../kernel/define.js").OperationDef
113
113
  message: z.ZodString;
114
114
  removed: z.ZodBoolean;
115
115
  }, z.core.$strip>, "data.prune">;
116
- /** The data page: disks, then one section per area, the total, and clean-up. */
116
+ /** The data page: the disks, then every kept folder in one table (its total as the summary, clean-up as its action). */
117
117
  export declare function dataPage(o: DataShow): Page;
118
118
  export declare const dataView: import("../../kernel/define.js").ViewDef<{
119
119
  data: import("../../kernel/define.js").QueryDef<z.ZodObject<{}, z.core.$strip>, z.ZodObject<{
@@ -103,7 +103,7 @@ export const pruneStorage = defineOperation({
103
103
  },
104
104
  });
105
105
  const DAYS_FIELD = fieldOf(new SchemaNode(PruneInputSchema).child('days'), ['days']);
106
- /** The data page: disks, then one section per area, the total, and clean-up. */
106
+ /** The data page: the disks, then every kept folder in one table (its total as the summary, clean-up as its action). */
107
107
  export function dataPage(o) {
108
108
  const disks = {
109
109
  id: 'disks',
@@ -132,44 +132,44 @@ export function dataPage(o) {
132
132
  })),
133
133
  notes: [{ text: 'Persistent data for the sandbox: ' + o.dataPath + ' (C:\\AgentData inside)' }],
134
134
  };
135
- const areas = DATA_AREAS.flatMap((area) => {
136
- const rows = o.entries.filter((e) => e.area === area);
137
- if (!rows.length)
138
- return [];
139
- return [
140
- {
141
- id: 'area:' + area,
142
- title: AREA_LABEL[area],
143
- summary: o.areaTotals[area] ?? '',
144
- columns: [
145
- { key: 'name', label: '' },
146
- { key: 'size', label: '' },
147
- { key: 'path', label: '' },
148
- ],
149
- columnsOn: 'cli',
150
- items: rows.map((e) => ({
151
- id: 'entry:' + e.name,
152
- label: e.name,
153
- hint: e.size,
154
- ...(e.target
155
- ? { keys: 'x deletes it.' }
156
- : { description: 'Kept on this PC on purpose; it is not deleted here.' }),
157
- details: ['Folder ' + e.path],
158
- cells: { name: ' ' + e.name, size: e.size, path: e.path },
159
- actions: e.target
160
- ? [action(cleanData, { targets: [e.target] }, { label: 'Delete', key: 'x', verb: 'delete' })]
161
- : [],
162
- })),
163
- },
164
- ];
165
- });
166
- const total = {
167
- id: 'total',
168
- facts: [{ id: 'total', label: 'Total', value: o.total }],
135
+ // Every folder in one table, grouped by kind (the order of DATA_AREAS), biggest first within a kind.
136
+ const kindRank = (area) => DATA_AREAS.indexOf(area);
137
+ const rows = [...o.entries].sort((a, b) => kindRank(a.area) - kindRank(b.area) || b.bytes - a.bytes);
138
+ const kept = {
139
+ id: 'kept',
140
+ title: 'What the sandbox keeps',
141
+ summary: o.total + ' in all',
142
+ columns: [
143
+ { key: 'kind', label: 'KIND' },
144
+ { key: 'name', label: 'NAME' },
145
+ { key: 'size', label: 'SIZE' },
146
+ { key: 'folder', label: 'FOLDER' },
147
+ ],
148
+ items: rows.map((e) => {
149
+ const del = e.target
150
+ ? action(cleanData, { targets: [e.target] }, { label: 'Delete', key: 'x', verb: 'delete' })
151
+ : undefined;
152
+ return {
153
+ id: 'entry:' + e.name,
154
+ label: e.name,
155
+ hint: e.size,
156
+ cells: { kind: AREA_LABEL[e.area], name: e.name, size: e.size, folder: e.path },
157
+ ...(del ? { primary: del } : {}),
158
+ };
159
+ }),
160
+ actions: [
161
+ action(pruneStorage, {}, {
162
+ label: 'Clean up…',
163
+ verb: 'choose what',
164
+ description: 'Delete caches, old sandboxes and other leftovers (the clean command, with its options).',
165
+ fields: [DAYS_FIELD],
166
+ }),
167
+ ],
169
168
  notes: [
170
169
  ...(o.entries.length
171
170
  ? []
172
171
  : [{ text: 'Nothing stored yet. Toolchains, databases and agent logins appear here after the first start.' }]),
172
+ ...keptNote(o),
173
173
  {
174
174
  text: 'Free space with: sandbox data clean <target...> (e.g. tools/go, databases/postgres-18, logs, launches)',
175
175
  tone: 'muted',
@@ -177,19 +177,12 @@ export function dataPage(o) {
177
177
  },
178
178
  ],
179
179
  };
180
- const clean = {
181
- id: 'clean',
182
- title: 'Clean up',
183
- actions: [
184
- action(pruneStorage, {}, {
185
- label: 'Clean up…',
186
- verb: 'choose what',
187
- description: 'Delete caches, old sandboxes and other leftovers (the clean command, with its options).',
188
- fields: [DAYS_FIELD],
189
- }),
190
- ],
191
- };
192
- return { title: 'Data', sections: [disks, ...areas, total, clean] };
180
+ return { title: 'Data', sections: [disks, kept] };
181
+ }
182
+ /** The folders kept on purpose (not deleted here), named in one note. */
183
+ function keptNote(o) {
184
+ const names = o.entries.filter((e) => !e.target).map((e) => e.name);
185
+ return names.length ? [{ text: 'Kept on this PC on purpose, not deleted here: ' + names.join(', ') + '.' }] : [];
193
186
  }
194
187
  export const dataView = defineView({
195
188
  id: 'data',
@@ -1262,9 +1262,16 @@ export interface NavSection {
1262
1262
  readonly elsewhere?: string;
1263
1263
  /** Entries under this one (Sandbox: Status, Projects). */
1264
1264
  readonly children?: readonly NavSection[];
1265
+ /** Views shown as more categories inside this entry's page (Settings: VPN, GitHub), not as entries of their own. */
1266
+ readonly embeds?: readonly {
1267
+ readonly label: string;
1268
+ readonly view: ViewId;
1269
+ }[];
1265
1270
  }
1266
1271
  /** Every entry of the navigation, children included, in order. */
1267
1272
  export declare function navSections(nav: readonly NavSection[]): NavSection[];
1268
1273
  /** Every view the web's navigation opens. */
1269
1274
  export declare function navViews(nav: readonly NavSection[]): ViewId[];
1275
+ /** The entry whose page shows this view as one of its categories (VPN: Settings), if any. */
1276
+ export declare function embeddingOf(nav: readonly NavSection[], view: string): NavSection | undefined;
1270
1277
  export declare const NAV: readonly NavSection[];
@@ -125,7 +125,11 @@ export function navSections(nav) {
125
125
  }
126
126
  /** Every view the web's navigation opens. */
127
127
  export function navViews(nav) {
128
- return navSections(nav).flatMap((n) => (n.view ? [n.view] : []));
128
+ return navSections(nav).flatMap((n) => [...(n.view ? [n.view] : []), ...(n.embeds ?? []).map((e) => e.view)]);
129
+ }
130
+ /** The entry whose page shows this view as one of its categories (VPN: Settings), if any. */
131
+ export function embeddingOf(nav, view) {
132
+ return navSections(nav).find((n) => n.embeds?.some((e) => e.view === view));
129
133
  }
130
134
  export const NAV = [
131
135
  { label: 'Agents', view: 'agents' },
@@ -140,8 +144,8 @@ export const NAV = [
140
144
  { label: 'Apps', view: 'apps' },
141
145
  {
142
146
  label: 'Settings',
143
- children: [
144
- { label: 'All settings', view: 'settings' },
147
+ view: 'settings',
148
+ embeds: [
145
149
  { label: 'VPN', view: 'vpn' },
146
150
  { label: 'GitHub', view: 'github' },
147
151
  ],
@@ -165,22 +165,32 @@ export const enableSandbox = defineOperation({
165
165
  },
166
166
  },
167
167
  });
168
+ /** A repair row: what it means is the description; what was found and the fix are the detail lines. */
168
169
  function checkItems(last) {
169
170
  return last.checks.map((r, i) => {
170
- const lines = resultLines(r)
171
+ const found = r.detail + (r.timedOut ? ' (took too long)' : '');
172
+ const rest = resultLines(r)
171
173
  .slice(1)
172
- .map((l) => l.text.trim());
174
+ .map((l) => l.text.trim())
175
+ .filter((l) => !l.startsWith('What it means: '));
176
+ const details = r.means ? [found, ...rest] : rest;
173
177
  return {
174
178
  id: 'repair:' + i,
175
179
  label: r.name,
176
180
  hint: BADGE[r.status],
177
181
  ...(TONE[r.status] ? { tone: TONE[r.status] } : {}),
178
182
  badges: [{ text: BADGE[r.status], ...(TONE[r.status] ? { tone: TONE[r.status] } : {}) }],
179
- description: r.detail,
180
- ...(lines.length ? { details: lines } : {}),
183
+ description: r.means ?? found,
184
+ ...(details.length ? { details } : {}),
181
185
  };
182
186
  });
183
187
  }
188
+ /** A "Set up this PC" row: the real value is the hint; OK or action needed is the tone and the badge. */
189
+ const SETUP_BADGE = {
190
+ ok: 'OK',
191
+ warn: 'WARNING',
192
+ action: 'NEEDS YOU',
193
+ };
184
194
  /** The health page: before any check, the two things to do; after one, what it found. Pure. */
185
195
  export function healthPage(o) {
186
196
  const last = o.last;
@@ -223,8 +233,8 @@ export function healthPage(o) {
223
233
  const sections = [
224
234
  {
225
235
  id: 'status',
226
- summary: (last.mode === 'repair' ? 'Repaired ' : 'Checked ') + ago(last.at),
227
- facts: last.log ? [{ id: 'log', label: 'Log', value: last.log }] : [],
236
+ summary: (last.mode === 'repair' ? 'Last repair ' : 'Last check ') + ago(last.at),
237
+ ...(last.log ? { notes: [{ text: 'Log: ' + last.log, tone: 'muted' }] } : {}),
228
238
  },
229
239
  {
230
240
  id: 'doctor',
@@ -232,8 +242,9 @@ export function healthPage(o) {
232
242
  items: last.details.map(([name, detail], i) => ({
233
243
  id: 'doctor:' + i,
234
244
  label: name,
245
+ // The hint is the whole value; only a long one is cut, and then the description keeps all of it.
235
246
  hint: cut(detail, 50),
236
- details: [detail],
247
+ ...(detail.length > 50 ? { description: detail } : {}),
237
248
  })),
238
249
  },
239
250
  {
@@ -252,9 +263,14 @@ export function healthPage(o) {
252
263
  : setup.checks.map((c, i) => ({
253
264
  id: 'setup:' + i,
254
265
  label: c.name,
255
- hint: c.status === 'ok' ? 'OK' : c.status === 'warn' ? 'WARNING' : 'ACTION NEEDED',
266
+ hint: c.detail,
256
267
  tone: c.status === 'ok' ? 'ok' : c.status === 'warn' ? 'warn' : 'danger',
257
- description: c.detail,
268
+ badges: [
269
+ {
270
+ text: SETUP_BADGE[c.status],
271
+ tone: c.status === 'ok' ? 'ok' : c.status === 'warn' ? 'warn' : 'danger',
272
+ },
273
+ ],
258
274
  })),
259
275
  notes: canEnable
260
276
  ? [{ text: 'Turn Windows Sandbox on from the menu or with sandbox setup.', tone: 'muted', only: 'web' }]
@@ -160,22 +160,17 @@ export const containedOff = defineOperation({
160
160
  });
161
161
  /** The Storage page: the data folder (change, move, use another), then contained storage (beta). */
162
162
  export function storagePage(o, c) {
163
- const folder = {
164
- id: '@data-folder',
165
- label: 'Data folder',
166
- hint: o.root,
167
- description: 'Where your tools, logins, history and logs are kept between sandboxes. Press Enter to move them to another folder or drive, or to use a different folder.',
168
- details: [
169
- o.size,
170
- 'Default folder: ' + o.defaultRoot + (o.defaultInUse ? ' (in use)' : ''),
171
- ...o.otherFoldersWithData.map((f) => 'Another folder with data: ' + f),
172
- ],
173
- primary: action(changeDataFolder, {}, { label: 'Data folder', verb: 'change' }),
174
- };
163
+ // Move and use another folder are the two ways to change it (the same choice as the Data folder setting).
175
164
  const data = {
176
165
  id: 'folder',
177
166
  title: 'Data folder',
178
- items: [folder],
167
+ description: 'Where your tools, logins, history and logs are kept between sandboxes.',
168
+ facts: [
169
+ { id: 'folder', label: 'Folder', value: o.root },
170
+ { id: 'size', label: 'Size', value: o.size },
171
+ { id: 'default', label: 'Default folder', value: o.defaultRoot + (o.defaultInUse ? ' (in use)' : '') },
172
+ ...o.otherFoldersWithData.map((f, i) => ({ id: 'other:' + i, label: 'Other folder with data', value: f })),
173
+ ],
179
174
  actions: [action(moveStorage, {}), action(useStorage, {})],
180
175
  };
181
176
  const contained = {
@@ -159,7 +159,6 @@ export function updatesPage(s, latest) {
159
159
  hint: 'off',
160
160
  tone: 'warn',
161
161
  description: s.autoOffText,
162
- details: [s.autoOffText],
163
162
  }),
164
163
  ]
165
164
  : []),
@@ -178,8 +177,8 @@ export function updatesPage(s, latest) {
178
177
  row('failed', 'Last failed update', {
179
178
  hint: s.failed.version,
180
179
  tone: 'danger',
181
- description: s.failed.reason,
182
- details: [s.failed.reason, 'It is not tried again until a newer version is released.'],
180
+ description: 'It is not tried again until a newer version is released.',
181
+ details: [s.failed.reason],
183
182
  }),
184
183
  ]
185
184
  : []),
@@ -30,6 +30,22 @@ export function ghExePath() {
30
30
  const dir = tool('cli', 'gh');
31
31
  return path.join(entry?.subdir ? path.join(dir, entry.subdir) : dir, entry?.exe ?? 'gh.exe');
32
32
  }
33
+ /**
34
+ * gh keeps its token in Windows Credential Manager unless told otherwise, and that is wiped with the sandbox: only a
35
+ * login made with --insecure-storage (the token in GH_CONFIG_DIR, which persists) is still there next time.
36
+ */
37
+ const GH_NOTE = ' (log in once with: gh auth login --web --insecure-storage; it is kept between sandboxes)';
38
+ /** A saved gh account whose token was in Credential Manager (gone now): say how to log in so that it lasts. */
39
+ function warnKeyringLogin() {
40
+ try {
41
+ const hosts = fs.readFileSync(path.join(ghConfigDir(), 'hosts.yml'), 'utf8');
42
+ if (/^\s*user:/m.test(hosts) && !/oauth_token:/.test(hosts))
43
+ warn('gh: the GitHub login from an earlier sandbox was kept in Windows Credential Manager, which is wiped with each sandbox. Log in again with: gh auth login --web --insecure-storage (then it lasts).');
44
+ }
45
+ catch {
46
+ // No saved login: nothing to say.
47
+ }
48
+ }
33
49
  const toolVars = {
34
50
  gh: () => ({
35
51
  GH_CONFIG_DIR: exists(deliveredGhConfigDir()) ? deliveredGhConfigDir() : mkdirp(ghConfigDir()),
@@ -85,8 +101,11 @@ export async function setupCli(env, settings, gitAccess, refresh = false) {
85
101
  refresh,
86
102
  subdir: entry.subdir,
87
103
  size: entry.size,
104
+ ...(name === 'gh' ? { note: GH_NOTE } : {}),
88
105
  });
89
106
  Object.assign(env.vars, toolVars[name]?.() ?? {});
107
+ if (name === 'gh')
108
+ warnKeyringLogin();
90
109
  }
91
110
  catch (e) {
92
111
  // One tool failing never takes the others down.
@@ -17,6 +17,24 @@ export type HostMemory = z.output<typeof HostMemorySchema>;
17
17
  export declare function parseHostMemory(text: string): HostMemory | null;
18
18
  /** This PC's memory now, or null when it cannot be read (and always during the self-test: it never asks Windows). */
19
19
  export declare function readHostMemory(storageRoot: string): HostMemory | null;
20
+ /**
21
+ * One long-running `HostTools memory-status --serve` for the watcher: a reading every `seconds`, instead of starting a
22
+ * program (and building a disk counter) for each one. Without HostTools.exe it does nothing and `latest()` stays null.
23
+ */
24
+ export declare class HostMemoryStream {
25
+ private readonly storageRoot;
26
+ private readonly seconds;
27
+ private child;
28
+ private last;
29
+ private startedAt;
30
+ private stopped;
31
+ constructor(storageRoot: string, seconds: number);
32
+ start(): void;
33
+ /** The newest reading while it is fresh; starts the stream again when it ended (at most once a minute). */
34
+ latest(): HostMemory | null;
35
+ /** Ends it (closing its input ends HostTools). */
36
+ stop(): void;
37
+ }
20
38
  /** The same without blocking: for the watcher, which must never wait on it. */
21
39
  export declare function readHostMemoryAsync(storageRoot: string): Promise<HostMemory | null>;
22
40
  /** How low this PC's memory is: what the host watchdog acts on (pure). */
@@ -47,6 +47,72 @@ export function readHostMemory(storageRoot) {
47
47
  const result = spawnSync(file, args, { encoding: 'utf8', windowsHide: true, timeout: TIMEOUT_MS });
48
48
  return parseHostMemory(result.stdout || '');
49
49
  }
50
+ /** A reading older than this is not used (the stream stopped answering): the watcher asks once instead. */
51
+ const STREAM_FRESH_MS = 30000;
52
+ /** A stream that ended is started again at most this often. */
53
+ const STREAM_RESTART_MS = 60000;
54
+ /**
55
+ * One long-running `HostTools memory-status --serve` for the watcher: a reading every `seconds`, instead of starting a
56
+ * program (and building a disk counter) for each one. Without HostTools.exe it does nothing and `latest()` stays null.
57
+ */
58
+ export class HostMemoryStream {
59
+ storageRoot;
60
+ seconds;
61
+ child = null;
62
+ last = null;
63
+ startedAt = 0;
64
+ stopped = false;
65
+ constructor(storageRoot, seconds) {
66
+ this.storageRoot = storageRoot;
67
+ this.seconds = seconds;
68
+ }
69
+ start() {
70
+ if (!isWin || selfTestRunning() || this.stopped || this.child)
71
+ return;
72
+ const tools = hostToolsExe(this.storageRoot);
73
+ if (!tools)
74
+ return;
75
+ this.startedAt = Date.now();
76
+ try {
77
+ const child = spawn(tools, ['memory-status', '--serve', String(this.seconds)], {
78
+ windowsHide: true,
79
+ stdio: ['pipe', 'pipe', 'ignore'],
80
+ });
81
+ this.child = child;
82
+ let buffer = '';
83
+ child.stdout.setEncoding('utf8');
84
+ child.stdout.on('data', (chunk) => {
85
+ buffer = (buffer + chunk).slice(-16384);
86
+ let end;
87
+ while ((end = buffer.indexOf('\n')) >= 0) {
88
+ const memory = parseHostMemory(buffer.slice(0, end).trim());
89
+ buffer = buffer.slice(end + 1);
90
+ if (memory)
91
+ this.last = { at: Date.now(), memory };
92
+ }
93
+ });
94
+ child.stdin.on('error', () => undefined);
95
+ child.on('error', () => (this.child = null));
96
+ child.on('exit', () => (this.child = null));
97
+ }
98
+ catch {
99
+ // Could not start it: the watcher asks the old way.
100
+ this.child = null;
101
+ }
102
+ }
103
+ /** The newest reading while it is fresh; starts the stream again when it ended (at most once a minute). */
104
+ latest() {
105
+ if (!this.child && Date.now() - this.startedAt >= STREAM_RESTART_MS)
106
+ this.start();
107
+ return this.last && Date.now() - this.last.at < STREAM_FRESH_MS ? this.last.memory : null;
108
+ }
109
+ /** Ends it (closing its input ends HostTools). */
110
+ stop() {
111
+ this.stopped = true;
112
+ this.child?.stdin?.end();
113
+ this.child = null;
114
+ }
115
+ }
50
116
  /** The same without blocking: for the watcher, which must never wait on it. */
51
117
  export function readHostMemoryAsync(storageRoot) {
52
118
  if (!isWin || selfTestRunning())
@@ -29978,6 +29978,17 @@ function ghConfigDir() {
29978
29978
  function deliveredGhConfigDir() {
29979
29979
  return path45.join(localDir, "gh-token");
29980
29980
  }
29981
+ var GH_NOTE = " (log in once with: gh auth login --web --insecure-storage; it is kept between sandboxes)";
29982
+ function warnKeyringLogin() {
29983
+ try {
29984
+ const hosts = fs35.readFileSync(path45.join(ghConfigDir(), "hosts.yml"), "utf8");
29985
+ if (/^\s*user:/m.test(hosts) && !/oauth_token:/.test(hosts))
29986
+ warn(
29987
+ "gh: the GitHub login from an earlier sandbox was kept in Windows Credential Manager, which is wiped with each sandbox. Log in again with: gh auth login --web --insecure-storage (then it lasts)."
29988
+ );
29989
+ } catch {
29990
+ }
29991
+ }
29981
29992
  var toolVars = {
29982
29993
  gh: () => ({
29983
29994
  GH_CONFIG_DIR: exists(deliveredGhConfigDir()) ? deliveredGhConfigDir() : mkdirp(ghConfigDir()),
@@ -30018,9 +30029,11 @@ async function setupCli(env, settings, gitAccess, refresh = false) {
30018
30029
  await setupSingleExe(env, name, entry.repo, entry.asset(arch), entry.exe, tool("cli", name), {
30019
30030
  refresh,
30020
30031
  subdir: entry.subdir,
30021
- size: entry.size
30032
+ size: entry.size,
30033
+ ...name === "gh" ? { note: GH_NOTE } : {}
30022
30034
  });
30023
30035
  Object.assign(env.vars, toolVars[name]?.() ?? {});
30036
+ if (name === "gh") warnKeyringLogin();
30024
30037
  } catch (e) {
30025
30038
  warn(name + " could not be set up and is skipped; the other tools are not affected. Reason: " + errMsg(e));
30026
30039
  env.summary.push(entry.name + ": FAILED");
@@ -29266,6 +29266,17 @@ function ghExePath() {
29266
29266
  const dir = tool("cli", "gh");
29267
29267
  return path34.join(entry?.subdir ? path34.join(dir, entry.subdir) : dir, entry?.exe ?? "gh.exe");
29268
29268
  }
29269
+ var GH_NOTE = " (log in once with: gh auth login --web --insecure-storage; it is kept between sandboxes)";
29270
+ function warnKeyringLogin() {
29271
+ try {
29272
+ const hosts = fs28.readFileSync(path34.join(ghConfigDir(), "hosts.yml"), "utf8");
29273
+ if (/^\s*user:/m.test(hosts) && !/oauth_token:/.test(hosts))
29274
+ warn(
29275
+ "gh: the GitHub login from an earlier sandbox was kept in Windows Credential Manager, which is wiped with each sandbox. Log in again with: gh auth login --web --insecure-storage (then it lasts)."
29276
+ );
29277
+ } catch {
29278
+ }
29279
+ }
29269
29280
  var toolVars = {
29270
29281
  gh: () => ({
29271
29282
  GH_CONFIG_DIR: exists(deliveredGhConfigDir()) ? deliveredGhConfigDir() : mkdirp(ghConfigDir()),
@@ -29306,9 +29317,11 @@ async function setupCli(env, settings, gitAccess, refresh = false) {
29306
29317
  await setupSingleExe(env, name, entry.repo, entry.asset(arch), entry.exe, tool("cli", name), {
29307
29318
  refresh,
29308
29319
  subdir: entry.subdir,
29309
- size: entry.size
29320
+ size: entry.size,
29321
+ ...name === "gh" ? { note: GH_NOTE } : {}
29310
29322
  });
29311
29323
  Object.assign(env.vars, toolVars[name]?.() ?? {});
29324
+ if (name === "gh") warnKeyringLogin();
29312
29325
  } catch (e) {
29313
29326
  warn(name + " could not be set up and is skipped; the other tools are not affected. Reason: " + errMsg(e));
29314
29327
  env.summary.push(entry.name + ": FAILED");
@@ -629,12 +629,18 @@ export const appSuite = {
629
629
  const dataSample = dataPage(stored);
630
630
  const dataSectionIds = dataSample.sections.map((s) => s.id);
631
631
  const dataOps = pageActions(dataSample);
632
- const accountsItem = dataSample.sections.find((s) => s.id === 'area:host')?.items?.[0];
633
- t.check(dataSectionIds.includes('area:tools') &&
634
- dataSectionIds.includes('area:state') &&
635
- dataOps.some((a) => a.kind === 'operation' && a.op === 'data.clean' && JSON.stringify(a.input) === '{"targets":["tools/rust"]}') &&
636
- (accountsItem?.actions ?? []).length === 0 &&
637
- dataOps.some((a) => a.kind === 'operation' && a.op === 'data.prune'), 'data: disks, one section per area, x deletes only what may be deleted', dataSectionIds.join());
632
+ const keptSection = dataSample.sections.find((s) => s.id === 'kept');
633
+ const keptItems = keptSection?.items ?? [];
634
+ const accountsItem = keptItems.find((i) => i.label === 'accounts');
635
+ t.check(dataSectionIds.join() === 'disks,kept' &&
636
+ keptItems.map((i) => i.label).join() === 'rust,state/claude,accounts' &&
637
+ keptSection?.summary === '6.5 GB in all' &&
638
+ keptItems[0]?.primary?.kind === 'operation' &&
639
+ keptItems[0].primary.op === 'data.clean' &&
640
+ JSON.stringify(keptItems[0].primary.input) === '{"targets":["tools/rust"]}' &&
641
+ accountsItem?.primary === undefined &&
642
+ (keptSection?.actions ?? []).some((a) => a.kind === 'operation' && a.op === 'data.prune') &&
643
+ dataOps.some((a) => a.kind === 'operation' && a.op === 'data.clean'), 'data: disks, then one table of every kept folder (total in the summary); delete only where it may be deleted', dataSectionIds.join());
638
644
  const shaped = showData.cli.shapeJson(stored);
639
645
  const shapedEntries = shaped.entries;
640
646
  const shapedDisks = shaped.disks;
@@ -644,6 +650,10 @@ export const appSuite = {
644
650
  const dataLines = pageLines(dataSample);
645
651
  t.check(dataLines.some((l) => l.startsWith('Disks')) &&
646
652
  dataLines.some((l) => /^DRIVE\s+USED\s+FREE\s+SIZE/.test(l)) &&
653
+ dataLines.some((l) => l.startsWith('What the sandbox keeps 6.5 GB in all')) &&
654
+ dataLines.some((l) => /^KIND\s+NAME\s+SIZE\s+FOLDER/.test(l)) &&
655
+ dataLines.some((l) => /^Toolchains\s+rust\s+4\.6 GB\s+C:\\AgentData\\tools\\rust/.test(l)) &&
656
+ dataLines.some((l) => l.startsWith('Kept on this PC on purpose, not deleted here: accounts.')) &&
647
657
  dataLines.some((l) => l.startsWith('Persistent data for the sandbox: ')) &&
648
658
  dataLines.some((l) => l.startsWith('Free space with:')), 'data on the command line: the disks table, the data folder line and the free space line', dataLines.join('\n'));
649
659
  t.check(definitionFor('data', ['clean'])?.id === 'data.clean' &&
@@ -837,10 +847,12 @@ export const appSuite = {
837
847
  mountedNow: null,
838
848
  };
839
849
  const storagePageNow = storagePage(shownStorage, shownContained);
840
- const folderPrimary = storagePageNow.sections[0]?.items?.[0]?.primary;
850
+ const folderSection = storagePageNow.sections[0];
851
+ const folderOps = (folderSection?.actions ?? []).map((a) => (a.kind === 'operation' ? a.op : a.kind));
841
852
  const containedActions = storagePageNow.sections[1]?.actions ?? [];
842
- t.check(folderPrimary?.kind === 'operation' &&
843
- folderPrimary.op === 'storage.change' &&
853
+ t.check(folderOps.join() === 'storage.move,storage.use' &&
854
+ (folderSection?.facts ?? []).map((f) => f.label).join() ===
855
+ 'Folder,Size,Default folder,Other folder with data' &&
844
856
  containedActions.length === 1 &&
845
857
  containedActions[0].kind === 'operation' &&
846
858
  containedActions[0].op === 'storage.containedOn', 'the storage page: the data folder row changes it, and contained storage offers to turn it on while it is off');
@@ -23,6 +23,8 @@ export declare class Watcher {
23
23
  private views;
24
24
  private stops;
25
25
  private watchdogTimer;
26
+ /** This PC's memory, read by one long-running HostTools instead of a program per sample. */
27
+ private memoryStream;
26
28
  private watchdog;
27
29
  private watchdogBusy;
28
30
  private lastCpu;
@@ -13,7 +13,7 @@ import { readForwards, sandboxAddress, startPortForwards } from '../host/network
13
13
  import { AuditCollector, readAuditSettings } from '../host/audit/collect.js';
14
14
  import { readMarkedProjects, saveDataSize, watchAndMark } from '../host/guard/index.js';
15
15
  import { sizeWithin } from '../core/disk.js';
16
- import { autoRestartAllowed, causeSince, appendHostMemory, cpuPercentBetween, KeepAwake, LifeRecord, readHostMemoryAsync, readLoadSettings, REQUEST_SECONDS, WATCHDOG_START, watchdogStep, saveCrashReport, SessionLedger, } from '../host/life/index.js';
16
+ import { autoRestartAllowed, causeSince, appendHostMemory, cpuPercentBetween, KeepAwake, LifeRecord, HostMemoryStream, readHostMemoryAsync, readLoadSettings, REQUEST_SECONDS, WATCHDOG_START, watchdogStep, saveCrashReport, SessionLedger, } from '../host/life/index.js';
17
17
  import { SandboxChannel } from '../host/channel/index.js';
18
18
  import { ProjectRegistry } from '../host/projects/registry.js';
19
19
  import { ContainedStorage, setNameOf, storageOf } from '../host/storage/index.js';
@@ -85,6 +85,8 @@ export class Watcher {
85
85
  views = [];
86
86
  stops = [];
87
87
  watchdogTimer = null;
88
+ /** This PC's memory, read by one long-running HostTools instead of a program per sample. */
89
+ memoryStream = null;
88
90
  watchdog = WATCHDOG_START;
89
91
  watchdogBusy = false;
90
92
  lastCpu = os.cpus();
@@ -208,6 +210,8 @@ export class Watcher {
208
210
  });
209
211
  this.stops = [...viewStops, stopForwards, stopMarking];
210
212
  // Memory and CPU of this PC: when low, the sandbox is asked to slow its builds down (host/life/watchdog.ts).
213
+ this.memoryStream = new HostMemoryStream(storageRoot, WATCHDOG_MS / 1000);
214
+ this.memoryStream.start();
211
215
  this.watchdogTimer = setInterval(() => void this.watchHost(), WATCHDOG_MS);
212
216
  this.watchdogTimer.unref();
213
217
  // sandbox.keepAwake, and the life record that tells the next launch how this run ended (host/life).
@@ -220,7 +224,7 @@ export class Watcher {
220
224
  this.watchdogBusy = true;
221
225
  try {
222
226
  const settings = readLoadSettings(this.storageRoot, this.dataPath);
223
- const memory = await readHostMemoryAsync(this.storageRoot);
227
+ const memory = this.memoryStream?.latest() ?? (await readHostMemoryAsync(this.storageRoot));
224
228
  const cpus = os.cpus();
225
229
  const cpuPercent = cpuPercentBetween(this.lastCpu, cpus);
226
230
  this.lastCpu = cpus;
@@ -324,6 +328,7 @@ export class Watcher {
324
328
  this.awake?.stop();
325
329
  if (this.watchdogTimer)
326
330
  clearInterval(this.watchdogTimer);
331
+ this.memoryStream?.stop();
327
332
  this.stops.forEach((stop) => stop());
328
333
  if (this.auditTimer)
329
334
  clearInterval(this.auditTimer);
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Sandbox</title>
7
7
  <link rel="icon" href="data:," />
8
- <link rel="stylesheet" href="/main-6JF3ASB5.css" />
9
- <script type="module" src="/main-Z645W45E.js"></script>
8
+ <link rel="stylesheet" href="/main-MU5XWZMS.css" />
9
+ <script type="module" src="/main-SLQNJRXD.js"></script>
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>