@agent-hubs/runtime-client
Advanced tools
+13
-0
@@ -50,2 +50,5 @@ async function buildError(response) { | ||
| } | ||
| export async function listRuntimeClients(gatewayUrl, provider) { | ||
| return gatewayRequest(gatewayUrl, `/gateway/v1/providers/${provider}/runtime-clients`); | ||
| } | ||
| export async function heartbeatRuntimeClient(gatewayUrl, provider, clientId, payload) { | ||
@@ -68,1 +71,11 @@ return gatewayRequest(gatewayUrl, `/gateway/v1/providers/${provider}/runtime-clients/${clientId}/heartbeat`, { | ||
| } | ||
| export async function renewRuntimeTaskLease(gatewayUrl, provider, clientId, taskId) { | ||
| return gatewayRequest(gatewayUrl, `/gateway/v1/providers/${provider}/runtime-clients/${clientId}/tasks/${taskId}/lease`, { | ||
| method: "POST" | ||
| }); | ||
| } | ||
| export async function getRuntimeTaskControlState(gatewayUrl, provider, clientId, taskId) { | ||
| return gatewayRequest(gatewayUrl, `/gateway/v1/providers/${provider}/runtime-clients/${clientId}/tasks/${taskId}/control-state`, { | ||
| method: "POST" | ||
| }); | ||
| } |
+668
-24
| import { spawn, spawnSync } from "node:child_process"; | ||
| import { createHash } from "node:crypto"; | ||
| import { constants } from "node:fs"; | ||
| import { mkdir, writeFile, access, readFile } from "node:fs/promises"; | ||
| import { basename, extname, join } from "node:path"; | ||
| import { mkdir, writeFile, access, readFile, readdir, stat } from "node:fs/promises"; | ||
| import os from "node:os"; | ||
| import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; | ||
| const WORKSPACE_CONFIG_THREADLESS = "__threadless__"; | ||
| const WORKSPACE_CONFIG_PATHS = [ | ||
| "AGENT.md", | ||
| "config/context/context.md", | ||
| "config/README.md", | ||
| "config/knowledge/eastmoney-stock-details.md", | ||
| "config/runbooks/eastmoney-realtime-details-monitor.md" | ||
| ]; | ||
| const workspaceConfigCache = new Map(); | ||
| function isWorkspaceConfigPromptInjectionEnabled() { | ||
| const raw = `${process.env.GATEWAY_ENABLE_CONFIG_PROMPT_INJECTION ?? ""}`.trim().toLowerCase(); | ||
| if (raw === "0" || raw === "false" || raw === "no" || raw === "off") | ||
| return false; | ||
| return true; | ||
| } | ||
| export function detectCodexBinary() { | ||
@@ -41,5 +58,43 @@ if (process.env.CODEX_BIN?.trim()) { | ||
| function codexTimeoutMs() { | ||
| const raw = Number(process.env.GATEWAY_CODEX_TIMEOUT_MS ?? "60000"); | ||
| return Number.isFinite(raw) && raw > 0 ? raw : 60_000; | ||
| const raw = Number(process.env.GATEWAY_CODEX_TIMEOUT_MS ?? "600000"); | ||
| return Number.isFinite(raw) && raw > 0 ? raw : 600_000; | ||
| } | ||
| export function isMockRuntimeProviderEnabled() { | ||
| return process.env.AGENT_RUNTIME_MOCK_PROVIDER === "1"; | ||
| } | ||
| async function runMockRuntimeTask(options) { | ||
| const traceMode = options.traceMode ?? "off"; | ||
| const trace = createRuntimeTraceRecorder(traceMode); | ||
| const delayMs = Math.max(0, Number.parseInt(process.env.AGENT_RUNTIME_MOCK_DELAY_MS ?? "25", 10) || 0); | ||
| const content = process.env.AGENT_RUNTIME_MOCK_CONTENT ?? `mock runtime reply: ${options.prompt}`; | ||
| const threadId = process.env.AGENT_RUNTIME_MOCK_THREAD_ID ?? options.threadId ?? "mock-thread"; | ||
| trace.add("runtime.mock.started", "Mock Runtime 开始执行", "success", { | ||
| delayMs, | ||
| hasThreadId: Boolean(options.threadId) | ||
| }); | ||
| await new Promise((resolve, reject) => { | ||
| if (options.signal?.aborted) { | ||
| reject(new Error("Runtime task cancelled; mock provider was terminated")); | ||
| return; | ||
| } | ||
| const timer = setTimeout(resolve, delayMs); | ||
| const abort = () => { | ||
| clearTimeout(timer); | ||
| reject(new Error("Runtime task cancelled; mock provider was terminated")); | ||
| }; | ||
| options.signal?.addEventListener("abort", abort, { once: true }); | ||
| }); | ||
| if (options.signal?.aborted) { | ||
| throw new Error("Runtime task cancelled; mock provider was terminated"); | ||
| } | ||
| trace.add("runtime.mock.completed", "Mock Runtime 执行完成", "success", { | ||
| threadId, | ||
| outputChars: content.length | ||
| }); | ||
| return { | ||
| content, | ||
| threadId, | ||
| traceEvents: trace.events | ||
| }; | ||
| } | ||
| function normalizeBaseUrl(url) { | ||
@@ -71,14 +126,301 @@ return url.replace(/\/+$/, ""); | ||
| } | ||
| async function contextPrompt(workspacePath) { | ||
| if (!workspacePath) | ||
| return ""; | ||
| const contextPath = join(workspacePath, "context.md"); | ||
| try { | ||
| const context = (await readFile(contextPath, "utf8")).trim(); | ||
| return context ? `请参考当前工作目录中的 context.md 作为长期上下文。\n\n<context>\n${context}\n</context>` : ""; | ||
| function localManagedWorkspaceRoot() { | ||
| const configured = process.env.AGENT_RUNTIME_WORKSPACE_ROOT?.trim(); | ||
| return configured || join(os.homedir(), ".agent-runtime", "workspaces"); | ||
| } | ||
| function resolveRuntimeWorkspacePath(workspacePath) { | ||
| const raw = workspacePath?.trim(); | ||
| if (!raw) | ||
| return { workspacePath: process.cwd(), mapped: false }; | ||
| const normalized = raw.replace(/\\/g, "/"); | ||
| const marker = "/runtime/workspaces/"; | ||
| const markerIndex = normalized.indexOf(marker); | ||
| if (normalized.startsWith("/app/") && markerIndex >= 0) { | ||
| const relativeWorkspace = normalized.slice(markerIndex + marker.length).replace(/^\/+/, ""); | ||
| if (relativeWorkspace) { | ||
| return { | ||
| workspacePath: join(localManagedWorkspaceRoot(), relativeWorkspace), | ||
| mapped: true, | ||
| originalWorkspacePath: raw | ||
| }; | ||
| } | ||
| } | ||
| catch { | ||
| return ""; | ||
| return { workspacePath: raw, mapped: false }; | ||
| } | ||
| function normalizeConfigRelativePath(rawPath) { | ||
| const normalized = (rawPath ?? "").trim().replaceAll("\\", "/").replace(/^\/+/, ""); | ||
| if (!normalized) { | ||
| throw new Error("path is required"); | ||
| } | ||
| if (normalized.includes("\0")) { | ||
| throw new Error("path is invalid"); | ||
| } | ||
| const segments = normalized.split("/").filter(Boolean); | ||
| if (segments.length === 0 || segments.some((segment) => segment === "." || segment === ".." || segment.startsWith("."))) { | ||
| throw new Error("path is invalid"); | ||
| } | ||
| const safePath = segments.join("/"); | ||
| if (safePath !== "AGENT.md" && !safePath.startsWith("config/")) { | ||
| throw new Error("path must be AGENT.md or config/*"); | ||
| } | ||
| return safePath; | ||
| } | ||
| function normalizeBootstrapRelativePath(rawPath) { | ||
| const normalized = (rawPath ?? "").trim().replaceAll("\\", "/").replace(/^\/+/, ""); | ||
| if (!normalized || normalized.includes("\0")) { | ||
| throw new Error("path is invalid"); | ||
| } | ||
| const segments = normalized.split("/").filter(Boolean); | ||
| if (segments.length === 0 || segments.some((segment) => segment === "." || segment === "..")) { | ||
| throw new Error("path is invalid"); | ||
| } | ||
| const safePath = segments.join("/"); | ||
| const allowed = safePath === ".gitignore" || safePath === "README.md" || safePath === "outputs/README.md" || safePath === "AGENT.md" || safePath.startsWith("config/"); | ||
| if (!allowed) { | ||
| throw new Error("path must be a workspace bootstrap or config file"); | ||
| } | ||
| return safePath; | ||
| } | ||
| async function listWorkspaceDocsRelativePaths(basePath, relativeRoot = "config") { | ||
| const targetPath = join(basePath, relativeRoot); | ||
| const entries = await readdir(targetPath, { withFileTypes: true }).catch(() => []); | ||
| const files = []; | ||
| for (const entry of entries) { | ||
| if (entry.name.startsWith(".")) { | ||
| continue; | ||
| } | ||
| const nextRelative = `${relativeRoot}/${entry.name}`; | ||
| if (entry.isDirectory()) { | ||
| files.push(...(await listWorkspaceDocsRelativePaths(basePath, nextRelative))); | ||
| } | ||
| else if (entry.isFile()) { | ||
| files.push(nextRelative.replaceAll("\\", "/")); | ||
| } | ||
| } | ||
| return files; | ||
| } | ||
| async function listWorkspaceConfigFiles(workspacePath) { | ||
| const candidates = [ | ||
| ...(await stat(join(workspacePath, "AGENT.md")).then((fileStats) => (fileStats.isFile() ? ["AGENT.md"] : [])).catch(() => [])), | ||
| ...(await listWorkspaceDocsRelativePaths(workspacePath)) | ||
| ]; | ||
| const files = []; | ||
| for (const relativePath of candidates) { | ||
| const absolutePath = join(workspacePath, relativePath); | ||
| const fileStats = await stat(absolutePath).catch(() => null); | ||
| if (!fileStats?.isFile()) | ||
| continue; | ||
| files.push({ | ||
| path: relativePath, | ||
| size: fileStats.size, | ||
| updatedAt: fileStats.mtime.toISOString() | ||
| }); | ||
| } | ||
| files.sort((left, right) => left.path.localeCompare(right.path)); | ||
| return files; | ||
| } | ||
| async function readWorkspaceConfigFile(workspacePath, rawPath) { | ||
| const relativePath = normalizeConfigRelativePath(rawPath); | ||
| const workspaceRoot = resolve(workspacePath); | ||
| const absolutePath = resolve(workspaceRoot, relativePath); | ||
| const rel = relative(workspaceRoot, absolutePath); | ||
| if (rel.startsWith("..") || rel.includes(`..${sep}`)) { | ||
| throw new Error("path is invalid"); | ||
| } | ||
| const fileStats = await stat(absolutePath).catch(() => null); | ||
| if (!fileStats?.isFile()) { | ||
| throw new Error("Config file not found"); | ||
| } | ||
| const content = await readFile(absolutePath, "utf8"); | ||
| return { | ||
| path: relativePath, | ||
| content, | ||
| size: fileStats.size, | ||
| updatedAt: fileStats.mtime.toISOString() | ||
| }; | ||
| } | ||
| export async function runWorkspaceOperationTask(options) { | ||
| const payload = options.operationPayload ?? {}; | ||
| const requestedWorkspace = typeof payload.workspacePath === "string" ? payload.workspacePath : options.workspacePath; | ||
| const resolvedWorkspace = resolveRuntimeWorkspacePath(requestedWorkspace); | ||
| const workspacePath = resolvedWorkspace.workspacePath; | ||
| if (options.operationKind === "workspace_config_list") { | ||
| return { | ||
| ok: true, | ||
| manageable: true, | ||
| workspacePath, | ||
| mapped: resolvedWorkspace.mapped, | ||
| files: await listWorkspaceConfigFiles(workspacePath) | ||
| }; | ||
| } | ||
| if (options.operationKind === "workspace_config_read") { | ||
| return { | ||
| ok: true, | ||
| workspacePath, | ||
| mapped: resolvedWorkspace.mapped, | ||
| ...(await readWorkspaceConfigFile(workspacePath, typeof payload.path === "string" ? payload.path : undefined)) | ||
| }; | ||
| } | ||
| if (options.operationKind === "workspace_file_preview") { | ||
| const rawPath = typeof payload.path === "string" ? payload.path.trim() : ""; | ||
| if (!rawPath || rawPath.includes("\0")) { | ||
| throw new Error("path is required"); | ||
| } | ||
| const workspaceRoot = resolve(workspacePath); | ||
| const absolutePath = rawPath.startsWith("/") | ||
| ? resolve(rawPath) | ||
| : resolve(workspaceRoot, rawPath.replace(/^\/+/, "")); | ||
| const rel = relative(workspaceRoot, absolutePath); | ||
| if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) { | ||
| throw new Error("path is outside workspace"); | ||
| } | ||
| const fileStats = await stat(absolutePath).catch(() => null); | ||
| if (!fileStats || (!fileStats.isFile() && !fileStats.isDirectory())) { | ||
| throw new Error("file not found"); | ||
| } | ||
| if (fileStats.isDirectory()) { | ||
| const entries = await readdir(absolutePath, { withFileTypes: true }); | ||
| const visibleEntries = entries | ||
| .filter((entry) => !entry.name.startsWith(".")) | ||
| .sort((left, right) => { | ||
| if (left.isDirectory() !== right.isDirectory()) | ||
| return left.isDirectory() ? -1 : 1; | ||
| return left.name.localeCompare(right.name); | ||
| }); | ||
| const listedEntries = visibleEntries | ||
| .slice(0, 200) | ||
| .map((entry) => `${entry.isDirectory() ? "目录" : "文件"} ${entry.name}${entry.isDirectory() ? "/" : ""}`); | ||
| return { | ||
| ok: true, | ||
| path: absolutePath, | ||
| size: 0, | ||
| truncated: visibleEntries.length > listedEntries.length, | ||
| content: [`目录:${absolutePath}`, `共 ${visibleEntries.length} 项,展示 ${listedEntries.length} 项`, "", ...listedEntries].join("\n") | ||
| }; | ||
| } | ||
| const maxBytesRaw = Number(payload.maxBytes); | ||
| const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? Math.min(maxBytesRaw, 1024 * 1024) : 256 * 1024; | ||
| const buffer = await readFile(absolutePath); | ||
| const previewBuffer = buffer.length > maxBytes ? buffer.subarray(0, maxBytes) : buffer; | ||
| if (previewBuffer.includes(0)) { | ||
| throw new Error("binary file preview is not supported"); | ||
| } | ||
| return { | ||
| ok: true, | ||
| path: absolutePath, | ||
| size: fileStats.size, | ||
| truncated: buffer.length > previewBuffer.length, | ||
| content: previewBuffer.toString("utf8") | ||
| }; | ||
| } | ||
| if (options.operationKind === "workspace_bootstrap") { | ||
| const files = Array.isArray(payload.files) ? payload.files : []; | ||
| await mkdir(workspacePath, { recursive: true }); | ||
| let changedCount = 0; | ||
| for (const file of files) { | ||
| if (!file || typeof file !== "object") | ||
| continue; | ||
| const path = normalizeBootstrapRelativePath(file.path); | ||
| const content = file.content; | ||
| if (typeof content !== "string") | ||
| continue; | ||
| const workspaceRoot = resolve(workspacePath); | ||
| const absolutePath = resolve(workspaceRoot, path); | ||
| const rel = relative(workspaceRoot, absolutePath); | ||
| if (rel.startsWith("..") || rel.includes(`..${sep}`)) { | ||
| throw new Error("path is invalid"); | ||
| } | ||
| await mkdir(dirname(absolutePath), { recursive: true }); | ||
| await writeFile(absolutePath, content, "utf8"); | ||
| changedCount += 1; | ||
| } | ||
| return { | ||
| ok: true, | ||
| workspacePath, | ||
| mapped: resolvedWorkspace.mapped, | ||
| changedCount, | ||
| files: await listWorkspaceConfigFiles(workspacePath) | ||
| }; | ||
| } | ||
| throw new Error(`Unsupported workspace operation: ${options.operationKind ?? "unknown"}`); | ||
| } | ||
| function workspaceThreadKey(threadId) { | ||
| const normalized = threadId?.trim(); | ||
| return normalized || WORKSPACE_CONFIG_THREADLESS; | ||
| } | ||
| async function buildWorkspaceConfigSignature(workspacePath) { | ||
| const signatures = []; | ||
| for (const relativePath of WORKSPACE_CONFIG_PATHS) { | ||
| const absolutePath = join(workspacePath, relativePath); | ||
| try { | ||
| const file = await stat(absolutePath); | ||
| if (!file.isFile()) { | ||
| signatures.push(`${relativePath}:none`); | ||
| continue; | ||
| } | ||
| signatures.push(`${relativePath}:${file.mtimeMs}:${file.size}`); | ||
| } | ||
| catch { | ||
| signatures.push(`${relativePath}:missing`); | ||
| } | ||
| } | ||
| return createHash("sha1").update(signatures.join("|")).digest("hex"); | ||
| } | ||
| async function buildWorkspaceConfigPrompt(workspacePath) { | ||
| const sections = []; | ||
| const files = []; | ||
| const missingFiles = []; | ||
| for (const relativePath of WORKSPACE_CONFIG_PATHS) { | ||
| const absolutePath = join(workspacePath, relativePath); | ||
| const content = await readFile(absolutePath, "utf8").catch(() => ""); | ||
| const trimmed = content.trim(); | ||
| if (!trimmed) { | ||
| missingFiles.push(relativePath); | ||
| continue; | ||
| } | ||
| files.push(relativePath); | ||
| sections.push(`### ${relativePath}\n${trimmed}`); | ||
| } | ||
| if (!sections.length) | ||
| return { prompt: "", files, missingFiles }; | ||
| const prompt = [ | ||
| "请严格参考以下机器人配置文件作为长期上下文和执行边界;若与临时指令冲突,优先保证安全与约束,再说明取舍:", | ||
| "", | ||
| ...sections | ||
| ].join("\n"); | ||
| return { prompt, files, missingFiles }; | ||
| } | ||
| async function workspaceConfigPrompt(workspacePath, threadId) { | ||
| if (!isWorkspaceConfigPromptInjectionEnabled()) { | ||
| return { prompt: "", signature: "", injected: false, threadKey: workspaceThreadKey(threadId), files: [], missingFiles: [] }; | ||
| } | ||
| const threadKey = workspaceThreadKey(threadId); | ||
| const signature = await buildWorkspaceConfigSignature(workspacePath); | ||
| const cache = workspaceConfigCache.get(workspacePath); | ||
| if (cache && cache.signature === signature && cache.injectedThreads.has(threadKey)) { | ||
| return { prompt: "", signature, injected: false, threadKey, files: cache.files, missingFiles: cache.missingFiles }; | ||
| } | ||
| if (cache && cache.signature === signature) { | ||
| cache.injectedThreads.add(threadKey); | ||
| return { prompt: cache.prompt, signature, injected: true, threadKey, files: cache.files, missingFiles: cache.missingFiles }; | ||
| } | ||
| const built = await buildWorkspaceConfigPrompt(workspacePath); | ||
| workspaceConfigCache.set(workspacePath, { | ||
| signature, | ||
| prompt: built.prompt, | ||
| injectedThreads: new Set([threadKey]), | ||
| files: built.files, | ||
| missingFiles: built.missingFiles | ||
| }); | ||
| return { prompt: built.prompt, signature, injected: true, threadKey, files: built.files, missingFiles: built.missingFiles }; | ||
| } | ||
| function markWorkspaceThreadInjected(workspacePath, signature, threadId) { | ||
| const normalized = threadId?.trim(); | ||
| if (!normalized) | ||
| return; | ||
| const cache = workspaceConfigCache.get(workspacePath); | ||
| if (!cache || cache.signature !== signature) | ||
| return; | ||
| cache.injectedThreads.add(normalized); | ||
| } | ||
| function normalizeCliError(stderr) { | ||
@@ -91,2 +433,215 @@ const lines = stderr | ||
| } | ||
| function createRuntimeTraceRecorder(mode) { | ||
| const events = []; | ||
| const enabled = mode === "light" || mode === "debug"; | ||
| const add = (type, title, status = "success", metadata) => { | ||
| if (!enabled) | ||
| return; | ||
| const id = `rt_evt_${String(events.length + 1).padStart(2, "0")}_${type.replace(/[^a-z0-9_]/gi, "_").slice(0, 36)}`; | ||
| const now = new Date().toISOString(); | ||
| events.push({ | ||
| id, | ||
| type, | ||
| title, | ||
| status, | ||
| startedAt: now, | ||
| endedAt: now, | ||
| ...(metadata ? { metadata } : {}) | ||
| }); | ||
| }; | ||
| return { enabled, events, add }; | ||
| } | ||
| function previewValue(value, limit) { | ||
| const raw = Array.isArray(value) && value.every((item) => typeof item === "string") | ||
| ? value.join(" ") | ||
| : typeof value === "string" | ||
| ? value | ||
| : JSON.stringify(value); | ||
| if (!raw) | ||
| return ""; | ||
| const normalized = redactTraceText(raw).replace(/\s+/g, " ").trim(); | ||
| return normalized.length > limit ? `${normalized.slice(0, limit)}...` : normalized; | ||
| } | ||
| function redactTraceText(value) { | ||
| return value | ||
| .replace(/(\bpassword\s*=\s*)('([^']*)'|"([^"]*)"|[^\s,)]+)/gi, "$1***") | ||
| .replace(/(\bpassword\s*:\s*)('([^']*)'|"([^"]*)"|[^\s,}]+)/gi, "$1***") | ||
| .replace(/(\bpass(word)?\s+)('([^']*)'|"([^"]*)"|[^\s,)]+)/gi, "$1***") | ||
| .replace(/(--password(?:=|\s+))('([^']*)'|"([^"]*)"|[^\s]+)/gi, "$1***") | ||
| .replace(/(\b(api[_-]?key|token|secret)\s*=\s*)('([^']*)'|"([^"]*)"|[^\s,)]+)/gi, "$1***") | ||
| .replace(/(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1***"); | ||
| } | ||
| function nestedTraceRecords(record) { | ||
| if (!record) | ||
| return []; | ||
| const nestedKeys = ["arguments", "args", "input", "params", "call", "tool", "metadata", "result", "output"]; | ||
| const records = [record]; | ||
| for (const key of nestedKeys) { | ||
| const value = record[key]; | ||
| if (value && typeof value === "object" && !Array.isArray(value)) { | ||
| records.push(value); | ||
| } | ||
| } | ||
| return records; | ||
| } | ||
| function firstStringField(record, keys, limit = 240) { | ||
| for (const source of nestedTraceRecords(record)) { | ||
| for (const key of keys) { | ||
| const value = source[key]; | ||
| if (typeof value === "string" && value.trim()) { | ||
| return previewValue(value, limit); | ||
| } | ||
| if (Array.isArray(value) && value.some((item) => typeof item === "string" && item.trim())) { | ||
| return previewValue(value, limit); | ||
| } | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| function firstNumberField(record, keys) { | ||
| for (const source of nestedTraceRecords(record)) { | ||
| for (const key of keys) { | ||
| const value = source[key]; | ||
| if (typeof value === "number" && Number.isFinite(value)) | ||
| return value; | ||
| if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) | ||
| return Number(value); | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| function firstStringArrayField(record, keys, limit = 8) { | ||
| for (const source of nestedTraceRecords(record)) { | ||
| for (const key of keys) { | ||
| const value = source[key]; | ||
| if (Array.isArray(value)) { | ||
| const strings = value.filter((item) => typeof item === "string" && item.trim().length > 0).slice(0, limit); | ||
| if (strings.length) | ||
| return strings; | ||
| } | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| function compactCommand(value) { | ||
| if (!value) | ||
| return undefined; | ||
| return value.length > 88 ? `${value.slice(0, 88)}...` : value; | ||
| } | ||
| function codexItemMetadata(item, mode) { | ||
| const itemType = typeof item?.type === "string" ? item.type : ""; | ||
| const command = firstStringField(item, ["command", "cmd", "shell_command", "shellCommand", "argv"], 2000); | ||
| const toolName = firstStringField(item, ["tool_name", "toolName", "name", "tool", "server"], 160); | ||
| const filePath = firstStringField(item, ["path", "file", "file_path", "filePath"], 260); | ||
| const files = firstStringArrayField(item, ["files", "paths", "modified_files", "modifiedFiles"]); | ||
| const status = firstStringField(item, ["status", "state", "outcome"], 80); | ||
| const exitCode = firstNumberField(item, ["exit_code", "exitCode", "code"]); | ||
| const failed = status === "failed" || (typeof exitCode === "number" && exitCode !== 0); | ||
| const outputPreview = firstStringField(item, ["stdout", "output", "result", "text", "aggregated_output"], 2000); | ||
| const errorPreview = firstStringField(item, failed ? ["stderr", "error", "message", "aggregated_output", "output", "result", "text"] : ["stderr", "error", "message"], 2000); | ||
| const argsPreview = firstStringField(item, ["arguments", "args", "input"], 400); | ||
| return { | ||
| itemType, | ||
| ...(command ? { command } : {}), | ||
| ...(toolName ? { toolName } : {}), | ||
| ...(filePath ? { filePath } : {}), | ||
| ...(files ? { files } : {}), | ||
| ...(status ? { status } : {}), | ||
| ...(typeof exitCode === "number" ? { exitCode } : {}), | ||
| ...(outputPreview ? { outputPreview } : {}), | ||
| ...(errorPreview ? { errorPreview } : {}), | ||
| ...(argsPreview ? { argsPreview } : {}), | ||
| ...(mode === "debug" ? { preview: previewValue(item, 1200) } : {}) | ||
| }; | ||
| } | ||
| function codexItemTitle(prefix, metadata) { | ||
| const itemType = typeof metadata.itemType === "string" ? metadata.itemType : ""; | ||
| const command = typeof metadata.command === "string" ? metadata.command : ""; | ||
| const toolName = typeof metadata.toolName === "string" ? metadata.toolName : ""; | ||
| const filePath = typeof metadata.filePath === "string" ? metadata.filePath : ""; | ||
| if (itemType === "command_execution") | ||
| return command ? `执行命令${prefix}:${compactCommand(command)}` : `执行命令${prefix}`; | ||
| if (itemType.includes("tool")) | ||
| return toolName ? `工具调用${prefix}:${toolName}` : `工具调用${prefix}`; | ||
| if (/file|patch|apply/i.test(itemType)) | ||
| return filePath ? `文件操作${prefix}:${filePath}` : `文件操作${prefix}`; | ||
| if (itemType === "agent_message") | ||
| return "Codex 生成回答"; | ||
| if (/reason|think/i.test(itemType)) | ||
| return `模型分析${prefix}`; | ||
| return itemType ? `Codex ${itemType} ${prefix}` : `Codex item ${prefix}`; | ||
| } | ||
| function codexEventTrace(event, mode) { | ||
| const type = typeof event.type === "string" ? event.type : ""; | ||
| if (!type) | ||
| return null; | ||
| if (type === "thread.started") { | ||
| return { | ||
| type: "codex.thread_started", | ||
| title: "Codex thread started", | ||
| status: "success", | ||
| metadata: { | ||
| threadId: typeof event.thread_id === "string" ? event.thread_id : undefined | ||
| } | ||
| }; | ||
| } | ||
| const item = event.item && typeof event.item === "object" ? event.item : undefined; | ||
| const itemType = typeof item?.type === "string" ? item.type : ""; | ||
| const metadata = codexItemMetadata(item, mode); | ||
| if (type === "item.started") { | ||
| return { | ||
| type: itemType.includes("tool") ? "codex.tool_call" : "codex.item_started", | ||
| title: codexItemTitle("开始", metadata), | ||
| status: "running", | ||
| metadata | ||
| }; | ||
| } | ||
| if (type === "item.completed") { | ||
| if (itemType === "agent_message") { | ||
| return { | ||
| type: "codex.agent_message", | ||
| title: "Codex 生成回答", | ||
| status: "success", | ||
| metadata: { | ||
| outputChars: typeof item?.text === "string" ? item.text.length : undefined | ||
| } | ||
| }; | ||
| } | ||
| if (itemType.includes("tool")) { | ||
| return { | ||
| type: "codex.tool_result", | ||
| title: codexItemTitle("完成", metadata), | ||
| status: "success", | ||
| metadata | ||
| }; | ||
| } | ||
| return { | ||
| type: "codex.item_completed", | ||
| title: codexItemTitle("完成", metadata), | ||
| status: metadata.status === "failed" || (typeof metadata.exitCode === "number" && metadata.exitCode !== 0) ? "failed" : "success", | ||
| metadata | ||
| }; | ||
| } | ||
| if (type === "error") { | ||
| return { | ||
| type: "codex.error", | ||
| title: "Codex 返回错误", | ||
| status: "failed", | ||
| metadata: { | ||
| message: typeof event.message === "string" ? event.message : undefined | ||
| } | ||
| }; | ||
| } | ||
| if (mode === "debug") { | ||
| return { | ||
| type: `codex.raw.${type}`, | ||
| title: `Codex 事件 ${type}`, | ||
| status: "success", | ||
| metadata: { | ||
| preview: previewValue(event, 500) | ||
| } | ||
| }; | ||
| } | ||
| return null; | ||
| } | ||
| export async function materializeAttachments(options) { | ||
@@ -122,4 +677,33 @@ const workspacePath = options.workspacePath; | ||
| export async function runLocalCodexTask(options) { | ||
| const workDir = options.workspacePath || process.cwd(); | ||
| if (isMockRuntimeProviderEnabled()) { | ||
| return runMockRuntimeTask({ | ||
| prompt: options.prompt, | ||
| threadId: options.threadId, | ||
| traceMode: options.traceMode, | ||
| signal: options.signal | ||
| }); | ||
| } | ||
| const traceMode = options.traceMode ?? "off"; | ||
| const trace = createRuntimeTraceRecorder(traceMode); | ||
| const resolvedWorkspace = resolveRuntimeWorkspacePath(options.workspacePath); | ||
| const workDir = resolvedWorkspace.workspacePath; | ||
| await ensureWritableWorkspace(workDir); | ||
| trace.add("runtime.started", "Runtime 开始执行", "success", { | ||
| workspacePath: workDir, | ||
| ...(resolvedWorkspace.mapped ? { originalWorkspacePath: resolvedWorkspace.originalWorkspacePath } : {}) | ||
| }); | ||
| if (options.networkAccessEnabled) { | ||
| trace.add("runtime.network_access_enabled", "启用网络授权执行", "success", { | ||
| mode: "dangerously-bypass-approvals-and-sandbox", | ||
| grantCount: Array.isArray(options.networkGrants) ? options.networkGrants.length : 0, | ||
| grants: options.networkGrants | ||
| }); | ||
| } | ||
| const configContext = await workspaceConfigPrompt(workDir, options.threadId); | ||
| trace.add(configContext.injected ? "config.injected" : "config.skipped", configContext.injected ? "注入机器人配置" : "跳过机器人配置注入", configContext.injected ? "success" : "skipped", { | ||
| injected: configContext.injected, | ||
| threadKey: configContext.threadKey, | ||
| files: configContext.files, | ||
| ...(traceMode === "debug" ? { missingFiles: configContext.missingFiles, signature: configContext.signature, promptChars: configContext.prompt.length } : {}) | ||
| }); | ||
| const attachmentItems = await materializeAttachments({ | ||
@@ -130,2 +714,12 @@ workspacePath: workDir, | ||
| }); | ||
| if (options.attachments.length) { | ||
| for (const attachment of attachmentItems) { | ||
| trace.add(attachment.localPath ? "runtime.attachment_synced" : "runtime.attachment_sync_failed", attachment.localPath ? "同步附件" : "附件同步失败", attachment.localPath ? "success" : "failed", { | ||
| name: attachment.name, | ||
| mimeType: attachment.mimeType, | ||
| kind: attachment.kind, | ||
| ...(traceMode === "debug" ? { localPath: attachment.localPath, downloadError: attachment.downloadError } : {}) | ||
| }); | ||
| } | ||
| } | ||
| const imageFiles = attachmentItems | ||
@@ -143,3 +737,10 @@ .filter((attachment) => attachment.kind === "image" && attachment.localPath) | ||
| : ""; | ||
| const finalPrompt = [await contextPrompt(workDir), options.prompt, attachmentPrompt].filter(Boolean).join("\n\n"); | ||
| const finalPrompt = [configContext.prompt, options.prompt, attachmentPrompt].filter(Boolean).join("\n\n"); | ||
| const promptText = finalPrompt.trim(); | ||
| if (!promptText) { | ||
| throw new Error("Codex prompt is empty"); | ||
| } | ||
| if (options.signal?.aborted) { | ||
| throw new Error("Runtime task cancelled before Codex CLI started"); | ||
| } | ||
| const codexBin = options.codexBin || detectCodexBinary(); | ||
@@ -153,5 +754,5 @@ return new Promise((resolve, reject) => { | ||
| "--json", | ||
| ...(options.networkAccessEnabled ? ["--dangerously-bypass-approvals-and-sandbox"] : []), | ||
| ...imageFiles.flatMap((file) => ["-i", file]), | ||
| options.threadId, | ||
| finalPrompt | ||
| options.threadId | ||
| ] | ||
@@ -164,8 +765,8 @@ : [ | ||
| "never", | ||
| "--sandbox", | ||
| "workspace-write", | ||
| ...(options.networkAccessEnabled | ||
| ? ["--dangerously-bypass-approvals-and-sandbox"] | ||
| : ["--sandbox", "workspace-write"]), | ||
| "-C", | ||
| workDir, | ||
| ...imageFiles.flatMap((file) => ["-i", file]), | ||
| finalPrompt | ||
| ...imageFiles.flatMap((file) => ["-i", file]) | ||
| ]; | ||
@@ -175,8 +776,12 @@ const child = spawn(codexBin, args, { | ||
| env: { ...process.env }, | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| shell: process.platform === "win32" | ||
| }); | ||
| child.stdin.write(promptText); | ||
| child.stdin.end(); | ||
| let stdout = ""; | ||
| let stderr = ""; | ||
| let timedOut = false; | ||
| let aborted = false; | ||
| let killTimer; | ||
| let resolvedThreadId = options.threadId; | ||
@@ -188,2 +793,12 @@ let resolvedContent = ""; | ||
| }, codexTimeoutMs()); | ||
| const abortCodex = () => { | ||
| if (aborted) | ||
| return; | ||
| aborted = true; | ||
| child.kill("SIGTERM"); | ||
| killTimer = setTimeout(() => { | ||
| child.kill("SIGKILL"); | ||
| }, 5_000); | ||
| }; | ||
| options.signal?.addEventListener("abort", abortCodex, { once: true }); | ||
| child.stdout.on("data", (chunk) => { | ||
@@ -198,2 +813,6 @@ stdout += chunk.toString(); | ||
| clearTimeout(timeout); | ||
| if (killTimer) { | ||
| clearTimeout(killTimer); | ||
| } | ||
| options.signal?.removeEventListener("abort", abortCodex); | ||
| const lines = stdout | ||
@@ -211,2 +830,6 @@ .split(/\r?\n/) | ||
| } | ||
| const traceEvent = trace.enabled ? codexEventTrace(event, traceMode) : null; | ||
| if (traceEvent) { | ||
| trace.add(traceEvent.type, traceEvent.title, traceEvent.status, traceEvent.metadata); | ||
| } | ||
| if (event.type === "item.completed" && event.item?.type === "agent_message") { | ||
@@ -224,11 +847,32 @@ resolvedContent = event.item.text ?? resolvedContent; | ||
| if (code === 0 && resolvedContent.trim()) { | ||
| if (!options.threadId && resolvedThreadId) { | ||
| markWorkspaceThreadInjected(workDir, configContext.signature, resolvedThreadId); | ||
| } | ||
| trace.add("runtime.completed", "Runtime 执行完成", "success", { | ||
| threadId: resolvedThreadId, | ||
| outputChars: resolvedContent.trim().length | ||
| }); | ||
| resolve({ | ||
| content: resolvedContent.trim(), | ||
| threadId: resolvedThreadId | ||
| threadId: resolvedThreadId, | ||
| traceEvents: trace.events | ||
| }); | ||
| return; | ||
| } | ||
| reject(new Error(timedOut ? `Codex CLI timed out after ${codexTimeoutMs()}ms` : normalizeCliError(stderr) || "Codex exec failed")); | ||
| trace.add("runtime.failed", "Runtime 执行失败", "failed", { | ||
| timedOut, | ||
| aborted, | ||
| error: aborted | ||
| ? "Runtime task cancelled; Codex CLI process was terminated" | ||
| : timedOut | ||
| ? `Codex CLI timed out after ${codexTimeoutMs()}ms` | ||
| : normalizeCliError(stderr) || "Codex exec failed" | ||
| }); | ||
| reject(new Error(aborted | ||
| ? "Runtime task cancelled; Codex CLI process was terminated" | ||
| : timedOut | ||
| ? `Codex CLI timed out after ${codexTimeoutMs()}ms` | ||
| : normalizeCliError(stderr) || "Codex exec failed")); | ||
| }); | ||
| }); | ||
| } |
+616
-138
| #!/usr/bin/env node | ||
| import { execFile, spawn } from "node:child_process"; | ||
| import { createHash } from "node:crypto"; | ||
| import { existsSync } from "node:fs"; | ||
| import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; | ||
| import os from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { createInterface } from "node:readline/promises"; | ||
| import { stdin as input, stdout as output } from "node:process"; | ||
| import { promisify } from "node:util"; | ||
| import { resolveConfig, resolveUrl } from "./lib/config.js"; | ||
| import { claimRuntimeTask, completeRuntimeTask, heartbeatRuntimeClient, listSessions, onboardProvider, registerRuntimeClient, testProvider, unbindProvider } from "./lib/client.js"; | ||
| import { claimRuntimeTask, completeRuntimeTask, getRuntimeTaskControlState, heartbeatRuntimeClient, listRuntimeClients, listSessions, onboardProvider, registerRuntimeClient, renewRuntimeTaskLease, testProvider, unbindProvider } from "./lib/client.js"; | ||
| import { printJson, printKeyValue, printSessions } from "./lib/output.js"; | ||
| import { detectCodexBinary, runLocalCodexTask, verifyCodexBinary } from "./lib/runtime.js"; | ||
| import { detectCodexBinary, isMockRuntimeProviderEnabled, runLocalCodexTask, runWorkspaceOperationTask, verifyCodexBinary } from "./lib/runtime.js"; | ||
| const RUNTIME_CLIENT_VERSION = "0.1.1"; | ||
| const DAEMON_ROOT_DIR = join(os.tmpdir(), "agent-runtime"); | ||
| const PROFILE_ROOT_DIR = join(os.homedir(), ".agent-runtime"); | ||
| const PROFILE_FILE = join(PROFILE_ROOT_DIR, "profile.json"); | ||
| const execFileAsync = promisify(execFile); | ||
| function parseArgs(argv) { | ||
@@ -63,2 +73,8 @@ if (argv[0] === "--help" || argv[0] === "-h") { | ||
| } | ||
| function parseProvider(value) { | ||
| if (!["codex", "claude_code", "custom"].includes(value)) { | ||
| throw new Error("provider must be one of: codex, claude_code, custom"); | ||
| } | ||
| return value; | ||
| } | ||
| async function promptText(question, defaultValue) { | ||
@@ -98,2 +114,5 @@ const rl = createInterface({ input, output }); | ||
| Usage: | ||
| agent-runtime start --provider codex --gateway-url http://host:3010 [--runtime-host WIN-DEV01] [--daemon] [--json] | ||
| agent-runtime restart --provider codex --gateway-url http://host:3010 [--runtime-host WIN-DEV01] [--daemon] [--json] | ||
| agent-runtime status --provider codex --gateway-url http://host:3010 [--runtime-host WIN-DEV01] [--daemon] [--json] | ||
| agent-runtime runtime serve --provider codex --gateway-url http://host:3010 [--runtime-host WIN-DEV01] [--json] | ||
@@ -123,5 +142,434 @@ agent-runtime onboard --provider codex --gateway-url http://host:3010 --platform-url http://host:3001/api/v1 --bot-key botkey_xxx [--gateway-label dev-gateway] [--runtime-host user@host] [--json] | ||
| } | ||
| function sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
| function readPositiveIntEnv(name, fallback, minimum = 1) { | ||
| const raw = Number.parseInt(process.env[name] ?? "", 10); | ||
| if (!Number.isFinite(raw) || raw < minimum) | ||
| return fallback; | ||
| return Math.floor(raw); | ||
| } | ||
| function buildRuntimeCapabilities(provider) { | ||
| const mock = isMockRuntimeProviderEnabled(); | ||
| return { | ||
| schemaVersion: 1, | ||
| executionMode: mock ? "mock" : "spawn_per_task", | ||
| provider, | ||
| shell: { | ||
| available: !mock, | ||
| sandbox: mock ? "none" : "workspace_write" | ||
| }, | ||
| filesystem: { | ||
| workspaceRead: true, | ||
| workspaceWrite: !mock, | ||
| externalRead: "unsupported", | ||
| externalWrite: "unsupported" | ||
| }, | ||
| network: { | ||
| available: !mock, | ||
| mode: "grant_required", | ||
| hostScopedEnforcement: false | ||
| }, | ||
| web: { | ||
| available: false | ||
| }, | ||
| attachments: { | ||
| download: true, | ||
| images: provider === "codex" | ||
| }, | ||
| skills: { | ||
| available: provider === "codex", | ||
| discovery: provider === "codex" ? "codex_builtin" : "unknown" | ||
| }, | ||
| trace: { | ||
| available: true, | ||
| modes: ["off", "light", "debug"] | ||
| }, | ||
| notes: [ | ||
| "network requires task-level authorization; hostScopedEnforcement is not yet enforced by Runtime Client", | ||
| "external file grants currently support platform preview and Project Memory import; Runtime shell access outside workspace is not exposed", | ||
| "web browsing is not exposed as a Runtime Client tool", | ||
| "skills are executed by the underlying provider and are not enumerated by Runtime Client" | ||
| ] | ||
| }; | ||
| } | ||
| function isProcessAlive(pid) { | ||
| if (!Number.isInteger(pid) || pid <= 0) { | ||
| return false; | ||
| } | ||
| try { | ||
| process.kill(pid, 0); | ||
| return true; | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| } | ||
| function daemonPaths(provider, gatewayUrl, runtimeHostLabel) { | ||
| const safeHost = runtimeHostLabel.replace(/[^a-zA-Z0-9._-]+/g, "-"); | ||
| const hash = createHash("sha1").update(`${provider}|${gatewayUrl}|${runtimeHostLabel}`).digest("hex").slice(0, 12); | ||
| const key = `${provider}-${safeHost}-${hash}`; | ||
| return { | ||
| key, | ||
| pidFile: join(DAEMON_ROOT_DIR, `${key}.pid`), | ||
| metaFile: join(DAEMON_ROOT_DIR, `${key}.json`) | ||
| }; | ||
| } | ||
| async function readDaemonMeta(paths) { | ||
| if (!existsSync(paths.metaFile)) { | ||
| return null; | ||
| } | ||
| try { | ||
| const raw = await readFile(paths.metaFile, "utf8"); | ||
| return JSON.parse(raw); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function writeDaemonMeta(paths, payload) { | ||
| await mkdir(DAEMON_ROOT_DIR, { recursive: true }); | ||
| await writeFile(paths.metaFile, JSON.stringify(payload, null, 2), "utf8"); | ||
| await writeFile(paths.pidFile, String(payload.pid), "utf8"); | ||
| } | ||
| async function clearDaemonFiles(paths) { | ||
| await rm(paths.pidFile, { force: true }).catch(() => undefined); | ||
| await rm(paths.metaFile, { force: true }).catch(() => undefined); | ||
| } | ||
| function runtimeServeCommandMatches(command, provider, gatewayUrl, runtimeHostLabel) { | ||
| const normalized = command.replace(/\s+/g, " ").trim(); | ||
| if (!normalized.includes(" runtime serve ")) | ||
| return false; | ||
| if (!normalized.includes("agent-runtime") && !normalized.includes("dist/main.js")) | ||
| return false; | ||
| return (normalized.includes(`--provider ${provider}`) && | ||
| normalized.includes(`--gateway-url ${gatewayUrl}`) && | ||
| normalized.includes(`--runtime-host ${runtimeHostLabel}`)); | ||
| } | ||
| async function stopMatchingRuntimeServeProcesses(provider, gatewayUrl, runtimeHostLabel) { | ||
| const output = await execFileAsync("ps", ["-axo", "pid=,command="], { maxBuffer: 1024 * 1024 }).catch(() => null); | ||
| const stdout = output?.stdout ?? ""; | ||
| const pids = stdout | ||
| .split("\n") | ||
| .map((line) => { | ||
| const match = line.match(/^\s*(\d+)\s+(.+)$/); | ||
| if (!match) | ||
| return null; | ||
| const pid = Number(match[1]); | ||
| const command = match[2] ?? ""; | ||
| if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) | ||
| return null; | ||
| return runtimeServeCommandMatches(command, provider, gatewayUrl, runtimeHostLabel) ? pid : null; | ||
| }) | ||
| .filter((pid) => typeof pid === "number"); | ||
| for (const pid of pids) { | ||
| try { | ||
| process.kill(pid, "SIGTERM"); | ||
| } | ||
| catch { | ||
| // Process may already have exited. | ||
| } | ||
| } | ||
| if (pids.length) { | ||
| await sleep(800); | ||
| } | ||
| for (const pid of pids) { | ||
| if (!isProcessAlive(pid)) | ||
| continue; | ||
| try { | ||
| process.kill(pid, "SIGKILL"); | ||
| } | ||
| catch { | ||
| // Process may already have exited. | ||
| } | ||
| } | ||
| return pids; | ||
| } | ||
| async function readRuntimeProfile() { | ||
| if (!existsSync(PROFILE_FILE)) { | ||
| return null; | ||
| } | ||
| try { | ||
| const raw = await readFile(PROFILE_FILE, "utf8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (typeof parsed.provider !== "string" || | ||
| typeof parsed.gatewayUrl !== "string" || | ||
| typeof parsed.runtimeHostLabel !== "string" || | ||
| typeof parsed.runtimeUserLabel !== "string") { | ||
| return null; | ||
| } | ||
| return { | ||
| provider: parseProvider(parsed.provider), | ||
| gatewayUrl: parsed.gatewayUrl.trim(), | ||
| runtimeHostLabel: parsed.runtimeHostLabel.trim(), | ||
| runtimeUserLabel: parsed.runtimeUserLabel.trim(), | ||
| updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString() | ||
| }; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function writeRuntimeProfile(profile) { | ||
| await mkdir(PROFILE_ROOT_DIR, { recursive: true }); | ||
| await writeFile(PROFILE_FILE, JSON.stringify(profile, null, 2), "utf8"); | ||
| } | ||
| async function stopRuntimeDaemon(paths) { | ||
| const meta = await readDaemonMeta(paths); | ||
| const pidFromFile = existsSync(paths.pidFile) ? Number((await readFile(paths.pidFile, "utf8")).trim()) : NaN; | ||
| const pid = Number.isInteger(meta?.pid) ? meta?.pid : pidFromFile; | ||
| if (!Number.isInteger(pid) || pid <= 0) { | ||
| await clearDaemonFiles(paths); | ||
| return { | ||
| stopped: false, | ||
| reason: "not_found" | ||
| }; | ||
| } | ||
| if (!isProcessAlive(pid)) { | ||
| await clearDaemonFiles(paths); | ||
| return { | ||
| stopped: false, | ||
| reason: "already_stopped", | ||
| pid | ||
| }; | ||
| } | ||
| try { | ||
| process.kill(pid, "SIGTERM"); | ||
| } | ||
| catch { | ||
| await clearDaemonFiles(paths); | ||
| return { | ||
| stopped: false, | ||
| reason: "signal_failed", | ||
| pid | ||
| }; | ||
| } | ||
| for (let i = 0; i < 20; i += 1) { | ||
| if (!isProcessAlive(pid)) { | ||
| await clearDaemonFiles(paths); | ||
| return { | ||
| stopped: true, | ||
| reason: "terminated", | ||
| pid | ||
| }; | ||
| } | ||
| await sleep(100); | ||
| } | ||
| try { | ||
| process.kill(pid, "SIGKILL"); | ||
| } | ||
| catch { | ||
| // ignore | ||
| } | ||
| await clearDaemonFiles(paths); | ||
| return { | ||
| stopped: true, | ||
| reason: "killed", | ||
| pid | ||
| }; | ||
| } | ||
| async function runRuntimeServe(params) { | ||
| const { gatewayUrl, provider, runtimeHostLabel, json } = params; | ||
| const runtimeUserLabel = os.userInfo().username; | ||
| const runtimePlatform = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; | ||
| const codexBin = isMockRuntimeProviderEnabled() ? "mock-codex" : verifyCodexBinary(detectCodexBinary()); | ||
| const heartbeatIntervalMs = readPositiveIntEnv("AGENT_RUNTIME_HEARTBEAT_INTERVAL_MS", 15_000, 100); | ||
| const workIntervalMs = readPositiveIntEnv("AGENT_RUNTIME_WORK_INTERVAL_MS", 1000, 50); | ||
| const leaseIntervalMs = readPositiveIntEnv("AGENT_RUNTIME_LEASE_INTERVAL_MS", 20_000, 50); | ||
| const controlIntervalMs = readPositiveIntEnv("AGENT_RUNTIME_CONTROL_INTERVAL_MS", 3000, 50); | ||
| const registrationPayload = { | ||
| gatewayUrl, | ||
| runtimeUserLabel, | ||
| runtimeHostLabel, | ||
| runtimePlatform, | ||
| codexBin, | ||
| clientVersion: RUNTIME_CLIENT_VERSION, | ||
| capabilities: buildRuntimeCapabilities(provider) | ||
| }; | ||
| let registration = await registerRuntimeClient(gatewayUrl, provider, registrationPayload); | ||
| let isRegistering = false; | ||
| let isWorking = false; | ||
| const reconnect = async (reason) => { | ||
| if (isRegistering) { | ||
| return; | ||
| } | ||
| isRegistering = true; | ||
| try { | ||
| registration = await registerRuntimeClient(gatewayUrl, provider, registrationPayload); | ||
| if (!json) { | ||
| process.stdout.write(`[reconnect] runtime client re-registered: ${registration.clientId} (${reason})\n`); | ||
| } | ||
| } | ||
| catch (error) { | ||
| if (!json) { | ||
| process.stderr.write(`[reconnect] ${error instanceof Error ? error.message : String(error)}\n`); | ||
| } | ||
| } | ||
| finally { | ||
| isRegistering = false; | ||
| } | ||
| }; | ||
| if (json) { | ||
| printJson(registration); | ||
| } | ||
| else { | ||
| printKeyValue("Runtime client registered", [ | ||
| ["provider", registration.provider], | ||
| ["clientId", registration.clientId], | ||
| ["runtimeUser", runtimeUserLabel], | ||
| ["runtimeHost", runtimeHostLabel], | ||
| ["runtimePlatform", runtimePlatform], | ||
| ["codexBin", codexBin], | ||
| ["status", registration.status] | ||
| ]); | ||
| process.stdout.write("Heartbeat + task loop started.\n"); | ||
| } | ||
| const tick = async () => { | ||
| try { | ||
| const heartbeat = await heartbeatRuntimeClient(gatewayUrl, provider, registration.clientId, { | ||
| runtimeUserLabel, | ||
| runtimeHostLabel, | ||
| runtimePlatform, | ||
| codexBin, | ||
| capabilities: buildRuntimeCapabilities(provider) | ||
| }); | ||
| if (!json) { | ||
| process.stdout.write(`[heartbeat] ${heartbeat.lastHeartbeatAt ?? new Date().toISOString()}\n`); | ||
| } | ||
| } | ||
| catch (error) { | ||
| process.stderr.write(`[heartbeat] ${error instanceof Error ? error.message : String(error)}\n`); | ||
| if (isRecoverableRuntimeClientError(error)) { | ||
| await reconnect("heartbeat failed"); | ||
| } | ||
| } | ||
| }; | ||
| const work = async () => { | ||
| if (isWorking || isRegistering) { | ||
| return; | ||
| } | ||
| isWorking = true; | ||
| let claimed = null; | ||
| let leaseTimer; | ||
| let controlTimer; | ||
| const abortController = new AbortController(); | ||
| try { | ||
| claimed = await claimRuntimeTask(gatewayUrl, provider, registration.clientId).catch(async (error) => { | ||
| if (isRecoverableRuntimeClientError(error)) { | ||
| await reconnect("task claim failed"); | ||
| return null; | ||
| } | ||
| throw error; | ||
| }); | ||
| if (!claimed?.task) { | ||
| return; | ||
| } | ||
| const renewLease = async () => { | ||
| try { | ||
| await renewRuntimeTaskLease(gatewayUrl, provider, registration.clientId, claimed.task.id); | ||
| } | ||
| catch (error) { | ||
| if (!json) { | ||
| process.stderr.write(`[lease] ${error instanceof Error ? error.message : String(error)}\n`); | ||
| } | ||
| if (isRecoverableRuntimeClientError(error)) { | ||
| await reconnect("task lease renew failed"); | ||
| } | ||
| } | ||
| }; | ||
| leaseTimer = setInterval(() => { | ||
| void renewLease(); | ||
| }, leaseIntervalMs); | ||
| const checkControlState = async () => { | ||
| try { | ||
| const control = await getRuntimeTaskControlState(gatewayUrl, provider, registration.clientId, claimed.task.id); | ||
| if (control.shouldStop && !abortController.signal.aborted) { | ||
| abortController.abort(control.cancelRequested ? "cancelled" : control.status); | ||
| if (!json) { | ||
| process.stderr.write(`[task] stop requested for ${claimed.task.id}: ${control.status}\n`); | ||
| } | ||
| } | ||
| } | ||
| catch (error) { | ||
| if (!json) { | ||
| process.stderr.write(`[control] ${error instanceof Error ? error.message : String(error)}\n`); | ||
| } | ||
| if (isRecoverableRuntimeClientError(error)) { | ||
| await reconnect("task control state failed"); | ||
| } | ||
| } | ||
| }; | ||
| controlTimer = setInterval(() => { | ||
| void checkControlState(); | ||
| }, controlIntervalMs); | ||
| const result = claimed.task.operationKind && claimed.task.operationKind !== "chat" | ||
| ? { | ||
| content: JSON.stringify(await runWorkspaceOperationTask({ | ||
| operationKind: claimed.task.operationKind, | ||
| operationPayload: claimed.task.operationPayload, | ||
| workspacePath: claimed.task.workspacePath | ||
| })), | ||
| threadId: undefined, | ||
| traceEvents: [] | ||
| } | ||
| : await runLocalCodexTask({ | ||
| codexBin, | ||
| prompt: claimed.task.prompt, | ||
| threadId: claimed.task.threadId, | ||
| workspacePath: claimed.task.workspacePath, | ||
| attachments: claimed.task.attachments, | ||
| platformApiUrl: claimed.task.platformApiUrl, | ||
| traceMode: claimed.task.traceMode, | ||
| networkAccessEnabled: claimed.task.networkAccessEnabled === true, | ||
| networkGrants: Array.isArray(claimed.task.networkGrants) ? claimed.task.networkGrants : [], | ||
| signal: abortController.signal | ||
| }); | ||
| await completeRuntimeTask(gatewayUrl, provider, registration.clientId, claimed.task.id, { | ||
| content: result.content, | ||
| threadId: result.threadId, | ||
| traceEvents: result.traceEvents | ||
| }); | ||
| if (!json) { | ||
| process.stdout.write(`[task] completed ${claimed.task.id} (${claimed.task.providerSessionId})\n`); | ||
| } | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| if (claimed?.task) { | ||
| await completeRuntimeTask(gatewayUrl, provider, registration.clientId, claimed.task.id, { | ||
| errorMessage: message | ||
| }).catch(async (completeError) => { | ||
| if (isRecoverableRuntimeClientError(completeError)) { | ||
| await reconnect("task result failed"); | ||
| } | ||
| }); | ||
| } | ||
| if (!json) { | ||
| process.stderr.write(`[task] ${message}\n`); | ||
| } | ||
| } | ||
| finally { | ||
| if (leaseTimer) { | ||
| clearInterval(leaseTimer); | ||
| } | ||
| if (controlTimer) { | ||
| clearInterval(controlTimer); | ||
| } | ||
| isWorking = false; | ||
| } | ||
| }; | ||
| await tick(); | ||
| await work(); | ||
| setInterval(() => { | ||
| void tick(); | ||
| }, heartbeatIntervalMs); | ||
| setInterval(() => { | ||
| void work(); | ||
| }, workIntervalMs); | ||
| return new Promise(() => undefined); | ||
| } | ||
| async function run() { | ||
| const parsed = parseArgs(process.argv.slice(2)); | ||
| const config = resolveConfig(); | ||
| const profile = await readRuntimeProfile(); | ||
| if (!parsed.command || hasFlag(parsed.flags, "help")) { | ||
@@ -132,3 +580,141 @@ printHelp(); | ||
| const json = hasFlag(parsed.flags, "json"); | ||
| const provider = readProvider(parsed.flags); | ||
| const providerInput = readStringFlag(parsed.flags, "provider"); | ||
| const provider = providerInput ? parseProvider(providerInput) : profile?.provider ?? "codex"; | ||
| if (parsed.command === "start" || parsed.command === "restart" || parsed.command === "status") { | ||
| const gatewayUrl = resolveUrl(readStringFlag(parsed.flags, "gateway-url"), config.gatewayUrl ?? profile?.gatewayUrl, "gatewayUrl"); | ||
| const runtimeHostLabel = readStringFlag(parsed.flags, "runtime-host") || profile?.runtimeHostLabel || os.hostname(); | ||
| const runtimeUserLabel = os.userInfo().username; | ||
| const useDaemon = hasFlag(parsed.flags, "daemon"); | ||
| const paths = daemonPaths(provider, gatewayUrl, runtimeHostLabel); | ||
| if (parsed.command !== "status") { | ||
| await writeRuntimeProfile({ | ||
| provider, | ||
| gatewayUrl, | ||
| runtimeHostLabel, | ||
| runtimeUserLabel, | ||
| updatedAt: new Date().toISOString() | ||
| }); | ||
| } | ||
| if (parsed.command === "status") { | ||
| const meta = await readDaemonMeta(paths); | ||
| const pid = meta?.pid ?? (existsSync(paths.pidFile) ? Number((await readFile(paths.pidFile, "utf8")).trim()) : NaN); | ||
| const daemonRunning = Number.isInteger(pid) && isProcessAlive(pid); | ||
| const clients = await listRuntimeClients(gatewayUrl, provider).catch(() => []); | ||
| const matchedClient = clients.find((client) => client.runtimeUserLabel === runtimeUserLabel && client.runtimeHostLabel === runtimeHostLabel); | ||
| const payload = { | ||
| provider, | ||
| gatewayUrl, | ||
| runtimeUserLabel, | ||
| runtimeHostLabel, | ||
| daemon: { | ||
| enabled: useDaemon, | ||
| key: paths.key, | ||
| pid: Number.isInteger(pid) ? pid : undefined, | ||
| running: daemonRunning, | ||
| startedAt: meta?.startedAt | ||
| }, | ||
| runtimeClient: matchedClient | ||
| ? { | ||
| id: matchedClient.id, | ||
| status: matchedClient.status, | ||
| lastHeartbeatAt: matchedClient.lastHeartbeatAt ?? matchedClient.lastSeenAt | ||
| } | ||
| : null | ||
| }; | ||
| if (json) { | ||
| printJson(payload); | ||
| } | ||
| else { | ||
| printKeyValue("Runtime daemon status", [ | ||
| ["provider", provider], | ||
| ["gatewayUrl", gatewayUrl], | ||
| ["runtimeUser", runtimeUserLabel], | ||
| ["runtimeHost", runtimeHostLabel], | ||
| ["daemonKey", paths.key], | ||
| ["daemonPid", Number.isInteger(pid) ? pid : "-"], | ||
| ["daemonRunning", daemonRunning], | ||
| ["clientId", matchedClient?.id ?? "-"], | ||
| ["clientStatus", matchedClient?.status ?? "offline"], | ||
| ["lastHeartbeatAt", matchedClient?.lastHeartbeatAt ?? matchedClient?.lastSeenAt ?? "-"] | ||
| ]); | ||
| } | ||
| return; | ||
| } | ||
| if (parsed.command === "restart") { | ||
| await stopRuntimeDaemon(paths); | ||
| await stopMatchingRuntimeServeProcesses(provider, gatewayUrl, runtimeHostLabel); | ||
| await sleep(200); | ||
| } | ||
| if (!useDaemon) { | ||
| await runRuntimeServe({ | ||
| gatewayUrl, | ||
| provider, | ||
| runtimeHostLabel, | ||
| json | ||
| }); | ||
| return; | ||
| } | ||
| const existingMeta = await readDaemonMeta(paths); | ||
| if (existingMeta && isProcessAlive(existingMeta.pid)) { | ||
| if (json) { | ||
| printJson({ | ||
| ok: true, | ||
| action: parsed.command, | ||
| daemon: "already_running", | ||
| pid: existingMeta.pid, | ||
| key: paths.key | ||
| }); | ||
| } | ||
| else { | ||
| printKeyValue("Runtime daemon", [ | ||
| ["action", parsed.command], | ||
| ["status", "already running"], | ||
| ["daemonKey", paths.key], | ||
| ["pid", existingMeta.pid], | ||
| ["startedAt", existingMeta.startedAt] | ||
| ]); | ||
| } | ||
| return; | ||
| } | ||
| await stopMatchingRuntimeServeProcesses(provider, gatewayUrl, runtimeHostLabel); | ||
| const scriptPath = process.argv[1]; | ||
| const args = [scriptPath, "runtime", "serve", "--provider", provider, "--gateway-url", gatewayUrl, "--runtime-host", runtimeHostLabel]; | ||
| const child = spawn(process.execPath, args, { | ||
| cwd: process.cwd(), | ||
| detached: true, | ||
| stdio: "ignore", | ||
| env: process.env | ||
| }); | ||
| child.unref(); | ||
| const meta = { | ||
| pid: child.pid ?? -1, | ||
| provider, | ||
| gatewayUrl, | ||
| runtimeUserLabel, | ||
| runtimeHostLabel, | ||
| startedAt: new Date().toISOString() | ||
| }; | ||
| await writeDaemonMeta(paths, meta); | ||
| if (json) { | ||
| printJson({ | ||
| ok: true, | ||
| action: parsed.command, | ||
| daemon: "started", | ||
| key: paths.key, | ||
| pid: meta.pid | ||
| }); | ||
| } | ||
| else { | ||
| printKeyValue("Runtime daemon started", [ | ||
| ["action", parsed.command], | ||
| ["provider", provider], | ||
| ["gatewayUrl", gatewayUrl], | ||
| ["runtimeUser", runtimeUserLabel], | ||
| ["runtimeHost", runtimeHostLabel], | ||
| ["daemonKey", paths.key], | ||
| ["pid", meta.pid] | ||
| ]); | ||
| } | ||
| return; | ||
| } | ||
| if (parsed.command === "onboard") { | ||
@@ -142,5 +728,5 @@ const isInteractive = !json && | ||
| const gatewayUrlInput = isInteractive | ||
| ? await promptText("请输入 Gateway URL", readStringFlag(parsed.flags, "gateway-url") || config.gatewayUrl) | ||
| ? await promptText("请输入 Gateway URL", readStringFlag(parsed.flags, "gateway-url") || config.gatewayUrl || profile?.gatewayUrl) | ||
| : readStringFlag(parsed.flags, "gateway-url"); | ||
| const gatewayUrl = resolveUrl(gatewayUrlInput, config.gatewayUrl, "gatewayUrl"); | ||
| const gatewayUrl = resolveUrl(gatewayUrlInput, config.gatewayUrl ?? profile?.gatewayUrl, "gatewayUrl"); | ||
| const platformApiUrlInput = isInteractive | ||
@@ -158,3 +744,3 @@ ? await promptText("请输入 Platform API URL", readStringFlag(parsed.flags, "platform-url") || config.platformUrl || gatewayUrl.replace(/:3010\/?$/, ":3001/api/v1")) | ||
| const runtimeUserLabel = os.userInfo().username; | ||
| const runtimeHostLabel = readStringFlag(parsed.flags, "runtime-host") || os.hostname(); | ||
| const runtimeHostLabel = readStringFlag(parsed.flags, "runtime-host") || profile?.runtimeHostLabel || os.hostname(); | ||
| const runtimePlatform = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; | ||
@@ -170,2 +756,9 @@ const result = await onboardProvider(gatewayUrl, selectedProvider, { | ||
| }); | ||
| await writeRuntimeProfile({ | ||
| provider: selectedProvider, | ||
| gatewayUrl, | ||
| runtimeHostLabel, | ||
| runtimeUserLabel, | ||
| updatedAt: new Date().toISOString() | ||
| }); | ||
| if (json) { | ||
@@ -193,136 +786,21 @@ printJson(result); | ||
| } | ||
| const gatewayUrl = resolveUrl(readStringFlag(parsed.flags, "gateway-url"), config.gatewayUrl, "gatewayUrl"); | ||
| const runtimeUserLabel = os.userInfo().username; | ||
| const runtimeHostLabel = readStringFlag(parsed.flags, "runtime-host") || os.hostname(); | ||
| const runtimePlatform = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; | ||
| const codexBin = verifyCodexBinary(detectCodexBinary()); | ||
| const registrationPayload = { | ||
| const gatewayUrl = resolveUrl(readStringFlag(parsed.flags, "gateway-url"), config.gatewayUrl ?? profile?.gatewayUrl, "gatewayUrl"); | ||
| const runtimeHostLabel = readStringFlag(parsed.flags, "runtime-host") || profile?.runtimeHostLabel || os.hostname(); | ||
| await writeRuntimeProfile({ | ||
| provider, | ||
| gatewayUrl, | ||
| runtimeUserLabel, | ||
| runtimeHostLabel, | ||
| runtimePlatform, | ||
| codexBin, | ||
| clientVersion: RUNTIME_CLIENT_VERSION | ||
| }; | ||
| let registration = await registerRuntimeClient(gatewayUrl, provider, registrationPayload); | ||
| let isRegistering = false; | ||
| let isWorking = false; | ||
| const reconnect = async (reason) => { | ||
| if (isRegistering) { | ||
| return; | ||
| } | ||
| isRegistering = true; | ||
| try { | ||
| registration = await registerRuntimeClient(gatewayUrl, provider, registrationPayload); | ||
| if (!json) { | ||
| process.stdout.write(`[reconnect] runtime client re-registered: ${registration.clientId} (${reason})\n`); | ||
| } | ||
| } | ||
| catch (error) { | ||
| if (!json) { | ||
| process.stderr.write(`[reconnect] ${error instanceof Error ? error.message : String(error)}\n`); | ||
| } | ||
| } | ||
| finally { | ||
| isRegistering = false; | ||
| } | ||
| }; | ||
| if (json) { | ||
| printJson(registration); | ||
| } | ||
| else { | ||
| printKeyValue("Runtime client registered", [ | ||
| ["provider", registration.provider], | ||
| ["clientId", registration.clientId], | ||
| ["runtimeUser", runtimeUserLabel], | ||
| ["runtimeHost", runtimeHostLabel], | ||
| ["runtimePlatform", runtimePlatform], | ||
| ["codexBin", codexBin], | ||
| ["status", registration.status] | ||
| ]); | ||
| process.stdout.write("Heartbeat + task loop started.\n"); | ||
| } | ||
| const tick = async () => { | ||
| try { | ||
| const heartbeat = await heartbeatRuntimeClient(gatewayUrl, provider, registration.clientId, { | ||
| runtimeUserLabel, | ||
| runtimeHostLabel, | ||
| runtimePlatform, | ||
| codexBin | ||
| }); | ||
| if (!json) { | ||
| process.stdout.write(`[heartbeat] ${heartbeat.lastHeartbeatAt ?? new Date().toISOString()}\n`); | ||
| } | ||
| } | ||
| catch (error) { | ||
| process.stderr.write(`[heartbeat] ${error instanceof Error ? error.message : String(error)}\n`); | ||
| if (isRecoverableRuntimeClientError(error)) { | ||
| await reconnect("heartbeat failed"); | ||
| } | ||
| } | ||
| }; | ||
| const work = async () => { | ||
| if (isWorking || isRegistering) { | ||
| return; | ||
| } | ||
| isWorking = true; | ||
| let claimed = null; | ||
| try { | ||
| claimed = await claimRuntimeTask(gatewayUrl, provider, registration.clientId).catch(async (error) => { | ||
| if (isRecoverableRuntimeClientError(error)) { | ||
| await reconnect("task claim failed"); | ||
| return null; | ||
| } | ||
| throw error; | ||
| }); | ||
| if (!claimed?.task) { | ||
| return; | ||
| } | ||
| const result = await runLocalCodexTask({ | ||
| codexBin, | ||
| prompt: claimed.task.prompt, | ||
| threadId: claimed.task.threadId, | ||
| workspacePath: claimed.task.workspacePath, | ||
| attachments: claimed.task.attachments, | ||
| platformApiUrl: claimed.task.platformApiUrl | ||
| }); | ||
| await completeRuntimeTask(gatewayUrl, provider, registration.clientId, claimed.task.id, { | ||
| content: result.content, | ||
| threadId: result.threadId | ||
| }); | ||
| if (!json) { | ||
| process.stdout.write(`[task] completed ${claimed.task.id} (${claimed.task.providerSessionId})\n`); | ||
| } | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| if (claimed?.task) { | ||
| await completeRuntimeTask(gatewayUrl, provider, registration.clientId, claimed.task.id, { | ||
| errorMessage: message | ||
| }).catch(async (completeError) => { | ||
| if (isRecoverableRuntimeClientError(completeError)) { | ||
| await reconnect("task result failed"); | ||
| } | ||
| }); | ||
| } | ||
| if (!json) { | ||
| process.stderr.write(`[task] ${message}\n`); | ||
| } | ||
| } | ||
| finally { | ||
| isWorking = false; | ||
| } | ||
| }; | ||
| await tick(); | ||
| await work(); | ||
| setInterval(() => { | ||
| void tick(); | ||
| }, 15_000); | ||
| setInterval(() => { | ||
| void work(); | ||
| }, 1000); | ||
| return new Promise(() => undefined); | ||
| runtimeUserLabel: os.userInfo().username, | ||
| updatedAt: new Date().toISOString() | ||
| }); | ||
| await runRuntimeServe({ | ||
| gatewayUrl, | ||
| provider, | ||
| runtimeHostLabel, | ||
| json | ||
| }); | ||
| return; | ||
| } | ||
| if (parsed.command === "sessions") { | ||
| const gatewayUrl = resolveUrl(readStringFlag(parsed.flags, "gateway-url"), config.gatewayUrl, "gatewayUrl"); | ||
| const gatewayUrl = resolveUrl(readStringFlag(parsed.flags, "gateway-url"), config.gatewayUrl ?? profile?.gatewayUrl, "gatewayUrl"); | ||
| const result = await listSessions(gatewayUrl, provider); | ||
@@ -353,3 +831,3 @@ if (json) { | ||
| if (parsed.command === "test") { | ||
| const gatewayUrl = resolveUrl(readStringFlag(parsed.flags, "gateway-url"), config.gatewayUrl, "gatewayUrl"); | ||
| const gatewayUrl = resolveUrl(readStringFlag(parsed.flags, "gateway-url"), config.gatewayUrl ?? profile?.gatewayUrl, "gatewayUrl"); | ||
| const token = readStringFlag(parsed.flags, "token"); | ||
@@ -380,3 +858,3 @@ const botName = readStringFlag(parsed.flags, "bot-name"); | ||
| if (parsed.command === "unbind") { | ||
| const gatewayUrl = resolveUrl(readStringFlag(parsed.flags, "gateway-url"), config.gatewayUrl, "gatewayUrl"); | ||
| const gatewayUrl = resolveUrl(readStringFlag(parsed.flags, "gateway-url"), config.gatewayUrl ?? profile?.gatewayUrl, "gatewayUrl"); | ||
| const providerSessionId = readStringFlag(parsed.flags, "session-id"); | ||
@@ -383,0 +861,0 @@ if (!providerSessionId) { |
+1
-1
| { | ||
| "name": "@agent-hubs/runtime-client", | ||
| "private": false, | ||
| "version": "0.1.1", | ||
| "version": "0.1.2", | ||
| "description": "Agent Hub local runtime client for connecting local Codex CLI to a shared Gateway.", | ||
@@ -6,0 +6,0 @@ "type": "module", |
+7
-0
@@ -69,4 +69,11 @@ # @agent-hubs/runtime-client | ||
| - 领取任务并在本机执行 Codex CLI | ||
| - 执行中每 `20s` 续租一次任务 lease,保持 Platform 侧任务所有权 | ||
| - 执行中每 `3s` 查询一次任务 control-state;用户取消后会尝试终止本机 Codex/Provider 子进程 | ||
| - 回传结果 | ||
| 任务 lease 规则: | ||
| - 每次领取获得 `60s` lease,本机执行期间定时续租。 | ||
| - 如果 Gateway 或网络故障超过 lease 窗口,Platform liveness sweep 会将任务安全标记为失败并隔离 thread,不自动重复执行;恢复后需要从会话异常任务入口人工重放。 | ||
| ## 绑定机器人 | ||
@@ -73,0 +80,0 @@ |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 6 instances in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
80061
146.72%1857
157.2%162
4.52%18
125%3
50%