aboutsummaryrefslogtreecommitdiffstats
path: root/ida-codemode-mcp.patch
diff options
context:
space:
mode:
Diffstat (limited to 'ida-codemode-mcp.patch')
-rw-r--r--ida-codemode-mcp.patch5848
1 files changed, 5848 insertions, 0 deletions
diff --git a/ida-codemode-mcp.patch b/ida-codemode-mcp.patch
new file mode 100644
index 0000000..82a9b3b
--- /dev/null
+++ b/ida-codemode-mcp.patch
@@ -0,0 +1,5848 @@
+From 0db18f870a45c0355ca1b28db1a2e763b60f6226 Mon Sep 17 00:00:00 2001
+From: Duncan Ogilvie <mr.exodia.tpodt@gmail.com>
+Date: Fri, 31 Jul 2026 01:40:21 +0200
+Subject: [PATCH] WIP: vibeslop ida-codemode port
+
+---
+ README.md | 100 +--
+ TODO | 17 +-
+ docs/CODEMODE_PORT.md | 175 +++++
+ docs/PAGING_FINDINGS.md | 54 +-
+ docs/PROJECTS.md | 41 +-
+ docs/SPLIT_VIEW.md | 12 +-
+ experiments/worker_smoke.py | 104 ++-
+ ida-tui | 7 +-
+ idatui/__init__.py | 5 +-
+ idatui/app.py | 206 +++---
+ idatui/codemode_client.py | 1107 +++++++++++++++++++++++++++++
+ idatui/domain.py | 194 +++--
+ idatui/drive.py | 3 +-
+ idatui/errors.py | 10 +-
+ idatui/launch.py | 92 +--
+ idatui/pane.py | 92 +--
+ idatui/pool.py | 100 +--
+ idatui/project.py | 35 +-
+ idatui/worker.py | 233 ------
+ idatui/worker_client.py | 234 -------
+ pyproject.toml | 17 +-
+ server/patch_server.py | 1248 ---------------------------------
+ tests/test_codemode_client.py | 137 ++++
+ tests/test_pool.py | 61 +-
+ tests/test_project.py | 2 +-
+ tests/test_scenarios.py | 18 +-
+ uv.lock | 65 +-
+ 27 files changed, 2055 insertions(+), 2314 deletions(-)
+ create mode 100644 docs/CODEMODE_PORT.md
+ create mode 100644 idatui/codemode_client.py
+ delete mode 100644 idatui/worker.py
+ delete mode 100644 idatui/worker_client.py
+ delete mode 100644 server/patch_server.py
+ create mode 100644 tests/test_codemode_client.py
+
+diff --git a/README.md b/README.md
+index 7d7d8d7..47d5366 100644
+--- a/README.md
++++ b/README.md
+@@ -1,13 +1,13 @@
+ # ida-tui
+
+ A minimal, keyboard-first (mouse-capable) **TUI frontend for IDA Pro**, built with
+-[Textual](https://textual.textualize.io/) and driving **idalib** (IDA headless).
++[Textual](https://textual.textualize.io/) and using
++[ida-codemode-mcp](../ida-codemode-mcp) as a Python library.
+
+-Opening a binary spawns our own **idalib worker** — a private subprocess talking a
+-unix socket (`idatui/worker.py` + `WorkerClient`), ~50–100× cheaper per call than
+-an HTTP transport. It reuses [ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp)'s
+-tool implementations in-process; the old ida-pro-mcp HTTP server/supervisor path
+-has been **removed**.
++ida-tui attaches to databases through `ida_codemode.client.DatabaseHandle`. A
++matching database already open in the IDA GUI is reused; otherwise Code Mode
++reuses or starts a shared managed idalib worker. The TUI owns only a client lease,
++never the GUI or worker process.
+
+ ## ⚠️ Status: not ready for public consumption
+
+@@ -31,7 +31,7 @@ don't file expectations. **Use at your own risk.**
+ - A unified **IDA-style listing** (continuous disassembly interleaved with data /
+ undefined heads) as the default code view; `F5`/`Tab` drops into the
+ **decompiler (pseudocode)** for the function under the cursor. Both are
+- line-virtualized and page lazily over the worker.
++ line-virtualized and page lazily over the Code Mode database.
+ - A **Ghidra-style split view** (`s`): listing and pseudocode side by side, kept
+ in cursor sync — the focused pane drives and the other highlights the linked
+ region (every instruction a C line owns), following you across functions.
+@@ -49,46 +49,58 @@ don't file expectations. **Use at your own risk.**
+
+ ## Architecture (three layers, kept separate)
+
+-- **`idatui/worker.py` + `idatui/worker_client.py`** — the backend. `worker.py`
+- opens one DB with idalib (on its main thread) and serves ida-pro-mcp's tool
+- functions over a unix socket; `WorkerClient` spawns it and is a stdlib-only
+- drop-in client (length-prefixed pickle, calls serialized under a lock). Shared
+- error types + the `Session` model live in `idatui/errors.py`.
+-- **`idatui/domain.py`** — paging/caching over the worker client (`FunctionIndex`,
+- `DisasmModel`, `ListingModel`, `decompile`, xrefs, resolve). Synchronous,
+- thread-safe. Tools ida-pro-mcp lacks (`heads`, `read_raw`, `resolve_names`,
+- `xref_types`, …) are injected by `server/patch_server.py`, which the worker
+- runs itself on startup.
++- **`idatui/codemode_client.py`** — lifecycle and execution adapter. It leases a
++ registered GUI/idalib instance with `DatabaseHandle`, waits for autoanalysis,
++ normalizes errors, saves, and releases the lease. Address-centric operations
++ are sent through Code Mode's `execute_python` surface and use its preloaded
++ `ida-domain` `db` object.
++- **`idatui/domain.py`** — synchronous, thread-safe paging/caching
++ (`FunctionIndex`, `DisasmModel`, `ListingModel`, decompile, xrefs, resolve).
++ It has no process/database ownership logic.
+ - **`idatui/app.py`** — the Textual app (virtualized `ScrollView`s, shared cursor/
+ search/nav mixins, modals).
+
+-The domain + worker-client layers are intentionally **stdlib-only** (the worker
+-process links idalib); only the TUI layer pulls in Textual + Pygments.
++`idatui/pool.py` retains LRU project leases. Releasing an entry never kills a GUI
++or another client's worker. See `docs/CODEMODE_PORT.md` for what maps to public
++ida-domain APIs and which remaining features require IDAPython inside the Code
++Mode execution sandbox.
+
+ ## Requirements
+
+ - Python ≥ 3.11
+-- A working **IDA Pro** with **idalib** and **ida-pro-mcp** installed (the worker
+- reuses ida-pro-mcp's tool implementations in-process — no server runs).
+-- Textual ≥ 8 and Pygments ≥ 2 for the TUI (`pip install -e '.[tui]'`).
++- IDA Pro 9.4+ with idalib configured
++- `ida-codemode-mcp` installed in the TUI environment (this checkout uses the
++ editable sibling path `../ida-codemode-mcp`)
++- The ida-codemode IDA plugin installed so GUI databases register themselves
++- Textual ≥ 8 and Pygments ≥ 2 (`uv sync` installs both)
+
+-Two python environments are expected: one with **textual + idapro** for the TUI
+-(`~/ida-venv`, override `$IDATUI_PYTHON`) and one with **idapro + ida_pro_mcp**
+-for the worker (auto-detected, override `$IDATUI_WORKER_PYTHON`).
++Code Mode's own worker launcher carries the correct Python environment; ida-tui
++no longer searches for a second Python or imports `ida_pro_mcp`.
+
+ ## Running
+
+-One command — it spawns a private idalib worker for the binary (which opens +
+-auto-analyzes it in its own process over a unix socket) and drops you into the
+-TUI behind a loading overlay:
++Install the project and its TUI dependencies:
+
+ ```sh
+-./ida-tui /path/to/binary # open a binary and drive it — that's it
++uv sync
+ ```
+
+-It uses `~/ida-venv/bin/python` for the TUI (override with `$IDATUI_PYTHON`) and
+-resolves binary paths against your real cwd. The binary's directory must be
+-writable (idalib writes a `.i64` there).
++Pass an executable/IDB path. If the plugin has registered a matching GUI session,
++ida-tui attaches to it; otherwise Code Mode opens a managed idalib database:
++
++```sh
++./ida-tui /path/to/binary
++```
++
++With exactly one registered database, the path may be omitted:
++
++```sh
++./ida-tui
++```
++
++When several databases are registered, the launcher lists their paths and asks
++for one explicitly. A newly managed single-binary database still needs a writable
++output location; projects stage binaries and IDBs in their sidecar directory.
+
+ Headerless blobs need to be told what they are — a raw firmware dump has no
+ format to detect, and IDA falls back to x86 at address 0, which analyses to
+@@ -102,15 +114,16 @@ ARM images that use Thumb need one more thing: press `t` on the listing to switc
+ ARM/Thumb decoding at the cursor (it sets IDA's `T` register, and the segment to
+ 32-bit, since Thumb doesn't exist in AArch64).
+
+-`--base` is a real address (IDA's own `-b` is in paragraphs; the conversion is
+-done for you). In a project the options are recorded per binary, which is what a
+-multi-image firmware wants. They apply to the first open only — after that the
+-`.i64` records how the image was loaded. See `docs/PROJECTS.md`.
++`--base` is a real address (Code Mode's typed loading address is also natural,
++so no paragraph conversion crosses the dependency boundary). In a project the
++options are recorded per binary. They apply only when Code Mode must create the
++first database; a registered or existing IDB already records them. Arbitrary
++`--ida-args` are rejected because `DatabaseHandle.open()` has no equivalent;
++processor, base, and loader/file type are the supported import surface.
+
+-> Recovering a wedged database: if a worker was hard-killed it leaves unpacked
+-> `foo.id0/.id1/.id2/.nam/.til` next to `foo.i64`, and the `.i64` then refuses to
+-> reopen. Delete those stale files (never the `.i64`) and retry — `ida-tui` does
+-> this automatically.
++ida-tui never deletes unpacked IDA scratch files during discovery: those files
++may belong to a registered GUI or another Code Mode client. Registry locks and
++health probes are the ownership authority.
+
+ ## Execution traces
+
+@@ -166,8 +179,8 @@ See `docs/RPC.md` for the full protocol.
+
+ ## Tests
+
+-A headless Textual `Pilot` suite lives in `tests/`; it spawns a worker on the
+-given binary (default `targets/echo`):
++A headless Textual `Pilot` suite lives in `tests/`; it attaches through Code Mode
++(or starts a managed worker) for the given binary:
+
+ ```sh
+ python tests/test_scenarios.py targets/echo # full UI suite
+@@ -177,6 +190,7 @@ python tests/test_scenarios.py --only hex,rename
+ ## Docs
+
+ - `docs/RPC.md` — the RPC protocol
+-- `docs/PAGING_FINDINGS.md` — idalib tool paging/scale quirks
++- `docs/CODEMODE_PORT.md` — port coverage, API gaps, and lifecycle semantics
++- `docs/PAGING_FINDINGS.md` — historical paging/scale findings
+ - `docs/TEXTUAL_NOTES.md` — Textual pitfalls encountered
+ - `docs/TUI_DRIVING_BLUEPRINT.md` — generalizing the driving layer
+diff --git a/TODO b/TODO
+index 58b4693..c578cff 100644
+--- a/TODO
++++ b/TODO
+@@ -4,14 +4,15 @@
+ bugs:
+
+ current:
+-[~] DITCH ida-pro-mcp -> our own idalib worker (idatui/worker.py + WorkerClient)
+- [x] worker + WorkerClient (drop-in for IDAClient, same tool shapes)
+- [x] --backend {worker,mcp}; worker is now the DEFAULT for opening a binary
+- [x] worker spawns under the IDA python; {"result":...} wrapping to match MCP
+- [ ] run the pilot suite against --backend worker (blocked: idalib reaping here)
+- [ ] progress reporting during analysis (worker streams notes to the overlay)
+- [ ] once solid: delete client.py, server/patch_server.py, spawn.sh, and
+- launch.py's whole supervisor/ensure_server/lock-sweep dance
++[x] PORT TO ida-codemode-mcp as a library dependency
++ [x] DatabaseHandle discovery prefers registered GUI sessions
++ [x] shared managed idalib workers + SSE lease lifecycle
++ [x] domain operations execute against ida-domain through Code Mode
++ [x] delete the private pickle worker and ida-pro-mcp patch injection
++ [x] stop sweeping/reaping resources that may belong to another client
++ [ ] run the full live Pilot suite against both GUI and managed backends
++ [ ] add database revision/change notifications for cross-client cache invalidation
++ [ ] decide how "discard changes" should work (Code Mode final workers save)
+ [x] RPC endpoint for robot-spectator-ida
+ -> progressssss
+
+diff --git a/docs/CODEMODE_PORT.md b/docs/CODEMODE_PORT.md
+new file mode 100644
+index 0000000..4b4bc45
+--- /dev/null
++++ b/docs/CODEMODE_PORT.md
+@@ -0,0 +1,175 @@
++# ida-tui → IDA Code Mode port
++
++This port is an experiment: can ida-tui be implemented as an ordinary client of
++`ida_codemode`, sharing GUI databases and managed idalib workers instead of
++owning a private worker and depending on ida-pro-mcp tool functions?
++
++## Result
++
++Yes for the database lifecycle and the complete current TUI feature set, with a
++small number of operations implemented using IDAPython inside Code Mode's
++`execute_python` sandbox because ida-domain does not yet expose the required
++behavior.
++
++The old components are gone:
++
++- `idatui/worker.py` (private pickle/socket idalib process)
++- `idatui/worker_client.py`
++- `server/patch_server.py` (ida-pro-mcp tool injection)
++
++The replacement is `idatui/codemode_client.py`.
++
++## Lifecycle mapping
++
++`CodeModeClient.connect()` calls `ida_codemode.client.DatabaseHandle.open()`.
++Resolution is therefore Code Mode's resolution, not ida-tui's:
++
++1. Match a registered GUI by executable path.
++2. Otherwise match the owner of the expected IDB.
++3. Otherwise serialize creation and start a managed `ida-codemode-worker`.
++4. Establish an authenticated SSE lease.
++5. Wait through the public autoanalysis route.
++
++The handle's registry entry supplies the backend, PID, executable path, IDB path,
++and record ID used by the status/pool layers.
++
++Closing ida-tui closes only its lease. It never closes a GUI or kills an idalib
++process. A managed worker saves and exits under Code Mode's own policy after its
++last lease disappears. A second agent or TUI can keep using the same instance.
++
++This also changes project pooling semantics. `DatabasePool` is an LRU pool of
++leases, not process ownership. Managed-IDB save-on-evict remains; budget eviction
++does not implicitly save a GUI. Eviction cannot force a shared worker to exit,
++and GUI process memory is only advisory.
++
++## ida-domain coverage
++
++The remote snippets receive Code Mode's preloaded `db` (`ida_domain.Database`).
++The following TUI needs map to public ida-domain entities:
++
++| TUI need | ida-domain surface |
++|---|---|
++| Function paging, lookup, names, sizes | `db.functions` |
++| Segments and names | `db.segments` |
++| Instructions and plain disassembly | `db.instructions`, `db.functions.get_instructions()` |
++| Heads and item classification | `db.heads`, `db.bytes` |
++| Bytes and strings | `db.bytes`, `db.strings` |
++| Symbol resolution and rename | `db.names`, `db.functions` |
++| Comments | `db.comments` |
++| Imports and exports | `db.imports`, `db.entries` |
++| Xrefs and fine type predicates | `db.xrefs` / `XrefInfo` |
++| Named types, members, parse/apply | `db.types` |
++| Function prototypes and local variables | `db.pseudocode`, `PseudocodeFunction.local_variables` |
++| Decompilation text and object references | `db.pseudocode` |
++
++All values are reduced to JSON primitives inside the database process. No SWIG
++or ida-domain object crosses the Code Mode boundary.
++
++## Remaining IDAPython gaps
++
++Code Mode intentionally allows regular Python imports, so these features still
++work, but they identify useful additions to ida-domain:
++
++1. **Rich continuous listing**
++ - ida-domain enumerates defined heads and renders plain disassembly.
++ - ida-tui also needs coalesced undefined runs, IDA colour-tag spans, function
++ banners, code-label rows, file-region offsets, and expanded struct members.
++ - The `heads` operation uses `ida_bytes`, `ida_lines`, and related modules for
++ this presentation model.
++
++2. **Instruction/function carving**
++ - Creating an instruction and walking a speculative decode run requires
++ `ida_ua.create_insn` and processor flow/return checks.
++ - Function creation exists in ida-domain; the explicit-end fallback still
++ needs lower-level item boundaries.
++
++3. **ARM/Thumb state**
++ - T-register ranges and segment addressing use `ida_segregs`, `ida_idp`, and
++ `ida_segment`. There is no equivalent ida-domain operation.
++
++4. **Detailed decompiler diagnostics and line maps**
++ - Pseudocode text, ctree objects, and the address map are available through
++ ida-domain.
++ - Reproducing IDA's per-rendered-line coverage uses
++ `cfunc.get_line_item`; obtaining the exact Hex-Rays failure description
++ uses `hexrays_failure_t`.
++
++5. **A few type/item primitives**
++ - Deleting a named local type and some exact item-undefinition/data-creation
++ behavior still use `ida_typeinf`/`ida_bytes` directly.
++
++These uses are isolated in `idatui/codemode_client.py`; the paging and Textual
++layers do not import IDAPython.
++
++## API limitations exposed by the port
++
++### No rollback or close-without-save
++
++A Code Mode lease has no rollback operation. Closing a GUI handle leaves the GUI
++state as-is. A managed idalib worker currently saves when its final lease closes.
++Consequently ida-tui's old “discard & quit” guarantee cannot be implemented.
++The UI now labels this choice “leave as-is & quit” and does not explicitly save,
++but managed-worker policy may still persist the changes.
++
++A true discard action would need a Code Mode/database API for transaction-like
++rollback, a close policy on a newly-owned worker, or a TUI-managed disposable DB
++copy.
++
++### Typed loader options only
++
++`DatabaseHandle.open()` supports processor, natural loading address, file type,
++output database, and fresh-database selection. It does not support ida-tui's
++arbitrary `ida_args` escape hatch. The adapter rejects unsupported switches
++rather than silently loading at the wrong architecture/base.
++
++### No database-change notification stream
++
++The lease reports liveness, not mutations. If a GUI user or another Code Mode
++client renames/retypes content while ida-tui is open, already-materialized TUI
++caches are not invalidated automatically. TUI-originated edits invalidate their
++own caches correctly. A database revision counter or change feed would make
++shared interactive editing robust.
++
++### Discovery requires a path for ambiguity
++
++`ida-tui` with no path attaches automatically when exactly one database is
++registered. With several registrations it lists them and requires an explicit
++executable/IDB path. There is not yet a pre-connection database picker in the
++Textual UI.
++
++### `DatabaseHandle` import stability
++
++The usable library primitive currently lives at
++`ida_codemode.client.DatabaseHandle`; `ida_codemode.__init__` exports nothing.
++The port therefore depends on a submodule path. Exporting the handle and public
++client exceptions from the package root would make the supported library API
++clearer.
++
++## Safety differences
++
++ida-tui no longer removes `.id0/.id1/.id2/.nam/.til` files before opening. That
++was only defensible when the TUI exclusively owned a private process; it is
++unsafe when a GUI or another client may own the database. Code Mode registry
++locks, health probes, and IDA itself now arbitrate ownership.
++
++The old pane “reap private workers” behavior is obsolete. A TUI crash closes its
++lease at the socket/kernel boundary; Code Mode decides whether a managed worker
++still has clients and when it should stop.
++
++## Verification surfaces
++
++The non-IDA suite verifies project staging, LRU lease behavior, load-option
++translation, and adapter response/error normalization. The existing live suites
++remain the end-to-end contract:
++
++```sh
++uv run python tests/test_codemode_client.py
++uv run python tests/test_pool.py
++uv run python tests/test_project.py
++uv run python tests/test_scenarios.py /path/to/binary
++```
++
++For GUI reuse, open the same binary in an IDA with the Code Mode plugin, confirm
++it appears in `ida_codemode.registry.discover_instances()`, then launch
++`ida-tui /path/to/binary`. The TUI status/`CodeModeClient.backend` should report
++`gui`, and closing the TUI must leave IDA open.
+diff --git a/docs/PAGING_FINDINGS.md b/docs/PAGING_FINDINGS.md
+index bcde583..bd6c38f 100644
+--- a/docs/PAGING_FINDINGS.md
++++ b/docs/PAGING_FINDINGS.md
+@@ -2,10 +2,10 @@
+
+ Measured against a real target: `libcrypto.so.3` (5.7 MB, **10,092 functions**,
+ biggest function **52,120 instructions**). These constraints drive the domain /
+-paging layer. They describe the ida-pro-mcp *tool functions* (`list_funcs`,
+-`disasm`, `decompile`, `xref_query`, …) which the idalib worker now calls
+-in-process (`idatui/worker.py`) — the shapes and caps below are the tools'
+-behaviour and are unchanged by dropping the HTTP transport.
++paging layer. The measurements below came from the former ida-pro-mcp tool
++backend. The Code Mode port preserves the adapter response shapes and conservative
++page sizes, but executes enumeration through ida-domain; old server caps and RTT
++numbers are historical rather than Code Mode constraints.
+
+ ## Response shape (list_* / *_query tools)
+
+@@ -93,32 +93,26 @@ disasm totals are **top-level** fields, not under `asm`:
+ (correct). The pseudocode view must handle "decompilation failed" gracefully —
+ fall back to the disassembly view or show an error panel.
+
+-Normal decompile bodies are server-truncated with a `[N chars total]` marker
+-(still to be solved for full-body display — see Phase 2).
+-
+-## Worker lifecycle (idatui's own idalib worker)
+-
+-idatui no longer uses ida-pro-mcp's shared HTTP supervisor. `idatui/worker.py`
+-opens exactly **one** database with `idapro.open_database(...)` in its own process
+-and serves tool calls over a unix socket (`WorkerClient`). Consequences vs the
+-old supervisor model, which several design choices here were built around:
+-
+-* **No `max_workers` cap, no cross-session contention.** Each TUI owns its
+- worker; there is no "Maximum idalib worker count reached" and no shared license
+- slot to free.
+-* **No idle self-exit / keepalive dance.** The old per-worker `WorkerLifecycle`
+- watchdog (`idle_ttl_sec`, default 600s) and the `KeepAlive` heartbeat that
+- fought it are gone with the supervisor. The worker lives as long as the TUI
+- holds the socket and dies with it. `WorkerClient.keepalive()` is a no-op kept
+- for API parity, and `--ttl` is passed through but the single owned worker does
+- not self-reap.
+-* **A crashed worker drops the socket**, surfacing as `IDAConnectionError`; the
+- app's `_reconnect` respawns a fresh worker (re-opening + re-analyzing the
+- binary). The hard-kill lock recovery below still applies.
++Code Mode returns the complete execution result directly; ida-tui no longer
++needs MCP structured-content/download-URL recovery for large pseudocode bodies.
++
++## Code Mode lifecycle
++
++`CodeModeClient` owns an authenticated SSE lease on a registered database:
++
++* A matching GUI is preferred and remains open when the TUI exits.
++* Otherwise Code Mode reuses or starts a shared managed idalib worker.
++* Releasing one lease never terminates another client's session. A managed
++ worker saves and exits after its final lease under Code Mode's grace policy.
++* Lease loss surfaces as `IDAConnectionError`; reconnect performs discovery
++ again and may bind a newly-created instance. It does not silently swap the
++ handle underneath an operation.
++* `--ttl` and the old keepalive flag are compatibility no-ops; the lease itself
++ carries heartbeats.
+
+ ## Writable path requirement (operational)
+
+-`idb_open` writes the `.i64` next to the input binary, so the path must be
+-**writable**. Opening from read-only dirs (e.g. `/usr/lib`) fails with
+-`"Failed to open database"`. Copy targets into a writable dir first
+-(`targets/` in this repo).
++Attaching to a registered GUI does not require ida-tui to write beside the input.
++Creating a managed database does require a writable output path. Multi-binary
++projects provide one in their sidecar. ida-tui does not sweep IDA scratch files,
++because another registered session may own them.
+diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md
+index fe6c2a8..0efee31 100644
+--- a/docs/PROJECTS.md
++++ b/docs/PROJECTS.md
+@@ -7,9 +7,11 @@ search across all of them, and (later) follow calls from one into another.
+
+ ## The constraint that shapes everything
+
+-`idatui/worker.py` is `serve(sock, binpath)` — **one worker process holds exactly
+-one database** (idalib is main-thread-only and single-DB). So N binaries = N
+-worker processes, each with the analyzed DB resident.
++IDA still exposes one active database per GUI/idalib process. Code Mode makes
++those instances discoverable and shareable: each project entry retains one
++`DatabaseHandle` lease, which may target a registered GUI or a managed idalib
++worker. N resident project databases can therefore mean up to N processes, but
++ida-tui no longer owns or terminates them.
+
+ Measured cost (this box, `targets/`):
+
+@@ -30,13 +32,13 @@ crypto library.
+
+ Two capabilities that feel like one, but aren't:
+
+-1. **Switching** to a binary needs a *live worker*.
++1. **Switching** to a binary needs a *live Code Mode lease*.
+ 2. **Searching across** binaries does *not* — if a per-binary index (functions,
+ strings, imports/exports) is cached on disk.
+
+ That split is the unlock: project-wide search stays instant across every binary,
+ including ones never opened this session, and only *jumping* to a hit costs a
+-worker spawn.
++Code Mode attach/open.
+
+ ## Layout
+
+@@ -83,17 +85,16 @@ basename and must be unique (it names the staged file).
+
+ ## Runtime
+
+-- **`WorkerPool`** — one `WorkerClient` per binary, spawned lazily on first
+- switch, kept resident until the memory budget is exceeded, then LRU-evicted.
+- Eviction **saves the DB first**, so returning to a binary is a DB load, not a
+- re-analysis. Binaries can be pinned to stay resident.
++- **`DatabasePool`** — one `CodeModeClient` lease per resident binary, attached
++ lazily on first switch and LRU-released when the advisory memory budget is
++ exceeded. Eviction explicitly saves managed IDBs but never implicitly saves a
++ GUI. Closing a lease never kills a GUI or another client's managed worker;
++ Code Mode owns final worker shutdown.
+ - **`BinaryState`** — per binary: `client, program, nav, cur, func_index,
+ pref/active/split, filter`. Switching snapshots the current state and restores
+- the target's. `_after_reconnect` already does exactly this swap (client +
+- program, reload the index, re-open the entry) — switching reuses that seam.
+-- **Clean shutdown** — the worker currently does `close_database(save=False)` and
+- is hard-killed on exit, which is why wedge files accumulate. Projects need
+- save-on-evict and an orderly close anyway, so that gets fixed here.
++ the target's. `_after_reconnect` provides the client/program swap seam.
++- **Clean shutdown** — release all leases. Managed idalib workers save/close on
++ their own main thread after the final lease; GUI sessions remain open.
+
+ ## UI
+
+@@ -109,8 +110,8 @@ basename and must be unique (it names the staged file).
+ ## Phases
+
+ **Phase 1 — project model + switching. DONE.** Project file + staging
+-(`idatui/project.py`), `WorkerPool` with budget eviction / save-on-evict /
+-clean shutdown (`idatui/pool.py`), `BinaryState` snapshot+restore and the switch
++(`idatui/project.py`), `DatabasePool` with budgeted lease release and
++save-on-evict (`idatui/pool.py`), `BinaryState` snapshot+restore and the switch
+ itself, the `Ctrl+O` switcher palette, the active binary in the status line, and
+ `--project` (which creates the project when given binaries). One active binary;
+ no cross-binary search yet.
+@@ -118,9 +119,9 @@ no cross-binary search yet.
+ Project mode is **additive**: with no `--project` the app is byte-for-byte the
+ single-binary tool it was, which is what keeps the 167-check pilot honest.
+ Switching reuses the `_after_reconnect` shape — swap client+program, rebuild the
+-index, reopen the entry. A binary whose worker is still resident restores
+-instantly (its `Program` and index are still in memory); an evicted one comes
+-back with a fresh worker but keeps its nav history, since that is just addresses.
++index, reopen the entry. A binary whose lease is still resident restores
++instantly (its `Program` and index are still in memory); an evicted one attaches
++again but keeps its nav history, since that is just addresses.
+
+ **Phase 2 — index cache + project-wide search. (symbols done)**
+ `idatui/index.py` keeps one **SQLite FTS5 trigram** index at
+@@ -222,7 +223,7 @@ records nothing — that's not navigation.
+ *Pre-warm follows the linkage graph, not list order.* When a binary finishes
+ indexing, `_prewarm_provider` warms the binary that provides the most of its
+ imports — where a follow is most likely to take you, so its startup is paid
+-before you ask. `WorkerPool.prewarm()` refuses rather than evicting: spending a
++before you ask. `DatabasePool.prewarm()` refuses rather than evicting: spending a
+ binary you visited on one you haven't is a straight downgrade, and it would throw
+ away that binary's caches too. At a tight budget pre-warm simply does nothing. It
+ estimates the cost of a not-yet-spawned worker from the largest resident one,
+diff --git a/docs/SPLIT_VIEW.md b/docs/SPLIT_VIEW.md
+index de37dc1..c421656 100644
+--- a/docs/SPLIT_VIEW.md
++++ b/docs/SPLIT_VIEW.md
+@@ -29,11 +29,11 @@ Ghidra highlights **all** instructions a C line owns. We have one ea per line
+ (the marker), not the set. Getting the set is the only real work, and it's a
+ known technique:
+
+-ida-pro-mcp derives the per-line marker via
++The old ida-pro-mcp backend derived the per-line marker via
+ `cfunc.get_line_item(line, col=0, …).get_ea()`. To get the **full set**, sweep
+ every column of the line (`get_line_item(line, x, …).get_ea()` for `x` in
+-`0..len`) and collect distinct non-`BADADDR` EAs. Same proven API, swept across
+-the line. A custom `decomp_map(ea)` tool in `server/patch_server.py` returns
++`0..len`) and collect distinct non-`BADADDR` EAs. The Code Mode adapter's
++`decomp_map(ea)` operation returns
+ `[{line, primary_ea, eas:[…]}, …]`; invert for `ea → line`.
+
+ ## State model
+@@ -78,14 +78,14 @@ decomp→listing uses `ListingModel.ensure_ea`. Tab re-links from the new driver
+ Still single-ea per line (one instruction highlighted); the region comes in
+ phase 3.
+
+-**Phase 3 — rich highlight. DONE.** `decomp_map` custom tool
+-(`server/patch_server.py`) sweeps `cfunc.get_line_item` across every column of
++**Phase 3 — rich highlight. DONE.** The Code Mode `decomp_map` operation
++(`idatui/codemode_client.py`) sweeps `cfunc.get_line_item` across every column of
+ each pseudocode line and collects the EAs from each item's `dstr()` (`'EA: desc'`
+ — the same source as the `/*ea*/` marker, so it aligns). `Program.decomp_map(ea)`
+ returns the per-line ea lists (cached by name-gen); the app loads it async into
+ `_split_eamap` / `_split_ea2line` and `_sync_split` bands the **whole** instruction
+ region of a C line (and uses the exact ea→line inverse for the reverse). Falls
+-back to the single marker until the map lands. Verified on the pilot's real worker
++back to the single marker until the map lands. Verified on a real Code Mode database
+ (alignment + multi-instruction region band).
+
+ **Phase 4 — polish. DONE.**
+diff --git a/experiments/worker_smoke.py b/experiments/worker_smoke.py
+index b215a57..9b55138 100644
+--- a/experiments/worker_smoke.py
++++ b/experiments/worker_smoke.py
+@@ -1,58 +1,56 @@
+-"""Runnable read-path smoke: drives the REAL domain.Program through WorkerClient
+-(our idalib worker over a unix socket). Run when idalib can spawn:
+- ~/ida-venv/bin/python experiments/worker_smoke.py
+-"""
+-import os, sys, shutil, time
+-REPO=os.path.expanduser("~/dev/ida-tui-maybe"); sys.path.insert(0, REPO); os.chdir(REPO)
+-# fresh copy so the worker's idalib doesn't fight any running server
+-src=f"{REPO}/targets/echo"; tmp="/tmp/echo_worker"
+-shutil.copy(src, tmp)
+-for e in ".i64 .id0 .id1 .id2 .nam .til".split():
+- try: os.remove(tmp+e)
+- except OSError: pass
+-
+-from idatui.worker_client import WorkerClient
+-from idatui.domain import Program
+-
+-print("spawning worker + opening echo…", flush=True)
+-t=time.time()
+-cl=WorkerClient(tmp)
+-cl.connect(progress=lambda m: None)
+-print(f" worker ready in {time.time()-t:.2f}s session={cl.resolve_db()}", flush=True)
+-prog=Program(cl)
+-
+-# --- drive the REAL domain layer through the worker (read path) ---
+-main=prog.resolve("main")
+-print("resolve('main') =", hex(main), flush=True)
++"""Exercise the real domain.Program through an IDA Code Mode lease.
+
+-idx=prog.functions(); idx.load_all()
+-print("functions() ->", len(idx), "funcs", flush=True)
+-
+-fn=prog.function_of(main)
+-print("function_of(main) ->", fn.name, hex(fn.addr), "size", fn.size, flush=True)
+-
+-b=prog.read_bytes(main, 16)
+-print("read_bytes(main,16) ->", b.hex(), flush=True)
+-
+-lm=prog.listing(main)
+-for _ in range(3): lm.load_next_page()
+-rows=[lm.get(i) for i in range(min(6,len(lm)))]
+-print("listing() first rows:", flush=True)
+-for h in rows:
+- if h: print(" ", hex(h.ea), h.kind, repr(h.text[:44]), flush=True)
++A matching registered GUI is reused; otherwise Code Mode starts a managed
++idalib worker. Usage: ``uv run python experiments/worker_smoke.py FILE``.
++"""
++from __future__ import annotations
+
+-d=prog.decompile(main)
+-print("decompile(main) -> failed?", d.failed, "lines:", len((d.code or '').splitlines()), flush=True)
++import os
++import sys
++import time
+
+-regs=prog.file_regions()
+-print("file_regions ->", len(regs), "segments", flush=True)
++from idatui.codemode_client import CodeModeClient
++from idatui.domain import Program
+
+-# xrefs to a called function
+-callee=next((f.addr for f in idx.all_loaded() if f.name.startswith("sub_")), None)
+-if callee:
+- xr=prog.xrefs_to(callee)
+- print("xrefs_to(", hex(callee), ") ->", len(xr), "refs", flush=True)
+
+-ok = (fn.name=="main" and len(idx)>100 and b and not d.failed and len(regs)>0)
+-print("VERDICT:", "OK — domain.Program runs unchanged on the worker" if ok else "FAIL", flush=True)
+-cl.close()
++def main() -> int:
++ target = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "experiments/fibonacci.elf")
++ print(f"attaching Code Mode to {target}…", flush=True)
++ started = time.time()
++ client = CodeModeClient(target)
++ client.connect(progress=lambda message: print(f" {message}", flush=True))
++ print(
++ f" ready in {time.time() - started:.2f}s; backend={client.backend}; "
++ f"session={client.resolve_db()}",
++ flush=True,
++ )
++ program = Program(client)
++ try:
++ index = program.functions()
++ index.load_all()
++ print(f"functions() -> {len(index)}", flush=True)
++ first = index.get(0)
++ if first is None:
++ print("VERDICT: FAIL — no functions", flush=True)
++ return 1
++ fn = program.function_of(first.addr)
++ data = program.read_bytes(first.addr, 16)
++ decompilation = program.decompile(first.addr)
++ print(f"function_of() -> {fn}", flush=True)
++ print(f"read_bytes() -> {data.hex()}", flush=True)
++ print(
++ f"decompile() -> failed={decompilation.failed}; "
++ f"lines={len((decompilation.code or '').splitlines())}",
++ flush=True,
++ )
++ print(f"file_regions() -> {len(program.file_regions())}", flush=True)
++ ok = fn is not None and bool(data) and bool(program.file_regions())
++ print(f"VERDICT: {'OK' if ok else 'FAIL'}", flush=True)
++ return 0 if ok else 1
++ finally:
++ program.close()
++ client.close()
++
++
++if __name__ == "__main__":
++ raise SystemExit(main())
+diff --git a/ida-tui b/ida-tui
+index 9ceb949..a488cac 100755
+--- a/ida-tui
++++ b/ida-tui
+@@ -3,10 +3,9 @@
+ #
+ # ./ida-tui foo.elf # open a binary and drive it — that's it
+ #
+-# Opening a binary spins up our own idalib worker (a unix-socket subprocess;
+-# no HTTP, no supervisor). Uses the venv python that has textual (override with
+-# $IDATUI_PYTHON); the worker auto-picks the python that has ida_pro_mcp
+-# (override with $IDATUI_WORKER_PYTHON).
++# The launcher leases a registered IDA GUI or shared managed idalib worker
++# through ida_codemode. The selected Python must have ida-tui's dependencies;
++# override it with $IDATUI_PYTHON.
+ set -eu
+
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+diff --git a/idatui/__init__.py b/idatui/__init__.py
+index 2cbdde8..7da28b3 100644
+--- a/idatui/__init__.py
++++ b/idatui/__init__.py
+@@ -1,5 +1,4 @@
+-"""idatui — a minimal keyboard-first TUI for IDA Pro, driving idalib via a
+-private unix-socket worker (idatui.worker / WorkerClient)."""
++"""idatui — a keyboard-first TUI using shared IDA Code Mode databases."""
+
+ from .errors import (
+ IDAError,
+@@ -11,6 +10,7 @@ from .errors import (
+ IDASessionError,
+ Session,
+ )
++from .codemode_client import CodeModeClient
+ from .domain import (
+ Program,
+ FunctionIndex,
+@@ -25,6 +25,7 @@ from .domain import (
+ )
+
+ __all__ = [
++ "CodeModeClient",
+ "Program",
+ "FunctionIndex",
+ "DisasmModel",
+diff --git a/idatui/app.py b/idatui/app.py
+index 1edc65e..984140f 100644
+--- a/idatui/app.py
++++ b/idatui/app.py
+@@ -10,8 +10,8 @@ Design notes:
+ without ever materializing 52k lines in a widget.
+ * All network/domain work runs in Textual worker threads; the UI never blocks.
+ * An address-history stack backs Enter (follow) / Esc (back), IDA-style.
+-* On startup we bump the worker idle-TTL and run a keepalive heartbeat so the
+- session never gets reaped while we chill.
++* Database lifecycle is lease-based through ida_codemode: matching GUI sessions
++ are reused, otherwise a shared managed idalib worker is opened on demand.
+ """
+
+ from __future__ import annotations
+@@ -30,7 +30,7 @@ from textual import work
+ from textual.app import App, ComposeResult
+ from textual.binding import Binding
+ from textual.command import DiscoveryHit, Hit, Provider
+-from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
++from textual.containers import Horizontal, Vertical, VerticalScroll
+ from textual.geometry import Region, Size
+ from textual.message import Message
+ from textual.reactive import reactive
+@@ -46,8 +46,8 @@ from textual.widgets.option_list import Option
+ from .highlight import highlight_c
+
+ from .errors import IDAToolError, IDAConnectionError
+-from .worker_client import WorkerClient
+-from .domain import DisasmModel, Func, Head, ListingModel, Program, Struct
++from .codemode_client import CodeModeClient, registered_database
++from .domain import Func, Head, ListingModel, Program, Struct
+
+ # Styles for the disassembly listing.
+ _S_ADDR = Style(color="#6b7684")
+@@ -126,8 +126,8 @@ _ADDR_MARK_STRIP_RE = re.compile(r"\s*/\*\s*0x[0-9A-Fa-f]+\s*\*/")
+ @dataclass
+ class BinaryState:
+ """Everything that makes one project binary's session resumable across a
+- switch. Addresses outlive the worker, so nav history survives eviction; the
+- Program/index only survive while that worker is still resident."""
++ switch. Addresses outlive a database lease, so nav history survives eviction;
++ the Program/index only survive while that lease remains resident."""
+
+ label: str
+ program: object | None = None
+@@ -812,9 +812,9 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
+ def _span_segments(h: Head, fallback: Style):
+ """Segments for a row's disassembly text.
+
+- Uses IDA's own token classification when the worker supplied it; falls
+- back to the old mnemonic/rest split so an older worker (or a row whose
+- spans didn't match the text) still renders.
++ Uses IDA's own token classification when Code Mode supplies it; falls
++ back to the mnemonic/rest split when spans are absent or disagree with
++ the plain text.
+ """
+ if h.spans:
+ return [Segment(t, _S_SPAN.get(k, fallback)) for k, t in h.spans]
+@@ -2228,12 +2228,16 @@ _HELP = (
+
+
+ class QuitScreen(ModalScreen):
+- """Asked before exiting with unsaved database changes. Dismisses with
+- "save", "discard" or None (stay)."""
++ """Asked before exiting with unsaved database changes.
++
++ Code Mode clients cannot roll a shared database back. The ``d`` choice means
++ "do not explicitly save": a GUI keeps the changes dirty, while a managed
++ idalib worker may persist them when its final lease closes.
++ """
+
+ BINDINGS = [
+ Binding("s", "save", "Save & quit"),
+- Binding("d", "discard", "Discard & quit"),
++ Binding("d", "discard", "Leave & quit"),
+ Binding("escape,c", "cancel", "Cancel"),
+ ]
+
+@@ -2250,7 +2254,7 @@ class QuitScreen(ModalScreen):
+ for label in self._labels:
+ body.append(f" \u2022 {label}\n", _S_LABEL)
+ yield Static(body, id="quit-list")
+- yield Static("s save & quit d discard & quit Esc cancel",
++ yield Static("s save & quit d leave as-is & quit Esc cancel",
+ id="quit-help")
+
+ def action_save(self) -> None:
+@@ -3392,15 +3396,16 @@ class IdaTui(App):
+ self._index = None # project-wide symbol/string index
+ if project is not None:
+ from .index import ProjectIndex
+- from .pool import WorkerPool
+- self._pool = WorkerPool(project, ttl=ttl)
++ from .pool import DatabasePool
++ self._pool = DatabasePool(project, ttl=ttl)
+ self._index = ProjectIndex(
+ os.path.join(project.index_dir, "project.db"))
+ self._binary = project.refs[0].label
+ open_path = project.refs[0].staged
+ self._open_path = open_path
+ self._ttl = ttl
+- self._load_args = load_args or "" # IDA switches for a headerless blob
++ self._load_args = load_args or "" # first-open options for a headerless blob
++ self._new_database = False # Ctrl+L asks Code Mode for a fresh IDB
+ self._title = (os.path.basename(open_path) if open_path else "")
+ self._trace_path = trace_path or "" # Tenet execution trace to explore
+ self._trace = None # the loaded Trace, once analysed
+@@ -3414,7 +3419,7 @@ class IdaTui(App):
+ self._do_keepalive = keepalive
+ self._rpc_path = rpc_path
+ self._rpc = None
+- self.client: WorkerClient | None = None
++ self.client: CodeModeClient | None = None
+ self.program: Program | None = None
+ self._loading_screen: LoadingScreen | None = None
+ self._ka = None
+@@ -3512,8 +3517,8 @@ class IdaTui(App):
+ if self._rpc_path:
+ self._start_rpc()
+ # A file no loader recognises has to be described before it can be
+- # opened, so ask BEFORE the worker starts — once IDA has made a database
+- # the answer is baked in and changing it means deleting the .i64.
++ # opened, so ask BEFORE Code Mode creates it — once IDA has made a database
++ # the answer is baked in and changing it requires a fresh-IDB reopen.
+ if self._project is not None:
+ ref = self._pending_load_ref()
+ if ref is not None:
+@@ -3544,6 +3549,11 @@ class IdaTui(App):
+ if os.path.exists(self._open_path + ".i64") or os.path.exists(
+ os.path.splitext(self._open_path)[0] + ".i64"):
+ return False
++ try:
++ if registered_database(self._open_path):
++ return False
++ except Exception:
++ pass # connect() will surface registry failures with full diagnostics
+ return needs_load_options(self._open_path)
+
+ def action_load_options(self) -> None:
+@@ -3557,7 +3567,11 @@ class IdaTui(App):
+ forward.
+ """
+ if not self._can_reload():
+- self._status("nothing to reload")
++ if self.client is not None and self.client.backend == "gui":
++ self._status(
++ "reload unavailable for a GUI-owned database — reopen it in IDA")
++ else:
++ self._status("nothing to reload")
+ return
+ n = len(self._func_index) if self._func_index else 0
+ note = ("this image has no functions, so nothing is lost"
+@@ -3576,10 +3590,13 @@ class IdaTui(App):
+ ref = self._project.by_label(self._binary)
+ if ref is not None:
+ path, label = ref.source, ref.label
+- # Drop the worker first: it holds the database open, and the .i64 can't
+- # be removed (or rebuilt) underneath a live one.
+- self._release_worker()
+- self._drop_database()
++ # Release our lease first. Code Mode waits for a managed worker's final
++ # lease grace, then creates the replacement IDB atomically. A GUI-backed
++ # database is rejected by _can_reload(): the TUI must never close it.
++ self._release_database()
++ self._new_database = True
++ if label is not None and self._pool is not None:
++ self._pool.recreate_on_next_open(label)
+ self._reset_for_reload()
+ self._load_args = ""
+ if label is not None and self._project is not None:
+@@ -3591,7 +3608,9 @@ class IdaTui(App):
+ self._pending_switch = None
+ self._ask_load_options(path, label=label)
+
+- def _release_worker(self) -> None:
++ def _release_database(self) -> None:
++ if self.program is not None:
++ self.program.close()
+ if self._pool is not None and self._binary is not None:
+ try:
+ self._pool.evict(self._binary, save=False)
+@@ -3605,23 +3624,6 @@ class IdaTui(App):
+ self.client = None
+ self.program = None
+
+- def _drop_database(self) -> None:
+- """Remove the .i64 (and any unpacked scratch) so the next open re-reads
+- the raw image with new options."""
+- base = self._open_path
+- if self._project is not None and self._binary is not None:
+- ref = self._project.by_label(self._binary)
+- if ref is not None:
+- base = ref.staged
+- if not base:
+- return
+- for suffix in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"):
+- for cand in (base + suffix, os.path.splitext(base)[0] + suffix):
+- try:
+- os.remove(cand)
+- except OSError:
+- pass
+-
+ def _reset_for_reload(self) -> None:
+ self._no_functions = False
+ self._func_index = None
+@@ -3665,6 +3667,11 @@ class IdaTui(App):
+ if os.path.exists(ref.db) or os.path.exists(
+ os.path.splitext(ref.staged)[0] + ".i64"):
+ return None # already analysed: the .i64 records how
++ try:
++ if registered_database(ref.staged, output_database=ref.db):
++ return None
++ except Exception:
++ pass
+ from .formats import needs_load_options
+ return ref if needs_load_options(ref.source) else None
+
+@@ -3718,10 +3725,6 @@ class IdaTui(App):
+
+ asyncio.get_running_loop().create_task(_serve())
+
+- async def on_unmount(self) -> None:
+- if self._rpc is not None:
+- await self._rpc.stop()
+-
+ # -- status helper ----------------------------------------------------- #
+ def _status(self, text: str, priority: bool = False) -> None:
+ """Write the status bar. ``priority`` marks the RESULT of something the
+@@ -3786,9 +3789,10 @@ class IdaTui(App):
+
+ # -- connection loss / recovery --------------------------------------- #
+ def _handle_exception(self, error: BaseException) -> None:
+- """Intercept a lost-connection error from any worker so the whole app
+- doesn't die when the analysis server goes away (it can idle out, be
+- killed, or the box can sleep). Everything else crashes as usual."""
++ """Intercept a lost Code Mode lease so the app can rediscover the DB.
++
++ Everything unrelated to database connectivity crashes as usual.
++ """
+ from textual.worker import WorkerFailed
+ orig = error.error if isinstance(error, WorkerFailed) else error
+ if isinstance(orig, IDAConnectionError):
+@@ -3820,15 +3824,15 @@ class IdaTui(App):
+
+ @work(thread=True, exclusive=True, group="reconnect")
+ def _reconnect(self) -> None:
+- # The worker died (segfault -> dropped socket). Respawn it: it re-opens
+- # and re-analyzes the binary in a fresh process, then we rebuild.
++ # The registered instance disappeared. Rediscover it; Code Mode may find
++ # a GUI/replacement worker, then we rebuild caches against the new handle.
+ try:
+ if self._open_path is None:
+ self.app.call_from_thread(self._reconnect_failed,
+ "no binary to reopen")
+ return
+- client = WorkerClient(self._open_path, ttl=self._ttl,
+- load_args=self._load_args)
++ client = CodeModeClient(self._open_path, ttl=self._ttl,
++ load_args=self._load_args)
+ client.connect(progress=lambda m: self.app.call_from_thread(
+ self._conn_note, m))
+ except Exception as e: # noqa: BLE001
+@@ -3836,7 +3840,7 @@ class IdaTui(App):
+ return
+ self.app.call_from_thread(self._after_reconnect, client, Program(client))
+
+- def _after_reconnect(self, client: "WorkerClient", program: "Program") -> None:
++ def _after_reconnect(self, client: "CodeModeClient", program: "Program") -> None:
+ self.client = client
+ self.program = program
+ self._reconnecting = False
+@@ -3856,13 +3860,13 @@ class IdaTui(App):
+ @work(thread=True, exclusive=True, group="connect")
+ def _connect(self) -> None:
+ try:
+- client = self._open_worker_client()
++ client = self._open_database_client()
+ if client is None:
+ return # the opener already reported + dismissed the overlay
+ module = client.health().get("module", "?")
+ if self._do_keepalive:
+- # Keep the session warm while we run; don't make it immortal, so
+- # it's reclaimed after the TUI closes. (No-op for the worker.)
++ # Compatibility shim: DatabaseHandle's SSE lease already owns
++ # liveness and heartbeat behavior.
+ self._ka = client.keepalive(interval=120.0).start()
+ program = Program(client)
+ except Exception as e: # noqa: BLE001
+@@ -3877,14 +3881,14 @@ class IdaTui(App):
+ return
+ self.client = client
+ self.program = program
+- self.app.call_from_thread(self._status, f"{module} — loading functions…")
++ self._new_database = False
++ self.app.call_from_thread(
++ self._status, f"{module} [{client.backend}] — loading functions…")
+ self._load_functions()
+
+- def _open_worker_client(self): # type: ignore[no-untyped-def]
+- """Our idalib-worker path: spawn the worker (it opens + analyzes the
+- binary in its own process) and connect. Returns the client, or None."""
+- from .worker_client import WorkerClient
+- if self._pool is not None: # project mode: the pool owns the workers
++ def _open_database_client(self): # type: ignore[no-untyped-def]
++ """Attach through Code Mode, reusing a GUI or managed idalib database."""
++ if self._pool is not None: # project mode: the pool owns the leases
+ label = self._binary or self._project.refs[0].label
+ client = self._pool.get(label, progress=lambda m:
+ self.app.call_from_thread(self._status, m))
+@@ -3895,14 +3899,15 @@ class IdaTui(App):
+ return client
+ if not self._open_path:
+ self.app.call_from_thread(
+- self._status, "the worker backend needs a binary path")
++ self._status, "Code Mode needs a database or executable path")
+ self.app.call_from_thread(self._dismiss_loading)
+ return None
+ base = os.path.basename(self._open_path)
+ self.app.call_from_thread(
+- self._status, f"starting worker — initial auto-analysis of {base}…")
+- client = WorkerClient(self._open_path, ttl=self._ttl,
+- load_args=self._load_args)
++ self._status, f"discovering Code Mode database for {base}…")
++ client = CodeModeClient(self._open_path, ttl=self._ttl,
++ load_args=self._load_args,
++ new_database=self._new_database)
+ client.connect(progress=lambda m: self.app.call_from_thread(
+ self._status, m))
+ return client
+@@ -3974,7 +3979,7 @@ class IdaTui(App):
+ @work(thread=True, exclusive=True, group="index")
+ def _index_binary(self) -> None:
+ """Fold this binary's symbols + strings into the project index, so it can
+- be searched later even when its worker is gone."""
++ be searched later even when its Code Mode lease is gone."""
+ if self._index is None or self._project is None or self._binary is None:
+ return
+ ref = self._project.by_label(self._binary)
+@@ -3992,7 +3997,7 @@ class IdaTui(App):
+ imps, exps = self.program.linkage()
+ entries += [(KIND_IMPORT, i.addr, i.name) for i in imps]
+ entries += [(KIND_EXPORT, e.addr, e.name) for e in exps]
+- except Exception: # noqa: BLE001 -- an old worker has no list_linkage
++ except Exception: # noqa: BLE001 -- indexing is best-effort
+ pass
+ try:
+ n = self._index.reindex(self._binary, entries, source=ref.source)
+@@ -4077,7 +4082,13 @@ class IdaTui(App):
+ cursor=0, push=True, is_region=True)
+
+ def _can_reload(self) -> bool:
+- """Whether we're able to re-open this binary with different options."""
++ """Whether Code Mode can replace this IDB with different options.
++
++ A GUI database is owned by the user and has no remote close/rollback
++ route. Managed idalib databases can be released and reopened fresh.
++ """
++ if self.client is not None and self.client.backend == "gui":
++ return False
+ if self._project is not None and self._binary is not None:
+ return True
+ return bool(self._open_path)
+@@ -4267,6 +4278,9 @@ class IdaTui(App):
+
+ def _on_quit_choice(self, choice: str | None) -> None:
+ if choice == "discard":
++ # Code Mode has no rollback/close-without-save operation. For GUI
++ # sessions this leaves changes dirty in IDA; a managed worker owns
++ # its final save policy and may persist them on final lease release.
+ self._save_on_exit = False
+ self.exit()
+ elif choice == "save":
+@@ -4283,7 +4297,7 @@ class IdaTui(App):
+ if self._pool is not None:
+ self._pool.close_all(save=True) # saves each resident worker
+ elif self.program is not None:
+- self.program.client.call("idb_save", timeout=600.0)
++ self.program.client.save_database()
+ except Exception as e: # noqa: BLE001 -- still exit, but say so
+ self.app.call_from_thread(self._status, f"save failed: {e}")
+ self.app.call_from_thread(self._finish_exit)
+@@ -4323,7 +4337,7 @@ class IdaTui(App):
+ self._ask_load_options(ref.source, label=label)
+ return
+ # Snapshot what we're leaving so coming back restores the view, then let
+- # the pool hand us a worker (spawning + evicting as the budget dictates).
++ # the pool hand us a lease (attaching + evicting as the budget dictates).
+ if self._binary is not None:
+ self._states[self._binary] = BinaryState(
+ label=self._binary, program=self.program,
+@@ -4344,8 +4358,8 @@ class IdaTui(App):
+ self.app.call_from_thread(self._switch_failed, label, str(e))
+ return
+ st = self._states.get(label)
+- # The Program (and its caches) only survive while that worker does; a
+- # binary that was evicted comes back with a fresh one. Either way the nav
++ # The Program (and its caches) only survive while that lease does; an
++ # evicted binary reattaches. Either way the nav
+ # history is just addresses, so it always survives.
+ reuse = (st is not None and st.program is not None
+ and getattr(st.program, "client", None) is client)
+@@ -4382,7 +4396,7 @@ class IdaTui(App):
+ self._did_auto_land = False
+ self._auto_land()
+ return
+- # Cold (first visit, or the worker was evicted): rebuild the index, then
++ # Cold (first visit, or the lease was evicted): rebuild the index, then
+ # land back where we were via _pending_restore.
+ self._cur = None
+ self._func_index = None
+@@ -4439,28 +4453,6 @@ class IdaTui(App):
+ return
+ self._goto_ea(addr, push=True) # land on the literal in the listing
+
+- def on_descendant_focus(self, event) -> None: # type: ignore[no-untyped-def]
+- """Keep ``_active`` in step with focus while split.
+-
+- Tab moves both together, but focus also moves on its own — a click, or a
+- pane focusing itself after a load — and then ``_active`` still names the
+- pane you're NOT in. Everything downstream trusts ``_active``: follow
+- resolves the word under that pane's cursor and pushes history for it, so
+- Enter in the pseudocode would follow something from the listing and the
+- next Esc got spent undoing it.
+- """
+- if not self._split:
+- return
+- w = self.focused
+- mode = ("decomp" if isinstance(w, DecompView)
+- else "listing" if isinstance(w, ListingView) else None)
+- if mode is None or mode == self._active:
+- return
+- self._active = mode
+- self._sync_split(mode) # re-link the band from the new driver
+- if not self.query_one(DecompView).loading:
+- self._status_for_cur("split") # never clobber "decompiling…"
+-
+ def action_toggle_view(self) -> None:
+ """Tab: switch the code pane between disassembly and pseudocode (or leave
+ the hex view back to the preferred code view)."""
+@@ -4807,7 +4799,7 @@ class IdaTui(App):
+ def _cross_binary_impl(self, name: str) -> tuple[str, int] | None:
+ """``(binary, addr)`` of a project binary that EXPORTS ``name``.
+
+- Reads the on-disk index, so a provider resolves even when its worker was
++ Reads the on-disk index, so a provider resolves even when its lease was
+ evicted — the whole reason the index exists.
+ """
+ if self._index is None or self._project is None or not name:
+@@ -4980,7 +4972,7 @@ class IdaTui(App):
+ def _foreign_importers(self, subj: int, subj_name, fn): # type: ignore[no-untyped-def]
+ """Project binaries that IMPORT the symbol at ``subj`` — the other half
+ of the phase-3 join, read from the on-disk index so a caller shows up
+- whether or not its worker is resident.
++ whether or not its database lease is resident.
+
+ Only for a symbol this binary actually exports: a local name that
+ happens to collide with another binary's import isn't a caller of ours.
+@@ -5422,7 +5414,7 @@ class IdaTui(App):
+ kind = "stack"
+ batch = {"stack": {"func_addr": hex(cur.ea), "old": old, "new": new}}
+ try:
+- res = prog.client.call("rename", batch=batch)
++ res = prog.client.invoke("rename", batch=batch)
+ except IDAToolError as e:
+ self.app.call_from_thread(self._status, f"rename failed: {e.message}")
+ return
+@@ -5438,7 +5430,6 @@ class IdaTui(App):
+ self.app.call_from_thread(self._after_rename, kind, addr, old, new)
+
+ def _after_rename(self, kind: str, addr: int | None, old: str, new: str) -> None:
+- cur = self._cur
+ # A renamed symbol can appear in many functions, so invalidate globally;
+ # each function refreshes its names the next time it's viewed.
+ self.program.bump_names()
+@@ -5465,7 +5456,7 @@ class IdaTui(App):
+ — unlike the symbol-by-name path, this names the address directly."""
+ assert self.program is not None
+ try:
+- res = self.program.client.call(
++ res = self.program.client.invoke(
+ "rename", batch={"data": {"addr": hex(addr), "new": name}})
+ except IDAToolError as e:
+ self.app.call_from_thread(self._status, f"name failed: {e.message}")
+@@ -5729,7 +5720,6 @@ class IdaTui(App):
+ screen row, so the eye tracks straight across.
+ """
+ lst = self.query_one(ListingView)
+- dec = self.query_one(DecompView)
+ if lst.model is None:
+ return False
+ row = lst.model.ensure_ea(pc)
+@@ -6022,7 +6012,7 @@ class IdaTui(App):
+ def _save(self) -> None:
+ assert self.program is not None
+ try:
+- self.program.client.call("idb_save", timeout=300.0)
++ self.program.client.save_database()
+ except Exception as e: # noqa: BLE001
+ self.app.call_from_thread(self._status, f"save failed: {e}")
+ return
+@@ -6951,7 +6941,9 @@ class IdaTui(App):
+ "(c code · p func · u undefine · Enter follow)")
+
+ # -- teardown ---------------------------------------------------------- #
+- def on_unmount(self) -> None:
++ async def on_unmount(self) -> None:
++ if self._rpc is not None:
++ await self._rpc.stop()
+ if self._ka is not None:
+ self._ka.stop()
+ if self.program is not None:
+@@ -6963,7 +6955,7 @@ class IdaTui(App):
+ elif self.client is not None:
+ if self._save_on_exit is None and self._dirty:
+ try: # unexpected teardown with edits: don't drop them
+- self.client.call("idb_save", timeout=600.0)
++ self.client.save_database()
+ except Exception: # noqa: BLE001
+ pass
+ self.client.close()
+diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py
+new file mode 100644
+index 0000000..ab4e698
+--- /dev/null
++++ b/idatui/codemode_client.py
+@@ -0,0 +1,1107 @@
++"""Client adapter from ida-tui's domain operations to IDA Code Mode.
++
++``DatabaseHandle`` is the lifecycle boundary: it discovers an already-registered
++GUI database, reuses a shared managed idalib worker, or starts one when needed.
++The TUI never owns or terminates an IDA process. Closing this client releases
++only its lease.
++
++The Code Mode transport intentionally exposes one broad operation,
++``execute_python``. ``CodeModeClient.invoke`` turns the small, address-centric
++operations needed by the paging layer into self-contained snippets. The
++snippets prefer the public ``ida-domain`` ``db`` object. A handful of features
++that ida-domain does not currently expose (IDA-coloured listing rows, creating
++instructions, ARM T-state, and detailed Hex-Rays line maps/failures) use the
++IDAPython modules that Code Mode deliberately makes importable.
++"""
++from __future__ import annotations
++
++import json
++import os
++import shlex
++import threading
++import time
++from textwrap import dedent
++from typing import Any
++
++from ida_codemode.client import (
++ ClientError,
++ DatabaseHandle,
++ InstanceDisconnectedError,
++ RemoteError,
++)
++from ida_codemode.registry import (
++ REGISTRY_DIR,
++ FileLock,
++ RegistryEntry,
++ canonical_path,
++ idb_key,
++ scan_instances,
++)
++from ida_codemode.resolver import IdbBusy, expected_idb_path
++
++from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session
++
++
++def registered_database(path: str, output_database: str | None = None) -> bool:
++ """Whether a live/lock-held Code Mode instance owns this target."""
++ source = canonical_path(path)
++ expected = canonical_path(output_database) if output_database else expected_idb_path(source)
++ expected_key = idb_key(expected)
++ for instance in scan_instances(timeout=0.5):
++ entry = instance.entry
++ if entry.idb_key == expected_key:
++ return True
++ if not output_database and entry.backend == "gui" and entry.exe_path:
++ if canonical_path(entry.exe_path) == source:
++ return True
++ return False
++
++
++class _NoopKeepAlive:
++ """Compatibility shim: the DatabaseHandle's SSE lease is the heartbeat."""
++
++ def __init__(self) -> None:
++ self.beats = self.failures = 0
++
++ def start(self) -> "_NoopKeepAlive":
++ return self
++
++ def stop(self) -> None:
++ pass
++
++
++def _parse_load_args(value: str) -> tuple[str | None, int | None, str | None]:
++ """Translate ida-tui's legacy first-open switches to Code Mode options.
++
++ Code Mode has typed options for processor, natural loading address and file
++ type. It deliberately has no arbitrary command-line escape hatch; reject
++ switches we cannot represent instead of silently loading a blob wrongly.
++ """
++ processor: str | None = None
++ loading_address: int | None = None
++ file_type: str | None = None
++ unsupported: list[str] = []
++ try:
++ words = shlex.split(value or "", posix=os.name != "nt")
++ except ValueError as exc:
++ raise ValueError(f"invalid IDA load options: {exc}") from exc
++ for word in words:
++ if word.startswith("-p") and len(word) > 2:
++ processor = word[2:]
++ elif word.startswith("-b") and len(word) > 2:
++ try:
++ # IDA's -b is in 16-byte paragraphs. DatabaseHandle expects the
++ # natural address, which is the safer public API.
++ loading_address = int(word[2:], 16) << 4
++ except ValueError as exc:
++ raise ValueError(f"invalid IDA loading address: {word!r}") from exc
++ elif word.startswith("-T") and len(word) > 2:
++ file_type = word[2:]
++ else:
++ unsupported.append(word)
++ if unsupported:
++ joined = " ".join(unsupported)
++ raise ValueError(
++ "ida-codemode cannot represent arbitrary IDA load options: "
++ f"{joined!r}; use processor/base/file type options instead"
++ )
++ return processor, loading_address, file_type
++
++
++def _script(args: dict[str, Any], body: str) -> str:
++ """Bind JSON arguments without interpolating user text into Python code."""
++ encoded = json.dumps(args, ensure_ascii=False, separators=(",", ":"))
++ return f"import json\na = json.loads({encoded!r})\n{dedent(body).strip()}\n"
++
++
++# Rich flat-listing generation is the largest ida-domain gap in this port.
++# ida-domain can enumerate heads and render plain disassembly, but it does not
++# expose undefined runs, IDA colour spans, function banners, or expanded UDT
++# members. Keep that IDAPython-only logic isolated in this one operation.
++_HEADS = r'''
++import ida_bytes, ida_funcs, ida_idaapi, ida_lines, ida_name, ida_nalt, ida_segment, ida_typeinf
++start = int(str(a["addr"]), 16)
++count = max(1, min(int(a.get("count", 200)), 2000))
++offset = max(0, int(a.get("offset", 0)))
++annotate = bool(a.get("annotate", False))
++seg = db.segments.get_at(start)
++if seg is None:
++ result = {"addr": a["addr"], "error": "no segment", "heads": [], "cursor": {"done": True}}
++else:
++ lo, hi = int(seg.start_ea), int(seg.end_ea)
++ if a.get("end"):
++ hi = min(hi, int(str(a["end"]), 16))
++
++ span_names = {
++ "insn": ("SCOLOR_INSN", "SCOLOR_KEYWORD", "SCOLOR_ASMDIR", "SCOLOR_MACRO"),
++ "reg": ("SCOLOR_REG",),
++ "num": ("SCOLOR_NUMBER", "SCOLOR_CHAR", "SCOLOR_BINPREF"),
++ "str": ("SCOLOR_STRING",),
++ "name": ("SCOLOR_DATNAME", "SCOLOR_CODNAME", "SCOLOR_LOCNAME", "SCOLOR_IMPNAME",
++ "SCOLOR_DEMNAME", "SCOLOR_LIBNAME", "SCOLOR_CNAME", "SCOLOR_DNAME",
++ "SCOLOR_CREF", "SCOLOR_DREF", "SCOLOR_CREFTAIL", "SCOLOR_DREFTAIL"),
++ "seg": ("SCOLOR_SEGNAME",),
++ "cmt": ("SCOLOR_AUTOCMT", "SCOLOR_REGCMT", "SCOLOR_RPTCMT", "SCOLOR_VOIDOP"),
++ "punct": ("SCOLOR_SYMBOL", "SCOLOR_ALTOP", "SCOLOR_HIDNAME"),
++ "err": ("SCOLOR_ERROR",),
++ }
++ tag_kinds = {}
++ for kind, names in span_names.items():
++ for name in names:
++ value = getattr(ida_lines, name, None)
++ if isinstance(value, str) and value:
++ tag_kinds[value[0]] = kind
++ elif isinstance(value, int):
++ tag_kinds[chr(value)] = kind
++
++ def spans(tagged):
++ on, off, esc = "\x01", "\x02", "\x03"
++ addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28))
++ addr_len = int(getattr(ida_lines, "COLOR_ADDR_SIZE", 16))
++ out, stack, buf = [], [], []
++ def flush():
++ if buf:
++ out.append([stack[-1] if stack else "text", "".join(buf)])
++ buf.clear()
++ i = 0
++ while i < len(tagged):
++ ch = tagged[i]
++ if ch == on and i + 1 < len(tagged):
++ tag = tagged[i + 1]
++ if tag == addr_tag:
++ i += 2 + addr_len
++ continue
++ flush(); stack.append(tag_kinds.get(tag, "text")); i += 2; continue
++ if ch == off and i + 1 < len(tagged):
++ flush()
++ if stack: stack.pop()
++ i += 2; continue
++ if ch == esc and i + 1 < len(tagged):
++ buf.append(tagged[i + 1]); i += 2; continue
++ buf.append(ch); i += 1
++ flush()
++ collapsed, previous_space = [], False
++ for kind, text in out:
++ acc = []
++ for ch in text:
++ if ch.isspace():
++ if previous_space: continue
++ acc.append(" "); previous_space = True
++ else:
++ acc.append(ch); previous_space = False
++ if acc: collapsed.append([kind, "".join(acc)])
++ if collapsed:
++ collapsed[0][1] = collapsed[0][1].lstrip()
++ collapsed[-1][1] = collapsed[-1][1].rstrip()
++ return [[kind, text] for kind, text in collapsed if text]
++
++ def row(ea):
++ flags = ida_bytes.get_flags(ea)
++ kind = "code" if ida_bytes.is_code(flags) else ("data" if ida_bytes.is_data(flags) else "unknown")
++ tagged = ida_lines.generate_disasm_line(ea, 0) or ""
++ text = " ".join(ida_lines.tag_remove(tagged).split()) if tagged else ""
++ item = {"ea": hex(ea), "kind": kind, "size": int(ida_bytes.get_item_size(ea)), "text": text}
++ if tagged:
++ rich = spans(tagged)
++ if " ".join("".join(x[1] for x in rich).split()) == text:
++ item["spans"] = rich
++ name = ida_name.get_ea_name(ea)
++ if name: item["name"] = name
++ return item
++
++ def unknown_row(ea, size):
++ if size <= 1: return row(ea)
++ item = {"ea": hex(ea), "kind": "unknown", "size": int(size), "text": f"db {size} dup(?)"}
++ name = ida_name.get_ea_name(ea)
++ if name: item["name"] = name
++ return item
++
++ def members(ea):
++ tif = db.types.get_at(ea)
++ if tif is None or not tif.is_udt(): return []
++ answer = []
++ for member in db.types.get_udt_members(tif):
++ type_text = member.type.dstr() or ""
++ text = f"+{member.offset:X} {member.name}" + (f" {type_text}" if type_text else "")
++ answer.append({"ea": hex(ea + member.offset), "kind": "member",
++ "size": int(member.size), "text": text})
++ return answer
++
++ def is_unknown(ea):
++ flags = ida_bytes.get_flags(ea)
++ return not (ida_bytes.is_code(flags) or ida_bytes.is_data(flags))
++ def run_end(ea):
++ nxt = ida_bytes.next_head(ea, hi)
++ return nxt if nxt != ida_idaapi.BADADDR and ea < nxt <= hi else hi
++ def advance(ea):
++ if is_unknown(ea): return run_end(ea)
++ nxt = ida_bytes.get_item_end(ea)
++ return nxt if nxt > ea else ea + 1
++ def rows_for(ea):
++ if is_unknown(ea): return [unknown_row(ea, run_end(ea) - ea)]
++ fn = db.functions.get_at(ea) if annotate else None
++ at_start = fn is not None and int(fn.start_ea) == ea
++ answer = []
++ if at_start:
++ name = db.functions.get_name(fn) or f"sub_{ea:X}"
++ answer += [
++ {"ea": hex(ea), "kind": "sep", "size": 0, "text": ""},
++ {"ea": hex(ea), "kind": "sep", "size": 0,
++ "text": "; " + "=" * 15 + " S U B R O U T I N E " + "=" * 15},
++ {"ea": hex(ea), "kind": "funchdr", "size": 0,
++ "text": name + " proc", "name": name},
++ ]
++ item = row(ea)
++ if at_start:
++ item["name"] = None
++ elif annotate and item["kind"] == "code" and item.get("name"):
++ name = item["name"]
++ answer.append({"ea": hex(ea), "kind": "label", "size": 0,
++ "text": name + ":", "name": name})
++ item["name"] = None
++ answer.append(item)
++ if item["kind"] == "data": answer += members(ea)
++ if fn is not None and ida_bytes.get_item_end(ea) >= int(fn.end_ea):
++ name = db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}"
++ answer += [
++ {"ea": hex(ea), "kind": "funchdr", "size": 0,
++ "text": name + " endp", "name": name},
++ {"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + "-" * 60},
++ ]
++ return answer
++
++ ea = ida_bytes.get_item_head(start)
++ if ea == ida_idaapi.BADADDR: ea = start
++ for _ in range(offset):
++ if ea >= hi: break
++ ea = advance(ea)
++ rows = []
++ more = False
++ while ea != ida_idaapi.BADADDR and ea < hi:
++ if len(rows) >= count:
++ more = True; break
++ rows += rows_for(ea)
++ ea = advance(ea)
++ result = {"addr": a["addr"], "heads": rows,
++ "cursor": {"next": hex(ea)} if more else {"done": True}}
++result
++'''
++
++
++_DECOMP_MAP_HELPER = r'''
++def line_map(cfunc):
++ import ida_hexrays
++ answer = []
++ for sl in cfunc.get_pseudocode():
++ tagged, eas, seen = sl.line, [], set()
++ for x in range(len(tagged) + 1):
++ head = ida_hexrays.ctree_item_t(); item = ida_hexrays.ctree_item_t(); tail = ida_hexrays.ctree_item_t()
++ if not cfunc.get_line_item(tagged, x, False, head, item, tail): continue
++ text = item.dstr() or ""
++ try: ea = int(text.split(": ", 1)[0], 16)
++ except (ValueError, IndexError): continue
++ if ea not in seen: seen.add(ea); eas.append(ea)
++ answer.append(eas)
++ return answer
++'''
++
++
++_OPERATIONS: dict[str, str] = {
++ "list_funcs": r'''
++import fnmatch
++queries = a.get("queries") or [{}]
++q = queries[0]
++offset, count = max(0, int(q.get("offset", 0))), max(1, int(q.get("count", 500)))
++pattern = str(q.get("filter") or "").lower()
++if pattern and not any(ch in pattern for ch in "*?["): pattern = "*" + pattern + "*"
++rows = []
++for fn in db.functions.get_all():
++ name = db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}"
++ if pattern and not fnmatch.fnmatchcase(name.lower(), pattern): continue
++ rows.append({"addr": hex(int(fn.start_ea)), "name": name,
++ "size": int(fn.end_ea) - int(fn.start_ea)})
++page = rows[offset:offset + count]
++result = {"result": [{"data": page, "next_offset": offset + len(page), "total": len(rows)}]}
++result
++''',
++ "disasm": r'''
++ea = int(str(a["addr"]), 16)
++fn = db.functions.get_at(ea)
++if fn is None:
++ result = {"instructions": [], "total_instructions": 0, "instruction_count": 0}
++else:
++ instructions = list(db.functions.get_instructions(fn))
++ limit = max(1, int(a.get("max_instructions", len(instructions) or 1)))
++ rows = [{"addr": hex(int(insn.ea)), "instruction": db.instructions.get_disassembly(insn)}
++ for insn in instructions[:limit]]
++ result = {"instructions": rows, "total_instructions": len(instructions),
++ "instruction_count": len(instructions)}
++result
++''',
++ "file_regions": r'''
++import idaapi
++rows = []
++for seg in db.segments.get_all():
++ try: file_off = int(idaapi.get_fileregion_offset(seg.start_ea))
++ except Exception: file_off = -1
++ if file_off < 0 or file_off >= (1 << 48): file_off = -1
++ rows.append({"start": hex(int(seg.start_ea)), "end": hex(int(seg.end_ea)),
++ "file_off": file_off, "name": db.segments.get_name(seg) or ""})
++result = {"regions": rows}
++result
++''',
++ "read_raw": r'''
++import ida_bytes
++ea, size = int(str(a["addr"]), 16), max(0, int(a["size"]))
++raw = ida_bytes.get_bytes(ea, size) or b""
++raw = raw[:size] + b"\xff" * max(0, size - len(raw))
++data = bytearray(raw)
++for index, value in enumerate(data):
++ if value == 0xFF and not ida_bytes.is_loaded(ea + index): data[index] = 0
++result = {"addr": a["addr"], "hex": bytes(data).hex(), "n": len(data)}
++result
++''',
++ "get_bytes": r'''
++rows = []
++for region in a.get("regions", []):
++ ea, size = int(str(region["addr"]), 16), int(region["size"])
++ raw = db.bytes.get_bytes_at(ea, size) or b""
++ rows.append({"addr": region["addr"], "data": " ".join(f"{b:02x}" for b in raw)})
++result = {"result": rows}
++result
++''',
++ "search_structs": r'''
++needle = str(a.get("filter") or "").lower()
++rows = []
++for tif in db.types.get_all():
++ name = tif.get_type_name() or ""
++ if not name or needle not in name.lower() or not tif.is_udt(): continue
++ members = list(db.types.get_udt_members(tif))
++ rows.append({"name": name, "size": int(tif.get_size()), "is_union": bool(tif.is_union()),
++ "cardinality": len(members), "ordinal": int(tif.get_ordinal())})
++result = {"result": rows}
++result
++''',
++ "type_inspect": r'''
++rows = []
++for query in a.get("queries", []):
++ name = str(query.get("name") or "")
++ tif = db.types.get_by_name(name)
++ if tif is None:
++ rows.append({"name": name, "error": "type not found"}); continue
++ members = [{"name": m.name, "type": m.type.dstr() or str(m.type),
++ "offset": int(m.offset), "size": int(m.size)}
++ for m in db.types.get_udt_members(tif)] if tif.is_udt() else []
++ rows.append({"name": name, "size": int(tif.get_size()), "is_union": bool(tif.is_union()),
++ "members": members})
++result = {"result": rows}
++result
++''',
++ "declare_type": r'''
++import ida_typeinf
++decls = a.get("decls", "")
++if isinstance(decls, str): decls = [decls]
++rows = []
++for declaration in decls:
++ try:
++ errors = int(db.types.parse_declarations(ida_typeinf.get_idati(), declaration))
++ rows.append({"ok": errors == 0, **({} if errors == 0 else {"error": f"{errors} parse error(s)"})})
++ except Exception as exc:
++ rows.append({"ok": False, "error": str(exc)})
++result = {"result": rows}
++result
++''',
++ "del_type": r'''
++import ida_typeinf
++name = str(a["name"])
++ok = bool(ida_typeinf.del_named_type(ida_typeinf.get_idati(), name, ida_typeinf.NTF_TYPE))
++result = {"name": name, "deleted": ok, **({} if ok else {"error": f"Type {name!r} not found or could not be deleted"})}
++result
++''',
++ "func_types": r'''
++import ida_typeinf
++ea = int(str(a["addr"]), 16)
++fn = db.functions.get_at(ea)
++if fn is None:
++ result = {"addr": a["addr"], "error": "no function at address"}
++else:
++ pseudo = db.pseudocode.decompile(fn)
++ name = db.functions.get_name(fn) or ""
++ tif = pseudo.get_func_type()
++ try: prototype = ida_typeinf.print_tinfo("", 0, 0, ida_typeinf.PRTYPE_1LINE, tif, name, "") if tif else ""
++ except Exception: prototype = tif.dstr() if tif else ""
++ lvars = [{"name": var.name, "type": var.type_info.dstr() if var.type_info else "",
++ "is_arg": bool(var.is_arg)} for var in pseudo.local_variables]
++ result = {"addr": hex(int(fn.start_ea)), "name": name,
++ "prototype": (prototype or "").strip(), "lvars": lvars}
++result
++''',
++ "set_lvar_type": r'''
++import ida_typeinf
++ea, variable, declaration = int(str(a["addr"]), 16), str(a["variable"]), str(a["type"])
++fn = db.functions.get_at(ea)
++if fn is None:
++ result = {"error": "no function at address"}
++else:
++ pseudo = db.pseudocode.decompile(fn)
++ var = pseudo.find_local_variable(variable)
++ if var is None:
++ result = {"error": f"local variable {variable!r} not found"}
++ else:
++ try:
++ tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
++ accepted = bool(var.set_type(tif))
++ saved = bool(pseudo.save_local_variable_info(var, save_type=True)) if accepted else False
++ result = {"addr": hex(int(fn.start_ea)), "variable": variable,
++ "type": declaration, "ok": accepted and saved}
++ except Exception as exc:
++ result = {"error": f"bad type {declaration!r}: {exc}"}
++result
++''',
++ "set_type": r'''
++from ida_domain.types import TypeApplyFlags
++rows = []
++for edit in a.get("edits", []):
++ ea = int(str(edit["addr"]), 16)
++ declaration = str(edit.get("signature") or edit.get("type") or "")
++ try:
++ ok = bool(db.types.apply_declaration_at(ea, declaration, TypeApplyFlags.DEFINITE))
++ rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "IDA rejected the type"})})
++ except Exception as exc:
++ rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
++result = {"result": rows}
++result
++''',
++ "data_type": r'''
++ea = int(str(a["addr"]), 16)
++try:
++ tif = db.types.get_at(ea)
++ fn = db.functions.get_at(ea)
++ result = {"addr": hex(ea), "name": db.names.get_at(ea) or "",
++ "type": tif.dstr() if tif else "", "size": int(db.heads.size(ea)) if db.heads.is_head(ea) else 0,
++ "is_func": bool(fn)}
++except Exception as exc:
++ result = {"addr": hex(ea), "error": str(exc)}
++result
++''',
++ "force_recompile": r'''
++import ida_hexrays
++rows = []
++for item in a.get("items", []):
++ ea = int(str(item["addr"]), 16)
++ ida_hexrays.mark_cfunc_dirty(ea, False)
++ rows.append({"addr": hex(ea), "ok": True})
++result = {"result": rows}
++result
++''',
++ "undefine": r'''
++import ida_bytes
++rows = []
++for item in a.get("items", []):
++ ea = int(str(item["addr"]), 16)
++ size = max(1, int(item.get("size") or ida_bytes.get_item_size(ea) or 1))
++ ok = bool(ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, size))
++ rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "delete items failed"})})
++result = {"result": rows}
++result
++''',
++ "define_code": r'''
++import ida_ua
++rows = []
++for item in a.get("items", []):
++ ea = int(str(item["addr"]), 16); size = int(ida_ua.create_insn(ea))
++ rows.append({"addr": hex(ea), "ok": size > 0, "size": size,
++ **({} if size > 0 else {"error": "instruction did not decode"})})
++result = {"result": rows}
++result
++''',
++ "define_func": r'''
++rows = []
++for item in a.get("items", []):
++ ea = int(str(item["addr"]), 16); ok = bool(db.functions.create(ea))
++ rows.append({"addr": hex(ea), "ok": ok, **({} if ok else {"error": "IDA refused the function"})})
++result = {"result": rows}
++result
++''',
++ "make_data": r'''
++import ida_bytes, ida_idaapi, ida_typeinf
++from ida_domain.types import TypeApplyFlags
++rows = []
++for item in a.get("items", []):
++ ea, declaration = int(str(item["addr"]), 16), str(item["type"])
++ try:
++ tif = db.types.parse_one_declaration(ida_typeinf.get_idati(), declaration)
++ size = max(1, int(tif.get_size()))
++ saved_names = [(addr, name) for addr, name in db.names.get_all()
++ if ea <= int(addr) < ea + size]
++ ida_bytes.del_items(ea, ida_bytes.DELIT_EXPAND | ida_bytes.DELIT_DELNAMES,
++ max(size, int(ida_bytes.get_item_size(ea) or 1)))
++ created = bool(ida_bytes.create_data(ea, ida_bytes.FF_BYTE, size, ida_idaapi.BADADDR))
++ ok = created and bool(db.types.apply_at(tif, ea, TypeApplyFlags.DEFINITE))
++ for address, name in saved_names:
++ db.names.set_name(int(address), name)
++ if ok and item.get("name"): ok = bool(db.names.set_name(ea, str(item["name"])))
++ rows.append({"addr": hex(ea), "ok": ok, "size": size,
++ **({} if ok else {"error": "IDA rejected the data type"})})
++ except Exception as exc:
++ rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
++result = {"result": rows}
++result
++''',
++ "make_string": r'''
++from ida_domain.strings import StringType
++ea, length = int(str(a["addr"]), 16), max(0, int(a.get("length", 0)))
++kind = {"c": StringType.C, "c16": StringType.C_16, "c32": StringType.C_32,
++ "pascal": StringType.PASCAL}.get(str(a.get("kind", "c")).lower(), StringType.C)
++import ida_bytes
++try:
++ ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, length if length > 0 else 1)
++except Exception:
++ pass
++try:
++ ok = bool(db.bytes.create_string_at(ea, length or None, kind))
++ text = db.bytes.get_string_at(ea) or "" if ok else ""
++ result = {"addr": hex(ea), "ok": ok, "size": int(db.heads.size(ea)) if ok else 0, "text": text}
++except Exception as exc:
++ result = {"addr": hex(ea), "ok": False, "error": str(exc)}
++result
++''',
++ "list_strings": r'''
++from ida_domain.strings import StringListConfig
++offset, count, min_len = max(0, int(a.get("offset", 0))), max(1, int(a.get("count", 2000))), max(1, int(a.get("min_len", 4)))
++if offset == 0 or a.get("refresh"):
++ from ida_domain.strings import StringType
++ db.strings.rebuild(StringListConfig(string_types=list(StringType), min_len=min_len,
++ only_ascii_7bit=False))
++items = list(db.strings.get_all())
++page = items[offset:offset + count]
++rows = []
++for item in page:
++ try: text = str(item)
++ except Exception: text = item.contents.decode("utf-8", "replace") if item.contents else ""
++ rows.append({"addr": hex(int(item.address)), "text": text, "len": int(item.length), "type": item.type.name})
++result = {"strings": rows, "total": len(items), "next_offset": offset + len(rows)}
++result
++''',
++ "list_linkage": r'''
++imports = [{"addr": hex(int(item.address)), "name": item.name, "module": item.module_name}
++ for item in db.imports.get_all_imports() if item.name]
++exports = [{"addr": hex(int(item.address)), "name": item.name, "ordinal": int(item.ordinal)}
++ for item in db.entries.get_all() if item.name]
++result = {"imports": imports, "exports": exports,
++ "n_imports": len(imports), "n_exports": len(exports)}
++result
++''',
++ "lookup_funcs": r'''
++rows = []
++for query in a.get("queries", []):
++ raw = str(query)
++ try: ea = int(raw, 16)
++ except ValueError:
++ fn = db.functions.get_by_name(raw); ea = int(fn.start_ea) if fn else None
++ else: fn = db.functions.get_at(ea)
++ if fn is None:
++ rows.append({"query": raw, "fn": None})
++ else:
++ rows.append({"query": raw, "fn": {"addr": hex(int(fn.start_ea)),
++ "name": db.functions.get_name(fn) or f"sub_{int(fn.start_ea):X}",
++ "size": int(fn.end_ea) - int(fn.start_ea)}})
++result = {"result": rows}
++result
++''',
++ "resolve_names": r'''
++import ida_idaapi, ida_name
++rows = []
++for query in a.get("queries", []):
++ name = str(query).strip(); ea = ida_name.get_name_ea(ida_idaapi.BADADDR, name)
++ rows.append({"query": name, "ea": hex(int(ea)) if ea != ida_idaapi.BADADDR else None})
++result = {"result": rows}
++result
++''',
++ "xref_types": r'''
++queries = a.get("queries") or []
++all_results = []
++for query in queries:
++ ea, direction = int(str(query["addr"]), 16), str(query.get("direction", "both"))
++ refs = []
++ if direction in ("to", "both"): refs += list(db.xrefs.to_ea(ea))
++ if direction in ("from", "both"): refs += list(db.xrefs.from_ea(ea))
++ rows, seen = [], set()
++ for ref in refs:
++ key = (int(ref.from_ea), int(ref.to_ea), int(ref.type))
++ if query.get("dedup") and key in seen: continue
++ seen.add(key)
++ fn = db.functions.get_at(int(ref.from_ea))
++ kind = ("call" if ref.is_call else "jump" if ref.is_jump else "flow" if ref.is_flow
++ else "read" if ref.is_read else "write" if ref.is_write else ref.type.name.lower())
++ row = {"from": hex(int(ref.from_ea)), "to": hex(int(ref.to_ea)),
++ "type": "code" if ref.is_code else "data", "kind": kind}
++ if query.get("include_fn") and fn is not None:
++ row["fn"] = {"addr": hex(int(fn.start_ea)), "name": db.functions.get_name(fn) or ""}
++ rows.append(row)
++ if len(rows) >= int(query.get("count", 2000)): break
++ all_results.append({"data": rows})
++result = {"result": all_results}
++result
++''',
++ "xref_query": r'''
++queries = a.get("queries") or []
++all_results = []
++for query in queries:
++ ea, direction = int(str(query["addr"]), 16), str(query.get("direction", "both"))
++ refs = []
++ if direction in ("to", "both"): refs += list(db.xrefs.to_ea(ea))
++ if direction in ("from", "both"): refs += list(db.xrefs.from_ea(ea))
++ rows = []
++ for ref in refs[:int(query.get("count", 2000))]:
++ fn = db.functions.get_at(int(ref.from_ea))
++ row = {"from": hex(int(ref.from_ea)), "to": hex(int(ref.to_ea)),
++ "type": "code" if ref.is_code else "data"}
++ if query.get("include_fn") and fn is not None:
++ row["fn"] = {"addr": hex(int(fn.start_ea)), "name": db.functions.get_name(fn) or ""}
++ rows.append(row)
++ all_results.append({"data": rows})
++result = {"result": all_results}
++result
++''',
++ "set_comments": r'''
++rows = []
++for item in a.get("items", []):
++ ea, text = int(str(item["addr"]), 16), str(item.get("comment") or "")
++ try:
++ if text: ok = bool(db.comments.set_at(ea, text))
++ else: db.comments.delete_at(ea); ok = True
++ rows.append({"addr": hex(ea), "ok": ok})
++ except Exception as exc:
++ rows.append({"addr": hex(ea), "ok": False, "error": str(exc)})
++result = {"result": rows}
++result
++''',
++ "rename": r'''
++import ida_idaapi, ida_name, ida_typeinf
++batch = a.get("batch") or {}
++out = {}; ok_count = failed = 0
++for category, edit in batch.items():
++ try:
++ if category == "func":
++ ea, new = int(str(edit["addr"]), 16), str(edit["name"])
++ fn = db.functions.get_at(ea); ok = bool(fn and db.functions.set_name(fn, new))
++ elif category == "data":
++ new = str(edit.get("new") or "")
++ if edit.get("addr") is not None: ea = int(str(edit["addr"]), 16)
++ else: ea = int(ida_name.get_name_ea(ida_idaapi.BADADDR, str(edit.get("old") or "")))
++ ok = bool(db.names.set_name(ea, new))
++ elif category in ("local", "stack"):
++ ea, old, new = int(str(edit["func_addr"]), 16), str(edit["old"]), str(edit["new"])
++ pseudo = db.pseudocode.decompile(ea); var = pseudo.find_local_variable(old)
++ if var is None: ok = False
++ else:
++ var.set_user_name(new)
++ ok = bool(pseudo.save_local_variable_info(var, save_name=True))
++ else:
++ raise ValueError(f"unsupported rename category: {category}")
++ row = {"ok": ok, **({} if ok else {"error": "IDA rejected the name"})}
++ except Exception as exc:
++ row = {"ok": False, "error": str(exc)}
++ out[category] = [row]
++ if row["ok"]: ok_count += 1
++ else: failed += 1
++out["summary"] = {"ok": ok_count, "failed": failed}
++result = out
++result
++''',
++}
++
++
++_OPERATIONS["decompile"] = _DECOMP_MAP_HELPER + r'''
++ea = int(str(a["addr"]), 16)
++fn = db.functions.get_at(ea)
++if fn is None:
++ result = {"error": f"no function at {ea:#x}"}
++else:
++ pseudo = db.pseudocode.decompile(fn)
++ mapping = line_map(pseudo.raw_cfunc)
++ plain = pseudo.to_text()
++ marked = [line + (f" /*0x{eas[0]:X}*/" if eas else "")
++ for line, eas in zip(plain, mapping)]
++ import ida_name
++ refs, seen = [], set()
++ for expr in pseudo.find_objects():
++ target = int(expr.obj_ea)
++ if target in seen or not (db.is_valid_ea(target) or db.is_private_ea(target)): continue
++ seen.add(target)
++ name = expr.obj_name or ida_name.get_name(target) or ""
++ try: string = db.bytes.get_string_at(target) if db.is_valid_ea(target) else None
++ except Exception: string = None
++ refs.append({"addr": hex(target), "name": name, "string": string})
++ result = {"addr": hex(int(fn.start_ea)), "code": "\n".join(marked), "refs": refs}
++result
++'''
++
++_OPERATIONS["decomp_map"] = _DECOMP_MAP_HELPER + r'''
++ea = int(str(a["addr"]), 16)
++fn = db.functions.get_at(ea)
++if fn is None:
++ result = {"error": f"no function at {ea:#x}"}
++else:
++ pseudo = db.pseudocode.decompile(fn)
++ mapping = line_map(pseudo.raw_cfunc)
++ result = {"addr": hex(int(fn.start_ea)),
++ "lines": [{"ea": hex(eas[0]) if eas else None,
++ "eas": [hex(item) for item in eas]} for eas in mapping]}
++result
++'''
++
++_OPERATIONS["define_code_run"] = r'''
++import ida_bytes, ida_idp, ida_segment, ida_ua, idaapi
++ea, limit = int(str(a["addr"]), 16), max(1, min(int(a.get("limit", 20000)), 200000))
++seg = ida_segment.getseg(ea)
++if seg is None:
++ result = {"addr": a["addr"], "error": "no segment", "count": 0}
++else:
++ start, count, stopped, hi = ea, 0, "limit", int(seg.end_ea)
++ while count < limit:
++ if ea >= hi: stopped = "segment"; break
++ flags = ida_bytes.get_flags(ea)
++ if ida_bytes.is_code(flags) or ida_bytes.is_data(flags): stopped = "defined"; break
++ size = int(ida_ua.create_insn(ea))
++ if size <= 0: stopped = "undecodable"; break
++ count += 1
++ insn = ida_ua.insn_t()
++ if ida_ua.decode_insn(insn, ea) > 0:
++ try: is_ret = bool(ida_idp.is_ret_insn(insn))
++ except Exception: is_ret = False
++ if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP):
++ ea += size; stopped = "flow"; break
++ ea += size
++ result = {"start": hex(start), "end": hex(ea), "count": count, "stopped": stopped}
++result
++'''
++
++_OPERATIONS["define_func_run"] = r'''
++import ida_bytes, ida_funcs, ida_segment
++ea = int(str(a["addr"]), 16)
++fn = db.functions.get_at(ea)
++if fn is not None and int(fn.start_ea) == ea:
++ result = {"addr": hex(ea), "ok": True, "start": hex(ea), "end": hex(int(fn.end_ea)), "how": "existed"}
++else:
++ automatic = bool(db.functions.create(ea))
++ if not automatic:
++ seg = db.segments.get_at(ea); end = ea; hi = int(seg.end_ea) if seg else ea
++ while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)):
++ nxt = int(ida_bytes.get_item_end(end))
++ if nxt <= end: break
++ end = nxt
++ ok = bool(end > ea and ida_funcs.add_func(ea, end))
++ else: ok = True
++ fn = db.functions.get_at(ea)
++ result = ({"addr": hex(ea), "ok": True, "start": hex(int(fn.start_ea)),
++ "end": hex(int(fn.end_ea)), "how": "auto" if automatic else "explicit-end"}
++ if ok and fn is not None else
++ {"addr": hex(ea), "ok": False, "error": f"IDA refused a function at {ea:#x}"})
++result
++'''
++
++_OPERATIONS["set_thumb"] = r'''
++import ida_bytes, ida_ida, ida_idp, ida_segment, ida_segregs
++ea = int(str(a["addr"]), 16); treg = ida_idp.str2reg("T")
++seg = ida_segment.getseg(ea)
++if treg is None or treg < 0:
++ result = {"addr": hex(ea), "error": "no T register (not an ARM database)"}
++elif seg is None:
++ result = {"addr": hex(ea), "error": "no segment"}
++else:
++ current = ida_segregs.get_sreg(ea, treg)
++ current = 0 if current in (None, 0xFFFFFFFF, -1) else int(current)
++ want = {"on": 1, "off": 0}.get(str(a.get("mode", "toggle")).lower(), 0 if current else 1)
++ changed = False
++ if want and seg.bitness != 1:
++ ida_segment.set_segm_addressing(seg, 1); changed = True
++ size = max(int(ida_bytes.get_item_size(ea)), 2)
++ ida_bytes.del_items(ea, 0, size)
++ ok = bool(ida_segregs.split_sreg_range(ea, treg, want, ida_segregs.SR_user))
++ now = ida_segregs.get_sreg(ea, treg)
++ result = {"addr": hex(ea), "thumb": bool(now), "was": bool(current), "ok": ok,
++ "bitness": ida_segment.getseg(ea).bitness, "forced_32bit": changed,
++ "db_64bit": bool(ida_ida.inf_get_app_bitness() == 64 and want)}
++result
++'''
++
++_OPERATIONS["thumb_scan"] = r'''
++import ida_bytes, ida_funcs, ida_idp, ida_segment, ida_segregs, ida_ua
++lo, hi = int(str(a["start"]), 16), int(str(a["end"]), 16)
++apply, limit = bool(a.get("apply", True)), int(a.get("limit", 512))
++treg = ida_idp.str2reg("T"); found = []; applied = 0; cursor = lo
++while cursor + 4 <= hi and len(found) < limit:
++ at = cursor; value = int(ida_bytes.get_dword(cursor)); cursor += 4
++ if not value & 1: continue
++ target = value & ~1; seg = ida_segment.getseg(target)
++ if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0): continue
++ flags = ida_bytes.get_flags(target)
++ if ida_bytes.is_data(flags): continue
++ item = {"at": hex(at), "value": hex(value), "target": hex(target),
++ "was_code": bool(ida_bytes.is_code(flags))}; found.append(item)
++ if not apply: continue
++ if treg is not None and treg >= 0: ida_segregs.split_sreg_range(target, treg, 1, ida_segregs.SR_user)
++ if not ida_bytes.is_code(ida_bytes.get_flags(target)):
++ ida_bytes.del_items(target, 0, 2)
++ if ida_ua.create_insn(target) <= 0: item["decoded"] = False; continue
++ item["decoded"] = True; item["function"] = bool(db.functions.get_at(target) or db.functions.create(target)); applied += 1
++result = {"start": hex(lo), "end": hex(hi), "found": found, "applied": applied, "n": len(found)}
++result
++'''
++
++_OPERATIONS["decomp_error"] = r'''
++import ida_hexrays, ida_ida
++ea = int(str(a["addr"]), 16); fn = db.functions.get_at(ea)
++result = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()}
++if fn is None:
++ result["reason"] = "no function here"
++else:
++ try:
++ failure = ida_hexrays.hexrays_failure_t(); cfunc = ida_hexrays.decompile_func(fn, failure)
++ if cfunc is not None: result["reason"] = ""
++ else:
++ result.update({"reason": failure.desc() or f"error {failure.code}",
++ "code": int(failure.code), "errea": hex(int(failure.errea))})
++ except Exception as exc: result["reason"] = f"{type(exc).__name__}: {exc}"
++result
++'''
++
++
++class CodeModeClient:
++ """A leased GUI/idalib database accessed through ``ida_codemode``."""
++
++ def __init__(
++ self,
++ binary_path: str,
++ *,
++ ttl: int = 0,
++ load_args: str = "",
++ processor: str | None = None,
++ loading_address: int | None = None,
++ file_type: str | None = None,
++ output_database: str | None = None,
++ spawn: bool = True,
++ new_database: bool = False,
++ ) -> None:
++ del ttl # managed-worker lifetime is lease-based, not idle-TTL based
++ self._path = os.path.abspath(os.path.expanduser(binary_path))
++ parsed_processor, parsed_address, parsed_file_type = _parse_load_args(load_args)
++ self._processor = processor or parsed_processor
++ self._loading_address = loading_address if loading_address is not None else parsed_address
++ self._file_type = file_type or parsed_file_type
++ self._output_database = output_database
++ self._spawn = spawn
++ self._new_database = new_database
++ self._handle: DatabaseHandle | None = None
++ self._last_entry: RegistryEntry | None = None
++ self._connect_lock = threading.Lock()
++
++ def connect(self, timeout: float = 1800.0, progress=None) -> "CodeModeClient":
++ with self._connect_lock:
++ if self._handle is not None and self._handle.connected:
++ return self
++ if progress:
++ progress(f"discovering Code Mode database for {os.path.basename(self._path)}…")
++ try:
++ # A Ctrl+L reload releases its current managed-worker lease, but
++ # that worker remains registered during Code Mode's final-lease
++ # grace period. Retry only that known handoff window. A GUI or
++ # another long-lived client remains busy and yields a clear
++ # failure rather than being modified underneath its owner.
++ deadline = time.monotonic() + min(timeout, 60.0)
++ while True:
++ try:
++ handle = DatabaseHandle.open(
++ self._path,
++ spawn=self._spawn,
++ timeout=max(0.1, timeout),
++ output_database=self._output_database,
++ processor=self._processor,
++ loading_address=self._loading_address,
++ file_type=self._file_type,
++ new_database=self._new_database,
++ )
++ break
++ except IdbBusy:
++ if not self._new_database or time.monotonic() >= deadline:
++ raise
++ if progress:
++ progress("waiting for the previous Code Mode lease to close…")
++ # Remember the record before managed shutdown withdraws
++ # its JSON. The lifetime lock remains held until IDA has
++ # actually closed the IDB; waiting on it avoids racing a
++ # replacement worker into the old process's file lock.
++ expected = canonical_path(
++ self._output_database or expected_idb_path(self._path)
++ )
++ owners = [item.entry for item in scan_instances(timeout=0.5)
++ if item.entry.idb_key == idb_key(expected)]
++ if owners:
++ self._wait_for_entry_release(
++ owners[0], max(0.0, deadline - time.monotonic())
++ )
++ else:
++ time.sleep(0.2)
++ if progress:
++ backend = handle.entry.backend
++ progress(f"attached to {backend} database; waiting for auto-analysis…")
++ handle.wait_autoanalysis(timeout=timeout)
++ except Exception as exc: # normalize the dependency's transport errors
++ raise self._connection_error(exc) from exc
++ self._handle = handle
++ self._last_entry = handle.entry
++ return self
++
++ @staticmethod
++ def _connection_error(exc: BaseException) -> IDAConnectionError:
++ return IDAConnectionError(str(exc) or type(exc).__name__)
++
++ @property
++ def connected(self) -> bool:
++ return self._handle is not None and self._handle.connected
++
++ @property
++ def pid(self) -> int | None:
++ return self._handle.entry.pid if self._handle is not None else None
++
++ @property
++ def backend(self) -> str | None:
++ return self._handle.entry.backend if self._handle is not None else None
++
++ def execute_python(self, code: str, *, timeout: float | None = None) -> Any:
++ if not self.connected:
++ self.connect()
++ handle = self._handle
++ if handle is None:
++ raise IDAConnectionError("Code Mode database is not connected")
++ try:
++ response = handle.execute_python(code, timeout=timeout)
++ except RemoteError as exc:
++ details = exc.details or {}
++ message = str(exc)
++ if details.get("traceback"):
++ message += f"\n{details['traceback']}"
++ if exc.code == "operation_timeout":
++ raise IDATimeoutError(message) from exc
++ raise IDAToolError("execute_python", message) from exc
++ except (InstanceDisconnectedError, ClientError) as exc:
++ raise self._connection_error(exc) from exc
++ if not isinstance(response, dict) or "result" not in response:
++ raise IDAToolError("execute_python", "Code Mode returned an invalid execution result")
++ return response["result"]
++
++ def invoke(self, operation: str, *, timeout: float | None = None, **args) -> Any:
++ """Execute one TUI domain operation through Code Mode."""
++ if operation in ("idb_save", "save"):
++ return self.save_database()
++ if operation in ("server_health", "ping", "health", "state"):
++ return self.health()
++ body = _HEADS if operation == "heads" else _OPERATIONS.get(operation)
++ if body is None:
++ raise IDAToolError(operation, f"unknown ida-tui Code Mode operation: {operation}")
++ try:
++ return self.execute_python(_script(args, body), timeout=timeout)
++ except IDAToolError as exc:
++ if exc.tool == "execute_python":
++ raise IDAToolError(operation, exc.message) from exc
++ raise
++
++ # Temporary source compatibility for external drivers/tests that used the
++ # old WorkerClient. Application code uses the accurately named invoke().
++ call = invoke
++
++ def save_database(self) -> dict[str, Any]:
++ if not self.connected:
++ self.connect()
++ handle = self._handle
++ if handle is None:
++ raise IDAConnectionError("Code Mode database is not connected")
++ try:
++ return handle.save_database()
++ except RemoteError as exc:
++ raise IDAToolError("save_database", str(exc)) from exc
++ except (InstanceDisconnectedError, ClientError) as exc:
++ raise self._connection_error(exc) from exc
++
++ def health(self) -> dict[str, Any]:
++ if not self.connected:
++ self.connect()
++ assert self._handle is not None
++ entry = self._handle.entry
++ module = os.path.basename(entry.exe_path or entry.idb_path or self._path)
++ return {
++ "ok": self._handle.connected,
++ "module": module,
++ "backend": entry.backend,
++ "record_id": entry.record_id,
++ "input_path": entry.exe_path,
++ "idb_path": entry.idb_path,
++ }
++
++ def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive:
++ del interval
++ return _NoopKeepAlive()
++
++ def resolve_db(self) -> str:
++ if not self.connected:
++ self.connect()
++ assert self._handle is not None
++ return self._handle.entry.record_id
++
++ def set_db(self, db: str | None) -> None:
++ del db # one handle is permanently bound to one registered database
++
++ def list_sessions(self) -> list[Session]:
++ if not self.connected:
++ self.connect()
++ assert self._handle is not None
++ entry = self._handle.entry
++ path = entry.exe_path or entry.idb_path or self._path
++ return [Session(session_id=entry.record_id, filename=os.path.basename(path),
++ input_path=path, is_active=True)]
++
++ def close(self, grace: float = 0.0) -> None:
++ del grace
++ with self._connect_lock:
++ handle, self._handle = self._handle, None
++ if handle is not None:
++ self._last_entry = handle.entry
++ handle.close() # release our lease; never close a GUI/other client's DB
++
++ @staticmethod
++ def _wait_for_entry_release(entry: RegistryEntry, timeout: float) -> bool:
++ path = REGISTRY_DIR / f"{entry.record_id}.lock"
++ deadline = time.monotonic() + max(0.0, timeout)
++ while True:
++ lock = FileLock(path)
++ try:
++ if lock.try_acquire():
++ return True
++ except OSError:
++ pass
++ finally:
++ lock.close()
++ if time.monotonic() >= deadline:
++ return False
++ time.sleep(min(0.1, deadline - time.monotonic()))
++
++ def wait_released(self, timeout: float = 45.0) -> bool:
++ """Wait until a managed instance releases its lifetime lock.
++
++ Normal application shutdown must not wait: another client may retain the
++ worker. This is an explicit test/maintenance helper for deleting a
++ temporary IDB safely after this client closes. GUI instances return
++ ``False`` immediately because clients never own their lifetime.
++ """
++ entry = self._last_entry
++ if entry is None or entry.backend != "idalib":
++ return False
++ return self._wait_for_entry_release(entry, timeout)
++
++ def __enter__(self) -> "CodeModeClient":
++ return self.connect()
++
++ def __exit__(self, *exc) -> None:
++ self.close()
+diff --git a/idatui/domain.py b/idatui/domain.py
+index 97b042d..2d6500e 100644
+--- a/idatui/domain.py
++++ b/idatui/domain.py
+@@ -1,19 +1,15 @@
+-"""Domain / paging layer: address-centric models over the raw MCP client.
++"""Domain / paging layer: address-centric models over IDA Code Mode.
+
+ This is where the "millions of lines" problem is solved, so the TUI widgets only
+ ever see a viewport-sized slice. Every hard-won constraint from
+ ``docs/PAGING_FINDINGS.md`` is encoded here:
+
+-* Per-call caps are silent (over the cap the server returns 10, not a clamp), so
+- we clamp page sizes ourselves: ``LIST_PAGE`` / ``DISASM_BLOCK`` <= the caps.
+-* ``next_offset`` is unreliable; we paginate by advancing ``len(data)``.
+-* ``disasm offset=N`` is O(N) with no resumable cursor, so windowed disassembly
+- is **block-cached** (revisits are free) and **prefetches** the next block on a
+- background thread (the client is concurrency-safe).
+-* ``include_total`` scans the whole function (~200ms on monsters); totals are
+- fetched once and cached.
+-* ``decompile`` can hard-fail on huge functions as a *soft* error (``code`` is
+- null); that is surfaced as data, not an exception.
++* Page sizes remain bounded so remote execution returns viewport-scale JSON.
++* Pagination advances by the number of rows actually returned.
++* Deep head walks are block-cached (revisits are free) and neighboring blocks
++ prefetch through the thread-safe Code Mode client.
++* Expensive function totals are fetched once and cached.
++* Decompilation failures are surfaced as data, not application crashes.
+
+ Everything here is synchronous and thread-safe. The TUI runs these calls from
+ Textual worker threads; the internal prefetch pool is separate and small.
+@@ -22,10 +18,8 @@ Textual worker threads; the internal prefetch pool is separate and small.
+ from __future__ import annotations
+
+ import bisect
+-import json
+ import re
+ import threading
+-import urllib.request
+ from concurrent.futures import ThreadPoolExecutor
+ from dataclasses import dataclass, field, replace
+ from typing import Callable, TYPE_CHECKING
+@@ -33,7 +27,7 @@ from typing import Callable, TYPE_CHECKING
+ from .errors import IDAToolError
+
+ if TYPE_CHECKING: # type hint only
+- from .worker_client import WorkerClient # noqa: F401
++ from .codemode_client import CodeModeClient
+
+ # Clamps derived from measured caps (list ~700, disasm ~500). Margin included.
+ LIST_PAGE = 500
+@@ -63,7 +57,7 @@ class Func:
+ def from_raw(cls, d: dict) -> "Func":
+ addr = _as_int(d["addr"])
+ name = d.get("name")
+- # An unnamed function (server returns null/empty) must still have a
++ # An unnamed function must still have a
+ # usable string name — synthesize IDA's sub_ADDR so every consumer
+ # (palette, sort, rename prefill) can treat name as a str.
+ if not name:
+@@ -91,7 +85,7 @@ class Line:
+
+ @dataclass(frozen=True)
+ class Head:
+- """One flat-listing item (from the ``heads`` server tool): a code
++ """One flat-listing item from the Code Mode ``heads`` operation: a code
+ instruction, a data item, or an undefined byte run."""
+
+ ea: int
+@@ -101,7 +95,7 @@ class Head:
+ name: str | None = None
+ raw: bytes | None = None # opcode/item bytes (filled in for code by the model)
+ #: [(kind, text)] from IDA's own colour tags — mnem/reg/num/name/str/punct/…
+- #: None when the worker didn't provide them (older worker, or the spans
++ #: None when Code Mode didn't provide them (or the spans
+ #: disagreed with the plain text, in which case the text wins).
+ spans: tuple[tuple[str, str], ...] | None = None
+
+@@ -240,7 +234,7 @@ class FunctionIndex:
+ """A lazily-paginated, cached view of the function list.
+
+ Loads pages of ``LIST_PAGE`` on demand, advancing by ``len(data)`` (never by
+- ``next_offset``). A single index instance corresponds to one server-side
++ ``next_offset``). A single index instance corresponds to one remote
+ ``filter`` glob (``None`` = all functions).
+ """
+
+@@ -260,7 +254,7 @@ class FunctionIndex:
+ query: dict = {"offset": offset, "count": LIST_PAGE}
+ if self.filter:
+ query["filter"] = self.filter
+- data = _query_data(self._prog.client.call("list_funcs", queries=[query]))
++ data = _query_data(self._prog.client.invoke("list_funcs", queries=[query]))
+ added = 0
+ with self._lock:
+ for d in data:
+@@ -370,7 +364,7 @@ class DisasmModel:
+ code function this equals the heads row count that backs the lines."""
+ if self._total is not None:
+ return self._total
+- payload = self._prog.client.call(
++ payload = self._prog.client.invoke(
+ "disasm", addr=hex(self.ea), max_instructions=1, include_total=True
+ )
+ total = payload.get("total_instructions")
+@@ -431,7 +425,7 @@ class DisasmModel:
+ # The function disasm view is a listing filtered to the function: fetch a
+ # block of heads (one per instruction for code). Over-fetch one row so
+ # the block knows where its last instruction ends (opcode-byte sizing).
+- payload = self._prog.client.call(
++ payload = self._prog.client.invoke(
+ "heads", addr=hex(self.ea), offset=b * self.BLOCK,
+ count=self.BLOCK + 1, **self._end_kw(),
+ )
+@@ -572,15 +566,15 @@ class ListingModel:
+ """A flat, IDA-style disassembly *listing* over one segment: code, data and
+ undefined heads interleaved, unlike ``DisasmModel`` (one function, code only).
+
+- Backed by the injected ``heads`` server tool, which walks item heads and
+- renders each via ``generate_disasm_line``. The segment is walked lazily in
++ Backed by the Code Mode adapter's ``heads`` operation, which walks item heads
++ and renders each via ``generate_disasm_line``. The segment is walked lazily in
+ forward pages (``FunctionIndex`` style); line index == position in the walked
+ head list. Random access to an address is O(distance-from-seg-start) the
+ first time (then cached) — the same tradeoff as ``disasm offset=N``. Grows
+ on demand as the viewport scrolls. Synchronous + thread-safe.
+ """
+
+- PAGE = 500 # heads per server call (well under the tool's 2000 cap)
++ PAGE = 500 # viewport-scale heads per Code Mode execution
+
+ def __init__(self, program: "Program", seg_start: int, seg_end: int,
+ name: str | None = None):
+@@ -654,7 +648,7 @@ class ListingModel:
+ if self._done or self._next is None:
+ return 0
+ frm = self._next
+- payload = self._prog.client.call(
++ payload = self._prog.client.invoke(
+ "heads", addr=hex(frm), count=self.PAGE, annotate=True)
+ rows = payload.get("heads", []) if isinstance(payload, dict) else []
+ cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
+@@ -956,7 +950,7 @@ class HexModel:
+ class Program:
+ """The bound analysis session: models, caches, and a small prefetch pool."""
+
+- def __init__(self, client: "WorkerClient", prefetch_workers: int = 2):
++ def __init__(self, client: "CodeModeClient", prefetch_workers: int = 2):
+ self.client = client
+ self._pool = ThreadPoolExecutor(
+ max_workers=prefetch_workers, thread_name_prefix="idatui-prefetch"
+@@ -973,7 +967,7 @@ class Program:
+ self._sections: list[tuple[int, int, str]] | None = None
+ self._fileregions: list[tuple[int, int, int]] | None = None
+ self._hexmodel: "HexModel | None" = None
+- self._no_read_raw = False # set if the server lacks the read_raw tool
++ self._no_read_raw = False # compatibility fallback for alternate clients
+ self._lock = threading.Lock()
+
+ # -- prefetch plumbing ------------------------------------------------- #
+@@ -1000,17 +994,14 @@ class Program:
+ """Sorted raw segment map [(start, end, file_off, name)] — the single
+ source for sections()/file_regions()/image_range. Cached.
+
+- Uses the injected ``file_regions`` tool (a plain segment walk, ~ms).
+- This deliberately AVOIDS ``survey_binary``, which also computes function
+- counts / strings / stats and takes *seconds* on a large IDB (it was the
+- cause of the multi-second hex-pane open). Falls back to survey_binary
+- only if the injected tool is missing.
++ Uses the Code Mode adapter's ``file_regions`` operation (a plain segment
++ walk, ~ms), avoiding broad binary surveys on the hex-pane open path.
+ """
+ if self._segments_cache is not None:
+ return self._segments_cache
+ segs: list[tuple[int, int, int, str]] = []
+ try:
+- r = self.client.call("file_regions")
++ r = self.client.invoke("file_regions")
+ for d in (r.get("regions", []) if isinstance(r, dict) else []):
+ if isinstance(d, dict) and "start" in d:
+ segs.append((_as_int(d["start"]), _as_int(d["end"]),
+@@ -1019,7 +1010,7 @@ class Program:
+ segs = []
+ if not segs: # older server without file_regions -> survey_binary (slow)
+ try:
+- sb = self.client.call("survey_binary")
++ sb = self.client.invoke("survey_binary")
+ for s in (sb.get("segments", []) if isinstance(sb, dict) else []):
+ try:
+ segs.append((_as_int(s["start"]), _as_int(s["end"]), -1,
+@@ -1061,8 +1052,7 @@ class Program:
+
+ def file_regions(self) -> list[tuple[int, int, int]]:
+ """Sorted [(start, end, file_off)] mapping loaded segments to raw file
+- offsets (file_off == -1 for non-file-backed, e.g. .bss). Cached; needs
+- the injected ``file_regions`` server tool."""
++ offsets (file_off == -1 for non-file-backed, e.g. .bss). Cached."""
+ if self._fileregions is not None:
+ return self._fileregions
+ regions = [(s, e, fo) for s, e, fo, _nm in self._segments()]
+@@ -1080,15 +1070,14 @@ class Program:
+ def read_bytes(self, ea: int, n: int) -> bytes:
+ """Raw bytes [ea, ea+n) from IDA (gaps read as zero).
+
+- Fast path: the injected ``read_raw`` tool returns one contiguous hex
+- string (C-speed both ends). Falls back to the stock ``get_bytes`` (a
+- per-byte '0x..'-with-spaces string) on an older server without it.
++ The Code Mode adapter returns one contiguous hex string (C-speed in IDA).
++ A legacy ``get_bytes`` decoding fallback remains for alternate clients.
+ """
+ if n <= 0:
+ return b""
+ if not self._no_read_raw:
+ try:
+- r = self.client.call("read_raw", addr=hex(ea), size=int(n))
++ r = self.client.invoke("read_raw", addr=hex(ea), size=int(n))
+ h = r.get("hex") if isinstance(r, dict) else None
+ if isinstance(h, str):
+ out = bytes.fromhex(h)
+@@ -1102,7 +1091,7 @@ class Program:
+ except (ValueError, KeyError):
+ pass # malformed hex -> fall through to the legacy decoder
+ try:
+- r = self.client.call("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}])
++ r = self.client.invoke("get_bytes", regions=[{"addr": hex(ea), "size": int(n)}])
+ except IDAToolError:
+ return b"\x00" * n
+ res = r.get("result", []) if isinstance(r, dict) else []
+@@ -1151,7 +1140,7 @@ class Program:
+ def list_structs(self, filter: str = "") -> list[Struct]:
+ """All local structs/unions (optionally name-substring filtered), sorted
+ by name."""
+- payload = self.client.call("search_structs", filter=filter)
++ payload = self.client.invoke("search_structs", filter=filter)
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ out = [Struct.from_raw(d) for d in res
+ if isinstance(d, dict) and d.get("name")
+@@ -1161,9 +1150,9 @@ class Program:
+
+ def struct_source(self, name: str) -> str:
+ """A C definition for ``name`` reconstructed from its member layout
+- (the server exposes members, not printable source). Faithful to IDA's
++ (the remote operation exposes members, not printable source). Faithful to IDA's
+ field names/types; array dims are moved after the field name."""
+- payload = self.client.call(
++ payload = self.client.invoke(
+ "type_inspect", queries=[{"name": name, "include_members": True}])
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ info = res[0] if res and isinstance(res[0], dict) else {}
+@@ -1186,7 +1175,7 @@ class Program:
+ def declare_type(self, decl: str) -> str | None:
+ """Create or update a C type. Returns None on success, else the parse
+ error. (Re-declaring a name updates it in place.)"""
+- payload = self.client.call("declare_type", decls=decl)
++ payload = self.client.invoke("declare_type", decls=decl)
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ if res and isinstance(res[0], dict):
+ return res[0].get("error")
+@@ -1195,10 +1184,9 @@ class Program:
+ # -- function / variable types ---------------------------------------- #
+ def func_types(self, ea: int) -> FuncTypes | None:
+ """Structured decompiler types for the function at ``ea`` (prototype +
+- local variables). None if ``ea`` isn't a decompilable function. Requires
+- the injected ``func_types`` server tool."""
++ local variables). None if ``ea`` isn't a decompilable function."""
+ try:
+- r = self.client.call("func_types", addr=hex(ea))
++ r = self.client.invoke("func_types", addr=hex(ea))
+ except IDAToolError:
+ return None
+ if not isinstance(r, dict) or r.get("error"):
+@@ -1211,7 +1199,7 @@ class Program:
+
+ def set_function_type(self, ea: int, signature: str) -> str | None:
+ """Set a function's prototype. None on success, else an error string."""
+- r = self.client.call("set_type", edits=[{"addr": hex(ea), "signature": signature}])
++ r = self.client.invoke("set_type", edits=[{"addr": hex(ea), "signature": signature}])
+ res = r.get("result", []) if isinstance(r, dict) else []
+ row = res[0] if res and isinstance(res[0], dict) else {}
+ if row.get("ok"):
+@@ -1220,9 +1208,9 @@ class Program:
+
+ def data_type(self, ea: int) -> dict | None:
+ """Current type info for a data item/global: {addr,name,type,size,is_func}.
+- None if the tool is unavailable or the address isn't mapped."""
++ None if the operation fails or the address isn't mapped."""
+ try:
+- r = self.client.call("data_type", addr=hex(ea))
++ r = self.client.invoke("data_type", addr=hex(ea))
+ except IDAToolError:
+ return None
+ if not isinstance(r, dict) or r.get("error"):
+@@ -1231,7 +1219,7 @@ class Program:
+
+ def set_data_type(self, ea: int, decl: str) -> str | None:
+ """Set a global/data item's type. None on success, else an error string."""
+- r = self.client.call(
++ r = self.client.invoke(
+ "set_type", edits=[{"kind": "global", "addr": hex(ea), "type": decl}])
+ res = r.get("result", []) if isinstance(r, dict) else []
+ row = res[0] if res and isinstance(res[0], dict) else {}
+@@ -1240,9 +1228,9 @@ class Program:
+ return row.get("error") or "failed to set the type"
+
+ def set_lvar_type(self, fn_ea: int, var: str, ty: str) -> str | None:
+- """Set a decompiler local variable's type (via the injected server tool).
++ """Set a decompiler local variable's type through ida-domain pseudocode.
+ None on success, else an error string."""
+- r = self.client.call("set_lvar_type", addr=hex(fn_ea), variable=var, type=ty)
++ r = self.client.invoke("set_lvar_type", addr=hex(fn_ea), variable=var, type=ty)
+ if isinstance(r, dict) and r.get("error"):
+ return r["error"]
+ if isinstance(r, dict) and not r.get("ok"):
+@@ -1251,15 +1239,14 @@ class Program:
+
+ def delete_type(self, name: str) -> str | None:
+ """Delete a named type. Returns None on success, else an error string.
+- Requires a server-side ``del_type`` tool; if absent, a clear message is
+- returned instead of raising."""
++ Returns a clear error instead of raising when the runtime cannot do it."""
+ try:
+- self.client.call("del_type", name=name)
++ self.client.invoke("del_type", name=name)
+ return None
+ except IDAToolError as e:
+ msg = e.message
+ if "not found" in msg.lower() and "del_type" in msg:
+- return "delete needs a 'del_type' tool on the ida-pro-mcp server"
++ return "the connected Code Mode runtime cannot delete local types"
+ return msg
+
+ # -- disassembly ------------------------------------------------------- #
+@@ -1273,13 +1260,7 @@ class Program:
+
+ # -- decompilation ----------------------------------------------------- #
+ def decompile(self, ea: int, refresh: bool = False) -> Decompilation:
+- """Full pseudocode for a function.
+-
+- The server truncates responses over 50KB (strings clipped to 1000
+- chars) but caches the full output and exposes it at
+- ``_meta.ida_mcp.download_url``. We transparently fetch that so the view
+- always gets the complete body, not a 1KB stub.
+- """
++ """Full pseudocode for a function, returned directly by Code Mode."""
+ if not refresh:
+ with self._lock:
+ hit = self._decomp.get(ea)
+@@ -1288,10 +1269,10 @@ class Program:
+ dec, hit_gen = hit
+ if hit_gen == gen:
+ return dec
+- # Cached before a rename: names may be stale. Drop the server's
+- # Hex-Rays cache so the refetch reflects the new names.
++ # Cached before a rename: names may be stale. Drop Hex-Rays'
++ # cache so the refetch reflects the new names.
+ try:
+- self.client.call("force_recompile", items=[{"addr": hex(ea)}])
++ self.client.invoke("force_recompile", items=[{"addr": hex(ea)}])
+ except Exception: # noqa: BLE001
+ pass
+ # Bound the decompile: a function Hex-Rays can't handle tends to stall
+@@ -1301,7 +1282,10 @@ class Program:
+ # rpcclient socket timeout, and cache the failure below so a re-request
+ # returns instantly instead of re-grinding.
+ try:
+- envelope = self.client.call_envelope(
++ # Code Mode returns the complete JSON result directly; unlike the
++ # old MCP tool transport there is no structured-content envelope or
++ # out-of-band download URL to unwrap.
++ payload = self.client.invoke(
+ "decompile", addr=hex(ea), timeout=DECOMPILE_TIMEOUT
+ )
+ except Exception as e: # noqa: BLE001 -- surface as a failed decompile
+@@ -1309,15 +1293,6 @@ class Program:
+ with self._lock:
+ self._decomp[ea] = (dec, self._name_gen)
+ return dec
+- result = envelope.get("result", {})
+- payload = result.get("structuredContent")
+- if payload is None: # fall back to text content
+- payload = self.client._extract_payload("decompile", result)
+- meta = (result.get("_meta") or {}).get("ida_mcp")
+- if isinstance(meta, dict) and meta.get("download_url"):
+- full = self._fetch_output(meta["download_url"])
+- if isinstance(full, dict) and full.get("code"):
+- payload = full
+ dec = _parse_decompilation(ea, payload)
+ with self._lock:
+ self._decomp[ea] = (dec, self._name_gen)
+@@ -1365,18 +1340,18 @@ class Program:
+ Undefine first so it works even when the bytes are currently part of a
+ data/align item — ``create_insn`` refuses to carve into a live item."""
+ try:
+- self.client.call("undefine", items=[{"addr": hex(ea)}])
++ self.client.invoke("undefine", items=[{"addr": hex(ea)}])
+ except IDAToolError:
+ pass # nothing defined here yet -> just try to create the insn
+ res = self._first_result(
+- self.client.call("define_code", items=[{"addr": hex(ea)}]))
++ self.client.invoke("define_code", items=[{"addr": hex(ea)}]))
+ if res.get("error"):
+ raise IDAToolError("define_code", f"@ {ea:#x}: {res['error']}")
+
+ def decomp_error(self, ea: int) -> str:
+ """Hex-Rays' own reason for refusing ``ea``, or "" if it won't say."""
+ try:
+- r = self.client.call("decomp_error", addr=hex(ea))
++ r = self.client.invoke("decomp_error", addr=hex(ea))
+ except IDAToolError:
+ return ""
+ if not isinstance(r, dict):
+@@ -1395,7 +1370,7 @@ class Program:
+
+ def thumb_scan(self, start: int, end: int, apply: bool = True) -> dict:
+ """Find Thumb entry points from odd pointers in ``[start, end)``."""
+- r = self.client.call("thumb_scan", start=hex(start), end=hex(end),
++ r = self.client.invoke("thumb_scan", start=hex(start), end=hex(end),
+ apply=bool(apply))
+ if not isinstance(r, dict) or r.get("error"):
+ raise IDAToolError("thumb_scan",
+@@ -1404,7 +1379,7 @@ class Program:
+
+ def set_thumb(self, ea: int, mode: str = "toggle") -> dict:
+ """Switch ARM/Thumb decoding at ``ea``. Returns the resulting state."""
+- r = self.client.call("set_thumb", addr=hex(ea), mode=mode)
++ r = self.client.invoke("set_thumb", addr=hex(ea), mode=mode)
+ if not isinstance(r, dict) or r.get("error"):
+ raise IDAToolError("set_thumb",
+ f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+@@ -1413,11 +1388,11 @@ class Program:
+ def define_code_run(self, ea: int, limit: int = 20000) -> dict:
+ """Disassemble consecutively from ``ea`` until something stops it.
+
+- Falls back to a single instruction when the worker predates the tool, so
+- an old worker degrades to the previous behaviour instead of failing.
++ Falls back to a single instruction for alternate clients that do not
++ provide the run operation.
+ """
+ try:
+- r = self.client.call("define_code_run", addr=hex(ea), limit=int(limit))
++ r = self.client.invoke("define_code_run", addr=hex(ea), limit=int(limit))
+ except IDAToolError:
+ self.define_code(ea)
+ return {"count": 1, "stopped": "single", "end": hex(ea)}
+@@ -1429,14 +1404,14 @@ class Program:
+ def define_func(self, ea: int) -> dict:
+ """Create a function starting at ``ea`` (IDA's 'p').
+
+- Prefers the injected tool, which works out the end when IDA can't;
+- falls back to the plain one for an older worker.
++ Prefers the Code Mode operation, which works out the end when IDA can't;
++ falls back to a plain create for alternate clients.
+ """
+ try:
+- r = self.client.call("define_func_run", addr=hex(ea))
++ r = self.client.invoke("define_func_run", addr=hex(ea))
+ except IDAToolError:
+ res = self._first_result(
+- self.client.call("define_func", items=[{"addr": hex(ea)}]))
++ self.client.invoke("define_func", items=[{"addr": hex(ea)}]))
+ if res.get("error"):
+ raise IDAToolError("define_func", f"@ {ea:#x}: {res['error']}")
+ return {"ok": True, "how": "legacy"}
+@@ -1450,7 +1425,7 @@ class Program:
+ item: dict = {"addr": hex(ea)}
+ if size:
+ item["size"] = int(size)
+- res = self._first_result(self.client.call("undefine", items=[item]))
++ res = self._first_result(self.client.invoke("undefine", items=[item]))
+ if res.get("error"):
+ raise IDAToolError("undefine", f"@ {ea:#x}: {res['error']}")
+
+@@ -1460,7 +1435,7 @@ class Program:
+ item: dict = {"addr": hex(ea), "type": type_decl}
+ if name:
+ item["name"] = name
+- res = self._first_result(self.client.call("make_data", items=[item]))
++ res = self._first_result(self.client.invoke("make_data", items=[item]))
+ if res.get("ok") is False or res.get("error"):
+ raise IDAToolError(
+ "make_data", f"@ {ea:#x}: {res.get('error') or 'rejected'}")
+@@ -1468,7 +1443,7 @@ class Program:
+ def make_string(self, ea: int, length: int = 0, kind: str = "c") -> str:
+ """Create a string literal at ``ea`` (IDA's 'A'); auto-length when 0.
+ Returns the decoded contents."""
+- r = self.client.call("make_string", addr=hex(ea), length=int(length), kind=kind)
++ r = self.client.invoke("make_string", addr=hex(ea), length=int(length), kind=kind)
+ res = r if isinstance(r, dict) else {}
+ if not res.get("ok"):
+ raise IDAToolError(
+@@ -1483,15 +1458,6 @@ class Program:
+ sec = None
+ return f"{sec} @ {ea:#x}" if sec else f"<no function> @ {ea:#x}"
+
+- @staticmethod
+- def _fetch_output(url: str, timeout: float = 15.0):
+- """GET the server's cached full-output blob (plain HTTP, not MCP)."""
+- try:
+- with urllib.request.urlopen(url, timeout=timeout) as r:
+- return json.loads(r.read().decode("utf-8", "replace"))
+- except Exception: # noqa: BLE001 -- fall back to the truncated preview
+- return None
+-
+ def strings(self, min_len: int = 4, refresh: bool = False) -> list[StrLit]:
+ """Every string literal in the binary (IDA's Shift+F12 list), paged in
+ full and cached. ``[]`` if the tool is unavailable."""
+@@ -1504,7 +1470,7 @@ class Program:
+ offset, page = 0, 2000
+ while True:
+ try:
+- payload = self.client.call(
++ payload = self.client.invoke(
+ "list_strings", offset=offset, count=page, min_len=min_len,
+ refresh=(refresh and offset == 0))
+ except IDAToolError:
+@@ -1529,13 +1495,13 @@ class Program:
+
+ def linkage(self) -> tuple[list[Linkage], list[Linkage]]:
+ """``(imports, exports)`` for this binary, cached. ``([], [])`` if the
+- tool is unavailable — an old worker must not break the caller."""
++ operation is unavailable — an alternate client must not break the caller."""
+ with self._lock:
+ hit = self._linkage
+ if hit is not None:
+ return hit
+ try:
+- payload = self.client.call("list_linkage", kind="both")
++ payload = self.client.invoke("list_linkage", kind="both")
+ except IDAToolError:
+ return ([], [])
+ if not isinstance(payload, dict):
+@@ -1566,7 +1532,7 @@ class Program:
+ if hit is not None and hit[1] == gen:
+ return hit[0]
+ try:
+- payload = self.client.call("decomp_map", addr=hex(ea))
++ payload = self.client.invoke("decomp_map", addr=hex(ea))
+ except IDAToolError:
+ return []
+ lines = payload.get("lines", []) if isinstance(payload, dict) else []
+@@ -1579,13 +1545,13 @@ class Program:
+ # -- cross-references & containing function --------------------------- #
+ def function_of(self, ea: int) -> Func | None:
+ """Return the function containing ``ea`` (resolves mid-function addrs)."""
+- payload = self.client.call("lookup_funcs", queries=[hex(ea)])
++ payload = self.client.invoke("lookup_funcs", queries=[hex(ea)])
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ fn = res[0].get("fn") if res and isinstance(res[0], dict) else None
+ return Func.from_raw(fn) if fn else None
+
+ def xrefs_from(self, ea: int) -> list[Xref]:
+- payload = self.client.call(
++ payload = self.client.invoke(
+ "xref_query",
+ queries=[{"addr": hex(ea), "direction": "from", "include_fn": True}],
+ )
+@@ -1597,9 +1563,9 @@ class Program:
+ try:
+ # xref_types adds a fine-grained `kind` (call/read/write/...) for the
+ # xref dialog; fall back to xref_query (code/data only) if absent.
+- payload = self.client.call("xref_types", queries=q)
++ payload = self.client.invoke("xref_types", queries=q)
+ except IDAToolError:
+- payload = self.client.call("xref_query", queries=q)
++ payload = self.client.invoke("xref_query", queries=q)
+ return _parse_xrefs(payload)
+
+ # -- address resolution ------------------------------------------------ #
+@@ -1617,17 +1583,17 @@ class Program:
+ # (loc_/locret_): lookup_funcs would map a label to its *containing*
+ # function's entry, so double-clicking a label jumped to the wrong place.
+ try:
+- payload = self.client.call("resolve_names", queries=[s])
++ payload = self.client.invoke("resolve_names", queries=[s])
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+ ea = res[0].get("ea") if res and isinstance(res[0], dict) else None
+ if ea:
+ return _as_int(ea)
+ except IDAToolError:
+- pass # older server without resolve_names -> fall back below
++ pass # alternate client without resolve_names -> fall back below
+ # Fall back to function-name resolution (also drives the 'did you mean'
+ # suggestion when the name is unknown).
+ try:
+- payload = self.client.call("lookup_funcs", queries=[s])
++ payload = self.client.invoke("lookup_funcs", queries=[s])
+ except IDAToolError as e:
+ raise KeyError(f"cannot resolve {target!r}: {e}") from e
+ res = payload.get("result", []) if isinstance(payload, dict) else []
+@@ -1665,7 +1631,7 @@ class Program:
+ """Set (empty text clears) the comment at ``ea``; affects both the disasm
+ and decompiler views. Returns the raw payload so the caller can surface a
+ soft per-item error. The caller must invalidate/recompile to see it."""
+- return self.client.call("set_comments", items=[{"addr": hex(ea), "comment": text}])
++ return self.client.invoke("set_comments", items=[{"addr": hex(ea), "comment": text}])
+
+ # -- invalidation (after edits) --------------------------------------- #
+ def invalidate(self, ea: int) -> None:
+diff --git a/idatui/drive.py b/idatui/drive.py
+index b6fa641..0d865b5 100644
+--- a/idatui/drive.py
++++ b/idatui/drive.py
+@@ -121,7 +121,8 @@ def cmd_pc(c, args):
+ lines = d["code"].splitlines()
+ if needle:
+ nlow = needle.lower()
+- lines = [f"{i:4} {l}" for i, l in enumerate(lines) if nlow in l.lower()]
++ lines = [f"{i:4} {line}" for i, line in enumerate(lines)
++ if nlow in line.lower()]
+ return "\n".join(lines) or f"(no line matches {needle!r})"
+ return d["code"]
+
+diff --git a/idatui/errors.py b/idatui/errors.py
+index 29b09ae..aaf2dc5 100644
+--- a/idatui/errors.py
++++ b/idatui/errors.py
+@@ -1,10 +1,8 @@
+-"""Transport-agnostic error hierarchy and the Session model.
++"""TUI-facing error hierarchy and lightweight database session model.
+
+-These were originally defined in client.py (the ida-pro-mcp HTTP client), but the
+-idalib worker path (worker_client / domain / app) needs the same exception types
+-and Session dataclass without dragging in the HTTP transport. They live here so
+-both backends share one definition; client.py re-exports them for backwards
+-compatibility with the (deprecated) mcp tooling and the stress tests.
++The Code Mode adapter normalizes ``ida_codemode.client`` transport and execution
++errors into these types so the domain and Textual layers do not depend on HTTP or
++registry implementation details.
+ """
+ from __future__ import annotations
+
+diff --git a/idatui/launch.py b/idatui/launch.py
+index 64e1546..f78213c 100644
+--- a/idatui/launch.py
++++ b/idatui/launch.py
+@@ -1,15 +1,13 @@
+-"""One-shot launcher: ``ida-tui foo.elf`` and you're in the TUI.
++"""One-shot launcher for the IDA Code Mode-backed TUI.
+
+-Spawns a private idalib worker (``idatui.worker``) that opens + auto-analyzes
+-THIS binary in its own process, talking to the TUI over a unix socket. No shared
+-supervisor, no HTTP: everything slow (open + analysis) happens behind the TUI's
+-loading overlay.
++A path first resolves to a registered GUI database; when none matches, Code Mode
++reuses or starts a managed idalib worker. With no path, a single registered
++database is selected automatically.
+
+-Usage:
++Usage::
+
+- ida-tui /path/to/binary # open a binary and drive it
+-
+-Extras: --ttl, --no-keepalive, --rpc (all forwarded to the TUI).
++ ida-tui /path/to/binary
++ ida-tui # attach when exactly one database is registered
+ """
+ from __future__ import annotations
+
+@@ -17,12 +15,6 @@ import argparse
+ import os
+ import sys
+
+-# The unpacked working-copy files IDA writes next to a `.i64` while a database is
+-# open. A hard-killed worker leaves them behind and the `.i64` then refuses to
+-# reopen ("Failed to open database"). Safe to delete when nothing holds the DB.
+-_LOCK_SUFFIXES = (".id0", ".id1", ".id2", ".nam", ".til")
+-
+-
+ def _load_args(load: dict) -> str:
+ """``load`` as IDA switches, for the single-binary path (no project ref)."""
+ from .formats import load_args
+@@ -34,24 +26,19 @@ def _log(msg: str) -> None:
+ print(f"ida-tui: {msg}", file=sys.stderr)
+
+
+-def _sweep_locks(binary: str) -> int:
+- """Remove stale unpacked DB files next to ``binary``. Returns how many."""
+- stem = os.path.splitext(binary)[0]
+- n = 0
+- for base in (binary, stem): # IDA may key on the full name or the stem
+- for suf in _LOCK_SUFFIXES:
+- try:
+- os.remove(base + suf)
+- n += 1
+- except OSError:
+- pass
+- return n
++def _registered_databases() -> tuple[list[dict], list[dict]]:
++ """Ready and blocked Code Mode registrations, with normalized errors."""
++ try:
++ from ida_codemode.registry import discover_instances
++ return discover_instances()
++ except Exception as exc: # discovery diagnostics belong at the CLI boundary
++ return [], [{"error": str(exc)}]
+
+
+ def main(argv: list[str] | None = None) -> int:
+ p = argparse.ArgumentParser(
+ prog="ida-tui",
+- description="Open a binary in the IDA TUI (private idalib worker).")
++ description="Open a registered GUI or managed idalib database in the IDA TUI.")
+ p.add_argument("binary", nargs="*",
+ help="binary to open and analyze (several with --project "
+ "creates/extends that project)")
+@@ -59,9 +46,9 @@ def main(argv: list[str] | None = None) -> int:
+ help="open a multi-binary project (created from the given "
+ "binaries if FILE doesn't exist)")
+ p.add_argument("--ttl", type=int, default=1800,
+- help="worker idle-TTL seconds (default 1800)")
++ help="deprecated compatibility option (Code Mode uses leases)")
+ p.add_argument("--no-keepalive", action="store_true",
+- help="do not run the keepalive heartbeat")
++ help="deprecated compatibility option (the lease is the heartbeat)")
+ p.add_argument("--rpc", metavar="PATH",
+ help="listen for RPC on this unix socket (puppeteer the TUI)")
+ p.add_argument("--trace", metavar="FILE",
+@@ -76,7 +63,7 @@ def main(argv: list[str] | None = None) -> int:
+ g.add_argument("--base", metavar="ADDR",
+ help="load address, e.g. 0x8000000 (any base; NOT paragraphs)")
+ g.add_argument("--ida-args", metavar="STR", dest="ida_args",
+- help="extra IDA command-line switches, passed through as-is")
++ help="legacy switches; only Code Mode-representable -p/-b/-T are accepted")
+ args = p.parse_args(argv)
+
+ load: dict = {}
+@@ -134,27 +121,42 @@ def main(argv: list[str] | None = None) -> int:
+ _log(str(e))
+ return 2
+ else:
+- if len(args.binary) != 1:
+- _log("give exactly one binary, or use --project for several")
++ ready, blocked = _registered_databases()
++ if len(args.binary) > 1:
++ _log("give at most one binary, or use --project for several")
+ return 2
+- binary = os.path.abspath(os.path.expanduser(args.binary[0]))
+- if not os.path.isfile(binary):
+- _log(f"no such file: {binary}")
++ if args.binary:
++ binary = os.path.abspath(os.path.expanduser(args.binary[0]))
++ key = os.path.normcase(os.path.realpath(binary))
++ registered = any(
++ key == os.path.normcase(os.path.realpath(str(item.get(field) or "")))
++ for item in ready for field in ("exe_path", "idb_path")
++ if item.get(field)
++ )
++ if not os.path.isfile(binary) and not registered:
++ _log(f"no such file or registered database: {binary}")
++ return 2
++ elif len(ready) == 1:
++ item = ready[0]
++ binary = str(item.get("exe_path") or item.get("idb_path") or "")
++ _log(f"attaching to registered {item.get('backend')} database: {binary}")
++ elif not ready:
++ detail = f" ({blocked[0].get('error')})" if blocked else ""
++ _log(f"no registered Code Mode database; pass a binary path{detail}")
+ return 2
+- if not os.access(os.path.dirname(binary), os.W_OK):
+- _log(f"directory not writable (IDA writes a .i64 there): "
+- f"{os.path.dirname(binary)}")
++ else:
++ _log("several Code Mode databases are registered; pass one of these paths:")
++ for item in ready:
++ _log(f" {item.get('exe_path') or item.get('idb_path')} "
++ f"[{item.get('backend')}, {item.get('record_id')}]")
+ return 2
+- swept = _sweep_locks(binary) # a crashed worker can leave the DB wedged
+- if swept:
+- _log(f"cleared {swept} stale lock file(s) from a crashed worker")
+
+- # Hand off to the TUI (imported late so --help works without textual). It
+- # spawns the worker behind its loading overlay while auto-analysis runs.
++ # Hand off to the TUI (imported late so --help works without Textual). Code
++ # Mode discovery/opening happens behind its loading overlay.
+ try:
+ from .app import IdaTui
+ except ImportError as e:
+- _log(f"the TUI needs textual; run with ~/ida-venv/bin/python ({e})")
++ _log(f"TUI dependencies are missing; run `uv sync` ({e})")
+ return 1
+ rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None
+ IdaTui(open_path=binary, keepalive=not args.no_keepalive,
+diff --git a/idatui/pane.py b/idatui/pane.py
+index 37a5f0c..0f8fa57 100644
+--- a/idatui/pane.py
++++ b/idatui/pane.py
+@@ -15,9 +15,9 @@ then close it — all without a human touching the keyboard.
+ python -m idatui.pane list
+ python -m idatui.pane stop --sock <sock> # graceful quit + kill pane
+
+-Requires: running inside tmux. Each pane spawns its own private idalib worker
+-(no shared supervisor). Uses ~/ida-venv/bin/python for the TUI (needs textual)
+-unless --python / IDATUI_PYTHON says otherwise.
++Requires: running inside tmux. Each pane leases a registered GUI or shared
++managed idalib database through Code Mode. Uses ~/ida-venv/bin/python for the
++TUI unless --python / IDATUI_PYTHON says otherwise.
+ """
+ from __future__ import annotations
+
+@@ -25,7 +25,6 @@ import argparse
+ import json
+ import os
+ import secrets
+-import signal
+ import subprocess
+ import sys
+ import time
+@@ -72,56 +71,14 @@ def _tmux(*args: str) -> str:
+ check=True).stdout.strip()
+
+
+-# --------------------------------------------------------------------------- #
+-# idalib worker reaping
+-#
+-# ``pane stop`` kills the TUI pane, but a hard-killed pane can leave its private
+-# idalib worker (idatui/worker.py) running. A worker is only *safe* to reap when
+-# no idatui pane is live (then every worker is orphaned), which avoids killing an
+-# in-use analyser.
+-# --------------------------------------------------------------------------- #
+-_WORKER_PATTERN = r"idatui/worker\.py"
+-
+-
+-def _worker_pids() -> list[int]:
+- """PIDs of our private per-pane idalib worker processes (idatui/worker.py),
+- never our own PID."""
+- try:
+- out = subprocess.run(["pgrep", "-f", _WORKER_PATTERN],
+- capture_output=True, text=True)
+- except OSError:
+- return []
+- me = os.getpid()
+- pids: list[int] = []
+- for tok in out.stdout.split():
+- try:
+- pid = int(tok)
+- except ValueError:
+- continue
+- if pid != me:
+- pids.append(pid)
+- return pids
+-
+-
+ def _count_live_panes() -> int:
+ return sum(1 for r in _load_registry() if _pane_alive(r.get("pane", "")))
+
+
+ def _reap_orphan_workers(force: bool = False) -> int:
+- """Kill leaked idalib workers when it is safe (no live pane) or ``force``.
+-
+- Returns the number of workers signalled. Best-effort; never raises.
+- """
+- if not force and _count_live_panes() > 0:
+- return 0
+- reaped = 0
+- for pid in _worker_pids():
+- try:
+- os.kill(pid, signal.SIGKILL)
+- reaped += 1
+- except OSError:
+- pass
+- return reaped
++ """Compatibility no-op: Code Mode workers are shared and lease-managed."""
++ del force
++ return 0
+
+
+ # --------------------------------------------------------------------------- #
+@@ -146,16 +103,8 @@ def spawn(args) -> int:
+ print(f"error: no such project: {project}", file=sys.stderr)
+ return 2
+
+- # Reap workers leaked by previously-stopped/crashed panes so we don't spawn
+- # into a full IDA_MCP_MAX_WORKERS (which makes the new TUI hang forever,
+- # never reaching ready). No-op while any pane is live.
+- reaped = _reap_orphan_workers()
+- if reaped:
+- print(f"reaped {reaped} orphaned idalib worker(s) before spawn",
+- file=sys.stderr)
+-
+- # the command the pane runs: the launcher spawns a private idalib worker for
+- # this binary and becomes the TUI, so kill-pane tears the whole thing down.
++ # The pane owns only the TUI. Code Mode's lease cleanup handles crashes;
++ # kill-pane must never reap a shared GUI/idalib database.
+ if project is not None:
+ # launch takes: --project FILE [binaries...]; extra binaries are added to
+ # the project (and a missing project file is created from them).
+@@ -202,9 +151,8 @@ def _wait_ready(sock: str, timeout: float, pane: str,
+ stuck_after: float = 45.0) -> dict[str, Any]:
+ """Poll the socket + ping until the TUI reports ready (or timeout).
+
+- Emits a one-time hint to stderr if it's still not ready after ``stuck_after``
+- seconds, so a wedged idalib worker / full worker pool surfaces a diagnostic
+- instead of an unexplained silent hang.
++ Emits a one-time hint if Code Mode discovery/opening is still not ready after
++ ``stuck_after`` seconds.
+ """
+ start = time.time()
+ deadline = start + timeout
+@@ -225,9 +173,8 @@ def _wait_ready(sock: str, timeout: float, pane: str,
+ warned = True
+ why = ("RPC socket not created yet" if not os.path.exists(sock)
+ else "TUI up but analysis not ready")
+- print(f"still waiting ({int(time.time() - start)}s): {why}. If this "
+- f"hangs, the idalib worker may be stuck — try "
+- f"`python -m idatui.pane reap`.", file=sys.stderr)
++ print(f"still waiting ({int(time.time() - start)}s): {why}. "
++ f"Check Code Mode registrations and worker logs.", file=sys.stderr)
+ time.sleep(0.4)
+ last = dict(last)
+ last["ready"] = False
+@@ -317,13 +264,9 @@ def list_panes(args) -> int:
+
+
+ def reap(args) -> int:
+- """Kill leaked idalib workers (safe when no pane is live; --force overrides)."""
+- live = _count_live_panes()
+- n = _reap_orphan_workers(force=args.force)
+- print(json.dumps({"reaped_workers": n, "live_panes": live, "forced": args.force}))
+- if n == 0 and not args.force and live > 0:
+- print(f"note: {live} live pane(s) — not reaping in-use workers; pass "
+- f"--force to reap anyway", file=sys.stderr)
++ """Deprecated no-op; shared Code Mode workers are managed by leases."""
++ print(json.dumps({"reaped_workers": 0, "live_panes": _count_live_panes(),
++ "forced": args.force, "deprecated": True}))
+ return 0
+
+
+@@ -361,9 +304,8 @@ def main(argv: list[str]) -> int:
+ ls.add_argument("--prune", action="store_true", help="drop dead panes (and their sockets)")
+ ls.set_defaults(fn=list_panes)
+
+- rp = sub.add_parser("reap", help="kill leaked idalib workers (frees worker slots)")
+- rp.add_argument("--force", action="store_true",
+- help="reap even while panes are live (may kill an in-use analyser)")
++ rp = sub.add_parser("reap", help="deprecated no-op (Code Mode uses shared leases)")
++ rp.add_argument("--force", action="store_true", help=argparse.SUPPRESS)
+ rp.set_defaults(fn=reap)
+
+ args = p.parse_args(argv)
+diff --git a/idatui/pool.py b/idatui/pool.py
+index ae37c25..465dff2 100644
+--- a/idatui/pool.py
++++ b/idatui/pool.py
+@@ -1,23 +1,16 @@
+-"""WorkerPool — keeps a live idalib worker per project binary, within a budget.
++"""DatabasePool — LRU leases on Code Mode databases for a project.
+
+-One worker process holds exactly one database (idalib is single-DB and
+-main-thread-only), so a project with N binaries means up to N processes. They are
+-not cheap and they do not share: a worker on ``bash`` measures ~126 MB RSS /
+-117 MB PSS, and the database working set dominates for anything larger
+-(``libcrypto.so.3``'s ``.i64`` alone is 72 MB).
++Code Mode may bind a lease to an existing IDA GUI or to a shared managed idalib
++worker. The pool therefore owns *client interest*, never an IDA process. Releasing
++an LRU entry persists managed IDBs but does not implicitly save a GUI, then closes
++only this TUI's lease; other clients and GUI sessions remain alive. Managed workers exit themselves after their final lease.
+
+-Residency is therefore bounded by a **memory budget**, not a worker count — a
+-count is the wrong knob when one project holds both a 50 KB helper and a 6 MB
+-crypto library. Workers are spawned lazily on first use, kept resident while they
+-fit, and least-recently-used ones evicted when they don't. Eviction **saves the
+-database first**, so coming back is a load rather than a re-analysis.
+-
+-The pool never evicts the active binary, nor anything pinned.
++The historical memory budget remains useful for managed idalib instances, while
++GUI process memory is only advisory. The active and pinned databases are never
++released to satisfy it.
+ """
+ from __future__ import annotations
+
+-import os
+-
+ from .project import BinaryRef, Project
+
+ #: Fallback budget if /proc/meminfo can't be read (MB).
+@@ -36,11 +29,11 @@ def _total_ram_mb() -> int:
+
+
+ def _pss_mb(pid: int | None) -> int:
+- """Proportional set size of a worker, in MB.
++ """Proportional set size of the leased instance process, in MB.
+
+- PSS (not RSS) is the honest per-worker cost: it splits shared pages between
+- the processes mapping them. In practice workers share very little, so the two
+- are close, but PSS is what makes summing across workers meaningful.
++ PSS is useful for managed idalib workers. For GUI/shared processes it is only
++ advisory because the TUI neither owns all that memory nor controls process
++ exit.
+ """
+ if not pid:
+ return 0
+@@ -54,13 +47,19 @@ def _pss_mb(pid: int | None) -> int:
+ return 0
+
+
+-def _default_spawn(ref: BinaryRef, ttl: int): # pragma: no cover - needs idalib
+- from .worker_client import WorkerClient
+- return WorkerClient(ref.staged, ttl=ttl, load_args=ref.load_args)
++def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): # pragma: no cover - needs IDA
++ from .codemode_client import CodeModeClient
++ return CodeModeClient(
++ ref.staged,
++ ttl=ttl,
++ load_args=ref.load_args,
++ output_database=ref.db,
++ new_database=new_database,
++ )
+
+
+-class WorkerPool:
+- """Live workers for a project's binaries, keyed by label."""
++class DatabasePool:
++ """Live Code Mode database leases, keyed by project label."""
+
+ def __init__(self, project: Project, *, budget_mb: int | None = None,
+ ttl: int = 1800, spawn=None, mem_fn=None) -> None:
+@@ -71,6 +70,7 @@ class WorkerPool:
+ self._clients: dict[str, object] = {}
+ self._lru: list[str] = [] # least-recently-used first
+ self._pinned: set[str] = set()
++ self._recreate: set[str] = set() # Ctrl+L: next attachment creates a fresh IDB
+ self.active: str | None = None # never evicted
+ if budget_mb is None:
+ ram = _total_ram_mb()
+@@ -98,11 +98,11 @@ class WorkerPool:
+
+ # -- acquire ----------------------------------------------------------- #
+ def get(self, label: str, progress=None):
+- """A live client for ``label``, spawning it (and making room) if needed.
++ """A live client for ``label``, attaching or spawning as needed.
+
+- Staging and the scratch sweep happen here: a worker killed hard last time
+- leaves unpacked ``.id0/.id1/...`` behind, and the database then refuses to
+- reopen. Nothing else holds this DB (one worker per label), so it is safe.
++ Do not sweep IDA scratch files here: a registered GUI or another Code
++ Mode client may own the database. Code Mode's registry locks and health
++ probes are the authority for safe discovery and stale-record cleanup.
+ """
+ client = self._clients.get(label)
+ if client is not None:
+@@ -118,28 +118,29 @@ class WorkerPool:
+
+ note(f"staging {ref.label}\u2026")
+ self.project.stage(ref)
+- self.project.sweep_scratch(ref)
+ note(f"opening {ref.label}\u2026")
+- client = self._spawn(ref, self._ttl)
++ fresh = label in self._recreate
++ client = (_default_spawn(ref, self._ttl, new_database=fresh)
++ if self._spawn is _default_spawn else self._spawn(ref, self._ttl))
+ connect = getattr(client, "connect", None)
+ if connect is not None:
+ connect(progress=progress) if progress is not None else connect()
+ self._clients[label] = client
++ self._recreate.discard(label)
+ self._lru.append(label)
+ self._enforce_budget(protect=label)
+ return client
+
+ def prewarm(self, label: str, progress=None) -> bool:
+- """Spawn a worker for ``label`` only if it fits the budget AS IT STANDS.
++ """Attach a database for ``label`` only if it fits the current budget.
+
+ Pre-warming must never cost residency: evicting a binary the user
+ actually visited to speculatively load one they haven't is a straight
+ downgrade, and the eviction would also throw away that binary's caches.
+ So this refuses rather than making room, and returns False.
+
+- The cost of a worker that doesn't exist yet can only be estimated; the
+- largest resident one is the best evidence available (they are all the
+- same program with a different database). With nothing resident we have
++ The cost of a database not attached yet can only be estimated; the
++ largest resident instance is the best evidence available. With nothing resident we have
+ no evidence at all, so we allow one — that is the case where the budget
+ is certainly free.
+ """
+@@ -153,13 +154,19 @@ class WorkerPool:
+ return False
+ self.get(label, progress=progress)
+ # get() enforces the budget protecting the NEW label; if that had to
+- # evict, our estimate was wrong and the speculative worker is the one
++ # evict, our estimate was wrong and the speculative lease is the one
+ # that should go — never a binary the user chose.
+ if self.memory_mb() > self.budget_mb and label != self.active:
+ self.evict(label)
+ return False
+ return True
+
++ def recreate_on_next_open(self, label: str) -> None:
++ """Request a fresh IDB after the current lease has been released."""
++ if self.project.by_label(label) is None:
++ raise KeyError(f"no such binary in the project: {label}")
++ self._recreate.add(label)
++
+ def _touch(self, label: str) -> None:
+ if label in self._lru:
+ self._lru.remove(label)
+@@ -171,16 +178,21 @@ class WorkerPool:
+ self._touch(label)
+
+ # -- release ----------------------------------------------------------- #
+- def evict(self, label: str, save: bool = True) -> bool:
+- """Drop a resident worker, persisting its database first."""
++ def evict(self, label: str, save: bool = True,
++ save_gui: bool = False) -> bool:
++ """Release a resident lease, persisting a managed database first.
++
++ A budget-driven eviction must not save somebody's GUI implicitly. GUI
++ saves are reserved for an explicit/defensive ``close_all(save=True)``.
++ """
+ client = self._clients.pop(label, None)
+ if client is None:
+ return False
+ if label in self._lru:
+ self._lru.remove(label)
+- if save:
++ if save and (save_gui or getattr(client, "backend", None) != "gui"):
+ try: # persist analysis + edits so the next open is a load
+- client.call("idb_save")
++ client.save_database()
+ except Exception: # noqa: BLE001 -- evict regardless
+ pass
+ try:
+@@ -198,7 +210,7 @@ class WorkerPool:
+ return None
+
+ def _enforce_budget(self, protect: str | None = None) -> int:
+- """Evict LRU workers until the pool fits its budget. Returns how many."""
++ """Release LRU leases until the pool fits its budget. Returns how many."""
+ n = 0
+ while self.memory_mb() > self.budget_mb:
+ victim = self._evictable(protect)
+@@ -210,7 +222,7 @@ class WorkerPool:
+
+ def close_all(self, save: bool = True) -> None:
+ for label in list(self._clients):
+- self.evict(label, save=save)
++ self.evict(label, save=save, save_gui=save)
+ self.active = None
+
+ # -- introspection ------------------------------------------------------ #
+@@ -231,5 +243,9 @@ class WorkerPool:
+ return out
+
+ def __repr__(self) -> str: # pragma: no cover - debug aid
+- return (f"<WorkerPool {len(self._clients)}/{len(self.project.refs)} resident "
++ return (f"<DatabasePool {len(self._clients)}/{len(self.project.refs)} resident "
+ f"{self.memory_mb()}/{self.budget_mb}MB active={self.active}>")
++
++
++# Source compatibility for callers that imported the pre-Code-Mode name.
++WorkerPool = DatabasePool
+diff --git a/idatui/project.py b/idatui/project.py
+index e2fc542..6afd8a1 100644
+--- a/idatui/project.py
++++ b/idatui/project.py
+@@ -23,7 +23,8 @@ firmware image, a cleaned build tree).
+ A source whose size/mtime no longer matches the staged copy is re-staged, and its
+ now-stale database is dropped (the DB describes the old bytes).
+
+-stdlib-only, like the domain/worker layers — the TUI is the only Textual consumer.
++The model has no IDA imports. Staging consults ida_codemode's registry before
++replacing files so it never mutates a database owned by a GUI/shared worker.
+ """
+ from __future__ import annotations
+
+@@ -56,7 +57,7 @@ class BinaryRef:
+ #: it is, a raw firmware image doesn't, and IDA defaults to metapc at 0.
+ processor: str = "" # IDA processor name: arm, armb, mipsb, metapc, …
+ base: int = 0 # load address (natural, e.g. 0x8000000)
+- ida_args: str = "" # escape hatch: extra IDA command-line switches
++ ida_args: str = "" # legacy -p/-b/-T switches accepted by Code Mode adapter
+
+ @property
+ def db(self) -> str:
+@@ -310,13 +311,32 @@ class Project:
+ """Ensure ``ref`` is staged in the sidecar; returns the staged path.
+
+ Re-staging a changed source drops its database: the DB describes the old
+- bytes, so keeping it would silently mismatch the disassembly (any renames
+- in it are lost, which is why callers should say so out loud).
++ bytes. Refuse while Code Mode reports a GUI/idalib owner; replacing a
++ staged executable or IDB underneath a shared live instance is corruption.
+ """
+ if not os.path.isfile(ref.source):
+ raise ProjectError(f"no such binary: {ref.source}")
+ if not self.is_stale(ref):
+ return ref.staged
++ try:
++ from ida_codemode.registry import canonical_path, idb_key, scan_instances
++ expected_key = idb_key(ref.db)
++ staged_path = canonical_path(ref.staged)
++ owner = next(
++ (item.entry for item in scan_instances(timeout=0.5)
++ if item.entry.idb_key == expected_key
++ or (item.entry.exe_path and canonical_path(item.entry.exe_path) == staged_path)),
++ None,
++ )
++ except Exception as exc:
++ raise ProjectError(
++ f"cannot verify Code Mode ownership before staging {ref.label}: {exc}"
++ ) from exc
++ if owner is not None:
++ raise ProjectError(
++ f"cannot restage {ref.label}: Code Mode instance {owner.record_id} "
++ f"still owns {owner.idb_path}; close/release it first"
++ )
+ os.makedirs(self.bin_dir, exist_ok=True)
+ tmp = ref.staged + ".staging"
+ _unlink(tmp)
+@@ -338,10 +358,11 @@ class Project:
+ return out
+
+ def sweep_scratch(self, ref: BinaryRef) -> int:
+- """Delete IDA's unpacked working files (never the ``.i64``) for ``ref``.
++ """Delete unpacked working files (never the ``.i64``) for maintenance.
+
+- A hard-killed worker leaves them behind and the database then refuses to
+- reopen. Only safe when no worker holds it.
++ Runtime paths no longer call this: Code Mode instances are shared, so a
++ registry owner may still be using these files. Callers must independently
++ prove that no GUI/idalib instance owns the database.
+ """
+ return sum(1 for suf in SCRATCH_SUFFIXES if _unlink(ref.staged + suf))
+
+diff --git a/idatui/worker.py b/idatui/worker.py
+deleted file mode 100644
+index a4e3509..0000000
+--- a/idatui/worker.py
++++ /dev/null
+@@ -1,233 +0,0 @@
+-"""idatui's own idalib worker — the replacement for the ida-pro-mcp supervisor.
+-
+-Opens ONE database in-process (on the main thread, as idalib requires) and
+-serves ida-pro-mcp's *tool functions* over a unix socket with length-prefixed
+-pickle. Same tool implementations as the MCP path (we call
+-``MCP_SERVER.tools.methods[name](**args)`` directly), so return shapes are
+-byte-identical — but with ~50us/call instead of the HTTP path's ~5ms, and no
+-supervisor / HTTP / JSON / 50KB-truncation machinery.
+-
+- python -m idatui.worker <sock_path> <binary_path>
+-
+-The socket only appears once the database is open + analyzed, so a client can
+-poll ``connect()`` to know when the worker is ready. Requests are served
+-serially on the main thread (idalib is single-threaded; every tool runs inline
+-through its own execute_sync, which is a no-op on the main thread).
+-
+-Protocol (both directions length-prefixed: 4-byte big-endian len + pickle):
+- request = (tool_name: str, kwargs: dict)
+- response = (ok: bool, result_or_error)
+- tool_name == "__shutdown__" ends the worker.
+-"""
+-from __future__ import annotations
+-
+-import os
+-import pickle
+-import socket
+-import struct
+-import sys
+-import uuid
+-
+-
+-# --------------------------------------------------------------------------- #
+-# framing
+-# --------------------------------------------------------------------------- #
+-def _recvn(sock: socket.socket, n: int) -> bytes | None:
+- buf = bytearray()
+- while len(buf) < n:
+- chunk = sock.recv(n - len(buf))
+- if not chunk:
+- return None
+- buf += chunk
+- return bytes(buf)
+-
+-
+-def send(sock: socket.socket, obj) -> None:
+- data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
+- sock.sendall(struct.pack(">I", len(data)) + data)
+-
+-
+-def recv(sock: socket.socket):
+- hdr = _recvn(sock, 4)
+- if hdr is None:
+- return None
+- (n,) = struct.unpack(">I", hdr)
+- body = _recvn(sock, n)
+- return None if body is None else pickle.loads(body)
+-
+-
+-# --------------------------------------------------------------------------- #
+-# worker
+-# --------------------------------------------------------------------------- #
+-def _ensure_tools_injected() -> None:
+- """Inject idatui's custom tools (heads/read_raw/resolve_names/func_types/...)
+- into the installed ida_pro_mcp, idempotently, so the worker is self-sufficient
+- (nothing else has to inject these tools first). Must run BEFORE
+- ida_pro_mcp.ida_mcp is imported (the injected code lives in api_types.py)."""
+- import importlib.util
+- repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+- patch = os.path.join(repo, "server", "patch_server.py")
+- if not os.path.exists(patch):
+- return
+- try:
+- spec = importlib.util.spec_from_file_location("_idatui_patch", patch)
+- mod = importlib.util.module_from_spec(spec)
+- spec.loader.exec_module(mod) # IDA-free; just defines + patches api_types
+- mod.main()
+- except Exception as e: # noqa: BLE001 -- tools may already be present
+- sys.stderr.write(f"idatui: tool injection skipped: {e}\n")
+-
+-
+-def _has_database(binpath: str) -> bool:
+- """Whether IDA already has a database for ``binpath``.
+-
+- IDA names it ``<file>.i64`` (keeping the extension), but a database made
+- from ``foo.bin`` can also appear as ``foo.i64`` depending on how it was
+- created — check both, because guessing wrong here means re-passing load
+- switches to an existing database, which fails the open.
+- """
+- return (os.path.exists(binpath + ".i64")
+- or os.path.exists(os.path.splitext(binpath)[0] + ".i64"))
+-
+-
+-def _open_and_register(binpath: str, load_args: str = ""):
+- """Open the DB (main thread) then import ida-pro-mcp so every @tool registers
+- against this live database. Returns (tools_dict, module_name, save_fn).
+-
+- ``load_args`` is passed to IDA as command-line switches, which is the only
+- way to tell it how to read a headerless blob: a raw firmware image has no
+- format to detect, so without ``-p<processor>`` it loads as metapc at 0 and
+- finds nothing. Ignored once a database exists — the .i64 already records how
+- it was loaded, and re-passing conflicting switches is how you corrupt one.
+- """
+- _ensure_tools_injected() # before any ida_pro_mcp import
+- import idapro
+- idapro.enable_console_messages(False)
+- args = load_args or None
+- if args and _has_database(binpath):
+- # The .i64 already records how this image was loaded. Passing the
+- # switches again on reopen makes IDA fail outright (rc != 0) — the load
+- # options belong to the FIRST open only.
+- args = None
+- if idapro.open_database(binpath, run_auto_analysis=True,
+- args=args): # nonzero == failure
+- if args:
+- # With load switches in play they are the likeliest culprit by far:
+- # IDA refuses an unknown -p name with no diagnostic of its own, so
+- # saying "the database is locked" here sends people hunting a
+- # problem they don't have.
+- raise RuntimeError(
+- f"failed to open {binpath} with load options {args!r}: IDA "
+- f"rejected them \u2014 an unknown processor name is the usual "
+- f"cause (see tools/verify_procs.py for the valid ones)")
+- raise RuntimeError(
+- f"failed to open {binpath}: the .i64 is likely held by a running "
+- f"ida-mcp worker (try: pkill -f idalib) or wedged from a crash "
+- f"(delete its .id0/.id1/.id2/.nam/.til next to the binary)")
+- import ida_auto
+- ida_auto.auto_wait() # block until auto-analysis settles (match ida-mcp)
+-
+- # importing the package registers all api_*/patched tools against MCP_SERVER
+- from ida_pro_mcp.ida_mcp import MCP_SERVER # noqa: WPS433
+-
+- import ida_nalt
+- module = os.path.basename(ida_nalt.get_root_filename() or binpath)
+-
+- def save():
+- import idc
+- try:
+- idc.save_database(idc.get_idb_path(), 0)
+- except Exception: # noqa: BLE001
+- import ida_loader, ida_pro # noqa: WPS433
+- ida_loader.save_database(idc.get_idb_path(), 0)
+-
+- return MCP_SERVER.tools.methods, module, save
+-
+-
+-def serve(sockpath: str, binpath: str, load_args: str = "") -> None:
+- tools, module, save = _open_and_register(binpath, load_args)
+- sid = uuid.uuid4().hex[:8]
+-
+- def dispatch(name: str, args: dict):
+- args = dict(args)
+- args.pop("database", None) # single-DB worker: no session routing
+- # session-management shims (were the supervisor's job):
+- if name in ("idb_open",):
+- return {"success": True,
+- "session": {"session_id": sid, "module": module,
+- "input_path": binpath}}
+- if name in ("idb_save", "save"):
+- save()
+- return {"success": True}
+- if name in ("server_health", "ping", "health", "state"):
+- return {"module": module, "ok": True, "session_id": sid}
+- if name in ("idb_list",):
+- return {"sessions": [{"session_id": sid, "module": module,
+- "input_path": binpath}]}
+- fn = tools.get(name)
+- if fn is None:
+- raise KeyError(f"unknown tool: {name!r}")
+- result = fn(**args)
+- # Match the MCP server's structuredContent: a dict passes through, any
+- # other return (list/scalar) is wrapped as {"result": ...}. domain.py
+- # parses that exact shape (e.g. lookup_funcs -> payload["result"]).
+- return result if isinstance(result, dict) else {"result": result}
+-
+- try:
+- os.unlink(sockpath)
+- except OSError:
+- pass
+- srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+- srv.bind(sockpath)
+- srv.listen(8)
+- try:
+- while True:
+- conn, _ = srv.accept()
+- try:
+- while True:
+- req = recv(conn)
+- if req is None:
+- break
+- name, args = req
+- if name == "__shutdown__":
+- return
+- try:
+- send(conn, (True, dispatch(name, args)))
+- except Exception as e: # noqa: BLE001 -- report, keep serving
+- send(conn, (False, f"{type(e).__name__}: {e}"))
+- except (ConnectionError, OSError):
+- pass
+- finally:
+- conn.close()
+- finally:
+- try:
+- import idapro
+- idapro.close_database(save=False)
+- except Exception: # noqa: BLE001
+- pass
+- try:
+- os.unlink(sockpath)
+- except OSError:
+- pass
+-
+-
+-def main(argv=None) -> None:
+- argv = argv if argv is not None else sys.argv[1:]
+- if len(argv) < 2:
+- sys.stderr.write(
+- "usage: python -m idatui.worker <sock> <binary> [ida-load-args]\n")
+- raise SystemExit(2)
+- try:
+- serve(argv[0], argv[1], argv[2] if len(argv) > 2 else "")
+- except SystemExit:
+- raise
+- except BaseException as e: # noqa: BLE001 -- surface a clean cause + code 1
+- import traceback
+- sys.stderr.write(f"\nWORKER-FATAL: {type(e).__name__}: {e}\n")
+- traceback.print_exc()
+- sys.stderr.flush()
+- raise SystemExit(1)
+-
+-
+-if __name__ == "__main__":
+- main()
+diff --git a/idatui/worker_client.py b/idatui/worker_client.py
+deleted file mode 100644
+index 79a6db9..0000000
+--- a/idatui/worker_client.py
++++ /dev/null
+@@ -1,234 +0,0 @@
+-"""WorkerClient — a drop-in replacement for ``IDAClient`` backed by our own
+-idalib worker (``idatui.worker``) over a unix socket instead of ida-pro-mcp's
+-HTTP/JSON transport.
+-
+-It exposes exactly the surface the app/domain use on the client
+-(``call``/``call_envelope``/``connect``/``set_db``/``resolve_db``/
+-``list_sessions``/``health``/``keepalive``/``close``) and returns byte-identical
+-payloads (the worker calls the same tool functions), so ``domain.py`` and the
+-app are unchanged — you just construct a WorkerClient instead of an IDAClient.
+-
+-Concurrency: the app fires calls from several worker threads over one client;
+-the worker is single-threaded, so calls are serialized under a lock (the worker
+-processes one tool at a time anyway — and at ~50us/call that's free).
+-"""
+-from __future__ import annotations
+-
+-import os
+-import socket
+-import subprocess
+-import sys
+-import threading
+-import time
+-import uuid
+-from typing import Any
+-
+-from .errors import IDAToolError, IDAConnectionError, Session
+-from .worker import recv as _recv
+-from .worker import send as _send
+-
+-_WORKER_PY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "worker.py")
+-_worker_python_cache: str | None = None
+-
+-
+-def _find_worker_python() -> str:
+- """A python that can import ``ida_pro_mcp`` (and thus idalib) — NOT necessarily
+- the TUI's python. On a typical box the TUI runs under a venv that has textual
+- + idalib but not ida_pro_mcp, while the system python has idalib +
+- ida_pro_mcp. Override with IDATUI_WORKER_PYTHON."""
+- global _worker_python_cache
+- if _worker_python_cache:
+- return _worker_python_cache
+- override = os.environ.get("IDATUI_WORKER_PYTHON")
+- candidates = [override] if override else []
+- candidates += ["/usr/bin/python", "/usr/bin/python3", sys.executable]
+- for py in candidates:
+- if not py or not os.path.exists(py):
+- continue
+- try:
+- r = subprocess.run([py, "-c", "import ida_pro_mcp"],
+- capture_output=True, timeout=30)
+- if r.returncode == 0:
+- _worker_python_cache = py
+- return py
+- except Exception: # noqa: BLE001
+- continue
+- return sys.executable # last resort; the worker will report the real error
+-
+-
+-class _NoopKeepAlive:
+- """The worker is ours and never idles out, so keepalive is a no-op."""
+-
+- def __init__(self) -> None:
+- self.beats = self.failures = 0
+-
+- def start(self):
+- return self
+-
+- def stop(self) -> None:
+- pass
+-
+-
+-class WorkerClient:
+- def __init__(self, binary_path: str, *, ttl: int = 0,
+- python: str | None = None, load_args: str = "") -> None:
+- self._bin = os.path.abspath(os.path.expanduser(binary_path))
+- self._load_args = load_args or "" # IDA switches for a headerless blob
+- self._python = python or _find_worker_python()
+- tag = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
+- self._sock_path = f"/tmp/idatui-worker-{tag}.sock"
+- self._log_path = f"/tmp/idatui-worker-{tag}.log"
+- self._proc: subprocess.Popen | None = None
+- self._sock: socket.socket | None = None
+- self._sid = uuid.uuid4().hex[:8]
+- self._lock = threading.Lock() # serialize socket use
+- self._spawn_lock = threading.Lock()
+-
+- # -- lifecycle --------------------------------------------------------- #
+- def connect(self, timeout: float = 1800.0, progress=None) -> "WorkerClient":
+- """Spawn the worker (opens + analyzes the DB) and connect once ready."""
+- with self._spawn_lock:
+- if self._sock is not None:
+- return self
+- if self._proc is None or self._proc.poll() is not None:
+- # run worker.py as a SCRIPT (not -m idatui.worker) so we don't
+- # import the textual-dependent idatui package __init__ under the
+- # IDA python, which usually has no textual.
+- argv = [self._python, _WORKER_PY, self._sock_path, self._bin]
+- if self._load_args:
+- argv.append(self._load_args)
+- self._proc = subprocess.Popen(
+- argv,
+- stdout=open(self._log_path, "wb"),
+- stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
+- )
+- deadline = time.time() + timeout
+- t0 = time.time()
+- while time.time() < deadline:
+- try:
+- s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+- s.connect(self._sock_path)
+- self._sock = s
+- return self
+- except OSError:
+- if self._proc.poll() is not None:
+- raise IDAConnectionError(
+- f"worker exited (code {self._proc.returncode}): "
+- f"{self._log_tail()} [full log: {self._log_path}]")
+- if progress:
+- progress(f"auto-analyzing {os.path.basename(self._bin)}… "
+- f"({int(time.time() - t0)}s)")
+- time.sleep(0.2)
+- raise IDAConnectionError("worker did not become ready in time")
+-
+- @property
+- def pid(self) -> int | None:
+- """The worker process id (for memory accounting), or None if not spawned."""
+- return self._proc.pid if self._proc is not None else None
+-
+- def close(self, grace: float = 20.0) -> None:
+- """Shut the worker down cleanly.
+-
+- After ``__shutdown__`` the worker still has to ``close_database()``, which
+- re-packs the ``.i64`` and removes the unpacked ``.id0/.id1/...`` scratch.
+- Signalling it before that finishes is what leaves databases wedged, so
+- wait out the grace period first and only escalate if it really is stuck.
+- """
+- with self._lock:
+- s = self._sock
+- self._sock = None
+- if s is not None:
+- try:
+- _send(s, ("__shutdown__", {}))
+- except Exception: # noqa: BLE001
+- pass
+- try:
+- s.close()
+- except Exception: # noqa: BLE001
+- pass
+- if self._proc is not None:
+- try:
+- self._proc.wait(timeout=grace) # let it close the DB properly
+- except Exception: # noqa: BLE001 -- TimeoutExpired: it's stuck
+- try:
+- self._proc.terminate()
+- self._proc.wait(timeout=5)
+- except Exception: # noqa: BLE001
+- try:
+- self._proc.kill()
+- except Exception: # noqa: BLE001
+- pass
+-
+- # -- the call surface -------------------------------------------------- #
+- def call(self, tool: str, *, timeout: float | None = None, **args) -> Any:
+- if self._sock is None:
+- self.connect()
+- with self._lock:
+- s = self._sock
+- if s is None:
+- raise IDAConnectionError("worker connection is closed")
+- try:
+- _send(s, (tool, args))
+- reply = _recv(s)
+- except (OSError, ConnectionError) as e:
+- self._sock = None
+- raise IDAConnectionError(f"worker transport failed: {e}") from e
+- if reply is None:
+- self._sock = None
+- raise IDAConnectionError("worker closed the connection")
+- ok, payload = reply
+- if not ok:
+- raise IDAToolError(tool, str(payload))
+- return payload
+-
+- def call_envelope(self, tool: str, *, timeout: float | None = None,
+- **args) -> dict:
+- # domain.decompile() reads result.structuredContent — mirror that shape.
+- return {"result": {"structuredContent": self.call(tool, timeout=timeout,
+- **args)}}
+-
+- # -- session shims (single-DB worker) --------------------------------- #
+- def set_db(self, db: str | None) -> None:
+- if db:
+- self._sid = db
+-
+- def resolve_db(self) -> str:
+- return self._sid
+-
+- def list_sessions(self) -> list[Session]:
+- return [Session(session_id=self._sid,
+- filename=os.path.basename(self._bin),
+- input_path=self._bin, is_active=True)]
+-
+- def health(self) -> dict:
+- try:
+- return self.call("server_health")
+- except IDAToolError:
+- return {"module": os.path.basename(self._bin), "ok": True}
+-
+- def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive:
+- return _NoopKeepAlive()
+-
+- def _log_tail(self, n: int = 400) -> str:
+- """Last meaningful line(s) of the worker log (skip IDA's licence banner),
+- so a startup crash surfaces the real cause instead of just 'code 1'."""
+- try:
+- with open(self._log_path, encoding="utf-8", errors="replace") as f:
+- lines = [ln.strip() for ln in f if ln.strip()]
+- except OSError:
+- return "(no worker log)"
+- # the worker prints a clean 'WORKER-FATAL: ...' line on a startup crash
+- for ln in reversed(lines):
+- if ln.startswith("WORKER-FATAL:"):
+- return ln[len("WORKER-FATAL:"):].strip()[-n:]
+- skip = ("thank you", "licensed to", "[mcp]", "ida ", "hex-rays")
+- meaningful = [ln for ln in lines
+- if not any(s in ln.lower() for s in skip)]
+- return " | ".join((meaningful or lines)[-3:])[-n:]
+-
+- # context manager parity with IDAClient
+- def __enter__(self) -> "WorkerClient":
+- return self.connect()
+-
+- def __exit__(self, *exc) -> None:
+- self.close()
+diff --git a/pyproject.toml b/pyproject.toml
+index 30f8cc2..72f1ff3 100644
+--- a/pyproject.toml
++++ b/pyproject.toml
+@@ -1,15 +1,17 @@
+ [project]
+ name = "idatui"
+ version = "0.0.1"
+-description = "A minimal keyboard-first TUI frontend for IDA Pro over the ida-pro-mcp (idalib) server."
++description = "A keyboard-first TUI frontend for shared IDA Code Mode databases."
+ requires-python = ">=3.11"
+-# The client layer is intentionally stdlib-only (urllib/http.client), matching the
+-# ida-mcp skill philosophy: no install needed to talk to the server.
+-dependencies = []
++# ida-codemode supplies GUI discovery, shared idalib workers, leases, and the
++# execute_python/ida-domain database surface.
++dependencies = [
++ "ida-codemode-mcp",
++ "textual>=8",
++ "pygments>=2", # Used directly for pseudocode highlighting.
++]
+
+ [project.optional-dependencies]
+-# The TUI layer pulls in Textual; the client/domain layers are stdlib-only.
+-tui = ["textual>=8", "pygments>=2"] # pygments ships with rich; explicit for the C lexer
+ dev = ["pytest>=8"]
+
+ [project.scripts]
+@@ -22,3 +24,6 @@ build-backend = "hatchling.build"
+
+ [tool.hatch.build.targets.wheel]
+ packages = ["idatui"]
++
++[tool.uv.sources]
++ida-codemode-mcp = { path = "../ida-codemode-mcp", editable = true }
+diff --git a/server/patch_server.py b/server/patch_server.py
+deleted file mode 100644
+index 160b15b..0000000
+--- a/server/patch_server.py
++++ /dev/null
+@@ -1,1248 +0,0 @@
+-#!/usr/bin/env python3
+-"""Inject idatui's extra ida-pro-mcp tools into the installed server package.
+-
+-DEPRECATED along with the ida-pro-mcp transport: the default backend is now the
+-idalib worker (idatui/worker.py), which registers these same tools in-process and
+-needs no patching. Kept only for `--backend mcp`; slated for removal.
+-
+-ida-pro-mcp lacks a few tools idatui needs. Rather than vendor/fork the server,
+-we keep the tool source here and inject it (idempotently) into the installed
+-``api_types.py``. That module is imported by every worker
+-(``python -m ida_pro_mcp.idalib_server``), so the tools register themselves via
+-``@tool`` on the shared ``MCP_SERVER`` — no server code is forked, and re-running
+-this (spawn.sh does, on every start) re-applies it after a reinstall/upgrade.
+-
+-Injected tools:
+- * ``del_type`` — delete a named local type (struct editor CRUD).
+- * ``func_types`` — structured decompiler types for a function (prototype +
+- local variables), so clients don't parse pseudocode text.
+- * ``set_lvar_type`` — set a decompiler local variable's type; works on auto/
+- register vars too (the stock set_type only updates lvars
+- that already have user-saved info).
+-
+-The block between the BEGIN/END markers is *replaced* on each run, so editing
+-BODY here and restarting the supervisor updates the tools.
+-
+-Run with the *same* interpreter the server uses (the idalib-mcp entry point's
+-``/usr/bin/python``), so it patches the file the workers actually import.
+-Changing a tool needs a supervisor restart so workers respawn.
+-"""
+-from __future__ import annotations
+-
+-import importlib.util
+-import pathlib
+-import sys
+-
+-BEGIN = "# >>> idatui-ext: begin (auto-injected by server/patch_server.py) >>>"
+-END = "# <<< idatui-ext: end <<<"
+-
+-# Appended to ida_pro_mcp/ida_mcp/api_types.py, which already imports
+-# ``Annotated``, ``tool``, ``idasync``, ``ida_typeinf``, ``parse_address`` and
+-# ``_parse_type_tinfo``.
+-BODY = '''
+-def _idatui_lv_get(x):
+- return x() if callable(x) else x
+-
+-
+-@tool
+-@idasync
+-def resolve_names(
+- queries: Annotated[list, "Symbol name(s) to resolve to their OWN address"],
+-) -> list:
+- """Resolve named locations (functions, labels like loc_/locret_, data) to the
+- exact address the NAME denotes, via get_name_ea. Unlike lookup_funcs, a
+- mid-function label resolves to the label's address, not the containing
+- function's entry."""
+- import idaapi
+- qs = queries if isinstance(queries, list) else [queries]
+- out = []
+- for q in qs:
+- q = str(q).strip()
+- ea = idaapi.get_name_ea(idaapi.BADADDR, q)
+- out.append({"query": q, "ea": (hex(ea) if ea != idaapi.BADADDR else None)})
+- return out
+-
+-
+-@tool
+-@idasync
+-def del_type(
+- name: Annotated[str, "Local type name to delete (struct/union/enum/typedef)"],
+-) -> dict:
+- """Delete a named local type from the local type library."""
+- til = ida_typeinf.get_idati()
+- ok = ida_typeinf.del_named_type(til, name, ida_typeinf.NTF_TYPE)
+- if not ok:
+- return {"name": name, "error": f"Type '{name}' not found or could not be deleted"}
+- return {"name": name, "deleted": True}
+-
+-
+-@tool
+-@idasync
+-def func_types(
+- addr: Annotated[str, "Function address or name"],
+-) -> dict:
+- """Structured decompiler types for a function: its prototype plus each local
+- variable (name/type/is_arg). Lets clients read/edit types without parsing
+- pseudocode text."""
+- import ida_hexrays
+- import idaapi
+-
+- def _tstr(tif):
+- try:
+- s = tif.dstr()
+- if s:
+- return s
+- except Exception:
+- pass
+- return str(tif)
+-
+- ea = parse_address(addr)
+- f = idaapi.get_func(ea)
+- if not f:
+- return {"addr": str(addr), "error": "no function at address"}
+- try:
+- cf = ida_hexrays.decompile(f.start_ea)
+- except Exception as e:
+- return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}"}
+- if cf is None:
+- return {"addr": hex(f.start_ea), "error": "decompilation failed"}
+- name = idaapi.get_func_name(f.start_ea) or ""
+- try:
+- proto = ida_typeinf.print_tinfo(
+- "", 0, 0, ida_typeinf.PRTYPE_1LINE, cf.type, name, "")
+- except Exception:
+- proto = ""
+- lvars = []
+- for lv in cf.get_lvars():
+- try:
+- ty = _tstr(_idatui_lv_get(lv.type))
+- except Exception:
+- ty = ""
+- lvars.append({
+- "name": _idatui_lv_get(lv.name),
+- "type": ty,
+- "is_arg": bool(_idatui_lv_get(lv.is_arg_var)),
+- })
+- return {
+- "addr": hex(f.start_ea),
+- "name": name,
+- "prototype": (proto or "").strip(),
+- "lvars": lvars,
+- }
+-
+-
+-@tool
+-@idasync
+-def set_lvar_type(
+- addr: Annotated[str, "Function address or name"],
+- variable: Annotated[str, "Local variable name"],
+- type: Annotated[str, "New C type for the variable"],
+-) -> dict:
+- """Set a decompiler local variable's type. Handles auto/register vars (unlike
+- set_type, which only updates lvars that already have user-saved info)."""
+- import ida_hexrays
+- import idaapi
+-
+- ea = parse_address(addr)
+- f = idaapi.get_func(ea)
+- if not f:
+- return {"error": "no function at address"}
+- try:
+- cf = ida_hexrays.decompile(f.start_ea)
+- except Exception as e:
+- return {"error": f"decompile failed: {e}"}
+- if cf is None:
+- return {"error": "decompilation failed"}
+- target = None
+- for lv in cf.get_lvars():
+- if _idatui_lv_get(lv.name) == variable:
+- target = lv
+- break
+- if target is None:
+- return {"error": f"local variable {variable!r} not found"}
+- try:
+- tif = _parse_type_tinfo(type)
+- except Exception as e:
+- return {"error": f"bad type {type!r}: {e}"}
+- lsi = ida_hexrays.lvar_saved_info_t()
+- try:
+- lsi.ll = target
+- except Exception:
+- try:
+- lsi.ll.location = _idatui_lv_get(target.location)
+- lsi.ll.defea = target.defea
+- except Exception as e:
+- return {"error": f"could not locate variable: {e}"}
+- lsi.type = tif
+- ok = bool(ida_hexrays.modify_user_lvar_info(
+- f.start_ea, ida_hexrays.MLI_TYPE, lsi))
+- return {"addr": hex(f.start_ea), "variable": variable, "type": type, "ok": ok}
+-
+-
+-@tool
+-@idasync
+-def file_regions() -> dict:
+- """Loaded segments mapped to their raw file offsets (get_fileregion_offset),
+- so clients can convert a virtual address to an on-disk file offset without a
+- format-specific header parser. file_off is -1 for non-file-backed segments
+- (e.g. .bss)."""
+- import ida_segment
+- import idaapi
+-
+- out = []
+- seg = ida_segment.get_first_seg()
+- while seg is not None:
+- try:
+- fo = int(idaapi.get_fileregion_offset(seg.start_ea))
+- except Exception:
+- fo = -1
+- if fo < 0 or fo >= (1 << 48):
+- fo = -1
+- try:
+- nm = ida_segment.get_segm_name(seg) or ""
+- except Exception:
+- nm = ""
+- out.append({"start": hex(seg.start_ea), "end": hex(seg.end_ea),
+- "file_off": fo, "name": nm})
+- seg = ida_segment.get_next_seg(seg.start_ea)
+- return {"regions": out}
+-
+-
+-@tool
+-@idasync
+-def make_string(
+- addr: Annotated[str, "Address of the string start"],
+- length: Annotated[int, "Length in bytes (0 = auto-detect to the terminator)"] = 0,
+- kind: Annotated[str, "String kind: c | c16 | c32 | pascal"] = "c",
+-) -> dict:
+- """Create a string literal at ``addr`` (IDA's 'A'). ``length`` 0 auto-detects
+- to the terminator. Undefines any items in the way first, like the UI does.
+- Returns the created byte size and the decoded contents."""
+- import ida_bytes
+- import ida_nalt
+-
+- ea = parse_address(addr)
+- strtype = {
+- "c": ida_nalt.STRTYPE_C,
+- "c16": ida_nalt.STRTYPE_C_16,
+- "c32": ida_nalt.STRTYPE_C_32,
+- "pascal": ida_nalt.STRTYPE_PASCAL,
+- }.get(str(kind).lower(), ida_nalt.STRTYPE_C)
+- n = max(int(length), 0)
+- # Free any existing item(s) so create_strlit can carve the literal.
+- ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, n if n > 0 else 1)
+- ok = bool(ida_bytes.create_strlit(ea, n, strtype))
+- if not ok:
+- return {"addr": addr, "ok": False, "error": "create_strlit failed"}
+- size = int(ida_bytes.get_item_size(ea))
+- try:
+- raw = ida_bytes.get_strlit_contents(ea, -1, strtype)
+- text = raw.decode("utf-8", "replace") if raw else ""
+- except Exception:
+- text = ""
+- return {"addr": addr, "ok": True, "size": size, "text": text}
+-
+-
+-@tool
+-@idasync
+-def read_raw(
+- addr: Annotated[str, "Start address (hex or name)"],
+- size: Annotated[int, "Number of bytes to read"],
+-) -> dict:
+- """Read ``size`` bytes at ``addr`` as ONE contiguous lowercase hex string
+- (no per-byte '0x'/spaces). The hot path for the hex view and disasm opcode
+- bytes.
+-
+- Fast: does a single bulk ``ida_bytes.get_bytes`` (C-speed) instead of the
+- per-byte read_bytes_bss_safe loop (2 IDA calls/byte). Unloaded bytes come
+- back from IDA as the 0xFF sentinel, so we only re-check is_loaded for the
+- (usually sparse) 0xFF bytes and zero the genuinely-unloaded ones — matching
+- get_bytes' bss semantics without paying per-byte for the whole range.
+-
+- Encoding is compact hex (~2.5x smaller than get_bytes' '0x..'-with-spaces)
+- and, unlike get_bytes, does not truncate on large reads."""
+- import ida_bytes
+-
+- ea = parse_address(addr)
+- n = max(int(size), 0)
+- if n == 0:
+- return {"addr": addr, "hex": "", "n": 0}
+- raw = ida_bytes.get_bytes(ea, n)
+- if raw is None or len(raw) < n: # nothing (or not all) mapped
+- base = bytearray(raw or b"")
+- base.extend(b"\\xff" * (n - len(base)))
+- raw = bytes(base)
+- ba = bytearray(raw)
+- # Only unloaded bytes read as 0xFF; correct just those to 0 (bss => zero).
+- i = ba.find(0xFF)
+- while i != -1:
+- if not ida_bytes.is_loaded(ea + i):
+- ba[i] = 0
+- i = ba.find(0xFF, i + 1)
+- return {"addr": addr, "hex": bytes(ba).hex(), "n": len(ba)}
+-
+-
+-def _idatui_head_row(ea):
+- """One flat-listing row for the head at ``ea``: kind (code/data/unknown),
+- byte size, rendered text, and any symbol name."""
+- import ida_bytes
+- import ida_lines
+- import ida_name
+-
+- f = ida_bytes.get_flags(ea)
+- if ida_bytes.is_code(f):
+- kind = "code"
+- elif ida_bytes.is_data(f):
+- kind = "data"
+- else:
+- kind = "unknown"
+- line = ida_lines.generate_disasm_line(ea, 0)
+- text = ida_lines.tag_remove(line) if line else ""
+- text = " ".join(text.split()) # collapse IDA's column padding
+- row = {
+- "ea": hex(ea),
+- "kind": kind,
+- "size": int(ida_bytes.get_item_size(ea)),
+- "text": text,
+- }
+- if line:
+- # Keep IDA's own token classification for syntax highlighting. Built from
+- # the SAME line as `text`, then whitespace-collapsed identically so the
+- # two never disagree about what the row says.
+- spans = _idatui_spans(line)
+- joined = "".join(t for _k, t in spans)
+- if " ".join(joined.split()) == text:
+- row["spans"] = spans
+- nm = ida_name.get_ea_name(ea)
+- if nm:
+- row["name"] = nm
+- return row
+-
+-
+-#: IDA colour tag -> the semantic kind the TUI styles. IDA already classifies
+-#: every token in a disassembly line, for every processor it supports, so there
+-#: is nothing to lex: generate_disasm_line emits \x01<tag>text\x02<tag> and the
+-#: tag says what the text IS. A pygments assembly lexer would be a worse guess at
+-#: this and would need one dialect per architecture.
+-_IDATUI_SPAN_KINDS = {
+- "insn": ("SCOLOR_INSN", "SCOLOR_KEYWORD", "SCOLOR_ASMDIR", "SCOLOR_MACRO"),
+- "reg": ("SCOLOR_REG",),
+- "num": ("SCOLOR_NUMBER", "SCOLOR_CHAR", "SCOLOR_BINPREF"),
+- "str": ("SCOLOR_STRING",),
+- # NB the real constant names: DATNAME/CODNAME, not "DNAME". Guessing here
+- # fails silently — an unmapped tag renders as plain body text, so symbols
+- # just quietly aren't blue and nothing tells you why.
+- "name": ("SCOLOR_DATNAME", "SCOLOR_CODNAME", "SCOLOR_LOCNAME",
+- "SCOLOR_IMPNAME", "SCOLOR_DEMNAME", "SCOLOR_LIBNAME",
+- "SCOLOR_CNAME", "SCOLOR_DNAME",
+- "SCOLOR_CREF", "SCOLOR_DREF", "SCOLOR_CREFTAIL", "SCOLOR_DREFTAIL"),
+- "seg": ("SCOLOR_SEGNAME",),
+- "cmt": ("SCOLOR_AUTOCMT", "SCOLOR_REGCMT", "SCOLOR_RPTCMT", "SCOLOR_VOIDOP"),
+- "punct": ("SCOLOR_SYMBOL", "SCOLOR_ALTOP", "SCOLOR_HIDNAME"),
+- "err": ("SCOLOR_ERROR",),
+-}
+-
+-
+-def _idatui_tag_map():
+- """{tag character: kind}, built once from whatever this IDA actually has."""
+- import ida_lines
+- out = {}
+- for kind, names in _IDATUI_SPAN_KINDS.items():
+- for n in names:
+- v = getattr(ida_lines, n, None)
+- if isinstance(v, str) and v:
+- out[v[0]] = kind
+- elif isinstance(v, int):
+- out[chr(v)] = kind
+- return out
+-
+-
+-_IDATUI_TAGS = None
+-
+-
+-def _idatui_spans(line):
+- """A tagged disasm line as [[kind, text], ...], colour tags resolved.
+-
+- Unknown tags become 'text' rather than being dropped: a processor module can
+- emit a colour we don't classify, and losing the characters would corrupt the
+- line."""
+- global _IDATUI_TAGS
+- import ida_lines
+- if _IDATUI_TAGS is None:
+- _IDATUI_TAGS = _idatui_tag_map()
+- on, off, esc = "\x01", "\x02", "\x03"
+- addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28))
+- addr_len = int(getattr(ida_lines, "COLOR_ADDR_SIZE", 16))
+- spans, stack, buf = [], [], []
+- i, n = 0, len(line)
+-
+- def flush():
+- if buf:
+- spans.append([stack[-1] if stack else "text", "".join(buf)])
+- del buf[:]
+-
+- while i < n:
+- ch = line[i]
+- if ch == on and i + 1 < n:
+- tag = line[i + 1]
+- if tag == addr_tag:
+- # An embedded target address, not display text: 16 hex digits
+- # that must not reach the screen.
+- i += 2 + addr_len
+- continue
+- flush()
+- stack.append(_IDATUI_TAGS.get(tag, "text"))
+- i += 2
+- continue
+- if ch == off and i + 1 < n:
+- flush()
+- if stack:
+- stack.pop()
+- i += 2
+- continue
+- if ch == esc and i + 1 < n: # escaped literal
+- buf.append(line[i + 1])
+- i += 2
+- continue
+- buf.append(ch)
+- i += 1
+- flush()
+- # Collapse IDA's column padding EXACTLY as the plain text does. A run of
+- # spaces can straddle two spans, so this walks characters rather than
+- # collapsing each span on its own — otherwise the spans and `text` disagree
+- # about the line and the row silently loses its highlighting.
+- out, prev_space = [], False
+- for kind, txt in spans:
+- acc = []
+- for ch in txt:
+- if ch.isspace():
+- if prev_space:
+- continue
+- acc.append(" ")
+- prev_space = True
+- else:
+- acc.append(ch)
+- prev_space = False
+- if acc:
+- out.append([kind, "".join(acc)])
+- while out and out[0][1] == " ":
+- out.pop(0)
+- while out and out[-1][1] == " ":
+- out.pop()
+- if out and out[0][1].startswith(" "):
+- out[0][1] = out[0][1].lstrip()
+- if out and out[-1][1].endswith(" "):
+- out[-1][1] = out[-1][1].rstrip()
+- return [[k, t] for k, t in out if t]
+-
+-
+-def _idatui_unknown_row(ea, size):
+- """One collapsed row for a run of ``size`` undefined bytes starting at
+- ``ea``. A single byte is rendered normally (shows its value); a longer run
+- collapses to ``db N dup(?)`` so a big .bss/gap doesn't explode into millions
+- of one-byte rows."""
+- import ida_name
+-
+- if size <= 1:
+- return _idatui_head_row(ea)
+- row = {"ea": hex(ea), "kind": "unknown", "size": int(size),
+- "text": f"db {size} dup(?)"}
+- nm = ida_name.get_ea_name(ea)
+- if nm:
+- row["name"] = nm
+- return row
+-
+-
+-def _idatui_struct_member_rows(ea):
+- """Indented member rows for a struct-typed data item at ``ea`` (expansion),
+- or [] if it isn't a struct. Top-level fields only."""
+- import ida_nalt
+- import ida_typeinf
+- import idaapi
+-
+- tif = ida_typeinf.tinfo_t()
+- if not (ida_nalt.get_tinfo(tif, ea) and tif.is_udt()):
+- return []
+- udt = ida_typeinf.udt_type_data_t()
+- if not tif.get_udt_details(udt):
+- return []
+- rows = []
+- for m in udt:
+- off = m.begin() // 8
+- try:
+- mtype = m.type._print() or ""
+- except Exception:
+- mtype = ""
+- try:
+- sz = int(m.type.get_size())
+- if sz == idaapi.BADSIZE:
+- sz = 0
+- except Exception:
+- sz = 0
+- name = m.name or ""
+- text = f"+{off:X} {name}" + (f" {mtype}" if mtype else "")
+- rows.append({"ea": hex(ea + off), "kind": "member", "size": sz,
+- "text": text})
+- return rows
+-
+-
+-def _idatui_func_header_rows(ea):
+- """IDA-style subroutine banner rows shown just before a function's entry."""
+- import ida_funcs
+-
+- name = ida_funcs.get_func_name(ea) or "sub_%X" % ea
+- bar = "=" * 15 + " S U B R O U T I N E " + "=" * 15
+- return [
+- {"ea": hex(ea), "kind": "sep", "size": 0, "text": ""},
+- {"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + bar},
+- {"ea": hex(ea), "kind": "funchdr", "size": 0,
+- "text": name + " proc", "name": name},
+- ]
+-
+-
+-def _idatui_func_footer_rows(ea, func):
+- """End-of-function marker shown just after a function's last item."""
+- import ida_funcs
+-
+- name = ida_funcs.get_func_name(func.start_ea) or "sub_%X" % func.start_ea
+- return [
+- {"ea": hex(ea), "kind": "funchdr", "size": 0,
+- "text": name + " endp", "name": name},
+- {"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + "-" * 60},
+- ]
+-
+-
+-@tool
+-@idasync
+-def heads(
+- addr: Annotated[str, "Start address or name to walk from"],
+- count: Annotated[int, "Max heads to return (default 200, max 2000)"] = 200,
+- offset: Annotated[int, "Skip first N heads from addr (default 0)"] = 0,
+- end: Annotated[str, "Optional exclusive end address; default = segment end"] = "",
+- back: Annotated[bool, "Walk backwards: return the count heads ENDING just before addr, in forward order"] = False,
+- annotate: Annotated[bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"] = False,
+-) -> dict:
+- """Walk item heads from ``addr`` as a flat listing: every head is rendered
+- (code OR data OR undefined) via generate_disasm_line and stepped with
+- next_head/prev_head. Unlike ``disasm`` (code-only, bails at the first data
+- byte) this shows db/dw/dd/... lines for data and undefined regions — IDA's
+- real disassembly view. Address-paged: page forward by re-calling with
+- ``addr`` = the returned cursor.next; page up with ``back=true``."""
+- import ida_bytes
+- import ida_segment
+- import idaapi
+-
+- count = 2000 if count > 2000 else (1 if count < 1 else count)
+- offset = max(int(offset), 0)
+- try:
+- start = parse_address(addr)
+- except Exception as e:
+- return {"addr": str(addr), "error": str(e), "heads": [], "cursor": {"done": True}}
+- seg = ida_segment.getseg(start)
+- if not seg:
+- return {"addr": str(addr), "error": "no segment", "heads": [], "cursor": {"done": True}}
+- lo, hi = seg.start_ea, seg.end_ea
+- if end:
+- try:
+- hi = min(hi, parse_address(end))
+- except Exception:
+- pass
+-
+- rows = []
+- if back:
+- # Collect up to (count+offset) heads strictly before `start`, then take
+- # the window closest to `start`, returned in forward order.
+- walk = []
+- cur = ida_bytes.prev_head(start, lo)
+- while cur != idaapi.BADADDR and cur >= lo and len(walk) < count + offset:
+- walk.append(cur)
+- cur = ida_bytes.prev_head(cur, lo)
+- walk.reverse()
+- chosen = walk[: len(walk) - offset] if offset else walk
+- chosen = chosen[-count:]
+- rows = [_idatui_head_row(e) for e in chosen]
+- first = chosen[0] if chosen else start
+- pea = ida_bytes.prev_head(first, lo)
+- cursor = {"done": True} if pea == idaapi.BADADDR or pea < lo else {"prev": hex(pea)}
+- return {"addr": str(addr), "heads": rows, "cursor": cursor}
+-
+- # Walk by item END (not next_head): next_head SKIPS undefined bytes, but a
+- # flat listing must show them (IDA renders undefined as `db ?` lines, and
+- # navigating to an unmarked address must land ON it). Defined items advance
+- # by get_item_end; a run of undefined bytes is COLLAPSED into one row (its
+- # end found in O(1) via next_head, which skips undefined) so a large .bss or
+- # gap doesn't explode into millions of one-byte rows.
+- def _is_unknown(e):
+- f = ida_bytes.get_flags(e)
+- return not (ida_bytes.is_code(f) or ida_bytes.is_data(f))
+-
+- def _run_end(e):
+- """End (exclusive) of the undefined run starting at ``e``."""
+- nh = ida_bytes.next_head(e, hi)
+- return nh if (nh != idaapi.BADADDR and e < nh <= hi) else hi
+-
+- def _advance(e):
+- if _is_unknown(e):
+- return _run_end(e)
+- nxt = ida_bytes.get_item_end(e)
+- return nxt if nxt > e else e + 1
+-
+- def _rows_for(e):
+- if _is_unknown(e):
+- return [_idatui_unknown_row(e, _run_end(e) - e)]
+- func = idaapi.get_func(e) if annotate else None
+- at_start = func is not None and func.start_ea == e
+- out = []
+- if at_start:
+- out.extend(_idatui_func_header_rows(e))
+- row = _idatui_head_row(e)
+- if at_start:
+- row = dict(row)
+- row["name"] = None # the name is shown on the proc header line
+- elif annotate and row.get("kind") == "code" and row.get("name"):
+- # A code label (loc_XXX/jump target) gets its OWN line at depth 0,
+- # like IDA; strip it from the instruction row below.
+- nm = row["name"]
+- out.append({"ea": hex(e), "kind": "label", "size": 0,
+- "text": nm + ":", "name": nm})
+- row = dict(row)
+- row["name"] = None
+- out.append(row)
+- if row.get("kind") == "data":
+- out.extend(_idatui_struct_member_rows(e)) # expand struct fields
+- if func is not None and ida_bytes.get_item_end(e) >= func.end_ea:
+- out.extend(_idatui_func_footer_rows(e, func))
+- return out
+-
+- ea = ida_bytes.get_item_head(start)
+- for _ in range(offset):
+- if ea >= hi or ea == idaapi.BADADDR:
+- break
+- ea = _advance(ea)
+- more = False
+- while ea != idaapi.BADADDR and ea < hi:
+- if len(rows) >= count:
+- more = True
+- break
+- rows.extend(_rows_for(ea)) # a struct head expands into member rows
+- ea = _advance(ea)
+- cursor = {"next": hex(ea)} if more else {"done": True}
+- return {"addr": str(addr), "heads": rows, "cursor": cursor}
+-
+-
+-@tool
+-@idasync
+-def xref_types(
+- queries: Annotated[list, "[{addr, direction:'to'|'from'|'both', include_fn, dedup, count}]"],
+-) -> dict:
+- """Like xref_query, but every row carries a fine-grained ``kind`` derived from
+- the IDA xref type \u2014 call/jump/flow for code, read/write/offset/text/info for
+- data \u2014 alongside the coarse ``type`` (code/data). Feeds the xref dialog's
+- r/w/call badges. Same query/envelope shape as xref_query."""
+- import idaapi, idautils, ida_funcs, ida_bytes, ida_xref
+- code_kind = {ida_xref.fl_CF: "call", ida_xref.fl_CN: "call",
+- ida_xref.fl_JF: "jump", ida_xref.fl_JN: "jump",
+- ida_xref.fl_F: "flow"}
+- data_kind = {ida_xref.dr_O: "offset", ida_xref.dr_W: "write",
+- ida_xref.dr_R: "read", ida_xref.dr_T: "text", ida_xref.dr_I: "info"}
+-
+- def _kind(xr):
+- table = code_kind if xr.iscode else data_kind
+- return table.get(xr.type, "code" if xr.iscode else "data")
+-
+- def _fn(ea):
+- f = ida_funcs.get_func(ea)
+- if not f:
+- return None
+- return {"addr": hex(f.start_ea), "name": ida_funcs.get_func_name(f.start_ea)}
+-
+- def _resolve(raw):
+- raw = str(raw).strip()
+- try:
+- return int(raw, 16) # handles '0x2490' and '2490'
+- except ValueError:
+- return idaapi.get_name_ea(idaapi.BADADDR, raw)
+-
+- qs = queries if isinstance(queries, list) else [queries]
+- result = []
+- for q in qs:
+- q = q if isinstance(q, dict) else {"addr": q}
+- raw = str(q.get("addr", "")).strip()
+- direction = str(q.get("direction", "to") or "to").lower()
+- include_fn = bool(q.get("include_fn", True))
+- dedup = bool(q.get("dedup", True))
+- try:
+- count = int(q.get("count", 2000) or 2000)
+- except (TypeError, ValueError):
+- count = 2000
+- target = _resolve(raw)
+- rows = []
+- if target is not None and target != idaapi.BADADDR and ida_bytes.is_mapped(target):
+- if direction in ("to", "both"):
+- for xr in idautils.XrefsTo(target, 0):
+- row = {"direction": "to", "addr": hex(xr.frm), "from": hex(xr.frm),
+- "to": hex(target), "type": "code" if xr.iscode else "data",
+- "kind": _kind(xr)}
+- if include_fn:
+- row["fn"] = _fn(xr.frm)
+- rows.append(row)
+- if direction in ("from", "both"):
+- for xr in idautils.XrefsFrom(target, 0):
+- row = {"direction": "from", "addr": hex(xr.to), "from": hex(target),
+- "to": hex(xr.to), "type": "code" if xr.iscode else "data",
+- "kind": _kind(xr)}
+- if include_fn:
+- row["fn"] = _fn(xr.to)
+- rows.append(row)
+- if dedup:
+- seen = set()
+- deduped = []
+- for r in rows:
+- k = (r["direction"], r["from"], r["to"], r["kind"])
+- if k in seen:
+- continue
+- seen.add(k)
+- deduped.append(r)
+- rows = deduped
+- rows = rows[:count]
+- result.append({"query": raw, "data": rows, "next_offset": None})
+- return {"result": result}
+-
+-
+-@tool
+-@idasync
+-def data_type(
+- addr: Annotated[str, "Address or name of a data item / global"],
+-) -> dict:
+- """The current C type of a data item, for prefilling a retype prompt:
+- {addr, name, type, size, is_func}. ``type`` is empty when the item is
+- untyped; ``is_func`` distinguishes a global from a function so the caller
+- knows which flavour of set_type to use."""
+- import idaapi
+- import ida_bytes
+- import ida_name
+- import idc
+- raw = str(addr).strip()
+- try:
+- ea = int(raw, 16)
+- except ValueError:
+- ea = idaapi.get_name_ea(idaapi.BADADDR, raw)
+- if ea == idaapi.BADADDR or not ida_bytes.is_mapped(ea):
+- return {"addr": raw, "error": f"not a mapped address: {raw}"}
+- return {
+- "addr": hex(ea),
+- "name": ida_name.get_name(ea) or "",
+- "type": idc.get_type(ea) or "",
+- "size": int(ida_bytes.get_item_size(ea) or 0),
+- "is_func": bool(idaapi.get_func(ea)),
+- }
+-
+-
+-@tool
+-@idasync
+-def decomp_map(
+- addr: Annotated[str, "Function address or name"],
+-) -> dict:
+- """Per-pseudocode-line instruction coverage for the split view's region
+- highlight: for each line, the set of EAs the decompiler attributes to it,
+- swept across the line's columns via get_line_item. Shape:
+- {addr, lines:[{ea: primary|None, eas:[hex,...]}, ...]}."""
+- import ida_hexrays
+- import idaapi
+- try:
+- ea = int(str(addr), 16)
+- except ValueError:
+- ea = idaapi.get_name_ea(idaapi.BADADDR, str(addr).strip())
+- func = idaapi.get_func(ea)
+- if not func:
+- return {"error": f"no function at {addr}"}
+- try:
+- cfunc = ida_hexrays.decompile(func.start_ea)
+- except Exception as e: # noqa: BLE001
+- return {"error": f"decompile failed: {e}"}
+- if cfunc is None:
+- return {"error": "decompile failed"}
+- lines = []
+- for sl in cfunc.get_pseudocode():
+- line = sl.line
+- eas, seen = [], set()
+- for x in range(len(line) + 1):
+- head = ida_hexrays.ctree_item_t()
+- item = ida_hexrays.ctree_item_t()
+- tail = ida_hexrays.ctree_item_t()
+- if not cfunc.get_line_item(line, x, False, head, item, tail):
+- continue
+- # Match the /*ea*/ marker's source (decompile_function_safe): the
+- # item's dstr() is 'EA: description'; get_ea() reports a different ea.
+- dstr = item.dstr()
+- if not dstr:
+- continue
+- parts = dstr.split(": ", 1)
+- if len(parts) != 2:
+- continue
+- try:
+- e = int(parts[0], 16)
+- except ValueError:
+- continue
+- if e not in seen:
+- seen.add(e)
+- eas.append(hex(e))
+- lines.append({"ea": eas[0] if eas else None, "eas": eas})
+- return {"addr": hex(func.start_ea), "lines": lines}
+-
+-
+-_idatui_strings_cache = {}
+-
+-
+-def _idatui_build_strings(min_len):
+- """[(ea, text, length, typename)] for every string IDA found, cached by
+- min_len (rebuilding the list is O(n) and the browser pages through it)."""
+- import idautils
+- import ida_nalt
+- hit = _idatui_strings_cache.get(min_len)
+- if hit is not None:
+- return hit
+- tnames = {}
+- for nm, lbl in (("STRTYPE_C", "C"), ("STRTYPE_C_16", "utf16"),
+- ("STRTYPE_C_32", "utf32"), ("STRTYPE_PASCAL", "pascal")):
+- v = getattr(ida_nalt, nm, None)
+- if v is not None:
+- tnames[v & 0xFF] = lbl
+- items = []
+- for s in idautils.Strings():
+- if s is None:
+- continue
+- try:
+- text = str(s)
+- except Exception: # noqa: BLE001 -- undecodable literal
+- continue
+- if len(text) < min_len:
+- continue
+- st = getattr(s, "strtype", 0) & 0xFF
+- items.append((s.ea, text, getattr(s, "length", len(text)),
+- tnames.get(st, "t%d" % st)))
+- _idatui_strings_cache[min_len] = items
+- return items
+-
+-
+-@tool
+-@idasync
+-def list_strings(
+- offset: Annotated[int, "Start index into the strings list"] = 0,
+- count: Annotated[int, "Max strings to return (page size)"] = 2000,
+- min_len: Annotated[int, "Minimum string length to include"] = 4,
+- refresh: Annotated[bool, "Rebuild the cached strings list"] = False,
+-) -> dict:
+- """Every string literal IDA found in the binary (IDA's Shift+F12 window),
+- paginated: {strings:[{addr,text,len,type}], total, next_offset}. Feeds the
+- TUI's strings browser."""
+- try:
+- min_len = max(int(min_len), 1)
+- except (TypeError, ValueError):
+- min_len = 4
+- try:
+- offset = max(int(offset), 0)
+- except (TypeError, ValueError):
+- offset = 0
+- try:
+- count = max(int(count), 1)
+- except (TypeError, ValueError):
+- count = 2000
+- if refresh:
+- _idatui_strings_cache.pop(min_len, None)
+- items = _idatui_build_strings(min_len)
+- page = items[offset:offset + count]
+- return {
+- "strings": [{"addr": hex(ea), "text": text, "len": ln, "type": ty}
+- for (ea, text, ln, ty) in page],
+- "total": len(items),
+- "next_offset": offset + len(page),
+- }
+-
+-@tool
+-@idasync
+-def list_linkage(
+- kind: Annotated[str, "'import', 'export' or 'both'"] = "both",
+-) -> dict:
+- """What this binary imports from, and exports to, other modules:
+- {imports:[{addr,name,module}], exports:[{addr,name,ordinal}]}. Feeds the
+- project-wide import/export join, which resolves a PLT stub in one binary to
+- the real implementation in another."""
+- import idaapi
+- import idautils
+- import ida_nalt
+- want = str(kind or "both").lower()
+- imports = []
+- exports = []
+- if want in ("import", "both"):
+- n = ida_nalt.get_import_module_qty()
+- for i in range(n):
+- mod = ida_nalt.get_import_module_name(i) or ""
+-
+- def _cb(ea, name, ordinal, _mod=mod):
+- # An ordinal-only import has no name; skip rather than invent one.
+- if name:
+- imports.append({"addr": hex(ea), "name": name, "module": _mod})
+- return True
+-
+- ida_nalt.enum_import_names(i, _cb)
+- if want in ("export", "both"):
+- for index, ordinal, ea, name in idautils.Entries():
+- if name:
+- exports.append({"addr": hex(ea), "name": name,
+- "ordinal": int(ordinal)})
+- return {"imports": imports, "exports": exports,
+- "n_imports": len(imports), "n_exports": len(exports)}
+-
+-@tool
+-@idasync
+-def define_code_run(
+- addr: Annotated[str, "Address to start disassembling from"],
+- limit: Annotated[int, "Max instructions to create (safety stop)"] = 20000,
+-) -> dict:
+- """Disassemble CONSECUTIVELY from ``addr`` until something stops it, the way
+- IDA's 'c' does — one instruction is rarely what you want when carving a raw
+- image. Returns {start,end,count,stopped} where ``stopped`` says why:
+- 'undecodable' (bytes aren't an instruction), 'flow' (the last instruction
+- doesn't fall through, e.g. RET/B), 'defined' (ran into existing code/data),
+- 'segment' (hit the end) or 'limit'.
+-
+- Runs in-process: doing this from the client would be one round trip per
+- instruction, which is minutes on a real firmware image."""
+- import ida_bytes
+- import ida_idp
+- import ida_segment
+- import ida_ua
+- import idaapi
+-
+- try:
+- ea = parse_address(addr)
+- except Exception as e:
+- return {"addr": str(addr), "error": str(e), "count": 0}
+-
+- seg = ida_segment.getseg(ea)
+- if not seg:
+- return {"addr": str(addr), "error": "no segment", "count": 0}
+- hi = seg.end_ea
+- try:
+- limit = max(1, min(int(limit), 200000))
+- except (TypeError, ValueError):
+- limit = 20000
+-
+- start, count, stopped = ea, 0, "limit"
+- while count < limit:
+- if ea >= hi:
+- stopped = "segment"
+- break
+- flags = ida_bytes.get_flags(ea)
+- if ida_bytes.is_code(flags) or ida_bytes.is_data(flags):
+- # Already defined: stop rather than clobber. Undefining someone's
+- # existing work to keep a speculative run going is not a trade the
+- # user asked for.
+- stopped = "defined"
+- break
+- n = ida_ua.create_insn(ea)
+- if n <= 0:
+- stopped = "undecodable"
+- break
+- count += 1
+- # Stop where control flow stops. Past a RET the next bytes are usually
+- # padding or a new function's data, and running on turns a clean carve
+- # into a mess that has to be undone by hand.
+- #
+- # Ask ida_idp.is_ret_insn, NOT the canonical feature bits: on AArch64
+- # get_canon_feature() returns 0 for RET, so a CF_STOP test silently never
+- # fires and the run walks straight through the end of the routine.
+- insn = ida_ua.insn_t()
+- if ida_ua.decode_insn(insn, ea) > 0:
+- try:
+- is_ret = ida_idp.is_ret_insn(insn)
+- except Exception:
+- is_ret = False
+- if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP):
+- ea += n
+- stopped = "flow"
+- break
+- ea += n
+-
+- return {"start": hex(start), "end": hex(ea), "count": count,
+- "stopped": stopped}
+-
+-@tool
+-@idasync
+-def set_thumb(
+- addr: Annotated[str, "Address to change the ARM decoding mode at"],
+- mode: Annotated[str, "'toggle', 'on' (Thumb) or 'off' (ARM)"] = "toggle",
+- end: Annotated[str, "Optional exclusive end address (default: this item)"] = "",
+-) -> dict:
+- """Switch ARM/Thumb decoding at ``addr`` (IDA's T segment register).
+-
+- Thumb is not a property of the bytes, it's a mode the CPU is in, so a raw
+- image gives IDA no way to know: at a Thumb entry point it decodes 16-bit
+- instructions as 32-bit ARM and produces confident nonsense
+- (``push {r3,lr}`` reads as ``SVCLT 0xBF00``).
+-
+- Also forces the segment to 32-bit when turning Thumb ON. Thumb does not
+- exist in AArch64, and a headerless blob loaded with -parm defaults to
+- 64-bit — so setting T alone changes nothing and looks broken. Asking for
+- Thumb IS asking for ARM32."""
+- import ida_bytes
+- import ida_idp
+- import ida_segment
+- import ida_segregs
+-
+- try:
+- ea = parse_address(addr)
+- except Exception as e:
+- return {"addr": str(addr), "error": str(e)}
+- treg = ida_idp.str2reg("T")
+- if treg is None or treg < 0:
+- return {"addr": hex(ea), "error": "no T register (not an ARM database)"}
+- seg = ida_segment.getseg(ea)
+- if not seg:
+- return {"addr": hex(ea), "error": "no segment"}
+-
+- import ida_ida
+- db64 = ida_ida.inf_get_app_bitness() == 64
+- cur = ida_segregs.get_sreg(ea, treg)
+- cur = 0 if cur in (None, 0xFFFFFFFF, -1) else int(cur)
+- want = {"on": 1, "off": 0}.get(str(mode).lower(), 0 if cur else 1)
+-
+- changed_bits = False
+- if want and seg.bitness != 1:
+- ida_segment.set_segm_addressing(seg, 1)
+- changed_bits = True
+-
+- try:
+- stop = parse_address(end) if end else 0
+- except Exception:
+- stop = 0
+- size = max(int(stop) - ea, 0) or max(ida_bytes.get_item_size(ea), 2)
+- # The bytes are currently decoded in the OLD mode; leaving that item defined
+- # pins the wrong instruction length and the new mode has nothing to apply to.
+- ida_bytes.del_items(ea, 0, size)
+- ok = bool(ida_segregs.split_sreg_range(ea, treg, want, ida_segregs.SR_user))
+- now = ida_segregs.get_sreg(ea, treg)
+- return {"addr": hex(ea), "thumb": bool(now), "was": bool(cur), "ok": ok,
+- "bitness": ida_segment.getseg(ea).bitness,
+- "forced_32bit": changed_bits,
+- # The DATABASE's bitness is fixed at load and can't be corrected
+- # here (setting it post-hoc makes the decompiler INTERR). In a
+- # 64-bit database a 32-bit function disassembles but Hex-Rays
+- # refuses it outright, so say so instead of leaving the user to
+- # discover that F5 does nothing.
+- "db_64bit": bool(db64 and want)}
+-
+-def _idatui_add_func(ea):
+- """add_func at ``ea``, falling back to an explicit end.
+-
+- ida_funcs.add_func(ea) asks IDA to find the end and on carved or
+- freshly-marked code it often can't, failing with no reason given."""
+- import ida_bytes
+- import ida_funcs
+- import ida_segment
+- import idaapi
+-
+- if idaapi.get_func(ea) is not None:
+- return True
+- if ida_funcs.add_func(ea):
+- return True
+- seg = ida_segment.getseg(ea)
+- hi = seg.end_ea if seg else ea
+- end = ea
+- while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)):
+- nxt = ida_bytes.get_item_end(end)
+- if nxt <= end:
+- break
+- end = nxt
+- return bool(end > ea and ida_funcs.add_func(ea, end))
+-
+-
+-@tool
+-@idasync
+-def define_func_run(
+- addr: Annotated[str, "Entry point of the function to create"],
+-) -> dict:
+- """Create a function at ``addr``, working out its end if IDA can't.
+-
+- ida_funcs.add_func(ea) asks IDA to find the end itself, and on hand-carved
+- code it often can't — a run that ends in a tail call, or whose last
+- instruction isn't recognised as a return, simply fails with no reason given.
+- You then have a disassembled routine that refuses to become a function, and
+- F5 has nothing to work with.
+-
+- So: try IDA's way, and if that fails, use the end of the contiguous
+- instruction run starting at ``addr``."""
+- import ida_bytes
+- import ida_funcs
+- import ida_segment
+- import idaapi
+-
+- try:
+- ea = parse_address(addr)
+- except Exception as e:
+- return {"addr": str(addr), "error": str(e), "ok": False}
+- fn = idaapi.get_func(ea)
+- if fn is not None and fn.start_ea == ea:
+- return {"addr": hex(ea), "ok": True, "start": hex(fn.start_ea),
+- "end": hex(fn.end_ea), "how": "existed"}
+- auto = ida_funcs.add_func(ea)
+- if not auto and not _idatui_add_func(ea):
+- return {"addr": hex(ea), "ok": False,
+- "error": f"IDA refused a function at {ea:#x}"}
+- f = idaapi.get_func(ea)
+- if f is None:
+- return {"addr": hex(ea), "ok": False, "error": "function did not stick"}
+- return {"addr": hex(ea), "ok": True, "start": hex(f.start_ea),
+- "end": hex(f.end_ea), "how": "auto" if auto else "explicit-end"}
+-
+-@tool
+-@idasync
+-def decomp_error(
+- addr: Annotated[str, "Address of the function that failed to decompile"],
+-) -> dict:
+- """Why Hex-Rays refused this function, in its own words.
+-
+- The plain decompile tool reports "Decompilation failed at 0x0" and drops the
+- reason, which is the only useful part. Hex-Rays fills in a hexrays_failure_t
+- saying things like "only 64-bit functions can be decompiled in the current
+- database" — that one is unfixable in place (the database's bitness is set at
+- load), so a user who can't see it has no way to know they must reload."""
+- import ida_funcs
+- import ida_hexrays
+- import ida_ida
+-
+- try:
+- ea = parse_address(addr)
+- except Exception as e:
+- return {"addr": str(addr), "error": str(e)}
+- out = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()}
+- fn = ida_funcs.get_func(ea)
+- if fn is None:
+- out["reason"] = "no function here"
+- return out
+- try:
+- if not ida_hexrays.init_hexrays_plugin():
+- out["reason"] = "the decompiler is not available for this processor"
+- return out
+- hf = ida_hexrays.hexrays_failure_t()
+- cf = ida_hexrays.decompile_func(fn, hf)
+- if cf is not None:
+- out["reason"] = "" # it decompiles now
+- return out
+- out["reason"] = hf.desc() or f"error {hf.code}"
+- out["code"] = int(hf.code)
+- out["errea"] = hex(hf.errea)
+- except Exception as e: # noqa: BLE001
+- out["reason"] = f"{type(e).__name__}: {e}"
+- return out
+-
+-@tool
+-@idasync
+-def thumb_scan(
+- start: Annotated[str, "Start of the range to scan for entry pointers"] = "",
+- end: Annotated[str, "Exclusive end of the range (default: 1KB from start)"] = "",
+- apply: Annotated[bool, "Mark the targets as Thumb and disassemble them"] = True,
+- limit: Annotated[int, "Max entries to act on"] = 512,
+-) -> dict:
+- """Find Thumb entry points from ODD pointers, e.g. a Cortex-M vector table.
+-
+- An ARM function pointer carries the mode in bit 0: odd means Thumb. A vector
+- table is therefore a list of Thumb entry points that IDA won't follow on a
+- headerless image, because nothing tells it those words are pointers at all.
+-
+- Being wrong here is expensive — marking a data word as code corrupts the
+- listing — so a word only counts when it is odd, lands inside a loaded
+- segment, and its target is EXECUTABLE and not already defined as data. The
+- even words in a vector table (the initial stack pointer) fail the first test,
+- which is the point."""
+- import ida_bytes
+- import ida_funcs
+- import ida_idp
+- import ida_segment
+- import ida_segregs
+- import ida_ua
+-
+- seg0 = ida_segment.getseg(parse_address(start)) if start else None
+- if seg0 is None:
+- seg0 = ida_segment.getnseg(0)
+- if seg0 is None:
+- return {"error": "no segments", "found": [], "applied": 0}
+- try:
+- lo = parse_address(start) if start else seg0.start_ea
+- hi = parse_address(end) if end else min(lo + 0x400, seg0.end_ea)
+- except Exception as e:
+- return {"error": str(e), "found": [], "applied": 0}
+-
+- treg = ida_idp.str2reg("T")
+- found, applied = [], 0
+- ea = lo
+- while ea + 4 <= hi and len(found) < limit:
+- w = ida_bytes.get_dword(ea)
+- ea += 4
+- if not (w & 1):
+- continue # even: not a Thumb pointer
+- tgt = w & ~1
+- seg = ida_segment.getseg(tgt)
+- if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0):
+- continue # points outside the image, or at data
+- f = ida_bytes.get_flags(tgt)
+- if ida_bytes.is_data(f):
+- continue # already something else; don't fight it
+- rec = {"at": hex(ea - 4), "value": hex(w), "target": hex(tgt),
+- "was_code": bool(ida_bytes.is_code(f))}
+- found.append(rec)
+- if not apply:
+- continue
+- if treg is not None and treg >= 0:
+- ida_segregs.split_sreg_range(tgt, treg, 1, ida_segregs.SR_user)
+- if not ida_bytes.is_code(ida_bytes.get_flags(tgt)):
+- ida_bytes.del_items(tgt, 0, 2)
+- if ida_ua.create_insn(tgt) <= 0:
+- rec["decoded"] = False
+- continue
+- rec["decoded"] = True
+- rec["function"] = _idatui_add_func(tgt)
+- applied += 1
+- return {"start": hex(lo), "end": hex(hi), "found": found,
+- "applied": applied, "n": len(found)}
+-'''
+-
+-SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n"
+-
+-
+-def api_types_path() -> pathlib.Path | None:
+- """Locate ida_pro_mcp/ida_mcp/api_types.py without importing it (importing the
+- submodule would pull in IDA, which isn't available outside a worker)."""
+- spec = importlib.util.find_spec("ida_pro_mcp") # top-level pkg is IDA-free
+- if spec is None or not spec.submodule_search_locations:
+- return None
+- p = pathlib.Path(spec.submodule_search_locations[0]) / "ida_mcp" / "api_types.py"
+- return p if p.exists() else None
+-
+-
+-def main() -> int:
+- path = api_types_path()
+- if path is None:
+- print("idatui: ida_pro_mcp not found; skipping tool injection", file=sys.stderr)
+- return 0
+- text = path.read_text()
+- if BEGIN in text and END in text: # replace the existing block in place
+- pre = text[: text.index(BEGIN)].rstrip()
+- post = text[text.index(END) + len(END):].lstrip("\n")
+- new = pre + "\n\n" + SNIPPET + ("\n" + post if post else "")
+- else:
+- new = text.rstrip() + "\n\n" + SNIPPET
+- if new == text:
+- return 0
+- try:
+- path.write_text(new)
+- except OSError as e:
+- print(f"idatui: could not patch {path}: {e}", file=sys.stderr)
+- return 1
+- print(f"idatui: injected/updated idatui-ext tools in {path}", file=sys.stderr)
+- return 0
+-
+-
+-if __name__ == "__main__":
+- raise SystemExit(main())
+diff --git a/tests/test_codemode_client.py b/tests/test_codemode_client.py
+new file mode 100644
+index 0000000..6303eb9
+--- /dev/null
++++ b/tests/test_codemode_client.py
+@@ -0,0 +1,137 @@
++"""IDA-free contract tests for the Code Mode client adapter."""
++from __future__ import annotations
++
++import os
++import sys
++import tempfile
++from dataclasses import dataclass
++
++sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
++
++import idatui.codemode_client as module # noqa: E402
++from idatui.codemode_client import CodeModeClient, _parse_load_args # noqa: E402
++from idatui.errors import IDAToolError # noqa: E402
++
++PASS = FAIL = 0
++
++
++def check(name: str, condition: bool, detail="") -> None:
++ global PASS, FAIL
++ if condition:
++ PASS += 1
++ print(f" ok {name}")
++ else:
++ FAIL += 1
++ print(f" FAIL {name} {detail}")
++
++
++@dataclass(frozen=True)
++class FakeEntry:
++ pid: int = 123
++ backend: str = "gui"
++ record_id: str = "123-abcdef"
++ exe_path: str = ""
++ idb_path: str = ""
++
++
++class FakeHandle:
++ def __init__(self, path: str) -> None:
++ self.connected = True
++ self.entry = FakeEntry(exe_path=path, idb_path=path + ".i64")
++ self.waited = None
++ self.saved = 0
++ self.closed = False
++ self.code = ""
++ self.code_timeout = None
++
++ def wait_autoanalysis(self, timeout=None):
++ self.waited = timeout
++ return {"complete": True, "status": "complete"}
++
++ def execute_python(self, code, timeout=None):
++ self.code = code
++ self.code_timeout = timeout
++ return {"result": {"sentinel": 7}, "stdout": "", "stderr": ""}
++
++ def save_database(self):
++ self.saved += 1
++ return {"saved": True, "idb_path": self.entry.idb_path}
++
++ def close(self):
++ self.connected = False
++ self.closed = True
++
++
++class FakeDatabaseHandle:
++ opened = None
++ kwargs = None
++
++ @classmethod
++ def open(cls, path, **kwargs):
++ cls.opened = path
++ cls.kwargs = kwargs
++ return FakeHandle(path)
++
++
++def main() -> int:
++ proc, base, file_type = _parse_load_args("-parm:ARMv7-M -b800000 -TRaw")
++ check("legacy switches map to typed Code Mode options",
++ (proc, base, file_type) == ("arm:ARMv7-M", 0x8000000, "Raw"),
++ (proc, base, file_type))
++ try:
++ _parse_load_args("-parm -zcustom")
++ except ValueError as exc:
++ check("arbitrary IDA switches fail loudly", "cannot represent" in str(exc), exc)
++ else:
++ check("arbitrary IDA switches fail loudly", False)
++
++ original = module.DatabaseHandle
++ module.DatabaseHandle = FakeDatabaseHandle
++ try:
++ with tempfile.TemporaryDirectory() as tmp:
++ path = os.path.join(tmp, "sample.bin")
++ with open(path, "wb") as file:
++ file.write(b"sample")
++ client = CodeModeClient(path, load_args="-parm:ARMv7-A -b100")
++ notes = []
++ client.connect(timeout=42, progress=notes.append)
++ handle = client._handle
++ check("connect delegates database discovery to DatabaseHandle.open",
++ FakeDatabaseHandle.opened == path and handle is not None)
++ check("typed loader options cross the dependency boundary",
++ FakeDatabaseHandle.kwargs["processor"] == "arm:ARMv7-A"
++ and FakeDatabaseHandle.kwargs["loading_address"] == 0x1000,
++ FakeDatabaseHandle.kwargs)
++ check("connect waits for Code Mode autoanalysis",
++ handle.waited == 42, getattr(handle, "waited", None))
++ check("progress distinguishes discovery and backend attachment",
++ len(notes) == 2 and "gui" in notes[-1], notes)
++ result = client.invoke("list_funcs", queries=[{"offset": 0, "count": 2}])
++ check("invoke returns execute_python's result", result == {"sentinel": 7}, result)
++ check("operation scripts use the preloaded ida-domain database",
++ "db.functions.get_all()" in handle.code, handle.code[:200])
++ check("health exposes registry identity",
++ client.health()["record_id"] == "123-abcdef")
++ client.save_database()
++ check("save uses the public Code Mode save route", handle.saved == 1)
++ client.close()
++ check("close releases only the handle lease", handle.closed)
++ check("GUI lifetime is never claimed by the client",
++ client.wait_released(0) is False)
++ finally:
++ module.DatabaseHandle = original
++
++ client = CodeModeClient(__file__)
++ try:
++ client.invoke("not-an-operation")
++ except IDAToolError as exc:
++ check("unknown adapter operations are explicit", exc.tool == "not-an-operation")
++ else:
++ check("unknown adapter operations are explicit", False)
++
++ print(f"\n{PASS} passed, {FAIL} failed")
++ return 1 if FAIL else 0
++
++
++if __name__ == "__main__":
++ raise SystemExit(main())
+diff --git a/tests/test_pool.py b/tests/test_pool.py
+index 5c6e2c4..ff0c016 100644
+--- a/tests/test_pool.py
++++ b/tests/test_pool.py
+@@ -1,8 +1,7 @@
+ #!/usr/bin/env python3
+-"""Unit tests for idatui.pool (worker residency: LRU + memory budget).
++"""Unit tests for idatui.pool (Code Mode lease residency and LRU budget).
+
+-Pure stdlib with a fake client injected, so the eviction policy is testable
+-without spawning real idalib workers.
++A fake client keeps the policy testable without IDA or Textual.
+
+ python tests/test_pool.py
+ """
+@@ -11,7 +10,7 @@ import sys
+ import tempfile
+
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+-from idatui.pool import WorkerPool # noqa: E402
++from idatui.pool import DatabasePool # noqa: E402
+ from idatui.project import Project # noqa: E402
+
+ PASS = FAIL = 0
+@@ -28,11 +27,12 @@ def check(name, cond, detail=""):
+
+
+ class FakeClient:
+- """Stands in for a WorkerClient: records saves/closes, reports fixed memory."""
++ """Stands in for a CodeModeClient lease and records saves/closes."""
+
+- def __init__(self, ref, mem=100):
++ def __init__(self, ref, mem=100, backend="idalib"):
+ self.ref = ref
+ self.mem = mem
++ self.backend = backend
+ self.saved = 0
+ self.closed = False
+ self.connected = False
+@@ -41,10 +41,9 @@ class FakeClient:
+ self.connected = True
+ return self
+
+- def call(self, tool, **kw):
+- if tool == "idb_save":
+- self.saved += 1
+- return {}
++ def save_database(self):
++ self.saved += 1
++ return {"saved": True}
+
+ def close(self, grace=None):
+ self.closed = True
+@@ -72,15 +71,15 @@ def main() -> int:
+ made[ref.label] = c
+ return c
+
+- pool = WorkerPool(proj, budget_mb=350, spawn=spawn,
++ pool = DatabasePool(proj, budget_mb=350, spawn=spawn,
+ mem_fn=lambda c: c.mem)
+
+ # -- lazy spawn + reuse -------------------------------------------- #
+ a = pool.get("bin0")
+- check("get() spawns a worker on first use", a is made["bin0"] and a.connected)
++ check("get() spawns a database lease on first use", a is made["bin0"] and a.connected)
+ check("get() stages the binary first",
+ os.path.isfile(proj.by_label("bin0").staged))
+- check("get() reuses the resident worker", pool.get("bin0") is a)
++ check("get() reuses the resident lease", pool.get("bin0") is a)
+ check("resident() reports it", pool.resident() == ["bin0"], pool.resident())
+
+ # -- LRU ordering ---------------------------------------------------- #
+@@ -96,9 +95,9 @@ def main() -> int:
+ check("exceeding the budget evicts the least-recently-used",
+ pool.evicted == ["bin1"] and not pool.is_resident("bin1"),
+ f"evicted={pool.evicted} resident={pool.resident()}")
+- check("the just-spawned worker is never the victim", pool.is_resident("bin3"))
++ check("the just-attached lease is never the victim", pool.is_resident("bin3"))
+ check("eviction saves the database first", made["bin1"].saved == 1)
+- check("eviction closes the worker", made["bin1"].closed)
++ check("eviction closes the lease", made["bin1"].closed)
+ check("pool is back within budget", pool.memory_mb() <= pool.budget_mb,
+ f"{pool.memory_mb()}/{pool.budget_mb}")
+
+@@ -112,7 +111,7 @@ def main() -> int:
+
+ # -- pinning ---------------------------------------------------------- #
+ pool.close_all()
+- pool2 = WorkerPool(proj, budget_mb=250, spawn=spawn, mem_fn=lambda c: c.mem)
++ pool2 = DatabasePool(proj, budget_mb=250, spawn=spawn, mem_fn=lambda c: c.mem)
+ pool2.get("bin0")
+ pool2.pin("bin0")
+ pool2.get("bin1")
+@@ -139,7 +138,7 @@ def main() -> int:
+
+ # -- teardown ----------------------------------------------------------- #
+ pool2.close_all()
+- check("close_all() closes every worker",
++ check("close_all() closes every lease",
+ not pool2.resident() and all(c.closed for c in made.values()))
+ check("close_all() clears the active binary", pool2.active is None)
+
+@@ -151,8 +150,8 @@ def main() -> int:
+ check("an unknown label raises KeyError", True)
+
+ # -- default budget comes from the project's memory_pct ------------------- #
+- pool3 = WorkerPool(proj, spawn=spawn, mem_fn=lambda c: c.mem)
+- check("default budget is derived, not a fixed worker count",
++ pool3 = DatabasePool(proj, spawn=spawn, mem_fn=lambda c: c.mem)
++ check("default budget is derived, not a fixed lease count",
+ pool3.budget_mb >= 256, pool3.budget_mb)
+
+ # -- prewarm: speculative, and never at the cost of a real binary ------ #
+@@ -165,7 +164,7 @@ def main() -> int:
+ made2[ref.label] = c
+ return c
+
+- pool = WorkerPool(proj, budget_mb=250, spawn=spawn2,
++ pool = DatabasePool(proj, budget_mb=250, spawn=spawn2,
+ mem_fn=lambda c: c.mem)
+ labels = [r.label for r in proj.refs]
+ a, b, c_ = labels[0], labels[1], labels[2]
+@@ -184,6 +183,28 @@ def main() -> int:
+ check("prewarm ignores a label outside the project",
+ pool.prewarm("nope") is False)
+
++ # Budget eviction releases GUI leases but must not save somebody's open IDA
++ # implicitly. An explicit save-and-close remains authoritative.
++ with tempfile.TemporaryDirectory() as tmp:
++ proj = _mkproject(tmp, n=1)
++ made_gui = []
++
++ def spawn_gui(ref, ttl):
++ client = FakeClient(ref, backend="gui")
++ made_gui.append(client)
++ return client
++
++ pool = DatabasePool(proj, spawn=spawn_gui, mem_fn=lambda c: c.mem)
++ label = proj.refs[0].label
++ pool.get(label)
++ pool.evict(label)
++ check("LRU release does not implicitly save a GUI database",
++ made_gui[-1].saved == 0)
++ pool.get(label)
++ pool.close_all(save=True)
++ check("explicit close_all(save=True) does save a GUI database",
++ made_gui[-1].saved == 1)
++
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+diff --git a/tests/test_project.py b/tests/test_project.py
+index 91fd250..7c0dcba 100644
+--- a/tests/test_project.py
++++ b/tests/test_project.py
+@@ -1,7 +1,7 @@
+ #!/usr/bin/env python3
+ """Unit tests for idatui.project (the multi-binary project model + staging).
+
+-Pure stdlib: no IDA, no textual, no worker — runs anywhere in under a second.
++IDA-free: exercises staging plus Code Mode ownership checks without opening a database.
+
+ python tests/test_project.py
+ """
+diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
+index 9d77680..ab510f4 100644
+--- a/tests/test_scenarios.py
++++ b/tests/test_scenarios.py
+@@ -2316,7 +2316,13 @@ async def _build_pristine(binary, cache):
+ await pilot.pause(0.05)
+ if app._func_index is not None and app._func_index.complete:
+ break
+- app.program.client.call("idb_save", timeout=600.0)
++ app.program.client.save_database()
++ # Textual's headless run_test context does not reliably emit App.Unmount on
++ # every platform/version; release the Code Mode lease explicitly.
++ if app.program is not None:
++ app.program.close()
++ if app.client is not None:
++ app.client.close()
+ db = binary + ".i64"
+ if os.path.exists(db):
+ shutil.copy2(db, cache)
+@@ -2346,7 +2352,7 @@ async def run(binary, only=None):
+
+
+ async def _run_on(binary, only=None):
+- # Own idalib worker: opens the binary in-process over a unix socket.
++ # Code Mode attaches a registered GUI or starts/reuses a managed worker.
+ app = IdaTui(open_path=binary, keepalive=False)
+ async with app.run_test(size=(140, 44)) as pilot:
+ c = Ctx(app, pilot)
+@@ -2366,6 +2372,14 @@ async def _run_on(binary, only=None):
+ print(f"── {name} ({asyncio.get_event_loop().time() - _t0:.1f}s) CRASHED")
+ c.check("scenario did not crash", False, f"{type(e).__name__}: {e}")
+ traceback.print_exc()
++ # Headless run_test does not reliably emit App.Unmount; explicitly release
++ # the lease. Then wait through the managed worker's final-lease grace and
++ # IDB close so Windows can remove this suite's TemporaryDirectory safely.
++ if app.program is not None:
++ app.program.close()
++ if app.client is not None:
++ app.client.close()
++ await asyncio.to_thread(app.client.wait_released, 45.0)
+
+
+ def main(argv):
+diff --git a/uv.lock b/uv.lock
+index 91414b5..392ebf0 100644
+--- a/uv.lock
++++ b/uv.lock
+@@ -11,27 +11,69 @@ wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+ ]
+
++[[package]]
++name = "ida-codemode-mcp"
++version = "0.2.0"
++source = { editable = "../ida-codemode-mcp" }
++dependencies = [
++ { name = "ida-domain" },
++ { name = "zeromcp" },
++]
++
++[package.metadata]
++requires-dist = [
++ { name = "ida-domain", git = "https://github.com/HexRaysSA/ida-domain?branch=main" },
++ { name = "zeromcp", specifier = ">=1.5.0" },
++]
++
++[package.metadata.requires-dev]
++dev = [
++ { name = "pytest", specifier = ">=9.0.3" },
++ { name = "ruff", specifier = ">=0.12.0" },
++]
++
++[[package]]
++name = "ida-domain"
++version = "0.5.1.dev1"
++source = { git = "https://github.com/HexRaysSA/ida-domain?branch=main#8f36bbce94f0dd55e4ad5f7c8b5f0ef59b9c557a" }
++dependencies = [
++ { name = "idapro" },
++ { name = "packaging" },
++ { name = "typing-extensions" },
++]
++
++[[package]]
++name = "idapro"
++version = "0.0.10"
++source = { registry = "https://pypi.org/simple" }
++sdist = { url = "https://files.pythonhosted.org/packages/f8/75/249c605cc144a6b3778c48381d31ff9242f3e0b7ae23a9ca9c27224e641a/idapro-0.0.10.tar.gz", hash = "sha256:417c03c4605d18417e470f6a748e397b39d6d5829ebd3bbdedd92ff5b9092d11", size = 1060989, upload-time = "2026-07-15T12:55:22.313Z" }
++wheels = [
++ { url = "https://files.pythonhosted.org/packages/e8/83/7b02832cc8b057f686cccdb771fe80282f801a9b798961f8070bb468c73c/idapro-0.0.10-py3-none-any.whl", hash = "sha256:43f227953a0e348ced21c050d277b7ce34103e2ce05fc739b7d8c186ef0e1542", size = 2194897, upload-time = "2026-07-15T12:55:20.88Z" },
++]
++
+ [[package]]
+ name = "idatui"
+ version = "0.0.1"
+ source = { editable = "." }
++dependencies = [
++ { name = "ida-codemode-mcp" },
++ { name = "pygments" },
++ { name = "textual" },
++]
+
+ [package.optional-dependencies]
+ dev = [
+ { name = "pytest" },
+ ]
+-tui = [
+- { name = "pygments" },
+- { name = "textual" },
+-]
+
+ [package.metadata]
+ requires-dist = [
+- { name = "pygments", marker = "extra == 'tui'", specifier = ">=2" },
++ { name = "ida-codemode-mcp", editable = "../ida-codemode-mcp" },
++ { name = "pygments", specifier = ">=2" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
+- { name = "textual", marker = "extra == 'tui'", specifier = ">=8" },
++ { name = "textual", specifier = ">=8" },
+ ]
+-provides-extras = ["tui", "dev"]
++provides-extras = ["dev"]
+
+ [[package]]
+ name = "iniconfig"
+@@ -191,3 +233,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d
+ wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
+ ]
++
++[[package]]
++name = "zeromcp"
++version = "1.5.0"
++source = { registry = "https://pypi.org/simple" }
++sdist = { url = "https://files.pythonhosted.org/packages/95/10/0c5018221766413c808b62f229a3b6b2cd0e4b10bc9ac25fee6152c22938/zeromcp-1.5.0.tar.gz", hash = "sha256:ef4e590ddb20a30a2ceaee86dbf893c9edb5d3e583c22a0ea7025e94763e59d2", size = 95257, upload-time = "2026-07-22T13:39:29.535Z" }
++wheels = [
++ { url = "https://files.pythonhosted.org/packages/8c/46/aa0e0941b511969a3eb70ea19add43b22c7336a4bf65a4fe4ea2176faf25/zeromcp-1.5.0-py3-none-any.whl", hash = "sha256:ca3b67687850ed463a255c180a286901ea69343612dc84ef279ec75b460f77ee", size = 21875, upload-time = "2026-07-22T13:39:28.44Z" },
++]
+--
+2.53.0.windows.2
+