loadtoagent 1.0.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 (82) hide show
  1. package/README.ko.md +175 -0
  2. package/README.md +175 -0
  3. package/README.zh-CN.md +175 -0
  4. package/bin/loadtoagent.js +171 -0
  5. package/docs/ARCHITECTURE.md +30 -0
  6. package/docs/PROVIDER-CONTRACTS.md +58 -0
  7. package/docs/assets/loadtoagent-dashboard.png +0 -0
  8. package/docs/assets/loadtoagent-demo.gif +0 -0
  9. package/main.js +493 -0
  10. package/package.json +133 -0
  11. package/preload.js +75 -0
  12. package/renderer/app-agent-actions.js +285 -0
  13. package/renderer/app-bootstrap.js +95 -0
  14. package/renderer/app-dashboard.js +361 -0
  15. package/renderer/app-drawer-content.js +203 -0
  16. package/renderer/app-drawer-data.js +41 -0
  17. package/renderer/app-drawer.js +183 -0
  18. package/renderer/app-events-dialogs.js +169 -0
  19. package/renderer/app-events-filters.js +74 -0
  20. package/renderer/app-events-navigation.js +96 -0
  21. package/renderer/app-events-sessions.js +195 -0
  22. package/renderer/app-events.js +16 -0
  23. package/renderer/app-graph-layout.js +71 -0
  24. package/renderer/app-graph-model.js +107 -0
  25. package/renderer/app-graph-orchestration.js +76 -0
  26. package/renderer/app-graph-view.js +544 -0
  27. package/renderer/app-run-modal.js +221 -0
  28. package/renderer/app-session-render.js +243 -0
  29. package/renderer/app-tmux-render.js +247 -0
  30. package/renderer/app.js +608 -0
  31. package/renderer/fonts/geist-ext.woff2 +0 -0
  32. package/renderer/fonts/geist.woff2 +0 -0
  33. package/renderer/i18n-messages.js +355 -0
  34. package/renderer/i18n.js +122 -0
  35. package/renderer/index.html +386 -0
  36. package/renderer/shared.js +26 -0
  37. package/renderer/styles-agent-map.css +401 -0
  38. package/renderer/styles-cards.css +600 -0
  39. package/renderer/styles-collaboration.css +534 -0
  40. package/renderer/styles-components.css +1293 -0
  41. package/renderer/styles-onboarding.css +344 -0
  42. package/renderer/styles-overlays.css +284 -0
  43. package/renderer/styles-product.css +686 -0
  44. package/renderer/styles-responsive-product.css +689 -0
  45. package/renderer/styles-responsive-runtime.css +718 -0
  46. package/renderer/styles-responsive-shell.css +533 -0
  47. package/renderer/styles-responsive-workflows.css +778 -0
  48. package/renderer/styles-run-composer.css +695 -0
  49. package/renderer/styles-settings.css +606 -0
  50. package/renderer/styles-terminal.css +1241 -0
  51. package/renderer/styles-tmux.css +1162 -0
  52. package/renderer/styles-workflow-map.css +513 -0
  53. package/renderer/styles-workflows.css +1217 -0
  54. package/renderer/styles.css +486 -0
  55. package/renderer/terminal-agent.js +152 -0
  56. package/renderer/terminal-events.js +226 -0
  57. package/renderer/terminal-workbench.js +464 -0
  58. package/renderer/terminal.js +497 -0
  59. package/src/agentMonitor/claudeParser.js +197 -0
  60. package/src/agentMonitor/codexCollaboration.js +95 -0
  61. package/src/agentMonitor/codexParser.js +447 -0
  62. package/src/agentMonitor/genericParser.js +244 -0
  63. package/src/agentMonitor/hierarchy.js +248 -0
  64. package/src/agentMonitor/sessionFiles.js +86 -0
  65. package/src/agentMonitor.js +591 -0
  66. package/src/agentRunner.js +465 -0
  67. package/src/bridgeServer.js +246 -0
  68. package/src/contracts.js +71 -0
  69. package/src/diagnostics.js +23 -0
  70. package/src/ipc/registerAgentIpc.js +12 -0
  71. package/src/ipc/registerAppIpc.js +20 -0
  72. package/src/ipc/registerTerminalIpc.js +50 -0
  73. package/src/ipc/registerTmuxIpc.js +30 -0
  74. package/src/ipc/registerWorkspaceIpc.js +14 -0
  75. package/src/monitorWorker.js +273 -0
  76. package/src/processMonitor.js +394 -0
  77. package/src/providerRegistry.js +120 -0
  78. package/src/terminalManager.js +438 -0
  79. package/src/tmuxController.js +183 -0
  80. package/src/tmuxMonitor.js +432 -0
  81. package/src/updateManager.js +339 -0
  82. package/src/workspaceStore.js +77 -0
