diff --git a/README.md b/README.md index 97a22fc..c6842f0 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ # Claude Ask -Claude Ask is an open-source Codex plugin that asks a locally authenticated -Claude Code model for an independent second opinion. +Claude Ask is an open-source Codex plugin that runs a locally authenticated +Claude Code Sonnet 5, Fable, or Opus model as an independent second opinion or +full autonomous coding agent. -It provides three MCP tools: +It provides five persistent asynchronous MCP tools: -- `ask` starts a persistent asynchronous Claude Fable or Opus job; -- `ask_status` reports progress and returns the final answer; -- `cancel_ask` explicitly cancels a job. +- `ask` starts a new advisory or workspace-agent job; +- `ask_followup` resumes a completed Claude session and forks a new branch; +- `ask_status` waits for progress or returns the final result; +- `list_jobs` lists recent jobs and recovers lost IDs; +- `cancel_ask` explicitly cancels a job and its process tree. -For repository reviews, `ask` accepts an explicit absolute `workspace`. Claude -runs from that directory with only the built-in `Read`, `Glob`, and `Grep` -tools. It cannot run shell commands or edit files. Without `workspace`, Claude -receives no tools. +Jobs support a human-readable name, `medium | high | xhigh | max` effort +(`high` by default), and an optional `max_budget_usd` ceiling. ## Install in Codex @@ -23,38 +24,55 @@ codex plugin marketplace add AbsoluteMode/claude-ask codex plugin add claude-ask@absolutmode ``` -Start a new Codex thread after installation so the new skill and MCP tool -schema are loaded. +Start a new Codex task after installing or updating so the skill and MCP tool +schemas are reloaded. ## Requirements - Codex with plugin support; - Node.js 22 or newer; -- Claude Code installed at `~/.local/bin/claude`, or `CLAUDE_ASK_CLI` set to - another executable; +- Claude Code on `PATH` or at `~/.local/bin/claude`; - an authenticated Claude Code session. -## Security model +## Access modes -- The prompt is transferred over stdin, never argv or environment variables. -- Job directories use mode `0700`; job files use `0600`. -- Workspace access is explicit per job and canonicalized before launch. -- Workspace jobs expose only `Read`, `Glob`, and `Grep`. -- There is no automatic elapsed-time or idle timeout. -- Jobs survive an MCP server restart and are retained for 24 hours by default. -- At most four jobs run concurrently by default. +Without `workspace`, Claude receives only the supplied question and runs in +safe mode with no tools. -See [the plugin documentation](plugins/claude-ask/README.md) for protocol and -configuration details. +With an explicit absolute `workspace`, Claude runs as a full-access coding +agent. Its default tools, shell, file editing, user/project instructions and +hooks, network access, and configured MCP servers, skills, and plugins remain +available. Interactive permission checks are bypassed because the detached +process cannot answer prompts. + +The workspace is Claude's working directory and intended task scope, not a +hard operating-system sandbox. Only pass trusted workspaces and prompts. + +## Reliability and privacy + +- Prompts travel over stdin, never argv, environment variables, state, or logs. +- Job directories use mode `0700`; private files use mode `0600`. +- Jobs survive MCP server restarts and remain discoverable through `list_jobs`. +- Boot-aware recovery avoids trusting reused PIDs after a machine restart. +- Cancellation terminates Claude's POSIX process group with + `SIGTERM → SIGKILL`. +- Stderr is capped at 1 MiB; failures expose at most a 4 KiB diagnostic tail. +- There is no automatic elapsed-time or idle-time cancellation. +- Completed plugin job data is retained for 24 hours by default; resumable + Claude sessions use Claude Code's normal session storage. + +See [the plugin documentation](plugins/claude-ask/README.md) for the complete +protocol and configuration reference. ## Development ```bash npm test +npm run check ``` -The default test uses a fake Claude executable and consumes no model usage. An -explicit live smoke test is available after Claude authentication: +The default suite uses a fake Claude executable and consumes no model usage. +An authenticated paid/limited-usage smoke test is explicit: ```bash npm run test:live diff --git a/package-lock.json b/package-lock.json index 222b3da..caa62a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-ask-marketplace", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-ask-marketplace", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "engines": { "node": ">=22" diff --git a/package.json b/package.json index 5bd56fe..73b2610 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "claude-ask-marketplace", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Codex marketplace for the Claude Ask plugin.", "type": "module", "scripts": { "test": "node plugins/claude-ask/scripts/test-server.mjs", "test:live": "CLAUDE_ASK_LIVE=1 node plugins/claude-ask/scripts/test-server.mjs", - "check": "node --check plugins/claude-ask/scripts/server.mjs && node --check plugins/claude-ask/scripts/job-runner.mjs && node --check plugins/claude-ask/scripts/test-server.mjs" + "check": "node --check plugins/claude-ask/scripts/lib.mjs && node --check plugins/claude-ask/scripts/server.mjs && node --check plugins/claude-ask/scripts/job-runner.mjs && node --check plugins/claude-ask/scripts/fixtures/fake-claude.mjs && node --check plugins/claude-ask/scripts/test-server.mjs" }, "engines": { "node": ">=22" diff --git a/plugins/claude-ask/.codex-plugin/plugin.json b/plugins/claude-ask/.codex-plugin/plugin.json index 6c43da8..a9e7caf 100644 --- a/plugins/claude-ask/.codex-plugin/plugin.json +++ b/plugins/claude-ask/.codex-plugin/plugin.json @@ -1,32 +1,36 @@ { "name": "claude-ask", - "version": "0.1.0+codex.20260716120619", - "description": "Ask local Claude Code Fable or Opus models from Codex.", + "version": "0.2.0", + "description": "Use local Claude Code as a persistent full-access coding agent or independent second opinion from Codex.", "author": { "name": "max" }, "license": "MIT", "keywords": [ "claude-code", + "sonnet", "fable", "opus", - "consultation" + "coding-agent" ], "skills": "./skills/", "mcpServers": "./.mcp.json", "interface": { "displayName": "Claude Ask", - "shortDescription": "Ask Claude Fable or Opus from Codex.", - "longDescription": "Adds persistent asynchronous consultation tools backed by the locally installed and authenticated Claude Code CLI.", + "shortDescription": "Run persistent Claude Code agents and follow-ups.", + "longDescription": "Adds persistent asynchronous Claude Code jobs with full-access workspace agents, resumable follow-ups, configurable effort and budgets, progress telemetry, job discovery, and structured diagnostics.", "developerName": "max", "category": "Productivity", "capabilities": [ "Read", + "Write", "Interactive" ], "defaultPrompt": [ - "Ask Claude for a second opinion.", - "Ask Opus to challenge this approach." + "Ask Sonnet for a quick independent opinion without repository tools.", + "Ask Claude to investigate and fix this issue, then run the tests.", + "Ask Opus for an independent review of this implementation.", + "Continue the previous Claude job with a focused follow-up." ] } } diff --git a/plugins/claude-ask/README.md b/plugins/claude-ask/README.md index 544baa7..b6b14af 100644 --- a/plugins/claude-ask/README.md +++ b/plugins/claude-ask/README.md @@ -1,67 +1,67 @@ # Claude Ask -A personal Codex plugin exposing asynchronous MCP tools backed by the local -Claude Code CLI. - -The caller supplies the complete prompt and chooses `fable` or `opus`. For -ordinary consultations Claude runs with `--safe-mode` and no tools. For a code -review, the caller may pass an explicit absolute `workspace`; Claude then runs -with that directory as its real working directory and receives only the -built-in `Read`, `Glob`, and `Grep` tools. It cannot edit files or run shell -commands. - -`ask` starts a detached background job and immediately returns a `job_id`. -Claude uses streaming JSON with partial messages and writes model output to a -private progress file. Poll `ask_status` until the file-backed job completes; -each response reports byte growth plus recent stream-event, partial-text, and -partial-thinking counters. There is no elapsed-time or idle-time deadline. An -unchanged file is a waiting job, never an automatic cancellation signal. - -This asynchronous contract keeps every MCP `tools/call` short. The detached -runner survives an MCP server restart and the same `job_id` remains queryable. -A machine reboot or runner crash is reported explicitly as an interrupted job; -it is never presented as a model answer. Only explicit `cancel_ask`, the output -safety limit, Claude/runner failure, or operating-system termination ends work. - -Job directories are mode 0700 and every state, result, cancel, and stream file -is mode 0600. The prompt is never put in argv, environment variables, state, or -diagnostic logs: it goes directly to the runner and Claude over stdin. Claude's -private output stream and completed result are retained for 24 hours, then -removed. At most four jobs run concurrently by default. - -## Requirements - -- Claude Code available at `~/.local/bin/claude`, or set `CLAUDE_ASK_CLI`. -- A working Claude Code login (`claude auth status`). +A personal Codex plugin that exposes local Claude Code Sonnet 5, Fable, and Opus as persistent asynchronous MCP jobs. -## Test +Sonnet with `high` effort is the default. Each job can select `medium`, `high`, `xhigh`, or `max` effort, set a human-readable name, and enforce a `max_budget_usd` ceiling. -```bash -node scripts/test-server.mjs -``` +## Tools + +| Tool | Purpose | +|---|---| +| `ask` | Start a new advisory or workspace-agent job | +| `ask_followup` | Resume a completed job's Claude session and fork an independent branch | +| `ask_status` | Wait for progress or retrieve the terminal result | +| `list_jobs` | List recent jobs and recover lost IDs | +| `cancel_ask` | Explicitly cancel a running job and its process tree | + +`ask` returns a `job_id` immediately. Poll `ask_status` until the status is `completed`, `error`, or `cancelled`. There is no elapsed-time or idle-time deadline; unchanged output means the job is still waiting. Progress includes output bytes, estimated thinking tokens, tool-call count, elapsed time, and the last event type. -The test uses a fake Claude executable and does not consume model usage. +Completed sessions remain resumable in Claude Code's normal session storage, outside the plugin job directory and its 24-hour cleanup. `ask_followup` inherits the parent model, workspace, mode, and effort unless effort is overridden. It uses Claude Code resume-and-fork semantics, so the parent remains unchanged. A budget applies only to the new call. -After authenticating Claude Code, an explicit paid/limited-usage smoke test is available with `CLAUDE_ASK_LIVE=1 node scripts/test-server.mjs`. +## Access modes -Optional controls are `CLAUDE_ASK_JOB_DIR`, -`CLAUDE_ASK_MAX_CONCURRENT_JOBS`, `CLAUDE_ASK_MAX_OUTPUT_BYTES`, and -`CLAUDE_ASK_COMPLETED_RETENTION_MS`. None of them is a wall-clock or idle -deadline. +Without `workspace`, Claude runs in advisory safe mode with no tools and receives only the supplied question. -## Workspace reviews +With an explicit absolute `workspace`, Claude runs as a full autonomous coding agent. Its normal default tools, shell, file editing, user/project instructions and hooks, network access, and configured MCP servers, skills, and plugins remain available. Interactive permission prompts are bypassed because a detached job cannot answer them. -The `ask` input accepts: +The workspace is Claude's actual working directory and intended task scope, not a hard operating-system sandbox. Claude runs as the current user and can technically access other paths and the network. Only pass trusted workspaces and prompts. + +Example: ```json { - "question": "Review the current implementation and report blockers.", - "model": "fable", - "workspace": "/absolute/path/to/repository" + "question": "Investigate the failure, implement a fix, run the relevant tests, and summarize the result.", + "model": "sonnet", + "workspace": "/absolute/path/to/repository", + "name": "fix flaky integration test", + "effort": "high", + "max_budget_usd": 5 } ``` -The workspace must already exist and be an absolute directory. It is -canonicalized before the job starts, recorded in private job state for audit, -and returned by `ask_status`. Workspace access is opt-in per job; no global -working-directory environment variable is used. +## Persistence and diagnostics + +Jobs survive MCP server restarts and remain queryable by ID. Boot-aware recovery distinguishes a live detached runner from an interrupted job. Cancellation signals Claude's whole process group on POSIX systems, with a direct-child fallback on Windows. + +Errors return structured `error_code`, `next_action`, `stderr_tail`, and `stderr_truncated` fields. Claude stderr is stored privately up to 1 MiB; only a bounded 4 KiB tail is returned. Output-limit errors warn that workspace changes may already exist. + +Job directories use mode 0700; state, result, stream, stderr, and cancellation files use mode 0600. Prompts never appear in argv, environment variables, state, or diagnostic logs: they travel over stdin. Completed data is retained for 24 hours by default. Up to four jobs run concurrently. + +## Requirements and tests + +- Claude Code on `PATH` or at `~/.local/bin/claude`; alternatively set `CLAUDE_ASK_CLI`. +- A working Claude Code login (`claude auth status`). + +Run the no-usage test suite: + +```bash +node scripts/test-server.mjs +``` + +An authenticated paid/limited-usage smoke test is opt-in: + +```bash +CLAUDE_ASK_LIVE=1 node scripts/test-server.mjs +``` + +Optional controls are `CLAUDE_ASK_JOB_DIR`, `CLAUDE_ASK_MAX_CONCURRENT_JOBS`, `CLAUDE_ASK_MAX_OUTPUT_BYTES`, and `CLAUDE_ASK_COMPLETED_RETENTION_MS`. None sets a wall-clock or idle deadline. diff --git a/plugins/claude-ask/scripts/fixtures/fake-claude.mjs b/plugins/claude-ask/scripts/fixtures/fake-claude.mjs index 85f7958..bf5f5e1 100755 --- a/plugins/claude-ask/scripts/fixtures/fake-claude.mjs +++ b/plugins/claude-ask/scripts/fixtures/fake-claude.mjs @@ -1,35 +1,47 @@ #!/usr/bin/env node +import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; const args = process.argv.slice(2); const modelIndex = args.indexOf("--model"); const model = modelIndex >= 0 ? args[modelIndex + 1] : "missing"; +const effortIndex = args.indexOf("--effort"); +const effort = effortIndex >= 0 ? args[effortIndex + 1] : "missing"; +const budgetIndex = args.indexOf("--max-budget-usd"); +const budget = budgetIndex >= 0 ? args[budgetIndex + 1] : null; +const sessionIndex = args.indexOf("--session-id"); +const sessionId = sessionIndex >= 0 ? args[sessionIndex + 1] : null; +const resumeIndex = args.indexOf("--resume"); +const resumeSessionId = resumeIndex >= 0 ? args[resumeIndex + 1] : null; +const forksSession = args.includes("--fork-session"); const requiredFlags = [ "-p", - "--safe-mode", - "--disable-slash-commands", - "--permission-mode", - "dontAsk", "--output-format", "stream-json", "--include-partial-messages", "--verbose", "--input-format", "text", - "--no-session-persistence", ]; const toolsIndex = args.indexOf("--tools"); -const allowedToolsIndex = args.indexOf("--allowedTools"); -const noToolsInvocation = toolsIndex >= 0 && args[toolsIndex + 1] === ""; -const readOnlyInvocation = +const noToolsInvocation = toolsIndex >= 0 && - args[toolsIndex + 1] === "Read,Glob,Grep" && - allowedToolsIndex >= 0 && - args[allowedToolsIndex + 1] === "Read,Glob,Grep"; + args[toolsIndex + 1] === "" && + args.includes("--safe-mode") && + args.includes("--disable-slash-commands") && + args.includes("--permission-mode") && + args.includes("dontAsk"); +const agentInvocation = + toolsIndex >= 0 && + args[toolsIndex + 1] === "default" && + args.includes("--dangerously-skip-permissions") && + args.includes("--append-system-prompt") && + !args.includes("--safe-mode") && + !args.includes("--disable-slash-commands"); const safeInvocation = requiredFlags.every((flag) => args.includes(flag)) && - (noToolsInvocation || readOnlyInvocation); + (noToolsInvocation || agentInvocation); let question = ""; for await (const chunk of process.stdin) question += chunk; @@ -46,7 +58,8 @@ const init = { type: "system", subtype: "init", model: `claude-${model}-fake`, - session_id: "fake-session", + session_id: + sessionId || (resumeSessionId ? "fake-followup-session" : "fake-session"), }; emit(init); @@ -61,6 +74,22 @@ if (!safeInvocation) { process.exitCode = 2; } else if (question === "hold-for-cancel") { await new Promise((resolve) => setInterval(resolve, 60_000)); +} else if (question === "hold-with-descendant") { + const descendant = spawn( + process.execPath, + [ + "-e", + "process.on('SIGTERM', () => {}); setInterval(() => {}, 60_000)", + ], + { stdio: "ignore" }, + ); + emit({ + type: "system", + subtype: "descendant_pid", + pid: descendant.pid, + session_id: sessionId || "fake-session", + }); + await new Promise((resolve) => setInterval(resolve, 60_000)); } else if (question === "large-output") { emit({ type: "stream_event", @@ -73,6 +102,13 @@ if (!safeInvocation) { } else { if (question === "idle-progress" || question === "restart-progress") { await delay(Number(process.env.FAKE_CLAUDE_DELAY_MS || 250)); + emit({ + type: "system", + subtype: "thinking_tokens", + estimated_tokens: 75, + estimated_tokens_delta: 75, + session_id: sessionId || "fake-session", + }); emit({ type: "stream_event", event: { @@ -81,6 +117,20 @@ if (!safeInvocation) { }, }); await delay(Number(process.env.FAKE_CLAUDE_DELAY_MS || 250)); + emit({ + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "fake-tool-use", + name: "Read", + input: { file_path: "fake.txt" }, + }, + ], + }, + session_id: sessionId || "fake-session", + }); emit({ type: "stream_event", event: { @@ -90,26 +140,98 @@ if (!safeInvocation) { }); await delay(Number(process.env.FAKE_CLAUDE_DELAY_MS || 250)); } - if (question === "authentication-error") { + if (question === "option-probe") { + const optionInvocation = + effort === "medium" && + budget === "1.25" && + /^[0-9a-f-]{36}$/i.test(sessionId || "") && + !args.includes("--no-session-persistence"); + emit({ + type: "result", + subtype: optionInvocation ? "success" : "error_during_execution", + is_error: !optionInvocation, + result: optionInvocation ? "OPTIONS_OK" : "option invocation mismatch", + session_id: sessionId || "fake-session", + }); + if (!optionInvocation) process.exitCode = 2; + } else if (question === "default-effort-probe") { + const defaultEffortInvocation = effort === "high"; + emit({ + type: "result", + subtype: defaultEffortInvocation ? "success" : "error_during_execution", + is_error: !defaultEffortInvocation, + result: defaultEffortInvocation + ? "DEFAULT_EFFORT_OK" + : "default effort mismatch", + session_id: sessionId || "fake-session", + }); + if (!defaultEffortInvocation) process.exitCode = 2; + } else if (question === "recall") { + const followupInvocation = + agentInvocation && + effort === "xhigh" && + budget === "2" && + /^[0-9a-f-]{36}$/i.test(resumeSessionId || "") && + forksSession && + sessionId === null; + emit({ + type: "result", + subtype: followupInvocation ? "success" : "error_during_execution", + is_error: !followupInvocation, + result: followupInvocation ? "FOLLOWUP_OK" : "follow-up invocation mismatch", + session_id: "fake-followup-session", + }); + if (!followupInvocation) process.exitCode = 2; + } else if (question === "authentication-error") { emit({ type: "result", subtype: "error_during_execution", is_error: true, api_error_status: 401, result: "authentication credentials rejected", - session_id: "fake-session", + session_id: sessionId || "fake-session", + }); + process.exitCode = 1; + } else if (question === "diagnostic-error") { + process.stderr.write("DIAGNOSTIC_MARKER\n"); + emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + result: "diagnostic failure", + session_id: sessionId || "fake-session", + }); + process.exitCode = 1; + } else if (question === "large-stderr-error") { + process.stderr.write("e".repeat(1024 * 1024 + 8192)); + emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + result: "large stderr failure", + session_id: sessionId || "fake-session", + }); + process.exitCode = 1; + } else if (question === "binary-stderr-error") { + process.stderr.write(Buffer.alloc(8192, 0xff)); + emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + result: "binary stderr failure", + session_id: sessionId || "fake-session", }); process.exitCode = 1; } else if (question.startsWith("workspace-probe:")) { const expectedWorkspace = question.slice("workspace-probe:".length); const workspaceOkay = - readOnlyInvocation && process.cwd() === expectedWorkspace; + agentInvocation && process.cwd() === expectedWorkspace; emit({ type: "result", subtype: workspaceOkay ? "success" : "error_during_execution", is_error: !workspaceOkay, result: workspaceOkay ? "WORKSPACE_OK" : "workspace invocation mismatch", - session_id: "fake-session", + session_id: sessionId || "fake-session", }); if (!workspaceOkay) process.exitCode = 2; } else { @@ -119,14 +241,14 @@ if (!safeInvocation) { message: { content: [{ type: "text", text: "fake complete" }], }, - session_id: "fake-session", + session_id: sessionId || "fake-session", }); emit({ type: "result", subtype: "success", is_error: false, result: `fake:${model}:${digest}`, - session_id: "fake-session", + session_id: sessionId || "fake-session", total_cost_usd: 0, usage: { input_tokens: 1, output_tokens: 1 }, modelUsage: { diff --git a/plugins/claude-ask/scripts/job-runner.mjs b/plugins/claude-ask/scripts/job-runner.mjs index 3039f7d..35e0dd3 100755 --- a/plugins/claude-ask/scripts/job-runner.mjs +++ b/plugins/claude-ask/scripts/job-runner.mjs @@ -18,21 +18,30 @@ import { questionDigest, readJson, safeClose, + validateBudget, + validateEffort, validateWorkspace, } from "./lib.mjs"; -const [jobPath, expectedJobId, model] = process.argv.slice(2); +const [jobPath, expectedJobId, model, requestedEffort] = process.argv.slice(2); const paths = jobPaths(jobPath || ""); let child = null; +let childProcessGroupId = null; let outputFd = null; +let stderrFd = null; let outputBytes = 0; let outputTooLarge = false; +let stderrBytes = 0; +let stderrTruncated = false; let cancelRequested = false; let shutdownRequested = false; let spawnError = null; let completed = false; let cancelPoll = null; -let forceKillTimer = null; +let stopPromise = null; +const MAX_STDERR_BYTES = 1024 * 1024; +const USE_PROCESS_GROUPS = process.platform !== "win32"; +const TERMINATION_GRACE_MS = 5_000; function writeState(patch) { let previous = {}; @@ -44,30 +53,72 @@ function writeState(patch) { atomicWriteJson(paths.state, { ...previous, ...patch }); } -function requestChildStop() { +function signalChildTree(signal) { + if (USE_PROCESS_GROUPS && Number.isSafeInteger(childProcessGroupId)) { + try { + process.kill(-childProcessGroupId, signal); + return; + } catch (error) { + if (error?.code === "ESRCH") return; + } + } if (!child || child.exitCode !== null || child.signalCode !== null) return; - child.kill("SIGTERM"); - if (!forceKillTimer) { - forceKillTimer = setTimeout(() => { - if (child?.exitCode === null && child?.signalCode === null) { - child.kill("SIGKILL"); - } - }, 5_000); - forceKillTimer.unref(); + try { + child.kill(signal); + } catch { + // The process may have exited between the state check and the signal. } } +function childTreeExists() { + if (USE_PROCESS_GROUPS && Number.isSafeInteger(childProcessGroupId)) { + try { + process.kill(-childProcessGroupId, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } + } + return Boolean( + child && child.exitCode === null && child.signalCode === null, + ); +} + +async function waitForChildTreeExit(timeoutMs = null) { + const deadline = + timeoutMs === null ? Number.POSITIVE_INFINITY : Date.now() + timeoutMs; + while (childTreeExists() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return !childTreeExists(); +} + +function requestChildStop() { + if (!child) return Promise.resolve(); + if (!stopPromise) { + stopPromise = (async () => { + signalChildTree("SIGTERM"); + if (await waitForChildTreeExit(TERMINATION_GRACE_MS)) return; + signalChildTree("SIGKILL"); + await waitForChildTreeExit(); + })(); + } + return stopPromise; +} + function finishState(status, patch = {}) { if (completed) return; completed = true; if (cancelPoll) clearInterval(cancelPoll); - if (forceKillTimer) clearTimeout(forceKillTimer); safeClose(outputFd); outputFd = null; + safeClose(stderrFd); + stderrFd = null; writeState({ status, completedAt: Date.now(), outputBytes, + stderrTruncated, ...patch, }); } @@ -79,10 +130,12 @@ async function readPrompt() { } async function main() { - if (!jobPath || !expectedJobId || !model) process.exit(2); + if (!jobPath || !expectedJobId || !model || !requestedEffort) process.exit(2); + const effort = validateEffort(requestedEffort); writeState({ id: expectedJobId, model, + effort, status: "starting", runnerPid: process.pid, }); @@ -105,8 +158,24 @@ async function main() { const expectedBytes = Number(transferHeader?.bytes); const expectedDigest = transferHeader?.digest; let workspace; + let maxBudgetUsd; + let resumeSessionId; try { workspace = validateWorkspace(transferHeader?.workspace); + maxBudgetUsd = validateBudget(transferHeader?.maxBudgetUsd); + resumeSessionId = + typeof transferHeader?.resumeSessionId === "string" && + transferHeader.resumeSessionId.length > 0 && + transferHeader.resumeSessionId.length <= 128 + ? transferHeader.resumeSessionId + : null; + if ( + transferHeader?.resumeSessionId !== null && + transferHeader?.resumeSessionId !== undefined && + resumeSessionId === null + ) { + throw new Error("Invalid resume session."); + } } catch { transferred.fill(0); finishState("error", { errorCode: "workspace_invalid" }); @@ -136,38 +205,66 @@ async function main() { } outputFd = openPrivateFile(paths.stream); + stderrFd = openPrivateFile(paths.stderr); + const localExecutable = `${homedir()}/.local/bin/claude`; const executable = - process.env.CLAUDE_ASK_CLI || `${homedir()}/.local/bin/claude`; - const readOnlyTools = "Read,Glob,Grep"; - const args = [ + process.env.CLAUDE_ASK_CLI || + (existsSync(localExecutable) ? localExecutable : "claude"); + const commonArgs = [ "-p", - "--safe-mode", - "--disable-slash-commands", "--model", model, - "--tools", - workspace ? readOnlyTools : "", - ...(workspace ? ["--allowedTools", readOnlyTools] : []), - "--permission-mode", - "dontAsk", + "--effort", + effort, "--output-format", "stream-json", "--include-partial-messages", "--verbose", "--input-format", "text", - "--no-session-persistence", ]; + if (resumeSessionId) { + commonArgs.push("--resume", resumeSessionId, "--fork-session"); + } else { + commonArgs.push("--session-id", expectedJobId); + } + if (maxBudgetUsd !== null) { + commonArgs.push("--max-budget-usd", String(maxBudgetUsd)); + } + const args = workspace + ? [ + ...commonArgs, + "--tools", + "default", + "--dangerously-skip-permissions", + "--append-system-prompt", + "Treat the current working directory as the task scope. Do not access or modify paths outside it unless the user's request explicitly requires that.", + ] + : [ + ...commonArgs, + "--safe-mode", + "--disable-slash-commands", + "--tools", + "", + "--permission-mode", + "dontAsk", + ]; child = spawn(executable, args, { cwd: workspace || jobPath, - env: { ...process.env, CLAUDE_CODE_SAFE_MODE: "1" }, + env: workspace + ? process.env + : { ...process.env, CLAUDE_CODE_SAFE_MODE: "1" }, shell: false, - stdio: ["pipe", "pipe", "ignore"], + detached: USE_PROCESS_GROUPS, + stdio: ["pipe", "pipe", "pipe"], }); + childProcessGroupId = USE_PROCESS_GROUPS ? child.pid || null : null; writeState({ id: expectedJobId, model, + effort, status: "running", + mode: workspace ? "agent" : "advisory", runnerPid: process.pid, claudePid: child.pid || null, }); @@ -190,6 +287,16 @@ async function main() { requestChildStop(); } }); + child.stderr.on("data", (chunk) => { + if (stderrFd === null || stderrTruncated) return; + const remaining = MAX_STDERR_BYTES - stderrBytes; + if (remaining > 0) { + const value = chunk.subarray(0, remaining); + writeSync(stderrFd, value); + stderrBytes += value.length; + } + if (chunk.length > remaining) stderrTruncated = true; + }); child.on("error", (error) => { spawnError = error; }); @@ -201,15 +308,19 @@ async function main() { }, 200); cancelPoll.unref(); - child.on("close", (code, signal) => { + child.on("close", async (code, signal) => { safeClose(outputFd); outputFd = null; + safeClose(stderrFd); + stderrFd = null; if (cancelRequested || existsSync(paths.cancel)) { + await requestChildStop(); finishState("cancelled", { exitCode: code, signal: signal || null }); return; } if (shutdownRequested) { + await requestChildStop(); finishState("error", { errorCode: "runner_interrupted", exitCode: code, @@ -218,6 +329,7 @@ async function main() { return; } if (outputTooLarge) { + await requestChildStop(); finishState("error", { errorCode: "output_limit_exceeded", exitCode: code, @@ -253,8 +365,10 @@ async function main() { } parsed.result.requested_model = model; + parsed.result.requested_effort = effort; atomicWriteJson(paths.result, parsed.result); finishState("completed", { + sessionId: parsed.result.session_id || null, exitCode: code, signal: signal || null, }); diff --git a/plugins/claude-ask/scripts/lib.mjs b/plugins/claude-ask/scripts/lib.mjs index 12fb377..3b671ad 100644 --- a/plugins/claude-ask/scripts/lib.mjs +++ b/plugins/claude-ask/scripts/lib.mjs @@ -14,7 +14,10 @@ import { import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; -export const SUPPORTED_MODELS = new Set(["fable", "opus"]); +export const DEFAULT_MODEL = "sonnet"; +export const DEFAULT_EFFORT = "high"; +export const SUPPORTED_MODELS = new Set(["sonnet", "fable", "opus"]); +export const SUPPORTED_EFFORTS = new Set(["medium", "high", "xhigh", "max"]); export const MAX_QUESTION_CHARS = 200_000; export const MAX_OUTPUT_BYTES = boundedInteger( process.env.CLAUDE_ASK_MAX_OUTPUT_BYTES, @@ -53,6 +56,41 @@ export function validateWorkspace(value) { return canonical; } +export function validateJobName(value) { + if (value === undefined || value === null) return null; + if (typeof value !== "string") { + throw new Error("Job name must be a string."); + } + const name = value.trim(); + if (name.length === 0 || name.length > 120) { + throw new Error("Job name must contain 1..120 characters."); + } + return name; +} + +export function validateEffort(value, fallback = DEFAULT_EFFORT) { + const effort = value ?? fallback; + if (!SUPPORTED_EFFORTS.has(effort)) { + throw new Error("Effort must be `medium`, `high`, `xhigh`, or `max`."); + } + return effort; +} + +export function validateBudget(value) { + if (value === undefined || value === null) return null; + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0.01 || + value > 1000 + ) { + throw new Error( + "Maximum budget must be a finite number from 0.01 through 1000.", + ); + } + return value; +} + function boundedInteger(raw, fallback, minimum, maximum) { if (raw === undefined || raw === "") return fallback; const parsed = Number(raw); @@ -103,6 +141,7 @@ export function jobPaths(jobPath) { state: join(jobPath, "state.json"), result: join(jobPath, "result.json"), stream: join(jobPath, "stream.jsonl"), + stderr: join(jobPath, "stderr.log"), cancel: join(jobPath, "cancel.request"), }; } @@ -197,6 +236,7 @@ export function parseClaudeStream(stdout) { ? resultEvent.result : JSON.stringify(resultEvent.result ?? ""), requested_model: null, + requested_effort: null, resolved_model: initEvent?.model || null, session_id: resultEvent.session_id || initEvent?.session_id || null, total_cost_usd: resultEvent.total_cost_usd ?? null, @@ -215,7 +255,8 @@ export function streamSummary(path) { return { json_events: 0, partial_text_chars: 0, - partial_thinking_chars: 0, + estimated_thinking_tokens: 0, + tool_calls: 0, last_event_type: null, }; } @@ -224,7 +265,8 @@ export function streamSummary(path) { let jsonEventsCount = 0; let partialTextChars = 0; - let partialThinkingChars = 0; + let estimatedThinkingTokens = 0; + let toolCalls = 0; let lastEventType = null; for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; @@ -242,16 +284,23 @@ export function streamSummary(path) { partialTextChars += delta.text.length; } if ( - delta?.type === "thinking_delta" && - typeof delta.thinking === "string" + item?.type === "system" && + item?.subtype === "thinking_tokens" && + Number.isFinite(item.estimated_tokens_delta) ) { - partialThinkingChars += delta.thinking.length; + estimatedThinkingTokens += item.estimated_tokens_delta; + } + if (item?.type === "assistant" && Array.isArray(item?.message?.content)) { + toolCalls += item.message.content.filter( + (content) => content?.type === "tool_use", + ).length; } } return { json_events: jsonEventsCount, partial_text_chars: partialTextChars, - partial_thinking_chars: partialThinkingChars, + estimated_thinking_tokens: estimatedThinkingTokens, + tool_calls: toolCalls, last_event_type: lastEventType, }; } diff --git a/plugins/claude-ask/scripts/server.mjs b/plugins/claude-ask/scripts/server.mjs index 6bc7e89..89a182c 100755 --- a/plugins/claude-ask/scripts/server.mjs +++ b/plugins/claude-ask/scripts/server.mjs @@ -10,12 +10,15 @@ import { readFileSync, rmSync, } from "node:fs"; +import { uptime } from "node:os"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; import { atomicWriteJson, COMPLETED_JOB_RETENTION_MS, createPrivateJobDirectory, + DEFAULT_EFFORT, + DEFAULT_MODEL, ensurePrivateDirectory, fileProgress, isAuthenticationError, @@ -29,13 +32,19 @@ import { readJson, streamSummary, SUPPORTED_MODELS, + validateBudget, + validateEffort, + validateJobName, validateWorkspace, } from "./lib.mjs"; const SERVER_NAME = "claude-ask"; -const SERVER_VERSION = "0.3.0"; +const SERVER_VERSION = "0.2.0"; const RUNNER_PATH = fileURLToPath(new URL("./job-runner.mjs", import.meta.url)); const TERMINAL_STATUSES = new Set(["completed", "error", "cancelled"]); +const SERVER_BOOT_STARTED_AT = Date.now() - uptime() * 1_000; +const BOOT_TIME_TOLERANCE_MS = 60_000; +const STARTUP_GRACE_MS = 5_000; ensurePrivateDirectory(JOB_DIR); @@ -61,6 +70,17 @@ function toolError(message, structuredContent = null) { }; } +function codedToolError(errorCode, message, nextActionText, extra = {}) { + return toolError(message, { + ...extra, + error_code: errorCode, + message, + next_action: nextActionText, + stderr_tail: null, + stderr_truncated: false, + }); +} + function processExists(pid) { if (!Number.isSafeInteger(pid) || pid <= 0) return false; try { @@ -77,12 +97,34 @@ function jobPathById(jobId) { return existsSync(path) ? path : null; } +function isValidJobState(state, expectedId) { + return Boolean( + state && + typeof state === "object" && + state.id === expectedId && + SUPPORTED_MODELS.has(state.model) && + ["starting", "running", "completed", "error", "cancelled"].includes( + state.status, + ) && + Number.isFinite(state.startedAt) && + (state.completedAt === null || + state.completedAt === undefined || + Number.isFinite(state.completedAt)), + ); +} + function recoverOrphanedJob(jobPath, state) { if (TERMINAL_STATUSES.has(state.status)) return state; - if (state.status === "starting" && processExists(state.launcherPid)) { - return state; - } - if (processExists(state.runnerPid)) return state; + const recordedBoot = Number(state.bootStartedAt); + const sameBoot = + !Number.isFinite(recordedBoot) || + Math.abs(recordedBoot - SERVER_BOOT_STARTED_AT) <= BOOT_TIME_TOLERANCE_MS; + const withinStartupGrace = + state.status === "starting" && + Number.isFinite(state.startedAt) && + Date.now() - state.startedAt < STARTUP_GRACE_MS; + if (sameBoot && withinStartupGrace) return state; + if (sameBoot && processExists(state.runnerPid)) return state; const paths = jobPaths(jobPath); if (existsSync(paths.cancel)) { @@ -100,10 +142,12 @@ function recoverOrphanedJob(jobPath, state) { const parsed = parseClaudeStream(readFileSync(paths.stream, "utf8")); if (!parsed.event?.is_error) { parsed.result.requested_model = state.model; + parsed.result.requested_effort = state.effort || DEFAULT_EFFORT; atomicWriteJson(paths.result, parsed.result); const recovered = { ...state, status: "completed", + sessionId: parsed.result.session_id || null, completedAt: Date.now(), recoveredAfterRestart: true, }; @@ -138,7 +182,9 @@ function loadJob(jobId) { const path = jobPathById(jobId); if (!path) return null; try { - const state = recoverOrphanedJob(path, readJson(jobPaths(path).state)); + const storedState = readJson(jobPaths(path).state); + if (!isValidJobState(storedState, jobId)) return null; + const state = recoverOrphanedJob(path, storedState); return { path, paths: jobPaths(path), state }; } catch { return null; @@ -166,11 +212,39 @@ function cleanupExpiredJobs() { } } -async function startClaudeJob(question, model, workspace) { +async function waitForRunnerStart(paths, timeoutMs = 2_000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + try { + const state = readJson(paths.state); + if ( + state.status === "running" || + TERMINAL_STATUSES.has(state.status) + ) { + return; + } + } catch { + // The runner may be between atomic state-file updates. + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +async function startClaudeJob({ + question, + model, + workspace, + name = null, + effort = DEFAULT_EFFORT, + maxBudgetUsd = null, + parentJobId = null, + resumeSessionId = null, +}) { if (runningJobCount() >= MAX_CONCURRENT_JOBS) { - return toolError( - `Claude Ask already has ${MAX_CONCURRENT_JOBS} concurrent jobs. ` + - "Wait for one to finish or cancel one explicitly.", + return codedToolError( + "concurrency_limit", + `Claude Ask already has ${MAX_CONCURRENT_JOBS} concurrent jobs.`, + "Wait for one to finish or cancel one explicitly.", ); } @@ -181,19 +255,27 @@ async function startClaudeJob(question, model, workspace) { const digest = questionDigest(question); atomicWriteJson(paths.state, { id: job.id, + name, model, + effort, + maxBudgetUsd, + parentJobId, + sessionId: null, + resumable: true, status: "starting", startedAt, completedAt: null, + bootStartedAt: SERVER_BOOT_STARTED_AT, runnerPid: null, claudePid: null, launcherPid: process.pid, workspace, + mode: workspace ? "agent" : "advisory", }); const runner = spawn( process.execPath, - [RUNNER_PATH, job.path, job.id, model], + [RUNNER_PATH, job.path, job.id, model, effort], { cwd: job.path, env: process.env, @@ -202,12 +284,23 @@ async function startClaudeJob(question, model, workspace) { stdio: ["pipe", "ignore", "ignore"], }, ); + atomicWriteJson(paths.state, { + ...readJson(paths.state), + bootStartedAt: SERVER_BOOT_STARTED_AT, + runnerPid: Number.isSafeInteger(runner.pid) ? runner.pid : null, + }); await new Promise((resolve, reject) => { runner.once("error", reject); runner.stdin.once("error", reject); runner.stdin.cork(); runner.stdin.write( - `${JSON.stringify({ bytes: questionBytes, digest, workspace })}\n`, + `${JSON.stringify({ + bytes: questionBytes, + digest, + workspace, + maxBudgetUsd, + resumeSessionId, + })}\n`, ); runner.stdin.end(question, "utf8", resolve); }).catch((spawnError) => { @@ -220,6 +313,7 @@ async function startClaudeJob(question, model, workspace) { throw spawnError; }); runner.unref(); + await waitForRunnerStart(paths); const loaded = loadJob(job.id); return TERMINAL_STATUSES.has(loaded.state.status) @@ -231,6 +325,15 @@ function progressFor(job) { return fileProgress(job.paths.stream, job.state.startedAt); } +function elapsedFor(job) { + const end = job.state.completedAt || Date.now(); + return Math.max(0, Math.round(end - job.state.startedAt)); +} + +function publicStatus(state) { + return state.status === "starting" ? "running" : state.status; +} + function waitForProgress(job, initialBytes, waitMs) { if ( waitMs <= 0 || @@ -263,13 +366,20 @@ function runningResult(job, previousBytes = 0) { const summary = streamSummary(job.paths.stream); const structuredContent = { job_id: job.state.id, - status: job.state.status === "starting" ? "running" : job.state.status, + name: job.state.name || null, + model: job.state.model, + parent_job_id: job.state.parentJobId || null, + status: publicStatus(job.state), progress, output_bytes: progressState.bytes, delta_bytes: deltaBytes, last_growth_ms_ago: Math.max(0, Date.now() - progressState.lastGrowthAt), started_at: new Date(job.state.startedAt).toISOString(), + elapsed_ms: elapsedFor(job), workspace: job.state.workspace || null, + mode: job.state.mode || (job.state.workspace ? "agent" : "advisory"), + effort: job.state.effort || DEFAULT_EFFORT, + max_budget_usd: job.state.maxBudgetUsd ?? null, ...summary, }; return { @@ -305,25 +415,85 @@ function errorMessage(errorCode) { return messages[errorCode] || "Claude job failed."; } +function nextAction(errorCode) { + const actions = { + authentication_failed: "Run `claude auth login`, then retry the job.", + output_limit_exceeded: + "Inspect the workspace because Claude may have changed files before its output was stopped.", + workspace_invalid: "Pass an existing absolute workspace directory.", + spawn_failed: + "Set `CLAUDE_ASK_CLI` to a working Claude executable or add `claude` to PATH.", + invalid_stream: + "Review stderr_tail and verify that the installed Claude CLI supports stream-json output.", + prompt_transfer_failed: + "Retry the job; if the error repeats, verify that the plugin server and runner files are from the same version.", + runner_spawn_failed: + "Verify the Node.js runtime and plugin files, then retry the job.", + runner_interrupted: + "Inspect the workspace for partial changes, then retry the job.", + runner_failed: + "Verify the plugin files and runtime, then retry the job.", + }; + return actions[errorCode] || "Review stderr_tail, correct the cause, and retry."; +} + +function stderrTail(job, maximumBytes = 4096) { + try { + const stderr = readFileSync(job.paths.stderr); + const decoded = stderr + .subarray(Math.max(0, stderr.length - maximumBytes)) + .toString("utf8"); + if (Buffer.byteLength(decoded, "utf8") <= maximumBytes) return decoded; + + let low = 0; + let high = decoded.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (Buffer.byteLength(decoded.slice(middle), "utf8") > maximumBytes) { + low = middle + 1; + } else { + high = middle; + } + } + return decoded.slice(low); + } catch (error) { + if (error?.code === "ENOENT") return null; + return null; + } +} + function finalJobResult(job) { const progressState = progressFor(job); const base = { job_id: job.state.id, + name: job.state.name || null, + model: job.state.model, + parent_job_id: job.state.parentJobId || null, status: job.state.status, output_bytes: progressState.bytes, started_at: new Date(job.state.startedAt).toISOString(), workspace: job.state.workspace || null, + mode: job.state.mode || (job.state.workspace ? "agent" : "advisory"), + effort: job.state.effort || DEFAULT_EFFORT, + max_budget_usd: job.state.maxBudgetUsd ?? null, completed_at: job.state.completedAt ? new Date(job.state.completedAt).toISOString() : null, + elapsed_ms: elapsedFor(job), recovered_after_restart: Boolean(job.state.recoveredAfterRestart), + ...streamSummary(job.paths.stream), }; if (job.state.status === "completed") { let answer; try { answer = readJson(job.paths.result); } catch { - return toolError("Claude result file is unavailable.", base); + return codedToolError( + "result_unavailable", + "Claude result file is unavailable.", + "Retry the job because its completed result cannot be read.", + base, + ); } return { content: [{ type: "text", text: answer.answer }], @@ -332,9 +502,23 @@ function finalJobResult(job) { }; } if (job.state.status === "cancelled") { - return toolError(`Claude job ${job.state.id} was cancelled.`, base); + return codedToolError( + "cancelled", + `Claude job ${job.state.id} was cancelled.`, + "Start a new job if the work is still needed.", + base, + ); } - return toolError(errorMessage(job.state.errorCode), base); + const errorCode = job.state.errorCode || "unknown_error"; + const message = errorMessage(errorCode); + return toolError(message, { + ...base, + error_code: errorCode, + message, + next_action: nextAction(errorCode), + stderr_tail: stderrTail(job), + stderr_truncated: Boolean(job.state.stderrTruncated), + }); } async function handleToolCall(params) { @@ -343,38 +527,171 @@ async function handleToolCall(params) { if (toolName === "ask") { const question = args.question; - const model = args.model || "fable"; + const model = args.model || DEFAULT_MODEL; let workspace; + let name; + let effort; + let maxBudgetUsd; if (typeof question !== "string" || question.trim().length === 0) { - return toolError("`question` must be a non-empty string."); + return codedToolError( + "invalid_request", + "`question` must be a non-empty string.", + "Pass a non-empty `question`.", + ); } if (question.length > MAX_QUESTION_CHARS) { - return toolError( + return codedToolError( + "invalid_request", `Question exceeds the ${MAX_QUESTION_CHARS}-character limit.`, + "Shorten the question and retry.", ); } if (!SUPPORTED_MODELS.has(model)) { - return toolError("`model` must be `fable` or `opus`."); + return codedToolError( + "invalid_request", + "`model` must be `sonnet`, `fable`, or `opus`.", + "Choose a supported model alias.", + ); } try { workspace = validateWorkspace(args.workspace); + name = validateJobName(args.name); + effort = validateEffort(args.effort); + maxBudgetUsd = validateBudget(args.max_budget_usd); } catch (workspaceError) { - return toolError( + return codedToolError( + "invalid_request", workspaceError instanceof Error ? workspaceError.message : "Invalid workspace path.", + "Correct the request options and retry.", + ); + } + try { + return await startClaudeJob({ + question, + model, + workspace, + name, + effort, + maxBudgetUsd, + }); + } catch { + return codedToolError( + "job_start_failed", + "Claude Ask could not start the background job.", + "Verify the plugin runtime and retry.", + ); + } + } + + if (toolName === "ask_followup") { + const parent = loadJob(args.job_id); + if (!parent) { + return codedToolError( + "session_unavailable", + "Unknown or expired parent Claude job ID.", + "Start a new Claude job with `ask`.", + ); + } + if (parent.state.status !== "completed") { + return codedToolError( + "parent_not_completed", + "Parent Claude job must be completed successfully.", + "Wait for a successful completed parent or start a new job.", + ); + } + if (parent.state.resumable !== true) { + return codedToolError( + "session_unavailable", + "Parent Claude session is unavailable for follow-up.", + "Start a new Claude job with `ask`.", + ); + } + + let parentResult; + try { + parentResult = readJson(parent.paths.result); + } catch { + return codedToolError( + "session_unavailable", + "Parent Claude result is unavailable.", + "Start a new Claude job with `ask`.", + ); + } + if ( + typeof parentResult.session_id !== "string" || + parentResult.session_id.length === 0 + ) { + return codedToolError( + "session_unavailable", + "Parent Claude session is unavailable for follow-up.", + "Start a new Claude job with `ask`.", + ); + } + + const question = args.question; + if (typeof question !== "string" || question.trim().length === 0) { + return codedToolError( + "invalid_request", + "`question` must be a non-empty string.", + "Pass a non-empty `question`.", + ); + } + if (question.length > MAX_QUESTION_CHARS) { + return codedToolError( + "invalid_request", + `Question exceeds the ${MAX_QUESTION_CHARS}-character limit.`, + "Shorten the question and retry.", ); } + + let name; + let effort; + let maxBudgetUsd; + try { + name = validateJobName(args.name); + effort = validateEffort(args.effort, parent.state.effort || DEFAULT_EFFORT); + maxBudgetUsd = validateBudget(args.max_budget_usd); + } catch (validationError) { + return codedToolError( + "invalid_request", + validationError instanceof Error + ? validationError.message + : "Invalid follow-up options.", + "Correct the follow-up options and retry.", + ); + } + try { - return await startClaudeJob(question, model, workspace); + return await startClaudeJob({ + question, + model: parent.state.model, + workspace: parent.state.workspace || null, + name, + effort, + maxBudgetUsd, + parentJobId: parent.state.id, + resumeSessionId: parentResult.session_id, + }); } catch { - return toolError("Claude Ask could not start the background job."); + return codedToolError( + "job_start_failed", + "Claude Ask could not start the follow-up job.", + "Verify the plugin runtime and retry.", + ); } } if (toolName === "ask_status") { let job = loadJob(args.job_id); - if (!job) return toolError("Unknown or expired Claude job ID."); + if (!job) { + return codedToolError( + "job_not_found", + "Unknown or expired Claude job ID.", + "Use `list_jobs` to recover a recent job ID or start a new job.", + ); + } const waitMs = Number.isInteger(args.wait_ms) ? Math.max(0, Math.min(args.wait_ms, 30_000)) : 10_000; @@ -384,15 +701,107 @@ async function handleToolCall(params) { : currentBytes; await waitForProgress(job, previousBytes, waitMs); job = loadJob(args.job_id); - if (!job) return toolError("Claude job expired while checking status."); + if (!job) { + return codedToolError( + "job_not_found", + "Claude job expired while checking status.", + "Use `list_jobs` or start a new job.", + ); + } return TERMINAL_STATUSES.has(job.state.status) ? finalJobResult(job) : runningResult(job, previousBytes); } + if (toolName === "list_jobs") { + const limit = + args.limit === undefined + ? 20 + : Number.isInteger(args.limit) && args.limit >= 1 && args.limit <= 100 + ? args.limit + : null; + if (limit === null) { + return codedToolError( + "invalid_request", + "`limit` must be an integer from 1 through 100.", + "Pass a `limit` from 1 through 100.", + ); + } + const allowedStatuses = new Set([ + "running", + "completed", + "error", + "cancelled", + ]); + if ( + args.status !== undefined && + (typeof args.status !== "string" || !allowedStatuses.has(args.status)) + ) { + return codedToolError( + "invalid_request", + "`status` must be `running`, `completed`, `error`, or `cancelled`.", + "Choose one of the supported status filters.", + ); + } + + const jobs = []; + for (const entry of readdirSync(JOB_DIR, { withFileTypes: true })) { + if (!entry.isDirectory() || !JOB_ID_PATTERN.test(entry.name)) continue; + const job = loadJob(entry.name); + if (!job) continue; + const status = publicStatus(job.state); + if (args.status !== undefined && status !== args.status) continue; + + const summary = { + job_id: job.state.id, + name: job.state.name || null, + status, + model: job.state.model, + effort: job.state.effort || DEFAULT_EFFORT, + mode: job.state.mode || (job.state.workspace ? "agent" : "advisory"), + workspace: job.state.workspace || null, + parent_job_id: job.state.parentJobId || null, + started_at: new Date(job.state.startedAt).toISOString(), + completed_at: job.state.completedAt + ? new Date(job.state.completedAt).toISOString() + : null, + elapsed_ms: elapsedFor(job), + }; + if (status === "completed") { + try { + const completed = readJson(job.paths.result); + if (Number.isFinite(completed.total_cost_usd)) { + summary.total_cost_usd = completed.total_cost_usd; + } + } catch { + // A corrupt result does not make the rest of the job list unusable. + } + } + jobs.push({ startedAt: job.state.startedAt, summary }); + } + jobs.sort((left, right) => right.startedAt - left.startedAt); + const summaries = jobs.slice(0, limit).map((job) => job.summary); + return { + content: [ + { + type: "text", + text: `Found ${summaries.length} recent Claude jobs.`, + }, + ], + structuredContent: { jobs: summaries }, + isError: false, + }; + } + if (toolName === "cancel_ask") { const job = loadJob(args.job_id); - if (!job) return toolError("Unknown or expired Claude job ID."); + if (!job) { + return codedToolError( + "job_not_found", + "Unknown or expired Claude job ID.", + "Use `list_jobs` to recover a recent job ID.", + ); + } if (!TERMINAL_STATUSES.has(job.state.status)) { try { const fd = openSync(job.paths.cancel, "wx", 0o600); @@ -400,7 +809,11 @@ async function handleToolCall(params) { closeSync(fd); } catch (error) { if (error?.code !== "EEXIST") { - return toolError("Could not request Claude job cancellation."); + return codedToolError( + "cancel_request_failed", + "Could not request Claude job cancellation.", + "Check job-directory permissions and retry.", + ); } } return { @@ -414,7 +827,11 @@ async function handleToolCall(params) { return finalJobResult(job); } - return toolError(`Unknown tool: ${toolName || ""}`); + return codedToolError( + "unknown_tool", + `Unknown tool: ${toolName || ""}`, + "Call one of the tools returned by `tools/list`.", + ); } async function handleRequest(message) { @@ -426,7 +843,7 @@ async function handleRequest(message) { capabilities: { tools: { listChanged: false } }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, instructions: - "Start Claude with ask, explicitly pass workspace for repository reviews, then poll ask_status until the final answer. Workspace jobs get read-only file discovery tools. Detached jobs survive MCP server restart and have no elapsed/idle deadline; cancellation is explicit.", + "Start Claude with ask and poll ask_status until the final answer. Use ask_followup to fork a completed session and list_jobs to recover job IDs. Pass workspace to run Claude Code as a full autonomous agent with its default tools, shell, editing, inherited user/project instructions and hooks, configured MCP servers, skills, plugins, network access, and permission to modify the workspace. Without workspace it receives only the question and no tools. Workspace is the working directory and intended task scope, not an OS sandbox. Detached jobs survive MCP server restart and have no elapsed/idle deadline; cancellation is explicit.", }); return; } @@ -443,7 +860,7 @@ async function handleRequest(message) { name: "ask", title: "Ask Claude", description: - "Start a persistent background Claude Code Fable or Opus consultation. Pass an absolute workspace path when Claude should inspect a repository; only Read, Glob, and Grep are enabled for that job. Returns a job ID immediately; poll ask_status. There is no elapsed-time or idle-time deadline.", + "Start a persistent background Claude Code job using Sonnet at high effort by default, with configurable effort plus Fable and Opus. With workspace, Claude runs as a full autonomous coding agent with default tools, shell commands, file editing, inherited user/project instructions and hooks, configured MCP servers, skills, and plugins, network access, and permission to modify files. Without workspace it receives only the question and no tools. The workspace is its working directory and intended task scope, not a hard OS sandbox. Returns a job ID immediately; poll ask_status. There is no elapsed-time or idle-time deadline.", inputSchema: { type: "object", properties: { @@ -455,8 +872,8 @@ async function handleRequest(message) { }, model: { type: "string", - enum: ["fable", "opus"], - default: "fable", + enum: ["sonnet", "fable", "opus"], + default: DEFAULT_MODEL, description: "Claude Code model alias.", }, workspace: { @@ -464,15 +881,72 @@ async function handleRequest(message) { minLength: 1, maxLength: 4096, description: - "Absolute existing workspace directory. Enables read-only Read, Glob, and Grep tools scoped by Claude Code permissions to this directory.", + "Absolute existing working directory. Enables full autonomous Claude Code agent capabilities, including shell and file writes. This is the intended task scope, not a hard OS sandbox.", + }, + name: { + type: "string", + minLength: 1, + maxLength: 120, + description: "Optional human-readable job name.", + }, + effort: { + type: "string", + enum: ["medium", "high", "xhigh", "max"], + default: DEFAULT_EFFORT, + description: "Claude Code reasoning effort.", + }, + max_budget_usd: { + type: "number", + minimum: 0.01, + maximum: 1000, + description: "Optional Claude Code cost ceiling for this job.", }, }, required: ["question"], additionalProperties: false, }, annotations: { - readOnlyHint: true, - destructiveHint: false, + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + { + name: "ask_followup", + title: "Continue Claude Job", + description: + "Start a persistent follow-up from a completed Claude job. It inherits the parent model, workspace, mode, and full session context while creating an independent session branch.", + inputSchema: { + type: "object", + properties: { + job_id: { type: "string", minLength: 1 }, + question: { + type: "string", + minLength: 1, + maxLength: MAX_QUESTION_CHARS, + }, + name: { + type: "string", + minLength: 1, + maxLength: 120, + }, + effort: { + type: "string", + enum: ["medium", "high", "xhigh", "max"], + }, + max_budget_usd: { + type: "number", + minimum: 0.01, + maximum: 1000, + }, + }, + required: ["job_id", "question"], + additionalProperties: false, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: true, }, @@ -508,6 +982,34 @@ async function handleRequest(message) { openWorldHint: true, }, }, + { + name: "list_jobs", + title: "List Claude Jobs", + description: + "List recent persistent Claude jobs newest first so lost job IDs can be recovered.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "integer", + minimum: 1, + maximum: 100, + default: 20, + }, + status: { + type: "string", + enum: ["running", "completed", "error", "cancelled"], + }, + }, + additionalProperties: false, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, { name: "cancel_ask", title: "Cancel Claude Answer", diff --git a/plugins/claude-ask/scripts/test-server.mjs b/plugins/claude-ask/scripts/test-server.mjs index 7261032..0a098ac 100755 --- a/plugins/claude-ask/scripts/test-server.mjs +++ b/plugins/claude-ask/scripts/test-server.mjs @@ -2,11 +2,12 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { chmodSync, closeSync, existsSync, + mkdirSync, mkdtempSync, openSync, readFileSync, @@ -16,11 +17,12 @@ import { statSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; +import { tmpdir, uptime } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const pluginRoot = dirname(scriptsDir); const live = process.env.CLAUDE_ASK_LIVE === "1"; const fakeClaude = join(scriptsDir, "fixtures", "fake-claude.mjs"); const jobRoot = mkdtempSync(join(tmpdir(), "claude-ask-test-")); @@ -132,21 +134,92 @@ async function cancelAndWait(client, jobId) { assert.equal(requested.result.structuredContent.status, "cancelling"); const terminal = await waitForTerminal(client, jobId); assert.equal(terminal.result.structuredContent.status, "cancelled"); + assert.equal(terminal.result.structuredContent.error_code, "cancelled"); +} + +function processExists(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +async function waitForProcessExit(pid, timeoutMs = 2_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!processExists(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return !processExists(pid); } async function fakeTests() { + const skill = readFileSync( + join(pluginRoot, "skills", "claude-ask", "SKILL.md"), + "utf8", + ); + assert.ok(skill.trim().split(/\s+/).length < 500); + assert.match(skill, /ask_followup/); + assert.match(skill, /list_jobs/); + assert.match(skill, /medium.*high.*xhigh.*max/is); + assert.match(skill, /MCP.*skills.*plugins/is); + + const readme = readFileSync(join(pluginRoot, "README.md"), "utf8"); + assert.match(readme, /ask_followup/); + assert.match(readme, /list_jobs/); + assert.match(readme, /stderr_tail/); + assert.doesNotMatch(readme, /Every consultation runs at explicit `xhigh`/); + + const manifest = JSON.parse( + readFileSync(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"), + ); + assert.equal(manifest.version, "0.2.0"); + assert.ok( + manifest.interface.defaultPrompt.some((prompt) => + /without repository tools/i.test(prompt), + ), + ); + assert.ok( + manifest.interface.defaultPrompt.some((prompt) => + /investigate and fix/i.test(prompt), + ), + ); + let client = new McpClient({ CLAUDE_ASK_MAX_CONCURRENT_JOBS: "2", FAKE_CLAUDE_DELAY_MS: "300", }); - await client.initialize(); + const initialized = await client.initialize(); + assert.equal(initialized.result.serverInfo.version, manifest.version); const listed = await client.request("tools/list"); assert.deepEqual( listed.result.tools.map((tool) => tool.name), - ["ask", "ask_status", "cancel_ask"], + ["ask", "ask_followup", "ask_status", "list_jobs", "cancel_ask"], ); assert.match(listed.result.tools[0].description, /no elapsed-time/i); + assert.match(listed.result.tools[0].description, /full autonomous coding agent/i); + assert.match(listed.result.tools[0].description, /high effort by default/i); + assert.match( + listed.result.tools[0].description, + /configured MCP servers, skills, and plugins/i, + ); + assert.match( + listed.result.tools[0].description, + /inherited user\/project instructions and hooks/i, + ); + assert.equal(listed.result.tools[0].annotations.readOnlyHint, false); + assert.equal(listed.result.tools[0].annotations.destructiveHint, true); assert.ok(listed.result.tools[0].inputSchema.properties.workspace); + assert.deepEqual( + listed.result.tools[0].inputSchema.properties.model.enum, + ["sonnet", "fable", "opus"], + ); + assert.equal( + listed.result.tools[0].inputSchema.properties.model.default, + "sonnet", + ); const relativeWorkspace = await client.call("ask", { question: "must-not-start", @@ -160,15 +233,40 @@ async function fakeTests() { ); const workspaceStarted = await client.call("ask", { question: `workspace-probe:${workspaceRoot}`, - model: "fable", workspace: workspaceRoot, }); const workspaceJobId = remember(workspaceStarted); const workspaceResult = await waitForTerminal(client, workspaceJobId); assert.equal(workspaceResult.result.isError, false); assert.equal(workspaceResult.result.content[0].text, "WORKSPACE_OK"); + assert.equal( + workspaceResult.result.structuredContent.requested_model, + "sonnet", + ); + assert.equal( + workspaceResult.result.structuredContent.requested_effort, + "high", + ); + assert.equal(workspaceResult.result.structuredContent.effort, "high"); + assert.equal( + workspaceResult.result.structuredContent.resolved_model, + "claude-sonnet-fake", + ); assert.equal(workspaceResult.result.structuredContent.workspace, workspaceRoot); - rmSync(workspaceRoot, { recursive: true, force: true }); + assert.equal(workspaceResult.result.structuredContent.mode, "agent"); + + const defaultEffort = await client.call("ask", { + question: "default-effort-probe", + }); + const defaultEffortResult = await waitForTerminal( + client, + remember(defaultEffort), + ); + assert.equal(defaultEffortResult.result.isError, false); + assert.equal( + defaultEffortResult.result.content[0].text, + "DEFAULT_EFFORT_OK", + ); const exactPrompt = "literal $(touch /tmp/nope) `whoami` $HOME; line 2\n"; const started = await client.call("ask", { @@ -186,11 +284,147 @@ async function fakeTests() { `fake:opus:${sha256(exactPrompt)}`, ); assert.equal(exactResult.result.structuredContent.requested_model, "opus"); + assert.equal(exactResult.result.structuredContent.mode, "advisory"); assert.equal( exactResult.result.structuredContent.resolved_model, "claude-opus-fake", ); + const configured = await client.call("ask", { + question: "option-probe", + name: " useful job ", + effort: "medium", + max_budget_usd: 1.25, + }); + const configuredResult = await waitForTerminal(client, remember(configured)); + assert.equal(configuredResult.result.isError, false); + assert.equal(configuredResult.result.content[0].text, "OPTIONS_OK"); + assert.equal(configuredResult.result.structuredContent.name, "useful job"); + assert.equal(configuredResult.result.structuredContent.effort, "medium"); + assert.equal(configuredResult.result.structuredContent.max_budget_usd, 1.25); + + for (const argumentsValue of [ + { question: "x", name: " " }, + { question: "x", effort: "low" }, + { question: "x", max_budget_usd: 0 }, + { question: "x", max_budget_usd: "1" }, + ]) { + const rejected = await client.call("ask", argumentsValue); + assert.equal(rejected.result.isError, true); + } + + const parent = await client.call("ask", { + question: "remember:alpha", + workspace: workspaceRoot, + name: "parent", + }); + const parentJobId = remember(parent); + const parentResult = await waitForTerminal(client, parentJobId); + assert.equal( + JSON.parse( + readFileSync(join(jobRoot, parentJobId, "state.json"), "utf8"), + ).sessionId, + parentResult.result.structuredContent.session_id, + ); + const followup = await client.call("ask_followup", { + job_id: parentResult.result.structuredContent.job_id, + question: "recall", + name: "child", + effort: "xhigh", + max_budget_usd: 2, + }); + const followupResult = await waitForTerminal(client, remember(followup)); + assert.equal(followupResult.result.isError, false); + assert.equal(followupResult.result.content[0].text, "FOLLOWUP_OK"); + assert.equal( + followupResult.result.structuredContent.parent_job_id, + parentResult.result.structuredContent.job_id, + ); + assert.equal(followupResult.result.structuredContent.workspace, workspaceRoot); + assert.equal(followupResult.result.structuredContent.effort, "xhigh"); + assert.equal(followupResult.result.structuredContent.max_budget_usd, 2); + assert.equal(followupResult.result.structuredContent.name, "child"); + + const unknownParent = await client.call("ask_followup", { + job_id: "00000000-0000-4000-8000-000000000000", + question: "recall", + }); + assert.equal(unknownParent.result.isError, true); + assert.equal( + unknownParent.result.structuredContent.error_code, + "session_unavailable", + ); + + const runningParent = await client.call("ask", { + question: "hold-for-cancel", + workspace: workspaceRoot, + }); + const runningParentId = remember(runningParent); + const runningFollowup = await client.call("ask_followup", { + job_id: runningParentId, + question: "recall", + }); + assert.equal(runningFollowup.result.isError, true); + assert.equal( + runningFollowup.result.structuredContent.error_code, + "parent_not_completed", + ); + await cancelAndWait(client, runningParentId); + const cancelledFollowup = await client.call("ask_followup", { + job_id: runningParentId, + question: "recall", + }); + assert.equal(cancelledFollowup.result.isError, true); + assert.equal( + cancelledFollowup.result.structuredContent.error_code, + "parent_not_completed", + ); + + const parentStatePath = join(jobRoot, parentResult.result.structuredContent.job_id, "state.json"); + const parentState = JSON.parse(readFileSync(parentStatePath, "utf8")); + writeFileSync( + parentStatePath, + `${JSON.stringify({ ...parentState, resumable: false })}\n`, + ); + const legacyFollowup = await client.call("ask_followup", { + job_id: parentResult.result.structuredContent.job_id, + question: "recall", + }); + assert.equal(legacyFollowup.result.isError, true); + assert.equal( + legacyFollowup.result.structuredContent.error_code, + "session_unavailable", + ); + + const corruptJobId = randomUUID(); + const corruptJobPath = join(jobRoot, corruptJobId); + mkdirSync(corruptJobPath, { mode: 0o700 }); + writeFileSync(join(corruptJobPath, "state.json"), "{}\n", { + mode: 0o600, + }); + const recentCompleted = await client.call("list_jobs", { + limit: 2, + status: "completed", + }); + assert.equal(recentCompleted.result.isError, false); + assert.equal(recentCompleted.result.structuredContent.jobs.length, 2); + assert.ok( + recentCompleted.result.structuredContent.jobs[0].started_at >= + recentCompleted.result.structuredContent.jobs[1].started_at, + ); + assert.ok( + recentCompleted.result.structuredContent.jobs.every( + (job) => + job.status === "completed" && + Number.isInteger(job.elapsed_ms) && + !("answer" in job), + ), + ); + const listWithCorruptState = await client.call("list_jobs", { limit: 100 }); + assert.equal(listWithCorruptState.result.isError, false); + rmSync(corruptJobPath, { recursive: true, force: true }); + rmSync(workspaceRoot, { recursive: true, force: true }); + assert.equal(statSync(jobRoot).mode & 0o777, 0o700); const exactJobPath = join(jobRoot, exactJobId); assert.equal(statSync(exactJobPath).mode & 0o777, 0o700); @@ -228,8 +462,9 @@ async function fakeTests() { assert.equal(unchanged.result.structuredContent.status, "running"); assert.equal(unchanged.result.isError, false); - let sawPartialThinking = false; let sawPartialText = false; + let sawEstimatedThinking = false; + let sawToolCalls = false; let previousBytes = unchanged.result.structuredContent.output_bytes; for (;;) { status = await client.call("ask_status", { @@ -238,16 +473,20 @@ async function fakeTests() { wait_ms: 250, }); previousBytes = status.result.structuredContent.output_bytes; - if (status.result.structuredContent.partial_thinking_chars > 0) { - sawPartialThinking = true; - } if (status.result.structuredContent.partial_text_chars > 0) { sawPartialText = true; } + if (status.result.structuredContent.estimated_thinking_tokens > 0) { + sawEstimatedThinking = true; + } + if (status.result.structuredContent.tool_calls > 0) { + sawToolCalls = true; + } if (status.result.structuredContent.status === "completed") break; } - assert.equal(sawPartialThinking, true); assert.equal(sawPartialText, true); + assert.equal(sawEstimatedThinking, true); + assert.equal(sawToolCalls, true); const restart = await client.call("ask", { question: "restart-progress", @@ -262,6 +501,122 @@ async function fakeTests() { }); const reinitialized = await client.initialize(); assert.match(reinitialized.result.instructions, /survive MCP server restart/i); + + const recoveryRaceId = randomUUID(); + const recoveryRacePath = join(jobRoot, recoveryRaceId); + mkdirSync(recoveryRacePath, { mode: 0o700 }); + writeFileSync( + join(recoveryRacePath, "state.json"), + `${JSON.stringify({ + id: recoveryRaceId, + model: "sonnet", + effort: "high", + status: "starting", + startedAt: Date.now(), + runnerPid: null, + launcherPid: 2_147_483_647, + bootStartedAt: Date.now() - uptime() * 1_000, + workspace: null, + mode: "advisory", + })}\n`, + { mode: 0o600 }, + ); + const startupGrace = await client.call("ask_status", { + job_id: recoveryRaceId, + wait_ms: 0, + }); + assert.equal(startupGrace.result.isError, false); + assert.equal(startupGrace.result.structuredContent.status, "running"); + rmSync(recoveryRacePath, { recursive: true, force: true }); + + const liveRunnerId = randomUUID(); + const liveRunnerPath = join(jobRoot, liveRunnerId); + mkdirSync(liveRunnerPath, { mode: 0o700 }); + writeFileSync( + join(liveRunnerPath, "state.json"), + `${JSON.stringify({ + id: liveRunnerId, + model: "sonnet", + effort: "high", + status: "starting", + startedAt: Date.now() - 10_000, + runnerPid: process.pid, + launcherPid: 2_147_483_647, + bootStartedAt: Date.now() - uptime() * 1_000, + workspace: null, + mode: "advisory", + })}\n`, + { mode: 0o600 }, + ); + const liveRunnerStatus = await client.call("ask_status", { + job_id: liveRunnerId, + wait_ms: 0, + }); + assert.equal(liveRunnerStatus.result.isError, false); + assert.equal(liveRunnerStatus.result.structuredContent.status, "running"); + rmSync(liveRunnerPath, { recursive: true, force: true }); + + const priorBootId = randomUUID(); + const priorBootPath = join(jobRoot, priorBootId); + mkdirSync(priorBootPath, { mode: 0o700 }); + writeFileSync( + join(priorBootPath, "state.json"), + `${JSON.stringify({ + id: priorBootId, + model: "sonnet", + effort: "high", + status: "running", + startedAt: Date.now() - 10_000, + runnerPid: process.pid, + launcherPid: process.pid, + bootStartedAt: 0, + workspace: null, + mode: "advisory", + })}\n`, + { mode: 0o600 }, + ); + const priorBootStatus = await client.call("ask_status", { + job_id: priorBootId, + wait_ms: 0, + }); + assert.equal(priorBootStatus.result.isError, true); + assert.equal( + priorBootStatus.result.structuredContent.error_code, + "runner_interrupted", + ); + rmSync(priorBootPath, { recursive: true, force: true }); + + const missingResultId = randomUUID(); + const missingResultPath = join(jobRoot, missingResultId); + mkdirSync(missingResultPath, { mode: 0o700 }); + writeFileSync( + join(missingResultPath, "state.json"), + `${JSON.stringify({ + id: missingResultId, + model: "sonnet", + effort: "high", + status: "completed", + startedAt: Date.now() - 1_000, + completedAt: Date.now(), + runnerPid: null, + launcherPid: null, + bootStartedAt: Date.now() - uptime() * 1_000, + workspace: null, + mode: "advisory", + })}\n`, + { mode: 0o600 }, + ); + const missingResult = await client.call("ask_status", { + job_id: missingResultId, + wait_ms: 0, + }); + assert.equal(missingResult.result.isError, true); + assert.equal( + missingResult.result.structuredContent.error_code, + "result_unavailable", + ); + rmSync(missingResultPath, { recursive: true, force: true }); + const afterRestart = await waitForTerminal(client, restartJobId); assert.equal(afterRestart.result.structuredContent.status, "completed"); assert.equal(afterRestart.result.isError, false); @@ -282,6 +637,10 @@ async function fakeTests() { }); assert.equal(overCapacity.result.isError, true); assert.match(overCapacity.result.content[0].text, /concurrent jobs/i); + assert.equal( + overCapacity.result.structuredContent.error_code, + "concurrency_limit", + ); await client.stop(); client = new McpClient({ CLAUDE_ASK_MAX_CONCURRENT_JOBS: "2" }); @@ -289,6 +648,38 @@ async function fakeTests() { await cancelAndWait(client, holdOneId); await cancelAndWait(client, holdTwoId); + const descendantJob = await client.call("ask", { + question: "hold-with-descendant", + model: "fable", + }); + const descendantJobId = remember(descendantJob); + let descendantPid = null; + for (let attempt = 0; attempt < 80; attempt += 1) { + const streamPath = join(jobRoot, descendantJobId, "stream.jsonl"); + if (existsSync(streamPath)) { + const events = readFileSync(streamPath, "utf8") + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)); + descendantPid = events.find( + (event) => event.subtype === "descendant_pid", + )?.pid; + if (Number.isInteger(descendantPid)) break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.ok(Number.isInteger(descendantPid)); + try { + await cancelAndWait(client, descendantJobId); + assert.equal( + await waitForProcessExit(descendantPid), + true, + "cancellation must terminate Claude descendants", + ); + } finally { + if (processExists(descendantPid)) process.kill(descendantPid, "SIGKILL"); + } + const runnerShutdown = await client.call("ask", { question: "hold-for-cancel", model: "fable", @@ -307,15 +698,90 @@ async function fakeTests() { process.kill(runnerPid, "SIGTERM"); const interrupted = await waitForTerminal(client, runnerShutdownId); assert.equal(interrupted.result.isError, true); - assert.match(interrupted.result.content[0].text, /runner exited/i); + assert.match(interrupted.result.content[0].text, /runner (?:exited|failed)/i); + assert.doesNotMatch( + interrupted.result.structuredContent.next_action, + /stderr_tail/i, + ); const auth = await client.call("ask", { question: "authentication-error", model: "fable", }); - const authResult = await waitForTerminal(client, remember(auth)); + const authJobId = remember(auth); + const authResult = await waitForTerminal(client, authJobId); assert.equal(authResult.result.isError, true); assert.match(authResult.result.content[0].text, /authentication was rejected/i); + const failedFollowup = await client.call("ask_followup", { + job_id: authJobId, + question: "recall", + }); + assert.equal(failedFollowup.result.isError, true); + assert.equal( + failedFollowup.result.structuredContent.error_code, + "parent_not_completed", + ); + + const diagnostic = await client.call("ask", { + question: "diagnostic-error", + }); + const diagnosticJobId = remember(diagnostic); + const diagnosticResult = await waitForTerminal(client, diagnosticJobId); + assert.equal(diagnosticResult.result.isError, true); + assert.equal( + diagnosticResult.result.structuredContent.error_code, + "claude_failed", + ); + assert.equal( + diagnosticResult.result.structuredContent.message, + "Claude Code returned an error.", + ); + assert.match( + diagnosticResult.result.structuredContent.stderr_tail, + /DIAGNOSTIC_MARKER/, + ); + assert.equal( + typeof diagnosticResult.result.structuredContent.next_action, + "string", + ); + assert.equal( + statSync(join(jobRoot, diagnosticJobId, "stderr.log")).mode & 0o777, + 0o600, + ); + + const largeStderr = await client.call("ask", { + question: "large-stderr-error", + }); + const largeStderrResult = await waitForTerminal( + client, + remember(largeStderr), + ); + assert.equal(largeStderrResult.result.isError, true); + assert.equal( + largeStderrResult.result.structuredContent.stderr_truncated, + true, + ); + assert.ok( + Buffer.byteLength( + largeStderrResult.result.structuredContent.stderr_tail, + "utf8", + ) <= 4096, + ); + + const binaryStderr = await client.call("ask", { + question: "binary-stderr-error", + }); + const binaryStderrResult = await waitForTerminal( + client, + remember(binaryStderr), + ); + assert.equal(binaryStderrResult.result.isError, true); + assert.ok( + Buffer.byteLength( + binaryStderrResult.result.structuredContent.stderr_tail, + "utf8", + ) <= 4096, + ); const unknown = await client.call("ask_status", { job_id: "../../etc/passwd", @@ -360,14 +826,16 @@ async function liveTest() { await client.initialize(); const started = await client.call("ask", { question: "Ответь ровно: CLAUDE_ASK_LIVE_OK", - model: "fable", }); const jobId = remember(started); const completed = await waitForTerminal(client, jobId); assert.equal(completed.result.isError, false); assert.equal(completed.result.content[0].text, "CLAUDE_ASK_LIVE_OK"); - assert.equal(completed.result.structuredContent.requested_model, "fable"); - assert.match(completed.result.structuredContent.resolved_model, /fable/i); + assert.equal(completed.result.structuredContent.requested_model, "sonnet"); + assert.equal(completed.result.structuredContent.requested_effort, "high"); + assert.equal(completed.result.structuredContent.effort, "high"); + assert.equal(completed.result.structuredContent.mode, "advisory"); + assert.match(completed.result.structuredContent.resolved_model, /sonnet-5/i); const stream = readFileSync(join(jobRoot, jobId, "stream.jsonl"), "utf8") .split(/\r?\n/) .filter(Boolean) @@ -375,25 +843,58 @@ async function liveTest() { assert.ok(stream.some((event) => event.type === "result")); assert.ok(stream.some((event) => event.type === "stream_event")); + const memoryNonce = `CLAUDE_ASK_MEMORY_${randomUUID()}`; const workspaceStarted = await client.call("ask", { question: - "Use the read-only file tools to read workspace-marker.txt in the current workspace. Reply with exactly its single-line content and nothing else.", - model: "fable", + "Act as a coding agent in the current workspace. Use the shell to read workspace-marker.txt, then create agent-proof.txt containing exactly CLAUDE_ASK_AGENT_WRITE_OK followed by a newline. Use the shell to verify the written file. " + + `Remember this nonce for a later follow-up: ${memoryNonce}. ` + + "Then reply with exactly CLAUDE_ASK_AGENT_OK.", + model: "sonnet", workspace: workspaceRoot, }); + const workspaceJobId = remember(workspaceStarted); const workspaceCompleted = await waitForTerminal( client, - remember(workspaceStarted), + workspaceJobId, ); assert.equal(workspaceCompleted.result.isError, false); - assert.equal( + assert.match( workspaceCompleted.result.content[0].text, - "CLAUDE_ASK_WORKSPACE_READ_OK", + /CLAUDE_ASK_AGENT_OK/, ); assert.equal( workspaceCompleted.result.structuredContent.workspace, workspaceRoot, ); + assert.equal(workspaceCompleted.result.structuredContent.mode, "agent"); + assert.equal( + readFileSync(join(workspaceRoot, "agent-proof.txt"), "utf8"), + "CLAUDE_ASK_AGENT_WRITE_OK\n", + ); + + const followup = await client.call("ask_followup", { + job_id: workspaceJobId, + question: + "Recall the nonce from my previous message without reading the test source. Use the shell to read agent-proof.txt in the inherited workspace. Reply with the nonce and the file contents.", + }); + const followupCompleted = await waitForTerminal( + client, + remember(followup), + ); + assert.equal(followupCompleted.result.isError, false); + assert.match(followupCompleted.result.content[0].text, new RegExp(memoryNonce)); + assert.match( + followupCompleted.result.content[0].text, + /CLAUDE_ASK_AGENT_WRITE_OK/, + ); + assert.equal( + followupCompleted.result.structuredContent.parent_job_id, + workspaceJobId, + ); + assert.equal( + followupCompleted.result.structuredContent.workspace, + workspaceRoot, + ); } finally { await client.stop(); rmSync(workspaceRoot, { recursive: true, force: true }); diff --git a/plugins/claude-ask/skills/claude-ask/SKILL.md b/plugins/claude-ask/skills/claude-ask/SKILL.md index c01375d..e9c4f06 100644 --- a/plugins/claude-ask/skills/claude-ask/SKILL.md +++ b/plugins/claude-ask/skills/claude-ask/SKILL.md @@ -1,42 +1,40 @@ --- name: claude-ask -description: Ask a local Claude Code Fable or Opus model for a second opinion when the user explicitly requests Claude, Fable, or Opus, or when an independent Claude perspective is useful. +description: Use when the user explicitly requests Claude, Sonnet, Fable, or Opus, or when an independent Claude perspective would materially improve a coding task, review, or diagnosis. --- # Claude Ask -Use the `ask`, `ask_status`, and when explicitly needed `cancel_ask` tools -exposed by the `claude-ask` MCP server. - -Compose the complete question on the Codex side. Include the relevant context, -requested role, output format, and constraints directly in `question`; the MCP -server does not add a task preset. - -When Claude needs to inspect repository files, always pass the current workspace -root as the absolute `workspace` argument. Do not merely mention the path inside -the question. The server validates and canonicalizes the directory, starts -Claude with that directory as its actual working directory, and enables only -the built-in `Read`, `Glob`, and `Grep` tools. Claude cannot edit files or run -shell commands. Include any test output, git diff facts, or command-derived -evidence that Claude also needs directly in the question. - -Choose `fable` by default. Choose `opus` when the user asks for Opus or when the question needs the strongest available Claude reasoning. - -Without `workspace`, Claude runs in safe mode with no tools and receives only -the supplied question. Use this mode for ordinary advisory consultations that -do not require repository access. - -`ask` is asynchronous and returns a `job_id` immediately. Poll `ask_status` -with that job ID, the previous `output_bytes`, and `wait_ms=30000` until status -is `completed`, `error`, or `cancelled`. The detached job survives an MCP server -restart, so keep the job ID and resume polling it. There is no job deadline. A -growing `output_bytes` value confirms streamed model activity; the partial text -and thinking counters provide additional progress evidence. An unchanged value -is not an error and must not cause cancellation. Keep polling while the user -wants the answer, and share concise progress updates during long consultations. - -Call `cancel_ask` only when the user explicitly cancels the consultation or the -active request has clearly been replaced. Do not cancel merely because output -has been idle or the model is taking a long time. - -Treat Claude's response as advisory input. Check it against the repository and user requirements before acting on it. +Use Claude as an independent coding agent or second opinion. Treat its output as evidence to verify, not an authority to copy blindly. + +## Prompt and access + +Send a complete, natural request with the desired result, observed facts, and material constraints. For an independent review or diagnosis, omit Codex's suspected cause and preferred fix from the initial question. + +Pass the absolute `workspace` whenever Claude needs repository context. Workspace mode is a full autonomous Claude Code session: default tools, shell, file writes, user/project instructions and hooks, network access, and configured MCP servers, skills, and plugins remain available with bypassed interactive permission prompts. The workspace is the working directory and intended scope, not an OS sandbox. If the task is review-only, explicitly tell Claude not to edit. + +Without `workspace`, Claude receives only the question and has no tools. Use that mode for self-contained advice. + +## Choose cost and depth + +| Setting | Use | +|---|---| +| `sonnet` | Default for implementation, diagnosis, and review | +| `opus` | High-risk security, concurrency, state, or data-loss work; or explicit request | +| `fable` | Only when explicitly requested | +| `medium` effort | Quick, bounded questions | +| `high` effort | Default | +| `xhigh` effort | Difficult multi-file or ambiguous work | +| `max` effort | Exceptional cases where depth justifies the cost | + +Use `name` for recognizable jobs and `max_budget_usd` when cost needs a hard ceiling. + +## Job workflow + +1. Call `ask`; retain its `job_id`. +2. Poll `ask_status` with `previous_output_bytes` and `wait_ms=30000` until `completed`, `error`, or `cancelled`. Idle output is not failure. +3. Use `list_jobs` to recover a lost ID or inspect recent work. +4. Use `ask_followup` only after completion when Claude should retain the parent session context. It inherits model, workspace, and effort unless overridden, then forks a new session branch. Budget is set per call. Claude stores resumable sessions outside the plugin's job directory. +5. Use `cancel_ask` only for an explicit cancellation or a clearly replaced request. + +Inspect Claude's edits and verification independently before reporting success.