create-harness-vibe-coding 0.8.17 → 0.8.19
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/CHANGELOG.md +22 -0
- package/README-CN.md +2 -0
- package/README.md +3 -0
- package/package.json +5 -2
- package/src/generator.js +613 -97
- package/src/index.js +556 -309
- package/src/prompts.js +18 -0
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
- package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
- package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
- package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
- package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
- package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
- package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
- package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
- package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
- package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
- package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
- package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
- package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
- package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
- package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
- package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
- package/src/ui/dist/index.html +18 -0
- package/src/ui/index.html +17 -0
- package/src/ui/package.json +33 -0
- package/src/ui/pnpm-lock.yaml +1582 -0
- package/src/ui/pnpm-workspace.yaml +2 -0
- package/src/ui/src/App.tsx +86 -0
- package/src/ui/src/api.ts +93 -0
- package/src/ui/src/components/AgentsRoute.tsx +503 -0
- package/src/ui/src/components/Footer.tsx +69 -0
- package/src/ui/src/components/Header.tsx +175 -0
- package/src/ui/src/components/LoadingView.tsx +22 -0
- package/src/ui/src/components/RolesRoute.tsx +169 -0
- package/src/ui/src/components/SettingsRoute.tsx +166 -0
- package/src/ui/src/components/TaskList.tsx +388 -0
- package/src/ui/src/components/TerminalDrawer.tsx +611 -0
- package/src/ui/src/components/WorkflowRoute.tsx +670 -0
- package/src/ui/src/hooks/useReducedMotion.ts +17 -0
- package/src/ui/src/hooks/useServerConnection.ts +114 -0
- package/src/ui/src/index.css +281 -0
- package/src/ui/src/main.tsx +11 -0
- package/src/ui/src/types.ts +108 -0
- package/src/ui/tsconfig.json +21 -0
- package/src/ui/vite.config.ts +19 -0
- package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
- package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
- package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
- package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
- package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
- package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
- package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
- package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
- package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
- package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
- package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
- package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
- package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
- package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
- package/src/wf-ui-server/a2a-store.mjs +296 -0
- package/src/wf-ui-server/peer-capsule.mjs +213 -0
- package/src/wf-ui-server/pty-adapter.mjs +155 -0
- package/src/wf-ui-server/runtime-config.mjs +153 -0
- package/src/wf-ui-server/runtime-detector.mjs +374 -0
- package/src/wf-ui-server/security.mjs +67 -0
- package/src/wf-ui-server/server.mjs +849 -0
- package/src/wf-ui-server/session-registry.mjs +204 -0
- package/src/wf-ui-server/settings.mjs +100 -0
- package/src/wf-ui-server/task-parser.mjs +133 -0
- package/src/wf-ui-server/terminal-store.mjs +225 -0
- package/src/wf-ui-server/token.mjs +41 -0
- package/src/wf-ui-server/ws-events.mjs +354 -0
- package/src/wf-ui-server/ws-terminal.mjs +473 -0
- package/templates/common/.claude/commands/wf-command-create.md +58 -0
- package/templates/common/.claude/commands/wf-help.md +5 -0
- package/templates/common/.claude/commands/wf-task-archive.md +26 -0
- package/templates/common/.claude/commands/wf-task-list.md +26 -0
- package/templates/common/.claude/commands/wf-task-record.md +24 -0
- package/templates/common/.claude/commands/wf-ui.md +26 -0
- package/templates/common/.claude/commands/wf-update.md +54 -9
- package/templates/common/.claude/rules/ecc/common.md +1 -1
- package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
- package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
- package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
- package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
- package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
- package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
- package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
- package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
- package/templates/common/.harness-version +105 -47
- package/templates/common/.opencode/commands/wf-command-create.md +61 -0
- package/templates/common/.opencode/commands/wf-help.md +5 -0
- package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
- package/templates/common/.opencode/commands/wf-task-list.md +29 -0
- package/templates/common/.opencode/commands/wf-task-record.md +27 -0
- package/templates/common/.opencode/commands/wf-ui.md +26 -0
- package/templates/common/.opencode/commands/wf-update.md +54 -9
- package/templates/common/CLAUDE.md +8 -6
- package/templates/common/Harness/MEMORY.md +11 -0
- package/templates/common/Harness/README.md +21 -38
- package/templates/common/Harness/a2a/role-graph.json +79 -0
- package/templates/common/Harness/a2a/runtime-registry.json +5 -0
- package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
- package/templates/common/Harness/ownership.manifest.json +142 -2
- package/templates/common/Harness/scripts/README.md +134 -0
- package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
- package/templates/common/Harness/scripts/context-budget.mjs +1 -1
- package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
- package/templates/common/Harness/scripts/task-state.mjs +949 -26
- package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
- package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
- package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
- package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
- package/templates/common/Harness/settings.json +35 -0
- package/templates/common/Harness/specs/guides/SETUP.md +8 -0
- package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
- package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
- package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
- package/templates/common/Harness/specs/runtime/subagents.md +6 -0
- package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
- package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
- package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
- package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
- package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
- package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
- package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
- package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
- package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
- package/templates/common/Harness/tasks/_template/STATE.json +6 -0
- package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
- package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { validateTaskId } from './security.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validate a peer ID string.
|
|
7
|
+
* Same rules as taskId: alphanumeric, dash, underscore only, no path traversal.
|
|
8
|
+
* @param {string} peerId
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function validatePeerId(peerId) {
|
|
12
|
+
// Peer IDs follow the same rules as task IDs
|
|
13
|
+
return validateTaskId(peerId);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validate peer capsule identifiers and throw on invalid input.
|
|
18
|
+
* @param {string} taskId
|
|
19
|
+
* @param {string} peerId
|
|
20
|
+
* @throws {Error} If taskId or peerId is invalid
|
|
21
|
+
*/
|
|
22
|
+
function validateIds(taskId, peerId) {
|
|
23
|
+
if (!validateTaskId(taskId)) {
|
|
24
|
+
throw new Error(`Invalid taskId: '${taskId}'. Must match [a-zA-Z0-9_-]+`);
|
|
25
|
+
}
|
|
26
|
+
if (!validatePeerId(peerId)) {
|
|
27
|
+
throw new Error(`Invalid peerId: '${peerId}'. Must match [a-zA-Z0-9_-]+`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the capsule directory path for a peer.
|
|
33
|
+
* @param {string} projectRoot
|
|
34
|
+
* @param {string} taskId
|
|
35
|
+
* @param {string} peerId
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function capsuleDir(projectRoot, taskId, peerId) {
|
|
39
|
+
return path.join(projectRoot, 'Harness', 'tasks', taskId, 'peers', peerId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a peer capsule directory with REQUEST.json and STATE.json.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} projectRoot
|
|
46
|
+
* @param {string} taskId
|
|
47
|
+
* @param {string} peerId
|
|
48
|
+
* @param {object} request - The request payload (must include runtime, taskId, peerId)
|
|
49
|
+
* @returns {{ capsuleDir: string, request: object, state: object }}
|
|
50
|
+
*/
|
|
51
|
+
export function createPeerCapsule(projectRoot, taskId, peerId, request) {
|
|
52
|
+
validateIds(taskId, peerId);
|
|
53
|
+
|
|
54
|
+
const dir = capsuleDir(projectRoot, taskId, peerId);
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
const requestPayload = {
|
|
58
|
+
...request,
|
|
59
|
+
taskId,
|
|
60
|
+
peerId,
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
fs.writeFileSync(path.join(dir, 'REQUEST.json'), JSON.stringify(requestPayload, null, 2), 'utf-8');
|
|
65
|
+
|
|
66
|
+
const state = {
|
|
67
|
+
status: 'starting',
|
|
68
|
+
createdAt: new Date().toISOString(),
|
|
69
|
+
updatedAt: new Date().toISOString(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
fs.writeFileSync(path.join(dir, 'STATE.json'), JSON.stringify(state, null, 2), 'utf-8');
|
|
73
|
+
|
|
74
|
+
return { capsuleDir: dir, request: requestPayload, state };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Append an event to the peer's events.jsonl file.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} projectRoot
|
|
81
|
+
* @param {string} taskId
|
|
82
|
+
* @param {string} peerId
|
|
83
|
+
* @param {object} event
|
|
84
|
+
*/
|
|
85
|
+
export function writePeerEvent(projectRoot, taskId, peerId, event) {
|
|
86
|
+
validateIds(taskId, peerId);
|
|
87
|
+
|
|
88
|
+
const dir = capsuleDir(projectRoot, taskId, peerId);
|
|
89
|
+
if (!fs.existsSync(dir)) {
|
|
90
|
+
throw new Error(`Capsule directory does not exist for ${taskId}/${peerId}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const eventLine = JSON.stringify({
|
|
94
|
+
...event,
|
|
95
|
+
timestamp: event.timestamp || new Date().toISOString(),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
fs.appendFileSync(path.join(dir, 'events.jsonl'), eventLine + '\n', 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Write a result payload to the peer's RESULT.json file.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} projectRoot
|
|
105
|
+
* @param {string} taskId
|
|
106
|
+
* @param {string} peerId
|
|
107
|
+
* @param {object} result
|
|
108
|
+
*/
|
|
109
|
+
export function writePeerResult(projectRoot, taskId, peerId, result) {
|
|
110
|
+
validateIds(taskId, peerId);
|
|
111
|
+
|
|
112
|
+
const dir = capsuleDir(projectRoot, taskId, peerId);
|
|
113
|
+
if (!fs.existsSync(dir)) {
|
|
114
|
+
throw new Error(`Capsule directory does not exist for ${taskId}/${peerId}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const resultPayload = {
|
|
118
|
+
...result,
|
|
119
|
+
completedAt: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
fs.writeFileSync(path.join(dir, 'RESULT.json'), JSON.stringify(resultPayload, null, 2), 'utf-8');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Mark a peer as blocked: update STATE.json and write PROGRESS.md with the reason.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} projectRoot
|
|
129
|
+
* @param {string} taskId
|
|
130
|
+
* @param {string} peerId
|
|
131
|
+
* @param {string} reason
|
|
132
|
+
*/
|
|
133
|
+
export function blockPeer(projectRoot, taskId, peerId, reason) {
|
|
134
|
+
validateIds(taskId, peerId);
|
|
135
|
+
|
|
136
|
+
const dir = capsuleDir(projectRoot, taskId, peerId);
|
|
137
|
+
if (!fs.existsSync(dir)) {
|
|
138
|
+
throw new Error(`Capsule directory does not exist for ${taskId}/${peerId}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const now = new Date().toISOString();
|
|
142
|
+
|
|
143
|
+
// Update STATE.json
|
|
144
|
+
const statePath = path.join(dir, 'STATE.json');
|
|
145
|
+
let state = {};
|
|
146
|
+
if (fs.existsSync(statePath)) {
|
|
147
|
+
state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
148
|
+
}
|
|
149
|
+
state.status = 'blocked';
|
|
150
|
+
state.blockedReason = reason;
|
|
151
|
+
state.updatedAt = now;
|
|
152
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
153
|
+
|
|
154
|
+
// Write PROGRESS.md
|
|
155
|
+
const progressContent = `# Peer Capsule: ${peerId}
|
|
156
|
+
|
|
157
|
+
**Status:** BLOCKED
|
|
158
|
+
|
|
159
|
+
**Block reason:** ${reason}
|
|
160
|
+
|
|
161
|
+
**Timestamp:** ${now}
|
|
162
|
+
`;
|
|
163
|
+
fs.writeFileSync(path.join(dir, 'PROGRESS.md'), progressContent, 'utf-8');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read a peer capsule's files and return the full capsule.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} projectRoot
|
|
170
|
+
* @param {string} taskId
|
|
171
|
+
* @param {string} peerId
|
|
172
|
+
* @returns {{ request: object|null, state: object|null, events: object[], result: object|null, progress: string|null }|null}
|
|
173
|
+
*/
|
|
174
|
+
export function getPeerCapsule(projectRoot, taskId, peerId) {
|
|
175
|
+
validateIds(taskId, peerId);
|
|
176
|
+
|
|
177
|
+
const dir = capsuleDir(projectRoot, taskId, peerId);
|
|
178
|
+
if (!fs.existsSync(dir)) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const readJSON = (filePath) => {
|
|
183
|
+
if (!fs.existsSync(filePath)) return null;
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const readText = (filePath) => {
|
|
192
|
+
if (!fs.existsSync(filePath)) return null;
|
|
193
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Read events from jsonl
|
|
197
|
+
const eventsPath = path.join(dir, 'events.jsonl');
|
|
198
|
+
let events = [];
|
|
199
|
+
if (fs.existsSync(eventsPath)) {
|
|
200
|
+
const content = fs.readFileSync(eventsPath, 'utf-8').trim();
|
|
201
|
+
if (content) {
|
|
202
|
+
events = content.split('\n').map(line => JSON.parse(line));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
request: readJSON(path.join(dir, 'REQUEST.json')),
|
|
208
|
+
state: readJSON(path.join(dir, 'STATE.json')),
|
|
209
|
+
events,
|
|
210
|
+
result: readJSON(path.join(dir, 'RESULT.json')),
|
|
211
|
+
progress: readText(path.join(dir, 'PROGRESS.md')),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ALLOWED_RUNTIMES, generateSessionId } from './session-registry.mjs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
getRuntimeDefinition,
|
|
5
|
+
isRuntimeLaunchable,
|
|
6
|
+
resolveRuntimeCommand,
|
|
7
|
+
resolveRuntimeLaunchArgs,
|
|
8
|
+
} from './runtime-detector.mjs';
|
|
9
|
+
|
|
10
|
+
async function loadPtyModule() {
|
|
11
|
+
const attempts = [
|
|
12
|
+
{
|
|
13
|
+
id: 'node-pty',
|
|
14
|
+
hint: 'Install node-pty or @homebridge/node-pty-prebuilt-multiarch.',
|
|
15
|
+
load: () => import('node-pty'),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: '@homebridge/node-pty-prebuilt-multiarch',
|
|
19
|
+
hint: 'Install optional dependency @homebridge/node-pty-prebuilt-multiarch and approve its build scripts for pnpm.',
|
|
20
|
+
load: () => import('@homebridge/node-pty-prebuilt-multiarch'),
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const errors = [];
|
|
25
|
+
for (const attempt of attempts) {
|
|
26
|
+
try {
|
|
27
|
+
const loaded = await attempt.load();
|
|
28
|
+
const module = loaded.default || loaded;
|
|
29
|
+
if (module && typeof module.spawn === 'function') {
|
|
30
|
+
return { module, provider: attempt.id };
|
|
31
|
+
}
|
|
32
|
+
errors.push(`${attempt.id}: spawn export missing`);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
errors.push(`${attempt.id}: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
blocked: true,
|
|
40
|
+
reason: 'pty-adapter-missing',
|
|
41
|
+
hint: `No PTY backend could be loaded. ${attempts[1].hint}`,
|
|
42
|
+
details: errors,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolvePtyCommand(executable, args) {
|
|
47
|
+
if (process.platform !== 'win32') return { executable, args };
|
|
48
|
+
const ext = path.extname(executable).toLowerCase();
|
|
49
|
+
if (ext !== '.cmd' && ext !== '.bat') return { executable, args };
|
|
50
|
+
return {
|
|
51
|
+
executable: 'cmd.exe',
|
|
52
|
+
args: ['/d', '/c', 'call', executable, ...args],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Spawn a PTY for a given runtime agent.
|
|
58
|
+
*
|
|
59
|
+
* Dynamically imports node-pty, then the prebuilt Homebridge fork. If both fail,
|
|
60
|
+
* returns a BLOCKED response. Never silently falls back to a fake terminal.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} opts
|
|
63
|
+
* @param {string} opts.runtime - Agent runtime (claude, codex, opencode)
|
|
64
|
+
* @param {string|null} opts.taskId - Optional task ID for the session
|
|
65
|
+
* @param {string} opts.peerId - Peer identifier
|
|
66
|
+
* @param {string} opts.projectRoot - Project root directory
|
|
67
|
+
* @param {string} [opts.command] - Resolved executable command from detector
|
|
68
|
+
* @param {string[]} [opts.commandArgs] - Extra args appended after runtime launch args
|
|
69
|
+
* @param {string} [opts.model] - Optional model override for runtimes that support it
|
|
70
|
+
* @param {number} [opts.cols=120] - Terminal columns
|
|
71
|
+
* @param {number} [opts.rows=32] - Terminal rows
|
|
72
|
+
* @param {function} opts.onData - Callback for PTY output data (chunk) => void
|
|
73
|
+
* @param {function} opts.onExit - Callback for PTY exit ({ exitCode, signal }) => void
|
|
74
|
+
* @returns {Promise<{sessionId: string, pid: number, ptyProcess: object}|{blocked: boolean, reason: string, hint: string}>}
|
|
75
|
+
* @throws {Error} If runtime is not in ALLOWED_RUNTIMES
|
|
76
|
+
*/
|
|
77
|
+
export async function spawnPty({
|
|
78
|
+
runtime,
|
|
79
|
+
taskId,
|
|
80
|
+
peerId,
|
|
81
|
+
projectRoot,
|
|
82
|
+
sessionId,
|
|
83
|
+
command,
|
|
84
|
+
commandArgs = [],
|
|
85
|
+
model = '',
|
|
86
|
+
cols = 120,
|
|
87
|
+
rows = 32,
|
|
88
|
+
onData,
|
|
89
|
+
onExit,
|
|
90
|
+
}) {
|
|
91
|
+
// Validate runtime before anything else
|
|
92
|
+
if (!ALLOWED_RUNTIMES.has(runtime)) {
|
|
93
|
+
throw new Error(`Invalid runtime '${runtime}'. Must be one of: ${[...ALLOWED_RUNTIMES].join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const definition = getRuntimeDefinition(runtime);
|
|
97
|
+
if (!isRuntimeLaunchable(runtime)) {
|
|
98
|
+
return {
|
|
99
|
+
blocked: true,
|
|
100
|
+
reason: 'runtime-adapter-needed',
|
|
101
|
+
hint: `${definition?.label || runtime} is detectable but does not yet have a launch adapter.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const loadedPty = await loadPtyModule();
|
|
106
|
+
if (loadedPty.blocked) return loadedPty;
|
|
107
|
+
const nodePty = loadedPty.module;
|
|
108
|
+
|
|
109
|
+
const resolvedSessionId = sessionId || generateSessionId();
|
|
110
|
+
|
|
111
|
+
const requestedExecutable = command || resolveRuntimeCommand(runtime);
|
|
112
|
+
const requestedArgs = [...resolveRuntimeLaunchArgs(runtime, { model }), ...commandArgs];
|
|
113
|
+
const { executable, args: launchArgs } = resolvePtyCommand(requestedExecutable, requestedArgs);
|
|
114
|
+
|
|
115
|
+
let ptyProcess;
|
|
116
|
+
try {
|
|
117
|
+
ptyProcess = nodePty.spawn(executable, launchArgs, {
|
|
118
|
+
name: 'xterm-256color',
|
|
119
|
+
cols,
|
|
120
|
+
rows,
|
|
121
|
+
cwd: projectRoot || process.cwd(),
|
|
122
|
+
env: {
|
|
123
|
+
...process.env,
|
|
124
|
+
HARNESS_PEER_RUNTIME: runtime,
|
|
125
|
+
CLAUDE_PEER_TASK_ID: taskId || '',
|
|
126
|
+
HARNESS_PEER_TASK_ID: taskId || '',
|
|
127
|
+
CLAUDE_PEER_ID: peerId,
|
|
128
|
+
HARNESS_PEER_SESSION_ID: resolvedSessionId,
|
|
129
|
+
TERM: 'xterm-256color',
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return {
|
|
134
|
+
blocked: true,
|
|
135
|
+
reason: 'runtime-spawn-failed',
|
|
136
|
+
hint: `${runtime} executable not found or failed to start: ${err.message}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const pid = ptyProcess.pid;
|
|
141
|
+
|
|
142
|
+
ptyProcess.onData((data) => {
|
|
143
|
+
if (typeof onData === 'function') {
|
|
144
|
+
onData(data);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
149
|
+
if (typeof onExit === 'function') {
|
|
150
|
+
onExit({ exitCode, signal });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return { sessionId: resolvedSessionId, pid, ptyProcess, ptyProvider: loadedPty.provider };
|
|
155
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getRuntimeDefinition } from './runtime-detector.mjs';
|
|
4
|
+
|
|
5
|
+
function resolveConfigPath(projectRoot, configFile) {
|
|
6
|
+
const rawPath = configFile.resolvedPath || configFile.path;
|
|
7
|
+
return path.isAbsolute(rawPath) ? rawPath : path.join(projectRoot, rawPath);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readJson(filePath) {
|
|
11
|
+
if (!fs.existsSync(filePath)) return {};
|
|
12
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function atomicWrite(filePath, content) {
|
|
16
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
17
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
18
|
+
fs.writeFileSync(tmpPath, content, 'utf8');
|
|
19
|
+
fs.renameSync(tmpPath, filePath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function backupIfExists(filePath) {
|
|
23
|
+
if (!fs.existsSync(filePath)) return null;
|
|
24
|
+
const backupPath = `${filePath}.${Date.now()}.bak`;
|
|
25
|
+
fs.copyFileSync(filePath, backupPath);
|
|
26
|
+
return backupPath;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getPathValue(object, dottedPath) {
|
|
30
|
+
const parts = String(dottedPath || '').split('.').filter(Boolean);
|
|
31
|
+
let current = object;
|
|
32
|
+
for (const part of parts) {
|
|
33
|
+
if (!current || typeof current !== 'object' || !(part in current)) return undefined;
|
|
34
|
+
current = current[part];
|
|
35
|
+
}
|
|
36
|
+
return current;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setPathValue(object, dottedPath, value) {
|
|
40
|
+
const parts = String(dottedPath || '').split('.').filter(Boolean);
|
|
41
|
+
if (parts.length === 0) return;
|
|
42
|
+
let current = object;
|
|
43
|
+
for (const part of parts.slice(0, -1)) {
|
|
44
|
+
if (!current[part] || typeof current[part] !== 'object' || Array.isArray(current[part])) {
|
|
45
|
+
current[part] = {};
|
|
46
|
+
}
|
|
47
|
+
current = current[part];
|
|
48
|
+
}
|
|
49
|
+
current[parts[parts.length - 1]] = value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function escapeRegExp(value) {
|
|
53
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readTomlFields(filePath, fields) {
|
|
57
|
+
if (!fs.existsSync(filePath)) return {};
|
|
58
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
59
|
+
const values = {};
|
|
60
|
+
for (const [alias, field] of Object.entries(fields || {})) {
|
|
61
|
+
if (String(field).includes('.')) continue;
|
|
62
|
+
const match = text.match(new RegExp(`^\\s*${escapeRegExp(field)}\\s*=\\s*(.*?)\\s*(?:#.*)?$`, 'm'));
|
|
63
|
+
if (!match) continue;
|
|
64
|
+
const raw = match[1].trim();
|
|
65
|
+
values[alias] = raw.replace(/^['"]|['"]$/g, '');
|
|
66
|
+
}
|
|
67
|
+
return values;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function updateTomlField(text, field, value) {
|
|
71
|
+
if (String(field).includes('.')) return text;
|
|
72
|
+
const nextLine = `${field} = ${JSON.stringify(String(value))}`;
|
|
73
|
+
const regex = new RegExp(`^\\s*${escapeRegExp(field)}\\s*=\\s*.*$`, 'm');
|
|
74
|
+
if (regex.test(text)) return text.replace(regex, nextLine);
|
|
75
|
+
return `${text.replace(/\s*$/, '')}\n${nextLine}\n`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function configFilesForRuntime(projectRoot, runtimeId) {
|
|
79
|
+
const definition = getRuntimeDefinition(runtimeId);
|
|
80
|
+
if (!definition) throw new Error(`Unknown runtime: ${runtimeId}`);
|
|
81
|
+
return (definition.configFiles || []).map((configFile) => {
|
|
82
|
+
const absolutePath = resolveConfigPath(projectRoot, configFile);
|
|
83
|
+
const fields = configFile.fields || {};
|
|
84
|
+
let values = {};
|
|
85
|
+
if (configFile.format === 'json') {
|
|
86
|
+
try {
|
|
87
|
+
const json = readJson(absolutePath);
|
|
88
|
+
values = Object.fromEntries(
|
|
89
|
+
Object.entries(fields)
|
|
90
|
+
.map(([alias, dottedPath]) => [alias, getPathValue(json, dottedPath)])
|
|
91
|
+
.filter(([, value]) => value !== undefined),
|
|
92
|
+
);
|
|
93
|
+
} catch {
|
|
94
|
+
values = {};
|
|
95
|
+
}
|
|
96
|
+
} else if (configFile.format === 'toml') {
|
|
97
|
+
values = readTomlFields(absolutePath, fields);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
scope: configFile.scope,
|
|
102
|
+
path: configFile.path,
|
|
103
|
+
absolutePath,
|
|
104
|
+
format: configFile.format,
|
|
105
|
+
fields,
|
|
106
|
+
values,
|
|
107
|
+
exists: fs.existsSync(absolutePath),
|
|
108
|
+
writable: Object.keys(fields).length > 0,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readRuntimeConfig(projectRoot, runtimeId) {
|
|
114
|
+
return {
|
|
115
|
+
runtime: runtimeId,
|
|
116
|
+
files: configFilesForRuntime(projectRoot, runtimeId),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function writeRuntimeConfig(projectRoot, runtimeId, payload = {}) {
|
|
121
|
+
const scope = String(payload.scope || 'user');
|
|
122
|
+
const values = payload.values || {};
|
|
123
|
+
const configFile = configFilesForRuntime(projectRoot, runtimeId)
|
|
124
|
+
.find((candidate) => candidate.scope === scope && candidate.writable);
|
|
125
|
+
|
|
126
|
+
if (!configFile) throw new Error(`No writable ${scope} config is known for ${runtimeId}`);
|
|
127
|
+
|
|
128
|
+
const backupPath = backupIfExists(configFile.absolutePath);
|
|
129
|
+
if (configFile.format === 'json') {
|
|
130
|
+
const data = readJson(configFile.absolutePath);
|
|
131
|
+
for (const [alias, dottedPath] of Object.entries(configFile.fields)) {
|
|
132
|
+
if (values[alias] !== undefined) setPathValue(data, dottedPath, String(values[alias]));
|
|
133
|
+
}
|
|
134
|
+
atomicWrite(configFile.absolutePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
135
|
+
} else if (configFile.format === 'toml') {
|
|
136
|
+
let text = fs.existsSync(configFile.absolutePath) ? fs.readFileSync(configFile.absolutePath, 'utf8') : '';
|
|
137
|
+
for (const [alias, field] of Object.entries(configFile.fields)) {
|
|
138
|
+
if (values[alias] !== undefined) text = updateTomlField(text, field, values[alias]);
|
|
139
|
+
}
|
|
140
|
+
atomicWrite(configFile.absolutePath, text);
|
|
141
|
+
} else {
|
|
142
|
+
throw new Error(`Unsupported config format: ${configFile.format}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
runtime: runtimeId,
|
|
147
|
+
scope,
|
|
148
|
+
path: configFile.path,
|
|
149
|
+
absolutePath: configFile.absolutePath,
|
|
150
|
+
backupPath,
|
|
151
|
+
values,
|
|
152
|
+
};
|
|
153
|
+
}
|