Skip to main content

WORKER_JAVA

Constant WORKER_JAVA 

Source
pub const WORKER_JAVA: &str = "// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0\n// ghidra-mcp worker: a persistent headless Ghidra worker driven over loopback TCP.\n// Boots via `analyzeHeadless \u{2026} -process <program> -postScript GhidraMcpWorker.java <port> <tokenFile>`.\n// The blocking request loop inside run() keeps the JVM resident (spike D1).\nimport ghidra.app.script.GhidraScript;\nimport ghidra.app.decompiler.DecompInterface;\nimport ghidra.app.decompiler.DecompileResults;\nimport ghidra.framework.model.DomainFile;\nimport ghidra.program.model.listing.Function;\nimport ghidra.program.model.listing.Program;\nimport com.google.gson.Gson;\nimport com.google.gson.JsonObject;\nimport com.google.gson.JsonParser;\n\nimport java.io.DataInputStream;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.net.InetSocketAddress;\nimport java.net.Socket;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\npublic class GhidraMcpWorker extends GhidraScript {\n\n    static final int PROTOCOL_VERSION = 1;\n    static final String WORKER_SCRIPT_VERSION = \"0.7.0\";\n    static final long MAX_FRAME_LEN = 16L * 1024 * 1024;\n\n    private final Gson gson = new Gson();\n    private Program attached; // set by attach_program; null until then (bootstrap == currentProgram)\n    private DecompInterface decomp; // one per attached program; rebuilt on re-attach (spec 3.3)\n\n    @Override\n    public void run() throws Exception {\n        String[] args = getScriptArgs();\n        if (args.length < 2) {\n            println(\"GhidraMcpWorker: expected <port> <tokenFile> script args\");\n            return;\n        }\n        int port = Integer.parseInt(args[0]);\n        String token = new String(Files.readAllBytes(Path.of(args[1])), StandardCharsets.UTF_8).trim();\n\n        try (Socket sock = new Socket()) {\n            sock.connect(new InetSocketAddress(\"127.0.0.1\", port));\n            InputStream rawIn = sock.getInputStream();\n            OutputStream out = sock.getOutputStream();\n            DataInputStream in = new DataInputStream(rawIn);\n\n            // 1) init handshake notification (rides the same JSON-RPC path as every later frame)\n            JsonObject announce = new JsonObject();\n            announce.addProperty(\"protocol_version\", PROTOCOL_VERSION);\n            announce.addProperty(\"ghidra_version\", resolveGhidraVersion());\n            announce.addProperty(\"worker_script_version\", WORKER_SCRIPT_VERSION);\n            announce.addProperty(\"token\", token);\n            JsonObject init = new JsonObject();\n            init.addProperty(\"jsonrpc\", \"2.0\");\n            init.addProperty(\"method\", \"worker/init\");\n            init.add(\"params\", announce);\n            writeFrame(out, gson.toJson(init));\n\n            // 2) request loop\n            while (true) {\n                byte[] body = readFrame(in);\n                if (body == null) {\n                    break; // socket closed by host\n                }\n                JsonObject req;\n                try {\n                    req = JsonParser.parseString(new String(body, StandardCharsets.UTF_8))\n                            .getAsJsonObject();\n                } catch (Exception parse) {\n                    // A malformed frame must NOT crash the resident worker: return a parse error\n                    // (id:null, JSON-RPC 2.0 \u{a7}5) and keep serving (agy panel R1 \u{2014} the parse used to\n                    // sit outside every try/catch, so one bad frame threw out of run() into exit()).\n                    writeFrame(out, parseError());\n                    continue;\n                }\n                String method = req.has(\"method\") ? req.get(\"method\").getAsString() : \"\";\n                if (\"shutdown\".equals(method)) {\n                    writeFrame(out, success(req, new JsonObject()));\n                    break;\n                }\n                String responseJson = dispatch(req, method);\n                try {\n                    writeFrame(out, responseJson);\n                } catch (IllegalStateException tooBig) {\n                    // The response exceeded MAX_FRAME_LEN (e.g. a giant decompiled function): send a\n                    // small error frame instead of letting the exception crash the resident worker\n                    // (agy panel R2 \u{2014} writeFrame sits outside dispatch()\'s try/catch). The error frame\n                    // is tiny, so its own writeFrame will not exceed the limit.\n                    writeFrame(out, failure(req, \"FRAME_TOO_LARGE\",\n                            \"response exceeds the 16 MiB frame limit\"));\n                }\n            }\n        }\n        // Release the attached program\'s consumer reference so its .lock/checkout is not leaked on\n        // exit (agy panel R1); getDomainObject() ref-counts consumers.\n        disposeDecompiler();\n        if (this.attached != null) {\n            this.attached.release(this);\n            this.attached = null;\n        }\n        // Clean teardown: run() returns naturally (no System.exit) so analyzeHeadless\'s own native\n        // teardown releases the project .lock (Task-0 spike S1 confirmed; spec \u{a7}7).\n    }\n\n    // Named `resolveGhidraVersion` (not `getGhidraVersion`) deliberately: GhidraScript already\n    // declares a public `getGhidraVersion()`, so a private same-named method is an illegal\n    // weaker-access override and the script fails to compile (surfaced by the real-Ghidra e2e).\n    private String resolveGhidraVersion() {\n        try {\n            return ghidra.framework.Application.getApplicationVersion();\n        } catch (Throwable t) {\n            return \"unknown\";\n        }\n    }\n\n    private String dispatch(JsonObject req, String method) {\n        try {\n            JsonObject params = req.has(\"params\") && req.get(\"params\").isJsonObject()\n                    ? req.getAsJsonObject(\"params\") : new JsonObject();\n            switch (method) {\n                case \"attach_program\":\n                    return success(req, attachProgram(params));\n                case \"decompile_function\":\n                    return success(req, decompileFunction(params));\n                case \"worker_info\":\n                    return success(req, workerInfo());\n                case \"list_project_programs\":\n                    return success(req, listProjectPrograms(params));\n                case \"inspect_function\":\n                    return success(req, inspectFunction(params));\n                case \"find_functions\":\n                    return success(req, findFunctions(params));\n                case \"resolve_symbol\":\n                    return success(req, resolveSymbol(params));\n                case \"list_symbols\":\n                    return success(req, listSymbols(params));\n                case \"list_strings\":\n                    return success(req, listStrings(params));\n                case \"get_xrefs\":\n                    return success(req, getXrefs(params));\n                case \"get_disassembly\":\n                    return success(req, getDisassembly(params));\n                case \"read_bytes\":\n                    return success(req, readBytes(params));\n                case \"get_datatype\":\n                    return success(req, getDatatype(params));\n                case \"list_segments\":\n                    return success(req, listSegments(params));\n                case \"list_data_items\":\n                    return success(req, listDataItems(params));\n                case \"describe_address\":\n                    return success(req, describeAddress(params));\n                case \"rename\":\n                    return success(req, rename(params));\n                case \"comment\":\n                    return success(req, comment(params));\n                case \"set_datatype\":\n                    return success(req, setDatatype(params));\n                case \"set_prototype\":\n                    return success(req, setPrototype(params));\n                case \"set_local\":\n                    return success(req, setLocal(params));\n                case \"__debug_crash\":\n                    // Test-only: hard-kill the JVM so the host observes a transport crash. Guarded by\n                    // an env var the harness sets; never reachable in a normal serve (spec \u{a7}8).\n                    if (\"1\".equals(System.getenv(\"GHIDRA_MCP_ALLOW_DEBUG_CRASH\"))) {\n                        Runtime.getRuntime().halt(137);\n                    }\n                    return failure(req, \"INVALID_PARAMS\", \"debug crash not enabled\");\n                case \"__debug_sleep\":\n                    if (\"1\".equals(System.getenv(\"GHIDRA_MCP_ALLOW_DEBUG_CRASH\"))) {\n                        try { Thread.sleep(params.has(\"ms\") ? params.get(\"ms\").getAsLong() : 1000); }\n                        catch (InterruptedException ie) { Thread.currentThread().interrupt(); }\n                        return success(req, new JsonObject());\n                    }\n                    return failure(req, \"INVALID_PARAMS\", \"debug sleep not enabled\");\n                default:\n                    return failure(req, \"INVALID_PARAMS\", \"unknown method: \" + method);\n            }\n        } catch (AmbiguousError ae) {\n            return failureWithCandidates(req, ae.code, ae.getMessage(), ae.candidates);\n        } catch (WorkerError we) {\n            return failure(req, we.code, we.getMessage(), we.details); // details=null for the old callers\n        } catch (Throwable t) {\n            return failure(req, \"WORKER_UNAVAILABLE\", String.valueOf(t));\n        }\n    }\n\n    // attach_program { program_path } -> { attached: <path> }\n    private JsonObject attachProgram(JsonObject params) throws Exception {\n        String path = params.has(\"program_path\") ? params.get(\"program_path\").getAsString() : null;\n        if (path == null) {\n            throw new WorkerError(\"INVALID_PARAMS\", \"attach_program requires program_path\");\n        }\n        DomainFile df = getState().getProject().getProjectData().getFile(path);\n        if (df == null) {\n            throw new WorkerError(\"PROGRAM_NOT_FOUND\", \"no program at VFS path: \" + path);\n        }\n        Object obj = df.getDomainObject(this, false, false, monitor);\n        if (!(obj instanceof Program)) {\n            throw new WorkerError(\"PROGRAM_NOT_FOUND\", \"not a program: \" + path);\n        }\n        // The decompiler is bound to the old program; drop it so decompiler(program) rebuilds for\n        // the new attachment (spec 3.3).\n        disposeDecompiler();\n        // Release the previously attached program before replacing it \u{2014} getDomainObject() increments\n        // a consumer refcount; overwriting without release leaks it and holds that program\'s .lock\n        // for the whole session (agy panel R1).\n        if (this.attached != null) {\n            this.attached.release(this);\n        }\n        this.attached = (Program) obj;\n        openProgram(this.attached); // sets currentProgram so FlatProgramAPI helpers target it\n        JsonObject res = new JsonObject();\n        res.addProperty(\"attached\", path);\n        res.addProperty(\"default_space\", this.attached.getAddressFactory().getDefaultAddressSpace().getName());\n        res.addProperty(\"pointer_size\", this.attached.getDefaultPointerSize());\n        return res;\n    }\n\n    // worker_info {} -> { attached: <vfs path|null>, default_space, pointer_size }. Lets the server\n    // learn the bootstrap program\'s VFS path + address geometry right after boot (spec 6/7) so it can\n    // build an AddressCanonicalizer and re-attach after a crash without an explicit attach_program.\n    private JsonObject workerInfo() {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        JsonObject res = new JsonObject();\n        if (program == null) {\n            res.add(\"attached\", com.google.gson.JsonNull.INSTANCE);\n            res.add(\"default_space\", com.google.gson.JsonNull.INSTANCE);\n            res.add(\"pointer_size\", com.google.gson.JsonNull.INSTANCE);\n            return res;\n        }\n        DomainFile df = program.getDomainFile();\n        res.addProperty(\"attached\", df != null ? df.getPathname() : null);\n        res.addProperty(\"default_space\", program.getAddressFactory().getDefaultAddressSpace().getName());\n        res.addProperty(\"pointer_size\", program.getDefaultPointerSize());\n        return res;\n    }\n\n    // list_project_programs { offset=0, limit=200 } -> { programs:[{program_path,name,language_id?,size?}], total, truncated }\n    // Walks the project\'s DomainFiles (Program content type only). Uses cheap getMetadata() so we do\n    // NOT open every program. Paginated so a firmware project\'s thousands of programs can\'t nuke the\n    // agent\'s context window (spec 3.1).\n    private JsonObject listProjectPrograms(JsonObject params) throws Exception {\n        int offset = params.has(\"offset\") ? params.get(\"offset\").getAsInt() : 0;\n        int limit = params.has(\"limit\") ? params.get(\"limit\").getAsInt() : 200;\n        if (offset < 0) offset = 0;\n        if (limit < 0) limit = 0;\n        java.util.List<ghidra.framework.model.DomainFile> all = new java.util.ArrayList<>();\n        collectProgramFiles(getState().getProject().getProjectData().getRootFolder(), all);\n        long total = all.size();\n        com.google.gson.JsonArray programs = new com.google.gson.JsonArray();\n        int end = Math.min(all.size(), (int) Math.min((long) offset + limit, Integer.MAX_VALUE));\n        for (int i = Math.min(offset, all.size()); i < end; i++) {\n            ghidra.framework.model.DomainFile df = all.get(i);\n            JsonObject row = new JsonObject();\n            row.addProperty(\"program_path\", df.getPathname());\n            row.addProperty(\"name\", df.getName());\n            try {\n                java.util.Map<String, String> md = df.getMetadata();\n                if (md != null) {\n                    if (md.get(\"Language ID\") != null) row.addProperty(\"language_id\", md.get(\"Language ID\"));\n                    String bytes = md.get(\"# of Bytes\");\n                    if (bytes != null) {\n                        try { row.addProperty(\"size\", Long.parseLong(bytes.replace(\",\", \"\").trim())); }\n                        catch (NumberFormatException ignore) { /* leave size absent */ }\n                    }\n                }\n            } catch (Throwable ignore) { /* metadata is best-effort; leave optional fields absent */ }\n            programs.add(row);\n        }\n        JsonObject res = new JsonObject();\n        res.add(\"programs\", programs);\n        res.addProperty(\"total\", total);\n        res.addProperty(\"truncated\", (long) offset + programs.size() < total);\n        return res;\n    }\n\n    // Recurse the project folder tree, collecting DomainFiles whose content is a Program.\n    private void collectProgramFiles(ghidra.framework.model.DomainFolder folder,\n                                     java.util.List<ghidra.framework.model.DomainFile> out) {\n        for (ghidra.framework.model.DomainFile df : folder.getFiles()) {\n            if (ghidra.program.model.listing.Program.class.getSimpleName().equals(df.getContentType())\n                    || \"Program\".equals(df.getContentType())) {\n                out.add(df);\n            }\n        }\n        for (ghidra.framework.model.DomainFolder sub : folder.getFolders()) {\n            collectProgramFiles(sub, out);\n        }\n    }\n\n    // decompile_function { name? , address? } -> { c, signature }\n    private JsonObject decompileFunction(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) {\n            throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        }\n        Function fn = resolveFunction(program, params);\n        DecompInterface di = decompiler(program);\n        DecompileResults r = di.decompileFunction(fn, 30, monitor);\n        if (!r.decompileCompleted()) {\n            throw new WorkerError(\"DECOMPILE_TIMEOUT\", \"decompile failed: \" + r.getErrorMessage());\n        }\n        JsonObject res = new JsonObject();\n        res.addProperty(\"c\", r.getDecompiledFunction().getC());\n        res.addProperty(\"signature\", r.getDecompiledFunction().getSignature());\n        return res;\n    }\n\n    // Resolve a name-or-address selector to exactly one Function, or throw a structured error.\n    // Address form: getFunctionAt (unambiguous). Name form: getGlobalFunctions -> 0=NOT_A_FUNCTION,\n    // 1=that function, >1=AMBIGUOUS_SYMBOL with candidates. NO silent first-hit (spec 6).\n    private Function resolveFunction(Program program, JsonObject params) throws Exception {\n        String selector = null;\n        boolean asAddress = false;\n        if (params.has(\"function\")) {\n            selector = params.get(\"function\").getAsString();\n        } else if (params.has(\"address\")) {\n            selector = params.get(\"address\").getAsString();\n            asAddress = true;\n        } else if (params.has(\"name\")) {\n            selector = params.get(\"name\").getAsString();\n        } else {\n            throw new WorkerError(\"INVALID_PARAMS\", \"requires \'function\' (name or address)\");\n        }\n        // Try address first (spec 6 resolution order); fall back to name.\n        if (!asAddress) {\n            ghidra.program.model.address.Address addr = tryParseAddr(program, selector);\n            if (addr != null) {\n                Function at = getFunctionAt(addr);\n                if (at != null) return at;\n                // Parsed as an address but no function sits there. Do NOT give up: an all-hex FUNCTION\n                // NAME (e.g. \"add\", \"beef\", \"face\", \"dead\") parses as a valid hex offset, so fall\n                // THROUGH to the name lookup below rather than wrongly returning NOT_A_FUNCTION for a\n                // real function named in hex (plan-panel R3 seat-H). `tryParseAddr` already returns null\n                // (never throws) for a non-hex name like \"main\", so that path also falls through.\n            }\n            // fall through to the name lookup below\n        } else {\n            ghidra.program.model.address.Address addr = tryParseAddr(program, selector);\n            if (addr == null) throw new WorkerError(\"INVALID_PARAMS\", \"unparseable address: \" + selector);\n            Function at = getFunctionAt(addr);\n            if (at == null) throw new WorkerError(\"NOT_A_FUNCTION\",\n                    \"no function defined at \" + selector + \" (the region may be unanalyzed)\");\n            return at;\n        }\n        java.util.List<Function> hits = getGlobalFunctions(selector);\n        if (hits.isEmpty()) {\n            throw new WorkerError(\"NOT_A_FUNCTION\", \"no function named \'\" + selector + \"\'\");\n        }\n        if (hits.size() == 1) {\n            return hits.get(0);\n        }\n        com.google.gson.JsonArray cands = new com.google.gson.JsonArray();\n        for (Function f : hits) {\n            JsonObject c = new JsonObject();\n            c.addProperty(\"name\", f.getName());\n            c.addProperty(\"address\", addrString(f.getEntryPoint()));\n            cands.add(c);\n        }\n        throw new AmbiguousError(\"\'\" + selector + \"\' matches \" + hits.size() + \" functions\", cands);\n    }\n\n    // Parse a lenient address in the program\'s factory; null if it does not parse (so the caller can\n    // fall back to a name lookup). Never throws.\n    private ghidra.program.model.address.Address tryParseAddr(Program program, String s) {\n        try {\n            String t = s.trim();\n            if (t.endsWith(\"L\") || t.endsWith(\"l\")) t = t.substring(0, t.length() - 1).trim();\n            int colon = t.indexOf(\':\');\n            String off = colon >= 0 ? t.substring(colon + 1).trim() : t;\n            if (off.startsWith(\"0x\") || off.startsWith(\"0X\")) off = off.substring(2);\n            if (off.isEmpty()) return null;\n            Long.parseLong(off, 16); // validate hex; reject plain names like \"main\"\n            return program.getAddressFactory().getAddress(t.startsWith(\"0x\") || t.startsWith(\"0X\") ? off : t);\n        } catch (Throwable x) {\n            return null;\n        }\n    }\n\n    // Render a Ghidra Address as the \"space:offset\" form the Rust AddressCanonicalizer parses.\n    private String addrString(ghidra.program.model.address.Address a) {\n        return a.getAddressSpace().getName() + \":\" + Long.toHexString(a.getOffset());\n    }\n\n    // ---- shared read-tool helpers (M1b) ----\n\n    // Filter match (spec \u{a7}7; M1b Task-1 decision B): substring-contains by DEFAULT (linear, no\n    // backtracking). Opt in to regex with regex=true -> java.util.regex, but cap the pattern length as a\n    // ReDoS guard and reject an uncompilable pattern with INVALID_PARAMS. A null/empty pattern matches\n    // everything. Backtracking is bounded by a work budget (MAX_FILTER_REGEX_WORK, below) so a\n    // pathological pattern fails fast with INVALID_PARAMS instead of burning the host\'s whole\n    // rpc-deadline; the deadline + poison/transparent-respawn engine stay as the backstop. RE2J/vendoring\n    // deferred.\n    private static final int MAX_FILTER_PATTERN_LEN = 200;\n    // Upper bound on character reads for ONE regex match attempt. A backtracking matcher re-reads the\n    // same characters, so reads are its work. Symbol/function names are short, so a legitimate pattern\n    // needs a few thousand reads at most; a catastrophic one (e.g. \"(a+)+$\" on a long \"aaa...b\") passes\n    // this in milliseconds.\n    private static final long MAX_FILTER_REGEX_WORK = 2_000_000L;\n\n    private static final class RegexBudgetExceeded extends RuntimeException {\n        RegexBudgetExceeded() { super(null, null, false, false); } // control flow only: no stack trace\n    }\n\n    // A read-counting view of the text being matched. java.util.regex reads its input only through\n    // this interface, so counting charAt calls bounds the work without changing what matches.\n    private static final class BudgetedCharSequence implements CharSequence {\n        private final CharSequence inner;\n        private long remaining;\n        BudgetedCharSequence(CharSequence inner, long budget) { this.inner = inner; this.remaining = budget; }\n        @Override public int length() { return inner.length(); }\n        @Override public char charAt(int index) {\n            if (--remaining < 0) throw new RegexBudgetExceeded();\n            return inner.charAt(index);\n        }\n        @Override public CharSequence subSequence(int start, int end) { return inner.subSequence(start, end); }\n        @Override public String toString() { return inner.toString(); }\n    }\n    private final java.util.Map<String, java.util.regex.Pattern> filterCache = new java.util.HashMap<>();\n    private boolean matchesFilter(String pattern, boolean regex, String text) {\n        if (pattern == null || pattern.isEmpty()) return true;\n        String hay = text == null ? \"\" : text;\n        if (!regex) return hay.contains(pattern);\n        if (pattern.length() > MAX_FILTER_PATTERN_LEN)\n            throw new WorkerError(\"INVALID_PARAMS\", \"filter regex too long (max \" + MAX_FILTER_PATTERN_LEN + \" chars)\");\n        java.util.regex.Pattern p = filterCache.get(pattern);\n        if (p == null) {\n            try { p = java.util.regex.Pattern.compile(pattern); }\n            catch (java.util.regex.PatternSyntaxException bad) {\n                throw new WorkerError(\"INVALID_PARAMS\", \"bad filter regex: \" + bad.getMessage());\n            }\n            filterCache.put(pattern, p);\n        }\n        try {\n            return p.matcher(new BudgetedCharSequence(hay, MAX_FILTER_REGEX_WORK)).find();\n        } catch (RegexBudgetExceeded tooCostly) {\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"filter regex is too expensive to evaluate (catastrophic backtracking?); simplify the pattern or use a substring filter\");\n        }\n    }\n\n    // Read {offset>=0, limit>=0} with defaults, for the limit+1 has_more pagination contract (spec \u{a7}3.0):\n    // a caller fetches up to limit matches and sets has_more when a further match exists beyond the page.\n    private int[] readOffsetLimit(JsonObject params, int defLimit) {\n        int offset = params.has(\"offset\") && !params.get(\"offset\").isJsonNull() ? params.get(\"offset\").getAsInt() : 0;\n        int limit = params.has(\"limit\") && !params.get(\"limit\").isJsonNull() ? params.get(\"limit\").getAsInt() : defLimit;\n        if (offset < 0) offset = 0;\n        if (limit < 0) limit = 0;\n        return new int[]{ offset, limit };\n    }\n\n    // Read an optional boolean param (default false); tolerates absence and explicit null.\n    private boolean boolParam(JsonObject params, String key) {\n        return params.has(key) && !params.get(key).isJsonNull() && params.get(key).getAsBoolean();\n    }\n\n    // inspect_function { function|name|address, max_c_lines=2000 } -> FunctionContext (spec 3.3).\n    private JsonObject inspectFunction(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        int maxCLines = params.has(\"max_c_lines\") ? params.get(\"max_c_lines\").getAsInt() : 2000;\n        if (maxCLines < 1) maxCLines = 1;\n\n        Function fn = resolveFunction(program, params);\n\n        // Gather from-data refs made BY this function\'s body (cap 256; spec 3.13/M1b Task 16). Iterate\n        // ONLY addresses that actually have references (panel r1 seat-6) \u{2014} NOT every byte in the body\n        // (getBody().getAddresses would be O(function-size) API thrash on a large function).\n        com.google.gson.JsonArray drefs = new com.google.gson.JsonArray(); boolean drTrunc = false;\n        ghidra.program.model.symbol.ReferenceManager drm = program.getReferenceManager();\n        ghidra.program.model.address.AddressIterator srcIt = drm.getReferenceSourceIterator(fn.getBody(), true);\n        outer:\n        while (srcIt.hasNext()) {\n            ghidra.program.model.address.Address da = srcIt.next();\n            for (ghidra.program.model.symbol.Reference r : drm.getReferencesFrom(da)) {\n                if (!r.getReferenceType().isData()) continue;\n                // D-TIDY (M2b Task 0): drop local stack/register-slot refs \u{2014} they are locals, not\n                // \"data the function touches\", and they bury the few named global/PTR refs (dogfood finding 1).\n                ghidra.program.model.address.AddressSpace toSpace = r.getToAddress().getAddressSpace();\n                if (toSpace.isStackSpace() || toSpace.isRegisterSpace()) continue;\n                if (drefs.size() >= 256) { drTrunc = true; break outer; }\n                JsonObject j = new JsonObject();\n                j.addProperty(\"address\", addrString(r.getToAddress()));\n                ghidra.program.model.symbol.Symbol s = program.getSymbolTable().getPrimarySymbol(r.getToAddress());\n                if (s != null) j.addProperty(\"name\", s.getName());\n                j.addProperty(\"ref_type\", r.getReferenceType().getName());\n                drefs.add(j);\n            }\n        }\n\n        DecompInterface di = decompiler(program);\n        DecompileResults r = di.decompileFunction(fn, 30, monitor);\n        String fullC;\n        if (r != null && r.decompileCompleted() && r.getDecompiledFunction() != null) {\n            fullC = r.getDecompiledFunction().getC();\n        } else {\n            // Distinguish an empty/failed decompile (spec 4.2 seat-5) from a timeout. getErrorMessage\n            // is null/empty on a clean-but-empty result; a timeout leaves a message.\n            String em = (r != null) ? r.getErrorMessage() : null;\n            if (em != null && em.toLowerCase().contains(\"timed out\")) {\n                throw new WorkerError(\"DECOMPILE_TIMEOUT\", \"decompile timed out: \" + em);\n            }\n            throw new WorkerError(\"DECOMPILATION_FAILED\",\n                    \"decompiler produced no output\" + (em != null && !em.isEmpty() ? \": \" + em : \"\"));\n        }\n        if (fullC == null || fullC.isEmpty()) {\n            throw new WorkerError(\"DECOMPILATION_FAILED\", \"decompiler produced empty output\");\n        }\n\n        // Cap C from the TOP by lines: signature + decls + first N body lines. Never slice through the\n        // signature/opening brace (spec 3.4). Append a truncation marker so the agent knows to raise\n        // max_c_lines. The head is the valuable part; the tail may be brace-unmatched by design.\n        String[] lines = fullC.split(\"\\n\", -1);\n        boolean truncated = lines.length > maxCLines;\n        String c;\n        if (!truncated) {\n            c = fullC;\n        } else {\n            // Guarantee at least through the first \"{\" line so a tiny max_c_lines still returns a whole\n            // signature (spec 3.4 seat-4).\n            int openBrace = 0;\n            for (int i = 0; i < lines.length; i++) { if (lines[i].contains(\"{\")) { openBrace = i; break; } }\n            int keep = Math.max(maxCLines, openBrace + 1);\n            StringBuilder sb = new StringBuilder();\n            for (int i = 0; i < Math.min(keep, lines.length); i++) { sb.append(lines[i]).append(\"\\n\"); }\n            sb.append(\"// [ghidra-mcp] truncated \").append(lines.length - Math.min(keep, lines.length))\n              .append(\" more line(s); raise max_c_lines to see the full body\\n\");\n            c = sb.toString();\n        }\n\n        // Comments anchored anywhere in this function\'s body \u{2014} a decompiler-INDEPENDENT breadcrumb channel\n        // (the decompiler can drop comments from generated C). Bounded 100; one row per (address,type).\n        com.google.gson.JsonArray comments = new com.google.gson.JsonArray();\n        boolean cmtTrunc = false;\n        ghidra.program.model.listing.Listing clst = program.getListing();\n        ghidra.program.model.address.AddressIterator cIt =\n                clst.getCommentAddressIterator(fn.getBody(), true);\n        outerC:\n        while (cIt.hasNext()) {\n            ghidra.program.model.address.Address ca = cIt.next();\n            JsonObject cobj = commentsAt(clst, ca);\n            if (cobj == null) continue;\n            for (java.util.Map.Entry<String, com.google.gson.JsonElement> ce : cobj.entrySet()) {\n                if (comments.size() >= 100) { cmtTrunc = true; break outerC; }\n                JsonObject row = new JsonObject();\n                row.addProperty(\"address\", addrString(ca));\n                row.addProperty(\"type\", ce.getKey());\n                row.addProperty(\"comment\", ce.getValue().getAsString());\n                comments.add(row);\n            }\n        }\n\n        JsonObject res = new JsonObject();\n        res.addProperty(\"name\", fn.getName());\n        res.addProperty(\"entry_point\", addrString(fn.getEntryPoint()));\n        // M2c Task 0.2: signature + structured prototype from the Program DB (Function.getSignature()),\n        // NOT the decompiler \u{2014} the decompiler infers params not locked in the DB, which set_prototype\'s\n        // CAS/write never touch (panel R2-Seat3/Seat4). The flat `signature` uses the SAME getPrototypeString\n        // overload the CAS check uses, so an echoed expected_signature matches exactly.\n        ghidra.program.model.listing.FunctionSignature dbSig = fn.getSignature();\n        res.addProperty(\"signature\", dbSig.getPrototypeString());\n        String cc = fn.getCallingConventionName();\n        res.addProperty(\"calling_convention\", cc != null ? cc : \"unknown\");\n        JsonObject proto = new JsonObject();\n        proto.addProperty(\"return_type\", dbSig.getReturnType().getName());\n        String pcc = fn.getCallingConventionName();\n        proto.addProperty(\"calling_convention\", pcc != null ? pcc : \"unknown\");\n        com.google.gson.JsonArray pparams = new com.google.gson.JsonArray();\n        for (ghidra.program.model.data.ParameterDefinition pd : dbSig.getArguments()) {\n            JsonObject po = new JsonObject();\n            // NEVER emit a JSON null for `name`: ParameterDefinition.getName() can be null for an unnamed\n            // param, which Rust\'s `ParamEntry.name: String` (non-Option) would fail to deserialize and break\n            // inspect_function (agy plan-panel R1-Seat2). Coalesce to \"\" \u{2014} the write side coerces \"\" back to\n            // null to auto-name, so the read\u{2194}write round-trip is preserved.\n            po.addProperty(\"name\", pd.getName() != null ? pd.getName() : \"\");\n            po.addProperty(\"type\", pd.getDataType().getName());\n            pparams.add(po);\n        }\n        proto.add(\"params\", pparams);\n        proto.addProperty(\"variadic\", dbSig.hasVarArgs());\n        res.add(\"prototype\", proto);\n        res.addProperty(\"is_thunk\", fn.isThunk());\n        Function thunked = fn.isThunk() ? fn.getThunkedFunction(true) : null;\n        res.add(\"thunked_to\", thunked != null\n                ? new com.google.gson.JsonPrimitive(addrString(thunked.getEntryPoint()))\n                : com.google.gson.JsonNull.INSTANCE);\n        res.addProperty(\"c\", c);\n        res.addProperty(\"c_truncated\", truncated);\n        res.add(\"callers\", edges(fn.getCallingFunctions(monitor)));\n        res.add(\"callees\", edges(fn.getCalledFunctions(monitor)));\n        res.add(\"data_refs\", drefs); res.addProperty(\"data_refs_truncated\", drTrunc);\n        res.add(\"comments\", comments);\n        res.addProperty(\"comments_truncated\", cmtTrunc);\n\n        // M2d: structured decompiler-visible locals (excludes parameters \u{2014} those are in `prototype`).\n        // Sourced from the SAME decompile as `c`, so the names match the body. Bounded 100.\n        com.google.gson.JsonArray locals = new com.google.gson.JsonArray();\n        boolean localsTrunc = false;\n        ghidra.program.model.pcode.HighFunction hf = r.getHighFunction();\n        if (hf != null) {\n            ghidra.program.model.pcode.LocalSymbolMap lsm = hf.getLocalSymbolMap();\n            java.util.Iterator<ghidra.program.model.pcode.HighSymbol> symIt = lsm.getSymbols();\n            while (symIt.hasNext()) {\n                ghidra.program.model.pcode.HighSymbol hs = symIt.next();\n                if (hs.isParameter()) continue; // params belong to prototype\n                if (locals.size() >= 100) { localsTrunc = true; break; }\n                ghidra.program.model.listing.VariableStorage vs = hs.getStorage();\n                JsonObject lo = new JsonObject();\n                lo.addProperty(\"name\", hs.getName());\n                lo.addProperty(\"type\", hs.getDataType() != null ? hs.getDataType().getName() : \"undefined\");\n                lo.addProperty(\"storage\", storageHandle(vs));\n                lo.addProperty(\"kind\", storageKind(vs));\n                locals.add(lo);\n            }\n        }\n        res.add(\"locals\", locals);\n        res.addProperty(\"locals_truncated\", localsTrunc);\n        return res;\n    }\n\n    // find_functions { filter?, regex=false, offset=0, limit=100 } -> { functions:[FunctionSummary], has_more, total? }\n    private JsonObject findFunctions(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        String filter = params.has(\"filter\") && !params.get(\"filter\").isJsonNull() ? params.get(\"filter\").getAsString() : null;\n        boolean regex = boolParam(params, \"regex\");\n        int[] ol = readOffsetLimit(params, 100); int offset = ol[0], limit = ol[1];\n        com.google.gson.JsonArray out = new com.google.gson.JsonArray();\n        int seen = 0; boolean hasMore = false;\n        java.util.Iterator<Function> it = program.getFunctionManager().getFunctions(true).iterator();\n        while (it.hasNext()) {\n            Function f = it.next();\n            if (!matchesFilter(filter, regex, f.getName())) continue;\n            if (seen++ < offset) continue;\n            if (out.size() >= limit) { hasMore = true; break; }\n            JsonObject r = new JsonObject();\n            r.addProperty(\"name\", f.getName());\n            r.addProperty(\"entry_point\", addrString(f.getEntryPoint()));\n            r.addProperty(\"signature\", f.getSignature() != null ? f.getSignature().getPrototypeString() : f.getName());\n            String cc = f.getCallingConventionName(); r.addProperty(\"calling_convention\", cc != null ? cc : \"unknown\");\n            r.addProperty(\"is_thunk\", f.isThunk());\n            Function th = f.isThunk() ? f.getThunkedFunction(true) : null;\n            if (th != null) r.addProperty(\"thunked_to\", addrString(th.getEntryPoint()));\n            try { r.addProperty(\"size\", f.getBody().getNumAddresses()); } catch (Throwable ignore) {}\n            r.addProperty(\"external\", f.isExternal());\n            out.add(r);\n        }\n        JsonObject res = new JsonObject();\n        res.add(\"functions\", out);\n        res.addProperty(\"has_more\", hasMore);\n        return res; // `total` omitted (filtered/streamed) per spec \u{a7}3.0\n    }\n\n    // resolve_symbol { name } -> { candidates:[Symbol] }. 0 -> SYMBOL_NOT_FOUND; enumerates all matches.\n    private JsonObject resolveSymbol(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        String name = params.has(\"name\") ? params.get(\"name\").getAsString() : null;\n        if (name == null) throw new WorkerError(\"INVALID_PARAMS\", \"resolve_symbol requires name\");\n        com.google.gson.JsonArray cands = new com.google.gson.JsonArray();\n        for (ghidra.program.model.symbol.Symbol s : program.getSymbolTable().getGlobalSymbols(name)) {\n            cands.add(symbolJson(s));\n        }\n        if (cands.size() == 0) throw new WorkerError(\"SYMBOL_NOT_FOUND\", \"no symbol named \'\" + name + \"\'\");\n        JsonObject res = new JsonObject(); res.add(\"candidates\", cands); return res;\n    }\n\n    // Shared Symbol serializer (reused by list_symbols). kind = lowercase SymbolType name.\n    private JsonObject symbolJson(ghidra.program.model.symbol.Symbol s) {\n        JsonObject r = new JsonObject();\n        r.addProperty(\"name\", s.getName());\n        r.addProperty(\"address\", addrString(s.getAddress()));\n        r.addProperty(\"kind\", s.getSymbolType().toString().toLowerCase());\n        if (s.getParentNamespace() != null && !s.getParentNamespace().isGlobal())\n            r.addProperty(\"namespace\", s.getParentNamespace().getName());\n        return r;\n    }\n\n    // list_symbols { kind=\"all\", filter?, regex=false, offset=0, limit=100 } -> { symbols:[Symbol], has_more }\n    private JsonObject listSymbols(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        String kind = params.has(\"kind\") && !params.get(\"kind\").isJsonNull() ? params.get(\"kind\").getAsString() : \"all\";\n        String filter = params.has(\"filter\") && !params.get(\"filter\").isJsonNull() ? params.get(\"filter\").getAsString() : null;\n        boolean regex = boolParam(params, \"regex\");\n        int[] ol = readOffsetLimit(params, 100); int offset = ol[0], limit = ol[1];\n        ghidra.program.model.symbol.SymbolTable st = program.getSymbolTable();\n        java.util.Iterator<ghidra.program.model.symbol.Symbol> src =\n            \"import\".equals(kind) ? st.getExternalSymbols() : st.getAllSymbols(true);\n        com.google.gson.JsonArray out = new com.google.gson.JsonArray(); int seen = 0; boolean hasMore = false;\n        while (src.hasNext()) {\n            ghidra.program.model.symbol.Symbol s = src.next();\n            if (\"export\".equals(kind) && !s.isExternalEntryPoint()) continue;\n            if (\"defined\".equals(kind) && s.isExternal()) continue;\n            if (!matchesFilter(filter, regex, s.getName())) continue;\n            if (seen++ < offset) continue;\n            if (out.size() >= limit) { hasMore = true; break; }\n            out.add(symbolJson(s));\n        }\n        JsonObject res = new JsonObject(); res.add(\"symbols\", out); res.addProperty(\"has_more\", hasMore); return res;\n    }\n\n    // list_strings { filter?, regex=false, offset=0, limit=200 } -> { strings:[StringItem], has_more }\n    private JsonObject listStrings(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        String filter = params.has(\"filter\") && !params.get(\"filter\").isJsonNull() ? params.get(\"filter\").getAsString() : null;\n        boolean regex = boolParam(params, \"regex\");\n        int[] ol = readOffsetLimit(params, 200); int offset = ol[0], limit = ol[1];\n        com.google.gson.JsonArray out = new com.google.gson.JsonArray(); int seen = 0; boolean hasMore = false;\n        ghidra.program.model.listing.DataIterator it =\n            ghidra.program.util.DefinedStringIterator.forProgram(program);\n        while (it.hasNext()) {\n            ghidra.program.model.listing.Data d = it.next();\n            Object val = d.getValue(); String sv = val != null ? val.toString() : \"\";\n            if (!matchesFilter(filter, regex, sv)) continue;\n            if (seen++ < offset) continue;\n            if (out.size() >= limit) { hasMore = true; break; }\n            JsonObject r = new JsonObject();\n            r.addProperty(\"address\", addrString(d.getAddress()));\n            r.addProperty(\"value\", sv);\n            r.addProperty(\"length\", (long) d.getLength());\n            r.addProperty(\"encoding\", d.getDataType().getName());\n            out.add(r);\n        }\n        JsonObject res = new JsonObject(); res.add(\"strings\", out); res.addProperty(\"has_more\", hasMore); return res;\n    }\n\n    // get_xrefs { target, direction=\"both\", offset=0, limit=100 } -> { xrefs:[Reference], has_more }\n    // target resolves as an address OR any symbol (function/data/label) \u{2014} NOT just functions, so xrefs\n    // to a data symbol (e.g. g_banner) work too. NO silent first-hit on an ambiguous name (spec 6).\n    private JsonObject getXrefs(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        String target = params.has(\"target\") ? params.get(\"target\").getAsString() : null;\n        if (target == null) throw new WorkerError(\"INVALID_PARAMS\", \"get_xrefs requires target\");\n        String dir = params.has(\"direction\") && !params.get(\"direction\").isJsonNull() ? params.get(\"direction\").getAsString() : \"both\";\n        ghidra.program.model.address.Address addr = tryParseAddr(program, target);\n        if (addr == null) { // resolve as ANY symbol (function OR data label/string \u{2014} panel r1 seat-2)\n            java.util.List<ghidra.program.model.symbol.Symbol> syms = program.getSymbolTable().getGlobalSymbols(target);\n            if (syms.isEmpty()) throw new WorkerError(\"SYMBOL_NOT_FOUND\", \"no symbol named \'\" + target + \"\'\");\n            if (syms.size() > 1) { // NO silent first-hit (spec \u{a7}6) \u{2014} panel r2: surface candidates like inspect_function\n                com.google.gson.JsonArray cands = new com.google.gson.JsonArray();\n                for (ghidra.program.model.symbol.Symbol s : syms) { JsonObject c = new JsonObject(); c.addProperty(\"name\", s.getName()); c.addProperty(\"address\", addrString(s.getAddress())); cands.add(c); }\n                throw new AmbiguousError(\"\'\" + target + \"\' matches \" + syms.size() + \" symbols\", cands);\n            }\n            addr = syms.get(0).getAddress(); // xrefs to a data symbol (e.g. g_banner) must work, not just functions\n        }\n        if (program.getMemory().getBlock(addr) == null)\n            throw new WorkerError(\"ADDRESS_NOT_FOUND\", \"address not mapped: \" + target);\n        ghidra.program.model.symbol.ReferenceManager rm = program.getReferenceManager();\n        int[] ol = readOffsetLimit(params, 100); int offset = ol[0], limit = ol[1];\n        java.util.List<ghidra.program.model.symbol.Reference> refs = new java.util.ArrayList<>();\n        if (!\"from\".equals(dir)) for (ghidra.program.model.symbol.Reference r : rm.getReferencesTo(addr)) refs.add(r);\n        if (!\"to\".equals(dir)) for (ghidra.program.model.symbol.Reference r : rm.getReferencesFrom(addr)) refs.add(r);\n        com.google.gson.JsonArray out = new com.google.gson.JsonArray(); int seen = 0; boolean hasMore = false;\n        for (ghidra.program.model.symbol.Reference r : refs) {\n            if (seen++ < offset) continue;\n            if (out.size() >= limit) { hasMore = true; break; }\n            JsonObject j = new JsonObject();\n            j.addProperty(\"from_address\", addrString(r.getFromAddress()));\n            j.addProperty(\"to_address\", addrString(r.getToAddress()));\n            j.addProperty(\"ref_type\", r.getReferenceType().getName());\n            Function ff = program.getFunctionManager().getFunctionContaining(r.getFromAddress());\n            if (ff != null) j.addProperty(\"from_function\", ff.getName());\n            out.add(j);\n        }\n        JsonObject res = new JsonObject(); res.add(\"xrefs\", out); res.addProperty(\"has_more\", hasMore); return res;\n    }\n\n    // get_disassembly { target, offset=0, limit=200 } -> { instructions:[Instruction], has_more, total }\n    // target resolves via the shared function resolver (name-or-address; can throw AMBIGUOUS_SYMBOL).\n    private JsonObject getDisassembly(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        Function fn = resolveFunction(program, remap(params, \"target\")); // reuse \u{a7}6 resolver\n        int[] ol = readOffsetLimit(params, 200); int offset = ol[0], limit = ol[1];\n        ghidra.program.model.listing.Listing lst = program.getListing();\n        // Collect first so total = EXACT instruction count \u{2014} cheap for one bounded function, and spec \u{a7}3.6\n        // mandates total here (unlike the O(N^2) filtered-table case) \u{2014} panel r1 seat-3.\n        java.util.List<ghidra.program.model.listing.Instruction> all = new java.util.ArrayList<>();\n        for (ghidra.program.model.listing.Instruction i0 : lst.getInstructions(fn.getBody(), true)) all.add(i0);\n        long total = all.size();\n        com.google.gson.JsonArray out = new com.google.gson.JsonArray(); boolean hasMore = false;\n        for (int idx = offset; idx < all.size(); idx++) {\n            if (out.size() >= limit) { hasMore = true; break; }\n            ghidra.program.model.listing.Instruction ins = all.get(idx);\n            JsonObject j = new JsonObject();\n            j.addProperty(\"address\", addrString(ins.getAddress()));\n            StringBuilder hex = new StringBuilder();\n            try { for (byte b : ins.getBytes()) hex.append(String.format(\"%02x\", b & 0xFF)); } catch (Throwable ignore) {}\n            j.addProperty(\"bytes\", hex.toString());\n            j.addProperty(\"mnemonic\", ins.getMnemonicString());\n            StringBuilder ops = new StringBuilder();\n            for (int i = 0; i < ins.getNumOperands(); i++) { if (i > 0) ops.append(\", \"); ops.append(ins.getDefaultOperandRepresentation(i)); }\n            j.addProperty(\"operands\", ops.toString());\n            JsonObject cmts = commentsAt(lst, ins.getAddress());\n            if (cmts != null) j.add(\"comments\", cmts);\n            out.add(j);\n        }\n        JsonObject res = new JsonObject(); res.add(\"instructions\", out); res.addProperty(\"has_more\", hasMore); res.addProperty(\"total\", total); return res;\n    }\n\n    // read_bytes { address, offset=0 (decimal), length=256 (clamped <=4096) } -> { address, length, bytes, truncated }\n    private JsonObject readBytes(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        String a = params.has(\"address\") ? params.get(\"address\").getAsString() : null;\n        if (a == null) throw new WorkerError(\"INVALID_PARAMS\", \"read_bytes requires address\");\n        ghidra.program.model.address.Address base = tryParseAddr(program, a);\n        if (base == null) throw new WorkerError(\"INVALID_PARAMS\", \"unparseable address: \" + a);\n        long offset = params.has(\"offset\") ? params.get(\"offset\").getAsLong() : 0L;\n        long length = params.has(\"length\") ? params.get(\"length\").getAsLong() : 256L;\n        boolean truncated = false; if (length > 4096) { length = 4096; truncated = true; } if (length < 0) length = 0;\n        ghidra.program.model.address.Address start;\n        try { start = base.add(offset); }\n        catch (ghidra.program.model.address.AddressOutOfBoundsException oob) {\n            throw new WorkerError(\"ADDRESS_NOT_FOUND\", \"address+offset out of bounds: \" + a); }\n        byte[] buf = new byte[(int) length];\n        int got;\n        try { got = program.getMemory().getBytes(start, buf); }\n        catch (ghidra.program.model.mem.MemoryAccessException mae) {\n            throw new WorkerError(\"ADDRESS_NOT_FOUND\", \"memory not readable at \" + addrString(start)); }\n        StringBuilder hex = new StringBuilder(); for (int i = 0; i < got; i++) hex.append(String.format(\"%02x\", buf[i] & 0xFF));\n        JsonObject res = new JsonObject();\n        res.addProperty(\"address\", addrString(start)); res.addProperty(\"length\", (long) got);\n        res.addProperty(\"bytes\", hex.toString()); res.addProperty(\"truncated\", truncated);\n        return res;\n    }\n\n    // get_datatype { name } -> DataTypeInfo (struct/union/enum/other); AMBIGUOUS_DATATYPE if >1 hit.\n    private JsonObject getDatatype(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        String name = params.has(\"name\") ? params.get(\"name\").getAsString() : null;\n        if (name == null) throw new WorkerError(\"INVALID_PARAMS\", \"get_datatype requires name\");\n        java.util.List<ghidra.program.model.data.DataType> hits = new java.util.ArrayList<>();\n        program.getDataTypeManager().findDataTypes(name, hits);\n        if (hits.isEmpty()) throw new WorkerError(\"DATATYPE_NOT_FOUND\", \"no data type named \'\" + name + \"\'\");\n        if (hits.size() > 1) {\n            com.google.gson.JsonArray cands = new com.google.gson.JsonArray();\n            for (ghidra.program.model.data.DataType dt : hits) { JsonObject c = new JsonObject(); c.addProperty(\"name\", dt.getPathName()); cands.add(c); }\n            throw new AmbiguousError(\"AMBIGUOUS_DATATYPE\", \"\'\" + name + \"\' matches \" + hits.size() + \" types\", cands);\n        }\n        return dataTypeJson(hits.get(0));\n    }\n    private JsonObject dataTypeJson(ghidra.program.model.data.DataType dt) {\n        JsonObject r = new JsonObject();\n        r.addProperty(\"name\", dt.getName());\n        r.addProperty(\"size\", (long) dt.getLength());\n        if (dt instanceof ghidra.program.model.data.Structure || dt instanceof ghidra.program.model.data.Union) {\n            r.addProperty(\"kind\", dt instanceof ghidra.program.model.data.Union ? \"union\" : \"struct\");\n            com.google.gson.JsonArray members = new com.google.gson.JsonArray();\n            for (ghidra.program.model.data.DataTypeComponent c : ((ghidra.program.model.data.Composite) dt).getComponents()) {\n                JsonObject m = new JsonObject();\n                m.addProperty(\"name\", c.getFieldName() != null ? c.getFieldName() : (\"field_\" + c.getOrdinal()));\n                m.addProperty(\"type\", c.getDataType().getName());\n                m.addProperty(\"offset\", (long) c.getOffset());\n                m.addProperty(\"size\", (long) c.getLength());\n                members.add(m);\n            }\n            r.add(\"members\", members);\n        } else if (dt instanceof ghidra.program.model.data.Enum) {\n            r.addProperty(\"kind\", \"enum\");\n            ghidra.program.model.data.Enum en = (ghidra.program.model.data.Enum) dt;\n            com.google.gson.JsonArray vals = new com.google.gson.JsonArray();\n            for (String n : en.getNames()) { JsonObject v = new JsonObject(); v.addProperty(\"name\", n); v.addProperty(\"value\", Long.toString(en.getValue(n))); vals.add(v); }\n            r.add(\"values\", vals);\n        } else {\n            r.addProperty(\"kind\", dt.getClass().getSimpleName().toLowerCase());\n        }\n        return r;\n    }\n\n    // list_segments {} -> { segments:[Segment] } (NOT paginated \u{2014} block counts are small).\n    private JsonObject listSegments(JsonObject params) {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        com.google.gson.JsonArray out = new com.google.gson.JsonArray();\n        for (ghidra.program.model.mem.MemoryBlock b : program.getMemory().getBlocks()) {\n            JsonObject j = new JsonObject();\n            j.addProperty(\"name\", b.getName());\n            j.addProperty(\"start\", addrString(b.getStart()));\n            j.addProperty(\"end\", addrString(b.getEnd()));\n            j.addProperty(\"size\", b.getSize());\n            j.addProperty(\"readable\", b.isRead());\n            j.addProperty(\"writable\", b.isWrite());\n            j.addProperty(\"executable\", b.isExecute());\n            j.addProperty(\"initialized\", b.isInitialized());\n            out.add(j);\n        }\n        JsonObject res = new JsonObject(); res.add(\"segments\", out); return res;\n    }\n\n    // list_data_items { filter?, regex=false, offset=0, limit=200 } -> { data:[DataItem], has_more }\n    private JsonObject listDataItems(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        String filter = params.has(\"filter\") && !params.get(\"filter\").isJsonNull() ? params.get(\"filter\").getAsString() : null;\n        boolean regex = boolParam(params, \"regex\");\n        int[] ol = readOffsetLimit(params, 200); int offset = ol[0], limit = ol[1];\n        com.google.gson.JsonArray out = new com.google.gson.JsonArray(); int seen = 0; boolean hasMore = false;\n        java.util.Iterator<ghidra.program.model.listing.Data> it = program.getListing().getDefinedData(true).iterator();\n        while (it.hasNext()) {\n            ghidra.program.model.listing.Data d = it.next();\n            String label = d.getLabel();\n            if (filter != null && !matchesFilter(filter, regex, label != null ? label : \"\")) continue;\n            if (seen++ < offset) continue;\n            if (out.size() >= limit) { hasMore = true; break; }\n            JsonObject j = new JsonObject();\n            j.addProperty(\"address\", addrString(d.getAddress()));\n            if (label != null) j.addProperty(\"name\", label);\n            j.addProperty(\"data_type\", d.getDataType().getName());\n            try { Object v = d.getValue(); if (v != null) j.addProperty(\"value\", v.toString()); } catch (Throwable ignore) {}\n            j.addProperty(\"size\", (long) d.getLength());\n            out.add(j);\n        }\n        JsonObject res = new JsonObject(); res.add(\"data\", out); res.addProperty(\"has_more\", hasMore); return res;\n    }\n\n    // describe_address { address } -> AddressInfo (spec 3.x, M1b Task 17). TOTAL: an unmapped or\n    // unparseable address is a valid answer (mapped:false), never a WorkerError.\n    private JsonObject describeAddress(JsonObject params) {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"attach a program first\");\n        String a = params.has(\"address\") ? params.get(\"address\").getAsString() : null;\n        if (a == null) throw new WorkerError(\"INVALID_PARAMS\", \"describe_address requires address\");\n        ghidra.program.model.address.Address addr = tryParseAddr(program, a);\n        JsonObject res = new JsonObject();\n        if (addr == null || program.getMemory().getBlock(addr) == null) {\n            res.addProperty(\"address\", a); res.addProperty(\"mapped\", false); res.addProperty(\"is_code\", false);\n            return res; // unmapped is a valid answer, not an error (spec \u{a7}3.11)\n        }\n        res.addProperty(\"address\", addrString(addr)); res.addProperty(\"mapped\", true);\n        res.addProperty(\"segment\", program.getMemory().getBlock(addr).getName());\n        res.addProperty(\"is_code\", program.getListing().getInstructionContaining(addr) != null);\n        Function cf = program.getFunctionManager().getFunctionContaining(addr);\n        if (cf != null) { JsonObject j = new JsonObject(); j.addProperty(\"name\", cf.getName()); j.addProperty(\"entry_point\", addrString(cf.getEntryPoint())); res.add(\"containing_function\", j); }\n        ghidra.program.model.symbol.Symbol s = program.getSymbolTable().getPrimarySymbol(addr);\n        if (s != null) { JsonObject j = new JsonObject(); j.addProperty(\"name\", s.getName()); j.addProperty(\"kind\", s.getSymbolType().toString().toLowerCase()); res.add(\"symbol\", j); }\n        ghidra.program.model.listing.Data d = program.getListing().getDataContaining(addr);\n        if (d != null && d.isDefined()) res.addProperty(\"data_type\", d.getDataType().getName());\n        JsonObject cmts = commentsAt(program.getListing(), addr);\n        if (cmts != null) res.add(\"comments\", cmts);\n        return res;\n    }\n\n    // Required string param: absent or JSON-null -> INVALID_PARAMS (no NPE \u{2014} checks presence before getAsString).\n    private static String reqStr(JsonObject params, String key) {\n        if (!params.has(key) || params.get(key).isJsonNull())\n            throw new WorkerError(\"INVALID_PARAMS\", \"missing required parameter: \" + key);\n        return params.get(key).getAsString();\n    }\n\n    @FunctionalInterface interface TxBody { void run() throws Exception; }\n\n    // Wraps ONLY start/end (spec \u{a7}4.1). save() + revert are orchestrated by rename() (\u{a7}4.3), NOT here.\n    // Operates on the caller-supplied program (the effective attached-or-bootstrap program).\n    private void withTransaction(Program program, String desc, TxBody body) {\n        int id = program.startTransaction(desc);\n        boolean commit = false;\n        try {\n            body.run();\n            commit = true;\n        } catch (ghidra.util.exception.DuplicateNameException | ghidra.util.exception.InvalidInputException e) {\n            // spec \u{a7}4 table: exact Ghidra message goes in details.reason so the LLM can self-correct.\n            com.google.gson.JsonObject d = new com.google.gson.JsonObject();\n            d.addProperty(\"reason\", e.getMessage());\n            throw new WorkerError(\"INVALID_PARAMS\", \"invalid new name\", d);\n        } catch (WorkerError we) {\n            throw we;\n        } catch (Throwable t) {\n            throw new WorkerError(\"TRANSACTION_FAILED\", String.valueOf(t));\n        } finally {\n            program.endTransaction(id, commit); // commit=false => rollback\n        }\n    }\n\n    // comment { target, comment_type, new_comment, expected_comment? } -> CommentResult (M2b \u{a7}3.1).\n    // Set/clear a comment on the code unit at the resolved address. already_applied FIRST (\u{a7}3.1 precedence),\n    // then optional CAS guard (STATE_CONFLICT), then apply + auto-save (durable). Empty new_comment clears.\n    private JsonObject comment(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"no program is open\");\n        String target = reqStr(params, \"target\");\n        String typeStr = reqStr(params, \"comment_type\");\n        String newComment = reqStr(params, \"new_comment\"); // \"\" clears\n        ghidra.program.model.listing.CommentType ctype = parseCommentType(typeStr);\n\n        ghidra.program.model.address.Address addr = resolveTargetAddress(program, target);\n        ghidra.program.model.listing.Listing listing = program.getListing();\n        // Offcut guard (panel R4-S3): setComment stores at ANY address, but the listing only RENDERS comments\n        // anchored at a code unit\'s START \u{2014} an offcut comment (inside a multi-byte instruction/data) is a\n        // permanent \"ghost\" the human never sees, though this tool\'s own re-read still finds it (false\n        // already_applied). Reject true offcuts. NOTE: an instruction AT its start IS a valid comment target\n        // (unlike set_datatype), so this guard only trips when addr != the containing unit\'s min address.\n        ghidra.program.model.listing.CodeUnit cuc = listing.getCodeUnitContaining(addr);\n        if (cuc != null && !cuc.getMinAddress().equals(addr)) {\n            throw new WorkerError(\"INVALID_PARAMS\",\n                    \"address \" + addr + \" is inside a code unit starting at \" + cuc.getMinAddress()\n                    + \"; comment that start address (an offcut comment would be invisible in the listing)\");\n        }\n        String currentRaw = listing.getComment(ctype, addr); // null when none\n        String current = currentRaw == null ? \"\" : currentRaw;\n\n        JsonObject res = new JsonObject();\n        res.addProperty(\"address\", addr.toString());\n        res.addProperty(\"comment_type\", typeStr);\n        res.addProperty(\"durable\", true);\n\n        if (current.equals(newComment)) {                 // already_applied \u{2014} FIRST, no tx/save (\u{a7}3.1)\n            res.addProperty(\"status\", \"already_applied\");\n            if (!current.isEmpty()) res.addProperty(\"previous_comment\", current);\n            return res;\n        }\n        if (params.has(\"expected_comment\") && !params.get(\"expected_comment\").isJsonNull()) {\n            String expected = params.get(\"expected_comment\").getAsString();\n            if (!current.equals(expected)) {              // drift -> STATE_CONFLICT (\u{a7}4.2)\n                JsonObject d = new JsonObject();\n                d.addProperty(\"address\", addr.toString());\n                d.addProperty(\"field\", \"comment\");\n                d.addProperty(\"comment_type\", typeStr);\n                d.addProperty(\"expected\", expected);\n                d.addProperty(\"actual\", current);\n                throw new WorkerError(\"STATE_CONFLICT\",\n                        \"current \" + typeStr + \" comment differs from expected_comment\", d);\n            }\n        }\n\n        final String toSet = newComment.isEmpty() ? null : newComment; // null clears\n        withTransaction(program, \"comment \" + typeStr + \" @ \" + addr,\n                () -> program.getListing().setComment(addr, ctype, toSet));\n        // M2a save dance (oracle: rename L908-929). end(true) closes the script tx so save() can lock; on\n        // failure re-apply the captured previous comment and re-start() before throwing (R4 tx-state invariant).\n        try {\n            end(true);\n            program.getDomainFile().save(monitor);\n        } catch (Throwable saveFail) {\n            try {\n                final String revert = currentRaw; // restore prior (null clears)\n                withTransaction(program, \"revert failed comment save\",\n                        () -> program.getListing().setComment(addr, ctype, revert));\n            } catch (Throwable revertFail) {\n                // Double-fault (save AND revert failed): in-memory is dirty vs disk. A RETURNED\n                // WORKER_UNAVAILABLE keeps THIS JVM alive (execute.rs treats a Failure frame as a domain\n                // error, NOT a crash), so a retry would read the dirty in-memory state and falsely report\n                // already_applied/durable (phantom durability). Halt so the host sees a TRANSPORT crash and\n                // respawns a CLEAN JVM from disk; the transparent retry then re-applies (panel R2-F4).\n                Runtime.getRuntime().halt(70);\n            }\n            start();\n            throw new WorkerError(\"TRANSACTION_FAILED\", \"edit rolled back (save to disk failed): \" + saveFail);\n        }\n        start();\n        res.addProperty(\"status\", newComment.isEmpty() ? \"cleared\" : \"set\");\n        if (!current.isEmpty()) res.addProperty(\"previous_comment\", current);\n        return res;\n    }\n\n    // Map the wire enum-string to Ghidra\'s modern CommentType. Unknown -> INVALID_PARAMS (defence in depth;\n    // the Rust schema already rejects bad values with -32602).\n    private ghidra.program.model.listing.CommentType parseCommentType(String s) {\n        switch (s) {\n            case \"eol\":        return ghidra.program.model.listing.CommentType.EOL;\n            case \"pre\":        return ghidra.program.model.listing.CommentType.PRE;\n            case \"post\":       return ghidra.program.model.listing.CommentType.POST;\n            case \"plate\":      return ghidra.program.model.listing.CommentType.PLATE;\n            case \"repeatable\": return ghidra.program.model.listing.CommentType.REPEATABLE;\n            default: throw new WorkerError(\"INVALID_PARAMS\",\n                    \"comment_type must be one of eol|pre|post|plate|repeatable, got: \" + s);\n        }\n    }\n\n    // Build a {type: text} comments object for an address via the modern CommentType API; null if none.\n    private JsonObject commentsAt(ghidra.program.model.listing.Listing listing,\n            ghidra.program.model.address.Address addr) {\n        JsonObject c = new JsonObject();\n        putCmt(c, \"eol\", listing.getComment(ghidra.program.model.listing.CommentType.EOL, addr));\n        putCmt(c, \"pre\", listing.getComment(ghidra.program.model.listing.CommentType.PRE, addr));\n        putCmt(c, \"post\", listing.getComment(ghidra.program.model.listing.CommentType.POST, addr));\n        putCmt(c, \"plate\", listing.getComment(ghidra.program.model.listing.CommentType.PLATE, addr));\n        putCmt(c, \"repeatable\", listing.getComment(ghidra.program.model.listing.CommentType.REPEATABLE, addr));\n        return c.size() > 0 ? c : null;\n    }\n    private static void putCmt(JsonObject c, String key, String val) {\n        if (val != null && !val.isEmpty()) c.addProperty(key, val);\n    }\n\n    // set_datatype { target, datatype_name, expected_datatype? } -> SetDatatypeResult (M2b \u{a7}3.2).\n    // Retypes DATA at the resolved address (variables/params are M2c). datatype_name \"undefined\" is the\n    // CLEAR sentinel. already_applied via DataType.isEquivalent (R3); optional CAS on display-name;\n    // CLEAR_SINGLE_DATA only (R1, non-destructive); dynamic-length sized by Ghidra (R2); auto-save.\n    private JsonObject setDatatype(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"no program is open\");\n        String target = reqStr(params, \"target\");\n        String typeName = reqStr(params, \"datatype_name\");\n        ghidra.program.model.address.Address addr = resolveTargetAddress(program, target);\n        ghidra.program.model.listing.Listing listing = program.getListing();\n\n        // Offcut / code guard (panel R3-S5). getDataAt/getInstructionAt are START-anchored: they return null\n        // for an address INSIDE a unit, so without this an OFFCUT target (e.g. addr+1 inside a dword) would be\n        // silently mis-handled \u{2014} a clear would falsely report already_applied (\"nothing here\"), and an apply\n        // would clear the containing unit and leave a gap. getCodeUnitContaining returns the Instruction/Data\n        // actually covering addr; reject CODE and OFFCUTs up-front so the getDataAt(addr) below is reliable.\n        // (This subsumes the clear-path instruction guard \u{2014} instructions are rejected here for BOTH paths.)\n        ghidra.program.model.listing.CodeUnit cu = listing.getCodeUnitContaining(addr);\n        if (cu instanceof ghidra.program.model.listing.Instruction) {\n            throw new WorkerError(\"INVALID_PARAMS\",\n                    \"that address is code (instruction at \" + cu.getMinAddress() + \"), not data; this tool \"\n                    + \"targets DATA. Retyping a function variable or parameter arrives with set_prototype (M2c).\");\n        }\n        if (cu != null && !cu.getMinAddress().equals(addr)) {\n            throw new WorkerError(\"INVALID_PARAMS\",\n                    \"address \" + addr + \" is inside a defined data unit starting at \" + cu.getMinAddress()\n                    + \"; pass that start address\");\n        }\n\n        // getDataAt (now reliable \u{2014} addr is a unit start or undefined): UNDEFINED bytes -> a non-null Data whose\n        // isDefined()==false (panel-F5-r2 \u{2014} Ghidra\'s data model; do NOT treat null as the only \"no data\" signal).\n        ghidra.program.model.listing.Data existing = listing.getDataAt(addr);\n        boolean hasDefinedData = existing != null && existing.isDefined();\n        ghidra.program.model.data.DataType prevDt = hasDefinedData ? existing.getDataType() : null;\n        final int prevLen = hasDefinedData ? existing.getLength() : 0; // CONCRETE instance length (panel-F5), for a lossless revert\n        String prevDisplay = hasDefinedData ? prevDt.getDisplayName() : \"undefined\"; // undefined location reads as \"undefined\" (spec \u{a7}7 D1)\n\n        JsonObject res = new JsonObject();\n        res.addProperty(\"address\", addr.toString());\n        res.addProperty(\"durable\", true);\n\n        boolean isClear = \"undefined\".equals(typeName);\n\n        // Resolve the target type up-front (real types only), so already_applied + guard can use it.\n        ghidra.program.model.data.DataType dt = null;\n        if (!isClear) {\n            dt = resolveDataType(program, typeName); // throws DATATYPE_NOT_FOUND / AMBIGUOUS_DATATYPE\n        }\n\n        // (Instruction + offcut targets were rejected up-front by the getCodeUnitContaining guard above \u{2014}\n        //  R4/F1 footgun closed there for BOTH the clear and apply paths.)\n\n        // --- already_applied (\u{a7}3.1 precedence). Semantic equivalence, NOT name-string (R3). ---\n        if (isClear) {\n            if (!hasDefinedData) { // already undefined (getDataAt null OR non-null-but-undefined) \u{2014} panel-F5-r2\n                res.addProperty(\"status\", \"already_applied\");\n                res.addProperty(\"datatype\", \"undefined\");\n                res.addProperty(\"length\", 0);\n                return res;\n            }\n        } else {\n            if (hasDefinedData && existing.getDataType().isEquivalent(dt)) {\n                res.addProperty(\"status\", \"already_applied\");\n                res.addProperty(\"datatype\", existing.getDataType().getDisplayName());\n                res.addProperty(\"length\", existing.getLength());\n                return res;\n            }\n        }\n\n        // --- optional CAS guard: compare caller\'s belief against the current DISPLAY NAME (\u{a7}3.2). ---\n        if (params.has(\"expected_datatype\") && !params.get(\"expected_datatype\").isJsonNull()) {\n            String expected = params.get(\"expected_datatype\").getAsString();\n            if (!prevDisplay.equals(expected)) {\n                JsonObject d = new JsonObject();\n                d.addProperty(\"address\", addr.toString());\n                d.addProperty(\"field\", \"datatype\");\n                d.addProperty(\"expected\", expected);\n                d.addProperty(\"actual\", prevDisplay);\n                throw new WorkerError(\"STATE_CONFLICT\",\n                        \"current datatype \'\" + prevDisplay + \"\' differs from expected_datatype \'\" + expected + \"\'\", d);\n            }\n        }\n\n        // --- clear sentinel: instruction target already rejected above (F1); `existing` is defined DATA here. ---\n        if (isClear) {\n            final ghidra.program.model.address.Address clearEnd = existing.getMaxAddress(); // span the whole unit\n            withTransaction(program, \"clear data @ \" + addr,\n                    () -> program.getListing().clearCodeUnits(addr, clearEnd, false));\n            saveOrRevertDatatype(program, addr, prevDt, prevLen); // save dance + R4 revert (below)\n            res.addProperty(\"status\", \"cleared\");\n            res.addProperty(\"datatype\", \"undefined\");\n            res.addProperty(\"previous_datatype\", prevDisplay);\n            res.addProperty(\"length\", 0);\n            return res;\n        }\n\n        // --- apply a real type. CLEAR_SINGLE_DATA (R1); concrete length for dynamic types (panel-F2). ---\n        final ghidra.program.model.data.DataType applyDt = dt;\n        final int length = concreteLength(program, addr, dt); // NEVER pass dt.getLength()==-1 into createData (F2)\n        withTransaction(program, \"set_datatype \" + typeName + \" @ \" + addr, () -> {\n            try {\n                ghidra.program.model.data.DataUtilities.createData(program, addr, applyDt, length,\n                        ghidra.program.model.data.DataUtilities.ClearDataMode.CLEAR_SINGLE_DATA);\n            } catch (ghidra.program.model.util.CodeUnitInsertionException cie) {\n                // overlaps an instruction, or the type won\'t fit / would hit a following defined unit (R1):\n                // user-fixable -> INVALID_PARAMS with the exact Ghidra message (M2a granularity choice).\n                JsonObject d = new JsonObject();\n                d.addProperty(\"reason\", cie.getMessage());\n                throw new WorkerError(\"INVALID_PARAMS\",\n                        \"cannot apply \'\" + typeName + \"\' at \" + addr + \": \" + cie.getMessage()\n                        + \" (pick a data address, or clear the neighbour first with datatype_name:\\\"undefined\\\")\", d);\n            }\n        });\n        saveOrRevertDatatype(program, addr, prevDt, prevLen);\n\n        ghidra.program.model.listing.Data now = program.getListing().getDataAt(addr);\n        res.addProperty(\"status\", \"set\");\n        res.addProperty(\"datatype\", now != null ? now.getDataType().getDisplayName() : typeName);\n        if (prevDt != null) res.addProperty(\"previous_datatype\", prevDisplay);\n        res.addProperty(\"length\", now != null ? now.getLength() : 0);\n        return res;\n    }\n\n    // set_prototype { target, return_type, calling_convention, params:[{name?,type}], variadic?,\n    //   expected_signature? } -> SetPrototypeResult (M2c \u{a7}3). Whole-function signature replace on the\n    //   M2a/M2b withTransaction + save-dance + halt(70) plumbing. All Ghidra APIs javac-pinned (spec \u{a7}7).\n    private JsonObject setPrototype(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"no program is open\");\n        String returnTypeName = reqStr(params, \"return_type\");\n        String callingConvention = reqStr(params, \"calling_convention\");\n        reqStr(params, \"target\"); // presence check (resolved via remap below)\n        if (!params.has(\"params\") || !params.get(\"params\").isJsonArray())\n            throw new WorkerError(\"INVALID_PARAMS\", \"set_prototype requires a params array (empty [] = no arguments)\");\n        com.google.gson.JsonArray paramArr = params.getAsJsonArray(\"params\");\n        boolean variadic = boolParam(params, \"variadic\");\n\n        // 1) Resolve target -> FUNCTION (name or address; reuse \u{a7}6 resolver). All of \u{a7}1-6 below are PURE\n        //    READS (no DB mutation) so a rejected request mutates nothing (panel R3-Seat1); the only\n        //    mutation is the tx-guarded apply/synthesis in \u{a7}7-8.\n        Function fn = resolveFunction(program, remap(params, \"target\"));\n        if (fn.isThunk()) {\n            Function tf = fn.getThunkedFunction(true);\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"can\'t set the signature of a thunk; edit the thunked function\"\n                + (tf != null ? \" at \" + addrString(tf.getEntryPoint()) : \"\") + \" instead\");\n        }\n        if (fn.isExternal())\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"can\'t set the signature of external function \'\" + fn.getName() + \"\' (an import has no body)\");\n        if (fn.hasCustomVariableStorage())\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"\'\" + fn.getName() + \"\' uses custom variable storage (__usercall/explicit registers); \"\n                + \"set_prototype applies dynamic storage and would discard it \u{2014} not supported yet\");\n\n        // 2) Validate calling_convention against the PROGRAM\'s set (P6). No host-side sentinel (R1-Seat2).\n        java.util.Collection<String> ccNames = program.getFunctionManager().getCallingConventionNames();\n        if (ccNames == null || !ccNames.contains(callingConvention)) {\n            JsonObject d = new JsonObject();\n            d.addProperty(\"reason\", \"unknown calling convention \'\" + callingConvention + \"\'\");\n            d.add(\"valid\", gson.toJsonTree(ccNames));\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"unknown calling_convention \'\" + callingConvention + \"\'; valid: \" + ccNames, d);\n        }\n\n        // 3) Validate each param entry (pure). Empty [] = no-arg; [{type:\"void\"}] REJECTED (R2-Seat1);\n        //    name \"\" -> null so ParameterImpl auto-names (R2-Seat5).\n        java.util.List<String> paramTypeNames = new java.util.ArrayList<>();\n        java.util.List<String> paramNames = new java.util.ArrayList<>();\n        for (int i = 0; i < paramArr.size(); i++) {\n            // Defence in depth (agy plan-panel R2-Seat4): the host schema already rejects a non-object\n            // element with -32602, but a direct/buggy caller could send params:[\"int\"]; getAsJsonObject()\n            // would throw IllegalStateException -> WORKER_UNAVAILABLE. Reject cleanly as INVALID_PARAMS.\n            if (!paramArr.get(i).isJsonObject())\n                throw new WorkerError(\"INVALID_PARAMS\", \"params[\" + i + \"] must be an object with a \'type\' field\");\n            JsonObject p = paramArr.get(i).getAsJsonObject();\n            if (!p.has(\"type\") || p.get(\"type\").isJsonNull())\n                throw new WorkerError(\"INVALID_PARAMS\", \"params[\" + i + \"] missing \'type\'\");\n            String pt = p.get(\"type\").getAsString();\n            if (\"void\".equalsIgnoreCase(pt))\n                throw new WorkerError(\"INVALID_PARAMS\",\n                    \"params[\" + i + \"] type \'void\' is not a valid parameter type; use empty params:[] for a no-argument function\");\n            paramTypeNames.add(pt);\n            String pn = (p.has(\"name\") && !p.get(\"name\").isJsonNull()) ? p.get(\"name\").getAsString() : \"\";\n            paramNames.add(pn.isEmpty() ? null : pn);\n        }\n\n        // 4) Read the CURRENT DB prototype string (previous_signature + CAS comparand; NOT the decompiler).\n        String currentProto = fn.getSignature().getPrototypeString();\n\n        // 5) Name-resolve every type (pure). A name MISS is deferred to the \u{a7}8 tx-synthesis path (the\n        //    DataTypeParser fallback is a DB mutation). Ambiguous -> AMBIGUOUS_DATATYPE now.\n        ghidra.program.model.data.DataType retByName = resolveTypeByName(program, returnTypeName);\n        ghidra.program.model.data.DataType[] paramByName =\n                new ghidra.program.model.data.DataType[paramTypeNames.size()];\n        boolean needsSynth = (retByName == null);\n        for (int i = 0; i < paramTypeNames.size(); i++) {\n            paramByName[i] = resolveTypeByName(program, paramTypeNames.get(i));\n            if (paramByName[i] == null) needsSynth = true;\n        }\n\n        JsonObject res = new JsonObject();\n        res.addProperty(\"address\", fn.getEntryPoint().toString());\n        res.addProperty(\"name\", fn.getName());\n        res.addProperty(\"durable\", true);\n\n        // 6) FAST PATH \u{2014} all types name-resolved: already_applied + CAS with NO transaction (\u{a7}3 steps 3-4;\n        //    already_applied FIRST for respawn-retry-safety, M2b precedence).\n        if (!needsSynth) {\n            ghidra.program.model.data.FunctionDefinitionDataType desired =\n                    buildDesiredDef(retByName, paramByName, paramNames, callingConvention, variadic);\n            if (signatureAlreadyApplied(fn, desired, paramNames, callingConvention, variadic)) {\n                res.addProperty(\"status\", \"already_applied\");\n                res.addProperty(\"signature\", currentProto);\n                return res;\n            }\n            casCheck(params, currentProto, fn); // STATE_CONFLICT if expected_signature drifted\n            ghidra.program.model.data.FunctionDefinitionDataType prevDef =\n                    new ghidra.program.model.data.FunctionDefinitionDataType(fn.getSignature());\n            withTransaction(program, \"set_prototype \" + fn.getName(),\n                    () -> applyFunctionDefinition(program, fn, desired));\n            saveOrRevertPrototype(program, fn, prevDef);\n            disposeDecompiler(); // signature change rebuilds the AST \u{2014} drop the cached DecompInterface so the\n                                 // next inspect_function re-decompiles against the NEW signature (agy plan-panel\n                                 // R2-Seat1: a cached decompiler would serve stale C, breaking the read-mutate-read\n                                 // loop + e2e test 3). Reuses the shipped dispose (worker re-attach path).\n            res.addProperty(\"status\", \"set\");\n            res.addProperty(\"signature\", fn.getSignature().getPrototypeString());\n            res.addProperty(\"previous_signature\", currentProto);\n            return res;\n        }\n\n        // 7) CAS can still run outside the tx (needs only currentProto).\n        casCheck(params, currentProto, fn);\n\n        // 8) SYNTHESIS PATH \u{2014} an un-nameable type (fn-pointer/anon). Synthesize + apply INSIDE the tx so an\n        //    orphan synthesized type rolls back on any failure (\u{a7}4.1). already_applied is NOT offered here\n        //    (always \"set\"): the deterministic-name reuse makes re-apply idempotent + dup-free; e2e test 6\n        //    asserts no duplicate TYPE, not the status. Documented, intentional simplification.\n        final ghidra.program.model.data.FunctionDefinitionDataType prevDef =\n                new ghidra.program.model.data.FunctionDefinitionDataType(fn.getSignature());\n        withTransaction(program, \"set_prototype \" + fn.getName(), () -> {\n            ghidra.program.model.data.DataType rt =\n                    (retByName != null) ? retByName : synthesizeType(program, returnTypeName);\n            ghidra.program.model.data.DataType[] pts =\n                    new ghidra.program.model.data.DataType[paramTypeNames.size()];\n            for (int i = 0; i < pts.length; i++) {\n                pts[i] = (paramByName[i] != null) ? paramByName[i]\n                        : synthesizeType(program, paramTypeNames.get(i));\n            }\n            ghidra.program.model.data.FunctionDefinitionDataType desired =\n                    buildDesiredDef(rt, pts, paramNames, callingConvention, variadic);\n            applyFunctionDefinition(program, fn, desired);\n        });\n        saveOrRevertPrototype(program, fn, prevDef);\n        disposeDecompiler(); // drop the stale cached decompiler after the write (agy plan-panel R2-Seat1).\n        res.addProperty(\"status\", \"set\");\n        res.addProperty(\"signature\", fn.getSignature().getPrototypeString());\n        res.addProperty(\"previous_signature\", currentProto);\n        return res;\n    }\n\n    // set_local { target, variable, new_type?, new_name?, expected_type?, expected_name? } -> SetLocalResult.\n    // Retype and/or rename ONE decompiler-visible local, keyed on its STORAGE handle (name is a fallback\n    // selector). Mirrors setPrototype: \u{a7}1-6 are PURE READS (a rejected request mutates nothing); the only\n    // mutation is the single tx-guarded updateDBVariable in \u{a7}7. All Ghidra HighFunction APIs Task-0-pinned.\n    private JsonObject setLocal(JsonObject params) throws Exception {\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"no program is open\");\n        String variable = reqStr(params, \"variable\");\n        boolean hasType = params.has(\"new_type\") && !params.get(\"new_type\").isJsonNull();\n        boolean hasName = params.has(\"new_name\") && !params.get(\"new_name\").isJsonNull();\n        String newTypeName = hasType ? params.get(\"new_type\").getAsString() : null;\n        String newName = hasName ? params.get(\"new_name\").getAsString() : null;\n\n        // 1) At least one mutation field (spec \u{a7}4.1).\n        if (!hasType && !hasName)\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"set_local requires at least one of new_type / new_name\");\n        // 2) Reject void-as-variable-type up-front (spec \u{a7}4.1 R2-Seat5): \'void\' name-resolves but is illegal\n        //    as a variable type and only fails deep inside updateDBVariable.\n        if (hasType && \"void\".equalsIgnoreCase(newTypeName))\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"\'void\' is not a valid variable type; give the local a concrete type\");\n        // 3) Reject namespace in new_name (reuse rename\'s rule, \u{a7}4.1).\n        if (hasName && newName.contains(\"::\"))\n            throw new WorkerError(\"INVALID_PARAMS\", \"pass a bare local name (no \'::\')\");\n\n        // 4) Resolve target -> FUNCTION (reuse \u{a7}6 resolver via remap).\n        Function fn = resolveFunction(program, remap(params, \"target\"));\n\n        // 5) Decompile + locate the HighSymbol by STORAGE first (stable), else by NAME. Reuse the cached\n        //    decompiler. hf/getLocalSymbolMap/getSymbols per Task-0 pins.\n        DecompInterface di = decompiler(program);\n        DecompileResults r = di.decompileFunction(fn, 30, monitor);\n        ghidra.program.model.pcode.HighFunction hf = (r != null) ? r.getHighFunction() : null;\n        if (hf == null)\n            throw new WorkerError(\"DECOMPILATION_FAILED\", \"decompiler produced no HighFunction for \" + fn.getName());\n        ghidra.program.model.pcode.HighSymbol match = null;\n        ghidra.program.model.pcode.HighSymbol paramMatch = null; // a PARAMETER whose handle/name the caller passed\n        java.util.List<ghidra.program.model.pcode.HighSymbol> byName = new java.util.ArrayList<>();\n        java.util.List<String> allStorages = new java.util.ArrayList<>();\n        java.util.Iterator<ghidra.program.model.pcode.HighSymbol> it = hf.getLocalSymbolMap().getSymbols();\n        while (it.hasNext()) {\n            ghidra.program.model.pcode.HighSymbol hs = it.next();\n            String sh = storageHandle(hs.getStorage());\n            if (hs.isParameter()) {\n                // Params live in the prototype, not locals[], so they are NOT selectable targets \u{2014} but if the\n                // caller passed a param\'s handle/name, remember it so we can point them at set_prototype below\n                // (spec \u{a7}4.1) instead of the less-helpful VARIABLE_NOT_FOUND. Keep params out of allStorages/byName.\n                if (sh.equals(variable) || hs.getName().equals(variable)) paramMatch = hs;\n                continue;\n            }\n            allStorages.add(sh);\n            if (sh.equals(variable)) { match = hs; break; }       // storage handle wins\n            if (hs.getName().equals(variable)) byName.add(hs);     // name fallback candidate\n        }\n        if (match == null) {\n            if (byName.size() == 1) {\n                match = byName.get(0);\n            } else if (byName.size() > 1) {\n                com.google.gson.JsonArray cands = new com.google.gson.JsonArray();\n                for (ghidra.program.model.pcode.HighSymbol hs : byName) cands.add(storageHandle(hs.getStorage()));\n                throw new AmbiguousError(\"AMBIGUOUS_SYMBOL\",\n                    \"\'\" + variable + \"\' matches \" + byName.size() + \" locals; use a storage handle\", cands);\n            } else if (paramMatch != null) {\n                // Caller targeted a parameter \u{2014} belongs to set_prototype (spec \u{a7}4.1). INVALID_PARAMS, not\n                // VARIABLE_NOT_FOUND (the param IS in the function, just not a set_local target).\n                throw new WorkerError(\"INVALID_PARAMS\",\n                    \"\'\" + variable + \"\' is a parameter; edit it with set_prototype, not set_local\");\n            } else {\n                // VARIABLE_NOT_FOUND: echo the <=5 nearest storages, NOT the whole list (spec \u{a7}4.1 R1-Seat5).\n                JsonObject d = new JsonObject();\n                d.add(\"nearest\", nearestStorages(allStorages, variable, 5));\n                d.addProperty(\"hint\", \"call inspect_function for the full locals[] list\");\n                throw new WorkerError(\"VARIABLE_NOT_FOUND\",\n                    \"no local with storage or name \'\" + variable + \"\' in \" + fn.getName(), d);\n            }\n        }\n\n        // 6) Reject a parameter target (spec \u{a7}4.1) \u{2014} belongs to set_prototype.\n        if (match.isParameter())\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"\'\" + variable + \"\' is a parameter; edit it with set_prototype, not set_local\");\n        // 6b) Q4 storage-class gate (spike M2d Task 0: STACK ONLY for v1; register edits ghost-duplicate the\n        //     decompiler SSA name, unique locals aren\'t exposed, custom-storage is unsafe to edit).\n        if (fn.hasCustomVariableStorage())\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"function \'\" + fn.getName() + \"\' uses custom variable storage; set_local cannot safely edit its locals\");\n        ghidra.program.model.listing.VariableStorage tvs = match.getStorage();\n        if (tvs == null || !tvs.isStackStorage())\n            throw new WorkerError(\"INVALID_PARAMS\",\n                \"set_local v1 edits STACK locals only; \'\" + variable + \"\' is a \"\n                + storageKind(tvs) + \" local (register/unique locals render unreliably after edit)\");\n\n        // 7) Capture current, compute already_applied (EVERY provided field matches) BEFORE the CAS guard\n        //    (respawn-retry precedence, M2b/M2c). previous_* only for a field that actually changed.\n        String curName = match.getName();\n        ghidra.program.model.data.DataType curType = match.getDataType();\n        String curTypeName = curType != null ? curType.getName() : \"undefined\";\n        String storage = storageHandle(match.getStorage());\n\n        // Resolve new type by name -> synthesis fallback (reuse M2c). null means \"unchanged\" (rename-only).\n        ghidra.program.model.data.DataType resolvedType = null;\n        boolean typeChanges = false;\n        if (hasType) {\n            resolvedType = resolveTypeByName(program, newTypeName); // may throw AMBIGUOUS_DATATYPE\n            // synthesis (fn-pointer/anon) happens inside the tx below if name-resolve missed.\n            typeChanges = (resolvedType == null) || !resolvedType.isEquivalent(curType);\n        }\n        boolean nameChanges = hasName && !newName.equals(curName);\n\n        JsonObject res = new JsonObject();\n        res.addProperty(\"function\", fn.getName());\n        res.addProperty(\"storage\", storage);\n        res.addProperty(\"durable\", true);\n\n        // already_applied iff no provided field changes (spec \u{a7}4.1 D-CAS-LOCAL: never short-circuit on a\n        // partial match \u{2014} if type matches but name differs we still apply).\n        boolean typeSatisfied = !hasType || (resolvedType != null && resolvedType.isEquivalent(curType));\n        boolean nameSatisfied = !hasName || newName.equals(curName);\n        if (typeSatisfied && nameSatisfied) {\n            res.addProperty(\"status\", \"already_applied\");\n            res.addProperty(\"name\", curName);\n            res.addProperty(\"type\", curTypeName);\n            return res;\n        }\n        // CAS guards (STATE_CONFLICT) compare caller belief vs current, AFTER already_applied.\n        if (params.has(\"expected_type\") && !params.get(\"expected_type\").getAsString().equals(curTypeName))\n            throw stateConflict(\"type\", params.get(\"expected_type\").getAsString(), curTypeName);\n        if (params.has(\"expected_name\") && !params.get(\"expected_name\").getAsString().equals(curName))\n            throw stateConflict(\"name\", params.get(\"expected_name\").getAsString(), curName);\n\n        // Omitted-field passthrough (spec \u{a7}4.1 R3-Seat3): updateDBVariable needs concrete name AND type;\n        // a null would NPE. Retype-only -> pass current name; rename-only -> pass current DataType.\n        final String applyName = hasName ? newName : curName;\n        final ghidra.program.model.pcode.HighSymbol sym = match;\n        final ghidra.program.model.data.DataType nameResolved = resolvedType;\n        // NPE GUARD (panel R1-Ghidra-seat): a HighSymbol\'s getDataType() can be null (Task 5 coalesces it\n        // for display). updateDBVariable needs a concrete type, so a rename-only edit on a null-typed local\n        // must fall back to the default undefined type, never pass null.\n        final ghidra.program.model.data.DataType curTypeOrDefault =\n                (curType != null) ? curType : ghidra.program.model.data.DataType.DEFAULT;\n        withTransaction(program, \"set_local \" + storage + \" in \" + fn.getName(), () -> {\n            ghidra.program.model.data.DataType applyType = !hasType ? curTypeOrDefault\n                    : (nameResolved != null ? nameResolved : synthesizeType(program, newTypeName));\n            ghidra.program.model.pcode.HighFunctionDBUtil.updateDBVariable(\n                    sym, applyName, applyType, ghidra.program.model.symbol.SourceType.USER_DEFINED);\n        });\n        // Save-dance: end(true)+save. On save failure set_local CANNOT cheaply revert in-memory the way\n        // rename/set_prototype do (they kept the prior symbol/def; the post-updateDBVariable HighSymbol\n        // handle is stale and re-resolving needs a fresh decompile that may itself fail). An un-revertable\n        // dirty in-memory Program is the phantom-durability hazard (M2b \u{a7}3): a later successful edit\'s save()\n        // would flush THIS failed edit to disk. So save-failure is treated as unrecoverable -> halt(70) so\n        // the host respawns a CLEAN JVM from disk (which never received the failed edit).\n        saveOrHaltLocal(program);\n        disposeDecompiler(); // drop the cached AST so the next inspect_function re-decompiles (M2c R2-Seat1).\n\n        // Read back the NEW values from a fresh symbol lookup would require a re-decompile; instead report\n        // the values we applied (type display name via the resolved/curr type).\n        String newTypeDisplay = !hasType ? curTypeName\n                : (nameResolved != null ? nameResolved.getName() : newTypeName);\n        res.addProperty(\"name\", applyName);\n        res.addProperty(\"type\", newTypeDisplay);\n        if (nameChanges) res.addProperty(\"previous_name\", curName);\n        if (typeChanges) res.addProperty(\"previous_type\", curTypeName);\n        res.addProperty(\"status\", (nameChanges && typeChanges) ? \"set\" : (nameChanges ? \"renamed\" : \"retyped\"));\n        return res;\n    }\n\n    // <=N nearest storage strings to `q` by simple edit distance (VARIABLE_NOT_FOUND details; \u{a7}4.1 R1-Seat5).\n    // Guard the edit-distance cost against a pathological `q` (panel R2-Security-seat): a huge `variable`\n    // string would make each editDistance allocate O(|q|) arrays. A real storage handle is < ~32 chars, so a\n    // q longer than that cannot fuzzy-match anything useful \u{2014} skip the distance sort and return the first N.\n    private com.google.gson.JsonArray nearestStorages(java.util.List<String> all, String q, int n) {\n        com.google.gson.JsonArray out = new com.google.gson.JsonArray();\n        if (q.length() <= 64) {\n            all.sort(java.util.Comparator.comparingInt(s -> editDistance(s, q)));\n        } // else: q is not a plausible handle; leave `all` unsorted and just surface the first N.\n        for (int i = 0; i < Math.min(n, all.size()); i++) out.add(all.get(i));\n        return out;\n    }\n\n    private int editDistance(String a, String b) {\n        int[] prev = new int[b.length() + 1];\n        for (int j = 0; j <= b.length(); j++) prev[j] = j;\n        for (int i = 1; i <= a.length(); i++) {\n            int[] cur = new int[b.length() + 1];\n            cur[0] = i;\n            for (int j = 1; j <= b.length(); j++) {\n                int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;\n                cur[j] = Math.min(Math.min(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);\n            }\n            prev = cur;\n        }\n        return prev[b.length()];\n    }\n\n    private WorkerError stateConflict(String field, String expected, String actual) {\n        JsonObject d = new JsonObject();\n        d.addProperty(\"field\", field);\n        d.addProperty(\"expected\", expected);\n        d.addProperty(\"actual\", actual);\n        return new WorkerError(\"STATE_CONFLICT\",\n            \"current \" + field + \" is \'\" + actual + \"\', not \'\" + expected + \"\'\", d);\n    }\n\n    // Save for set_local. Unlike rename/set_prototype (which keep the prior symbol/def and revert-in-memory\n    // on save failure, then report TRANSACTION_FAILED), set_local\'s post-updateDBVariable HighSymbol handle\n    // is stale and cannot be cheaply re-resolved without a fresh decompile. So a save failure leaves an\n    // UN-REVERTABLE dirty in-memory Program \u{2014} the exact phantom-durability hazard (M2b \u{a7}3): if this JVM\n    // stayed alive, a later successful edit\'s save() would flush THIS failed edit to disk. Therefore\n    // save-failure is unrecoverable -> halt(70) so the host respawns a CLEAN JVM from disk (which never\n    // received the failed edit). The agent sees the connection reset and re-reads state (WORKER_RESTARTED).\n    private void saveOrHaltLocal(Program program) throws Exception {\n        try {\n            end(true);\n            program.getDomainFile().save(monitor);\n        } catch (Throwable saveFail) {\n            Runtime.getRuntime().halt(70); // unrecoverable dirty in-memory state; respawn clean from disk\n        }\n        start();\n    }\n\n    // Name-lookup a type via findDataTypes: 0 -> null (caller uses the parse fallback), 1 -> it, >1 ->\n    // AMBIGUOUS_DATATYPE. (Sibling of resolveDataType, which throws DATATYPE_NOT_FOUND on empty; here empty\n    // means \"try to synthesize\".)\n    private ghidra.program.model.data.DataType resolveTypeByName(Program program, String name) {\n        java.util.List<ghidra.program.model.data.DataType> list = new java.util.ArrayList<>();\n        program.getDataTypeManager().findDataTypes(name, list);\n        if (list.isEmpty()) return null;\n        if (list.size() > 1) {\n            com.google.gson.JsonArray cands = new com.google.gson.JsonArray();\n            for (ghidra.program.model.data.DataType d : list) cands.add(d.getPathName());\n            throw new AmbiguousError(\"AMBIGUOUS_DATATYPE\",\n                    name + \" resolves to \" + list.size() + \" data types\", cands);\n        }\n        return list.get(0);\n    }\n\n    // Single-type parse fallback for an un-nameable type (fn-pointer/anon). DataTypeParser is javac-pinned\n    // (agy\'s CParserUtils.parseDataType does not exist). Give the result a DETERMINISTIC name looked up\n    // FIRST so a retry REUSES it via DEFAULT_HANDLER rather than spawning FunctionDef_1/_2 (R2-Seat2). MUST\n    // run inside an open transaction (addDataType mutates the DB).\n    private ghidra.program.model.data.DataType synthesizeType(Program program, String typeStr) {\n        ghidra.program.model.data.DataTypeManager dtm = program.getDataTypeManager();\n        String synthName = \"mcp_synth_\"\n                + Integer.toHexString(typeStr.trim().replaceAll(\"\\\\s+\", \" \").hashCode());\n        java.util.List<ghidra.program.model.data.DataType> existing = new java.util.ArrayList<>();\n        dtm.findDataTypes(synthName, existing);\n        if (!existing.isEmpty()) return existing.get(0);\n        ghidra.program.model.data.DataType parsed;\n        try {\n            ghidra.util.data.DataTypeParser parser = new ghidra.util.data.DataTypeParser(\n                    dtm, dtm, null, ghidra.util.data.DataTypeParser.AllowedDataTypes.ALL);\n            parsed = parser.parse(typeStr);\n        } catch (Exception parseErr) {\n            throw new WorkerError(\"DATATYPE_NOT_FOUND\",\n                    \"no data type named \'\" + typeStr + \"\', and it did not parse as a type: \" + parseErr.getMessage());\n        }\n        if (parsed == null)\n            throw new WorkerError(\"DATATYPE_NOT_FOUND\", \"no data type named \'\" + typeStr + \"\'\");\n        // Name it deterministically where nameable (a Pointer\'s name lives on its base FunctionDefinition;\n        // setName may be a no-op for a pointer \u{2014} DEFAULT_HANDLER still dedups structurally, gated by test 6).\n        try { parsed.setName(synthName); } catch (Exception ignore) { /* pointer/array not directly nameable */ }\n        return dtm.addDataType(parsed, ghidra.program.model.data.DataTypeConflictHandler.DEFAULT_HANDLER);\n    }\n\n    // Build the desired FunctionDefinitionDataType from resolved components (used for the isEquivalent\n    // already_applied check AND for the apply). All setters javac-pinned (spec \u{a7}7 CAS-build).\n    private ghidra.program.model.data.FunctionDefinitionDataType buildDesiredDef(\n            ghidra.program.model.data.DataType retType,\n            ghidra.program.model.data.DataType[] paramTypes,\n            java.util.List<String> paramNames,\n            String callingConvention, boolean variadic) throws Exception {\n        ghidra.program.model.data.FunctionDefinitionDataType def =\n                new ghidra.program.model.data.FunctionDefinitionDataType(\"mcp_set_prototype\");\n        def.setReturnType(retType);\n        ghidra.program.model.data.ParameterDefinition[] pdefs =\n                new ghidra.program.model.data.ParameterDefinition[paramTypes.length];\n        for (int i = 0; i < paramTypes.length; i++) {\n            pdefs[i] = new ghidra.program.model.data.ParameterDefinitionImpl(\n                    paramNames.get(i), paramTypes[i], null); // null name -> auto param_N\n        }\n        def.setArguments(pdefs);\n        def.setVarArgs(variadic);\n        def.setCallingConvention(callingConvention);\n        return def;\n    }\n\n    // Idempotency (D-CAS3): desired isEquivalent current, PLUS explicit cc + varargs equality (defensive \u{2014}\n    // isEquivalent ignores calling convention/varargs) PLUS caller-specified param NAMES (isEquivalent also\n    // ignores param names \u{2014} agy plan-panel R3-Seat1: without this, a rename-only edit param_1->count would\n    // silently return already_applied and drop the rename). Semantic TYPE equivalence (dword==uint) still\n    // comes from isEquivalent. A caller who OMITTED a name (paramNames[i]==null -> auto-name) is a WILDCARD\n    // that never blocks already_applied (so auto-named callers still get correct idempotency).\n    private boolean signatureAlreadyApplied(Function fn,\n            ghidra.program.model.data.FunctionDefinitionDataType desired,\n            java.util.List<String> paramNames, String callingConvention, boolean variadic) {\n        ghidra.program.model.data.FunctionDefinitionDataType current =\n                new ghidra.program.model.data.FunctionDefinitionDataType(fn.getSignature());\n        // Compare signature COMPONENTS explicitly rather than desired.isEquivalent(current): the whole-def\n        // isEquivalent also compares the generic calling convention, but building a FunctionDefinitionDataType\n        // from fn.getSignature() does NOT carry the cc that setCallingConvention() set on `desired`, so an\n        // otherwise-identical re-apply never matched (verified live: idempotency returned \"set\" every time).\n        // Return type + each arg\'s type are the semantic TYPE equivalence (dword==uint via isEquivalent);\n        // calling convention, varargs, and caller-specified param names are checked EXPLICITLY below.\n        ghidra.program.model.data.ParameterDefinition[] cur = current.getArguments();\n        ghidra.program.model.data.ParameterDefinition[] des = desired.getArguments();\n        if (des.length != cur.length) return false;\n        if (!desired.getReturnType().isEquivalent(current.getReturnType())) return false;\n        for (int i = 0; i < des.length; i++) {\n            if (!des[i].getDataType().isEquivalent(cur[i].getDataType())) return false;\n        }\n        String curCc = fn.getCallingConventionName();\n        if (curCc == null || !curCc.equals(callingConvention)) return false;\n        if (fn.hasVarArgs() != variadic) return false;\n        for (int i = 0; i < paramNames.size() && i < cur.length; i++) {\n            String want = paramNames.get(i);\n            if (want != null && !want.equals(cur[i].getName())) return false; // specified name must match\n        }\n        return true;\n    }\n\n    // Optional expected_signature guard -> STATE_CONFLICT (\u{a7}4.2). Comparand is the current DB prototype\n    // string (read it from inspect_function.signature, NOT the decompiled C body \u{2014} R3-Seat5).\n    private void casCheck(JsonObject params, String currentProto, Function fn) {\n        if (params.has(\"expected_signature\") && !params.get(\"expected_signature\").isJsonNull()) {\n            String expected = params.get(\"expected_signature\").getAsString();\n            if (!currentProto.equals(expected)) {\n                JsonObject d = new JsonObject();\n                d.addProperty(\"address\", fn.getEntryPoint().toString());\n                d.addProperty(\"name\", fn.getName());\n                d.addProperty(\"expected\", expected);\n                d.addProperty(\"actual\", currentProto);\n                throw new WorkerError(\"STATE_CONFLICT\",\n                        \"current signature differs from expected_signature\", d);\n            }\n        }\n    }\n\n    // Apply a FunctionDefinitionDataType to a live Function via the javac-pinned updateFunction overload\n    // (P3). Shared by the forward apply and the save-failure revert. updateFunction throws\n    // InvalidInputException/DuplicateNameException -> caught by withTransaction -> INVALID_PARAMS with the\n    // Ghidra message in details.reason (M2a granularity: user-fixable validation is INVALID_PARAMS).\n    private void applyFunctionDefinition(Program program, Function fn,\n            ghidra.program.model.data.FunctionDefinitionDataType def) throws Exception {\n        ghidra.program.model.listing.Variable ret =\n                new ghidra.program.model.listing.ReturnParameterImpl(def.getReturnType(), program);\n        java.util.List<ghidra.program.model.listing.Parameter> ps = new java.util.ArrayList<>();\n        for (ghidra.program.model.data.ParameterDefinition pd : def.getArguments()) {\n            ps.add(new ghidra.program.model.listing.ParameterImpl(pd.getName(), pd.getDataType(), program));\n        }\n        fn.updateFunction(def.getCallingConventionName(), ret, ps,\n                Function.FunctionUpdateType.DYNAMIC_STORAGE_ALL_PARAMS, true,\n                ghidra.program.model.symbol.SourceType.USER_DEFINED);\n        fn.setVarArgs(def.hasVarArgs());\n    }\n\n    // The M2a/M2b save dance (oracle: saveOrRevertDatatype + rename save path). On save failure restore the\n    // PREVIOUS signature via applyFunctionDefinition(prevDef); re-start() before throwing (tx-state\n    // invariant). Save-then-revert double fault -> halt(70) (phantom-durability guard, M2b \u{a7}3). The\n    // save-failure edge is document-and-best-effort (not an e2e), as M2a \u{a7}4.3.\n    private void saveOrRevertPrototype(Program program, Function fn,\n            ghidra.program.model.data.FunctionDefinitionDataType prevDef) throws Exception {\n        try {\n            end(true);\n            program.getDomainFile().save(monitor);\n        } catch (Throwable saveFail) {\n            try {\n                withTransaction(program, \"revert failed set_prototype save\",\n                        () -> applyFunctionDefinition(program, fn, prevDef));\n            } catch (Throwable revertFail) {\n                Runtime.getRuntime().halt(70);\n            }\n            start();\n            throw new WorkerError(\"TRANSACTION_FAILED\", \"edit rolled back (save to disk failed): \" + saveFail);\n        }\n        start();\n    }\n\n    // Resolve a data type name via DataTypeManager.findDataTypes: 0 -> DATATYPE_NOT_FOUND, >1 ->\n    // AMBIGUOUS_DATATYPE (candidates in details), 1 -> that type. (Mirrors M1b get_datatype resolution.)\n    private ghidra.program.model.data.DataType resolveDataType(Program program, String name) {\n        java.util.List<ghidra.program.model.data.DataType> list = new java.util.ArrayList<>();\n        program.getDataTypeManager().findDataTypes(name, list);\n        if (list.isEmpty())\n            throw new WorkerError(\"DATATYPE_NOT_FOUND\", \"no data type named \'\" + name + \"\'\");\n        if (list.size() > 1) {\n            com.google.gson.JsonArray cands = new com.google.gson.JsonArray();\n            for (ghidra.program.model.data.DataType d : list) cands.add(d.getPathName());\n            throw new AmbiguousError(\"AMBIGUOUS_DATATYPE\",\n                    name + \" resolves to \" + list.size() + \" data types\", cands);\n        }\n        return list.get(0);\n    }\n\n    // Compute a CONCRETE length for createData. createData throws IllegalArgumentException for a dynamically-\n    // sized DataType if length <= 0 (panel-F2) \u{2014} so a `string`/dynamic struct (getLength()==-1) must be sized\n    // at the address via a MemBuffer. Fixed types return their static length. The exact Dynamic.getLength /\n    // DumbMemBufferImpl signatures are pinned by the javac step (\u{a7}7 D4); if they differ, use the createData\n    // overload Ghidra provides that sizes the type itself.\n    private int concreteLength(Program program, ghidra.program.model.address.Address addr,\n            ghidra.program.model.data.DataType dt) {\n        int len = dt.getLength();\n        if (len > 0) return len; // fixed-size type\n        // Unwrap typedefs (panel R4-S1): a typedef OF a dynamic type reports getLength()==-1 but is NOT\n        // `instanceof Dynamic` (it implements TypeDef) \u{2014} without this, dynamic typedefs are permanently\n        // rejected. Size from the resolved base; createData still applies the ORIGINAL `dt` (typedef identity\n        // preserved) \u{2014} only the LENGTH is taken from the base.\n        ghidra.program.model.data.DataType base = dt;\n        while (base instanceof ghidra.program.model.data.TypeDef) {\n            base = ((ghidra.program.model.data.TypeDef) base).getBaseDataType();\n        }\n        if (base instanceof ghidra.program.model.data.Dynamic) {\n            ghidra.program.model.mem.MemBuffer buf =\n                    new ghidra.program.model.mem.DumbMemBufferImpl(program.getMemory(), addr);\n            int dl = ((ghidra.program.model.data.Dynamic) base).getLength(buf, -1);\n            if (dl > 0) return dl;\n        }\n        throw new WorkerError(\"INVALID_PARAMS\",\n                \"could not determine a concrete length for dynamic type \'\" + dt.getName() + \"\' at \" + addr);\n    }\n\n    // The M2a save dance (oracle: rename L908-929), factored so both the clear and apply paths reuse it.\n    // On save failure: revert to the captured previous type (or clear if none), re-start() before throwing\n    // (R4 tx-state invariant) so the outer callers\' subsequent requests find a live script tx. `prevLen` is\n    // the CONCRETE instance length captured pre-mutation (panel-F5) \u{2014} a dynamic prevDt\'s getLength()==-1 would\n    // otherwise throw in the revert createData and wedge the worker.\n    private void saveOrRevertDatatype(Program program, ghidra.program.model.address.Address addr,\n            ghidra.program.model.data.DataType prevDt, int prevLen) throws Exception {\n        try {\n            end(true);\n            program.getDomainFile().save(monitor);\n        } catch (Throwable saveFail) {\n            try {\n                withTransaction(program, \"revert failed set_datatype save\", () -> {\n                    ghidra.program.model.listing.Data cur = program.getListing().getDataAt(addr);\n                    ghidra.program.model.address.Address end = cur != null ? cur.getMaxAddress() : addr;\n                    program.getListing().clearCodeUnits(addr, end, false);\n                    if (prevDt != null) {\n                        ghidra.program.model.data.DataUtilities.createData(program, addr, prevDt, prevLen,\n                                ghidra.program.model.data.DataUtilities.ClearDataMode.CLEAR_SINGLE_DATA);\n                    }\n                });\n            } catch (Throwable revertFail) {\n                // Double-fault: dirty in-memory vs disk. Halt so the host respawns a CLEAN JVM from disk\n                // rather than a returned WORKER_UNAVAILABLE leaving this JVM alive to serve dirty state on a\n                // retry (phantom durability). See the comment-method twin (panel R2-F4).\n                Runtime.getRuntime().halt(70);\n            }\n            start();\n            throw new WorkerError(\"TRANSACTION_FAILED\", \"edit rolled back (save to disk failed): \" + saveFail);\n        }\n        start();\n    }\n\n    // rename { target, expected_name, new_name } -> RenameResult (spec \u{a7}3). Renames the PRIMARY symbol\n    // at the resolved address. CAS: already_applied checked FIRST (\u{a7}3.3); auto-save (\u{a7}5); durable=true.\n    private JsonObject rename(JsonObject params) throws Exception {\n        // Operate on the attached program, or the bootstrap currentProgram when nothing is explicitly\n        // attached \u{2014} same effective-program resolution the read tools use. (The e2e drives the bootstrap\n        // program without an explicit attach_program; requiring attach here would wrongly reject it.)\n        Program program = this.attached != null ? this.attached : currentProgram;\n        if (program == null) throw new WorkerError(\"PROGRAM_NOT_OPEN\", \"no program is open\");\n        String target = reqStr(params, \"target\");\n        String expected = reqStr(params, \"expected_name\");\n        String newName = reqStr(params, \"new_name\");\n        if (newName.contains(\"::\"))\n            throw new WorkerError(\"INVALID_PARAMS\", \"namespace moves are not supported in M2a; pass a bare local name\");\n        ghidra.program.model.address.Address addr = resolveTargetAddress(program, target);\n        ghidra.program.model.symbol.Symbol sym = program.getSymbolTable().getPrimarySymbol(addr);\n        if (sym == null) throw new WorkerError(\"SYMBOL_NOT_FOUND\", \"no primary symbol at \" + addr);\n        String current = sym.getName();\n\n        JsonObject res = new JsonObject();\n        res.addProperty(\"address\", addr.toString());\n        res.addProperty(\"durable\", true);\n        if (current.equals(newName)) {                    // already_applied \u{2014} FIRST, no tx/save (\u{a7}3.3)\n            res.addProperty(\"status\", \"already_applied\");\n            res.addProperty(\"name\", newName);\n            return res;\n        }\n        if (!current.equals(expected)) {                  // drift -> RENAME_CONFLICT (\u{a7}4.2)\n            JsonObject d = new JsonObject();\n            d.addProperty(\"address\", addr.toString());\n            d.addProperty(\"expected\", expected);\n            d.addProperty(\"actual\", current);\n            throw new WorkerError(\"RENAME_CONFLICT\", \"current name is \'\" + current + \"\', not \'\" + expected + \"\'\", d);\n        }\n        // CAS matched -> apply in a transaction, then save (\u{a7}3.3/\u{a7}4.3).\n        ghidra.program.model.symbol.SourceType origSource = sym.getSource();\n        withTransaction(program, \"rename \" + current + \" -> \" + newName,\n                () -> program.getSymbolTable().getPrimarySymbol(addr).setName(newName,\n                        ghidra.program.model.symbol.SourceType.USER_DEFINED));\n        // U1 FIX (Task-0 spike): close the persistent script-framework tx so save() can lock, then reopen.\n        // end()/start() (FlatProgramAPI) act on currentProgram, which == `program` here: attach_program\n        // calls openProgram(attached), and with no attach `program` IS currentProgram (the bootstrap).\n        try {\n            end(true);\n            program.getDomainFile().save(monitor);\n        } catch (Throwable saveFail) {\n            // \u{a7}4.3: in-memory ahead of disk. Re-fetch (dynamic-DEFAULT rename can invalidate the handle),\n            // revert with the ORIGINAL source type, then report TRANSACTION_FAILED.\n            try {\n                withTransaction(program, \"revert failed save\", () -> {\n                    ghidra.program.model.symbol.Symbol fresh = program.getSymbolTable().getPrimarySymbol(addr);\n                    fresh.setName(current, origSource);\n                });\n            } catch (Throwable revertFail) {\n                // Double-fault: dirty in-memory vs disk. A returned WORKER_UNAVAILABLE keeps THIS JVM alive\n                // (execute.rs treats a Failure frame as a domain error, not a crash), so a rename retry would\n                // read the dirty in-memory name and falsely report already_applied/durable (phantom durability).\n                // Halt so the host respawns a CLEAN JVM from disk (panel R2-F4, user-approved).\n                Runtime.getRuntime().halt(70);\n            }\n            start();      // reopen the script tx before returning the error\n            throw new WorkerError(\"TRANSACTION_FAILED\", \"edit rolled back (save to disk failed): \" + saveFail);\n        }\n        start();          // reopen the script tx for subsequent requests (happy path)\n        res.addProperty(\"status\", \"renamed\");\n        res.addProperty(\"name\", newName);\n        res.addProperty(\"previous_name\", current);\n        return res;\n    }\n\n    // target = an address string OR a global symbol name (spec \u{a7}3.2). Reuses established resolution.\n    private ghidra.program.model.address.Address resolveTargetAddress(Program program, String target) {\n        ghidra.program.model.address.Address a = program.getAddressFactory().getAddress(target);\n        if (a != null) return a;\n        java.util.List<ghidra.program.model.symbol.Symbol> syms = program.getSymbolTable().getGlobalSymbols(target);\n        if (syms.isEmpty()) throw new WorkerError(\"SYMBOL_NOT_FOUND\", \"no symbol or address: \" + target);\n        if (syms.size() > 1) {\n            com.google.gson.JsonArray cands = new com.google.gson.JsonArray();\n            for (ghidra.program.model.symbol.Symbol s : syms) cands.add(s.getAddress().toString());\n            throw new AmbiguousError(\"AMBIGUOUS_SYMBOL\", target + \" resolves to \" + syms.size() + \" symbols\", cands);\n        }\n        return syms.get(0).getAddress();\n    }\n\n    // Adapt a {target} param to the {function} key resolveFunction expects (L293).\n    private JsonObject remap(JsonObject p, String key) {\n        JsonObject q = new JsonObject();\n        if (p.has(key)) q.add(\"function\", p.get(key));\n        return q;\n    }\n\n    // Build a JSON array of {name,address,external} edges from a set of functions (spec 3.3).\n    private com.google.gson.JsonArray edges(java.util.Set<Function> fns) {\n        com.google.gson.JsonArray arr = new com.google.gson.JsonArray();\n        for (Function f : fns) {\n            JsonObject e = new JsonObject();\n            e.addProperty(\"name\", f.getName());\n            e.addProperty(\"address\", addrString(f.getEntryPoint()));\n            e.addProperty(\"external\", f.isExternal());\n            arr.add(e);\n        }\n        return arr;\n    }\n\n    // Canonical storage handle for a decompiler local: \"stack:-0x<hex>\"/\"stack:0x<hex>\" (SIGNED,\n    // `0x`-prefixed to match spec \u{a7}3 / DESC_SET_LOCAL examples), \"<reg>:<size>\", \"unique:0x<hex>\", else a\n    // stable fallback. The SAME string the set_local resolver matches on (read<->write parity; M2c\n    // getPrototypeString-parity lesson). VariableStorage accessors are Task-0-javac-pinned (M2d spike).\n    private String storageHandle(ghidra.program.model.listing.VariableStorage vs) {\n        if (vs == null) return \"unknown\";\n        if (vs.isStackStorage()) {\n            long off = vs.getStackOffset();\n            return off < 0 ? \"stack:-0x\" + Long.toHexString(-off) : \"stack:0x\" + Long.toHexString(off);\n        }\n        if (vs.isRegisterStorage()) {\n            ghidra.program.model.lang.Register reg = vs.getRegister();\n            return (reg != null ? reg.getName() : \"reg\") + \":\" + vs.size();\n        }\n        if (vs.isUniqueStorage()) return \"unique:0x\" + Long.toHexString(vs.getFirstVarnode().getOffset());\n        return vs.toString(); // other: the pinned canonical form from Task 0\n    }\n\n    // Coarse class for the agent\'s trust model: which handles survive edits. MUST emit the full spec \u{a7}3\n    // domain kind \u{2208} stack|register|unique|other \u{2014} do NOT lump `unique` into `other` (panel R3-Spec-seat:\n    // that violates the LocalEntry wire contract). `isUniqueStorage` is Task-0-javac-pinned.\n    private String storageKind(ghidra.program.model.listing.VariableStorage vs) {\n        if (vs == null) return \"other\";\n        if (vs.isStackStorage()) return \"stack\";\n        if (vs.isRegisterStorage()) return \"register\";\n        if (vs.isUniqueStorage()) return \"unique\";\n        return \"other\";\n    }\n\n    // Build (or return) the DecompInterface bound to the currently-attached program. Cached so a\n    // read-heavy loop of inspect_function calls keeps decompiler warmth instead of re-spinning it.\n    private DecompInterface decompiler(Program program) {\n        if (this.decomp == null) {\n            DecompInterface di = new DecompInterface();\n            di.openProgram(program);\n            this.decomp = di;\n        }\n        return this.decomp;\n    }\n\n    private void disposeDecompiler() {\n        if (this.decomp != null) {\n            this.decomp.closeProgram();\n            this.decomp.dispose();\n            this.decomp = null;\n        }\n    }\n\n    // ---- JSON-RPC envelope helpers ----\n    private String success(JsonObject req, JsonObject result) {\n        JsonObject resp = new JsonObject();\n        resp.addProperty(\"jsonrpc\", \"2.0\");\n        resp.add(\"id\", req.has(\"id\") ? req.get(\"id\") : com.google.gson.JsonNull.INSTANCE);\n        resp.add(\"result\", result);\n        return gson.toJson(resp);\n    }\n\n    private String failure(JsonObject req, String code, String message) {\n        JsonObject err = new JsonObject();\n        err.addProperty(\"code\", code);\n        err.addProperty(\"message\", message);\n        JsonObject resp = new JsonObject();\n        resp.addProperty(\"jsonrpc\", \"2.0\");\n        resp.add(\"id\", req.has(\"id\") ? req.get(\"id\") : com.google.gson.JsonNull.INSTANCE);\n        resp.add(\"error\", err);\n        return gson.toJson(resp);\n    }\n\n    private String failure(JsonObject req, String code, String message, com.google.gson.JsonObject details) {\n        JsonObject err = new JsonObject();\n        err.addProperty(\"code\", code);\n        err.addProperty(\"message\", message);\n        if (details != null) err.add(\"details\", details);\n        JsonObject resp = new JsonObject();\n        resp.addProperty(\"jsonrpc\", \"2.0\");\n        resp.add(\"id\", req.has(\"id\") ? req.get(\"id\") : com.google.gson.JsonNull.INSTANCE);\n        resp.add(\"error\", err);\n        return gson.toJson(resp);\n    }\n\n    private String failureWithCandidates(JsonObject req, String code, String message,\n                                         com.google.gson.JsonArray candidates) {\n        JsonObject err = new JsonObject();\n        err.addProperty(\"code\", code);\n        err.addProperty(\"message\", message);\n        err.add(\"candidates\", candidates);\n        JsonObject resp = new JsonObject();\n        resp.addProperty(\"jsonrpc\", \"2.0\");\n        resp.add(\"id\", req.has(\"id\") ? req.get(\"id\") : com.google.gson.JsonNull.INSTANCE);\n        resp.add(\"error\", err);\n        return gson.toJson(resp);\n    }\n\n    // Parse-error reply for an unparseable frame: id is null (we could not read one). JSON-RPC 2.0 \u{a7}5.\n    private String parseError() {\n        JsonObject err = new JsonObject();\n        err.addProperty(\"code\", \"PARSE_ERROR\");\n        err.addProperty(\"message\", \"malformed JSON frame\");\n        JsonObject resp = new JsonObject();\n        resp.addProperty(\"jsonrpc\", \"2.0\");\n        resp.add(\"id\", com.google.gson.JsonNull.INSTANCE);\n        resp.add(\"error\", err);\n        return gson.toJson(resp);\n    }\n\n    private static class WorkerError extends RuntimeException {\n        final String code;\n        final com.google.gson.JsonObject details; // nullable; surfaced at error.details\n        WorkerError(String code, String message) { this(code, message, null); }\n        WorkerError(String code, String message, com.google.gson.JsonObject details) {\n            super(message);\n            this.code = code;\n            this.details = details;\n        }\n    }\n\n    // Ambiguous name/lookup: carries the candidate list so the server can surface it in the error\'s\n    // details and the agent can re-call with a disambiguating value (spec 6). Generalized (M1b Task 13)\n    // to carry its own `code` \u{2014} most callers still mean AMBIGUOUS_SYMBOL (2-arg ctor delegates to it),\n    // but get_datatype throws AMBIGUOUS_DATATYPE via the 3-arg ctor. dispatch()\'s catch reads ae.code,\n    // not a hardcoded string, so this flows through unchanged.\n    private static final class AmbiguousError extends WorkerError {\n        final com.google.gson.JsonArray candidates;\n        AmbiguousError(String message, com.google.gson.JsonArray candidates) { this(\"AMBIGUOUS_SYMBOL\", message, candidates); }\n        AmbiguousError(String code, String message, com.google.gson.JsonArray candidates) { super(code, message); this.candidates = candidates; }\n    }\n\n    // ---- framing: 4-byte big-endian length prefix + UTF-8 body (matches ghidra-ipc) ----\n    private void writeFrame(OutputStream out, String json) throws Exception {\n        byte[] body = json.getBytes(StandardCharsets.UTF_8);\n        if (body.length > MAX_FRAME_LEN) {\n            throw new IllegalStateException(\"frame exceeds MAX_FRAME_LEN\");\n        }\n        byte[] prefix = new byte[4];\n        prefix[0] = (byte) (body.length >>> 24);\n        prefix[1] = (byte) (body.length >>> 16);\n        prefix[2] = (byte) (body.length >>> 8);\n        prefix[3] = (byte) (body.length);\n        out.write(prefix);\n        out.write(body);\n        out.flush();\n    }\n\n    private byte[] readFrame(DataInputStream in) throws Exception {\n        byte[] prefix = new byte[4];\n        try {\n            in.readFully(prefix);\n        } catch (java.io.EOFException eof) {\n            return null; // clean close\n        }\n        long len = ((long) (prefix[0] & 0xFF) << 24)\n                | ((prefix[1] & 0xFF) << 16)\n                | ((prefix[2] & 0xFF) << 8)\n                | (prefix[3] & 0xFF);\n        if (len > MAX_FRAME_LEN) {\n            throw new IllegalStateException(\"declared frame length exceeds MAX_FRAME_LEN\");\n        }\n        byte[] body = new byte[(int) len];\n        in.readFully(body);\n        return body;\n    }\n}\n";
Expand description

The worker GhidraScript, embedded at compile time (version-locked to this binary).