From f72905243e58b7a0a4e74ac11c19781c37883b79 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 11:28:58 +0800 Subject: [PATCH] Improve code agent trace inspection tooling --- .../cloud/code-agent-session-registry.mjs | 59 +++++++++ .../code-agent-session-registry.test.mjs | 49 ++++++++ internal/cloud/server.mjs | 68 ++++++++++ internal/cloud/server.test.mjs | 119 ++++++++++++++++++ skills/device-pod-cli/SKILL.md | 9 ++ skills/hwlab-agent-runtime/SKILL.md | 4 + tools/device-pod-cli.mjs | 102 ++++++++++++++- 7 files changed, 408 insertions(+), 2 deletions(-) diff --git a/internal/cloud/code-agent-session-registry.mjs b/internal/cloud/code-agent-session-registry.mjs index 905a73f4..9950e7f4 100644 --- a/internal/cloud/code-agent-session-registry.mjs +++ b/internal/cloud/code-agent-session-registry.mjs @@ -183,6 +183,59 @@ export function createCodeAgentSessionRegistry(options = {}) { return sessionId ? get(sessionId, { ...params, conversationId }) : null; } + function findSession(params = {}) { + const conversationId = optionalId(params.conversationId); + const sessionId = optionalId(params.sessionId); + const traceId = optionalId(params.traceId); + const threadId = optionalId(params.threadId); + if (sessionId) { + const session = get(sessionId, { conversationId }); + if (session) return session; + } + if (conversationId) { + const session = getByConversation(conversationId, { conversationId }); + if (session) return session; + } + for (const session of sessions.values()) { + const conversationIds = [...session.conversationIds]; + if (conversationId && conversationIds.includes(conversationId)) return publicSession(session, { conversationId }); + if (traceId && (session.lastTraceId === traceId || session.currentTraceId === traceId)) return publicSession(session, { conversationId: conversationIds[0] ?? null }); + if (threadId && session.threadId === threadId) return publicSession(session, { conversationId: conversationIds[0] ?? null }); + } + return null; + } + + function inspect(params = {}) { + const session = findSession(params); + const conversationId = optionalId(params.conversationId) ?? session?.conversationId ?? session?.conversationIds?.[0] ?? null; + const facts = conversationId ? getConversationFacts(conversationId) : null; + const requestedTraceId = optionalId(params.traceId); + const traceIds = uniqueStrings([ + requestedTraceId, + facts?.latestTraceId, + session?.currentTraceId, + session?.lastTraceId, + ...(Array.isArray(facts?.traceIds) ? facts.traceIds : []) + ]); + return { + ok: Boolean(session || facts || traceIds.length > 0), + action: "code-agent.session.inspect", + status: session || facts ? "found" : "not_found", + query: { + conversationId: optionalId(params.conversationId), + sessionId: optionalId(params.sessionId), + threadId: optionalId(params.threadId), + traceId: requestedTraceId + }, + session, + conversationFacts: facts, + traceIds, + latestTraceId: traceIds[0] ?? null, + valuesRedacted: true, + secretMaterialStored: false + }; + } + function recordFact(conversationIdValue, fact, params = {}) { const conversationId = requiredId(conversationIdValue, "cnv"); const timestamp = timestampFor(params.now ?? defaultNow); @@ -285,6 +338,8 @@ export function createCodeAgentSessionRegistry(options = {}) { interrupt, get, getByConversation, + findSession, + inspect, recordFact, getConversationFacts, expireIdle, @@ -554,6 +609,10 @@ function optionalId(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } +function uniqueStrings(values) { + return [...new Set(values.map((value) => typeof value === "string" ? value.trim() : "").filter(Boolean))]; +} + function positiveInteger(value, fallback) { return Number.isInteger(value) && value > 0 ? value : fallback; } diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 173a0f3f..26d885aa 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -212,6 +212,55 @@ test("code agent session registry stores bounded non-sensitive conversation fact assert.match(facts.summary, /workspace=\/workspace\/hwlab/u); }); +test("code agent session registry inspect locates conversation, session, and trace summary", () => { + const registry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_inspect_registry" + }); + const acquired = registry.acquire({ + conversationId: "cnv_inspect_registry", + workspace: "/workspace/hwlab", + sandbox: "workspace-write", + runnerKind: "codex-app-server-stdio-runner", + capabilityLevel: "long-lived-codex-stdio-session", + traceId: "trc_inspect_registry_1", + now: "2026-05-23T00:00:00.000Z" + }); + registry.release(acquired.session.sessionId, { + conversationId: "cnv_inspect_registry", + traceId: "trc_inspect_registry_1", + now: "2026-05-23T00:00:00.010Z" + }); + registry.recordFact("cnv_inspect_registry", { + traceId: "trc_inspect_registry_1", + provider: "codex-stdio", + backend: "hwlab-cloud-api/codex-app-server-stdio", + workspace: "/workspace/hwlab", + sandbox: "workspace-write", + session: acquired.session, + runner: { kind: "codex-app-server-stdio-runner", sessionId: acquired.session.sessionId }, + runnerTrace: { + traceId: "trc_inspect_registry_1", + runnerKind: "codex-app-server-stdio-runner", + sessionMode: "codex-app-server-stdio-long-lived", + valuesPrinted: false + } + }, { now: "2026-05-23T00:00:00.020Z" }); + + const byConversation = registry.inspect({ conversationId: "cnv_inspect_registry" }); + assert.equal(byConversation.ok, true); + assert.equal(byConversation.status, "found"); + assert.equal(byConversation.session.sessionId, "ses_inspect_registry"); + assert.equal(byConversation.conversationFacts.conversationId, "cnv_inspect_registry"); + assert.deepEqual(byConversation.traceIds, ["trc_inspect_registry_1"]); + assert.equal(byConversation.latestTraceId, "trc_inspect_registry_1"); + assert.equal(byConversation.valuesRedacted, true); + assert.equal(byConversation.secretMaterialStored, false); + + const byTrace = registry.inspect({ traceId: "trc_inspect_registry_1" }); + assert.equal(byTrace.ok, true); + assert.equal(byTrace.session.sessionId, "ses_inspect_registry"); +}); + test("code agent session registry reports busy sessions as structured blocker", () => { const registry = createCodeAgentSessionRegistry({ idFactory: () => "ses_busy" diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index ebe78092..107feab7 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -370,6 +370,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname === "/v1/agent/chat/inspect") { + await handleCodeAgentInspectHttp(request, response, url, options); + return; + } + if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/result/")) { await handleCodeAgentChatResultHttp(request, response, url, options); return; @@ -1578,6 +1583,64 @@ async function handleCodeAgentChatResultHttp(request, response, url, options) { }); } +async function handleCodeAgentInspectHttp(request, response, url, options) { + const query = { + conversationId: safeConversationId(url.searchParams.get("conversationId")), + sessionId: safeSessionId(url.searchParams.get("sessionId")), + threadId: safeOpaqueId(url.searchParams.get("threadId")), + traceId: safeTraceId(url.searchParams.get("traceId")) + }; + const sessionRegistry = options.sessionRegistry; + const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; + const manager = options.codexStdioManager; + const managerDescription = manager && typeof manager.describe === "function" ? manager.describe() : null; + const managerRecentSessions = Array.isArray(managerDescription?.recentSessions) ? managerDescription.recentSessions : []; + const directManagerSession = query.sessionId && manager && typeof manager.get === "function" + ? manager.get(query.sessionId, { conversationId: query.conversationId }) + : null; + const matchedManagerSession = directManagerSession ?? managerRecentSessions.find((session) => sessionMatchesInspectQuery(session, query)) ?? null; + const registryInspect = sessionRegistry && typeof sessionRegistry.inspect === "function" + ? sessionRegistry.inspect(query) + : { ok: false, status: "unavailable", traceIds: [], session: null, conversationFacts: null }; + const session = matchedManagerSession ?? registryInspect.session ?? null; + const conversationFacts = registryInspect.conversationFacts ?? null; + const traceIds = uniqueStrings([ + query.traceId, + session?.currentTraceId, + session?.lastTraceId, + conversationFacts?.latestTraceId, + ...(Array.isArray(conversationFacts?.traceIds) ? conversationFacts.traceIds : []), + ...(Array.isArray(registryInspect.traceIds) ? registryInspect.traceIds : []) + ]); + const latestTraceId = traceIds[0] ?? null; + const runnerTrace = latestTraceId ? traceStore.snapshot(latestTraceId) : null; + const found = Boolean(registryInspect.ok || session || latestTraceId); + sendJson(response, found ? 200 : 404, { + ok: found, + action: "code-agent.chat.inspect", + status: found ? "found" : "not_found", + query, + session, + conversationFacts, + traceIds, + latestTraceId, + traceUrl: latestTraceId ? `/v1/agent/chat/trace/${encodeURIComponent(latestTraceId)}` : null, + resultUrl: latestTraceId ? `/v1/agent/chat/result/${encodeURIComponent(latestTraceId)}` : null, + runnerTrace, + valuesRedacted: true, + secretMaterialStored: false + }); +} + +function sessionMatchesInspectQuery(session, query) { + if (!session || typeof session !== "object") return false; + if (query.sessionId && session.sessionId === query.sessionId) return true; + if (query.threadId && session.threadId === query.threadId) return true; + if (query.conversationId && session.conversationId === query.conversationId) return true; + if (query.traceId && (session.currentTraceId === query.traceId || session.lastTraceId === query.traceId)) return true; + return false; +} + async function handleCodeAgentCancelHttp(request, response, options) { const body = await readBody(request, options.bodyLimitBytes); let params = {}; @@ -2217,6 +2280,11 @@ function safeConversationId(value) { return /^cnv_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; } +function safeOpaqueId(value) { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null; +} + function parsePositiveInteger(value, fallback) { const parsed = Number.parseInt(value ?? "", 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 7635877b..19bf57eb 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1886,6 +1886,125 @@ test("cloud api /v1/agent/chat supports short submit and result polling", async } }); +test("cloud api /v1/agent/chat/inspect maps conversation session and thread details to trace evidence", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-")); + const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-codex-home-")); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }, + codexStdioManager: { + describe() { + return { + ...codexStdioReadyFixture({ workspace, codexHome }), + sandbox: "danger-full-access", + recentSessions: [{ + sessionId: "ses_server_stdio_pwd", + conversationId: "cnv_server-test-inspect", + threadId: "019e6c72-2373-75e3-9856-eff286db7163", + status: "idle", + currentTraceId: null, + lastTraceId: "trc_server-test-inspect", + workspace, + sandbox: "danger-full-access", + secretMaterialStored: false, + valuesRedacted: true + }] + }; + }, + async probe() { + return { + ...codexStdioReadyFixture({ workspace, codexHome }), + sandbox: "danger-full-access" + }; + }, + async chat(params = {}) { + const base = codexStdioChatFixture({ workspace, codexHome, params }); + return { + ...base, + sandbox: "danger-full-access", + session: { + ...base.session, + threadId: "019e6c72-2373-75e3-9856-eff286db7163", + sandbox: "danger-full-access" + }, + sessionReuse: { + ...base.sessionReuse, + threadId: "019e6c72-2373-75e3-9856-eff286db7163" + } + }; + }, + get(sessionId) { + if (sessionId !== "ses_server_stdio_pwd") return null; + return { + sessionId, + conversationId: "cnv_server-test-inspect", + threadId: "019e6c72-2373-75e3-9856-eff286db7163", + status: "idle", + lastTraceId: "trc_server-test-inspect", + workspace, + sandbox: "danger-full-access", + secretMaterialStored: false, + valuesRedacted: true + }; + }, + cancel() {}, + reapIdle() {} + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const traceId = "trc_server-test-inspect"; + const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": traceId, + "prefer": "respond-async", + "x-hwlab-short-connection": "1" + }, + body: JSON.stringify({ + conversationId: "cnv_server-test-inspect", + message: "inspect mapping smoke" + }) + }); + assert.equal(submit.status, 202); + const payload = await pollAgentResult(port, traceId); + assert.equal(payload.status, "completed"); + + const inspect = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/inspect?conversationId=cnv_server-test-inspect&sessionId=ses_server_stdio_pwd&threadId=019e6c72-2373-75e3-9856-eff286db7163`); + assert.equal(inspect.status, 200); + const body = await inspect.json(); + assert.equal(body.ok, true); + assert.equal(body.status, "found"); + assert.equal(body.latestTraceId, traceId); + assert.equal(body.traceUrl, `/v1/agent/chat/trace/${traceId}`); + assert.equal(body.resultUrl, `/v1/agent/chat/result/${traceId}`); + assert.equal(body.query.threadId, "019e6c72-2373-75e3-9856-eff286db7163"); + assert.equal(body.session.sessionId, "ses_server_stdio_pwd"); + assert.equal(body.conversationFacts.conversationId, "cnv_server-test-inspect"); + assert.equal(body.runnerTrace.traceId, traceId); + assert.equal(body.valuesRedacted, true); + assert.equal(body.secretMaterialStored, false); + assert.equal(JSON.stringify(body).includes("test-openai-key-material"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(workspace, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + test("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-")); diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index 261e0250..a8c4e859 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -29,6 +29,11 @@ not the HWLAB runner contract and cause stale instructions. Do not start by calling `/app/tools/hwlab-gateway-tran.mjs` directly, do not hunt for Windows-side Keil skills, and do not call `keil-cli.py` directly when a matching device-pod profile exists. +- When a profile, selector, host route, or upload step is ambiguous, run + `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ` + before trying lower-level commands. Treat the `doctor` JSON as the source of + truth for profile path, profile hash, route, host profile path, selectors, and + the exact upload/host-health commands. - The profile already carries `hostWorkspaceRoot`, `projectWorkspace`, `debugInterface.uv4Path`, probe UID, UART port, and gateway route. Let `device-pod-cli -> device-host-cli` apply those values; do not rediscover the @@ -36,6 +41,10 @@ not the HWLAB runner contract and cause stale instructions. - If a command returns `profile-missing`, run profile bootstrap. If it returns a device-host-cli JSON failure, fix the named device-pod operation or host CLI; do not bypass the profile with generic gateway shell. +- If a command returns a JSON `next` array or object, follow those CLI-owned next + commands in order. Do not discard the failure context, slash the route into an + ad hoc command, or replace `device-pod-cli` with direct gateway shell unless + the `next` field explicitly instructs that bootstrap/repair action. - Workspace search follows a compact ripgrep-like shape: `device-pod-71-freq:workspace:/ rg "printf|main" projects/01_baseline -g "*.c" -g "*.h" -l`. The optional path after the pattern is honored as the search root; use it to diff --git a/skills/hwlab-agent-runtime/SKILL.md b/skills/hwlab-agent-runtime/SKILL.md index 997f7ee7..3e891c62 100644 --- a/skills/hwlab-agent-runtime/SKILL.md +++ b/skills/hwlab-agent-runtime/SKILL.md @@ -19,6 +19,10 @@ Use this skill when working on HWLAB agent runtime skeletons. - Keep the control plane long-lived and worker execution session-scoped. - Model the lifecycle as create, start, trace, finish, cleanup. - Always carry explicit evidence record fields through the run plan. +- When investigating a Cloud Workbench or DeepSeek task from UI session details, + start with `GET /v1/agent/chat/inspect?conversationId=&sessionId=&threadId=`. + Use its `traceUrl`, `resultUrl`, `latestTraceId`, `conversationFacts`, and + `runnerTrace` instead of guessing a trace id from frontend text. - Use repo-local skills artifacts only when `commitId` and `version` are supplied explicitly. - Do not infer or fall back to external skill sources. diff --git a/tools/device-pod-cli.mjs b/tools/device-pod-cli.mjs index ebe42af1..726c337a 100644 --- a/tools/device-pod-cli.mjs +++ b/tools/device-pod-cli.mjs @@ -25,7 +25,8 @@ function print(body, exitCode = 0) { } function fail(action, error, details = {}) { const message = error instanceof Error ? error.message : String(error); - print({ ok: false, action, status: 'failed', blocker: classifyBlocker(message), error: message, details }, 1); + const blocker = classifyBlocker(message); + print({ ok: false, action, status: 'failed', blocker, error: message, details, next: nextForBlocker(blocker, { ...details, message }) }, 1); } function classifyBlocker(message) { if (/profile not found/u.test(message)) return 'profile-missing'; @@ -35,8 +36,41 @@ function classifyBlocker(message) { return 'operation-failed'; } +function nextForBlocker(blocker, details = {}) { + const podId = details.podId || podIdFromMessage(details.message) || process.env.DEVICE_POD_ID || ''; + if (blocker === 'profile-missing') { + return [ + `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`, + `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id ${podId} --force`, + 'upload the generated profile and device-host-cli asset using the doctor next.upload commands' + ]; + } + if (blocker === 'profile-invalid') { + return [ + `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`, + `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id ${podId}` + ]; + } + if (blocker === 'approval-required') { + return ['rerun the mutating operation with --approved --reason after the user explicitly requested it']; + } + if (blocker === 'invalid-request') { + return [ + 'keep the device selector as one token, for example device-pod-71-00075-11:io-probe:/uart/1 read', + `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}` + ]; + } + return [`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`]; +} + +function podIdFromMessage(message) { + const match = String(message || '').match(/([A-Za-z0-9_.-]+)\.json\b/u); + return match?.[1] || null; +} + function parseTarget(raw) { if (!raw || raw === '--help' || raw === 'help') return { kind: 'help' }; + if (raw === 'doctor') return { kind: 'doctor' }; if (raw === 'health') return { kind: 'health' }; if (raw === 'profile') return { kind: 'profile' }; const match = String(raw).trim().match(/^([^:]+):(workspace|debug-probe|io-probe)(?::(.*))?$/u); @@ -249,6 +283,68 @@ function showProfile(parsed) { print({ ok: true, action: 'profile.show', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile), projectWorkspace: profile.projectWorkspace || null, debugInterface: profile.debugInterface || null, ioInterface: profile.ioInterface || null, hostProfilePath: hostProfilePath(profile) }); } +function profileMissingDoctor(podId, file) { + const templateAvailable = Boolean(podId); + return { + ok: false, + action: 'doctor', + status: 'failed', + blocker: 'profile-missing', + profileDir: PROFILE_DIR, + devicePodId: podId || null, + expectedProfilePath: file || (podId ? path.join(PROFILE_DIR, `${podId}.json`) : null), + next: { + create: templateAvailable ? `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id ${podId} --force` : 'pass --pod-id or set DEVICE_POD_ID', + list: 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list' + }, + valuesRedacted: true, + secretMaterialStored: false + }; +} + +function doctor(parsed) { + const podId = parsed.podId || parsed._[0] || process.env.DEVICE_POD_ID || null; + try { + const profile = readProfile(podId); + const script = 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs'; + const selector = `${profile.podId}:workspace:/`; + const hostProfile = hostProfilePath(profile); + const uploadRoot = `${profile.gatewaySessionId}:${String(profile.hostWorkspaceRoot || '').replace(/^[A-Za-z]:/u, (drive) => `/${drive[0].toLowerCase()}`).replace(/\\/gu, '/')}`; + print({ + ok: true, + action: 'doctor', + status: 'succeeded', + ...profileSummary(profile), + profileDir: PROFILE_DIR, + route: routeSummary(profile), + hostProfilePath: hostProfile, + selectors: { + workspace: `${profile.podId}:workspace:/`, + debugProbe: `${profile.podId}:debug-probe`, + ioProbe: `${profile.podId}:io-probe:/uart/1` + }, + next: { + show: `${script} profile show --pod-id ${profile.podId}`, + health: `${script} health --pod-id ${profile.podId}`, + plan: `${script} ${selector} ls --dry-run`, + uploadHostCli: `node /app/tools/tran.mjs ${uploadRoot} upload /app/skills/device-pod-cli/assets/device-host-cli.mjs tools/device-host-cli.mjs`, + uploadProfile: `node /app/tools/tran.mjs ${uploadRoot} upload ${profile.__profilePath} .device-pod/${profile.podId}.json`, + hostHealth: `node /app/tools/tran.mjs ${uploadRoot} cmd -- node tools\\device-host-cli.mjs --profile ${hostProfile} --pod-id ${profile.podId} health` + }, + valuesRedacted: true, + secretMaterialStored: false + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const missingMatch = message.match(/profile not found: (.+)$/u); + if (classifyBlocker(message) === 'profile-missing') { + print(profileMissingDoctor(podId, missingMatch?.[1] || null), 1); + return; + } + fail('doctor', error, { podId }); + } +} + function handleProfileCommand(parsed) { const subcommand = parsed._[0] || 'list'; if (subcommand === 'list') return listProfiles(); @@ -436,6 +532,7 @@ function hostJsonSummary(hostJson) { function help() { print({ ok: true, action: 'help', scope: 'HWLAB internal code agents only; not for external UniDesk workspaces.', usage: [ + 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-freq', 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list', 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id device-pod-71-freq', 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id device-pod-71-00075-11 --force', @@ -454,9 +551,10 @@ async function main() { const selector = parseTarget(rawTarget); if (selector.kind === 'help') return help(); const parsed = parseOptions(argv); + if (selector.kind === 'doctor') return doctor(parsed); if (selector.kind === 'profile') return handleProfileCommand(parsed); const profile = readProfile(selector.podId || parsed.podId); - if (selector.kind === 'health') return print({ ok: true, action: 'health', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile) }); + if (selector.kind === 'health') return print({ ok: true, action: 'health', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile), next: [`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${profile.podId}`] }); const built = await buildHostCommand(selector, parsed); const hostArgs = ['--profile', hostProfilePath(profile), '--pod-id', profile.podId, ...built.commandArgs]; const command = `${profile.hostCli} ${hostArgs.map(cmdQuote).join(' ')}`;