@harness-mix/cli 0.1.5 → 0.1.7

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 (92) hide show
  1. package/README.md +86 -56
  2. package/docs/harness-management.md +4 -4
  3. package/docs/multi-agent-collaboration.md +9 -9
  4. package/docs/storage-verification-design.md +49 -0
  5. package/output/native-build/renderer-extension.js +4210 -2273
  6. package/package.json +11 -9
  7. package/scripts/codebuddy-migration-test.cjs +1 -1
  8. package/scripts/collaboration-test.cjs +8 -3
  9. package/scripts/collaboration-ui-smoke.cjs +40 -26
  10. package/scripts/core-runtime-test.cjs +9 -1
  11. package/scripts/integrations-test.cjs +63 -4
  12. package/scripts/integrations-ui-smoke.cjs +77 -16
  13. package/scripts/native-protocol-test.cjs +113 -2
  14. package/scripts/native-sidebar-test.cjs +7 -0
  15. package/scripts/native-skill-roots-test.cjs +90 -0
  16. package/scripts/native-ui-smoke.cjs +23 -4
  17. package/scripts/projector-test.cjs +13 -0
  18. package/scripts/storage-verification-test.cjs +78 -0
  19. package/scripts/switch-harness-test.cjs +1 -1
  20. package/scripts/test-live-mentions.cjs +152 -0
  21. package/src/main/adapters/antigravity.js +20 -17
  22. package/src/main/adapters/claude.js +41 -8
  23. package/src/main/adapters/codebuddy.js +1 -0
  24. package/src/main/adapters/codex.js +20 -2
  25. package/src/main/adapters/cursor.js +5 -1
  26. package/src/main/adapters/dsh.js +8 -1
  27. package/src/main/adapters/grok.js +4 -1
  28. package/src/main/adapters/hermes.js +8 -0
  29. package/src/main/adapters/kiro.js +2 -1
  30. package/src/main/adapters/managed-mcp.js +15 -2
  31. package/src/main/adapters/omp.js +11 -0
  32. package/src/main/adapters/openclaw.js +6 -0
  33. package/src/main/adapters/opencode.js +5 -1
  34. package/src/main/adapters/pi.js +5 -1
  35. package/src/main/adapters/qoder.js +1 -0
  36. package/src/main/adapters/trae.js +4 -0
  37. package/src/main/adapters/zcode.js +3 -0
  38. package/src/main/host/collaboration.js +59 -11
  39. package/src/main/host/core-session.js +7 -1
  40. package/src/main/host/handoff-checkpoints.js +8 -1
  41. package/src/main/host/integrations.js +164 -26
  42. package/src/main/host/runtime.js +270 -116
  43. package/src/main/host/store.js +32 -11
  44. package/src/main/host/thread-storage.js +73 -0
  45. package/src/main/host/thread-store.js +123 -0
  46. package/src/main/host/verification-gates.js +118 -0
  47. package/src/main/native/protocol.js +209 -83
  48. package/src/main/native/thread-list.js +3 -0
  49. package/src/main/protocol-core/projector.js +20 -3
  50. package/src/main/shared-contracts/event.js +2 -1
  51. package/src/main/shared-contracts/item.js +1 -0
  52. package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts +2 -2
  53. package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
  54. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  55. package/src/native-ui/renderer-extension/dist/types/renderer-chatgpt-context.d.ts.map +1 -1
  56. package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts +11 -0
  57. package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts.map +1 -1
  58. package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts +29 -4
  59. package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts.map +1 -1
  60. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +5 -0
  61. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +2 -1
  63. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
  64. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
  65. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
  66. package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts +2 -1
  67. package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts.map +1 -1
  68. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  69. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
  70. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  71. package/src/native-ui/renderer-extension/dist/types/settings/storage-page.d.ts +20 -0
  72. package/src/native-ui/renderer-extension/dist/types/settings/storage-page.d.ts.map +1 -0
  73. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  74. package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +8 -88
  75. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +150 -168
  76. package/src/native-ui/renderer-extension/src/renderer-chatgpt-context.ts +5 -1
  77. package/src/native-ui/renderer-extension/src/renderer-harness-mentions.ts +556 -123
  78. package/src/native-ui/renderer-extension/src/renderer-integrations-client.ts +36 -3
  79. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +4 -0
  80. package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +12 -0
  81. package/src/native-ui/renderer-extension/src/settings/icons.ts +6 -3
  82. package/src/native-ui/renderer-extension/src/settings/integrations-page.ts +1434 -62
  83. package/src/native-ui/renderer-extension/src/settings/localization.ts +8 -4
  84. package/src/native-ui/renderer-extension/src/settings/pages.ts +17 -8
  85. package/src/native-ui/renderer-extension/src/settings/shell.css +103 -8
  86. package/src/native-ui/renderer-extension/src/settings/storage-page.ts +74 -0
  87. package/src/native-ui/renderer-extension/test/renderer-chatgpt-context.test.ts +4 -1
  88. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +2 -0
  89. package/src/native-ui/renderer-extension/test/settings/harness-settings.test.ts +5 -0
  90. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -1
  91. package/src/native-ui/renderer-extension/test/settings/pages.test.ts +50 -4
  92. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +8 -2
