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
@@ -0,0 +1,305 @@
1
+ // Editing one value with the screen that fits it: on/off flips, choices are picked from a list, numbers and text are
2
+ // typed and checked as you type, lists and maps get an editor of their own (add, change, remove), and objects (a
3
+ // mapped folder, an agent) open as a small form. JSON is only the last resort for shapes none of these fit.
4
+ import _ from 'lodash';
5
+ import { Text, Theme } from '../../tui/index.js';
6
+ /** A value in one short line, for the right-hand column. */
7
+ export const formatValue = (value, max = 60) => {
8
+ if (value === undefined)
9
+ return Theme.muted('not set');
10
+ if (value === true)
11
+ return Theme.ok('On');
12
+ if (value === false)
13
+ return Theme.muted('Off');
14
+ if (value === null)
15
+ return Theme.muted('none');
16
+ if (typeof value === 'string')
17
+ return value === '' ? Theme.muted('(empty)') : Text.truncate(value, max);
18
+ if (typeof value === 'number')
19
+ return String(value);
20
+ if (Array.isArray(value))
21
+ return value.length === 0 ? Theme.muted('none') : Text.truncate(value.length + ' · ' + value.map((v) => (typeof v === 'object' ? summary(v) : String(v))).join(', '), max);
22
+ const keys = Object.keys(value);
23
+ return keys.length ? Text.truncate(keys.length + ' · ' + keys.join(', '), max) : Theme.muted('none');
24
+ };
25
+ /** A one-line name for an object in a list (a mapping's folders, an agent's package ...). */
26
+ const summary = (value) => {
27
+ if (!_.isPlainObject(value))
28
+ return String(value);
29
+ const o = value;
30
+ const strings = Object.entries(o).filter(([, v]) => typeof v === 'string' && v);
31
+ if (typeof o.host === 'string' && typeof o.guest === 'string')
32
+ return o.host + ' → ' + o.guest + (o.readOnly ? ' (read-only)' : '');
33
+ return strings.slice(0, 2).map(([, v]) => v).join(' · ') || '{ ' + Object.keys(o).join(', ') + ' }';
34
+ };
35
+ const choiceLabel = (v) => (v === true ? 'On' : v === false ? 'Off' : String(v));
36
+ export class ValueEditor {
37
+ app;
38
+ hints;
39
+ constructor(app, hints = {}) {
40
+ this.app = app;
41
+ this.hints = hints;
42
+ }
43
+ async edit(path, key, node, current) {
44
+ const about = node.description ? [node.description] : [];
45
+ const suggestions = this.hints.suggestions?.(key);
46
+ if (suggestions?.length && node.type === 'string')
47
+ return this.suggest(path, key, node, current, suggestions);
48
+ switch (node.editor) {
49
+ case 'boolean': return { value: !(current ?? false) };
50
+ case 'choice': return this.pick(path, node, current, node.choiceValues);
51
+ case 'union': {
52
+ const open = node.openOption;
53
+ const custom = '@custom';
54
+ const picked = await this.app.choose(path, node.description ?? '', [...node.choiceValues.map((v) => ({ value: v, label: choiceLabel(v) })), ...(open ? [{ value: custom, label: 'Custom ' + open.type + '…', description: 'Type a ' + open.type + ' of your own.' }] : [])], node.choiceValues.includes(current) ? current : open && current !== undefined ? custom : undefined);
55
+ if (picked === undefined)
56
+ return undefined;
57
+ return picked === custom && open ? this.edit(path, key, open, node.choiceValues.includes(current) ? undefined : current) : { value: picked };
58
+ }
59
+ case 'number': {
60
+ const range = node.min !== undefined || node.max !== undefined ? ' (' + (node.min ?? '…') + ' to ' + (node.max ?? '…') + ')' : '';
61
+ const typed = await this.app.text({ path, label: Text.humanize(key[key.length - 1]) + range, about, initial: current === undefined ? '' : String(current), validate: (v) => (v.trim() === '' || !Number.isFinite(Number(v)) ? 'Enter a number.' : node.check(Number(v))) });
62
+ return typed === undefined ? undefined : { value: Number(typed) };
63
+ }
64
+ case 'string': {
65
+ const typed = await this.app.text({ path, label: Text.humanize(key[key.length - 1]), about, initial: typeof current === 'string' ? current : '', allowEmpty: node.check('') === true, validate: (v) => node.check(v) });
66
+ return typed === undefined ? undefined : { value: typed };
67
+ }
68
+ case 'choice-list': {
69
+ const ticked = Array.isArray(current) ? current : [];
70
+ const picked = await this.app.list({ path, intro: node.description, multi: true, items: node.element.choices.map((v) => ({ value: v, label: String(v), checked: ticked.includes(v) })) });
71
+ return picked.kind === 'multi' ? { value: picked.values } : undefined;
72
+ }
73
+ case 'string-list': return new StringListEditor(this.app, path, node, this).run(Array.isArray(current) ? current : []);
74
+ case 'string-map': return new MapEditor(this.app, path, node, this).run(_.isPlainObject(current) ? current : {});
75
+ case 'object-list': return new ObjectListEditor(this.app, path, node, this).run(Array.isArray(current) ? current : []);
76
+ case 'object-map': return new ObjectMapEditor(this.app, path, node, this).run(_.isPlainObject(current) ? current : {});
77
+ case 'object': return new ObjectEditor(this.app, path, node, this).run(_.isPlainObject(current) ? current : node.initial());
78
+ default: return this.json(path, key, node, current);
79
+ }
80
+ }
81
+ async pick(path, node, current, values) {
82
+ const picked = await this.app.choose(path, node.description ?? '', values.map((v) => ({ value: v, label: choiceLabel(v) })), current);
83
+ return picked === undefined ? undefined : { value: picked };
84
+ }
85
+ async suggest(path, key, node, current, suggestions) {
86
+ const other = '@other';
87
+ const picked = await this.app.choose(path, node.description ?? '', [...suggestions.map((s) => ({ value: s, label: s })), { value: other, label: 'Other…', description: 'Type a name that is not in the list.' }], typeof current === 'string' ? current : undefined);
88
+ if (picked === undefined)
89
+ return undefined;
90
+ if (picked !== other)
91
+ return { value: picked };
92
+ const typed = await this.app.text({ path, label: Text.humanize(key[key.length - 1]), initial: typeof current === 'string' ? current : '', validate: (v) => node.check(v) });
93
+ return typed === undefined ? undefined : { value: typed };
94
+ }
95
+ async json(path, key, node, current) {
96
+ const typed = await this.app.text({
97
+ path, label: Text.humanize(key[key.length - 1]) + ' (JSON)', about: node.description ? [node.description] : [], initial: current === undefined ? '' : JSON.stringify(current),
98
+ validate: (v) => { try {
99
+ return node.check(JSON.parse(v));
100
+ }
101
+ catch {
102
+ return 'This is not valid JSON.';
103
+ } },
104
+ });
105
+ return typed === undefined ? undefined : { value: JSON.parse(typed) };
106
+ }
107
+ }
108
+ // ---- collections -----------------------------------------------------------------------------------------------------
109
+ const SAVE = '@save';
110
+ const ADD = '@add';
111
+ /**
112
+ * The shared shape of the list and map editors: a working copy shown as a list with "Save" and "Add" at the top, Enter
113
+ * to change an entry, d to delete it. Esc with unsaved changes asks first.
114
+ */
115
+ class CollectionEditor {
116
+ app;
117
+ path;
118
+ node;
119
+ editor;
120
+ value;
121
+ original;
122
+ constructor(app, path, node, editor) {
123
+ this.app = app;
124
+ this.path = path;
125
+ this.node = node;
126
+ this.editor = editor;
127
+ }
128
+ noun = 'entry';
129
+ async run(start) {
130
+ this.original = structuredClone(start);
131
+ this.value = structuredClone(start);
132
+ let focus;
133
+ for (;;) {
134
+ const dirty = !_.isEqual(this.value, this.original);
135
+ const entries = this.entries();
136
+ const picked = await this.app.list({
137
+ path: this.path,
138
+ intro: this.node.description,
139
+ items: [
140
+ { value: SAVE, label: dirty ? Theme.ok(Theme.glyph.ok + ' Save changes') : Theme.muted(Theme.glyph.ok + ' Done (no changes)'), section: 'Actions' },
141
+ { value: ADD, label: '+ Add ' + this.noun, section: 'Actions' },
142
+ ...entries.map((e) => ({ ...e, section: entries.length + ' ' + this.noun + (entries.length === 1 ? '' : 's') })),
143
+ ],
144
+ startAt: focus,
145
+ actions: [{ key: 'd', label: 'delete' }, ...(this.move ? [{ key: 'u', label: 'move up' }, { key: 'j', label: 'move down' }] : [])],
146
+ empty: 'No ' + this.noun + 's yet.',
147
+ });
148
+ if (picked.kind === 'back') {
149
+ if (!dirty || await this.app.confirm({ path: this.path, question: 'Discard your changes?', yes: 'Discard', no: 'Keep editing', initial: false, danger: true }))
150
+ return undefined;
151
+ continue;
152
+ }
153
+ if (picked.kind === 'pick' && picked.value === SAVE)
154
+ return dirty ? { value: this.value } : undefined;
155
+ if (picked.kind === 'pick' && picked.value === ADD) {
156
+ await this.add();
157
+ continue;
158
+ }
159
+ const id = picked.kind === 'pick' || picked.kind === 'action' ? picked.value : undefined;
160
+ if (id === undefined || id === SAVE || id === ADD)
161
+ continue;
162
+ focus = id;
163
+ if (picked.kind === 'pick')
164
+ await this.open(id);
165
+ else if (picked.kind === 'action' && picked.key === 'd')
166
+ this.remove(id);
167
+ else if (picked.kind === 'action' && this.move)
168
+ focus = this.move(id, picked.key === 'u' ? -1 : 1) ?? id;
169
+ }
170
+ }
171
+ }
172
+ /** A list of strings (project roots, scripts, extra packages ...). */
173
+ class StringListEditor extends CollectionEditor {
174
+ noun = 'item';
175
+ entries() { return this.value.map((v, i) => ({ value: String(i), label: v || Theme.muted('(empty)') })); }
176
+ async ask(initial) {
177
+ const element = this.node.element;
178
+ return this.app.text({ path: this.path, label: 'Value', about: this.node.description ? [this.node.description] : [], initial, validate: (v) => element.check(v) });
179
+ }
180
+ async add() { const v = await this.ask(''); if (v !== undefined)
181
+ this.value.push(v); }
182
+ async open(id) { const v = await this.ask(this.value[Number(id)]); if (v !== undefined)
183
+ this.value[Number(id)] = v; }
184
+ remove(id) { this.value.splice(Number(id), 1); }
185
+ move(id, by) {
186
+ const from = Number(id);
187
+ const to = Math.max(0, Math.min(this.value.length - 1, from + by));
188
+ const [item] = this.value.splice(from, 1);
189
+ this.value.splice(to, 0, item);
190
+ return String(to);
191
+ }
192
+ }
193
+ /** A map of names to simple values (environment variables). */
194
+ class MapEditor extends CollectionEditor {
195
+ noun = 'variable';
196
+ entries() { return Object.entries(this.value).map(([k, v]) => ({ value: k, label: k, hint: formatValue(v) })); }
197
+ async add() {
198
+ const name = await this.app.text({ path: this.path, label: 'Name', about: ['Letters, digits and _ only, e.g. NPM_CONFIG_REGISTRY.'], validate: (v) => (this.value[v] !== undefined ? 'That name is there already.' : /^[A-Za-z_][A-Za-z0-9_]*$/.test(v) ? true : 'Letters, digits and _ only.') });
199
+ if (name === undefined)
200
+ return;
201
+ const edited = await this.editor.edit([...this.path, name], [name], this.node.element, this.node.element.initial());
202
+ if (edited)
203
+ this.value[name] = edited.value;
204
+ }
205
+ async open(id) {
206
+ const edited = await this.editor.edit([...this.path, id], [id], this.node.element, this.value[id]);
207
+ if (edited)
208
+ this.value[id] = edited.value;
209
+ }
210
+ remove(id) { delete this.value[id]; }
211
+ }
212
+ /** A list of objects (mapped folders). */
213
+ class ObjectListEditor extends CollectionEditor {
214
+ noun = 'entry';
215
+ entries() { return this.value.map((v, i) => ({ value: String(i), label: summary(v) })); }
216
+ async add() {
217
+ const edited = await new ObjectEditor(this.app, [...this.path, 'New'], this.node.element, this.editor).run(this.node.element.initial(), true);
218
+ if (edited)
219
+ this.value.push(edited.value);
220
+ }
221
+ async open(id) {
222
+ const edited = await new ObjectEditor(this.app, [...this.path, summary(this.value[Number(id)])], this.node.element, this.editor).run(this.value[Number(id)]);
223
+ if (edited)
224
+ this.value[Number(id)] = edited.value;
225
+ }
226
+ remove(id) { this.value.splice(Number(id), 1); }
227
+ }
228
+ /** A map of names to objects (agents). */
229
+ class ObjectMapEditor extends CollectionEditor {
230
+ noun = 'entry';
231
+ entries() { return Object.entries(this.value).map(([k, v]) => ({ value: k, label: k, hint: Theme.muted(summary(v)) })); }
232
+ async add() {
233
+ const name = await this.app.text({ path: this.path, label: 'Name', validate: (v) => (this.value[v] !== undefined ? 'That name is there already.' : /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v) ? true : 'Letters, digits, . _ and - only.') });
234
+ if (name === undefined)
235
+ return;
236
+ const edited = await new ObjectEditor(this.app, [...this.path, name], this.node.element, this.editor).run(this.node.element.initial(), true);
237
+ if (edited)
238
+ this.value[name] = edited.value;
239
+ }
240
+ async open(id) {
241
+ const edited = await new ObjectEditor(this.app, [...this.path, id], this.node.element, this.editor).run(this.value[id]);
242
+ if (edited)
243
+ this.value[id] = edited.value;
244
+ }
245
+ remove(id) { delete this.value[id]; }
246
+ }
247
+ /** One object as a form: each field with its value, Enter to change it, r to clear an optional one. */
248
+ export class ObjectEditor {
249
+ app;
250
+ path;
251
+ node;
252
+ editor;
253
+ constructor(app, path, node, editor) {
254
+ this.app = app;
255
+ this.path = path;
256
+ this.node = node;
257
+ this.editor = editor;
258
+ }
259
+ async run(start, isNew = false) {
260
+ const value = structuredClone(start);
261
+ let focus;
262
+ let notice;
263
+ for (;;) {
264
+ const dirty = isNew || !_.isEqual(value, start);
265
+ const problem = this.node.check(value);
266
+ const fields = this.node.children();
267
+ const picked = await this.app.list({
268
+ path: this.path,
269
+ intro: this.node.description,
270
+ notice,
271
+ items: [
272
+ { value: SAVE, label: problem === true ? (dirty ? Theme.ok(Theme.glyph.ok + ' Save') : Theme.muted(Theme.glyph.ok + ' Done (no changes)')) : Theme.warn(Theme.glyph.cross + ' Not complete yet'), description: problem === true ? undefined : problem, section: 'Actions' },
273
+ ...fields.map(([name, field]) => ({ value: name, label: Text.humanize(name), hint: formatValue(value[name]), description: field.description, section: 'Fields' })),
274
+ ],
275
+ startAt: focus,
276
+ actions: [{ key: 'r', label: 'clear' }],
277
+ });
278
+ notice = undefined;
279
+ if (picked.kind === 'back') {
280
+ if (!dirty || await this.app.confirm({ path: this.path, question: 'Discard your changes?', yes: 'Discard', no: 'Keep editing', initial: false, danger: true }))
281
+ return undefined;
282
+ continue;
283
+ }
284
+ if (picked.kind === 'pick' && picked.value === SAVE) {
285
+ if (problem !== true) {
286
+ notice = Theme.warn(problem);
287
+ continue;
288
+ }
289
+ return dirty ? { value } : undefined;
290
+ }
291
+ const name = picked.kind === 'pick' || picked.kind === 'action' ? picked.value : undefined;
292
+ if (!name || name === SAVE)
293
+ continue;
294
+ focus = name;
295
+ const field = this.node.child(name);
296
+ if (picked.kind === 'action') {
297
+ delete value[name];
298
+ continue;
299
+ }
300
+ const edited = await this.editor.edit([...this.path, Text.humanize(name)], [name], field, value[name]);
301
+ if (edited)
302
+ value[name] = edited.value;
303
+ }
304
+ }
305
+ }
@@ -0,0 +1,32 @@
1
+ import { App } from '../../tui/index.js';
2
+ import { ConfigFile } from './config-file.js';
3
+ import type { SchemaNode } from './schema-node.js';
4
+ export { CATEGORIES, nameOf } from './categories.js';
5
+ export { ConfigFile, editConfigText, fileValue, type SettingsTarget } from './config-file.js';
6
+ export { SchemaNode } from './schema-node.js';
7
+ interface Setting {
8
+ key: string[];
9
+ node: SchemaNode;
10
+ category: string;
11
+ section?: string;
12
+ }
13
+ export type Scope = 'user' | 'project';
14
+ export declare class SettingsEditor {
15
+ private readonly app;
16
+ private readonly files;
17
+ private scope;
18
+ private readonly dataPath;
19
+ private readonly editor;
20
+ /** files: your config and this folder's project file; dataPath: where the apps catalogue looks for installed apps. */
21
+ constructor(app: App, files: Record<Scope, ConfigFile>, scope?: Scope, dataPath?: string);
22
+ /** The file the current tab edits. */
23
+ get file(): ConfigFile;
24
+ private get title();
25
+ /** Every editable setting with the category and heading it is shown under. */
26
+ settings(): Setting[];
27
+ private item;
28
+ run(): Promise<void>;
29
+ private edit;
30
+ /** Saves (or resets) one setting and returns the notice to show. */
31
+ private save;
32
+ }
@@ -0,0 +1,136 @@
1
+ // The settings screen: two scopes on tabs, your own settings ("All projects", blue) and this folder's
2
+ // .sandbox-config.json ("This project", purple); Tab switches and keeps your place. Every setting is grouped into
3
+ // categories (sidebar), searchable, with its value in both scopes, its default and description. Enter edits (on/off
4
+ // flips at once), r resets. Edits keep the file's comments and formatting, and nothing is saved unless the whole file
5
+ // stays valid.
6
+ import _ from 'lodash';
7
+ import { NEVER_LOOSER } from '../../../core/local-config.js';
8
+ import { Text, Theme } from '../../tui/index.js';
9
+ import { AppsCatalogue } from '../apps.js';
10
+ import { CATEGORIES, categoryOf, nameOf } from './categories.js';
11
+ import { formatValue, ValueEditor } from './editors.js';
12
+ export { CATEGORIES, nameOf } from './categories.js';
13
+ export { ConfigFile, editConfigText, fileValue } from './config-file.js';
14
+ export { SchemaNode } from './schema-node.js';
15
+ const SCOPES = [
16
+ { scope: 'user', tab: { label: 'All projects', badge: 'YOURS', paint: Theme.scopeGlobal } },
17
+ { scope: 'project', tab: { label: 'This project', badge: 'PROJECT', paint: Theme.scopeProject } },
18
+ ];
19
+ export class SettingsEditor {
20
+ app;
21
+ files;
22
+ scope;
23
+ dataPath;
24
+ editor;
25
+ /** files: your config and this folder's project file; dataPath: where the apps catalogue looks for installed apps. */
26
+ constructor(app, files, scope = 'user', dataPath = '') {
27
+ this.app = app;
28
+ this.files = files;
29
+ this.scope = scope;
30
+ this.dataPath = dataPath;
31
+ this.editor = new ValueEditor(app, { suggestions: (key) => (['defaultHarness', 'harness'].includes(key.join('.')) ? Object.keys(this.file.userConfig().harnesses) : undefined) });
32
+ }
33
+ /** The file the current tab edits. */
34
+ get file() { return this.files[this.scope]; }
35
+ get title() { return 'Settings · ' + (this.file.isProject ? 'This project' : 'All projects'); }
36
+ /** Every editable setting with the category and heading it is shown under. */
37
+ settings() {
38
+ return this.file.schema.children().flatMap(([top, node]) => {
39
+ const category = categoryOf(top);
40
+ if (!category)
41
+ return [];
42
+ const several = category.keys.filter((k) => this.file.schema.child(k)).length > 1;
43
+ if (!node.isSection)
44
+ return [{ key: [top], node, category: category.id, section: several && category.id !== 'general' ? nameOf(top) : undefined }];
45
+ return node.leaves([top]).map(({ key, node: leaf }) => {
46
+ const heading = [several ? nameOf(top) : undefined, key.length > 2 ? key.slice(1, -1).map(nameOf).join(' › ') : undefined].filter(Boolean).join(' › ');
47
+ return { key, node: leaf, category: category.id, section: heading || undefined };
48
+ });
49
+ });
50
+ }
51
+ item(s, config) {
52
+ const inFile = this.file.valueInFile(s.key);
53
+ const set = inFile !== undefined;
54
+ const yours = _.get(config, s.key);
55
+ const id = s.key.join('.');
56
+ const project = this.files.project;
57
+ const inProject = project.schema.leaves().some((l) => l.key.join('.') === id) ? project.valueInFile(s.key) : undefined;
58
+ const paint = this.file.isProject ? Theme.project : Theme.changed;
59
+ const value = this.file.isProject && !set ? Theme.muted('all projects: ') + formatValue(yours, 40) : formatValue(this.file.effective(s.key, config));
60
+ const details = [
61
+ Theme.muted('Setting ') + id,
62
+ Theme.muted('All projects ') + formatValue(yours, 40) + Theme.muted(this.files.user.valueInFile(s.key) === undefined ? ' (built-in default)' : ' (your file)'),
63
+ Theme.muted('This project ') + (inProject === undefined ? Theme.muted('not set') : Theme.project(Text.strip(formatValue(inProject, 40)))),
64
+ ...(!this.file.isProject ? [Theme.muted('Default ') + formatValue(this.file.defaultOf(s.key), 40)] : []),
65
+ ...(this.file.isProject && NEVER_LOOSER.includes(id) ? [Theme.warn(Theme.glyph.lock + ' A project can only make this stricter than your own setting.')] : []),
66
+ ];
67
+ return {
68
+ value: id,
69
+ label: Text.humanize(s.key[s.key.length - 1]),
70
+ hint: set ? paint(Text.strip(value)) + ' ' + paint(Theme.glyph.changed) : value,
71
+ description: s.node.description,
72
+ details,
73
+ section: s.section,
74
+ category: s.category,
75
+ keywords: id,
76
+ };
77
+ }
78
+ async run() {
79
+ let focus;
80
+ let notice;
81
+ for (;;) {
82
+ const config = this.file.userConfig();
83
+ const settings = this.settings();
84
+ const categories = CATEGORIES.filter((c) => settings.some((s) => s.category === c.id)).map((c) => ({ id: c.id, label: c.label, description: c.description }));
85
+ const picked = await this.app.list({
86
+ path: [this.title],
87
+ tabs: { items: SCOPES.map((x) => x.tab), active: SCOPES.findIndex((x) => x.scope === this.scope) },
88
+ status: (this.file.isProject ? Theme.project('●') : Theme.accent('●')) + Theme.muted(' Editing ') + Text.middle(this.file.file, 80) + (this.file.exists ? '' : Theme.muted(' (created when you save)')) + (this.file.isProject ? Theme.muted(' · only what a project may set; needs your approval at the next launch') : ''),
89
+ items: settings.map((s) => this.item(s, config)),
90
+ categories,
91
+ startAt: focus,
92
+ actions: [{ key: 'r', label: this.file.isProject ? 'remove from project' : 'reset to default' }],
93
+ notice,
94
+ });
95
+ notice = undefined;
96
+ if (picked.kind === 'back')
97
+ return;
98
+ // The other tab, on the same setting when that scope has it.
99
+ if (picked.kind === 'tab') {
100
+ this.scope = SCOPES[picked.index].scope;
101
+ continue;
102
+ }
103
+ if (picked.kind !== 'pick' && picked.kind !== 'action')
104
+ continue;
105
+ if (picked.value === undefined)
106
+ continue;
107
+ focus = picked.value;
108
+ const setting = settings.find((s) => s.key.join('.') === picked.value);
109
+ if (picked.kind === 'action')
110
+ notice = this.save(setting.key, undefined, 'reset');
111
+ else
112
+ notice = await this.edit(setting, config);
113
+ }
114
+ }
115
+ async edit(s, config) {
116
+ // The apps list is picked from the catalogue (by category, with sizes), not typed.
117
+ if (s.key.join('.') === 'apps.install')
118
+ return new AppsCatalogue(this.app, this.file, this.dataPath).run();
119
+ const current = this.file.isProject ? this.file.valueInFile(s.key) ?? _.get(config, s.key) : _.get(config, s.key);
120
+ const path = [this.title, ...s.key.slice(0, -1).map(nameOf), Text.humanize(s.key[s.key.length - 1])];
121
+ const edited = await this.editor.edit(path, s.key, s.node, current);
122
+ return edited ? this.save(s.key, edited.value, 'save') : undefined;
123
+ }
124
+ /** Saves (or resets) one setting and returns the notice to show. */
125
+ save(key, value, how) {
126
+ const name = key.join('.');
127
+ const result = this.file.set(key, value);
128
+ if (result === 'unchanged')
129
+ return Theme.muted(how === 'reset' ? name + ' is already the default.' : 'Unchanged.');
130
+ if (typeof result === 'object')
131
+ return Theme.error(Theme.glyph.cross + ' Not saved: ' + result.problems.join('; '));
132
+ // The approval is tied to the file's exact contents, so any change makes the project file need approval again.
133
+ const after = this.file.isProject ? ' The next launch asks you to approve the project file again.' : ' New sandboxes use it.';
134
+ return Theme.ok(Theme.glyph.ok + ' ' + (how === 'reset' ? 'Reset ' + name + '.' : 'Saved ' + name + '.')) + Theme.muted(after);
135
+ }
136
+ }
@@ -0,0 +1,62 @@
1
+ import type { z } from 'zod';
2
+ type Def = {
3
+ type: string;
4
+ options?: ZodLike[];
5
+ values?: unknown[];
6
+ entries?: Record<string, string>;
7
+ element?: ZodLike;
8
+ innerType?: ZodLike;
9
+ keyType?: ZodLike;
10
+ valueType?: ZodLike;
11
+ defaultValue?: unknown;
12
+ in?: ZodLike;
13
+ out?: ZodLike;
14
+ };
15
+ type ZodLike = z.ZodType & {
16
+ _zod: {
17
+ def: Def;
18
+ };
19
+ shape?: Record<string, ZodLike>;
20
+ description?: string;
21
+ minValue?: number | null;
22
+ maxValue?: number | null;
23
+ };
24
+ /** The editor a setting gets. */
25
+ export type EditorKind = 'boolean' | 'choice' | 'union' | 'number' | 'string' | 'string-list' | 'choice-list' | 'object-list' | 'string-map' | 'object-map' | 'object' | 'json';
26
+ export declare class SchemaNode {
27
+ readonly schema: ZodLike;
28
+ constructor(schema: unknown);
29
+ /** The real type under optional() / default() / nullable() / transforms. */
30
+ get inner(): ZodLike;
31
+ get type(): string;
32
+ /** A group of settings (an object with known fields), opened rather than edited. */
33
+ get isSection(): boolean;
34
+ /** The description, which .partial() / .optional() wrappers may carry on the inner schema instead. */
35
+ get description(): string | undefined;
36
+ get min(): number | undefined;
37
+ get max(): number | undefined;
38
+ /** The fields of an object, in schema order. */
39
+ children(): [string, SchemaNode][];
40
+ child(name: string): SchemaNode | undefined;
41
+ /** Every setting below this one that has an editor of its own (not a section), with its path. */
42
+ leaves(key?: string[]): {
43
+ key: string[];
44
+ node: SchemaNode;
45
+ }[];
46
+ /** Array items, or a map's values. */
47
+ get element(): SchemaNode | undefined;
48
+ /** Union members. */
49
+ get options(): SchemaNode[];
50
+ /** The values an enum or literal allows. */
51
+ get choices(): unknown[];
52
+ get editor(): EditorKind;
53
+ /** Every value a choice allows (booleans of a union included). */
54
+ get choiceValues(): unknown[];
55
+ /** The member of a union that is not a fixed value (a number, a string ...), edited after "Custom...". */
56
+ get openOption(): SchemaNode | undefined;
57
+ /** What a new entry (in a list or a map) starts as: the default if there is one, otherwise the simplest valid shape. */
58
+ initial(): unknown;
59
+ /** Is `value` acceptable here on its own? (The whole file is checked again when it is saved.) */
60
+ check(value: unknown): true | string;
61
+ }
62
+ export {};
@@ -0,0 +1,104 @@
1
+ /** Settings never shown (the link to the JSON Schema). */
2
+ const HIDDEN = new Set(['$schema']);
3
+ export class SchemaNode {
4
+ schema;
5
+ constructor(schema) { this.schema = schema; }
6
+ /** The real type under optional() / default() / nullable() / transforms. */
7
+ get inner() {
8
+ let s = this.schema;
9
+ for (;;) {
10
+ const def = s._zod.def;
11
+ if (['optional', 'default', 'nullable', 'prefault', 'readonly'].includes(def.type) && def.innerType)
12
+ s = def.innerType;
13
+ else if (def.type === 'pipe' && def.in)
14
+ s = def.in;
15
+ else
16
+ return s;
17
+ }
18
+ }
19
+ get type() { return this.inner._zod.def.type; }
20
+ /** A group of settings (an object with known fields), opened rather than edited. */
21
+ get isSection() { return this.type === 'object' && Boolean(this.inner.shape); }
22
+ /** The description, which .partial() / .optional() wrappers may carry on the inner schema instead. */
23
+ get description() { return this.schema.description ?? this.inner.description; }
24
+ get min() { return this.inner.minValue ?? undefined; }
25
+ get max() { return this.inner.maxValue ?? undefined; }
26
+ /** The fields of an object, in schema order. */
27
+ children() {
28
+ return Object.entries(this.inner.shape ?? {}).filter(([name]) => !HIDDEN.has(name)).map(([name, s]) => [name, new SchemaNode(s)]);
29
+ }
30
+ child(name) {
31
+ const s = this.inner.shape?.[name];
32
+ return s ? new SchemaNode(s) : undefined;
33
+ }
34
+ /** Every setting below this one that has an editor of its own (not a section), with its path. */
35
+ leaves(key = []) {
36
+ return this.children().flatMap(([name, child]) => (child.isSection ? child.leaves([...key, name]) : [{ key: [...key, name], node: child }]));
37
+ }
38
+ /** Array items, or a map's values. */
39
+ get element() {
40
+ const def = this.inner._zod.def;
41
+ return def.type === 'array' && def.element ? new SchemaNode(def.element) : def.type === 'record' && def.valueType ? new SchemaNode(def.valueType) : undefined;
42
+ }
43
+ /** Union members. */
44
+ get options() { return (this.inner._zod.def.options ?? []).map((o) => new SchemaNode(o)); }
45
+ /** The values an enum or literal allows. */
46
+ get choices() {
47
+ const def = this.inner._zod.def;
48
+ if (def.type === 'enum')
49
+ return Object.values(def.entries ?? {});
50
+ if (def.type === 'literal')
51
+ return def.values ?? [];
52
+ return [];
53
+ }
54
+ get editor() {
55
+ const type = this.type;
56
+ if (type === 'boolean')
57
+ return 'boolean';
58
+ if (type === 'enum' || type === 'literal')
59
+ return 'choice';
60
+ if (type === 'union')
61
+ return this.options.every((o) => ['literal', 'enum', 'boolean'].includes(o.type)) ? 'choice' : 'union';
62
+ if (type === 'number' || type === 'string')
63
+ return type;
64
+ const element = this.element;
65
+ if (type === 'array' && element)
66
+ return element.type === 'string' ? 'string-list' : element.type === 'enum' ? 'choice-list' : element.type === 'object' ? 'object-list' : 'json';
67
+ if (type === 'record' && element)
68
+ return ['string', 'number', 'boolean'].includes(element.type) ? 'string-map' : element.type === 'object' ? 'object-map' : 'json';
69
+ if (type === 'object')
70
+ return 'object';
71
+ return 'json';
72
+ }
73
+ /** Every value a choice allows (booleans of a union included). */
74
+ get choiceValues() {
75
+ if (this.type !== 'union')
76
+ return this.choices;
77
+ return this.options.flatMap((o) => (o.type === 'boolean' ? [true, false] : o.choices));
78
+ }
79
+ /** The member of a union that is not a fixed value (a number, a string ...), edited after "Custom...". */
80
+ get openOption() { return this.options.find((o) => !['literal', 'enum', 'boolean'].includes(o.type)); }
81
+ /** What a new entry (in a list or a map) starts as: the default if there is one, otherwise the simplest valid shape. */
82
+ initial() {
83
+ const def = this.schema._zod.def;
84
+ if (def.type === 'default')
85
+ return structuredClone(def.defaultValue);
86
+ switch (this.type) {
87
+ case 'boolean': return false;
88
+ case 'number': return this.min ?? 0;
89
+ case 'string': return '';
90
+ case 'enum':
91
+ case 'literal': return this.choices[0];
92
+ case 'union': return this.options[0]?.initial();
93
+ case 'array': return [];
94
+ case 'record': return {};
95
+ case 'object': return Object.fromEntries(this.children().filter(([, c]) => !['optional', 'default'].includes(c.schema._zod.def.type)).map(([name, c]) => [name, c.initial()]));
96
+ default: return null;
97
+ }
98
+ }
99
+ /** Is `value` acceptable here on its own? (The whole file is checked again when it is saved.) */
100
+ check(value) {
101
+ const result = this.schema.safeParse(value);
102
+ return result.success ? true : result.error.issues.map((i) => (i.path.length ? i.path.join('.') + ': ' : '') + i.message).join('; ');
103
+ }
104
+ }
@@ -1,4 +1,5 @@
1
- import type { Context } from './commands/context.js';
1
+ import type { Context } from '../commands/context.js';
2
+ import { App } from '../tui/index.js';
2
3
  /** Folders worth offering: this one, the projects started before, and the folders inside your roots. */
3
4
  export declare const candidateFolders: (ctx: Context, cwd?: string) => string[];
4
5
  /** The command line for several sessions: one --session per (agent, folder), with the shared options. */
@@ -9,4 +10,11 @@ export declare const severalAgentsArgv: (sessions: {
9
10
  headless: boolean;
10
11
  attachFirst: boolean;
11
12
  }) => string[];
12
- export declare const askSeveralAgents: (ctx: Context) => Promise<string[] | null>;
13
+ export declare class SeveralAgents {
14
+ private readonly app;
15
+ private readonly ctx;
16
+ private readonly path;
17
+ constructor(app: App, ctx: Context);
18
+ run(): Promise<string[] | null>;
19
+ private folders;
20
+ }