@harness-mix/cli 0.1.5 → 0.1.6
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.
- package/README.md +1 -1
- package/docs/harness-management.md +4 -4
- package/docs/multi-agent-collaboration.md +9 -9
- package/output/native-build/renderer-extension.js +1959 -435
- package/package.json +9 -8
- package/scripts/collaboration-test.cjs +8 -3
- package/scripts/collaboration-ui-smoke.cjs +40 -26
- package/scripts/integrations-test.cjs +63 -4
- package/scripts/integrations-ui-smoke.cjs +77 -16
- package/scripts/native-protocol-test.cjs +102 -1
- package/scripts/native-sidebar-test.cjs +7 -0
- package/scripts/native-skill-roots-test.cjs +90 -0
- package/scripts/test-live-mentions.cjs +152 -0
- package/src/main/adapters/antigravity.js +20 -17
- package/src/main/adapters/claude.js +41 -8
- package/src/main/adapters/codebuddy.js +1 -0
- package/src/main/adapters/codex.js +20 -2
- package/src/main/adapters/cursor.js +5 -1
- package/src/main/adapters/dsh.js +8 -1
- package/src/main/adapters/grok.js +4 -1
- package/src/main/adapters/hermes.js +8 -0
- package/src/main/adapters/kiro.js +2 -1
- package/src/main/adapters/managed-mcp.js +15 -2
- package/src/main/adapters/omp.js +11 -0
- package/src/main/adapters/openclaw.js +6 -0
- package/src/main/adapters/opencode.js +5 -1
- package/src/main/adapters/pi.js +5 -1
- package/src/main/adapters/qoder.js +1 -0
- package/src/main/adapters/trae.js +4 -0
- package/src/main/adapters/zcode.js +3 -0
- package/src/main/host/collaboration.js +57 -11
- package/src/main/host/integrations.js +164 -26
- package/src/main/host/runtime.js +126 -86
- package/src/main/native/protocol.js +189 -78
- package/src/main/native/thread-list.js +3 -0
- package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-chatgpt-context.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts +11 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts +29 -4
- package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts +2 -1
- package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +3 -2
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +149 -171
- package/src/native-ui/renderer-extension/src/renderer-chatgpt-context.ts +5 -1
- package/src/native-ui/renderer-extension/src/renderer-harness-mentions.ts +556 -123
- package/src/native-ui/renderer-extension/src/renderer-integrations-client.ts +36 -3
- package/src/native-ui/renderer-extension/src/settings/integrations-page.ts +1434 -62
- package/src/native-ui/renderer-extension/src/settings/localization.ts +4 -2
- package/src/native-ui/renderer-extension/src/settings/pages.ts +5 -3
- package/src/native-ui/renderer-extension/src/settings/shell.css +96 -6
- package/src/native-ui/renderer-extension/test/renderer-chatgpt-context.test.ts +4 -1
- package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -1
- package/src/native-ui/renderer-extension/test/settings/shell.test.ts +5 -2
|
@@ -5,3 +5,6 @@ module.exports = nativeAcp({
|
|
|
5
5
|
id: 'zcode', name: 'ZCode', args: [],
|
|
6
6
|
capabilities: { questions: false, thinkingLevels: false, usage: true, contextUsage: true, attachments: false, fork: false, compaction: false },
|
|
7
7
|
});
|
|
8
|
+
// ZCode scans, per scope: .zcode/skills then .agents/skills (deeper workspace levels win).
|
|
9
|
+
// https://zcode.z.ai/en/docs/skill
|
|
10
|
+
module.exports.manifest.integrations.skills = { global: ['.zcode/skills', '.agents/skills'], project: ['.zcode/skills', '.agents/skills'] };
|
|
@@ -8,6 +8,22 @@ const { createWorkspace, reviewWorkspace, applyWorkspace, discardWorkspace, push
|
|
|
8
8
|
const validators = new Map(tools.map(tool => [tool.name, z.fromJSONSchema(tool.inputSchema)]));
|
|
9
9
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
10
10
|
|
|
11
|
+
function defaultWorkerPermissionMode(agent) {
|
|
12
|
+
switch (agent) {
|
|
13
|
+
case 'claude':
|
|
14
|
+
case 'claude-code':
|
|
15
|
+
return 'bypassPermissions';
|
|
16
|
+
case 'antigravity':
|
|
17
|
+
case 'agy':
|
|
18
|
+
return 'skip';
|
|
19
|
+
case 'pi':
|
|
20
|
+
case 'omp':
|
|
21
|
+
return 'no-approve';
|
|
22
|
+
default:
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
// A session-scoped local bridge. Native models/credentials and approvals stay in adapters.
|
|
12
28
|
class Collaboration {
|
|
13
29
|
constructor(runtime) {
|
|
@@ -134,6 +150,13 @@ class Collaboration {
|
|
|
134
150
|
if (name === 'delegate_to_agent') {
|
|
135
151
|
const agent = rt.resolveHarnessId(args.agent_type);
|
|
136
152
|
if (!agent || !rt.status[agent]?.available) throw new Error('Target Harness unavailable');
|
|
153
|
+
// Server-side enforcement: multi-agent collaboration can ONLY start when the user explicitly selected/mentioned agents.
|
|
154
|
+
if (!parent.activeMentions || !parent.activeMentions.length) {
|
|
155
|
+
throw new Error('跨 Harness 协作仅在用户显式使用 #agent 或 @agent(如 #pi、#claude)指定时允许启动。用户本轮未显式委派,不能由大模型自行决定启动跨 Harness 协作。请直接使用当前 Harness 的原生工具完成任务。');
|
|
156
|
+
}
|
|
157
|
+
if (!parent.activeMentions.includes(agent)) {
|
|
158
|
+
throw new Error(`用户仅显式指定了 [${parent.activeMentions.join(', ')}],不能委派给未指定的 "${agent}"。请向用户确认是否需要委派给其他 Harness。`);
|
|
159
|
+
}
|
|
137
160
|
const jobs = [...this.jobs.values()].filter(j => j.owner === owner);
|
|
138
161
|
if (jobs.filter(j => j.status === 'running').length >= 4) throw new Error('At most four concurrent subtasks; collect existing results first');
|
|
139
162
|
if (jobs.filter(j => j.turnId === rt.execution.lastTurn(owner)?.id).length >= 16) throw new Error('At most sixteen subtasks per lead turn');
|
|
@@ -146,7 +169,7 @@ class Collaboration {
|
|
|
146
169
|
if (name === 'get_delegation_status') {
|
|
147
170
|
const jobs = args.task_ids.map(id => this.owned(owner, id));
|
|
148
171
|
const until = Date.now() + (args.wait_ms ?? 0);
|
|
149
|
-
while (jobs.every(j => j.status === 'running') && Date.now() < until && !this.closing) await delay(Math.min(100, until - Date.now()));
|
|
172
|
+
while (jobs.every(j => j.status === 'running') && Date.now() < until && !this.closing && !this.cancelling.has(owner) && rt.execution.isRunning(owner)) await delay(Math.min(100, until - Date.now()));
|
|
150
173
|
return jobs.map(j => this.view(j));
|
|
151
174
|
}
|
|
152
175
|
const job = this.owned(owner, args.task_id);
|
|
@@ -184,9 +207,12 @@ class Collaboration {
|
|
|
184
207
|
job.workspace = await createWorkspace(parent.cwd, job.id, job.isolation);
|
|
185
208
|
await this.save();
|
|
186
209
|
}
|
|
187
|
-
|
|
188
|
-
const child = job.childId ? rt.threads.find(t => t.id === job.childId) : await rt.createThread({
|
|
189
|
-
|
|
210
|
+
const workerPermMode = defaultWorkerPermissionMode(job.agent);
|
|
211
|
+
const child = job.childId ? rt.threads.find(t => t.id === job.childId) : await rt.createThread({
|
|
212
|
+
harnessId: job.agent, cwd: job.workspace.cwd, title: `${parent.title} › ${task.slice(0, 40)}`, parentThreadId: parent.id,
|
|
213
|
+
options: { ...(workerPermMode ? { permissionMode: workerPermMode } : {}) },
|
|
214
|
+
onCreated: async thread => { job.childId = thread.id; await this.save(); }
|
|
215
|
+
});
|
|
190
216
|
if (!child) throw new Error('Native child history is missing; no replacement session was created');
|
|
191
217
|
job.childId = child.id;
|
|
192
218
|
await this.save();
|
|
@@ -198,7 +224,15 @@ class Collaboration {
|
|
|
198
224
|
void sending.then(() => { sendDone = true; }, error => { sendDone = true; sendError = error; });
|
|
199
225
|
const until = Date.now() + 30 * 60 * 1000;
|
|
200
226
|
let displayedStatus = 'running';
|
|
201
|
-
|
|
227
|
+
let turnInactiveSince = null;
|
|
228
|
+
while (job.status === 'running' && !this.closing && !this.cancelling.has(parent.id) && rt.execution.isRunning(parent.id)) {
|
|
229
|
+
const childRunning = rt.execution.isRunning(child.id) || child.reviewPending;
|
|
230
|
+
if (!childRunning) {
|
|
231
|
+
if (!turnInactiveSince) turnInactiveSince = Date.now();
|
|
232
|
+
if (sendDone || Date.now() - turnInactiveSince > 2000) break;
|
|
233
|
+
} else {
|
|
234
|
+
turnInactiveSince = null;
|
|
235
|
+
}
|
|
202
236
|
const current = this.view(job).display_status;
|
|
203
237
|
if (current !== displayedStatus) { displayedStatus = current; emit({ kind: 'tool', toolCallId, state: 'running', output: JSON.stringify(this.view(job)) }); }
|
|
204
238
|
if (Date.now() > until) { await rt.cancel(child.id); throw new Error('Subtask timed out after 30 minutes'); }
|
|
@@ -227,16 +261,26 @@ class Collaboration {
|
|
|
227
261
|
if (job.status !== 'running') return;
|
|
228
262
|
job.status = this.closing ? 'interrupted' : 'cancelled';
|
|
229
263
|
job.cancelling = true;
|
|
230
|
-
try {
|
|
231
|
-
|
|
264
|
+
try {
|
|
265
|
+
if (job.childId) {
|
|
266
|
+
await Promise.race([
|
|
267
|
+
this.runtime.cancel(job.childId),
|
|
268
|
+
new Promise(r => setTimeout(r, 3_000)),
|
|
269
|
+
]).catch(() => {});
|
|
270
|
+
}
|
|
271
|
+
} finally { job.cancelling = false; await this.save(); }
|
|
232
272
|
}
|
|
233
273
|
|
|
234
274
|
async cancelOwner(owner) {
|
|
235
275
|
const jobs = [...this.jobs.values()].filter(j => j.owner === owner && j.status === 'running');
|
|
236
276
|
if (!jobs.length) return;
|
|
237
277
|
this.cancelling.add(owner);
|
|
238
|
-
try {
|
|
239
|
-
|
|
278
|
+
try {
|
|
279
|
+
await Promise.race([
|
|
280
|
+
Promise.all(jobs.map(j => this.cancel(j))),
|
|
281
|
+
new Promise(r => setTimeout(r, 5_000)),
|
|
282
|
+
]).catch(() => {});
|
|
283
|
+
} finally { this.cancelling.delete(owner); }
|
|
240
284
|
}
|
|
241
285
|
isParticipant(thread, owner) { return thread.id === owner || [...this.jobs.values()].some(j => j.owner === owner && j.childId === thread.id); }
|
|
242
286
|
async close() {
|
|
@@ -254,11 +298,13 @@ function mentionedAgents(text, runtime) {
|
|
|
254
298
|
// Ignore code and email/package addresses; explicit links survive draft copy/paste.
|
|
255
299
|
const prose = text.replace(/```[\s\S]*?```|`[^`\n]*`/g, '');
|
|
256
300
|
const ids = new Set();
|
|
257
|
-
|
|
301
|
+
// CJK ideographs (\u4e00-\u9fff) and fullwidth/halfwidth forms are valid word boundaries,
|
|
302
|
+
// so #agent works in Chinese prose (for example 帮我#pi做这个). @ remains native Codex syntax.
|
|
303
|
+
for (const match of prose.matchAll(/\[[^\]\n]+\]\(harness-mix:\/\/agent\/([\w-]+)\)|(?:^|[\s\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff,。;:、!?""''()【】])#([\w-]+)(?=$|[\s\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff,。;:、!?""''()【】])/g)) {
|
|
258
304
|
const id = runtime.resolveHarnessId(match[1] || match[2]);
|
|
259
305
|
if (id) ids.add(id);
|
|
260
306
|
}
|
|
261
307
|
return [...ids];
|
|
262
308
|
}
|
|
263
309
|
|
|
264
|
-
module.exports = { Collaboration, mentionedAgents };
|
|
310
|
+
module.exports = { Collaboration, mentionedAgents, defaultWorkerPermissionMode };
|
|
@@ -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'
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 {
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
});
|