Replace OpenCode with SmartAgent runtime
This commit is contained in:
@@ -0,0 +1,811 @@
|
||||
import type { IIdeSshTarget } from '@git.zone/ide-protocol';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IAgentProjectContext {
|
||||
instanceId: string;
|
||||
title: string;
|
||||
path: string;
|
||||
runtimeRoot: string;
|
||||
target: IIdeSshTarget;
|
||||
batchMode: boolean;
|
||||
}
|
||||
|
||||
export interface IAgentEventEnvelope {
|
||||
instanceId: string;
|
||||
event: IAgentEvent;
|
||||
}
|
||||
|
||||
type TAgentStatus =
|
||||
| { type: 'idle' }
|
||||
| { type: 'busy'; message?: string }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
interface IAgentEvent {
|
||||
type: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface IAgentMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
text: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface IAgentSessionDescriptor {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface IPendingPermission {
|
||||
id: string;
|
||||
key: string;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface IAgentSession extends IAgentSessionDescriptor {
|
||||
instanceId: string;
|
||||
messages: IAgentMessage[];
|
||||
status: TAgentStatus;
|
||||
abortController?: AbortController;
|
||||
pendingPermissions: Map<string, IPendingPermission>;
|
||||
rememberedPermissions: Set<string>;
|
||||
}
|
||||
|
||||
interface IPersistedAgentSession {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: IAgentMessage[];
|
||||
rememberedPermissions: string[];
|
||||
}
|
||||
|
||||
interface IPersistedAgentSessionsFile {
|
||||
version: 1;
|
||||
project: {
|
||||
title: string;
|
||||
path: string;
|
||||
target: {
|
||||
hostAlias: string;
|
||||
hostName?: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
};
|
||||
};
|
||||
sessions: IPersistedAgentSession[];
|
||||
}
|
||||
|
||||
interface IAgentSessionInput {
|
||||
instanceId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface ICreateAgentSessionInput {
|
||||
instanceId?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface IAgentPromptInput extends IAgentSessionInput {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface IAgentPermissionResponseInput extends IAgentSessionInput {
|
||||
permissionId?: string;
|
||||
response?: 'once' | 'always' | 'reject';
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
type TEmitAgentEvent = (payload: IAgentEventEnvelope) => void;
|
||||
type TResolveProjectContext = (instanceId: string) => IAgentProjectContext | undefined;
|
||||
|
||||
interface IOpenAiMaxIdTokenInfo {
|
||||
chatgptAccountId?: string;
|
||||
expiresAt?: string;
|
||||
rawJwt: string;
|
||||
}
|
||||
|
||||
interface IOpenAiMaxTokenData {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken: string;
|
||||
idTokenInfo: IOpenAiMaxIdTokenInfo;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
interface ILooseSmartAiModule {
|
||||
getModel: (options: { provider: 'openai'; model: string; apiKey?: string; openAiMaxAuth?: IOpenAiMaxTokenData }) => unknown;
|
||||
parseOpenAiMaxIdToken: (idToken: string) => IOpenAiMaxIdTokenInfo;
|
||||
refreshOpenAiMaxTokenData: (tokenData: IOpenAiMaxTokenData) => Promise<IOpenAiMaxTokenData>;
|
||||
}
|
||||
|
||||
interface ILooseZodBuilder {
|
||||
default: (value: unknown) => ILooseZodBuilder;
|
||||
describe: (description: string) => ILooseZodBuilder;
|
||||
optional: () => ILooseZodBuilder;
|
||||
}
|
||||
|
||||
interface ILooseSmartAgentModule {
|
||||
runAgent: (options: {
|
||||
model: unknown;
|
||||
prompt: string;
|
||||
system?: string;
|
||||
messages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
tools?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
maxSteps?: number;
|
||||
cache?: 'auto' | false;
|
||||
abort?: AbortSignal;
|
||||
onToken?: (delta: string) => void;
|
||||
onToolCall?: (toolName: string, toolInput: unknown) => void;
|
||||
onToolResult?: (toolName: string, toolResult: unknown) => void;
|
||||
}) => Promise<{ text?: string }>;
|
||||
tool: <TInput extends object>(options: {
|
||||
description: string;
|
||||
inputSchema: unknown;
|
||||
execute: (input: TInput) => Promise<string> | string;
|
||||
}) => unknown;
|
||||
truncateOutput: (text: string, options?: { maxLines?: number; maxBytes?: number }) => { content: string; truncated: boolean; notice?: string };
|
||||
z: {
|
||||
object: (shape: Record<string, unknown>) => unknown;
|
||||
number: () => ILooseZodBuilder;
|
||||
string: () => ILooseZodBuilder;
|
||||
};
|
||||
}
|
||||
|
||||
const smartAgentPackageName: string = '@push.rocks/smartagent';
|
||||
const smartAiPackageName: string = '@push.rocks/smartai';
|
||||
|
||||
let smartAgentModulePromise: Promise<ILooseSmartAgentModule> | undefined;
|
||||
let smartAiModulePromise: Promise<ILooseSmartAiModule> | undefined;
|
||||
|
||||
const loadSmartAgent = (): Promise<ILooseSmartAgentModule> => {
|
||||
smartAgentModulePromise ??= import(smartAgentPackageName).then((module: unknown) => module as ILooseSmartAgentModule);
|
||||
return smartAgentModulePromise;
|
||||
};
|
||||
|
||||
const loadSmartAi = (): Promise<ILooseSmartAiModule> => {
|
||||
smartAiModulePromise ??= import(smartAiPackageName).then((module: unknown) => module as ILooseSmartAiModule);
|
||||
return smartAiModulePromise;
|
||||
};
|
||||
|
||||
export class GitZoneAgentRuntime {
|
||||
private readonly sessionsByInstance = new Map<string, Map<string, IAgentSession>>();
|
||||
private readonly sessionLoadPromises = new Map<string, Promise<Map<string, IAgentSession>>>();
|
||||
|
||||
constructor(
|
||||
private readonly emitEvent: TEmitAgentEvent,
|
||||
private readonly resolveProjectContext: TResolveProjectContext,
|
||||
private readonly persistenceRoot = path.join(os.homedir(), '.git.zone', 'ide', 'agent-sessions'),
|
||||
) {}
|
||||
|
||||
async listSessions(input: IAgentSessionInput): Promise<IAgentSessionDescriptor[]> {
|
||||
const instanceId = requireString(input.instanceId, 'Agent instance id');
|
||||
return [...(await this.ensureInstanceSessions(instanceId)).values()].map(toSessionDescriptor);
|
||||
}
|
||||
|
||||
async createSession(input: ICreateAgentSessionInput): Promise<IAgentSessionDescriptor> {
|
||||
const instanceId = requireString(input.instanceId, 'Agent instance id');
|
||||
await this.ensureInstanceSessions(instanceId);
|
||||
const now = new Date().toISOString();
|
||||
const session: IAgentSession = {
|
||||
id: randomId('agent'),
|
||||
instanceId,
|
||||
title: trimOptional(input.title) ?? 'Git.Zone IDE Session',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messages: [],
|
||||
status: { type: 'idle' },
|
||||
pendingPermissions: new Map(),
|
||||
rememberedPermissions: new Set(),
|
||||
};
|
||||
this.sessionsByInstance.get(instanceId)!.set(session.id, session);
|
||||
await this.persistInstanceSessions(instanceId);
|
||||
this.emit(instanceId, 'session.created', { session: toSessionDescriptor(session), sessionId: session.id });
|
||||
return toSessionDescriptor(session);
|
||||
}
|
||||
|
||||
async getMessages(input: IAgentSessionInput & { limit?: number }): Promise<IAgentMessage[]> {
|
||||
const session = await this.requireSession(input);
|
||||
const limit = input.limit && Number.isInteger(input.limit) && input.limit > 0 ? input.limit : session.messages.length;
|
||||
return session.messages.slice(-limit);
|
||||
}
|
||||
|
||||
async getSessionStatus(input: IAgentSessionInput): Promise<Record<string, TAgentStatus>> {
|
||||
const instanceId = requireString(input.instanceId, 'Agent instance id');
|
||||
const result: Record<string, TAgentStatus> = {};
|
||||
for (const session of (await this.ensureInstanceSessions(instanceId)).values()) {
|
||||
result[session.id] = session.status;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getChildren(_input: IAgentSessionInput): unknown[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async prompt(input: IAgentPromptInput): Promise<IAgentMessage> {
|
||||
const session = await this.requireSession(input);
|
||||
const context = this.requireProjectContext(session.instanceId);
|
||||
const text = requireString(input.text, 'Prompt text');
|
||||
if (session.abortController) {
|
||||
throw new Error('Agent session already has a running prompt.');
|
||||
}
|
||||
|
||||
const userMessage = this.addMessage(session, 'user', text);
|
||||
this.emit(session.instanceId, 'message.created', { sessionId: session.id, message: userMessage });
|
||||
const assistantMessage = this.addMessage(session, 'assistant', '');
|
||||
this.emit(session.instanceId, 'message.created', { sessionId: session.id, message: assistantMessage });
|
||||
|
||||
const abortController = new AbortController();
|
||||
session.abortController = abortController;
|
||||
this.setStatus(session, { type: 'busy', message: 'Running SmartAgent' });
|
||||
|
||||
try {
|
||||
const smartagent = await loadSmartAgent();
|
||||
const model = await this.createModel();
|
||||
const result = await smartagent.runAgent({
|
||||
model,
|
||||
prompt: text,
|
||||
system: createSystemPrompt(context),
|
||||
messages: toModelMessages(session.messages.filter((message) => message.id !== userMessage.id && message.id !== assistantMessage.id)),
|
||||
tools: this.createRemoteTools(smartagent, context, session),
|
||||
sessionId: session.id,
|
||||
maxSteps: 20,
|
||||
cache: 'auto',
|
||||
abort: abortController.signal,
|
||||
onToken: (delta: string) => {
|
||||
assistantMessage.text += delta;
|
||||
assistantMessage.updatedAt = new Date().toISOString();
|
||||
this.emit(session.instanceId, 'message.updated', { sessionId: session.id, message: assistantMessage });
|
||||
},
|
||||
onToolCall: (toolName: string, toolInput: unknown) => {
|
||||
const toolMessage = this.addMessage(session, 'tool', `Tool: ${toolName}\n${formatJson(toolInput)}`);
|
||||
this.emit(session.instanceId, 'tool.started', { sessionId: session.id, message: toolMessage, toolName, input: toolInput });
|
||||
},
|
||||
onToolResult: (toolName: string, toolResult: unknown) => {
|
||||
const toolMessage = this.addMessage(session, 'tool', `Tool result: ${toolName}\n${formatJson(toolResult)}`);
|
||||
this.emit(session.instanceId, 'tool.finished', { sessionId: session.id, message: toolMessage, toolName, output: toolResult });
|
||||
},
|
||||
});
|
||||
|
||||
assistantMessage.text = result.text || assistantMessage.text;
|
||||
assistantMessage.updatedAt = new Date().toISOString();
|
||||
session.updatedAt = assistantMessage.updatedAt;
|
||||
this.emit(session.instanceId, 'message.updated', { sessionId: session.id, message: assistantMessage });
|
||||
this.setStatus(session, { type: 'idle' });
|
||||
return assistantMessage;
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
assistantMessage.text = `${assistantMessage.text}\n\nStopped.`.trim();
|
||||
this.setStatus(session, { type: 'idle' });
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
assistantMessage.text = `${assistantMessage.text}\n\nAgent error: ${message}`.trim();
|
||||
this.setStatus(session, { type: 'error', message });
|
||||
}
|
||||
assistantMessage.updatedAt = new Date().toISOString();
|
||||
this.emit(session.instanceId, 'message.updated', { sessionId: session.id, message: assistantMessage });
|
||||
throw error;
|
||||
} finally {
|
||||
session.abortController = undefined;
|
||||
await this.persistInstanceSessions(session.instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
async abort(input: IAgentSessionInput): Promise<{ aborted: boolean }> {
|
||||
const session = await this.requireSession(input);
|
||||
if (!session.abortController) {
|
||||
return { aborted: false };
|
||||
}
|
||||
session.abortController.abort();
|
||||
this.rejectPendingPermissions(session, new Error('Agent run was stopped.'));
|
||||
return { aborted: true };
|
||||
}
|
||||
|
||||
async respondToPermission(input: IAgentPermissionResponseInput): Promise<{ accepted: boolean }> {
|
||||
const session = await this.requireSession(input);
|
||||
const permissionId = requireString(input.permissionId, 'Permission id');
|
||||
const pending = session.pendingPermissions.get(permissionId);
|
||||
if (!pending) {
|
||||
return { accepted: false };
|
||||
}
|
||||
|
||||
session.pendingPermissions.delete(permissionId);
|
||||
const response = input.response ?? 'reject';
|
||||
if (response === 'reject') {
|
||||
pending.reject(new Error('Permission rejected.'));
|
||||
this.emit(session.instanceId, 'permission.replied', { sessionId: session.id, requestID: permissionId, response });
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
if (response === 'always' || input.remember) {
|
||||
session.rememberedPermissions.add(pending.key);
|
||||
await this.persistInstanceSessions(session.instanceId);
|
||||
}
|
||||
pending.resolve();
|
||||
this.emit(session.instanceId, 'permission.replied', { sessionId: session.id, requestID: permissionId, response });
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const instanceSessions of this.sessionsByInstance.values()) {
|
||||
for (const session of instanceSessions.values()) {
|
||||
session.abortController?.abort();
|
||||
this.rejectPendingPermissions(session, new Error('Agent runtime is shutting down.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInstanceSessions(instanceId: string): Promise<Map<string, IAgentSession>> {
|
||||
const existing = this.sessionsByInstance.get(instanceId);
|
||||
if (existing) return existing;
|
||||
|
||||
const existingLoad = this.sessionLoadPromises.get(instanceId);
|
||||
if (existingLoad) return existingLoad;
|
||||
|
||||
const loadPromise = this.loadInstanceSessions(instanceId);
|
||||
this.sessionLoadPromises.set(instanceId, loadPromise);
|
||||
try {
|
||||
const sessions = await loadPromise;
|
||||
this.sessionsByInstance.set(instanceId, sessions);
|
||||
return sessions;
|
||||
} finally {
|
||||
this.sessionLoadPromises.delete(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
private async requireSession(input: IAgentSessionInput): Promise<IAgentSession> {
|
||||
const instanceId = requireString(input.instanceId, 'Agent instance id');
|
||||
const sessionId = requireString(input.sessionId, 'Agent session id');
|
||||
const session = (await this.ensureInstanceSessions(instanceId)).get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Agent session not found: ${sessionId}`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private requireProjectContext(instanceId: string): IAgentProjectContext {
|
||||
const context = this.resolveProjectContext(instanceId);
|
||||
if (!context) {
|
||||
throw new Error(`Project instance not found for agent runtime: ${instanceId}`);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private setStatus(session: IAgentSession, status: TAgentStatus): void {
|
||||
session.status = status;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
this.emit(session.instanceId, 'session.updated', { sessionId: session.id, status });
|
||||
}
|
||||
|
||||
private addMessage(session: IAgentSession, role: IAgentMessage['role'], text: string): IAgentMessage {
|
||||
const now = new Date().toISOString();
|
||||
const message: IAgentMessage = {
|
||||
id: randomId('message'),
|
||||
role,
|
||||
text,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
session.messages.push(message);
|
||||
session.updatedAt = now;
|
||||
return message;
|
||||
}
|
||||
|
||||
private emit(instanceId: string, type: string, data?: Record<string, unknown>): void {
|
||||
this.emitEvent({ instanceId, event: { type, data } });
|
||||
}
|
||||
|
||||
private async loadInstanceSessions(instanceId: string): Promise<Map<string, IAgentSession>> {
|
||||
const context = this.requireProjectContext(instanceId);
|
||||
const sessions = new Map<string, IAgentSession>();
|
||||
let parsed: IPersistedAgentSessionsFile;
|
||||
try {
|
||||
parsed = JSON.parse(await fs.readFile(this.persistenceFilePath(context), 'utf8')) as IPersistedAgentSessionsFile;
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT') {
|
||||
return sessions;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed.sessions)) {
|
||||
return sessions;
|
||||
}
|
||||
|
||||
for (const persisted of parsed.sessions) {
|
||||
const session = normalizePersistedSession(instanceId, persisted);
|
||||
if (session) {
|
||||
sessions.set(session.id, session);
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
private async persistInstanceSessions(instanceId: string): Promise<void> {
|
||||
const context = this.requireProjectContext(instanceId);
|
||||
const sessions = this.sessionsByInstance.get(instanceId);
|
||||
if (!sessions) return;
|
||||
|
||||
const filePath = this.persistenceFilePath(context);
|
||||
const payload: IPersistedAgentSessionsFile = {
|
||||
version: 1,
|
||||
project: {
|
||||
title: context.title,
|
||||
path: context.path,
|
||||
target: {
|
||||
hostAlias: context.target.hostAlias,
|
||||
hostName: context.target.hostName,
|
||||
user: context.target.user,
|
||||
port: context.target.port,
|
||||
},
|
||||
},
|
||||
sessions: [...sessions.values()].map(toPersistedSession),
|
||||
};
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(tempPath, `${JSON.stringify(payload, undefined, 2)}\n`, { mode: 0o600 });
|
||||
await fs.rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
private persistenceFilePath(context: IAgentProjectContext): string {
|
||||
return path.join(this.persistenceRoot, `${persistenceKeyForContext(context)}.json`);
|
||||
}
|
||||
|
||||
private async createModel(): Promise<unknown> {
|
||||
const smartai = await loadSmartAi();
|
||||
const model = process.env.GITZONE_IDE_AGENT_MODEL || 'gpt-5.5';
|
||||
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_TOKEN;
|
||||
if (apiKey) {
|
||||
return smartai.getModel({ provider: 'openai', model, apiKey });
|
||||
}
|
||||
|
||||
const openAiMaxAuth = await loadOpenAiMaxAuth();
|
||||
if (openAiMaxAuth) {
|
||||
return smartai.getModel({ provider: 'openai', model, openAiMaxAuth });
|
||||
}
|
||||
|
||||
throw new Error('No OpenAI credentials available. Set OPENAI_API_KEY/OPENAI_TOKEN or sign in with ChatGPT using Codex device auth.');
|
||||
}
|
||||
|
||||
private createRemoteTools(smartagent: ILooseSmartAgentModule, context: IAgentProjectContext, session: IAgentSession): Record<string, unknown> {
|
||||
return {
|
||||
run_command: smartagent.tool({
|
||||
description: 'Run a shell command on the remote SSH host inside the active project directory. Requires user permission.',
|
||||
inputSchema: smartagent.z.object({
|
||||
command: smartagent.z.string().describe('Shell command to run'),
|
||||
timeoutMs: smartagent.z.number().optional().describe('Timeout in milliseconds'),
|
||||
}),
|
||||
execute: async ({ command, timeoutMs }: { command: string; timeoutMs?: number }) => {
|
||||
await this.requirePermission(session, context, 'shell', 'Run remote command', { command });
|
||||
const result = await runRemoteShellCommand(context, command, timeoutMs ?? 120000);
|
||||
return truncateRemoteResult(result, smartagent);
|
||||
},
|
||||
}),
|
||||
list_directory: smartagent.tool({
|
||||
description: 'List files in a directory under the active remote project. Paths may be relative to the project root.',
|
||||
inputSchema: smartagent.z.object({
|
||||
path: smartagent.z.string().default('.').describe('Directory path'),
|
||||
}),
|
||||
execute: async ({ path: directoryPath }: { path: string }) => {
|
||||
const result = await runRemoteNodeTool(context, listDirectoryScript, {
|
||||
GITZONE_AGENT_PATH: directoryPath,
|
||||
});
|
||||
return truncateRemoteResult(result, smartagent);
|
||||
},
|
||||
}),
|
||||
read_file: smartagent.tool({
|
||||
description: 'Read a UTF-8 file under the active remote project. Paths may be relative to the project root.',
|
||||
inputSchema: smartagent.z.object({
|
||||
path: smartagent.z.string().describe('File path'),
|
||||
startLine: smartagent.z.number().optional().describe('First line, 1-indexed'),
|
||||
endLine: smartagent.z.number().optional().describe('Last line, 1-indexed'),
|
||||
}),
|
||||
execute: async ({ path: filePath, startLine, endLine }: { path: string; startLine?: number; endLine?: number }) => {
|
||||
const result = await runRemoteNodeTool(context, readFileScript, {
|
||||
GITZONE_AGENT_PATH: filePath,
|
||||
GITZONE_AGENT_START_LINE: startLine ? String(startLine) : '',
|
||||
GITZONE_AGENT_END_LINE: endLine ? String(endLine) : '',
|
||||
});
|
||||
return truncateRemoteResult(result, smartagent);
|
||||
},
|
||||
}),
|
||||
write_file: smartagent.tool({
|
||||
description: 'Write a UTF-8 file under the active remote project. Requires user permission.',
|
||||
inputSchema: smartagent.z.object({
|
||||
path: smartagent.z.string().describe('File path'),
|
||||
content: smartagent.z.string().describe('Complete file content to write'),
|
||||
}),
|
||||
execute: async ({ path: filePath, content }: { path: string; content: string }) => {
|
||||
await this.requirePermission(session, context, 'write', 'Write remote file', { path: filePath, bytes: Buffer.byteLength(content) });
|
||||
const result = await runRemoteNodeTool(context, writeFileScript, {
|
||||
GITZONE_AGENT_PATH: filePath,
|
||||
GITZONE_AGENT_CONTENT_B64: Buffer.from(content, 'utf8').toString('base64'),
|
||||
});
|
||||
return truncateRemoteResult(result, smartagent);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async requirePermission(
|
||||
session: IAgentSession,
|
||||
_context: IAgentProjectContext,
|
||||
type: string,
|
||||
title: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const key = `${type}:${JSON.stringify(metadata)}`;
|
||||
if (session.rememberedPermissions.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = randomId('permission');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.pendingPermissions.set(id, { id, key, resolve, reject });
|
||||
this.emit(session.instanceId, 'permission.asked', {
|
||||
requestID: id,
|
||||
sessionId: session.id,
|
||||
title,
|
||||
type,
|
||||
metadata,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private rejectPendingPermissions(session: IAgentSession, error: Error): void {
|
||||
for (const pending of session.pendingPermissions.values()) {
|
||||
pending.reject(error);
|
||||
this.emit(session.instanceId, 'permission.replied', { sessionId: session.id, requestID: pending.id, response: 'reject' });
|
||||
}
|
||||
session.pendingPermissions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const toSessionDescriptor = (session: IAgentSession): IAgentSessionDescriptor => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
});
|
||||
|
||||
const toPersistedSession = (session: IAgentSession): IPersistedAgentSession => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
messages: session.messages,
|
||||
rememberedPermissions: [...session.rememberedPermissions],
|
||||
});
|
||||
|
||||
const normalizePersistedSession = (instanceId: string, persisted: IPersistedAgentSession): IAgentSession | undefined => {
|
||||
if (!persisted || typeof persisted !== 'object') return undefined;
|
||||
const id = stringValue(persisted.id);
|
||||
if (!id) return undefined;
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
instanceId,
|
||||
title: stringValue(persisted.title) ?? 'Git.Zone IDE Session',
|
||||
createdAt: stringValue(persisted.createdAt) ?? now,
|
||||
updatedAt: stringValue(persisted.updatedAt) ?? now,
|
||||
messages: Array.isArray(persisted.messages)
|
||||
? persisted.messages.map(normalizePersistedMessage).filter((message): message is IAgentMessage => !!message)
|
||||
: [],
|
||||
status: { type: 'idle' },
|
||||
pendingPermissions: new Map(),
|
||||
rememberedPermissions: new Set(Array.isArray(persisted.rememberedPermissions) ? persisted.rememberedPermissions.filter((item) => typeof item === 'string') : []),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePersistedMessage = (message: IAgentMessage): IAgentMessage | undefined => {
|
||||
if (!message || typeof message !== 'object') return undefined;
|
||||
const id = stringValue(message.id);
|
||||
const role = message.role;
|
||||
const text = typeof message.text === 'string' ? message.text : undefined;
|
||||
const createdAt = stringValue(message.createdAt);
|
||||
if (!id || !isAgentMessageRole(role) || text === undefined || !createdAt) return undefined;
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
text,
|
||||
createdAt,
|
||||
updatedAt: stringValue(message.updatedAt),
|
||||
};
|
||||
};
|
||||
|
||||
const isAgentMessageRole = (role: unknown): role is IAgentMessage['role'] => {
|
||||
return role === 'user' || role === 'assistant' || role === 'tool';
|
||||
};
|
||||
|
||||
const persistenceKeyForContext = (context: IAgentProjectContext): string => {
|
||||
return plugins.crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify({
|
||||
hostAlias: context.target.hostAlias,
|
||||
hostName: context.target.hostName,
|
||||
path: context.path,
|
||||
port: context.target.port,
|
||||
user: context.target.user,
|
||||
}))
|
||||
.digest('hex');
|
||||
};
|
||||
|
||||
const toModelMessages = (messages: IAgentMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> => {
|
||||
return messages
|
||||
.filter((message) => (message.role === 'user' || message.role === 'assistant') && message.text.trim())
|
||||
.map((message) => ({ role: message.role as 'user' | 'assistant', content: message.text }));
|
||||
};
|
||||
|
||||
const createSystemPrompt = (context: IAgentProjectContext) => [
|
||||
'You are the Git.Zone IDE SmartAgent for a remote SSH workspace.',
|
||||
`Project title: ${context.title}`,
|
||||
`Remote project root: ${context.path}`,
|
||||
'Use the provided remote tools for project inspection and changes. Do not assume local filesystem access is the remote workspace.',
|
||||
'Prefer small, focused changes. Ask for clarification if the user request is ambiguous or risky.',
|
||||
].join('\n');
|
||||
|
||||
const runRemoteShellCommand = async (context: IAgentProjectContext, command: string, timeoutMs: number) => {
|
||||
const remoteCommand = [
|
||||
'set -euo pipefail',
|
||||
`cd ${plugins.ideServerInstaller.quoteRemotePath(context.path)}`,
|
||||
command,
|
||||
].join('\n');
|
||||
return plugins.ideSsh.runSshCommand(context.target, remoteCommand, {
|
||||
batchMode: context.batchMode,
|
||||
timeoutMs,
|
||||
});
|
||||
};
|
||||
|
||||
const runRemoteNodeTool = async (
|
||||
context: IAgentProjectContext,
|
||||
script: string,
|
||||
env: Record<string, string>,
|
||||
timeoutMs = 60000,
|
||||
) => {
|
||||
const nodePath = plugins.ideServerInstaller.joinRemotePath(context.runtimeRoot, 'node/bin/node');
|
||||
const nodeLibPath = plugins.ideServerInstaller.joinRemotePath(context.runtimeRoot, 'node/lib');
|
||||
const remoteCommand = [
|
||||
'set -euo pipefail',
|
||||
`export LD_LIBRARY_PATH=${plugins.ideServerInstaller.quoteRemotePath(nodeLibPath)}:\${LD_LIBRARY_PATH:-}`,
|
||||
`export GITZONE_AGENT_ROOT=${plugins.ideServerInstaller.quoteRemotePath(context.path)}`,
|
||||
...Object.entries(env).map(([key, value]) => `export ${key}=${plugins.ideServerInstaller.quoteShellArg(value)}`),
|
||||
`${plugins.ideServerInstaller.quoteRemotePath(nodePath)} <<'GITZONE_IDE_AGENT_NODE'`,
|
||||
script,
|
||||
'GITZONE_IDE_AGENT_NODE',
|
||||
].join('\n');
|
||||
return plugins.ideSsh.runSshCommand(context.target, remoteCommand, {
|
||||
batchMode: context.batchMode,
|
||||
timeoutMs,
|
||||
});
|
||||
};
|
||||
|
||||
const truncateRemoteResult = (result: plugins.ideSsh.ISshRunResult, smartagent: ILooseSmartAgentModule): string => {
|
||||
const output = result.exitCode === 0
|
||||
? result.stdout
|
||||
: `Exit code: ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`;
|
||||
return smartagent.truncateOutput(output).content;
|
||||
};
|
||||
|
||||
const remotePathPrelude = `
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const root = path.resolve(process.env.GITZONE_AGENT_ROOT || '.');
|
||||
const requested = process.env.GITZONE_AGENT_PATH || '.';
|
||||
const target = path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(root, requested);
|
||||
if (target !== root && !target.startsWith(root + path.sep)) {
|
||||
throw new Error('Path is outside the active project root: ' + requested);
|
||||
}
|
||||
`;
|
||||
|
||||
const listDirectoryScript = `${remotePathPrelude}
|
||||
const entries = fs.readdirSync(target, { withFileTypes: true })
|
||||
.map((entry) => entry.name + (entry.isDirectory() ? '/' : ''))
|
||||
.sort();
|
||||
console.log(entries.join('\n'));
|
||||
`;
|
||||
|
||||
const readFileScript = `${remotePathPrelude}
|
||||
const text = fs.readFileSync(target, 'utf8');
|
||||
const startLine = Number.parseInt(process.env.GITZONE_AGENT_START_LINE || '', 10);
|
||||
const endLine = Number.parseInt(process.env.GITZONE_AGENT_END_LINE || '', 10);
|
||||
if (Number.isFinite(startLine) || Number.isFinite(endLine)) {
|
||||
const lines = text.split('\n');
|
||||
const start = Number.isFinite(startLine) && startLine > 0 ? startLine - 1 : 0;
|
||||
const end = Number.isFinite(endLine) && endLine > 0 ? endLine : lines.length;
|
||||
console.log(lines.slice(start, end).join('\n'));
|
||||
} else {
|
||||
console.log(text);
|
||||
}
|
||||
`;
|
||||
|
||||
const writeFileScript = `${remotePathPrelude}
|
||||
const content = Buffer.from(process.env.GITZONE_AGENT_CONTENT_B64 || '', 'base64').toString('utf8');
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content, 'utf8');
|
||||
console.log('Written ' + Buffer.byteLength(content, 'utf8') + ' bytes to ' + path.relative(root, target));
|
||||
`;
|
||||
|
||||
const loadOpenAiMaxAuth = async (): Promise<IOpenAiMaxTokenData | undefined> => {
|
||||
const smartai = await loadSmartAi();
|
||||
const localAuthPath = path.join(os.homedir(), '.git.zone', 'ide', 'openai-max-auth.json');
|
||||
const codexAuthPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
return await loadOpenAiMaxAuthFromPath(smartai, localAuthPath, true) ?? await loadOpenAiMaxAuthFromPath(smartai, codexAuthPath, false);
|
||||
};
|
||||
|
||||
const loadOpenAiMaxAuthFromPath = async (smartai: ILooseSmartAiModule, filePath: string, allowRefreshWrite: boolean): Promise<IOpenAiMaxTokenData | undefined> => {
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as Record<string, unknown>;
|
||||
const tokenData = normalizeOpenAiMaxAuth(smartai, parsed);
|
||||
if (!tokenData) return undefined;
|
||||
if (allowRefreshWrite && shouldRefreshToken(tokenData)) {
|
||||
const refreshed = await smartai.refreshOpenAiMaxTokenData(tokenData);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(refreshed, undefined, 2)}\n`, { mode: 0o600 });
|
||||
return refreshed;
|
||||
}
|
||||
return tokenData;
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT') return undefined;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeOpenAiMaxAuth = (smartai: ILooseSmartAiModule, input: Record<string, unknown>): IOpenAiMaxTokenData | undefined => {
|
||||
const tokens = (input.tokens && typeof input.tokens === 'object' ? input.tokens : input) as Record<string, unknown>;
|
||||
const accessToken = stringValue(tokens.accessToken) ?? stringValue(tokens.access_token);
|
||||
const refreshToken = stringValue(tokens.refreshToken) ?? stringValue(tokens.refresh_token);
|
||||
const idToken = stringValue(tokens.idToken) ?? stringValue(tokens.id_token) ?? stringValue((tokens.idTokenInfo as Record<string, unknown> | undefined)?.rawJwt);
|
||||
if (!accessToken || !refreshToken || !idToken) return undefined;
|
||||
const idTokenInfo = smartai.parseOpenAiMaxIdToken(idToken);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
idTokenInfo,
|
||||
accountId: stringValue(tokens.accountId) ?? stringValue(tokens.account_id) ?? idTokenInfo.chatgptAccountId,
|
||||
};
|
||||
};
|
||||
|
||||
const shouldRefreshToken = (tokenData: IOpenAiMaxTokenData): boolean => {
|
||||
if (!tokenData.idTokenInfo.expiresAt) return false;
|
||||
return Date.parse(tokenData.idTokenInfo.expiresAt) - Date.now() < 5 * 60 * 1000;
|
||||
};
|
||||
|
||||
const requireString = (value: string | undefined, label: string): string => {
|
||||
const trimmed = trimOptional(value);
|
||||
if (!trimmed) {
|
||||
throw new Error(`${label} is required.`);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const trimOptional = (value: string | undefined): string | undefined => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
const stringValue = (value: unknown): string | undefined => {
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
};
|
||||
|
||||
const randomId = (prefix: string): string => `${prefix}-${plugins.crypto.randomBytes(12).toString('hex')}`;
|
||||
|
||||
const formatJson = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, undefined, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as http from 'node:http';
|
||||
import * as electron from 'electron';
|
||||
import * as ideOpenCodeBridge from '@git.zone/ide-opencode-bridge';
|
||||
import * as ideServerInstaller from '@git.zone/ide-server-installer';
|
||||
import * as ideSsh from '@git.zone/ide-ssh';
|
||||
|
||||
export { crypto, electron, http, ideOpenCodeBridge, ideServerInstaller, ideSsh };
|
||||
export { crypto, electron, ideServerInstaller, ideSsh };
|
||||
|
||||
Reference in New Issue
Block a user