Replace OpenCode with SmartAgent runtime
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@git.zone/ide-opencode-bridge",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"dist_ts/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IOpenCodeClientOptions {
|
||||
baseUrl: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface IOpenCodeMessagePart {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpenCodePromptBody {
|
||||
messageID?: string;
|
||||
model?: {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
};
|
||||
agent?: string;
|
||||
noReply?: boolean;
|
||||
system?: string;
|
||||
tools?: Record<string, boolean>;
|
||||
parts: IOpenCodeMessagePart[];
|
||||
}
|
||||
|
||||
export interface IOpenCodeCommandBody {
|
||||
messageID?: string;
|
||||
agent?: string;
|
||||
model?: {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
};
|
||||
command: string;
|
||||
arguments?: string;
|
||||
}
|
||||
|
||||
export interface IOpenCodeShellBody {
|
||||
agent: string;
|
||||
model?: {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
};
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface IOpenCodeEvent {
|
||||
type: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
data?: unknown;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface IRendererOpenCodeEvent {
|
||||
type: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export class OpenCodeHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly body: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenCodeServerClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly authorizationHeader?: string;
|
||||
|
||||
constructor(options: IOpenCodeClientOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/g, '');
|
||||
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
if (options.username || options.password) {
|
||||
const username = options.username ?? 'opencode';
|
||||
const password = options.password ?? '';
|
||||
this.authorizationHeader = `Basic ${plugins.Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
}
|
||||
}
|
||||
|
||||
async health<T = unknown>() {
|
||||
return this.request<T>('/global/health');
|
||||
}
|
||||
|
||||
async projects<T = unknown>() {
|
||||
return this.request<T>('/project');
|
||||
}
|
||||
|
||||
async currentProject<T = unknown>() {
|
||||
return this.request<T>('/project/current');
|
||||
}
|
||||
|
||||
async path<T = unknown>() {
|
||||
return this.request<T>('/path');
|
||||
}
|
||||
|
||||
async vcs<T = unknown>() {
|
||||
return this.request<T>('/vcs');
|
||||
}
|
||||
|
||||
async config<T = unknown>() {
|
||||
return this.request<T>('/config');
|
||||
}
|
||||
|
||||
async providers<T = unknown>() {
|
||||
return this.request<T>('/provider');
|
||||
}
|
||||
|
||||
async agents<T = unknown>() {
|
||||
return this.request<T>('/agent');
|
||||
}
|
||||
|
||||
async commands<T = unknown>() {
|
||||
return this.request<T>('/command');
|
||||
}
|
||||
|
||||
async sessions<T = unknown>() {
|
||||
return this.request<T>('/session');
|
||||
}
|
||||
|
||||
async createSession<T = unknown>(body: { parentID?: string; title?: string } = {}) {
|
||||
return this.request<T>('/session', { method: 'POST', body });
|
||||
}
|
||||
|
||||
async session<T = unknown>(id: string) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async updateSession<T = unknown>(id: string, body: { title?: string }) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
||||
}
|
||||
|
||||
async deleteSession<T = unknown>(id: string) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async sessionStatus<T = unknown>() {
|
||||
return this.request<T>('/session/status');
|
||||
}
|
||||
|
||||
async children<T = unknown>(id: string) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/children`);
|
||||
}
|
||||
|
||||
async todo<T = unknown>(id: string) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/todo`);
|
||||
}
|
||||
|
||||
async messages<T = unknown>(id: string, limit?: number) {
|
||||
const query = limit ? `?limit=${encodeURIComponent(`${limit}`)}` : '';
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/message${query}`);
|
||||
}
|
||||
|
||||
async message<T = unknown>(id: string, messageID: string) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/message/${encodeURIComponent(messageID)}`);
|
||||
}
|
||||
|
||||
async prompt<T = unknown>(id: string, body: IOpenCodePromptBody) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/message`, { method: 'POST', body });
|
||||
}
|
||||
|
||||
async promptAsync(id: string, body: IOpenCodePromptBody) {
|
||||
return this.request<void>(`/session/${encodeURIComponent(id)}/prompt_async`, { method: 'POST', body });
|
||||
}
|
||||
|
||||
async command<T = unknown>(id: string, body: IOpenCodeCommandBody) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/command`, { method: 'POST', body });
|
||||
}
|
||||
|
||||
async shell<T = unknown>(id: string, body: IOpenCodeShellBody) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/shell`, { method: 'POST', body });
|
||||
}
|
||||
|
||||
async abort<T = unknown>(id: string) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/abort`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async diff<T = unknown>(id: string, messageID?: string) {
|
||||
const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : '';
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/diff${query}`);
|
||||
}
|
||||
|
||||
async revert<T = unknown>(id: string, body: { messageID: string; partID?: string }) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/revert`, { method: 'POST', body });
|
||||
}
|
||||
|
||||
async unrevert<T = unknown>(id: string) {
|
||||
return this.request<T>(`/session/${encodeURIComponent(id)}/unrevert`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async respondToPermission<T = unknown>(
|
||||
sessionID: string,
|
||||
permissionID: string,
|
||||
body: { response: string; remember?: boolean },
|
||||
) {
|
||||
return this.request<T>(
|
||||
`/session/${encodeURIComponent(sessionID)}/permissions/${encodeURIComponent(permissionID)}`,
|
||||
{ method: 'POST', body },
|
||||
);
|
||||
}
|
||||
|
||||
async findFiles<T = unknown>(query: { query: string; type?: 'file' | 'directory'; directory?: string; limit?: number }) {
|
||||
return this.request<T>(`/find/file?${new URLSearchParams(stringifyQuery(query))}`);
|
||||
}
|
||||
|
||||
async findText<T = unknown>(query: { pattern: string }) {
|
||||
return this.request<T>(`/find?${new URLSearchParams(stringifyQuery(query))}`);
|
||||
}
|
||||
|
||||
async fileContent<T = unknown>(path: string) {
|
||||
return this.request<T>(`/file/content?${new URLSearchParams({ path })}`);
|
||||
}
|
||||
|
||||
async fileStatus<T = unknown>() {
|
||||
return this.request<T>('/file/status');
|
||||
}
|
||||
|
||||
async *events(signal?: AbortSignal): AsyncGenerator<IOpenCodeEvent> {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}/event`, {
|
||||
headers: this.createHeaders(),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await this.createHttpError(response, '/event');
|
||||
}
|
||||
if (!response.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split(/\n\n/);
|
||||
buffer = parts.pop() ?? '';
|
||||
for (const part of parts) {
|
||||
const event = parseServerSentEvent(part);
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalEvent = parseServerSentEvent(buffer);
|
||||
if (finalEvent) {
|
||||
yield finalEvent;
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options: { method?: string; body?: unknown } = {}) {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: this.createHeaders(options.body !== undefined),
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await this.createHttpError(response, path);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
private createHeaders(hasJsonBody = false) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (hasJsonBody) {
|
||||
headers['content-type'] = 'application/json';
|
||||
}
|
||||
if (this.authorizationHeader) {
|
||||
headers.authorization = this.authorizationHeader;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async createHttpError(response: Response, path: string) {
|
||||
const body = await response.text();
|
||||
return new OpenCodeHttpError(`OpenCode request failed: ${response.status} ${path}`, response.status, body);
|
||||
}
|
||||
}
|
||||
|
||||
export const openCodeBridgeToolNames = [
|
||||
'bash',
|
||||
'read',
|
||||
'write',
|
||||
'edit',
|
||||
'grep',
|
||||
'glob',
|
||||
'apply_patch',
|
||||
] as const;
|
||||
|
||||
export type TOpenCodeBridgeToolName = typeof openCodeBridgeToolNames[number];
|
||||
|
||||
export interface IOpenCodeBridgeConfigRenderOptions {
|
||||
bridgeUrlEnvName?: string;
|
||||
bridgeTokenEnvName?: string;
|
||||
}
|
||||
|
||||
export const renderOpenCodeBridgeConfigContent = () => `${JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
autoupdate: false,
|
||||
snapshot: false,
|
||||
permission: {
|
||||
lsp: 'deny',
|
||||
skill: 'deny',
|
||||
},
|
||||
}, undefined, 2)}\n`;
|
||||
|
||||
export const renderOpenCodeBridgeToolFiles = (options: IOpenCodeBridgeConfigRenderOptions = {}) => {
|
||||
const files: Record<string, string> = {};
|
||||
for (const toolName of openCodeBridgeToolNames) {
|
||||
files[`tools/${toolName}.js`] = renderOpenCodeBridgeToolFile(toolName, options);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
export const renderOpenCodeBridgeToolFile = (
|
||||
toolName: TOpenCodeBridgeToolName,
|
||||
options: IOpenCodeBridgeConfigRenderOptions = {},
|
||||
) => {
|
||||
const bridgeUrlEnvName = options.bridgeUrlEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_URL';
|
||||
const bridgeTokenEnvName = options.bridgeTokenEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_TOKEN';
|
||||
const definition = openCodeBridgeToolDefinitions[toolName];
|
||||
return `import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const bridgeUrlEnvName = ${JSON.stringify(bridgeUrlEnvName)};
|
||||
const bridgeTokenEnvName = ${JSON.stringify(bridgeTokenEnvName)};
|
||||
|
||||
const forwardTool = async (toolName, args, context) => {
|
||||
const bridgeUrl = process.env[bridgeUrlEnvName];
|
||||
const bridgeToken = process.env[bridgeTokenEnvName];
|
||||
if (!bridgeUrl || !bridgeToken) {
|
||||
throw new Error("Git.Zone OpenCode tool bridge is not configured.");
|
||||
}
|
||||
const response = await fetch(new URL("/tool/" + encodeURIComponent(toolName), bridgeUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"authorization": "Bearer " + bridgeToken,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
args,
|
||||
context: {
|
||||
sessionID: context.sessionID,
|
||||
messageID: context.messageID,
|
||||
agent: context.agent,
|
||||
directory: context.directory,
|
||||
worktree: context.worktree,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(responseText || ("Git.Zone OpenCode tool bridge failed with HTTP " + response.status));
|
||||
}
|
||||
return responseText ? JSON.parse(responseText) : "";
|
||||
};
|
||||
|
||||
export default tool({
|
||||
description: ${JSON.stringify(definition.description)},
|
||||
args: {
|
||||
${definition.args.map((arg) => ` ${arg.name}: ${arg.schema},`).join('\n')}
|
||||
},
|
||||
async execute(args, context) {
|
||||
return forwardTool(${JSON.stringify(toolName)}, args, context);
|
||||
},
|
||||
});
|
||||
`;
|
||||
};
|
||||
|
||||
const openCodeBridgeToolDefinitions: Record<TOpenCodeBridgeToolName, {
|
||||
description: string;
|
||||
args: Array<{ name: string; schema: string }>;
|
||||
}> = {
|
||||
bash: {
|
||||
description: 'Execute a shell command on the selected remote Git.Zone workspace over SSH. The command runs on the remote host, not on the local proxy workspace. Use workdir for a remote working directory when needed.',
|
||||
args: [
|
||||
{ name: 'command', schema: 'tool.schema.string().describe("The command to execute on the remote host")' },
|
||||
{ name: 'timeout', schema: 'tool.schema.number().optional().describe("Optional timeout in milliseconds")' },
|
||||
{ name: 'workdir', schema: 'tool.schema.string().optional().describe("Remote working directory. Defaults to the selected remote project path.")' },
|
||||
{ name: 'description', schema: 'tool.schema.string().describe("Clear, concise description of what this command does in 5-10 words")' },
|
||||
],
|
||||
},
|
||||
read: {
|
||||
description: 'Read a file or directory from the selected remote Git.Zone workspace. Paths may be remote absolute paths or paths relative to the selected remote project.',
|
||||
args: [
|
||||
{ name: 'filePath', schema: 'tool.schema.string().describe("Remote file or directory path to read")' },
|
||||
{ name: 'offset', schema: 'tool.schema.number().optional().describe("The line number to start reading from (1-indexed)")' },
|
||||
{ name: 'limit', schema: 'tool.schema.number().optional().describe("The maximum number of lines to read")' },
|
||||
],
|
||||
},
|
||||
write: {
|
||||
description: 'Create or overwrite a file in the selected remote Git.Zone workspace.',
|
||||
args: [
|
||||
{ name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to write")' },
|
||||
{ name: 'content', schema: 'tool.schema.string().describe("The content to write to the remote file")' },
|
||||
],
|
||||
},
|
||||
edit: {
|
||||
description: 'Modify an existing remote file using exact string replacement.',
|
||||
args: [
|
||||
{ name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to modify")' },
|
||||
{ name: 'oldString', schema: 'tool.schema.string().describe("The text to replace")' },
|
||||
{ name: 'newString', schema: 'tool.schema.string().describe("The replacement text")' },
|
||||
{ name: 'replaceAll', schema: 'tool.schema.boolean().optional().describe("Replace all occurrences of oldString")' },
|
||||
],
|
||||
},
|
||||
grep: {
|
||||
description: 'Search remote file contents in the selected Git.Zone workspace using ripgrep on the remote host.',
|
||||
args: [
|
||||
{ name: 'pattern', schema: 'tool.schema.string().describe("The regex pattern to search for in file contents")' },
|
||||
{ name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory or file to search. Defaults to the selected remote project path.")' },
|
||||
{ name: 'include', schema: 'tool.schema.string().optional().describe("File glob to include, for example *.ts or *.{ts,tsx}")' },
|
||||
],
|
||||
},
|
||||
glob: {
|
||||
description: 'Find remote files by glob pattern in the selected Git.Zone workspace using ripgrep on the remote host.',
|
||||
args: [
|
||||
{ name: 'pattern', schema: 'tool.schema.string().describe("The glob pattern to match remote files against")' },
|
||||
{ name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory to search. Defaults to the selected remote project path.")' },
|
||||
],
|
||||
},
|
||||
apply_patch: {
|
||||
description: 'Apply a stripped-down file patch to the selected remote Git.Zone workspace. Patch paths are relative to the selected remote project unless absolute remote paths are supplied.',
|
||||
args: [
|
||||
{ name: 'patchText', schema: 'tool.schema.string().describe("The full patch text that describes all remote file changes to make")' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const parseServerSentEvent = (raw: string): IOpenCodeEvent | undefined => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dataLines: string[] = [];
|
||||
let type = 'message';
|
||||
let id: string | undefined;
|
||||
let retry: number | undefined;
|
||||
|
||||
for (const line of trimmed.split(/\r?\n/)) {
|
||||
if (line.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
const separator = line.indexOf(':');
|
||||
const field = separator === -1 ? line : line.slice(0, separator);
|
||||
const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
|
||||
if (field === 'event') {
|
||||
type = value;
|
||||
} else if (field === 'data') {
|
||||
dataLines.push(value);
|
||||
} else if (field === 'id') {
|
||||
id = value;
|
||||
} else if (field === 'retry') {
|
||||
const retryNumber = Number(value);
|
||||
if (Number.isFinite(retryNumber)) {
|
||||
retry = retryNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataText = dataLines.join('\n');
|
||||
const data = parseJsonIfPossible(dataText);
|
||||
if (type === 'message' && data && typeof data === 'object' && 'type' in data) {
|
||||
type = String((data as { type: unknown }).type);
|
||||
}
|
||||
return { type, id, retry, data, raw };
|
||||
};
|
||||
|
||||
export const sanitizeOpenCodeEventForRenderer = (event: IOpenCodeEvent): IRendererOpenCodeEvent => ({
|
||||
type: event.type,
|
||||
id: event.id,
|
||||
retry: event.retry,
|
||||
data: event.data,
|
||||
});
|
||||
|
||||
const parseJsonIfPossible = (value: string) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const stringifyQuery = (query: Record<string, string | number | boolean | undefined>) => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined) {
|
||||
result[key] = String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
export { Buffer };
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "ts",
|
||||
"outDir": "dist_ts",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["ts/**/*.ts"]
|
||||
}
|
||||
@@ -30,14 +30,6 @@ export interface IRemoteServerPaths {
|
||||
manifestPath: string;
|
||||
}
|
||||
|
||||
export interface IRemoteOpenCodeDescriptor {
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
status: TRemoteProcessStatus;
|
||||
}
|
||||
|
||||
export interface IRemoteTheiaDescriptor {
|
||||
baseUrl: string;
|
||||
localPort: number;
|
||||
@@ -52,7 +44,6 @@ export interface IRemoteSessionDescriptor {
|
||||
serverVersion: string;
|
||||
createdAt: string;
|
||||
theia: IRemoteTheiaDescriptor;
|
||||
opencode?: IRemoteOpenCodeDescriptor;
|
||||
}
|
||||
|
||||
export interface IRemoteProbeResult {
|
||||
@@ -65,7 +56,6 @@ export interface IRemoteProbeResult {
|
||||
nodeVersion?: string;
|
||||
pnpmVersion?: string;
|
||||
gitVersion?: string;
|
||||
opencodeVersion?: string;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,6 @@ export interface IRemoteServerBootstrapOptions {
|
||||
serverVersion: string;
|
||||
workspacePath: string;
|
||||
theiaPort: number;
|
||||
opencodePort: number;
|
||||
opencodeUsername: string;
|
||||
opencodePassword: string;
|
||||
installRoot?: string;
|
||||
nodeEnv?: string;
|
||||
theiaColorTheme?: string;
|
||||
@@ -78,14 +75,6 @@ export interface IRemoteProjectUpsertOptions extends IRemoteProjectRegistryOptio
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface IRemoteOpenCodeToolCommandOptions {
|
||||
runtimeRoot: string;
|
||||
workspacePath: string;
|
||||
toolName: string;
|
||||
nodePath?: string;
|
||||
rgPath?: string;
|
||||
}
|
||||
|
||||
export const defaultIdeDataRoot = '~/.git.zone/ide';
|
||||
export const defaultInstallRoot = '~/.git.zone/ide/server';
|
||||
export const remoteEphemeralRuntimeMarkerFileName = '.gitzone-runtime-sha256';
|
||||
@@ -147,10 +136,6 @@ export const createRemoteBootstrapCommand = (options: IRemoteServerBootstrapOpti
|
||||
const logFile = joinRemotePath(plan.paths.logsDir, `theia-${options.theiaPort}.log`);
|
||||
const env = {
|
||||
GITZONE_IDE_WORKSPACE: options.workspacePath,
|
||||
GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`,
|
||||
GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART: '1',
|
||||
OPENCODE_SERVER_USERNAME: options.opencodeUsername,
|
||||
OPENCODE_SERVER_PASSWORD: options.opencodePassword,
|
||||
NODE_ENV: options.nodeEnv ?? 'production',
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
@@ -167,7 +152,6 @@ export const createRemoteBootstrapCommand = (options: IRemoteServerBootstrapOpti
|
||||
}),
|
||||
`nohup pnpm --dir ${quoteRemotePath(appDir)} start --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteRemotePath(options.workspacePath)} > ${quoteRemotePath(logFile)} 2>&1 < /dev/null &`,
|
||||
`printf 'theiaPort=%s\\n' ${options.theiaPort}`,
|
||||
`printf 'opencodePort=%s\\n' ${options.opencodePort}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -196,10 +180,6 @@ export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralB
|
||||
].join('\n');
|
||||
const env = {
|
||||
GITZONE_IDE_WORKSPACE: options.workspacePath,
|
||||
GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`,
|
||||
GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART: '1',
|
||||
OPENCODE_SERVER_USERNAME: options.opencodeUsername,
|
||||
OPENCODE_SERVER_PASSWORD: options.opencodePassword,
|
||||
NODE_ENV: options.nodeEnv ?? 'production',
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
@@ -222,7 +202,6 @@ export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralB
|
||||
`nohup ${quoteRemotePath(nodePath)} ${quoteRemotePath(joinRemotePath(appDir, 'lib/backend/main.js'))} --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteRemotePath(options.workspacePath)} > ${quoteRemotePath(logFile)} 2>&1 < /dev/null &`,
|
||||
`printf 'runtimeRoot=%s\n' ${quoteShellArg(options.runtimeRoot)}`,
|
||||
`printf 'theiaPort=%s\n' ${options.theiaPort}`,
|
||||
`printf 'opencodePort=%s\n' ${options.opencodePort}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -313,20 +292,6 @@ export const createRemoteEphemeralPortAllocationCommand = (options: IRemoteEphem
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteOpenCodeToolCommand = (options: IRemoteOpenCodeToolCommandOptions) => {
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const rgPath = options.rgPath ?? joinRemotePath(options.runtimeRoot, 'applications/remote-theia/lib/backend/native/rg');
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`export GITZONE_IDE_WORKSPACE=${quoteRemotePath(options.workspacePath)}`,
|
||||
`export GITZONE_IDE_TOOL_NAME=${quoteShellArg(options.toolName)}`,
|
||||
`export GITZONE_IDE_RG_PATH=${quoteRemotePath(rgPath)}`,
|
||||
`${quoteRemotePath(nodePath)} -e ${quoteShellArg(remoteOpenCodeToolScript)}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteProjectListCommand = (options: IRemoteProjectRegistryOptions) => {
|
||||
const projectsFile = joinRemotePath(options.ideDataRoot ?? defaultIdeDataRoot, remoteProjectsFileName);
|
||||
|
||||
@@ -396,198 +361,6 @@ export const createRemoteHealthCommand = (serverVersion: string, installRoot = d
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const remoteOpenCodeToolScript = [
|
||||
"const fs = require('fs');",
|
||||
"const path = require('path');",
|
||||
"const childProcess = require('child_process');",
|
||||
"const toolName = process.env.GITZONE_IDE_TOOL_NAME;",
|
||||
"const workspacePath = expandHome(process.env.GITZONE_IDE_WORKSPACE || process.cwd());",
|
||||
"const rgPath = process.env.GITZONE_IDE_RG_PATH || 'rg';",
|
||||
"const inputText = fs.readFileSync(0, 'utf8');",
|
||||
"const request = inputText.trim() ? JSON.parse(inputText) : {};",
|
||||
"const args = request.args || {};",
|
||||
"const MAX_LINES = 2000;",
|
||||
"const MAX_LINE_LENGTH = 2000;",
|
||||
"const MAX_BYTES = 50 * 1024;",
|
||||
"function expandHome(value) {",
|
||||
" const text = String(value || '');",
|
||||
" if (text === '~' || text === '$HOME') return process.env.HOME || text;",
|
||||
" if (text.startsWith('~/')) return path.join(process.env.HOME || '', text.slice(2));",
|
||||
" if (text.startsWith('$HOME/')) return path.join(process.env.HOME || '', text.slice(6));",
|
||||
" return text;",
|
||||
"}",
|
||||
"function normalizeRemotePath(value, fallback) {",
|
||||
" if (!value) return fallback;",
|
||||
" const expanded = expandHome(value);",
|
||||
" return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(fallback, expanded);",
|
||||
"}",
|
||||
"function limitLine(line) {",
|
||||
" return line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) + '... (line truncated to 2000 chars)' : line;",
|
||||
"}",
|
||||
"function writeJson(result) { process.stdout.write(JSON.stringify(result)); }",
|
||||
"function ensureParent(filePath) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); }",
|
||||
"function toLines(text) {",
|
||||
" if (!text) return [];",
|
||||
" const lines = text.split(/\\r?\\n/);",
|
||||
" if (text.endsWith('\\n')) lines.pop();",
|
||||
" return lines;",
|
||||
"}",
|
||||
"function readTool() {",
|
||||
" const filePath = normalizeRemotePath(args.filePath, workspacePath);",
|
||||
" const stat = fs.statSync(filePath);",
|
||||
" const offset = Math.max(1, Number(args.offset || 1));",
|
||||
" const limit = Math.max(0, Number(args.limit || MAX_LINES));",
|
||||
" if (stat.isDirectory()) {",
|
||||
" const entries = fs.readdirSync(filePath, { withFileTypes: true }).map((entry) => entry.name + (entry.isDirectory() ? '/' : '')).sort((a, b) => a.localeCompare(b));",
|
||||
" const sliced = entries.slice(offset - 1, offset - 1 + limit);",
|
||||
" const truncated = offset - 1 + sliced.length < entries.length;",
|
||||
" return { output: ['<path>' + filePath + '</path>', '<type>directory</type>', '<entries>', sliced.join('\\n'), truncated ? '\\n(Showing ' + sliced.length + ' of ' + entries.length + ' entries. Use offset=' + (offset + sliced.length) + ' to continue.)' : '\\n(' + entries.length + ' entries)', '</entries>'].join('\\n'), metadata: { preview: sliced.slice(0, 20).join('\\n'), truncated } };",
|
||||
" }",
|
||||
" const buffer = fs.readFileSync(filePath);",
|
||||
" if (buffer.includes(0)) throw new Error('Cannot read binary file: ' + filePath);",
|
||||
" const lines = toLines(buffer.toString('utf8'));",
|
||||
" if (offset > lines.length && !(lines.length === 0 && offset === 1)) throw new Error('Offset ' + offset + ' is out of range for this file (' + lines.length + ' lines)');",
|
||||
" const raw = [];",
|
||||
" let bytes = 0;",
|
||||
" let cut = false;",
|
||||
" for (const line of lines.slice(offset - 1)) {",
|
||||
" if (raw.length >= limit) break;",
|
||||
" const next = limitLine(line);",
|
||||
" const size = Buffer.byteLength(next, 'utf8') + (raw.length > 0 ? 1 : 0);",
|
||||
" if (bytes + size > MAX_BYTES) { cut = true; break; }",
|
||||
" raw.push(next);",
|
||||
" bytes += size;",
|
||||
" }",
|
||||
" const last = offset + raw.length - 1;",
|
||||
" const more = cut || last < lines.length;",
|
||||
" let output = '<path>' + filePath + '</path>\\n<type>file</type>\\n<content>\\n';",
|
||||
" output += raw.map((line, index) => (index + offset) + ': ' + line).join('\\n');",
|
||||
" output += more ? '\\n\\n(Showing lines ' + offset + '-' + last + ' of ' + lines.length + '. Use offset=' + (last + 1) + ' to continue.)' : '\\n\\n(End of file - total ' + lines.length + ' lines)';",
|
||||
" output += '\\n</content>';",
|
||||
" return { output, metadata: { preview: raw.slice(0, 20).join('\\n'), truncated: more } };",
|
||||
"}",
|
||||
"function writeTool() {",
|
||||
" const filePath = normalizeRemotePath(args.filePath, workspacePath);",
|
||||
" const existed = fs.existsSync(filePath);",
|
||||
" ensureParent(filePath);",
|
||||
" fs.writeFileSync(filePath, String(args.content || ''));",
|
||||
" return { output: 'Wrote file successfully.', metadata: { filepath: filePath, exists: existed } };",
|
||||
"}",
|
||||
"function editTool() {",
|
||||
" const filePath = normalizeRemotePath(args.filePath, workspacePath);",
|
||||
" if (args.oldString === args.newString) throw new Error('No changes to apply: oldString and newString are identical.');",
|
||||
" let content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';",
|
||||
" let next;",
|
||||
" if (args.oldString === '') {",
|
||||
" next = String(args.newString || '');",
|
||||
" } else {",
|
||||
" const oldString = String(args.oldString || '');",
|
||||
" const newString = String(args.newString || '');",
|
||||
" const matches = content.split(oldString).length - 1;",
|
||||
" if (matches === 0) throw new Error('Could not find oldString in the file. It must match exactly.');",
|
||||
" if (!args.replaceAll && matches > 1) throw new Error('Found multiple matches for oldString. Provide more surrounding context or set replaceAll.');",
|
||||
" next = args.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);",
|
||||
" }",
|
||||
" ensureParent(filePath);",
|
||||
" fs.writeFileSync(filePath, next);",
|
||||
" return { output: 'Edit applied successfully.', metadata: { filepath: filePath } };",
|
||||
"}",
|
||||
"function bashTool() {",
|
||||
" const cwd = normalizeRemotePath(args.workdir, workspacePath);",
|
||||
" const timeout = Number(args.timeout || 120000);",
|
||||
" const shell = process.env.SHELL || '/bin/sh';",
|
||||
" const result = childProcess.spawnSync(shell, ['-lc', String(args.command || '')], { cwd, encoding: 'utf8', timeout, maxBuffer: 10 * 1024 * 1024 });",
|
||||
" const outputText = [result.stdout || '', result.stderr || ''].filter(Boolean).join('');",
|
||||
" const metadata = [];",
|
||||
" if (result.error && result.error.code === 'ETIMEDOUT') metadata.push('remote shell tool terminated command after exceeding timeout ' + timeout + ' ms.');",
|
||||
" metadata.push('exit=' + (result.status === null || result.status === undefined ? 1 : result.status));",
|
||||
" const output = (outputText.trim() ? outputText.replace(/\\s+$/g, '') : '(no output)') + '\\n\\n<shell_metadata>\\n' + metadata.join('\\n') + '\\n</shell_metadata>';",
|
||||
" return { output, metadata: { exit: result.status, cwd } };",
|
||||
"}",
|
||||
"function grepTool() {",
|
||||
" if (!args.pattern) throw new Error('pattern is required');",
|
||||
" const search = normalizeRemotePath(args.path, workspacePath);",
|
||||
" const executable = fs.existsSync(rgPath) ? rgPath : 'rg';",
|
||||
" const rgArgs = ['--line-number', '--with-filename', '--color', 'never', '--no-heading'];",
|
||||
" if (args.include) rgArgs.push('--glob', String(args.include));",
|
||||
" rgArgs.push('--', String(args.pattern), search);",
|
||||
" const result = childProcess.spawnSync(executable, rgArgs, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });",
|
||||
" if (result.status !== 0 && result.status !== 1) throw new Error(result.stderr || ('ripgrep failed with exit ' + result.status));",
|
||||
" const rows = (result.stdout || '').split(/\\r?\\n/).filter(Boolean).map((line) => { const match = line.match(/^(.*?):(\\d+):(.*)$/); if (!match) return undefined; const filePath = path.resolve(match[1]); let mtime = 0; try { mtime = fs.statSync(filePath).mtimeMs; } catch {} return { path: filePath, line: Number(match[2]), text: match[3], mtime }; }).filter(Boolean);",
|
||||
" rows.sort((left, right) => right.mtime - left.mtime);",
|
||||
" const limit = 100;",
|
||||
" const truncated = rows.length > limit;",
|
||||
" const finalRows = truncated ? rows.slice(0, limit) : rows;",
|
||||
" if (finalRows.length === 0) return { output: 'No files found', metadata: { matches: 0, truncated: false } };",
|
||||
" const output = ['Found ' + rows.length + ' matches' + (truncated ? ' (showing first ' + limit + ')' : '')];",
|
||||
" let current = '';",
|
||||
" for (const row of finalRows) { if (current !== row.path) { if (current) output.push(''); current = row.path; output.push(row.path + ':'); } output.push(' Line ' + row.line + ': ' + limitLine(row.text)); }",
|
||||
" if (truncated) output.push('', '(Results truncated: showing ' + limit + ' of ' + rows.length + ' matches.)');",
|
||||
" return { output: output.join('\\n'), metadata: { matches: rows.length, truncated } };",
|
||||
"}",
|
||||
"function globTool() {",
|
||||
" if (!args.pattern) throw new Error('pattern is required');",
|
||||
" const search = normalizeRemotePath(args.path, workspacePath);",
|
||||
" const executable = fs.existsSync(rgPath) ? rgPath : 'rg';",
|
||||
" const result = childProcess.spawnSync(executable, ['--files', '--color', 'never', '--glob', String(args.pattern), '--', search], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });",
|
||||
" if (result.status !== 0 && result.status !== 1) throw new Error(result.stderr || ('ripgrep failed with exit ' + result.status));",
|
||||
" const files = (result.stdout || '').split(/\\r?\\n/).filter(Boolean).map((entry) => path.isAbsolute(entry) ? entry : path.resolve(search, entry)).map((filePath) => { let mtime = 0; try { mtime = fs.statSync(filePath).mtimeMs; } catch {} return { path: filePath, mtime }; }).sort((left, right) => right.mtime - left.mtime);",
|
||||
" const limit = 100;",
|
||||
" const truncated = files.length > limit;",
|
||||
" const finalFiles = truncated ? files.slice(0, limit) : files;",
|
||||
" const output = finalFiles.length ? finalFiles.map((file) => file.path) : ['No files found'];",
|
||||
" if (truncated) output.push('', '(Results are truncated: showing first ' + limit + ' results.)');",
|
||||
" return { output: output.join('\\n'), metadata: { count: finalFiles.length, truncated } };",
|
||||
"}",
|
||||
"function parsePatch(text) {",
|
||||
" const lines = String(text || '').replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n').split('\\n');",
|
||||
" if (lines[0] !== '*** Begin Patch') throw new Error('apply_patch verification failed: missing begin marker');",
|
||||
" const ops = [];",
|
||||
" let index = 1;",
|
||||
" while (index < lines.length) {",
|
||||
" const line = lines[index];",
|
||||
" if (line === '*** End Patch') break;",
|
||||
" if (line.startsWith('*** Add File: ')) { const file = line.slice(14); const body = []; index++; while (index < lines.length && !lines[index].startsWith('*** ')) { if (!lines[index].startsWith('+')) throw new Error('add file lines must start with +'); body.push(lines[index].slice(1)); index++; } ops.push({ type: 'add', file, body }); continue; }",
|
||||
" if (line.startsWith('*** Delete File: ')) { ops.push({ type: 'delete', file: line.slice(17) }); index++; continue; }",
|
||||
" if (line.startsWith('*** Update File: ')) { const file = line.slice(17); const body = []; let moveTo; index++; if (lines[index] && lines[index].startsWith('*** Move to: ')) { moveTo = lines[index].slice(13); index++; } while (index < lines.length && !lines[index].startsWith('*** ')) { body.push(lines[index]); index++; } ops.push({ type: 'update', file, moveTo, body }); continue; }",
|
||||
" throw new Error('apply_patch verification failed: unknown patch line ' + line);",
|
||||
" }",
|
||||
" if (ops.length === 0) throw new Error('patch rejected: empty patch');",
|
||||
" return ops;",
|
||||
"}",
|
||||
"function replaceHunk(content, oldLines, newLines) {",
|
||||
" const oldText = oldLines.join('\\n');",
|
||||
" const newText = newLines.join('\\n');",
|
||||
" const candidates = oldLines.length === 0 ? [['', newText]] : [[oldText + '\\n', newText + '\\n'], [oldText, newText]];",
|
||||
" for (const pair of candidates) { const from = pair[0]; const to = pair[1]; const position = from === '' ? content.length : content.indexOf(from); if (position !== -1) return content.slice(0, position) + to + content.slice(position + from.length); }",
|
||||
" throw new Error('apply_patch verification failed: hunk context not found');",
|
||||
"}",
|
||||
"function applyPatchTool() {",
|
||||
" const ops = parsePatch(args.patchText);",
|
||||
" const summary = [];",
|
||||
" for (const op of ops) {",
|
||||
" const filePath = normalizeRemotePath(op.file, workspacePath);",
|
||||
" if (op.type === 'add') { ensureParent(filePath); fs.writeFileSync(filePath, op.body.join('\\n') + (op.body.length ? '\\n' : '')); summary.push('A ' + path.relative(workspacePath, filePath)); continue; }",
|
||||
" if (op.type === 'delete') { fs.rmSync(filePath, { force: true }); summary.push('D ' + path.relative(workspacePath, filePath)); continue; }",
|
||||
" let content = fs.readFileSync(filePath, 'utf8');",
|
||||
" const groups = []; let current = [];",
|
||||
" for (const line of op.body) { if (line.startsWith('@@')) { if (current.length) groups.push(current); current = []; continue; } current.push(line); }",
|
||||
" if (current.length) groups.push(current);",
|
||||
" for (const group of groups) { const oldLines = []; const newLines = []; for (const line of group) { if (!line) continue; const marker = line[0]; const value = line.slice(1); if (marker === ' ') { oldLines.push(value); newLines.push(value); } else if (marker === '-') { oldLines.push(value); } else if (marker === '+') { newLines.push(value); } else if (line.startsWith('\\\\ No newline')) { } else { throw new Error('apply_patch verification failed: invalid hunk line ' + line); } } content = replaceHunk(content, oldLines, newLines); }",
|
||||
" const targetPath = op.moveTo ? normalizeRemotePath(op.moveTo, workspacePath) : filePath;",
|
||||
" ensureParent(targetPath); fs.writeFileSync(targetPath, content); if (op.moveTo) fs.rmSync(filePath, { force: true }); summary.push('M ' + path.relative(workspacePath, targetPath));",
|
||||
" }",
|
||||
" return { output: 'Success. Updated the following files:\\n' + summary.join('\\n'), metadata: { files: summary } };",
|
||||
"}",
|
||||
"try {",
|
||||
" const handlers = { bash: bashTool, read: readTool, write: writeTool, edit: editTool, grep: grepTool, glob: globTool, apply_patch: applyPatchTool };",
|
||||
" if (!handlers[toolName]) throw new Error('Unsupported Git.Zone OpenCode tool: ' + toolName);",
|
||||
" writeJson(handlers[toolName]());",
|
||||
"} catch (error) {",
|
||||
" console.error(error && error.stack ? error.stack : String(error));",
|
||||
" process.exit(1);",
|
||||
"}",
|
||||
].join('\n');
|
||||
|
||||
export const quoteShellArg = (value: string | number | boolean) => {
|
||||
const stringValue = String(value);
|
||||
|
||||
@@ -440,7 +440,6 @@ export const probeRemoteHost = async (
|
||||
`printf 'nodeVersion=%s\\n' "$(node --version 2>/dev/null || true)"`,
|
||||
`printf 'pnpmVersion=%s\\n' "$(pnpm --version 2>/dev/null || true)"`,
|
||||
`printf 'gitVersion=%s\\n' "$(git --version 2>/dev/null || true)"`,
|
||||
`printf 'opencodeVersion=%s\\n' "$(opencode --version 2>/dev/null || true)"`,
|
||||
].join('; ');
|
||||
|
||||
const result = await runSshCommand(target, command, options);
|
||||
@@ -460,7 +459,6 @@ export const probeRemoteHost = async (
|
||||
nodeVersion: fields.nodeVersion,
|
||||
pnpmVersion: fields.pnpmVersion,
|
||||
gitVersion: fields.gitVersion,
|
||||
opencodeVersion: fields.opencodeVersion,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user