@@ -7,6 +7,15 @@ const slug = value => typeof value === 'string' && /^[a-z0-9][a-z0-9_-]{0,63}$/i
7
7
  const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
8
  const within = (root, file) => { const rel = path.relative(root, file); return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); };
9
9
  async function exists(file) { try { await fs.lstat(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
10
+ async function robustRename(from, to) {
11
+ for (let attempt = 0; ; attempt++) {
12
+ try { await fs.rename(from, to); break; }
13
+ catch (error) {
14
+ if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt >= 9) throw error;
15
+ await new Promise(resolve => setTimeout(resolve, 50));
16
+ }
17
+ }
18
+ }
10
19
 
11
20
  // This store contains only user-managed launch declarations. Native account,
12
21
  // authentication and MCP configuration files are never read or rewritten.
@@ -76,14 +85,77 @@ class Integrations {
76
85
  const adapter = this.adapter(input.harnessId), scope = await this.scope(input);
77
86
  if (!adapter.manifest.integrations?.mcp) throw new Error('This Harness has no supported native MCP injection interface');
78
87
  const s = input.server;
79
- if (!s || !slug(s.name) || s.name === 'harness-mix' || typeof s.command !== 'string' || !s.command.trim() || s.command.length > 2048 || /[\r\n\0]/.test(s.command)) throw new Error('Invalid MCP name or executable');
80
- if (!Array.isArray(s.args) || s.args.length > 100 || s.args.some(a => typeof a !== 'string' || a.length > 4096 || /[\r\n\0]/.test(a))) throw new Error('Arguments must be a JSON array of strings');
81
- if (Object.keys(s).some(k => !['name', 'command', 'args', 'enabled'].includes(k))) throw new Error('Only executable and arguments are supported; configure credentials in the native environment');
88
+ if (!s || !slug(s.name) || s.name === 'harness-mix') throw new Error('Invalid MCP name');
89
+ const isHttp = s.transportType === 'streamable_http' || (typeof s.url === 'string' && s.url.trim().length > 0);
90
+ const allowedKeys = [
91
+ 'id', 'name', 'transportType', 'enabled',
92
+ 'command', 'args', 'env', 'env_vars', 'cwd',
93
+ 'url', 'bearer_token_env_var', 'http_headers', 'env_http_headers'
94
+ ];
95
+ if (Object.keys(s).some(k => !allowedKeys.includes(k))) throw new Error('Unsupported MCP configuration properties');
82
96
  if (typeof s.enabled !== 'boolean') throw new Error('enabled must be boolean');
83
- if (/(?:api[_-]?key|token|password|secret|authorization)(?:=|\s)|:\/\/[^/\s]+@/i.test([s.command, ...s.args].join(' '))) throw new Error('Use the native environment for credentials, not MCP arguments');
97
+
98
+ let validated;
99
+ if (isHttp) {
100
+ if (typeof s.url !== 'string' || !s.url.trim() || s.url.length > 2048 || /[\r\n\0]/.test(s.url)) throw new Error('Invalid MCP URL');
101
+ try {
102
+ const parsed = new URL(s.url.trim());
103
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Invalid MCP URL protocol');
104
+ } catch { throw new Error('Invalid MCP URL'); }
105
+ if (s.bearer_token_env_var !== undefined && s.bearer_token_env_var !== null && s.bearer_token_env_var !== '') {
106
+ if (typeof s.bearer_token_env_var !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s.bearer_token_env_var.trim())) {
107
+ throw new Error('Invalid bearer token environment variable name');
108
+ }
109
+ }
110
+ if (s.http_headers !== undefined && s.http_headers !== null) {
111
+ if (typeof s.http_headers !== 'object' || Array.isArray(s.http_headers)) throw new Error('Headers must be an object');
112
+ for (const [k, v] of Object.entries(s.http_headers)) {
113
+ if (typeof k !== 'string' || !k.trim() || typeof v !== 'string' || /[\r\n\0]/.test(k) || /[\r\n\0]/.test(v)) throw new Error('Invalid HTTP headers');
114
+ }
115
+ }
116
+ if (s.env_http_headers !== undefined && s.env_http_headers !== null) {
117
+ if (typeof s.env_http_headers !== 'object' || Array.isArray(s.env_http_headers)) throw new Error('Env HTTP headers must be an object');
118
+ for (const [k, v] of Object.entries(s.env_http_headers)) {
119
+ if (typeof k !== 'string' || !k.trim() || typeof v !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(v.trim())) throw new Error('Invalid env HTTP headers');
120
+ }
121
+ }
122
+ validated = {
123
+ transportType: 'streamable_http',
124
+ url: s.url.trim(),
125
+ ...(s.bearer_token_env_var && s.bearer_token_env_var.trim() ? { bearer_token_env_var: s.bearer_token_env_var.trim() } : {}),
126
+ ...(s.http_headers && Object.keys(s.http_headers).length ? { http_headers: s.http_headers } : {}),
127
+ ...(s.env_http_headers && Object.keys(s.env_http_headers).length ? { env_http_headers: s.env_http_headers } : {}),
128
+ };
129
+ } else {
130
+ if (typeof s.command !== 'string' || !s.command.trim() || s.command.length > 2048 || /[\r\n\0]/.test(s.command)) throw new Error('Invalid MCP name or executable');
131
+ const args = s.args || [];
132
+ if (!Array.isArray(args) || args.length > 100 || args.some(a => typeof a !== 'string' || a.length > 4096 || /[\r\n\0]/.test(a))) throw new Error('Arguments must be a JSON array of strings');
133
+ if (/(?:api[_-]?key|token|password|secret|authorization)(?:=|\s)|:\/\/[^/\s]+@/i.test([s.command, ...args].join(' '))) throw new Error('Use the native environment for credentials, not MCP arguments');
134
+ if (s.env !== undefined && s.env !== null) {
135
+ if (typeof s.env !== 'object' || Array.isArray(s.env)) throw new Error('Environment variables must be an object');
136
+ for (const [k, v] of Object.entries(s.env)) {
137
+ if (typeof k !== 'string' || !k.trim() || typeof v !== 'string' || /[\0]/.test(k) || /[\0]/.test(v)) throw new Error('Invalid environment variables');
138
+ }
139
+ }
140
+ if (s.env_vars !== undefined && s.env_vars !== null) {
141
+ if (!Array.isArray(s.env_vars) || s.env_vars.some(v => typeof v !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(v.trim()))) throw new Error('Invalid environment variable passthrough');
142
+ }
143
+ if (s.cwd !== undefined && s.cwd !== null && s.cwd !== '') {
144
+ if (typeof s.cwd !== 'string' || s.cwd.length > 2048 || /[\r\n\0]/.test(s.cwd)) throw new Error('Invalid working directory');
145
+ }
146
+ validated = {
147
+ transportType: 'stdio',
148
+ command: s.command.trim(),
149
+ args,
150
+ ...(s.env && Object.keys(s.env).length ? { env: s.env } : {}),
151
+ ...(s.env_vars && s.env_vars.length ? { env_vars: s.env_vars.map(v => v.trim()).filter(Boolean) } : {}),
152
+ ...(s.cwd && s.cwd.trim() ? { cwd: s.cwd.trim() } : {}),
153
+ };
154
+ }
155
+
84
156
  const data = await this.read();
85
157
  const index = data.servers.findIndex(r => r.harnessId === adapter.manifest.id && r.scope === scope.scope && r.cwd === scope.cwd && r.name === s.name);
86
- const row = { id: index < 0 ? randomUUID() : data.servers[index].id, harnessId: adapter.manifest.id, ...scope, name: s.name, command: s.command.trim(), args: s.args, enabled: s.enabled };
158
+ const row = { id: index < 0 ? randomUUID() : data.servers[index].id, harnessId: adapter.manifest.id, ...scope, name: s.name, enabled: s.enabled, ...validated };
87
159
  if (index < 0) data.servers.push(row); else data.servers[index] = row;
88
160
  await this.write(data);
89
161
  return { saved: true };
@@ -100,7 +172,7 @@ class Integrations {
100
172
  async write(data) {
101
173
  await fs.mkdir(path.dirname(this.file), { recursive: true });
102
174
  const temp = `${this.file}.${randomUUID()}.tmp`;
103
- try { await fs.writeFile(temp, JSON.stringify(data, null, 2), { mode: 0o600, flag: 'wx' }); await fs.rename(temp, this.file); }
175
+ try { await fs.writeFile(temp, JSON.stringify(data, null, 2), { mode: 0o600, flag: 'wx' }); await robustRename(temp, this.file); }
104
176
  finally { await fs.rm(temp, { force: true }); }
105
177
  }
106
178
  async forSession(thread, adapter) {
@@ -111,7 +183,31 @@ class Integrations {
111
183
  if (s.harnessId === adapter.manifest.id && s.scope === scope && (scope === 'global' || s.cwd === cwd)) selected.set(s.name, s);
112
184
  }
113
185
  const rows = [...selected.values()].filter(s => s.enabled);
114
- return { servers: rows.map(s => ({ name: `hm-user-${s.name}`, command: s.command, args: s.args, env: {} })), records: rows.map(s => ({ id: s.id, digest: digest(s) })), cwd };
186
+ return {
187
+ servers: rows.map(s => {
188
+ if (s.transportType === 'streamable_http' || s.url) {
189
+ return {
190
+ name: `hm-user-${s.name}`,
191
+ transportType: 'streamable_http',
192
+ url: s.url,
193
+ ...(s.bearer_token_env_var ? { bearer_token_env_var: s.bearer_token_env_var } : {}),
194
+ ...(s.http_headers ? { http_headers: s.http_headers } : {}),
195
+ ...(s.env_http_headers ? { env_http_headers: s.env_http_headers } : {}),
196
+ };
197
+ }
198
+ return {
199
+ name: `hm-user-${s.name}`,
200
+ transportType: 'stdio',
201
+ command: s.command,
202
+ args: s.args || [],
203
+ env: s.env || {},
204
+ ...(s.env_vars ? { env_vars: s.env_vars } : {}),
205
+ ...(s.cwd ? { cwd: s.cwd } : {}),
206
+ };
207
+ }),
208
+ records: rows.map(s => ({ id: s.id, digest: digest(s) })),
209
+ cwd,
210
+ };
115
211
  }
116
212
  roots(adapter, scope) {
117
213
  const spec = adapter.manifest.integrations?.skills;
@@ -131,6 +227,31 @@ class Integrations {
131
227
  catch (e) { if (e.code !== 'ENOENT') throw e; }
132
228
  }
133
229
  }
230
+ /**
231
+ * Launch-time guarantee: every declared native skill root exists before the harness starts.
232
+ * A harness that is installed but has never run has no skill directory yet, and some
233
+ * harnesses only scan a directory that already exists, so the root is created here instead
234
+ * of asking the user to prepare it by hand.
235
+ * Best-effort by design: a root that cannot be created is reported and skipped, never fatal,
236
+ * because a missing skill directory must not stop a native session from opening.
237
+ */
238
+ async ensureSkillRoots(thread, adapter, { onError } = {}) {
239
+ const spec = adapter.manifest.integrations?.skills;
240
+ if (!spec) return [];
241
+ let project = null;
242
+ try { project = await this.scope({ scope: 'project', cwd: thread.cwd }); }
243
+ catch (error) { onError?.(`project scope unavailable (${error.message})`); }
244
+ const roots = [...new Set([
245
+ ...this.roots(adapter, { scope: 'global', cwd: null }),
246
+ ...(project ? this.roots(adapter, project) : []),
247
+ ])];
248
+ const ensured = [];
249
+ for (const root of roots) {
250
+ try { await this.safeRoot(root); await fs.mkdir(root, { recursive: true }); ensured.push(root); }
251
+ catch (error) { onError?.(`${root} (${error.message})`); }
252
+ }
253
+ return ensured;
254
+ }
134
255
  async skills(adapter, scope) {
135
256
  const result = [];
136
257
  for (const root of this.roots(adapter, scope)) for (const enabled of [true, false]) {
@@ -159,23 +280,39 @@ class Integrations {
159
280
  const active = path.join(root, input.name), inactive = path.join(`${root}.harness-mix-disabled`, input.name);
160
281
  if (input.action === 'install') {
161
282
  if (await exists(active) || await exists(inactive)) throw new Error('Skill already exists; existing files were preserved');
162
- if (typeof input.source !== 'string' || !path.isAbsolute(input.source)) throw new Error('Choose an absolute local skill directory');
163
- await this.safeRoot(input.source);
164
- const source = await fs.realpath(input.source);
165
- if (within(source, active)) throw new Error('Skill source must not contain the destination');
166
- const files = []; let bytes = 0;
167
- const walk = async dir => {
168
- for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
169
- const file = path.join(dir, entry.name);
170
- if (entry.isSymbolicLink()) throw new Error('Skill installation does not follow symbolic links');
171
- if (entry.isDirectory()) await walk(file);
172
- else if (entry.isFile()) {
173
- bytes += (await fs.stat(file)).size; files.push(path.relative(source, file));
174
- if (bytes > 10 * 1024 * 1024 || files.length > 500) throw new Error('Skill exceeds 10 MB or 500 files');
175
- } else throw new Error('Unsupported skill file type');
283
+ let source = null, files = [], dropped = null, bytes = 0;
284
+ if (Array.isArray(input.files)) {
285
+ if (!input.files.length || input.files.length > 500) throw new Error('Skill must contain between 1 and 500 files');
286
+ dropped = new Map();
287
+ for (const item of input.files) {
288
+ if (!item || typeof item.path !== 'string' || typeof item.contentBase64 !== 'string') throw new Error('Invalid dropped skill file');
289
+ const relative = item.path.replaceAll('\\', '/');
290
+ if (!relative || relative.length > 512 || relative.startsWith('/') || relative.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('Dropped skill contains an invalid path');
291
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(item.contentBase64)) throw new Error('Dropped skill contains invalid file data');
292
+ const content = Buffer.from(item.contentBase64, 'base64');
293
+ if (content.toString('base64') !== item.contentBase64) throw new Error('Dropped skill contains invalid file data');
294
+ bytes += content.length;
295
+ if (bytes > 10 * 1024 * 1024 || dropped.has(relative)) throw new Error('Skill exceeds 10 MB or contains duplicate files');
296
+ dropped.set(relative, content); files.push(relative);
176
297
  }
177
- };
178
- await walk(source);
298
+ } else {
299
+ if (typeof input.source !== 'string' || !path.isAbsolute(input.source)) throw new Error('Choose an absolute local skill directory');
300
+ await this.safeRoot(input.source);
301
+ source = await fs.realpath(input.source);
302
+ if (within(source, active)) throw new Error('Skill source must not contain the destination');
303
+ const walk = async dir => {
304
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
305
+ const file = path.join(dir, entry.name);
306
+ if (entry.isSymbolicLink()) throw new Error('Skill installation does not follow symbolic links');
307
+ if (entry.isDirectory()) await walk(file);
308
+ else if (entry.isFile()) {
309
+ bytes += (await fs.stat(file)).size; files.push(path.relative(source, file));
310
+ if (bytes > 10 * 1024 * 1024 || files.length > 500) throw new Error('Skill exceeds 10 MB or 500 files');
311
+ } else throw new Error('Unsupported skill file type');
312
+ }
313
+ };
314
+ await walk(source);
315
+ }
179
316
  if (!files.includes('SKILL.md')) throw new Error('Source must contain SKILL.md');
180
317
  const staging = path.join(path.dirname(root), `.hm-skill-${randomUUID()}`);
181
318
  try {
@@ -183,16 +320,17 @@ class Integrations {
183
320
  for (const relative of files) {
184
321
  const target = path.join(staging, relative);
185
322
  await fs.mkdir(path.dirname(target), { recursive: true });
186
- await fs.copyFile(path.join(source, relative), target);
323
+ if (dropped) await fs.writeFile(target, dropped.get(relative));
324
+ else await fs.copyFile(path.join(source, relative), target);
187
325
  }
188
- await fs.mkdir(root, { recursive: true }); await fs.rename(staging, active);
326
+ await fs.mkdir(root, { recursive: true }); await robustRename(staging, active);
189
327
  } finally { if (within(path.dirname(root), staging)) await fs.rm(staging, { recursive: true, force: true }); }
190
328
  } else if (input.action === 'enable' || input.action === 'disable') {
191
329
  const from = input.action === 'enable' ? inactive : active, to = input.action === 'enable' ? active : inactive;
192
330
  await this.safeRoot(from);
193
331
  if (!await exists(path.join(from, 'SKILL.md'))) throw new Error('Skill no longer exists');
194
332
  if (await exists(to)) throw new Error('Destination already exists; existing files were preserved');
195
- await fs.mkdir(path.dirname(to), { recursive: true }); await fs.rename(from, to);
333
+ await fs.mkdir(path.dirname(to), { recursive: true }); await robustRename(from, to);
196
334
  } else throw new Error('Unknown skill action');
197
335
  return { saved: true, effective: 'next-session' };
198
336
  });