feat: add structured agent tools and improve workspace chat UX

This commit is contained in:
2026-10-19 11:30:23 +09:00
parent 098af30fa7
commit cc589fd8cf
35 changed files with 2831 additions and 247 deletions
+133
View File
@@ -0,0 +1,133 @@
import { createHash, randomUUID } from 'node:crypto'
import { chmod, lstat, readdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { safeAgentPath, type AgentCheckedChange, type AgentToolRequest } from '../shared/agentTools'
const textFile = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$/i
export const agentFileHash = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex')
async function checkedPath(root: string, path: string, allowMissing = false): Promise<string> {
safeAgentPath(path)
const base = await realpath(root)
const absolute = resolve(base, path)
if (!absolute.startsWith(base + sep)) throw new Error('모듈 경로를 벗어날 수 없습니다.')
let current = base
const parts = relative(base, absolute).split(sep)
for (let i = 0; i < parts.length; i++) {
current = join(current, parts[i])
try {
const info = await lstat(current)
if (info.isSymbolicLink() || (i < parts.length - 1 && !info.isDirectory())) throw new Error('심볼릭 링크와 일반 파일이 아닌 경로는 사용할 수 없습니다.')
} catch (error) {
if (allowMissing && i === parts.length - 1 && (error as NodeJS.ErrnoException).code === 'ENOENT') return absolute
throw error
}
}
return absolute
}
async function readText(root: string, path: string): Promise<string> {
if (!textFile.test(path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 조회·수정할 수 있습니다.')
const absolute = await checkedPath(root, path)
const info = await lstat(absolute)
if (!info.isFile() || info.size > 1_048_576) throw new Error('일반 텍스트 파일(1MB 이하)만 조회할 수 있습니다.')
const text = await readFile(absolute, 'utf8')
if (text.includes('\0') || text.includes('\uFFFD')) throw new Error('UTF-8 텍스트 파일이 아닙니다.')
return text
}
export async function runAgentFileTool(root: string, request: AgentToolRequest): Promise<{ data: unknown; truncated: boolean }> {
const args = request.args ?? {}
if (request.name === 'files.read') {
const path = safeAgentPath(args.path)
try {
const content = await readText(root, path)
const lines = content.split('\n')
const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
const startLine = args.startLine ?? 1
const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
// A missing leaf in an existing directory is evidence for a new file.
await checkedPath(root, path, true)
return { data: { path, exists: false, hash: null, content: '', complete: true }, truncated: false }
}
}
const prefix = safeAgentPath(args.path, true)
if (prefix) await checkedPath(root, prefix)
const paths: string[] = []
const matches: Array<{ path: string; line: number; text: string }> = []
let visited = 0
let scanned = 0
let truncated = false
const visit = async (directory: string, depth: number): Promise<void> => {
if (depth > 10 || visited >= 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; return }
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
const path = relative(root, join(directory, entry.name)).split(sep).join('/')
if (prefix && path !== prefix && !path.startsWith(prefix + '/') && !prefix.startsWith(path + '/')) continue
if (++visited > 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; break }
try { safeAgentPath(path) } catch { continue }
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) { await visit(join(directory, entry.name), depth + 1); continue }
if (!entry.isFile() || !textFile.test(entry.name)) continue
if (prefix && path !== prefix && !path.startsWith(prefix + '/')) continue
if (request.name === 'files.list') { paths.push(path); continue }
let content: string
try { content = await readText(root, path) } catch { continue }
scanned += content.length
for (const [index, line] of content.split('\n').entries()) {
if (line.toLowerCase().includes(args.query!.toLowerCase())) {
matches.push({ path, line: index + 1, text: line.slice(0, 500) })
if (matches.length >= 80) { truncated = true; break }
}
}
}
}
await visit(root, 0)
return { data: request.name === 'files.list' ? { paths } : { query: args.query, matches }, truncated }
}
export async function applyAgentFiles(changes: Array<AgentCheckedChange & { root: string }>): Promise<void> {
if (!changes.length || changes.length > 24 || changes.reduce((n, c) => n + c.content.length, 0) > 1_000_000) throw new Error('변경 파일 용량을 확인하세요.')
const prepared: Array<{ path: string; temp: string; original: string | null; mode: number }> = []
const committed: typeof prepared = []
const seen = new Set<string>()
try {
for (const change of changes) {
if (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash)) throw new Error('원본 파일 조회 정보가 없습니다. 다시 분석해주세요.')
if (!textFile.test(change.path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 수정할 수 있습니다.')
const path = await checkedPath(change.root, change.path, true)
if (seen.has(path)) throw new Error('중복된 변경 파일입니다.')
seen.add(path)
let original: string | null = null
let mode = 0o644
try { original = await readText(change.root, change.path); mode = (await lstat(path)).mode } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if ((original === null ? null : agentFileHash(original)) !== change.expectedHash) throw new Error(`분석 이후 파일이 변경되었습니다: ${change.moduleName}/${change.path}. 다시 분석해주세요.`)
prepared.push({ path, original, mode, temp: join(dirname(path), `.lms-agent-${randomUUID()}.tmp`) })
}
for (const [index, file] of prepared.entries()) {
await writeFile(file.temp, changes[index].content, { encoding: 'utf8', mode: file.mode, flag: 'wx' })
await chmod(file.temp, file.mode & 0o777)
}
// Recheck the entire batch immediately before committing any file.
for (const file of prepared) {
let latest: string | null = null
try { latest = await readFile(file.path, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
if (latest !== file.original) throw new Error('적용 직전 파일이 변경되었습니다. 다시 분석해주세요.')
}
for (const file of prepared) { await rename(file.temp, file.path); committed.push(file) }
} catch (error) {
const failures: string[] = []
for (const file of committed.reverse()) {
try { if (file.original === null) await unlink(file.path); else await writeFile(file.path, file.original, { mode: file.mode }) } catch { failures.push(file.path) }
}
if (failures.length) throw new Error(`일부 파일 복구에 실패했습니다: ${failures.join(', ')}. ${String(error)}`)
throw error
} finally {
await Promise.all(prepared.map(file => unlink(file.temp).catch(() => undefined)))
}
}
+181
View File
@@ -0,0 +1,181 @@
import type { AgentCheckedChange, AgentToolRequest } from '../shared/agentTools'
export function agentRemoteFilesScript(input: { root: string; request: AgentToolRequest } | { changes: Array<AgentCheckedChange & { root: string }> }): string {
const encoded = Buffer.from(JSON.stringify(input)).toString('base64')
return `python3 - <<'LMS_AGENT_FILES'
import sys
sys.argv = ['lms-agent', '${encoded}']
${agentRemoteFilesPython}
LMS_AGENT_FILES`
}
export const agentRemoteFilesPython = String.raw`
import base64, hashlib, json, os, re, stat, sys, tempfile
args = json.loads(base64.b64decode(sys.argv[1]))
blocked = {'node_modules', 'build', 'install', 'log', 'logs', '__pycache__', 'id_rsa', 'id_ed25519', 'credentials'}
text_pattern = re.compile(r'\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$', re.I)
def safe(value):
if not isinstance(value, str) or not value or len(value) > 500 or value.startswith('/') or any(c in value for c in '\\\x00\r\n'):
raise ValueError('Invalid source path')
if any(not p or p.startswith('.') or p.lower() in blocked or re.search(r'\.(pem|key|p12|pfx)$', p, re.I) for p in value.split('/')):
raise ValueError('Private or excluded source path')
return value
def checked(root, path, missing=False):
safe(path)
root = os.path.realpath(root)
current = root
parts = path.split('/')
for i, part in enumerate(parts):
current = os.path.join(current, part)
try:
info = os.lstat(current)
if stat.S_ISLNK(info.st_mode) or (i < len(parts)-1 and not stat.S_ISDIR(info.st_mode)):
raise ValueError('Symlink or non-directory source path')
except FileNotFoundError:
if not (missing and i == len(parts)-1):
raise
if os.path.commonpath([root, current]) != root:
raise ValueError('Source path escapes module')
return current
def read(root, path):
if not text_pattern.search(os.path.basename(path)):
raise ValueError('Only text source files are supported')
absolute = checked(root, path)
info = os.lstat(absolute)
if not stat.S_ISREG(info.st_mode) or info.st_size > 1048576:
raise ValueError('Only regular source files up to 1 MB are supported')
with open(absolute, 'rb') as fp:
content = fp.read(1048577)
if len(content) > 1048576 or b'\x00' in content:
raise ValueError('Binary or oversized file')
return content.decode('utf-8')
def digest(content):
return hashlib.sha256(content.encode('utf-8')).hexdigest()
if 'changes' in args:
changes = args['changes']
if not changes or len(changes) > 24 or sum(len(c['content']) for c in changes) > 1000000:
raise ValueError('Invalid change batch size')
prepared, committed, seen = [], [], set()
try:
for c in changes:
path = checked(c['root'], c['path'], True)
if path in seen or not text_pattern.search(os.path.basename(path)):
raise ValueError('Duplicate or unsupported change file')
seen.add(path)
try:
original = read(c['root'], c['path'])
mode = stat.S_IMODE(os.stat(path).st_mode)
except FileNotFoundError:
original, mode = None, 0o644
if (None if original is None else digest(original)) != c.get('expectedHash', 'missing'):
raise ValueError('File changed after analysis: ' + c['moduleName'] + '/' + c['path'])
prepared.append({'path': path, 'original': original, 'mode': mode, 'content': c['content'], 'temp': None})
for p in prepared:
fd, p['temp'] = tempfile.mkstemp(prefix='.lms-agent-', dir=os.path.dirname(p['path']))
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as fp:
fp.write(p['content'])
os.chmod(p['temp'], p['mode'])
for p in prepared:
try:
with open(p['path'], 'r', encoding='utf-8', newline='') as fp:
latest = fp.read()
except FileNotFoundError:
latest = None
if latest != p['original']:
raise ValueError('File changed immediately before apply')
for p in prepared:
os.replace(p['temp'], p['path'])
committed.append(p)
except Exception as error:
failed = []
for p in reversed(committed):
try:
if p['original'] is None:
os.unlink(p['path'])
else:
with open(p['path'], 'w', encoding='utf-8', newline='') as fp:
fp.write(p['original'])
os.chmod(p['path'], p['mode'])
except Exception:
failed.append(p['path'])
if failed:
raise ValueError('Rollback failed: ' + ', '.join(failed)) from error
raise
finally:
for p in prepared:
if p['temp'] and os.path.exists(p['temp']):
os.unlink(p['temp'])
print(json.dumps({'ok': True}))
else:
root = os.path.realpath(args['root'])
req = args['request']
options = req.get('args') or {}
name = req['name']
if name == 'files.read':
path = options['path']
try:
content = read(root, path)
lines = content.split('\n')
partial = 'startLine' in options or 'lineCount' in options or len(content) > 40000
start = options.get('startLine', 1)
selected = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)])[:40000] if partial else content
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': not partial, 'startLine': start, 'totalLines': len(lines)}
except FileNotFoundError:
checked(root, path, True)
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
print(json.dumps({'data': data, 'truncated': partial}, ensure_ascii=False))
else:
prefix = options.get('path', '')
if prefix:
checked(root, prefix)
paths, matches = [], []
visited, scanned, truncated = 0, 0, False
def fail(error):
raise error
for directory, dirs, files in os.walk(root, followlinks=False, onerror=fail):
depth = len(os.path.relpath(directory, root).split('/')) if directory != root else 0
kept = []
for d in sorted(dirs):
rel = os.path.relpath(os.path.join(directory, d), root)
if prefix and rel != prefix and not rel.startswith(prefix + '/') and not prefix.startswith(rel + '/'):
continue
visited += 1
try:
safe(rel)
if not os.path.islink(os.path.join(directory, d)):
kept.append(d)
except ValueError:
pass
dirs[:] = kept if depth < 10 else []
for f in sorted(files):
path = os.path.relpath(os.path.join(directory, f), root)
if prefix and path != prefix and not path.startswith(prefix + '/'):
continue
visited += 1
if visited > 5000 or len(paths) >= 250 or len(matches) >= 80 or scanned >= 4000000:
truncated = True
break
try:
safe(path)
if os.path.islink(os.path.join(directory, f)) or not text_pattern.search(f):
continue
if prefix and path != prefix and not path.startswith(prefix + '/'):
continue
if name == 'files.list':
paths.append(path)
continue
content = read(root, path)
except (ValueError, OSError, UnicodeError):
continue
scanned += len(content)
for number, line in enumerate(content.split('\n'), 1):
if options['query'].lower() in line.lower():
matches.append({'path': path, 'line': number, 'text': line[:500]})
if len(matches) >= 80:
truncated = True
break
if truncated:
break
data = {'paths': paths} if name == 'files.list' else {'query': options['query'], 'matches': matches}
print(json.dumps({'data': data, 'truncated': truncated}, ensure_ascii=False))
`
+79
View File
@@ -0,0 +1,79 @@
import type { AgentToolRequest } from '../shared/agentTools'
export const DEFAULT_AGENT_TOPICS = ['/raw/lidar', '/scan', '/lidar_filtered', '/cloud_map', '/odom', '/map', '/tf', '/tf_static']
export function agentRosProbe(topics: string[] = DEFAULT_AGENT_TOPICS): string {
const encoded = Buffer.from(JSON.stringify(topics)).toString('base64')
return `python3 -c ${shellQuote(agentRosProbePython)} ${shellQuote(encoded)}`
}
export function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'` }
export const agentRosProbePython = String.raw`
import base64, json, sys, time
import rclpy
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
from rosidl_runtime_py.utilities import get_message
rclpy.init(args=[])
node = rclpy.create_node('lms_agent_readonly_probe_' + str(int(time.time() * 1000)))
topics = json.loads(base64.b64decode(sys.argv[1]))
samples, valid_samples, frames, edges, errors, subscriptions = {}, {}, {}, set(), {}, []
try:
deadline = time.monotonic() + 1.5
while time.monotonic() < deadline:
rclpy.spin_once(node, timeout_sec=0.1)
graph = dict(node.get_topic_names_and_types())
def callback(topic):
def receive(message):
samples[topic] = samples.get(topic, 0) + 1
if hasattr(message, 'header') and message.header.frame_id:
frame_set = frames.setdefault(topic, set())
if len(frame_set) < 8:
frame_set.add(message.header.frame_id)
valid = True
if hasattr(message, 'ranges'):
valid = len(message.ranges) > 0
elif hasattr(message, 'width') and hasattr(message, 'data'):
valid = message.width * message.height > 0 and len(message.data) > 0
elif hasattr(message, 'info') and hasattr(message, 'data'):
valid = message.info.width * message.info.height > 0 and len(message.data) > 0
if valid:
valid_samples[topic] = valid_samples.get(topic, 0) + 1
if topic in ('/tf', '/tf_static'):
for transform in message.transforms:
if len(edges) < 200:
edges.add((transform.header.frame_id, transform.child_frame_id))
return receive
for topic in topics:
if topic not in graph:
continue
try:
retained = any(info.qos_profile.durability == DurabilityPolicy.TRANSIENT_LOCAL for info in node.get_publishers_info_by_topic(topic))
qos = QoSProfile(depth=1, reliability=ReliabilityPolicy.BEST_EFFORT, durability=DurabilityPolicy.TRANSIENT_LOCAL if retained else DurabilityPolicy.VOLATILE)
subscriptions.append(node.create_subscription(get_message(graph[topic][0]), topic, callback(topic), qos))
except Exception as error:
errors[topic] = str(error)[:500]
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
rclpy.spin_once(node, timeout_sec=0.1)
graph = dict(node.get_topic_names_and_types())
print(json.dumps({'nodes': node.get_node_names()[:200], 'topics': [{'name': t, 'types': graph.get(t, []), 'publishers': node.count_publishers(t), 'samples': samples.get(t, 0), 'validSamples': valid_samples.get(t, 0), 'frameIds': sorted(frames.get(t, [])), 'error': errors.get(t)} for t in topics], 'tfEdges': [{'parent': p, 'child': c} for p, c in sorted(edges)], 'observationSeconds': 3.0}))
finally:
node.destroy_node()
rclpy.shutdown()
`
export async function inspectAgentRuntime(
request: AgentToolRequest,
host: (executable: string, args: string[]) => Promise<string>,
container: (command: string) => Promise<string>
): Promise<unknown> {
if (request.name === 'network.inspect') {
const interfaces = JSON.parse(await host('ip', ['-j', 'address', 'show']))
const routes = JSON.parse(await host('ip', ['-j', 'route', 'show']))
let containerNetwork: unknown
try { containerNetwork = JSON.parse(await container('ip -j address show')) } catch (error) { containerNetwork = { error: String(error).slice(0, 500) } }
return { host: { interfaces, routes }, container: containerNetwork, note: '호스트는 해당 모듈이 설치된 로컬 또는 SSH 대상 장치입니다. 라이다·공유기 자체 설정은 조회하지 않았습니다.' }
}
if (request.name === 'ros.inspect') return JSON.parse(await container(agentRosProbe(request.args?.topics)))
throw new Error('지원하지 않는 런타임 조회입니다.')
}
+38
View File
@@ -0,0 +1,38 @@
import { BrowserWindow, clipboard, Menu } from 'electron'
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
import type { ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu'
export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise<ChatTextMenuAction> | null {
if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null
const window = BrowserWindow.fromWebContents(event.sender)
if (!window || window.isDestroyed()) return null
const label = (value: unknown, fallback: string): string => typeof value === 'string' && value.trim() ? value.slice(0, 80) : fallback
return new Promise((resolve) => {
const items: MenuItemConstructorOptions[] = [{
label: label(input.labels?.copy, 'Copy'),
enabled: input.canCopy === true,
click: () => {
if (!event.sender.isDestroyed() && input.canCopy === true) event.sender.copy()
resolve(null)
}
}]
if (input.kind === 'input') {
items.push({
label: label(input.labels?.paste, 'Paste'),
enabled: input.canPaste === true && clipboard.readText().length > 0,
click: () => {
if (!event.sender.isDestroyed() && input.canPaste === true && clipboard.readText().length > 0) event.sender.paste()
resolve(null)
}
})
} else if (input.canClear !== undefined) {
items.push({ type: 'separator' }, {
label: label(input.labels?.clear, 'Delete conversation'),
enabled: input.canClear === true,
click: () => resolve(input.canClear === true ? 'clear' : null)
})
}
Menu.buildFromTemplate(items).popup({ window, callback: () => resolve(null) })
})
}
+116 -6
View File
@@ -1,8 +1,13 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { appLog } from './appLogging'
import { checkDockerStatus } from './dockerStatus'
import { showChatTextMenu } from './chatTextMenu'
import { cameraRuntimeArtifacts } from './cameraRuntime'
import { remoteModuleFilesScript } from './remoteModuleFiles'
import { applyAgentFiles, runAgentFileTool } from './agentFileTools'
import { agentRemoteFilesScript } from './agentRemoteFiles'
import { inspectAgentRuntime } from './agentRuntimeTools'
import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools'
import { resolveContainerTerminal } from './containerTerminal'
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
@@ -1794,7 +1799,7 @@ function handleModuleOperation(channel: string, handler: Parameters<typeof ipcMa
})
}
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined'): Promise<string> {
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined', input?: string): Promise<string> {
const readProgress = createModuleProgressParser()
return new Promise<string>((resolvePromise, rejectPromise) => {
let output = ''
@@ -1803,7 +1808,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
const child = spawn(command, args, {
cwd,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
env: {
...process.env,
...env,
@@ -1826,13 +1831,17 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
}, timeoutMs)
child.stdout.setEncoding('utf8')
child.stdout.on('data', (data: string) => {
if (input !== undefined) {
child.stdin?.on('error', () => { /* Process exit is reported by close/error. */ })
child.stdin?.end(input)
}
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (data: string) => {
stdout += data.toString()
output += data.toString()
readProgress(data.toString())
})
child.stderr.on('data', (data: Buffer) => {
child.stderr?.on('data', (data: Buffer) => {
output += data.toString()
})
child.on('error', (error) => {
@@ -2219,7 +2228,7 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
))
}
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number): Promise<string> {
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number, input?: string): Promise<string> {
const normalized = normalizeRemoteTarget(target)
return runCommandOutput(
'ssh',
@@ -2249,6 +2258,7 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri
remoteShellEnvironment(process.env),
timeoutMs,
'stdout',
input,
)
}
@@ -6016,6 +6026,86 @@ async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleN
}
}
function agentFolder(folderName?: string | null): string | undefined {
if (folderName == null) return undefined
if (!/^[A-Za-z0-9[\](). _-]{1,160}$/.test(folderName) || folderName === '.' || folderName === '..') throw new Error('모듈 폴더명이 올바르지 않습니다.')
return folderName
}
async function runWorkspaceAgentTool(input: AgentToolRequest, workspaceRoot?: string, folderName?: string, remote?: RemoteWorkspaceTarget): Promise<AgentToolResult> {
const request = validateAgentTool(input)
const target = remote ? `ssh:${normalizeRemoteTarget(remote).host}` : 'local-host'
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
try {
const folder = agentFolder(folderName)
const resolved = remote ? await resolveRemoteGitModuleRoot(remote, request.moduleName!, folder) : null
const root = resolved?.moduleRoot ?? await findWorkspaceModuleRoot(request.moduleName!, workspaceRoot, folder)
if (!root) throw new Error('설치된 모듈을 찾을 수 없습니다.')
let payload: unknown
if (request.name.startsWith('files.')) {
const response = remote
? JSON.parse(await runUnqueuedSshOutput(resolved!.target, agentRemoteFilesScript({ root, request }), 30_000))
: await runAgentFileTool(root, request)
payload = response.data
result.truncated = response.truncated
} else if (request.name === 'logs.read') {
const response = remote
? await readRemoteWorkspaceModuleLogs(remote, request.moduleName!, folder)
: await readWorkspaceModuleLogs(request.moduleName!, workspaceRoot, folder)
payload = { ...response, logs: response.logs.slice(-20_000) }
if (!response.ok) result.status = 'error'
result.truncated = response.logs.length > 20_000
} else {
const host = (executable: string, args: string[]): Promise<string> => remote
? runUnqueuedSshOutput(resolved!.target, [executable, ...args].map(quoteShellSingle).join(' '), 12_000)
: runCommandOutput(executable, args, undefined, undefined, 12_000, 'stdout')
const container = async (command: string): Promise<string> => {
const runtime = remote ? await resolveRemoteAgentContainer(resolved!.target, root, folder) : await resolveLocalAgentContainer(root, folder)
const args = agentContainerCommandArgs(runtime, command, false)
// Unlike free-form commands, these fixed probes always have a container-side deadline.
args.splice(4, 0, 'timeout', '--signal=INT', '--kill-after=2s', '10s')
return host('docker', args)
}
payload = await inspectAgentRuntime(request, host, container)
if (request.name === 'network.inspect' && (payload as { container?: { error?: string } }).container?.error) result.truncated = true
if (request.name === 'ros.inspect') result.target += ':module-container'
}
result.data = JSON.stringify(payload)
if (result.data.length > 60_000) {
result.data = JSON.stringify({ message: '조회 결과가 용량 한도를 초과했습니다. 파일 범위나 검색어를 좁혀주세요.', excerpt: result.data.slice(0, 20_000) })
result.truncated = true
}
} catch (error) {
result.status = 'error'
result.data = JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) })
}
return result
}
let agentFileApplyPending = false
async function applyWorkspaceAgentFiles(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
if (agentFileApplyPending) throw new Error('다른 파일 변경안을 적용 중입니다.')
agentFileApplyPending = true
try { return await applyWorkspaceAgentFilesOnce(changes, workspaceRoot, remote) }
finally { agentFileApplyPending = false }
}
async function applyWorkspaceAgentFilesOnce(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
if (!Array.isArray(changes) || !changes.length || changes.length > 24) throw new Error('변경 파일 수를 확인하세요.')
const resolved: Array<AgentCheckedChange & { root: string }> = []
for (const change of changes) {
if (!change || typeof change.content !== 'string' || change.content.length > 200_000 || (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash))) throw new Error('검증된 파일 변경안이 아닙니다.')
validateAgentTool({ id: 'apply-check', name: 'files.read', moduleName: change.moduleName, args: { path: change.path } })
const folder = agentFolder(change.folderName)
const root = remote ? (await resolveRemoteGitModuleRoot(remote, change.moduleName, folder)).moduleRoot : await findWorkspaceModuleRoot(change.moduleName, workspaceRoot, folder)
if (!root) throw new Error(`변경 대상 모듈이 설치되지 않았습니다: ${change.moduleName}`)
resolved.push({ ...change, root })
}
if (remote) await runUnqueuedSshOutput(remote, 'bash --noprofile --norc -s', 30_000, agentRemoteFilesScript({ changes: resolved }))
else await applyAgentFiles(resolved)
return { ok: true }
}
async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
@@ -6681,6 +6771,25 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
ipcMain.handle('local-workspace:agent-command:run', (_event, moduleName: string, command: string, workspaceRoot?: string | null, folderName?: string | null) => runLocalAgentCommand(moduleName, command, workspaceRoot, folderName))
const checkAgentSender = (event: Electron.IpcMainInvokeEvent): void => {
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) throw new Error('허용되지 않은 에이전트 요청입니다.')
}
ipcMain.handle('local-workspace:agent-tool:read', (event, request: AgentToolRequest, workspaceRoot?: string, folderName?: string) => {
checkAgentSender(event)
return runWorkspaceAgentTool(request, workspaceRoot, folderName)
})
ipcMain.handle('remote-workspace:agent-tool:read', (event, target: RemoteWorkspaceTarget, request: AgentToolRequest, folderName?: string) => {
checkAgentSender(event)
return runWorkspaceAgentTool(request, undefined, folderName, target)
})
ipcMain.handle('local-workspace:agent-files:apply', (event, changes: AgentCheckedChange[], workspaceRoot?: string) => {
checkAgentSender(event)
return applyWorkspaceAgentFiles(changes, workspaceRoot)
})
ipcMain.handle('remote-workspace:agent-files:apply', (event, target: RemoteWorkspaceTarget, changes: AgentCheckedChange[]) => {
checkAgentSender(event)
return applyWorkspaceAgentFiles(changes, undefined, target)
})
ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose))
ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
@@ -6927,6 +7036,7 @@ app.whenReady().then(() => {
return { opened: true }
})
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics())
ipcMain.handle('chat-text:context-menu', showChatTextMenu)
ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
ipcMain.on('app-quit', () => app.quit())
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))