Files
pikasTech-HWLAB/internal/cloud/codex-stdio-session.ts
T

1897 lines
69 KiB
TypeScript

import {
appServerActivityTimeoutError,
appServerHardTimeoutError,
normalizeAppServerTurnResult,
summarizeAppServerTurn,
codexStdioProviderTrace,
codexAppServerTextInput,
extractAppServerRecord,
extractAppServerString,
appServerTerminalStatus,
appServerCommandStatus,
commandExecutionCommandText,
commandExecutionOutputText,
commandExecutionErrorText,
firstCommandExecutionText,
commandExecutionTextValue,
longLivedSessionGate,
publicSession,
blockedAcquire,
runnerDescriptor,
runnerTrace,
sessionReuseEvidence,
extractCodexToolOutput,
summarizePrompt,
summarizeToolResult,
collectWorkspaceSidecarEvidence,
toolTraceEvent,
detectWorkspaceSidecarIntent,
extractWorkspaceTarget,
pwdToolCall,
lsToolCall,
workspaceSmokeToolCalls,
discoverSkillsForStdio,
resolveSkillDirs,
skillManifestPaths,
readSkillManifest,
parseFrontmatter,
firstMarkdownSummary,
firstManifestField,
boundSkillText,
dedupeSkills,
notRequestedSkills,
codexReplyWithSidecar,
skillsDiscoveryReply,
resolveWorkspaceTarget,
isPathInside,
isForbiddenPath,
blockedToolCall,
safeDisplayPath,
buildCodexUserPrompt,
normalizeGatewayShellTimeoutMs,
codexSidecarSkillsPrompt,
codexConversationFactsPrompt,
promptSkills,
promptToolCalls,
promptList,
safePromptFact,
resolveCodexWorkspace,
resolveCodexCommand,
resolveCodexSandbox,
resolveCodexHome,
codexStdioEnabled,
supervisorState,
tokenBoundaryState,
egressState,
workspaceStateSync,
directoryStateSync,
codexHomeFilesStateSync,
filePresentSync,
accessSyncBoolean,
pathReadableSync,
codexBinaryState,
codexNativeDependencyState,
findPackageRoot,
commandPathSync,
commandOnPathSync,
codexVersionProbe,
protocolState,
cachedProtocolReady,
cachedCommandProbe,
protocolProbeSummary,
commandProbeSummary,
lifecycleState,
workspaceContractState,
cancelReapTraceState,
runtimeContract,
codexHomeContractState,
childProcessEnv,
gitConfigEnv,
childProcessEnvState,
codexProviderNoProxyEntries,
mergeNoProxy,
splitNoProxy,
codexStdioSafety,
codexStdioError,
sessionExpired,
timestampFor,
plusMs,
requiredId,
optionalId,
positiveInteger,
firstNonEmpty,
hasEnvValue,
effectiveTimeout,
effectiveActivityTimeout,
effectiveHardTimeout,
dropEmpty,
parseJsonOrNull,
tailText,
boundToolOutput,
redactText
} from "./codex-stdio-session-helpers.ts";
export { longLivedSessionGate } from "./codex-stdio-session-helpers.ts";
import { createAppServerTurnState } from "./codex-stdio-session-turn-state.ts";
import { spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { accessSync, constants as fsConstants, existsSync, realpathSync, statSync } from "node:fs";
import { mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
createCodeAgentTraceRecorder,
defaultCodeAgentTraceStore,
runnerTraceFromSnapshot
} from "./code-agent-trace-store.ts";
import {
CODE_AGENT_SESSION_LIFECYCLE_STATUSES,
CODE_AGENT_SESSION_STATUS_ALIASES,
codeAgentSessionLifecycleSummary,
decorateCodeAgentSession
} from "./code-agent-session-lifecycle.ts";
export const CODEX_STDIO_PROVIDER = "codex-stdio";
export const CODEX_STDIO_BACKEND = "hwlab-cloud-api/codex-app-server-stdio";
export const CODEX_STDIO_RUNNER_KIND = "codex-app-server-stdio-runner";
export const CODEX_STDIO_SESSION_MODE = "codex-app-server-stdio-long-lived";
export const CODEX_STDIO_IMPLEMENTATION_TYPE = "repo-owned-codex-app-server-stdio-session";
export const CODEX_STDIO_CAPABILITY_LEVEL = "long-lived-codex-stdio-session";
export const CODEX_STDIO_SANDBOX = "workspace-write";
export const DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
export const DEFAULT_CODEX_STDIO_MAX_SESSIONS = 64;
export const DEFAULT_CODEX_STDIO_COMMAND = "codex";
export const DEFAULT_CODEX_STDIO_PROBE_TTL_MS = 30 * 1000;
export const DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
export const DEFAULT_CODEX_STDIO_TURN_HARD_TIMEOUT_MS = 10 * 60 * 1000;
export const DEFAULT_CODEX_STDIO_BUSY_STALE_MS = 2 * 60 * 1000;
export const CODEX_STDIO_SESSION_STATUSES = Object.freeze([
"creating",
"ready",
"busy",
"idle",
"timeout",
"error",
"canceled",
"interrupted",
"expired",
"failed"
]);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio";
export const CODEX_APP_SERVER_WIRE_API = "responses";
export const CODEX_APP_SERVER_REQUIRED_METHODS = Object.freeze(["initialize", "thread/start", "thread/resume", "turn/start"]);
export const CODEX_STDIO_TOOL_OUTPUT_LIMIT = 4000;
export const CODEX_STDIO_SKILL_LIMIT = 40;
export const CODEX_STDIO_SKILL_REPLY_LIMIT = 20;
export const CODEX_STDIO_SKILL_SUMMARY_LIMIT = 180;
export const CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS = 3000;
export const CODEX_STDIO_COMMAND_PROBE_TRACE_ID = "trc_codex_stdio_command_probe";
export const CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID = "cnv_codex_stdio_command_probe";
export const CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS = 15 * 1000;
export const CODEX_STDIO_NO_PROGRESS_DIAGNOSIS_MS = 60 * 1000;
export const CODEX_CHILD_STRIPPED_ENV_KEYS = Object.freeze([
"OPENAI_API_KEY",
"CODEX_API_KEY",
"HWLAB_CODE_AGENT_OPENAI_BASE_URL",
"OPENAI_BASE_URL",
"OPENAI_API_BASE",
"OPENAI_RESPONSES_URL",
"CODEX_OPENAI_BASE_URL",
"CODEX_BASE_URL",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy"
]);
export const CODEX_CHILD_NO_PROXY_REQUIRED = Object.freeze([
"hyueapi.com",
".hyueapi.com",
"hyui.com",
".hyui.com",
"127.0.0.1",
"localhost",
"::1",
"api.minimaxi.com",
".minimaxi.com"
]);
export const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [
"You are the HWLAB Cloud Workbench Code Agent.",
"Use the provided workspace and repo-owned Codex stdio session only.",
"Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.",
"For hardware, gateway, box-simu, patch-panel, DAP, PWM, Keil, serial, and Windows skill requests, execute the requested work through the repo-owned Codex stdio session with the available tran wrapper, skill CLI, or project tool that actually reaches the target.",
"For any request that mentions HWPOD, hwpod, hwpod-spec, hwpod-cli, hwpod-ctl, D601-F103-V2, or a registered hardware build/download/UART/debug-probe operation, first use the HWLAB internal skills named hwpod-cli and hwpod-ctl. The canonical skill locations are /app/skills/hwpod-cli/SKILL.md and /app/skills/hwpod-ctl/SKILL.md; read those manifests as needed. hwpod is the standard HWPOD task runner entry, and hwpod-ctl is the standard workspace-local spec management entry; if either required command is absent, report a runner image/package error instead of invoking a long-path launcher.",
"Do not pass --api-base-url, --api-url, --cloud-api-url, --base-url, or session tokens to hwpod in HWLAB runners. WEB and AgentRun assemble the current runtime endpoint and credential; hwpod must auto-locate from that assembled environment.",
"When a matching workspace-local hwpod-spec exists, keep hardware work on hwpod-cli and hwpod-ctl. Only drop to tran or Windows-side skills when the hwpod-cli or hwpod-ctl skill explicitly says to bootstrap, install, or repair the lower layer.",
"For registered PC gateway Windows command or skill requests, use the preloaded tran wrapper: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work <cmd|ps|upload|download> [options] -- <args>. The locator before ':' is the gateway session and the path after ':' is the Windows workspace, with /f/work and f:/work both mapping to F:\\work.",
"For Windows cmd, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- <command>. For PowerShell, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work ps -- <script>. The wrapper owns UTF-8, quoting, cwd, EncodedCommand, and stdout/stderr passthrough; do not hand-build nested cmd /c, cd &&, pipes, or quoting workarounds.",
"For reliable Windows file transfer, use tran upload/download instead of pasting large base64 through chat or shell output. Prefer the canonical tool workspace gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp when updating PC-side helper files.",
"If /app/tools/hwlab-gateway-tran.mjs itself is awkward, unreliable, or causes quoting, Chinese text, encoding, cwd, stdout/stderr, upload, or download friction, improve /app/tools/hwlab-gateway-tran.mjs first, then update the gateway-side copy at gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp before continuing the user task.",
"For Windows-side skills under C:\\Users\\liang\\.agents\\skills\\, read the skill manifest when needed and call its CLI through hwlab-gateway-tran.mjs using explicit paths or argument arrays. Do not reimplement skill logic.",
"For F:\\work or F:\\work\\ConStart discovery, first list top-level project markers and *.uvprojx candidates with bounded output. For Keil build/program requests, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\keil with py -3 keil-cli.py through hwlab-gateway-tran.mjs; do not reimplement Keil build logic.",
"For boot logs and UART capture, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\serial-monitor with npm run cli -- server status/start, monitor start, and fetch through hwlab-gateway-tran.mjs; for 71-FREQ prefer the skill-documented baud rate unless live evidence says otherwise.",
"For long Keil build/program/download jobs, prefer the skill CLI async job flow: start the job with a short bounded wrapper call, then poll job-status, state files, or logs with short wrapper calls. Do not use gateway --wait long polling unless the user explicitly asks for synchronous waiting and sets a sufficient gateway timeout.",
"If a Windows gateway command reaches the gateway but fails due script syntax or output size, simplify once and report the failed operationId plus the corrected bounded command evidence. Do not spend multiple turns on exploratory rewrites.",
"Use the Windows-side skill CLIs under C:\\Users\\liang\\.agents\\skills\\ from that cmd command when they exist; do not reimplement those tools in the prompt.",
"Do not satisfy PC gateway requests by string matching, assistant-only claims, internal shortcut dispatch, or direct gateway URLs; the Codex turn must run the wrapper and report command evidence.",
"Do not claim M3, M4, or M5 acceptance unless the response includes live HWLAB evidence."
].join("\n");
export function createCodexStdioSessionManager(options = {}) {
const idleTimeoutMs = positiveInteger(options.idleTimeoutMs, DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS);
const maxSessions = positiveInteger(options.maxSessions, DEFAULT_CODEX_STDIO_MAX_SESSIONS);
const probeCacheTtlMs = positiveInteger(options.probeCacheTtlMs, DEFAULT_CODEX_STDIO_PROBE_TTL_MS);
const busyStaleMs = positiveInteger(options.busyStaleMs, DEFAULT_CODEX_STDIO_BUSY_STALE_MS);
const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`;
const nowDefault = options.now;
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const sessions = new Map();
const conversations = new Map();
let rpcStartedAt = null;
let rpcInitialized = false;
const activeRpcClients = new Map();
let protocolProbe = null;
let commandProbe = null;
function describe(params = {}) {
const env = params.env ?? options.env ?? process.env;
const workspace = resolveCodexWorkspace(env, params);
const command = resolveCodexCommand(env, params, options);
const sandbox = resolveCodexSandbox(env, params);
const enabled = codexStdioEnabled(env, params, options);
const supervisor = supervisorState(env, params, options, enabled);
const binary = codexBinaryState(command, env);
const binaryOnPath = binary.present;
const workspaceInfo = workspaceStateSync(workspace, sandbox);
const codexHome = resolveCodexHome(env);
const codexHomeInfo = directoryStateSync(codexHome, { writableRequired: true });
const codexHomeFiles = codexHomeFilesStateSync(codexHome);
const tokenBoundary = tokenBoundaryState(env, codexHomeFiles);
const egress = egressState(env);
const childEnvBoundary = childProcessEnvState(env);
const protocol = protocolState({
command,
binary,
supervisor,
initialized: params.protocolInitialized ?? (rpcInitialized || cachedProtocolReady({ command, workspace, codexHome, probe: protocolProbe }))
});
const lifecycle = lifecycleState({
supervisor,
idleTimeoutMs,
maxSessions,
activeSessions: sessions.size
});
const blockers = [];
if (!binaryOnPath) {
blockers.push({
code: "codex_cli_binary_missing",
sourceIssue: "pikasTech/HWLAB#275",
summary: `Codex CLI command ${command} is not present on the runtime PATH.`,
evidence: binary.nextEvidence
});
} else if (binary.executable !== true) {
blockers.push({
code: "codex_cli_not_executable",
sourceIssue: "pikasTech/HWLAB#377",
summary: `Codex CLI command ${command} is present but not executable or --version failed.`,
evidence: binary.nextEvidence
});
} else if (binary.nativeDependencyPresent !== true) {
blockers.push({
code: "codex_cli_native_dependency_missing",
sourceIssue: "pikasTech/HWLAB#377",
summary: `Codex CLI command ${command} is present, but the native @openai/codex executable dependency was not found.`,
evidence: binary.nativeDependencyEvidence
});
}
if (!enabled || supervisor.configured !== true) {
blockers.push({
code: "codex_stdio_supervisor_disabled",
sourceIssue: "pikasTech/HWLAB#275",
summary: "Repo-owned Codex stdio supervisor is not enabled for this runtime."
});
blockers.push({
code: "runner_lifecycle_missing",
sourceIssue: "pikasTech/HWLAB#275",
summary: "Repo-owned lifecycle supervisor is not configured, so create/reuse/cancel/reap/trace readiness cannot pass."
});
}
if (!protocol.wired) {
blockers.push({
code: "stdio_protocol_not_wired",
sourceIssue: "pikasTech/HWLAB#275",
summary: protocol.summary,
evidence: protocol.nextEvidence
});
}
const commandProbeState = commandProbeSummary({ command, workspace, codexHome, probe: commandProbe });
if (commandProbeState.status === "blocked") {
blockers.push({
code: "codex_stdio_command_probe_failed",
sourceIssue: "pikasTech/HWLAB#275",
summary: "Codex stdio command/session probe failed on the same controlled workspace tool path used by /v1/agent/chat.",
evidence: commandProbeState.error
});
}
if (!workspaceInfo.exists || !workspaceInfo.readable) {
blockers.push({
code: "workspace_mount_missing",
sourceIssue: "pikasTech/HWLAB#275",
summary: `Configured Codex workspace is not mounted/readable: ${workspace}.`
});
}
if (sandbox === "workspace-write" && workspaceInfo.writable !== true) {
blockers.push({
code: "workspace_write_boundary_blocked",
sourceIssue: "pikasTech/HWLAB#275",
summary: `Configured Codex workspace is not writable for workspace-write sandbox: ${workspace}.`
});
}
if (!codexHomeInfo.exists || !codexHomeInfo.readable) {
blockers.push({
code: "codex_home_missing",
sourceIssue: "pikasTech/HWLAB#377",
summary: `Configured CODEX_HOME is not present/readable: ${codexHome}.`
});
}
if (codexHomeInfo.writable !== true) {
blockers.push({
code: "codex_home_write_blocked",
sourceIssue: "pikasTech/HWLAB#377",
summary: `Configured CODEX_HOME is not writable for Codex session state: ${codexHome}.`
});
}
if (codexHomeFiles.configPresent !== true || codexHomeFiles.configReadable !== true) {
blockers.push({
code: "codex_home_config_missing",
sourceIssue: "pikasTech/HWLAB#426",
summary: `Configured CODEX_HOME does not expose readable Codex config.toml: ${codexHomeFiles.configPath}.`
});
}
if (codexHomeFiles.authPresent !== true || codexHomeFiles.authReadable !== true) {
blockers.push({
code: "codex_home_auth_missing",
sourceIssue: "pikasTech/HWLAB#426",
summary: `Configured CODEX_HOME does not expose readable Codex auth.json: ${codexHomeFiles.authPath}.`
});
}
if (!tokenBoundary.present) {
blockers.push({
code: "provider_token_boundary",
sourceIssue: "pikasTech/HWLAB#426",
summary: "A long-lived Codex app-server runner needs a repo-owned /codex-home auth boundary; this endpoint only reports file presence and never reads or prints token material."
});
}
if (childEnvBoundary.forbiddenEnvPresent) {
blockers.push({
code: "codex_child_env_polluted",
sourceIssue: "pikasTech/HWLAB#426",
summary: "Codex child process environment still contains provider/proxy env keys that must be stripped before app-server startup."
});
}
if (egress.directPublicOpenAi) {
blockers.push({
code: "codex_stdio_egress_boundary",
sourceIssue: "pikasTech/HWLAB#426",
summary: "Codex app-server provider egress must use /codex-home config/auth and DEV egress boundary, not direct public api.openai.com env fallback."
});
}
const ready = blockers.length === 0;
const startupBlockers = blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired");
const startupReady = startupBlockers.length === 0 && protocol.probeReady === true;
return {
kind: CODEX_STDIO_RUNNER_KIND,
provider: CODEX_STDIO_PROVIDER,
backend: CODEX_STDIO_BACKEND,
status: ready ? "feasible" : "blocked",
ready,
startupReady,
canStartLongLivedCodexStdio: startupReady,
command,
binary,
binaryOnPath,
workspace,
workspaceState: workspaceInfo,
codexHome,
codexHomeState: codexHomeInfo,
codexHomeFiles,
sandbox,
enabled,
supervisor,
tokenBoundary,
egress,
childEnvBoundary,
protocol,
stdioProtocol: protocol,
protocolProbe: protocolProbeSummary({ command, workspace, codexHome, probe: protocolProbe }),
commandProbe: commandProbeState,
sessionLifecycle: lifecycle,
lifecycleStatuses: [...CODE_AGENT_SESSION_LIFECYCLE_STATUSES],
statusAliases: { ...CODE_AGENT_SESSION_STATUS_ALIASES },
lifecycleSupervisor: lifecycle,
recentSessions: recentSessions(),
statusCounts: sessionStatusCounts(),
workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace),
cancelReapTraceReadiness: cancelReapTraceState(lifecycle),
runtimeContract: runtimeContract({
ready,
binary,
protocol,
lifecycle,
workspaceInfo,
workspace,
sandbox,
codexHome,
codexHomeInfo,
codexHomeFiles,
tokenBoundary,
egress,
childEnvBoundary,
commandProbe: commandProbeState
}),
blockers,
blockerCodes: blockers.map((blocker) => blocker.code),
safety: codexStdioSafety()
};
}
async function chat(params = {}) {
const env = params.env ?? options.env ?? process.env;
const now = params.now ?? nowDefault;
const timestamp = timestampFor(now);
const traceId = optionalId(params.traceId) ?? `trc_${randomUUID()}`;
const conversationId = requiredId(params.conversationId, "cnv");
const workspace = resolveCodexWorkspace(env, params);
const sandbox = resolveCodexSandbox(env, params);
const traceRecorder = params.traceRecorder ?? createCodeAgentTraceRecorder({
traceStore,
traceId,
now,
runnerKind: CODEX_STDIO_RUNNER_KIND,
workspace,
sandbox,
sessionMode: CODEX_STDIO_SESSION_MODE,
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE
});
traceRecorder.append({
type: "request",
status: "received",
label: "request:received",
promptSummary: summarizePrompt(params.message),
waitingFor: "runtime-readiness"
});
let availability = describe({ ...params, env, workspace, sandbox });
const startupBlockers = availability.blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired");
if (startupBlockers.length > 0) {
traceRecorder.append({
type: "error",
status: "blocked",
label: "runtime-readiness:blocked",
errorCode: startupBlockers[0]?.code ?? "codex_stdio_blocked",
message: startupBlockers[0]?.summary ?? "Codex stdio session is blocked by runtime readiness gates.",
terminal: true
});
throw codexStdioError("codex_stdio_blocked", "Codex stdio session is blocked by runtime readiness gates.", {
availability: {
...availability,
blockers: startupBlockers,
blockerCodes: startupBlockers.map((blocker) => blocker.code)
},
blockers: startupBlockers
});
}
const acquire = await acquireSession({
conversationId,
requestedSessionId: params.sessionId,
threadId: params.threadId,
traceId,
workspace,
sandbox,
now
});
if (!acquire.ok) {
traceRecorder.append({
type: "session",
status: "blocked",
label: `session:${acquire.code}`,
errorCode: acquire.code,
message: acquire.message,
sessionId: acquire.session?.sessionId,
sessionStatus: acquire.session?.status,
terminal: true
});
throw codexStdioError(acquire.code, acquire.message, {
availability,
session: acquire.session,
blockers: [acquire.blocker],
runnerTrace: runnerTrace({
traceRecorder,
traceId,
workspace,
sandbox,
session: acquire.session,
startedAt: timestamp,
outputTruncated: false
}),
providerTrace: codexStdioProviderTrace({
availability,
toolName: `codex-stdio.${acquire.code}`,
turnResult: {
threadId: acquire.session?.threadId ?? null,
turnId: null,
terminalStatus: "blocked",
appServerExit: null
},
session: acquire.session
})
});
}
let session = acquire.session;
const startedAt = timestamp;
traceRecorder.append({
type: "session",
status: session.reused ? "reused" : "created",
label: session.reused ? "session:reused" : "session:created",
sessionId: session.sessionId,
sessionStatus: session.status,
sessionReused: session.reused,
turn: session.turn,
waitingFor: "stdio-client"
});
try {
let client = null;
client = await createTurnRpcClient({ env, availability, timeoutMs: params.timeoutMs, sessionId: session.sessionId });
availability = describe({ ...params, env, workspace, sandbox });
traceRecorder.append({
type: "stdio",
status: "ready",
label: "stdio:ready",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
waitingFor: "prompt-send"
});
const sidecar = await collectWorkspaceSidecarEvidence({
message: params.message,
workspace,
traceId,
env,
now,
skillsDirs: params.skillsDirs,
skillsDirsExact: params.skillsDirsExact
});
for (const event of sidecar.events) traceRecorder.append(event);
if (sidecar.intent?.skills && sidecar.skills?.status !== "ready") {
session = releaseSession(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
status: "idle"
}) ?? session;
traceRecorder.append({
type: "error",
status: "blocked",
label: "skills.discover:blocked",
toolName: "skills.discover",
errorCode: "skills_unavailable",
message: "No readable SKILL.md files were found in configured skills directories; skills discovery is blocked before any generic model answer.",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
waitingFor: "skills-manifest",
terminal: true
});
throw codexStdioError("skills_unavailable", "Code Agent Skill 清单未挂载或不可读;已通过 Codex stdio runner 建立 trace,但不能编造 skills 列表。", {
availability,
session,
toolCalls: sidecar.toolCalls,
skills: sidecar.skills,
blockers: sidecar.skills.blockers,
runnerTrace: runnerTrace({
traceRecorder,
traceId,
workspace,
sandbox,
session,
startedAt,
outputTruncated: sidecar.outputTruncated
}),
preserveSessionStatus: true,
retryable: false,
userMessage: "技能发现受阻:运行时没有返回可读取的 SKILL.md 清单;本次不会用泛化文本编造可用 skill。请关联 #136/#237 补齐 skills 挂载或 manifest。",
route: "/v1/agent/chat",
toolName: "skills.discover",
sessionMode: CODEX_STDIO_SESSION_MODE,
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
runnerLimitations: ["skills-manifest-unavailable", "no-text-fallback-for-skills-discovery"]
});
}
const prompt = buildCodexUserPrompt(params.message, {
conversationId,
traceId,
conversationFacts: params.conversationFacts,
sidecar,
gatewayShellTimeoutMs: normalizeGatewayShellTimeoutMs(params.gatewayShellTimeoutMs, env)
});
const toolName = session.threadId ? "codex-app-server.thread/resume+turn/start" : "codex-app-server.thread/start+turn/start";
traceRecorder.append({
type: "prompt",
status: "sent",
label: "prompt:sent",
toolName,
promptSummary: summarizePrompt(params.message),
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
waitingFor: "app-server-thread"
});
traceRecorder.append({
type: "tool_call",
status: "started",
label: `tool:${toolName}:started`,
toolName,
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
waitingFor: "app-server-turn"
});
const turnResult = await runAppServerTurn({
client,
session,
prompt,
workspace,
sandbox,
model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"),
traceRecorder,
timeoutMs: effectiveActivityTimeout(params.timeoutMs),
hardTimeoutMs: effectiveHardTimeout(params.hardTimeoutMs, params.timeoutMs)
});
traceRecorder.append({
type: "tool_call",
status: "output_chunk",
label: `tool:${toolName}:output_chunk`,
toolName,
outputSummary: summarizeAppServerTurn(turnResult),
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
waitingFor: "assistant-message"
});
if (turnResult.terminalStatus !== "completed") {
const providerTrace = codexStdioProviderTrace({ availability, toolName, turnResult, session });
throw codexStdioError("codex_stdio_failed", redactText(turnResult.terminalError || `Codex app-server turn finished with status=${turnResult.terminalStatus || "unknown"}.`), {
availability,
session,
terminalStatus: turnResult.terminalStatus,
threadId: turnResult.threadId,
turnId: turnResult.turnId,
appServerExit: turnResult.appServerExit,
idleMs: turnResult.idleMs,
lastActivityAt: turnResult.lastActivityAt,
waitingFor: turnResult.waitingFor,
diagnosis: turnResult.diagnosis,
providerTrace
});
}
const assistantContent = redactText(firstNonEmpty(turnResult.finalResponse, turnResult.assistantText));
if (!assistantContent) {
throw codexStdioError("codex_stdio_empty_response", "Codex app-server turn completed without assistant content.", {
availability,
session,
terminalStatus: turnResult.terminalStatus,
threadId: turnResult.threadId,
turnId: turnResult.turnId
});
}
session = releaseSession(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
threadId: turnResult.threadId ?? session.threadId,
status: "idle"
}) ?? session;
const codexToolCall = {
id: `tool_${randomUUID()}`,
type: "codex-app-server-stdio",
name: toolName,
status: "completed",
cwd: workspace,
command: "codex app-server --listen stdio://",
exitCode: 0,
stdout: [
turnResult.threadId ? `threadId=${turnResult.threadId}` : "threadId=captured",
turnResult.turnId ? `turnId=${turnResult.turnId}` : null,
`terminalStatus=${turnResult.terminalStatus}`
].filter(Boolean).join(" "),
stderrSummary: "",
outputTruncated: false,
traceId
};
const toolCalls = [codexToolCall, ...sidecar.toolCalls];
traceRecorder.append({
type: "tool_call",
status: "completed",
label: `tool:${toolName}:completed`,
toolName,
outputSummary: turnResult.threadId ? `threadId=${turnResult.threadId} turnId=${turnResult.turnId ?? "unknown"}` : "threadId=captured",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn
});
traceRecorder.appendAssistantDelta?.({
itemId: turnResult.turnId ?? "assistant-message",
chunk: assistantContent,
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
waitingFor: "assistant-message-complete"
});
traceRecorder.append({
type: "assistant_message",
status: "completed",
label: "assistant:completed",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
terminal: true
});
const providerTrace = codexStdioProviderTrace({ availability, toolName, turnResult, session });
closeTurnRpcClient(session.sessionId, client);
client = null;
return {
provider: CODEX_STDIO_PROVIDER,
model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"),
backend: CODEX_STDIO_BACKEND,
content: codexReplyWithSidecar(assistantContent, sidecar),
workspace,
sandbox,
session,
sessionMode: CODEX_STDIO_SESSION_MODE,
sessionReuse: sessionReuseEvidence(session),
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
runnerLimitations: ["secret-values-redacted"],
codexStdioFeasibility: availability,
longLivedSessionGate: longLivedSessionGate({
provider: CODEX_STDIO_PROVIDER,
runnerKind: CODEX_STDIO_RUNNER_KIND,
session,
sessionMode: CODEX_STDIO_SESSION_MODE,
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
codexStdioFeasibility: availability
}),
toolCalls,
skills: sidecar.skills,
runner: runnerDescriptor({ workspace, sandbox, session }),
runnerTrace: runnerTrace({
traceRecorder,
traceId,
workspace,
sandbox,
session,
startedAt,
outputTruncated: sidecar.outputTruncated
}),
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
providerTrace
};
} catch (error) {
closeTurnRpcClient(session?.sessionId);
const timeout = /timed out|timeout|超时/iu.test(String(error.message ?? ""));
const currentSession = sessions.get(session.sessionId) ?? null;
const canceled = currentSession?.status === "canceled";
const preserveSessionStatus = error.preserveSessionStatus === true && error.session?.status && error.session.status !== "busy";
session = canceled
? publicSession(currentSession, { conversationId, reused: session.reused })
: preserveSessionStatus
? error.session
: failSession(session.sessionId, {
now,
traceId,
conversationId,
reused: session.reused,
status: timeout ? "timeout" : "failed",
statusReason: timeout ? "codex_stdio_timeout" : error.code ?? "codex_stdio_failed"
}) ?? session;
traceRecorder.append({
type: canceled ? "cancel" : timeout ? "timeout" : "error",
status: canceled ? "canceled" : "failed",
label: canceled ? "cancel:canceled" : timeout ? "timeout" : `error:${error.code ?? "codex_stdio_failed"}`,
errorCode: canceled ? "codex_stdio_canceled" : error.code ?? (timeout ? "codex_stdio_timeout" : "codex_stdio_failed"),
message: canceled ? "Codex stdio request was canceled by the user." : error.message || "Codex stdio session failed.",
timeoutMs: timeout ? error.timeoutMs ?? effectiveActivityTimeout(params.timeoutMs) : undefined,
idleMs: timeout ? error.idleMs : undefined,
lastActivityAt: timeout ? error.lastActivityAt : undefined,
hardTimeoutMs: timeout ? error.hardTimeoutMs : undefined,
diagnosis: error.diagnosis ?? undefined,
diagnosisCode: error.diagnosis?.code ?? error.diagnosisCode ?? undefined,
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
waitingFor: canceled ? "user-retry" : timeout ? error.waitingFor ?? "codex-stdio-tool-response" : null,
terminal: true
});
if (canceled) {
throw codexStdioError("codex_stdio_canceled", "Codex stdio request was canceled by the user.", {
availability,
session,
runnerTrace: runnerTrace({
traceRecorder,
traceId,
workspace,
sandbox,
session,
startedAt,
outputTruncated: false
})
});
}
if (error.code && (error.code.startsWith("codex_stdio") || [
"skills_unavailable"
].includes(error.code))) {
error.session = session;
error.availability = error.availability ?? describe({ ...params, env, workspace, sandbox });
error.runnerTrace = runnerTrace({
traceRecorder,
traceId,
workspace,
sandbox,
session,
startedAt,
outputTruncated: false
});
throw error;
}
throw codexStdioError("codex_stdio_failed", redactText(error.message || "Codex stdio session failed."), {
availability,
session,
runnerTrace: runnerTrace({
traceRecorder,
traceId,
workspace,
sandbox,
session,
startedAt,
outputTruncated: false
})
});
}
}
async function probe(params = {}) {
const env = params.env ?? options.env ?? process.env;
const workspace = resolveCodexWorkspace(env, params);
const command = resolveCodexCommand(env, params, options);
const sandbox = resolveCodexSandbox(env, params);
const codexHome = resolveCodexHome(env);
let availability = describe({ ...params, env, workspace, command, sandbox });
const startupBlockers = availability.blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired");
if (startupBlockers.length > 0) return availability;
if (rpcInitialized === true) {
availability = describe({ ...params, env, workspace, command, sandbox, protocolInitialized: true });
return ensureCommandProbeReady({
env,
workspace,
command,
sandbox,
codexHome,
availability,
now: params.now ?? nowDefault,
probeTimeoutMs: params.commandProbeTimeoutMs
});
}
if (!params.forceProbe && cachedProtocolReady({ command, workspace, codexHome, probe: protocolProbe })) {
availability = describe({ ...params, env, workspace, command, sandbox, protocolInitialized: true });
return ensureCommandProbeReady({
env,
workspace,
command,
sandbox,
codexHome,
availability,
now: params.now ?? nowDefault,
probeTimeoutMs: params.commandProbeTimeoutMs
});
}
let probeClient = null;
const startedAt = timestampFor(params.now ?? nowDefault);
try {
if (options.createProbeRpcClient) {
probeClient = await options.createProbeRpcClient({ env, availability });
} else if (options.createRpcClient) {
probeClient = await options.createRpcClient({ env, availability, probe: true });
} else {
probeClient = createCodexAppServerJsonLineClient({
command: availability.command,
args: codexAppServerArgs(env),
env: childProcessEnv(env),
cwd: availability.workspace
});
}
const timeoutMs = positiveInteger(params.probeTimeoutMs, 10000);
if (typeof probeClient.initialize === "function") await probeClient.initialize(timeoutMs);
protocolProbe = {
status: "ready",
ready: true,
command,
workspace,
codexHome,
initialized: true,
requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS],
startedAt,
finishedAt: timestampFor(params.now ?? nowDefault),
expiresAtMs: Date.now() + probeCacheTtlMs,
secretMaterialRead: false,
valuesRedacted: true
};
availability = describe({ ...params, env, workspace, command, sandbox, protocolInitialized: true });
return ensureCommandProbeReady({
env,
workspace,
command,
sandbox,
codexHome,
availability,
now: params.now ?? nowDefault,
probeTimeoutMs: params.commandProbeTimeoutMs
});
} catch (error) {
protocolProbe = {
status: "blocked",
ready: false,
command,
workspace,
codexHome,
initialized: false,
requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS],
startedAt,
finishedAt: timestampFor(params.now ?? nowDefault),
expiresAtMs: Date.now() + Math.min(probeCacheTtlMs, 5000),
error: redactText(error.message),
secretMaterialRead: false,
valuesRedacted: true
};
return describe({ ...params, env, workspace, command, sandbox, protocolInitialized: false });
} finally {
if (probeClient && typeof probeClient.close === "function") {
probeClient.close();
}
}
}
function cancel(sessionId, params = {}) {
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
if (!session) return null;
const timestamp = timestampFor(params.now ?? nowDefault);
session.status = "canceled";
session.updatedAt = timestamp;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
session.currentTraceId = null;
session.statusReason = params.reason ?? "user_canceled";
closeTurnRpcClient(session.sessionId);
return publicSession(session, { conversationId: params.conversationId, reused: true });
}
function reapIdle(params = {}) {
const timestamp = timestampFor(params.now ?? nowDefault);
const timestampMs = Date.parse(timestamp);
const reaped = [];
for (const session of sessions.values()) {
if (!sessionExpired(session, timestampMs)) continue;
session.status = "expired";
session.updatedAt = timestamp;
session.currentTraceId = null;
reaped.push(session.sessionId);
}
if (sessionsBusyCount() === 0 && reaped.length > 0) closeAllRpcClients();
return {
reapedCount: reaped.length,
sessionIds: reaped
};
}
function get(sessionId, params = {}) {
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
return session ? publicSession(session, params) : null;
}
function clear() {
sessions.clear();
conversations.clear();
closeAllRpcClients();
}
function closeTurnRpcClient(sessionId, expectedClient = null) {
const key = optionalId(sessionId);
const activeClient = key ? activeRpcClients.get(key) ?? null : null;
const client = expectedClient ?? activeClient;
if (client && (!activeClient || activeClient === client) && typeof client.close === "function") {
client.close();
}
if (key && (!expectedClient || activeClient === expectedClient)) activeRpcClients.delete(key);
if (activeRpcClients.size === 0) {
rpcStartedAt = null;
rpcInitialized = false;
}
}
function closeAllRpcClients() {
for (const client of activeRpcClients.values()) {
if (client && typeof client.close === "function") client.close();
}
activeRpcClients.clear();
rpcStartedAt = null;
rpcInitialized = false;
protocolProbe = null;
commandProbe = null;
}
async function ensureCommandProbeReady({
env,
workspace,
command,
sandbox,
codexHome,
availability,
now,
probeTimeoutMs
} = {}) {
if (cachedCommandProbe({ command, workspace, codexHome, probe: commandProbe })) {
return describe({ env, workspace, command, sandbox, protocolInitialized: availability.protocol?.initialized ?? rpcInitialized });
}
const startedAt = timestampFor(now);
try {
const sidecar = await collectWorkspaceSidecarEvidence({
message: "用pwd列出你当前的工作目录",
workspace,
traceId: CODEX_STDIO_COMMAND_PROBE_TRACE_ID,
env,
now
});
const pwdCall = sidecar.toolCalls.find((toolCall) => toolCall.name === "pwd");
if (pwdCall?.status === "blocked") {
throw new Error(pwdCall.stderrSummary || "controlled pwd probe was blocked");
}
if (!pwdCall || pwdCall.status !== "completed" || String(pwdCall.stdout ?? "").trim() !== workspace) {
throw new Error("controlled pwd probe did not return the configured workspace");
}
commandProbe = {
status: "ready",
ready: true,
command,
workspace,
codexHome,
probe: "workspace.pwd",
conversationId: CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID,
traceId: CODEX_STDIO_COMMAND_PROBE_TRACE_ID,
toolCalls: [{
name: pwdCall.name,
status: pwdCall.status,
cwd: pwdCall.cwd,
command: pwdCall.command,
exitCode: pwdCall.exitCode,
stdoutSummary: "workspace path matched",
outputTruncated: pwdCall.outputTruncated === true
}],
startedAt,
finishedAt: timestampFor(now),
expiresAtMs: Date.now() + probeCacheTtlMs,
timeoutMs: positiveInteger(probeTimeoutMs, CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS),
secretMaterialRead: false,
valuesRedacted: true
};
} catch (error) {
commandProbe = {
status: "blocked",
ready: false,
command,
workspace,
codexHome,
probe: "workspace.pwd",
conversationId: CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID,
traceId: CODEX_STDIO_COMMAND_PROBE_TRACE_ID,
toolCalls: [],
startedAt,
finishedAt: timestampFor(now),
expiresAtMs: Date.now() + Math.min(probeCacheTtlMs, 5000),
timeoutMs: positiveInteger(probeTimeoutMs, CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS),
error: redactText(error.message),
secretMaterialRead: false,
valuesRedacted: true
};
}
return describe({ env, workspace, command, sandbox, protocolInitialized: availability.protocol?.initialized ?? rpcInitialized });
}
async function createTurnRpcClient({ env, availability, timeoutMs, sessionId } = {}) {
const args = codexAppServerArgs(env);
const childEnv = childProcessEnv(env);
const client = options.createRpcClient
? await options.createRpcClient({ env, availability })
: createCodexAppServerJsonLineClient({
command: availability.command,
args,
env: childEnv,
cwd: availability.workspace
});
const key = requiredId(sessionId, "ses");
activeRpcClients.set(key, client);
try {
if (typeof client.initialize === "function") await client.initialize(effectiveTimeout(timeoutMs));
} catch (error) {
closeTurnRpcClient(key, client);
throw error;
}
rpcInitialized = true;
rpcStartedAt = timestampFor(nowDefault);
return client;
}
async function acquireSession(params = {}) {
const timestamp = timestampFor(params.now ?? nowDefault);
const timestampMs = Date.parse(timestamp);
const conversationId = requiredId(params.conversationId, "cnv");
const requestedSessionId = optionalId(params.requestedSessionId);
const threadId = optionalId(params.threadId);
const mappedSessionId = conversations.get(conversationId) ?? null;
if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) {
const mappedSession = sessions.get(mappedSessionId) ?? null;
return blockedAcquire({
code: "session_reuse_conflict",
summary: `conversationId ${conversationId} is already bound to sessionId ${mappedSessionId}; requested ${requestedSessionId}.`,
session: mappedSession,
timestamp,
traceId: params.traceId
});
}
let effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses");
let session = sessions.get(effectiveSessionId) ?? null;
let reused = Boolean(session);
if (session && sessionExpired(session, timestampMs)) {
const resumeThreadId = threadId ?? optionalId(session.threadId);
if (["idle", "expired"].includes(session.status) && resumeThreadId) {
session.status = "idle";
session.threadId = resumeThreadId;
session.updatedAt = timestamp;
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
session.currentTraceId = null;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
session.statusReason = "idle_timeout_resume";
} else {
session.status = "expired";
session.updatedAt = timestamp;
session.currentTraceId = null;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
return blockedAcquire({
code: "session_expired",
summary: `Codex stdio session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`,
session,
timestamp,
traceId: params.traceId
});
}
}
if (session?.status === "busy") {
const staleBusy = staleBusyEvidence(session, timestampMs, traceStore, busyStaleMs);
if (staleBusy.stale) {
session.status = staleBusy.releaseStatus;
session.updatedAt = timestamp;
session.currentTraceId = null;
session.statusReason = staleBusy.reason;
if (staleBusy.closeRpcClient) closeTurnRpcClient(session.sessionId);
} else {
return blockedAcquire({
code: "session_busy",
summary: `Codex stdio session ${effectiveSessionId} is already busy with traceId ${session.currentTraceId ?? "unknown"}.`,
session,
timestamp,
traceId: params.traceId,
currentTrace: staleBusy.runnerTrace
});
}
}
if (!session) {
session = {
sessionId: effectiveSessionId,
conversationIds: new Set(),
status: "creating",
workspace: params.workspace,
sandbox: params.sandbox,
runnerKind: CODEX_STDIO_RUNNER_KIND,
sessionMode: CODEX_STDIO_SESSION_MODE,
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
createdAt: timestamp,
updatedAt: timestamp,
idleTimeoutMs,
expiresAt: plusMs(timestampMs, idleTimeoutMs),
lastTraceId: optionalId(params.traceId),
currentTraceId: null,
turn: 0,
threadId,
durable: true,
longLivedSession: true,
codexStdio: true,
writeCapable: true,
secretMaterialStored: false
};
sessions.set(effectiveSessionId, session);
reused = Boolean(threadId);
}
session.conversationIds.add(conversationId);
session.threadId = threadId ?? session.threadId;
session.workspace = params.workspace ?? session.workspace;
session.sandbox = params.sandbox ?? session.sandbox;
session.status = "busy";
session.updatedAt = timestamp;
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
session.currentTraceId = optionalId(params.traceId);
session.turn += 1;
conversations.set(conversationId, effectiveSessionId);
pruneSessions();
return {
ok: true,
reused,
session: publicSession(session, { conversationId, reused })
};
}
function releaseSession(sessionId, params = {}) {
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
if (!session) return null;
const timestamp = timestampFor(params.now ?? nowDefault);
const timestampMs = Date.parse(timestamp);
session.status = CODEX_STDIO_SESSION_STATUSES.includes(params.status) ? params.status : "idle";
session.updatedAt = timestamp;
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
session.currentTraceId = null;
session.threadId = optionalId(params.threadId) ?? session.threadId;
session.statusReason = params.statusReason ?? null;
return publicSession(session, {
conversationId: params.conversationId,
reused: params.reused
});
}
function failSession(sessionId, params = {}) {
return releaseSession(sessionId, { ...params, status: params.status ?? "failed" });
}
function pruneSessions() {
if (sessions.size <= maxSessions) return;
const sorted = [...sessions.entries()]
.sort((a, b) => String(a[1].updatedAt).localeCompare(String(b[1].updatedAt)));
for (const [sessionId, session] of sorted.slice(0, sessions.size - maxSessions)) {
sessions.delete(sessionId);
for (const conversationId of session.conversationIds) {
if (conversations.get(conversationId) === sessionId) conversations.delete(conversationId);
}
}
}
function sessionsBusyCount() {
return [...sessions.values()].filter((session) => session.status === "busy").length;
}
function staleBusyEvidence(session, timestampMs, store, staleMs) {
const runnerTrace = session.currentTraceId && store?.snapshot
? store.snapshot(session.currentTraceId)
: null;
const terminalTrace = runnerTrace && runnerTrace.status !== "missing" && runnerTrace.finishedAt;
if (terminalTrace) {
return {
stale: true,
runnerTrace,
reason: "busy_trace_terminal",
releaseStatus: "idle",
closeRpcClient: true
};
}
const updatedMs = Date.parse(
runnerTrace?.updatedAt ??
runnerTrace?.lastEvent?.createdAt ??
session.updatedAt ??
session.createdAt ??
""
);
const ageMs = Number.isFinite(updatedMs) && Number.isFinite(timestampMs)
? timestampMs - updatedMs
: 0;
if (ageMs >= staleMs) {
return {
stale: true,
runnerTrace,
reason: "busy_stale_timeout",
releaseStatus: "idle",
closeRpcClient: true
};
}
return { stale: false, runnerTrace };
}
function recentSessions() {
return [...sessions.values()]
.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)))
.slice(0, 10)
.map((session) => publicSession(session));
}
function sessionStatusCounts() {
const counts = {};
for (const status of CODEX_STDIO_SESSION_STATUSES) counts[status] = 0;
for (const session of sessions.values()) {
counts[session.status] = (counts[session.status] ?? 0) + 1;
}
return counts;
}
return {
describe,
probe,
chat,
cancel,
reapIdle,
get,
clear,
longLivedSessionGate,
get rpcStartedAt() {
return rpcStartedAt;
}
};
}
export function codexAppServerArgs(env = process.env) {
return [
...codexAppServerProviderConfigArgs(env),
"app-server",
"--listen",
"stdio://"
];
}
export function codexAppServerProviderBaseUrl(env = process.env) {
const configured = firstNonEmpty(env.HWLAB_CODE_AGENT_OPENAI_BASE_URL, env.OPENAI_BASE_URL);
if (!configured) return null;
try {
const url = new URL(configured);
url.hash = "";
url.search = "";
const normalizedPath = url.pathname.replace(/\/+$/u, "");
if (normalizedPath.endsWith("/responses")) {
url.pathname = normalizedPath.slice(0, -"/responses".length) || "/";
}
const serialized = url.toString();
return serialized.endsWith("/") && url.pathname === "/" ? serialized.slice(0, -1) : serialized.replace(/\/$/u, "");
} catch {
return null;
}
}
function codexAppServerProviderConfigArgs(env = process.env) {
const baseUrl = codexAppServerProviderBaseUrl(env);
if (!baseUrl) return [];
const model = firstNonEmpty(env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL);
const args = [
"-c",
"model_provider=\"OpenAI\"",
"-c",
`model_providers.OpenAI.base_url=${tomlString(baseUrl)}`,
"-c",
"model_providers.OpenAI.name=\"OpenAI\"",
"-c",
"model_providers.OpenAI.wire_api=\"responses\"",
"-c",
"model_providers.OpenAI.requires_openai_auth=true"
];
if (model) {
args.push("-c", `model=${tomlString(model)}`);
args.push("-c", `review_model=${tomlString(model)}`);
}
return args;
}
function tomlString(value) {
return JSON.stringify(String(value ?? ""));
}
function spawnCodexAppServerProcess(command, args, options = {}) {
const native = resolveCodexNativeSpawn(command, options.env ?? process.env);
return spawn(native?.command ?? command, args, {
...options,
env: native?.env ?? options.env
});
}
function resolveCodexNativeSpawn(command, env = process.env) {
const resolved = commandPathSync(command, env);
if (!resolved) return null;
let real = resolved;
try {
real = realpathSync(resolved);
} catch {
// Keep the commandPathSync result; spawn will surface any executable error.
}
const packageRoot = findPackageRoot(real);
if (!packageRoot) return null;
const candidates = codexNativeBinaryCandidates(packageRoot);
const nativeCommand = candidates.find((candidate) => existsSync(candidate) && accessSyncBoolean(candidate, fsConstants.X_OK));
if (!nativeCommand) return null;
const pathDir = path.resolve(nativeCommand, "..", "..", "path");
const envPath = String(env.PATH ?? process.env.PATH ?? "");
return {
command: nativeCommand,
env: {
...env,
PATH: existsSync(pathDir) ? [pathDir, envPath].filter(Boolean).join(path.delimiter) : envPath,
CODEX_MANAGED_BY_NPM: env.CODEX_MANAGED_BY_NPM ?? "1"
}
};
}
function codexNativeBinaryCandidates(packageRoot) {
if (process.platform === "linux" && process.arch === "x64") {
return [
path.join(packageRoot, "..", "codex-linux-x64", "vendor", "x86_64-unknown-linux-musl", "codex", "codex"),
path.join(packageRoot, "vendor", "x86_64-unknown-linux-musl", "codex", "codex")
];
}
if (process.platform === "linux" && process.arch === "arm64") {
return [
path.join(packageRoot, "..", "codex-linux-arm64", "vendor", "aarch64-unknown-linux-musl", "codex", "codex"),
path.join(packageRoot, "vendor", "aarch64-unknown-linux-musl", "codex", "codex")
];
}
return [];
}
export function createCodexAppServerJsonLineClient({ command = DEFAULT_CODEX_STDIO_COMMAND, args = null, env = process.env, cwd = repoRoot, onNotification = null } = {}) {
const spawnArgs = Array.isArray(args) ? args : codexAppServerArgs(env);
const childDetached = process.platform !== "win32";
const child = spawnCodexAppServerProcess(command, spawnArgs, {
cwd,
env,
detached: childDetached,
stdio: ["pipe", "pipe", "pipe"]
});
let nextId = 1;
let stdoutBuffer = "";
let stderr = "";
const pending = new Map();
let initialized = false;
let notificationHandler = typeof onNotification === "function" ? onNotification : null;
let closed = false;
let closeResolve;
const closedPromise = new Promise((resolve) => {
closeResolve = resolve;
});
child.stdout.on("data", (chunk) => {
stdoutBuffer += chunk.toString("utf8");
let newlineIndex = stdoutBuffer.indexOf("\n");
while (newlineIndex >= 0) {
const line = stdoutBuffer.slice(0, newlineIndex).trim();
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
if (line) handleLine(line);
newlineIndex = stdoutBuffer.indexOf("\n");
}
});
child.stderr.on("data", (chunk) => {
stderr = tailText(`${stderr}${chunk.toString("utf8")}`, 4000);
});
child.on("error", (error) => closeWith(`Codex app-server process error: ${error.message}`, 127, "error"));
child.on("close", (code, signal) => closeWith(`Codex app-server process closed code=${code ?? "null"} signal=${signal ?? "null"}`, code, signal));
function handleLine(line) {
let payload = null;
try {
payload = JSON.parse(line);
} catch {
return;
}
const id = typeof payload.id === "number" || typeof payload.id === "string" ? payload.id : null;
const method = typeof payload.method === "string" ? payload.method : null;
if (id !== null && method === null) {
const request = pending.get(id);
if (!request) return;
pending.delete(id);
clearTimeout(request.timer);
if (payload.error) {
request.reject(new Error(redactText(payload.error.message ?? JSON.stringify(payload.error))));
} else {
request.resolve(payload.result);
}
return;
}
if (id !== null && method !== null) {
handleServerRequest(id, method, extractAppServerRecord(payload.params));
return;
}
if (method !== null && notificationHandler) {
notificationHandler(payload);
}
}
function closeWith(message, code, signal) {
if (closed) return;
closed = true;
terminateChildProcessGroup(child, { detached: childDetached });
for (const [id, request] of pending.entries()) {
pending.delete(id);
clearTimeout(request.timer);
request.reject(new Error(redactText(`${message}; stderr=${tailText(stderr, 800)}`)));
}
closeResolve({
code: Number.isInteger(code) ? code : null,
signal: typeof signal === "string" ? signal : null,
stderrTail: redactText(tailText(stderr, 8000))
});
}
function request(method, params = {}, timeoutMs = 30000) {
if (closed) {
return Promise.reject(new Error("Codex app-server process is already closed."));
}
const id = nextId;
nextId += 1;
const message = JSON.stringify({
id,
method,
params
});
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`Codex stdio request ${method} timed out after ${timeoutMs}ms`));
}, timeoutMs);
pending.set(id, { resolve, reject, timer });
child.stdin.write(`${message}\n`, "utf8", (error) => {
if (!error) return;
pending.delete(id);
clearTimeout(timer);
reject(error);
});
});
}
async function initialize(timeoutMs = 30000) {
if (initialized) return { initialized: true, requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS] };
await request("initialize", {
clientInfo: {
name: "hwlab-cloud-api",
title: "HWLAB Cloud API Code Agent",
version: "0"
},
capabilities: { experimentalApi: true }
}, timeoutMs);
notify("initialized", {});
initialized = true;
return { initialized: true, requiredMethods: [...CODEX_APP_SERVER_REQUIRED_METHODS] };
}
function notify(method, params = {}) {
child.stdin.write(`${JSON.stringify({ method, params })}\n`);
}
function handleServerRequest(id, method, params = {}) {
notifyClientRequest(method, params, "received");
if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval" || method === "item/permissions/requestApproval") {
const approval = approvalResponseForServerRequest(params);
notifyClientRequest(method, params, approval.decision);
child.stdin.write(`${JSON.stringify({ id, result: approval })}\n`);
return;
}
if (method === "item/tool/requestUserInput" || method === "mcpServer/elicitation/request") {
notifyClientRequest(method, params, "denied");
child.stdin.write(`${JSON.stringify({ id, result: { canceled: true, decision: "denied", message: "Non-interactive HWLAB Code Agent cannot request user input during a running turn." } })}\n`);
return;
}
notifyClientRequest(method, params, "unsupported");
child.stdin.write(`${JSON.stringify({ id, error: { code: -32601, message: `Unsupported client-side request: ${method}` } })}\n`);
}
function approvalResponseForServerRequest(params = {}) {
const available = Array.isArray(params?.availableDecisions) ? params.availableDecisions.map(String) : [];
const decision = available.find((item) => item === "approved")
?? available.find((item) => item === "approved_for_session")
?? available.find((item) => item.startsWith("approved"))
?? "approved";
return { decision };
}
function notifyClientRequest(method, params, decision) {
notificationHandler?.({
method: "client/request/handled",
params: {
method,
decision,
itemId: optionalId(params?.itemId ?? params?.item?.id),
approvalId: optionalId(params?.approvalId ?? params?.id),
targetItemId: optionalId(params?.targetItemId)
}
});
}
async function startThread({ model, cwd: threadCwd, sandbox, approvalPolicy = "never", serviceName = "hwlab-cloud-api" } = {}, timeoutMs = 30000) {
const result = await request("thread/start", dropEmpty({
model,
cwd: threadCwd,
sandbox,
approvalPolicy,
serviceName
}), timeoutMs);
const threadId = extractAppServerString(extractAppServerRecord(result)?.thread, "id");
if (!threadId) throw new Error("thread/start response did not include thread.id");
return { threadId, result };
}
async function resumeThread({ threadId, model, cwd: threadCwd, sandbox, approvalPolicy = "never" } = {}, timeoutMs = 30000) {
const result = await request("thread/resume", dropEmpty({
threadId,
model,
cwd: threadCwd,
sandbox,
approvalPolicy
}), timeoutMs);
const resumedThreadId = extractAppServerString(extractAppServerRecord(result)?.thread, "id") ?? optionalId(threadId);
if (!resumedThreadId) throw new Error("thread/resume response did not include thread.id");
return { threadId: resumedThreadId, result };
}
async function startTurn({ threadId, prompt, model, cwd: turnCwd, approvalPolicy = "never", effort = null } = {}, timeoutMs = 30000) {
const result = await request("turn/start", dropEmpty({
threadId,
input: codexAppServerTextInput(prompt),
cwd: turnCwd,
approvalPolicy,
model,
effort
}), timeoutMs);
const turnId = extractAppServerString(extractAppServerRecord(result)?.turn, "id");
if (!turnId) throw new Error("turn/start response did not include turn.id");
return { turnId, result };
}
function setNotificationHandler(handler) {
notificationHandler = typeof handler === "function" ? handler : null;
}
function close() {
if (closed) return;
terminateChildProcess(child, { detached: childDetached });
}
return {
closedPromise,
initialize,
startThread,
resumeThread,
startTurn,
setNotificationHandler,
close
};
}
function terminateChildProcess(child, { detached = false } = {}) {
return terminateChildProcessGroup(child, { detached });
}
function terminateChildProcessGroup(child, { detached = false } = {}) {
if (!child || child.killed) return;
try {
if (detached && Number.isInteger(child.pid)) {
process.kill(-child.pid, "SIGTERM");
} else {
child.kill("SIGTERM");
}
} catch {
try {
child.kill("SIGTERM");
} catch {
return;
}
}
setTimeout(() => {
if (child.killed) return;
try {
if (detached && Number.isInteger(child.pid)) {
process.kill(-child.pid, "SIGKILL");
} else {
child.kill("SIGKILL");
}
} catch {
// Process already exited or the runtime does not allow group signaling.
}
}, 2000).unref?.();
}
async function runAppServerTurn({
client,
session,
prompt,
workspace,
sandbox,
model,
traceRecorder,
timeoutMs,
hardTimeoutMs
} = {}) {
const state = createAppServerTurnState({ traceRecorder, session });
const effectiveMs = effectiveActivityTimeout(timeoutMs);
const effectiveHardMs = effectiveHardTimeout(hardTimeoutMs, effectiveMs);
if (typeof client.setNotificationHandler === "function") {
client.setNotificationHandler((message) => state.handle(message));
}
try {
let threadId = optionalId(session?.threadId);
if (threadId) {
state.appendTrace({
type: "thread",
status: "started",
label: "thread:resume:started",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
threadId,
waitingFor: "thread/resume"
});
try {
const resumed = await client.resumeThread({
threadId,
model,
cwd: workspace,
sandbox,
approvalPolicy: "never"
}, effectiveMs);
threadId = optionalId(resumed?.threadId) ?? threadId;
state.setThreadId(threadId);
state.appendTrace({
type: "thread",
status: "completed",
label: "thread:resume:completed",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
threadId,
waitingFor: "turn/start"
});
} catch (error) {
if (!isStaleAppServerThreadError(error)) throw error;
state.appendTrace({
type: "thread",
status: "degraded",
label: "thread:resume:stale",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
threadId,
message: "Persisted app-server thread is no longer resumable; starting a fresh thread for this turn.",
waitingFor: "thread/start"
});
session.threadId = null;
threadId = null;
state.setThreadId(null);
const started = await startFreshAppServerThread({
client,
state,
session,
workspace,
sandbox,
model,
effectiveMs
});
threadId = started.threadId;
}
} else {
const started = await startFreshAppServerThread({
client,
state,
session,
workspace,
sandbox,
model,
effectiveMs
});
threadId = started.threadId;
}
state.appendTrace({
type: "turn",
status: "started",
label: "turn:start:started",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
threadId,
waitingFor: "turn/start"
});
const turnStart = await client.startTurn({
threadId,
prompt,
model,
cwd: workspace,
sandbox,
approvalPolicy: "never"
}, effectiveMs);
const turnId = optionalId(turnStart?.turnId);
if (!turnId) throw new Error("turn/start response did not include turn.id");
state.setTurnId(turnId);
state.appendTrace({
type: "turn",
status: "started",
label: "turn:start:completed",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
threadId,
turnId,
waitingFor: "turn/completed"
});
const rawResult = await state.wait(effectiveMs, client.closedPromise, { hardTimeoutMs: effectiveHardMs });
return normalizeAppServerTurnResult(rawResult, state.snapshot(), { threadId, turnId });
} finally {
if (typeof client.setNotificationHandler === "function") {
client.setNotificationHandler(null);
}
}
}
async function startFreshAppServerThread({ client, state, session, workspace, sandbox, model, effectiveMs }) {
state.appendTrace({
type: "thread",
status: "started",
label: "thread:start:started",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
waitingFor: "thread/start"
});
const started = await client.startThread({
model,
cwd: workspace,
sandbox,
approvalPolicy: "never",
serviceName: "hwlab-cloud-api"
}, effectiveMs);
const threadId = optionalId(started?.threadId);
if (!threadId) throw new Error("thread/start response did not include thread.id");
state.setThreadId(threadId);
state.appendTrace({
type: "thread",
status: "completed",
label: "thread:start:completed",
sessionId: session.sessionId,
sessionStatus: session.status,
turn: session.turn,
threadId,
waitingFor: "turn/start"
});
return { threadId };
}
function isStaleAppServerThreadError(error) {
const message = String(error?.message ?? "");
return /\bno\s+rollout\s+found\s+for\s+thread\s+id\b/iu.test(message) ||
/\bthread\b.*\b(?:not\s+found|missing|expired|gone|unknown)\b/iu.test(message);
}