@@ -0,0 +1,339 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { EventEmitter } = require('events');
7
+ const { reportRecoverableError } = require('./diagnostics');
8
+
9
+ const RELEASE_API = 'https://api.github.com/repos/minjund/LodeToAgent/releases/latest';
10
+ const RELEASE_PAGE = 'https://github.com/minjund/LodeToAgent/releases/latest';
11
+
12
+ function normalizeVersion(value) {
13
+ const normalized = String(value || '')
14
+ .trim()
15
+ .replace(/^refs\/tags\//i, '')
16
+ .replace(/^v/i, '');
17
+ const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
18
+ if (!match) return null;
19
+ return {
20
+ raw: normalized,
21
+ core: match.slice(1, 4),
22
+ prerelease: match[4] ? match[4].split('.') : [],
23
+ };
24
+ }
25
+
26
+ function compareVersions(left, right) {
27
+ const a = normalizeVersion(left);
28
+ const b = normalizeVersion(right);
29
+ if (!a || !b) throw new Error('비교할 버전 형식이 올바르지 않습니다.');
30
+ for (let index = 0; index < 3; index += 1) {
31
+ const leftPart = BigInt(a.core[index]);
32
+ const rightPart = BigInt(b.core[index]);
33
+ if (leftPart !== rightPart) return leftPart > rightPart ? 1 : -1;
34
+ }
35
+ if (!a.prerelease.length && !b.prerelease.length) return 0;
36
+ if (!a.prerelease.length) return 1;
37
+ if (!b.prerelease.length) return -1;
38
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
39
+ for (let index = 0; index < length; index += 1) {
40
+ const aPart = a.prerelease[index];
41
+ const bPart = b.prerelease[index];
42
+ if (aPart == null) return -1;
43
+ if (bPart == null) return 1;
44
+ if (aPart === bPart) continue;
45
+ const aNumeric = /^\d+$/.test(aPart);
46
+ const bNumeric = /^\d+$/.test(bPart);
47
+ if (aNumeric && bNumeric) return BigInt(aPart) > BigInt(bPart) ? 1 : -1;
48
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
49
+ return aPart > bPart ? 1 : -1;
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ function trustedDownloadUrl(value) {
55
+ try {
56
+ const url = new URL(String(value || ''));
57
+ return url.protocol === 'https:' && url.hostname === 'github.com' && url.pathname.startsWith('/minjund/LodeToAgent/releases/download/');
58
+ } catch (_invalidDownloadUrl) {
59
+ // Malformed external input is an expected validation miss, not an operational failure.
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function assetScore(asset, options) {
65
+ const name = String(asset && asset.name || '');
66
+ const lower = name.toLowerCase();
67
+ const version = String(options.version || '').toLowerCase();
68
+ if (!name || asset.state && asset.state !== 'uploaded' || !trustedDownloadUrl(asset.browser_download_url)) return -1;
69
+ const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
70
+ const hasExactVersion = new RegExp(`(?:^|[-_.])${escapedVersion}(?:[-_.]|$)`).test(lower);
71
+ if (!version || !hasExactVersion) return -1;
72
+ const hasArm64 = /(?:^|[-_.])arm64(?:[-_.]|$)/.test(lower);
73
+ const hasX64 = /(?:^|[-_.])(?:x64|amd64)(?:[-_.]|$)/.test(lower);
74
+ const hasIa32 = /(?:^|[-_.])(?:ia32|x86)(?:[-_.]|$)/.test(lower);
75
+ let score = 12;
76
+ if (options.platform === 'win32') {
77
+ if (!lower.endsWith('.exe')) return -1;
78
+ if (options.arch === 'arm64' && !hasArm64) return -1;
79
+ if (options.arch === 'x64' && (hasArm64 || hasIa32)) return -1;
80
+ if (options.arch === 'ia32' && !hasIa32) return -1;
81
+ if (lower.includes('setup')) score += 100;
82
+ else if (lower.includes('portable')) score += 70;
83
+ else score += 30;
84
+ if (options.arch === 'arm64' && hasArm64) score += 25;
85
+ if (options.arch === 'x64' && hasX64) score += 25;
86
+ if (options.arch === 'ia32' && hasIa32) score += 25;
87
+ return score;
88
+ }
89
+ if (options.platform === 'darwin') {
90
+ if (!lower.endsWith('.dmg')) return -1;
91
+ if (options.arch === 'arm64' && !hasArm64) return -1;
92
+ if (options.arch === 'x64' && !hasX64) return -1;
93
+ score += 90;
94
+ if (options.arch === 'arm64') score += 30;
95
+ if (options.arch === 'x64') score += 30;
96
+ return score;
97
+ }
98
+ return -1;
99
+ }
100
+
101
+ function selectReleaseAsset(assets, options) {
102
+ return (Array.isArray(assets) ? assets : [])
103
+ .map(asset => ({ asset, score: assetScore(asset, options || {}) }))
104
+ .filter(item => item.score >= 0)
105
+ .sort((a, b) => b.score - a.score)[0]?.asset || null;
106
+ }
107
+
108
+ function publicAsset(asset) {
109
+ if (!asset) return null;
110
+ return {
111
+ name: String(asset.name || ''),
112
+ size: Number.isSafeInteger(Number(asset.size)) && Number(asset.size) >= 0 ? Number(asset.size) : 0,
113
+ url: String(asset.browser_download_url || ''),
114
+ digest: /^sha256:[0-9a-f]{64}$/i.test(String(asset.digest || '')) ? String(asset.digest).toLowerCase() : '',
115
+ };
116
+ }
117
+
118
+ function safeFileName(value) {
119
+ const fileName = path.basename(String(value || '')).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 180);
120
+ return !fileName || fileName === '.' || fileName === '..' ? '' : fileName;
121
+ }
122
+
123
+ class UpdateManager extends EventEmitter {
124
+ constructor(options = {}) {
125
+ super();
126
+ this.currentVersion = String(options.currentVersion || '0.0.0');
127
+ this.platform = String(options.platform || process.platform);
128
+ this.arch = String(options.arch || process.arch);
129
+ this.installType = String(options.installType || 'desktop');
130
+ this.fetch = options.fetch;
131
+ this.shell = options.shell;
132
+ this.downloadsDir = String(options.downloadsDir || '');
133
+ this.apiUrl = String(options.apiUrl || RELEASE_API);
134
+ this.checkPromise = null;
135
+ this.downloadPromise = null;
136
+ this.state = {
137
+ status: this.platform === 'darwin' || this.platform === 'win32' ? 'idle' : 'unsupported',
138
+ currentVersion: this.currentVersion,
139
+ latestVersion: '',
140
+ tag: '',
141
+ releaseUrl: RELEASE_PAGE,
142
+ publishedAt: '',
143
+ notes: '',
144
+ asset: null,
145
+ progress: 0,
146
+ downloadedBytes: 0,
147
+ totalBytes: 0,
148
+ downloadedPath: '',
149
+ checkedAt: '',
150
+ error: '',
151
+ platform: this.platform,
152
+ arch: this.arch,
153
+ installType: this.installType,
154
+ };
155
+ }
156
+
157
+ getState() {
158
+ return { ...this.state, asset: this.state.asset ? { ...this.state.asset } : null };
159
+ }
160
+
161
+ setState(patch) {
162
+ this.state = { ...this.state, ...patch };
163
+ const snapshot = this.getState();
164
+ this.emit('state', snapshot);
165
+ return snapshot;
166
+ }
167
+
168
+ async check() {
169
+ if (this.checkPromise) return this.checkPromise;
170
+ if (this.state.status === 'unsupported') return this.getState();
171
+ this.checkPromise = this.performCheck().finally(() => { this.checkPromise = null; });
172
+ return this.checkPromise;
173
+ }
174
+
175
+ async performCheck() {
176
+ this.setState({ status: 'checking', error: '', checkedAt: new Date().toISOString() });
177
+ try {
178
+ if (typeof this.fetch !== 'function') throw new Error('업데이트 서버에 연결할 수 없습니다.');
179
+ const response = await this.fetch(this.apiUrl, {
180
+ headers: {
181
+ Accept: 'application/vnd.github+json',
182
+ 'X-GitHub-Api-Version': '2022-11-28',
183
+ 'User-Agent': `LoadToAgent/${this.currentVersion}`,
184
+ },
185
+ });
186
+ if (!response || !response.ok) throw new Error(`GitHub에서 최신 버전을 확인하지 못했습니다${response && response.status ? ` (${response.status})` : ''}.`);
187
+ const release = await response.json();
188
+ const latest = normalizeVersion(release && release.tag_name);
189
+ if (!latest || release.draft || release.prerelease) throw new Error('최신 정식 릴리스의 버전 정보가 올바르지 않습니다.');
190
+ const releaseUrl = trustedReleasePage(release.html_url) ? release.html_url : RELEASE_PAGE;
191
+ const asset = selectReleaseAsset(release.assets, { platform: this.platform, arch: this.arch, version: latest.raw });
192
+ const available = compareVersions(latest.raw, this.currentVersion) > 0;
193
+ const exposedAsset = available ? publicAsset(asset) : null;
194
+ return this.setState({
195
+ status: available ? 'available' : 'current',
196
+ latestVersion: latest.raw,
197
+ tag: String(release.tag_name || `v${latest.raw}`),
198
+ releaseUrl,
199
+ publishedAt: String(release.published_at || ''),
200
+ notes: String(release.body || '').slice(0, 12_000),
201
+ asset: exposedAsset,
202
+ progress: 0,
203
+ downloadedBytes: 0,
204
+ totalBytes: exposedAsset ? exposedAsset.size : 0,
205
+ downloadedPath: '',
206
+ checkedAt: new Date().toISOString(),
207
+ error: available && !asset ? '이 운영체제에 맞는 설치 파일이 아직 릴리스에 올라오지 않았습니다.' : '',
208
+ });
209
+ } catch (error) {
210
+ return this.setState({ status: 'error', error: error && error.message || '업데이트 확인 중 문제가 발생했습니다.', checkedAt: new Date().toISOString() });
211
+ }
212
+ }
213
+
214
+ async download() {
215
+ if (this.downloadPromise) return this.downloadPromise;
216
+ if (this.state.status === 'downloaded' && this.state.downloadedPath && fs.existsSync(this.state.downloadedPath)) return this.getState();
217
+ if (!this.state.asset || !trustedDownloadUrl(this.state.asset.url)) throw new Error('다운로드할 설치 파일이 없습니다.');
218
+ this.downloadPromise = this.performDownload().finally(() => { this.downloadPromise = null; });
219
+ return this.downloadPromise;
220
+ }
221
+
222
+ async performDownload() {
223
+ const asset = { ...this.state.asset };
224
+ const fileName = safeFileName(asset.name);
225
+ if (!fileName || !this.downloadsDir) throw new Error('업데이트 파일을 저장할 위치를 준비하지 못했습니다.');
226
+ const finalPath = path.join(this.downloadsDir, fileName);
227
+ const temporaryPath = `${finalPath}.download`;
228
+ let handle = null;
229
+ try {
230
+ await fs.promises.mkdir(this.downloadsDir, { recursive: true });
231
+ await fs.promises.rm(temporaryPath, { force: true });
232
+ const response = await this.fetch(asset.url, { headers: { 'User-Agent': `LoadToAgent/${this.currentVersion}` } });
233
+ if (!response || !response.ok) throw new Error(`업데이트 파일을 내려받지 못했습니다${response && response.status ? ` (${response.status})` : ''}.`);
234
+ const rawHeaderSize = Number(response.headers && response.headers.get && response.headers.get('content-length') || 0);
235
+ const headerSize = Number.isSafeInteger(rawHeaderSize) && rawHeaderSize > 0 ? rawHeaderSize : 0;
236
+ const totalBytes = Number(asset.size) > 0 ? Number(asset.size) : headerSize;
237
+ const hash = crypto.createHash('sha256');
238
+ let downloadedBytes = 0;
239
+ let lastProgressAt = 0;
240
+ handle = await fs.promises.open(temporaryPath, 'w');
241
+ const writeChunk = async value => {
242
+ const chunk = Buffer.from(value);
243
+ let offset = 0;
244
+ while (offset < chunk.length) {
245
+ const result = await handle.write(chunk, offset, chunk.length - offset);
246
+ if (!result.bytesWritten) throw new Error('업데이트 파일을 디스크에 저장하지 못했습니다.');
247
+ offset += result.bytesWritten;
248
+ }
249
+ hash.update(chunk);
250
+ downloadedBytes += chunk.length;
251
+ const now = Date.now();
252
+ if (now - lastProgressAt > 100 || totalBytes && downloadedBytes >= totalBytes) {
253
+ lastProgressAt = now;
254
+ this.setState({
255
+ status: 'downloading',
256
+ downloadedBytes,
257
+ totalBytes,
258
+ progress: totalBytes ? Math.min(100, Math.round(downloadedBytes / totalBytes * 100)) : 0,
259
+ error: '',
260
+ });
261
+ }
262
+ };
263
+ this.setState({ status: 'downloading', progress: 0, downloadedBytes: 0, totalBytes, error: '' });
264
+ if (response.body && typeof response.body.getReader === 'function') {
265
+ const reader = response.body.getReader();
266
+ while (true) {
267
+ const result = await reader.read();
268
+ if (result.done) break;
269
+ await writeChunk(result.value);
270
+ }
271
+ } else {
272
+ await writeChunk(await response.arrayBuffer());
273
+ }
274
+ await handle.close();
275
+ handle = null;
276
+ if (asset.size && downloadedBytes !== Number(asset.size)) throw new Error('다운로드한 파일 크기가 GitHub 릴리스 정보와 다릅니다.');
277
+ const digest = `sha256:${hash.digest('hex')}`;
278
+ if (asset.digest && digest !== asset.digest) throw new Error('다운로드한 파일의 SHA-256 검증에 실패했습니다.');
279
+ await fs.promises.rm(finalPath, { force: true });
280
+ await fs.promises.rename(temporaryPath, finalPath);
281
+ return this.setState({
282
+ status: 'downloaded',
283
+ progress: 100,
284
+ downloadedBytes,
285
+ totalBytes: totalBytes || downloadedBytes,
286
+ downloadedPath: finalPath,
287
+ error: '',
288
+ });
289
+ } catch (error) {
290
+ if (handle) {
291
+ await handle.close().catch(cleanupError => {
292
+ reportRecoverableError('update-download-handle-close', cleanupError);
293
+ });
294
+ }
295
+ await fs.promises.rm(temporaryPath, { force: true }).catch(cleanupError => {
296
+ reportRecoverableError('update-download-temporary-remove', cleanupError);
297
+ });
298
+ this.setState({ status: 'available', progress: 0, downloadedBytes: 0, downloadedPath: '', error: error && error.message || '업데이트 파일을 내려받지 못했습니다.' });
299
+ throw error;
300
+ }
301
+ }
302
+
303
+ async openDownloaded() {
304
+ const file = this.state.downloadedPath;
305
+ if (!file || !fs.existsSync(file)) throw new Error('내려받은 설치 파일을 찾지 못했습니다. 다시 다운로드해 주세요.');
306
+ if (!this.shell || typeof this.shell.openPath !== 'function') throw new Error('설치 파일을 열 수 없습니다.');
307
+ const error = await this.shell.openPath(file);
308
+ if (error) throw new Error(error);
309
+ return this.getState();
310
+ }
311
+
312
+ async openReleasePage() {
313
+ const url = trustedReleasePage(this.state.releaseUrl) ? this.state.releaseUrl : RELEASE_PAGE;
314
+ if (!this.shell || typeof this.shell.openExternal !== 'function') throw new Error('릴리스 페이지를 열 수 없습니다.');
315
+ await this.shell.openExternal(url);
316
+ return { ok: true };
317
+ }
318
+ }
319
+
320
+ function trustedReleasePage(value) {
321
+ try {
322
+ const url = new URL(String(value || ''));
323
+ return url.protocol === 'https:' && url.hostname === 'github.com' && url.pathname.startsWith('/minjund/LodeToAgent/releases/');
324
+ } catch (_invalidReleaseUrl) {
325
+ // Malformed external input is an expected validation miss, not an operational failure.
326
+ return false;
327
+ }
328
+ }
329
+
330
+ module.exports = {
331
+ RELEASE_API,
332
+ RELEASE_PAGE,
333
+ UpdateManager,
334
+ compareVersions,
335
+ normalizeVersion,
336
+ selectReleaseAsset,
337
+ trustedDownloadUrl,
338
+ safeFileName,
339
+ };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function isDirectory(fileSystem, target) {
7
+ try {
8
+ return fileSystem.statSync(target).isDirectory();
9
+ } catch (_missingOrUnreadableWorkspace) {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ function workspaceKey(target, platform = process.platform) {
15
+ return platform === 'win32' ? target.toLowerCase() : target;
16
+ }
17
+
18
+ function normalizeWorkspaces(items, options = {}) {
19
+ const fileSystem = options.fileSystem || fs;
20
+ const platform = options.platform || process.platform;
21
+ const unique = [];
22
+ const seen = new Set();
23
+
24
+ for (const item of Array.isArray(items) ? items : []) {
25
+ const suppliedPath = typeof item?.path === 'string' ? item.path.trim() : '';
26
+ if (!suppliedPath) continue;
27
+
28
+ const target = path.resolve(suppliedPath);
29
+ const key = workspaceKey(target, platform);
30
+ if (seen.has(key) || !isDirectory(fileSystem, target)) continue;
31
+
32
+ seen.add(key);
33
+ unique.push({
34
+ path: target,
35
+ name: String(item.name || '').trim() || path.basename(target),
36
+ });
37
+ }
38
+
39
+ return unique;
40
+ }
41
+
42
+ function readWorkspaces(file, options = {}) {
43
+ const fileSystem = options.fileSystem || fs;
44
+ try {
45
+ const parsed = JSON.parse(fileSystem.readFileSync(file, 'utf8'));
46
+ return normalizeWorkspaces(parsed, options);
47
+ } catch (_missingOrInvalidConfig) {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ function writeWorkspaces(file, items, options = {}) {
53
+ const fileSystem = options.fileSystem || fs;
54
+ const normalized = normalizeWorkspaces(items, options);
55
+ fileSystem.mkdirSync(path.dirname(file), { recursive: true });
56
+ const temporary = `${file}.${process.pid}.tmp`;
57
+ fileSystem.writeFileSync(temporary, JSON.stringify(normalized, null, 2), 'utf8');
58
+ fileSystem.renameSync(temporary, file);
59
+ return normalized;
60
+ }
61
+
62
+ function removeWorkspace(items, suppliedPath, options = {}) {
63
+ const value = typeof suppliedPath === 'string' ? suppliedPath.trim() : '';
64
+ if (!value) return normalizeWorkspaces(items, options);
65
+
66
+ const platform = options.platform || process.platform;
67
+ const removalKey = workspaceKey(path.resolve(value), platform);
68
+ return normalizeWorkspaces(items, options).filter(item => workspaceKey(item.path, platform) !== removalKey);
69
+ }
70
+
71
+ module.exports = {
72
+ normalizeWorkspaces,
73
+ readWorkspaces,
74
+ removeWorkspace,
75
+ workspaceKey,
76
+ writeWorkspaces,
77
+ };