Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@agent-hubs/runtime-client

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@agent-hubs/runtime-client - npm Package Compare versions

Comparing version
0.1.1
to
0.1.2
+13
-0
dist/lib/client.js

@@ -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",

@@ -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 @@