aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--README.md100
-rw-r--r--TODO10
-rw-r--r--docs/CODEMODE_PORT.md175
-rw-r--r--docs/PAGING_FINDINGS.md48
-rw-r--r--docs/PROJECTS.md41
-rw-r--r--docs/SPLIT_VIEW.md12
-rw-r--r--experiments/worker_smoke.py100
-rw-r--r--ida-codemode-mcp.patch5848
-rwxr-xr-xida-tui7
-rw-r--r--idatui/__init__.py5
-rw-r--r--idatui/app.py200
-rw-r--r--idatui/codemode_client.py1164
-rw-r--r--idatui/domain.py209
-rw-r--r--idatui/drive.py3
-rw-r--r--idatui/edit_ctl.py4
-rw-r--r--idatui/errors.py10
-rw-r--r--idatui/launch.py103
-rw-r--r--idatui/pane.py93
-rw-r--r--idatui/pool.py100
-rw-r--r--idatui/project.py28
-rw-r--r--idatui/rpc.py4
-rw-r--r--idatui/worker.py301
-rw-r--r--idatui/worker_client.py266
-rw-r--r--pyproject.toml17
-rw-r--r--server/patch_server.py2258
-rw-r--r--tests/_fixtures.py8
-rwxr-xr-xtests/run.py4
-rw-r--r--tests/test_codemode_client.py140
-rw-r--r--tests/test_launch.py124
-rw-r--r--tests/test_pool.py61
-rw-r--r--tests/test_project.py2
-rw-r--r--tests/test_scenarios.py34
-rw-r--r--tests/test_worker_client.py447
-rw-r--r--uv.lock65
34 files changed, 7999 insertions, 3992 deletions
diff --git a/README.md b/README.md
index bc514c5..ffd15c6 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.
- The startup splash draws the **real logo image** on terminals that speak the
kitty graphics protocol (~10× the resolution of the block art), and falls back
to `logo.ans` everywhere else. Support is detected by *asking the terminal*,
@@ -77,46 +77,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
@@ -130,15 +142,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
@@ -195,7 +208,9 @@ See `docs/RPC.md` for the full protocol.
## Tests
-`tests/run.py` is the front door — it runs every suite and prints one table:
+`tests/run.py` is the front door — it runs every suite and prints one table.
+The live suites attach through Code Mode (a registered GUI database, or a managed
+idalib worker started on demand) for the given binary:
```sh
python3 tests/run.py --fast # 257 checks, ~0.5s, any python3 — between edits
@@ -227,6 +242,7 @@ The individual suites still run standalone, which is how you iterate on one:
## 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 f3338a0..726e2b1 100644
--- a/TODO
+++ b/TODO
@@ -1,6 +1,16 @@
RESTART.
TODO:
+[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] add support for toggling literal types, ala `o` in IDA. (decimal to hex to
reference etc.)
-> `o` / `O` on either code view cycle the literal under the cursor;
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).
+Code Mode returns the complete execution result directly; ida-tui no longer
+needs MCP structured-content/download-URL recovery for large pseudocode bodies.
-## Worker lifecycle (idatui's own idalib worker)
+## Code Mode lifecycle
-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:
+`CodeModeClient` owns an authenticated SSE lease on a registered database:
-* **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.
+* 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)
+"""Exercise the real domain.Program through an IDA Code Mode lease.
-# --- drive the REAL domain layer through the worker (read path) ---
-main=prog.resolve("main")
-print("resolve('main') =", hex(main), flush=True)
-
-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)
+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
-b=prog.read_bytes(main, 16)
-print("read_bytes(main,16) ->", b.hex(), flush=True)
+import os
+import sys
+import time
-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)
+from idatui.codemode_client import CodeModeClient
+from idatui.domain import Program
-d=prog.decompile(main)
-print("decompile(main) -> failed?", d.failed, "lines:", len((d.code or '').splitlines()), flush=True)
-regs=prog.file_regions()
-print("file_regions ->", len(regs), "segments", flush=True)
+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()
-# 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()
+if __name__ == "__main__":
+ raise SystemExit(main())
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
+
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 4a58b16..a4b03a1 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
@@ -32,7 +32,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
@@ -53,8 +53,8 @@ from .trace_ctl import TraceController
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")
@@ -173,8 +173,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
@@ -1085,9 +1085,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]
@@ -3493,12 +3493,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"),
]
@@ -3515,7 +3519,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:
@@ -4780,15 +4784,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 "")
#: Where we are in the execution trace, and everything that moves us.
#: Owns the trace state; the _trace/_t/_trail_* properties below
@@ -4797,7 +4802,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
@@ -4900,8 +4905,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:
@@ -4932,6 +4937,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:
@@ -4945,7 +4955,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"
@@ -4964,10 +4978,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:
@@ -4979,7 +4996,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)
@@ -4993,23 +5012,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
@@ -5053,6 +5055,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
@@ -5106,10 +5113,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
@@ -5174,9 +5177,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):
@@ -5208,15 +5212,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
@@ -5224,7 +5228,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
@@ -5244,13 +5248,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
@@ -5265,14 +5269,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))
@@ -5283,14 +5287,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
@@ -5362,7 +5367,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)
@@ -5380,7 +5385,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)
@@ -5465,7 +5470,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)
@@ -5654,6 +5665,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":
@@ -5670,7 +5684,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)
@@ -5710,7 +5724,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,
@@ -5731,8 +5745,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)
@@ -5769,7 +5783,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
@@ -5826,28 +5840,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)."""
@@ -6356,7 +6348,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:
@@ -6529,7 +6521,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.
@@ -6742,7 +6734,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
@@ -7727,7 +7719,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:
@@ -7739,7 +7733,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..77a0227
--- /dev/null
+++ b/idatui/codemode_client.py
@@ -0,0 +1,1164 @@
+"""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 .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session
+
+# ida_codemode is imported EAGERLY-IF-PRESENT but never at hard import cost.
+#
+# The paging/graph/trace layers and their offline test suites must keep importing
+# `idatui` on a machine with no IDA and no Code Mode installed -- that is the
+# house rule the stdlib-only worker client used to satisfy for free, and
+# `tests/run.py --fast` (257 checks, any python3) depends on it. A hard top-level
+# import here makes the whole package unimportable, so the failure is deferred to
+# the first operation that genuinely needs the library.
+_CODEMODE_ERROR: Exception | None = None
+try:
+ 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
+except ImportError as _exc: # library absent: usable only for offline layers
+ _CODEMODE_ERROR = _exc
+ # Bound to None rather than left undefined so the names stay patchable: the
+ # offline contract tests inject a fake DatabaseHandle here.
+ ClientError = InstanceDisconnectedError = RemoteError = None # type: ignore[assignment,misc]
+ DatabaseHandle = RegistryEntry = FileLock = None # type: ignore[assignment,misc]
+ REGISTRY_DIR = canonical_path = idb_key = scan_instances = None # type: ignore[assignment]
+ IdbBusy = expected_idb_path = None # type: ignore[assignment]
+
+
+def _require_codemode() -> None:
+ """Raise an actionable error when the Code Mode library is missing.
+
+ Gated on the binding, not on the original import result, so a test that
+ injects a fake ``DatabaseHandle`` exercises the real adapter logic.
+ """
+ if DatabaseHandle is None:
+ raise IDAConnectionError(
+ "ida-codemode-mcp is not installed in this environment "
+ f"({_CODEMODE_ERROR}). Install it (e.g. `uv sync`, or "
+ "`pip install -e ../ida-codemode-mcp`) so ida-tui can lease a "
+ "database.") from _CODEMODE_ERROR
+
+
+def database_owner(idb_path: str, staged_path: str | None = None):
+ """The registry entry that owns ``idb_path``/``staged_path``, else None.
+
+ Returns None when the Code Mode library is absent: with no library there is
+ no client in this environment that could be holding the database, and the
+ IDA-free layers (project staging) must keep working. Registry errors that
+ happen WITH the library installed still propagate -- those mean "we could
+ not determine ownership", which is not the same as "nobody owns it".
+ """
+ if DatabaseHandle is None:
+ return None
+ expected_key = idb_key(idb_path)
+ staged = canonical_path(staged_path) if staged_path else None
+ for item in scan_instances(timeout=0.5):
+ entry = item.entry
+ if entry.idb_key == expected_key:
+ return entry
+ if staged and entry.exe_path and canonical_path(entry.exe_path) == staged:
+ return entry
+ return None
+
+
+def registered_database(path: str, output_database: str | None = None) -> bool:
+ """Whether a live/lock-held Code Mode instance owns this target."""
+ _require_codemode()
+ 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":
+ _require_codemode()
+ 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:
+ _require_codemode()
+ 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 aedade9..6fe73c2 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 collections.abc import Sequence
from dataclasses import dataclass, field, replace
@@ -36,7 +30,7 @@ from . import diag
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
@@ -66,7 +60,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:
@@ -93,7 +87,7 @@ class Line:
class Head(NamedTuple):
- """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.
A ``NamedTuple`` rather than a dataclass because this is by far the
@@ -114,7 +108,7 @@ class Head(NamedTuple):
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).
#:
#: Held exactly as it came off the wire, and **read-only**. The worker
@@ -301,7 +295,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).
"""
@@ -321,7 +315,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:
@@ -431,7 +425,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")
@@ -492,7 +486,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(),
)
@@ -633,15 +627,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):
@@ -754,7 +748,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 {}
@@ -1019,7 +1013,7 @@ class ListingModel:
# the expectation rather than asking first means a page that HAS changed
# still costs one round trip.
try:
- payload = self._prog.client.call(
+ payload = self._prog.client.invoke(
"heads", addr=hex(addr), count=self.PAGE, annotate=True,
expect="" if want_digest is None else str(want_digest))
except Exception: # noqa: BLE001 -- keep the old text rather than blank
@@ -1233,7 +1227,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"
@@ -1257,7 +1251,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 ------------------------------------------------- #
@@ -1284,17 +1278,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"]),
@@ -1303,7 +1294,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,
@@ -1345,8 +1336,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()]
@@ -1364,15 +1354,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)
@@ -1386,7 +1375,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 []
@@ -1437,7 +1426,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")
@@ -1447,9 +1436,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 {}
@@ -1472,7 +1461,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")
@@ -1481,10 +1470,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"):
@@ -1497,7 +1485,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"):
@@ -1506,9 +1494,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"):
@@ -1517,7 +1505,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 {}
@@ -1526,9 +1514,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"):
@@ -1537,15 +1525,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 ------------------------------------------------------- #
@@ -1559,13 +1546,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)
@@ -1574,10 +1555,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
@@ -1587,7 +1568,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
@@ -1595,15 +1579,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)
@@ -1681,18 +1656,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):
@@ -1711,7 +1686,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",
@@ -1720,7 +1695,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')}")
@@ -1729,11 +1704,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)}
@@ -1745,14 +1720,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"}
@@ -1766,7 +1741,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']}")
@@ -1776,7 +1751,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'}")
@@ -1784,7 +1759,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(
@@ -1801,7 +1776,7 @@ class Program:
``cycle``/``back`` (step the stops that make sense for this value) or a
format by name. ``show`` reports without changing anything.
"""
- r = self.client.call("op_format", addr=hex(ea), mode=str(mode),
+ r = self.client.invoke("op_format", addr=hex(ea), mode=str(mode),
col=int(col), n=int(n))
res = r if isinstance(r, dict) else {}
if res.get("error"):
@@ -1825,7 +1800,7 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- r = self.client.call("pc_nums", addr=hex(fn_ea))
+ r = self.client.invoke("pc_nums", addr=hex(fn_ea))
except Exception: # noqa: BLE001 -- an older worker hasn't got the tool
r = {}
out: dict[int, list[tuple[int, int, str, int, int]]] = {}
@@ -1848,7 +1823,7 @@ class Program:
listing's format doesn't reach the pseudocode and vice versa, so this is
a separate call rather than a flag on ``op_format``.
"""
- r = self.client.call("pc_num_format", addr=hex(fn_ea), mode=str(mode),
+ r = self.client.invoke("pc_num_format", addr=hex(fn_ea), mode=str(mode),
line=int(line), col=int(col))
res = r if isinstance(r, dict) else {}
if res.get("error"):
@@ -1865,18 +1840,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 as e: # noqa: BLE001 -- fall back to the truncated preview
- # The user gets CLIPPED pseudocode with no indication that a fetch
- # failed rather than the function genuinely being that short.
- diag.note(f"decompile: full-body fetch {url}", e)
- 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."""
@@ -1889,7 +1852,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:
@@ -1914,13 +1877,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):
@@ -1951,7 +1914,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 []
@@ -1981,7 +1944,7 @@ class Program:
if hit is not None and hit[1] == gen:
return hit[0]
try:
- payload = self.client.call("flowchart", addr=hex(ea))
+ payload = self.client.invoke("flowchart", addr=hex(ea))
except IDAToolError:
return None
if not isinstance(payload, dict) or payload.get("error"):
@@ -2054,7 +2017,7 @@ class Program:
for _ in range(64): # bounded: ~128k heads
if addr >= hi:
break
- payload = self.client.call("heads", addr=hex(addr), end=hex(hi),
+ payload = self.client.invoke("heads", addr=hex(addr), end=hex(hi),
count=2000)
rows = payload.get("heads", []) if isinstance(payload, dict) else []
if not rows:
@@ -2083,13 +2046,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}],
)
@@ -2101,9 +2064,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 ------------------------------------------------ #
@@ -2121,17 +2084,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 []
@@ -2169,7 +2132,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 1c7d8c2..825111c 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/edit_ctl.py b/idatui/edit_ctl.py
index 435fab9..80e1109 100644
--- a/idatui/edit_ctl.py
+++ b/idatui/edit_ctl.py
@@ -242,7 +242,7 @@ class EditController:
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:
app.call_from_thread(app._status, f"rename failed: {e.message}")
return
@@ -274,7 +274,7 @@ class EditController:
app = self.app
assert app.program is not None
try:
- res = app.program.client.call(
+ res = app.program.client.invoke(
"rename", batch={"data": {"addr": hex(addr), "new": name}})
except IDAToolError as e:
app.call_from_thread(app._status, f"name failed: {e.message}")
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 35221a1..0c53987 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).
@@ -41,35 +33,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.
-
- Never touches the ``.i64`` -- that is the real database, and nothing is
- saved unless ``idb_save`` was called -- and never the input file itself.
- The second guard is not theoretical: ``.til`` is both an unpacked-DB suffix
- and the extension of an IDA type library, so ``ida-tui mylib.til`` swept its
- own argument out of existence. Same for anything named ``*.id0``/``*.nam``.
- """
- keep = os.path.abspath(binary)
- 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:
- victim = base + suf
- if os.path.abspath(victim) == keep:
- continue # that's what the user asked us to open
- try:
- os.remove(victim)
- 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)")
@@ -77,9 +53,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",
@@ -94,7 +70,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 = {}
@@ -152,27 +128,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
# Ask the terminal about graphics support NOW: the query needs a reply from
# stdin, and once Textual starts it reads stdin on its own thread and would
diff --git a/idatui/pane.py b/idatui/pane.py
index 2592581..c31a93c 100644
--- a/idatui/pane.py
+++ b/idatui/pane.py
@@ -23,9 +23,9 @@ per pane in the registry, so stop/list/capture/keys keep working across both
python -m idatui.pane capture --pane <pane>
python -m idatui.pane keys --pane <pane> Escape
-Requires: running inside tmux or zellij. 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 or zellij. Each pane leases a registered GUI or
+shared managed idalib database through Code Mode. Uses ~/ida-venv/bin/python for
+the TUI (needs textual) unless --python / IDATUI_PYTHON says otherwise.
"""
from __future__ import annotations
@@ -33,7 +33,6 @@ import argparse
import json
import os
import secrets
-import signal
import subprocess
import sys
import time
@@ -251,35 +250,9 @@ def _pane_keys(pane: str, keys: list[str], mux: str | None = None) -> None:
subprocess.run(["tmux", "send-keys", "-t", pane, *keys], check=True)
-# --------------------------------------------------------------------------- #
-# 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
+# Code Mode owns database process lifetime: a closed pane drops its lease at the
+# socket/kernel boundary and Code Mode decides whether a managed worker still
+# has clients. There is nothing for the pane layer to reap.
def _count_live_panes() -> int:
@@ -288,20 +261,9 @@ def _count_live_panes() -> int:
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
# --------------------------------------------------------------------------- #
@@ -332,16 +294,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).
@@ -393,9 +347,8 @@ def _wait_ready(sock: str, timeout: float, pane: str,
stuck_after: float = 45.0, mux: str | None = None) -> 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
@@ -416,9 +369,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
@@ -515,13 +467,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
@@ -624,9 +572,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)
cp = sub.add_parser("capture", help="print a pane's visible screen")
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..53f3b0a 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,25 @@ 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 .codemode_client import database_owner
+ owner = database_owner(ref.db, ref.staged)
+ 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 +351,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/rpc.py b/idatui/rpc.py
index 698e4cf..225aeb2 100644
--- a/idatui/rpc.py
+++ b/idatui/rpc.py
@@ -757,7 +757,7 @@ class RpcServer:
batch = {"func": ops, "allow_overwrite": bool(overwrite)}
# The worker is blocking and single-threaded; off the event loop it goes,
# or the TUI freezes for the length of the batch.
- res = await asyncio.to_thread(app.program.client.call, "rename", batch=batch)
+ res = await asyncio.to_thread(app.program.client.invoke, "rename", batch=batch)
summary = res.get("summary", {}) if isinstance(res, dict) else {}
failed = [r for r in (res.get("func") or []) if isinstance(r, dict)
and r.get("error")] if isinstance(res, dict) else []
@@ -768,7 +768,7 @@ class RpcServer:
# this a batch import leaves pseudocode calling sub_98C0 forever while
# the listing (and every readback) says memset.
try:
- await asyncio.to_thread(app.program.client.call, "force_recompile")
+ await asyncio.to_thread(app.program.client.invoke, "force_recompile")
except Exception: # noqa: BLE001 -- older worker without the tool
pass
app.program.bump_names()
diff --git a/idatui/worker.py b/idatui/worker.py
deleted file mode 100644
index 556e69a..0000000
--- a/idatui/worker.py
+++ /dev/null
@@ -1,301 +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 threading
-import time
-import uuid
-
-#: Seconds a single tool call may run before it is cancelled. 0 disables the
-#: deadline entirely.
-TOOL_TIMEOUT_SEC = float(os.environ.get("IDATUI_TOOL_TIMEOUT_SEC") or 60)
-
-# ida-pro-mcp enforces its own tool deadline by installing a `sys.setprofile`
-# hook for the duration of every call, so that a pure-python loop inside a tool
-# body can be interrupted. That hook runs a python function on EVERY python call
-# and return -- and our tools are exactly the call-heavy kind: `heads` renders
-# hundreds of items per request and measured 92us/row with the hook against
-# 28us/row without it. A 3.3x tax on the whole backend to bound loops that are
-# already bounded by their `count` argument.
-#
-# So: turn the upstream mechanism off and re-arm the half that does the real
-# work ourselves (see _Deadline). ida_kernwin.set_cancelled() is what actually
-# frees the IDA main thread -- decompile, auto_wait, find_bytes and friends poll
-# user_cancelled() and bail within a poll cycle -- and it costs nothing until it
-# fires.
-os.environ["IDA_MCP_TOOL_TIMEOUT_SEC"] = "0"
-
-
-class _Deadline:
- """A single watchdog thread that cancels a tool call which overruns.
-
- Arming is two attribute writes, because it is on the path of every call the
- TUI makes (a scroll is dozens of them). The watchdog polls instead of being
- signalled for the same reason: waking a thread per call costs more than the
- 0.25s of granularity it buys on a 60s deadline.
- """
-
- TICK = 0.25
-
- def __init__(self, seconds: float) -> None:
- import ida_kernwin
- self._kernwin = ida_kernwin
- self.seconds = seconds
- self._until: float | None = None
- t = threading.Thread(target=self._run, name="idatui-deadline",
- daemon=True)
- t.start()
-
- def _run(self) -> None:
- while True:
- time.sleep(self.TICK)
- until = self._until
- if until is not None and time.monotonic() >= until:
- self._until = None
- # THREAD_SAFE in the IDA SDK; upstream fires it off a Timer too.
- self._kernwin.set_cancelled()
-
- def arm(self) -> None:
- # Clear unconditionally: the flag is sticky, and one left set would make
- # every later user_cancelled() true forever.
- self._kernwin.clr_cancelled()
- self._until = time.monotonic() + self.seconds
-
- def disarm(self) -> None:
- self._until = None
-
-
-# --------------------------------------------------------------------------- #
-# 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]
- deadline = _Deadline(TOOL_TIMEOUT_SEC) if TOOL_TIMEOUT_SEC > 0 else None
-
- 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}")
- if deadline is None:
- result = fn(**args)
- else:
- deadline.arm()
- try:
- result = fn(**args)
- finally:
- deadline.disarm()
- # 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 173cd9d..0000000
--- a/idatui/worker_client.py
+++ /dev/null
@@ -1,266 +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()
- #: Set by close(). A dropped socket is respawned on the next call (the
- #: worker segfaulted and we want it back); a CLOSED one must not be.
- #: Teardown and binary-switch both close while @work threads are still
- #: in flight, so without this, quitting during a decompile spawned a
- #: fresh idalib worker that re-opened the database nobody was looking
- #: at any more -- a stray process holding the .i64 we just released.
- self._closed = False
-
- # -- 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:
- self._closed = False # an explicit reconnect revives this client
- 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()
- # Poll fast at first, then back off. A flat 0.2s cost every caller a
- # fifth of a second even when the worker was ready in milliseconds
- # (a small binary, or a seeded .i64), which is most of the time in
- # the tests and noticeable on a re-open.
- #
- # Backing off all the way to 0.2s was too eager: a seeded database
- # is ready at ~250ms, by which point the delay has grown to 134ms, so
- # every open waited ~350ms whatever the binary -- the same number for
- # a 47KB `echo` and a 1.2MB `bash`, which is what gives a polling
- # artefact away. Cap the backoff at 25ms instead: the overshoot on a
- # fast open is bounded by that, and 40 probes a second is nothing
- # next to an auto-analysis that runs for minutes.
- #
- # Do NOT be tempted to hold the 5ms rate instead. This poll runs on a
- # background thread while the UI thread is drawing, and 200 wakeups a
- # second through a cold analysis cost enough GIL time to delay the
- # app's own startup -- it left the loading overlay up long enough for
- # project mode's first keypress to land on it.
- delay = 0.005
- 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(delay)
- delay = min(delay * 1.6, 0.025)
- 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
- self._closed = True
- 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._closed:
- raise IDAConnectionError(
- f"{tool}: this worker was closed (call connect() to revive it)")
- 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 6667e12..0000000
--- a/server/patch_server.py
+++ /dev/null
@@ -1,2258 +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, flags=None):
- """One flat-listing row for the head at ``ea``: kind (code/data/unknown),
- byte size, rendered text, and any symbol name.
-
- ``flags`` lets a caller that already asked for them say so -- the walk in
- ``heads`` used to fetch them three times per head (here, in _is_unknown from
- _advance, and again from _rows_for).
- """
- import ida_bytes
- import ida_lines
- import ida_name
-
- f = ida_bytes.get_flags(ea) if flags is None else flags
- 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, spans, ops = _idatui_line_parts(line) if line else ("", None, None)
- row = {
- "ea": hex(ea),
- "kind": kind,
- "size": int(ida_bytes.get_item_size(ea)),
- "text": text,
- }
- if spans is not None:
- row["spans"] = spans
- # Where each operand sits in `text`. Comes out of the same tag walk
- # (free), and is what lets the client show WHICH literal a keypress
- # would reformat before you press it.
- if ops:
- row["ops"] = ops
- nm = ida_name.get_ea_name(ea)
- if nm:
- row["name"] = nm
- return row
-
-
-import functools as _idatui_functools
-import os as _idatui_os
-
-#: Entries in the per-line render cache. Sized to hold a whole segment's
-#: DISTINCT lines rather than a working set, because the listing gets rendered
-#: TWICE: once when it is first walked, and again after a rename, which restates
-#: every row's text. bash's .text is 228 659 rows but only 53 363 distinct
-#: lines, and the difference between thrashing and not is the whole win:
-#:
-#: maxsize first sweep second sweep worker RSS
-#: 16 384 17.2 us/row 16.9 us/row +29 MB
-#: 32 768 17.0 17.2 +52 MB
-#: 65 536 17.0 11.1 +75 MB
-#: 131 072 16.9 11.2 +75 MB (working set fits)
-#:
-#: It is a bound, not a proportion: a bigger binary fills it and stops, so the
-#: cost is capped at ~56 MB whatever is open. Lower it with IDATUI_LINE_CACHE if
-#: a pool of workers is competing for memory.
-_IDATUI_LINE_CACHE = int(_idatui_os.environ.get("IDATUI_LINE_CACHE") or 65536)
-
-
-@_idatui_functools.lru_cache(maxsize=_IDATUI_LINE_CACHE)
-def _idatui_line_parts(line):
- """``(text, spans, ops)`` for one tagged disassembly line -- memoised.
-
- A function of the tagged line and nothing else, so the same line always
- gives the same answer: a rename changes the line, which changes the key.
- And listings repeat themselves hard -- 196k lines of bash are 53k distinct
- ones, so a 16k-entry cache serves ~70% of them and takes the per-line cost
- from 10.4us to 3.9us. This is the most expensive thing the backend does per
- listing row, and a jump to an address near the end of a big binary walks
- hundreds of thousands of them.
-
- ``spans`` is None when the tag walk and the plain text disagree about what
- the line says (then the text wins and the row renders unhighlighted).
-
- The returned lists are SHARED between every row that has the same line;
- treat them as read-only. Pickle notices the sharing too, so a page of
- repetitive disassembly also serialises smaller.
- """
- import ida_lines
- text = " ".join(ida_lines.tag_remove(line).split()) # collapse the padding
- spans, ops = _idatui_spans(line)
- # Built from the SAME line as `text`, then whitespace-collapsed identically,
- # so the two can never disagree about what the row says.
- joined = "".join([t for _k, t in spans])
- if " ".join(joined.split()) != text:
- return (text, None, None)
- return (text, spans, ops)
-
-
-#: 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
-_IDATUI_OPND_TAGS = None
-_IDATUI_CTL = None # re: a tag = one of three control chars plus its argument
-#: {tag character: (kind, operand index or None)} -- the two maps above merged,
-#: because the span walker wants both for the same tag and a dict lookup per
-#: tag per line is one of the few things it does often enough to matter.
-_IDATUI_TAGINFO = None
-
-
-def _idatui_opnd_tag_map():
- """{tag character: operand index}. IDA wraps each operand of a disassembly
- line in COLOR_OPND1..8, so the line already says where operand N starts and
- ends -- no need to re-render operands with print_operand to find out (and
- the two agree exactly; checked over thousands of instructions)."""
- import ida_lines
- out = {}
- for i in range(1, 9):
- v = getattr(ida_lines, "COLOR_OPND%d" % i, None)
- if isinstance(v, int):
- out[chr(v)] = i - 1
- elif isinstance(v, str) and v:
- out[v[0]] = i - 1
- return out
-
-
-def _idatui_spans(line):
- """(spans, ops) for a tagged disasm line.
-
- ``spans`` is [[kind, text], ...] with colour tags resolved; ``ops`` is
- [[start, end, n], ...], the extent of each operand in the SAME (collapsed)
- coordinates the row's ``text`` uses -- which is what lets a cursor column
- name the operand it is standing on.
-
- 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, _IDATUI_OPND_TAGS, _IDATUI_CTL, _IDATUI_TAGINFO
- import ida_lines
- if _IDATUI_TAGS is None:
- _IDATUI_TAGS = _idatui_tag_map()
- if _IDATUI_OPND_TAGS is None:
- _IDATUI_OPND_TAGS = _idatui_opnd_tag_map()
- if _IDATUI_CTL is None:
- import re as _re
- # One capturing split gives [text, tag, text, tag, ..., text] in a
- # single C pass. A per-character python loop over the line used to be
- # the most expensive thing the `heads` tool did, and a line is ~54
- # characters but only ~13 tags -- everything between two tags is already
- # exactly one span's worth of text.
- _IDATUI_CTL = _re.compile("([\\x01\\x02\\x03](?s:.))")
- if _IDATUI_TAGINFO is None:
- _IDATUI_TAGINFO = {
- tag: (_IDATUI_TAGS.get(tag, "text"), _IDATUI_OPND_TAGS.get(tag))
- for tag in set(_IDATUI_TAGS) | set(_IDATUI_OPND_TAGS)}
- taginfo = _IDATUI_TAGINFO
- plain_tag = ("text", None)
- 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))
- parts = _IDATUI_CTL.split(line)
- spans, stack = [], [] # stack entries: (kind, operand index|None)
- kind, opnd = "text", None # state the current run of text belongs to
- pend = ""
- skip = 0 # characters of an address payload still due
- i, n = 0, len(parts)
- while i < n:
- txt = parts[i]
- i += 1
- if skip:
- if len(txt) <= skip:
- skip -= len(txt)
- txt = ""
- else:
- txt = txt[skip:]
- skip = 0
- if txt:
- pend += txt
- if i >= n:
- break
- pair = parts[i]
- i += 1
- if skip: # a tag INSIDE an address payload: 2 chars
- skip = skip - 2 if skip > 2 else 0
- continue
- ch = pair[0]
- if ch == esc: # escaped literal: keep the char it guards
- pend += pair[1]
- continue
- tag = pair[1]
- if ch == on and tag == addr_tag:
- # An embedded target address, not display text: 16 hex digits that
- # must not reach the screen. Deliberately NOT a span boundary.
- skip = addr_len
- continue
- if pend:
- spans.append([kind, pend, opnd])
- pend = ""
- if ch == on:
- stack.append((kind, opnd))
- kind, o = taginfo.get(tag, plain_tag)
- if o is not None:
- opnd = o # operands nest: an inner colour keeps the operand
- elif stack:
- kind, opnd = stack.pop()
- else:
- kind, opnd = "text", None
- if pend:
- spans.append([kind, pend, opnd])
- # Collapse IDA's column padding EXACTLY as the plain text does. A run of
- # spaces can straddle two spans, so the leading space of a span is dropped
- # when the previous one ended in space — otherwise the spans and `text`
- # disagree about the line and the row silently loses its highlighting.
- # ``" ".join(txt.split())`` splits on exactly what str.isspace() calls
- # whitespace, which is what the character walk this replaces tested.
- out, prev_space = [], False
- for kind, txt, opnd in spans:
- core = " ".join(txt.split())
- if core == txt:
- # Nothing to collapse and no edge whitespace -- which is the common
- # case ("mov", "rax", ", ") and skips both isspace() probes below.
- prev_space = False
- out.append([kind, txt, opnd])
- continue
- if not core: # the span is nothing but padding
- if not prev_space:
- prev_space = True
- out.append([kind, " ", opnd])
- continue
- acc = core
- if txt[0].isspace() and not prev_space:
- acc = " " + acc
- if txt[-1].isspace():
- acc += " "
- prev_space = acc[-1] == " "
- out.append([kind, acc, opnd])
- 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()
- out = [s for s in out if s[1]]
- # Operand extents, in the coordinates of the collapsed text these spans
- # spell out. Adjacent spans of the same operand merge, so an operand like
- # ``[rbp+var_40]`` (five differently-coloured tokens) comes back as ONE
- # range -- which is the thing a cursor is inside of, and the thing a format
- # change applies to.
- ops, pos, cur, start = [], 0, None, 0
- for _kind, txt, opnd in out:
- if opnd != cur:
- if cur is not None and pos > start:
- ops.append([start, pos, cur])
- cur, start = opnd, pos
- pos += len(txt)
- if cur is not None and pos > start:
- ops.append([start, pos, cur])
- text = "".join(t for _k, t, _o in out)
- trimmed = []
- for lo, hi, k in ops: # don't let a range own trailing space
- while hi > lo and text[hi - 1].isspace():
- hi -= 1
- while lo < hi and text[lo].isspace():
- lo += 1
- if hi > lo:
- trimmed.append([lo, hi, k])
- return [[k, t] for k, t, _o in out], trimmed
-
-
-def _idatui_rows_digest(rows):
- """A value that changes whenever any of ``rows`` would render differently.
-
- Covers everything a client keeps off a row: address, kind, size, the plain
- text, the symbol name and the colour spans (which is what makes it exact
- rather than a heuristic -- two lines can collapse to the same text and still
- be coloured differently).
-
- Uses the interpreter's own ``hash``, deliberately. It never has to mean
- anything outside this process: the client stores what a page hashed to when
- it loaded it and hands the same number back to ask whether the page still
- hashes to that. One worker, one process, one hash seed.
- """
- acc = 0
- # The per-line render is memoised, so one spans list is shared by every row
- # that says the same thing -- about 45% of them within a page. Hash each
- # distinct list once and key that by identity, rather than rebuilding a
- # tuple of tuples per row (which is the exact cost that was measured and
- # removed from the client side for the same reason).
- seen = {}
- for r in rows:
- sp = r.get("spans")
- if sp is None:
- sh = None
- else:
- key = id(sp)
- sh = seen.get(key)
- if sh is None:
- sh = seen[key] = hash(tuple(map(tuple, sp)))
- acc = hash((acc, r.get("ea"), r.get("kind"), r.get("size"),
- r.get("text"), r.get("name"), sh))
- return acc
-
-
-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,
- expect: Annotated[str, "Digest a caller already holds: the rows are omitted when they still hash to it"] = "",
-) -> 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_f(f):
- 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, f):
- if _is_unknown_f(f):
- return _run_end(e)
- nxt = ida_bytes.get_item_end(e)
- return nxt if nxt > e else e + 1
-
- # The function the walk is currently inside, reused while it stays inside.
- # get_func is ~0.5us and the walk asks per head; a head is nearly always in
- # the same function as the one before it. Only ever consulted when ``e``
- # falls in [start_ea, end_ea), so a tail chunk elsewhere cannot be
- # misattributed -- checked against get_func over 437k heads of
- # bash/ls_ttl/echo with zero disagreements.
- fn_cache = [None]
-
- def _func_at(e):
- cur = fn_cache[0]
- if cur is not None and cur.start_ea <= e < cur.end_ea:
- return cur
- cur = idaapi.get_func(e)
- fn_cache[0] = cur
- return cur
-
- def _rows_for(e, f):
- if _is_unknown_f(f):
- return [_idatui_unknown_row(e, _run_end(e) - e)]
- func = _func_at(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, f)
- 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)
- get_flags = ida_bytes.get_flags
- for _ in range(offset):
- if ea >= hi or ea == idaapi.BADADDR:
- break
- ea = _advance(ea, get_flags(ea))
- more = False
- while ea != idaapi.BADADDR and ea < hi:
- if len(rows) >= count:
- more = True
- break
- f = get_flags(ea) # once per head, not once per consumer
- rows.extend(_rows_for(ea, f)) # a struct head expands into member rows
- ea = _advance(ea, f)
- cursor = {"next": hex(ea)} if more else {"done": True}
- dig = _idatui_rows_digest(rows)
- out = {"addr": str(addr), "cursor": cursor, "digest": dig, "count": len(rows)}
- # ``expect`` says "I already hold a page that hashed to this". The rows are
- # built either way -- generate_disasm_line is the floor and there is no way
- # to know a line is unchanged without rendering it -- but pickling several
- # hundred rows with their colour spans, unpickling them and rebuilding Heads
- # is about 40% of what a page costs, and after a rename almost every page
- # comes back identical.
- #
- # It carries the expected value rather than being a yes/no "digest mode" so
- # that a page which HAS changed still costs one round trip: asking first and
- # fetching afterwards made every changed page two.
- if not (expect and str(dig) == expect):
- out["heads"] = rows
- return out
-
-
-@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"}
- import ida_lines
- # Three things this loop must not do, each measured on real functions (the 25
- # largest of bash went 68.3s -> 6.5s; echo's 60 largest 5.4s -> 0.6s, with
- # byte-identical output):
- #
- # * allocate ctree_item_t's per COLUMN. They are SWIG objects and this is
- # the innermost loop; one per call is enough, and head/tail are never
- # read, so don't ask for them at all.
- # * sweep the TAGGED length. ``x`` is a screen column but ``sl.line`` still
- # carries IDA's colour tags, so a 23-column line was swept 124 times.
- # * call dstr() per column. It formats a whole 'EA: description' string --
- # 24us a call, which is 79% of this tool. Comparing against the PREVIOUS
- # column's item id is not enough: items interleave, so `foo(a, b)` flips
- # call -> arg -> call -> arg and every flip re-formats an item already
- # seen (106 594 calls for 15 417 lines of bash). Memoise id -> ea for the
- # whole function instead: obj_id is unique within a cfunc, so the same id
- # always yields the same string, and the result is deduped by ``seen``
- # anyway. Items with no ctree node (it is None) have no id to key on and
- # still pay per occurrence.
- item = ida_hexrays.ctree_item_t()
- tag_remove = ida_lines.tag_remove
- get_line_item = cfunc.get_line_item
- ea_of_id = {}
- lines = []
- for sl in cfunc.get_pseudocode():
- line = sl.line
- eas, seen = [], set()
- prev_id = None
- for x in range(len(tag_remove(line)) + 1):
- if not get_line_item(line, x, False, None, item, None):
- continue
- it = item.it
- if it is not None:
- oid = it.obj_id
- if oid == prev_id:
- continue
- prev_id = oid
- if oid in ea_of_id:
- e = ea_of_id[oid]
- if e is not None and e not in seen:
- seen.add(e)
- eas.append(hex(e))
- continue
- else:
- oid = None
- prev_id = None
- # Match the /*ea*/ marker's source (decompile_function_safe): the
- # item's dstr() is 'EA: description'; get_ea() reports a different ea.
- e = None
- dstr = item.dstr()
- if dstr:
- parts = dstr.split(": ", 1)
- if len(parts) == 2:
- try:
- e = int(parts[0], 16)
- except ValueError:
- e = None
- if oid is not None:
- ea_of_id[oid] = e
- if e is not None and 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)}
-
-
-# --------------------------------------------------------------------------- #
-# operand display formats (IDA's 'o' family: hex / dec / char / offset / ...)
-# --------------------------------------------------------------------------- #
-#: The stops a cycle walks, in order, before filtering to the ones that make
-#: sense for the operand in hand. Octal is deliberately NOT one of them -- every
-#: extra stop is another keypress and nobody reads octal -- but it is still
-#: reachable by name. "default" hands the operand back to IDA's own choice,
-#: which for data is how you get an auto-detected offset/string back.
-_IDATUI_FMT_CYCLE = ("hex", "dec", "bin", "char", "offset", "default")
-
-#: Formats we can re-apply from a name alone. enum/stroff/custom carry an id
-#: (which enum, which struct) that a nibble doesn't record, so they are never
-#: cycled INTO -- and cycling out of one is called out in ``warn``.
-_IDATUI_FMT_SETTABLE = ("hex", "dec", "oct", "bin", "char", "offset", "seg",
- "float", "stack", "default")
-
-
-def _idatui_fmt_nibbles():
- """{format name: IDA operand-type nibble}. Built on call, not at import:
- this module is injected into a file that is imported before a database is
- open."""
- import ida_bytes
- return {
- "default": ida_bytes.FF_N_VOID, "hex": ida_bytes.FF_N_NUMH,
- "dec": ida_bytes.FF_N_NUMD, "char": ida_bytes.FF_N_CHAR,
- "seg": ida_bytes.FF_N_SEG, "offset": ida_bytes.FF_N_OFF,
- "bin": ida_bytes.FF_N_NUMB, "oct": ida_bytes.FF_N_NUMO,
- "enum": ida_bytes.FF_N_ENUM, "forced": ida_bytes.FF_N_FOP,
- "stroff": ida_bytes.FF_N_STRO, "stack": ida_bytes.FF_N_STK,
- "float": ida_bytes.FF_N_FLT, "custom": ida_bytes.FF_N_CUST,
- }
-
-
-def _idatui_fmt_name(nib):
- for name, v in _idatui_fmt_nibbles().items():
- if v == nib:
- return name
- return "default"
-
-
-def _idatui_op_fmt(ea, n):
- """The format operand ``n`` of the item at ``ea`` is currently displayed in.
-
- Reads the nibble IDA keeps per operand rather than guessing from the text --
- ``1`` renders identically in hex and decimal, so the rendered line cannot
- answer this."""
- import ida_bytes
- F = ida_bytes.get_flags(ea)
- nib = (F >> ida_bytes.get_operand_type_shift(int(n))) & 0xF
- return _idatui_fmt_name(nib)
-
-
-def _idatui_op_value(ea, n):
- """(value, byte width) of operand ``n``, or (None, 0) if it hasn't got one.
-
- The value is what decides which formats are OFFERED: a character constant
- for 0x38A9 or an offset to an unmapped address are stops worth skipping."""
- import ida_bytes
- import ida_ua
-
- F = ida_bytes.get_flags(ea)
- if ida_bytes.is_code(F):
- insn = ida_ua.insn_t()
- if ida_ua.decode_insn(insn, ea) <= 0:
- return None, 0
- try:
- op = insn.ops[int(n)]
- except Exception:
- return None, 0
- if op.type == ida_ua.o_void:
- return None, 0
- v = op.value if op.type == ida_ua.o_imm else op.addr
- try:
- size = int(ida_ua.get_dtype_size(op.dtype))
- except Exception:
- size = 0
- return int(v), size
- size = int(ida_bytes.get_item_size(ea))
- read = {1: ida_bytes.get_byte, 2: ida_bytes.get_word,
- 4: ida_bytes.get_dword, 8: ida_bytes.get_qword}.get(size)
- if read is None:
- return None, size
- try:
- return int(read(ea)), size
- except Exception:
- return None, size
-
-
-def _idatui_printable(v):
- """Whether ``v`` would actually render as a character constant. IDA accepts
- op_chr on anything and then prints the number anyway, so a cycle that offers
- 'char' for 0x18 has a stop where nothing visibly happens."""
- if v is None or v < 0 or v > 0xFFFFFFFF:
- return False
- bs, x = [], int(v)
- while True:
- bs.append(x & 0xFF)
- x >>= 8
- if not x:
- break
- return all(0x20 <= b <= 0x7E or b in (9, 10, 13) for b in bs)
-
-
-def _idatui_offset_worth(v):
- """Whether 'offset' is worth OFFERING as a cycle stop for value ``v``.
-
- Making an offset is not free: IDA invents a dummy name at the target
- (``off_18``) and that name STAYS once you cycle past it. So the ring only
- stops there when the target is already something you could name -- a symbol,
- a function, or an item something else references. In a PIE at base 0 half
- the small constants in a function are 'mapped' (they land in the ELF
- header); ``sub rsp, 18h`` is not a reference and must not offer to become
- one on the way past.
-
- An explicit request still converts anything mapped: that's a decision, not a
- keypress that happened to land here. After it, the target HAS a name, so the
- ring includes the stop from then on."""
- import ida_bytes
- import ida_name
-
- return bool(v and ida_bytes.is_mapped(v) and ida_name.get_ea_name(v))
-
-
-def _idatui_op_candidates(ea):
- """Operand indices at ``ea`` whose display format is worth changing.
-
- Immediates and displacements -- the literals. Deliberately NOT:
-
- * branch targets (o_near/o_far), or every jump on the listing would offer to
- become a bare number, on a view you navigate by label;
- * memory references (o_mem), e.g. x86-64's RIP-relative ``lea rdi, name``.
- IDA prints those from the reference, not from the operand's number format,
- so setting one is accepted and changes nothing on screen -- a keypress
- that appears to do nothing is worse than one that says it can't.
-
- An explicit ``n`` still reaches them; this is what a bare cursor picks."""
- import ida_bytes
- import ida_ua
-
- F = ida_bytes.get_flags(ea)
- if ida_bytes.is_data(F):
- return [0] # a data item's value is operand 0
- if not ida_bytes.is_code(F):
- return [] # undefined bytes: IDA refuses a format outright
- insn = ida_ua.insn_t()
- if ida_ua.decode_insn(insn, ea) <= 0:
- return []
- want = (ida_ua.o_imm, ida_ua.o_displ)
- out = []
- for i in range(len(insn.ops)):
- op = insn.ops[i]
- if op.type == ida_ua.o_void:
- break
- if op.type in want:
- out.append(i)
- return out
-
-
-def _idatui_op_spans(ea, text):
- """[(start, end, n)] -- where each operand sits inside ``text`` (the
- whitespace-collapsed line the TUI shows), so a cursor column can name the
- operand it is standing on.
-
- Read out of IDA's own COLOR_OPND markers on the line, which is both free
- (the line is generated anyway) and exact. print_operand is kept as a
- fallback for a processor module that emits no operand markers -- it agrees
- with the tags where both exist, but it re-renders every operand to say so.
- """
- import ida_lines
- import ida_ua
-
- line = ida_lines.generate_disasm_line(ea, 0)
- if line:
- _spans, ops = _idatui_spans(line)
- if ops:
- return [tuple(o) for o in ops]
-
- out, pos = [], 0
- for n in range(8):
- try:
- raw = ida_ua.print_operand(ea, n)
- except Exception:
- raw = None
- if not raw:
- continue
- op = " ".join(ida_lines.tag_remove(raw).split())
- if not op:
- continue
- i = text.find(op, pos)
- if i < 0: # duplicated operand text (mov eax, eax)
- i = text.find(op)
- if i < 0:
- continue
- out.append((i, i + len(op), n))
- pos = i + len(op)
- return out
-
-
-def _idatui_line_text(ea):
- import ida_lines
- line = ida_lines.generate_disasm_line(ea, 0)
- return " ".join(ida_lines.tag_remove(line).split()) if line else ""
-
-
-def _idatui_op_text(ea, text, n):
- """How operand ``n`` reads on the line, for a message that names it."""
- for lo, hi, i in _idatui_op_spans(ea, text):
- if i == int(n):
- return text[lo:hi].strip()
- return ""
-
-
-def _idatui_apply_fmt(ea, n, fmt):
- """Set operand ``n``'s display format. Returns (ok, error)."""
- import ida_bytes
- import ida_offset
- import idaapi
-
- n = int(n)
- if fmt == "default":
- return bool(ida_bytes.clr_op_type(ea, n)), ""
- if fmt == "offset":
- base = ida_offset.calc_offset_base(ea, n)
- if base in (idaapi.BADADDR, None) or base < 0:
- base = 0
- return bool(ida_offset.op_plain_offset(ea, n, base)), ""
- fn = {"hex": ida_bytes.op_hex, "dec": ida_bytes.op_dec,
- "oct": ida_bytes.op_oct, "bin": ida_bytes.op_bin,
- "char": ida_bytes.op_chr, "seg": ida_bytes.op_seg,
- "float": ida_bytes.op_flt, "stack": ida_bytes.op_stkvar}.get(fmt)
- if fn is None:
- return False, (f"can't set {fmt!r} from a name alone"
- if fmt in _idatui_fmt_nibbles() else
- f"unknown format {fmt!r}")
- return bool(fn(ea, n)), ""
-
-
-@tool
-@idasync
-def op_format(
- addr: Annotated[str, "Address of the instruction or data item"],
- mode: Annotated[str, "cycle | back | show | hex | dec | oct | bin | char | offset | stack | default"] = "cycle",
- col: Annotated[int, "Cursor column inside the rendered line (-1: first literal)"] = -1,
- n: Annotated[int, "Operand index; -1 derives it from ``col``"] = -1,
-) -> dict:
- """Change how a literal is DISPLAYED (IDA's 'o' family): hex, decimal,
- binary, character, or an offset to the address it names.
-
- The value in the bytes never changes -- only the representation IDA renders
- and remembers. ``cycle``/``back`` step the stops that make sense for THIS
- operand: 'char' is skipped unless the value prints as one, 'offset' unless
- the target is already named, so no press is ever a no-op you have to press
- again. ``show`` reports without changing anything.
-
- A format the ring can't hold (a stack variable, an enum) is reported in
- ``warn`` on the way out, with what to do about it -- ``mode`` takes any of
- the names above outright, which is also how you put one back.
-
- Which operand: ``n`` if given, else the one under ``col`` (a column in the
- whitespace-collapsed line, as ``heads`` renders it), else the first literal
- on the line."""
- import ida_bytes
-
- try:
- ea = ida_bytes.get_item_head(parse_address(addr))
- except Exception as e:
- return {"addr": str(addr), "error": str(e)}
-
- before = _idatui_line_text(ea)
- cands = _idatui_op_candidates(ea)
- n = int(n)
- if n < 0:
- n = -1
- if int(col) >= 0:
- for lo, hi, i in _idatui_op_spans(ea, before):
- if not (lo <= int(col) < hi):
- continue
- if i in cands:
- n = i
- break
- # The cursor IS on an operand, just not one with a format. The
- # client highlights what the cursor is on, so quietly moving to
- # a different operand would make that highlight a lie -- say
- # which one can be changed instead.
- where = before[lo:hi].strip()
- alt = (f"; the literal on this line is operand {cands[0]} "
- f"({_idatui_op_text(ea, before, cands[0])})"
- if cands else "")
- return {"addr": hex(ea), "n": i, "text": before,
- "error": f"operand {i} ({where}) has no format to "
- f"change{alt}"}
- if n < 0:
- if not cands:
- F = ida_bytes.get_flags(ea)
- why = ("no literal on this line to reformat"
- if ida_bytes.is_code(F) or ida_bytes.is_data(F) else
- "undefined bytes have no format to change -- define "
- "them first ('d' makes data, 'c' makes code)")
- return {"addr": hex(ea), "text": before, "error": why}
- n = cands[0]
-
- cur = _idatui_op_fmt(ea, n)
- value, width = _idatui_op_value(ea, n)
- mapped = value is not None and value != 0 and ida_bytes.is_mapped(value)
- # The ring is a property of the OPERAND, not of what you last pressed: every
- # stop is one that changes what you see for this value, and it is the same
- # ring at every step, so a lap always comes home.
- choices = [f for f in _IDATUI_FMT_CYCLE
- if (f != "char" or _idatui_printable(value))
- and (f != "offset" or _idatui_offset_worth(value))]
- # A stack variable is deliberately NOT a stop: ``[rbp+var_40]`` is a frame
- # member, not a way of writing a number, and IDA's own "is this a stack
- # variable" test isn't exposed to Python here (calc_stkvar_struc_offset
- # happily answers for ``[r14+8]`` too, which would put a bogus stop in the
- # ring). Leaving one is reported instead, with the command that undoes it.
- lossy = cur not in choices and cur != "default"
-
- mode = str(mode or "cycle").lower()
- if mode == "show":
- return {"addr": hex(ea), "n": n, "format": cur, "prev": cur,
- "choices": choices, "text": before, "before": before,
- "value": None if value is None else hex(value),
- "width": width, "applied": False}
- if mode in ("cycle", "back"):
- step = 1 if mode == "cycle" else -1
- if cur in choices:
- want = choices[(choices.index(cur) + step) % len(choices)]
- else:
- # Standing on a format the ring can't hold (an enum names a type a
- # nibble doesn't record): enter the ring at its end, don't skip a
- # stop working out where we "would have" been.
- want = choices[0] if step > 0 else choices[-1]
- else:
- want = mode
- if want not in _idatui_fmt_nibbles():
- return {"addr": hex(ea), "n": n, "text": before,
- "error": f"unknown format {mode!r}; one of "
- + ", ".join(_IDATUI_FMT_SETTABLE)}
- if want == "offset" and not mapped:
- return {"addr": hex(ea), "n": n, "text": before, "format": cur,
- "error": (f"{'0x%x' % value if value is not None else 'this operand'}"
- " isn't a mapped address -- an offset to it would"
- " invent a name for nothing")}
-
- ok, err = _idatui_apply_fmt(ea, n, want)
- if err:
- return {"addr": hex(ea), "n": n, "text": before, "format": cur,
- "error": err}
- got = _idatui_op_fmt(ea, n)
- out = {"addr": hex(ea), "n": n, "prev": cur, "format": got,
- "requested": want, "applied": bool(ok), "choices": choices,
- "before": before, "text": _idatui_line_text(ea),
- "value": None if value is None else hex(value), "width": width}
- if not ok:
- out["error"] = f"IDA refused {want} on operand {n}"
- elif lossy:
- out["warn"] = (
- f"operand {n} was {cur} and the ring has no stop there -- "
- + (f"'{cur}' sets it again" if cur in _IDATUI_FMT_SETTABLE else
- f"{cur} names a type this can't put back, reassign it by hand"))
- return out
-
-
-# --------------------------------------------------------------------------- #
-# the same thing in the decompiler (Hex-Rays keeps its own number formats)
-# --------------------------------------------------------------------------- #
-#: Hex-Rays prints C, so two of the listing's stops are missing here: binary
-#: (C has no binary literal -- the format takes and then renders decimal, which
-#: would be a lie on screen) and offset (it makes the function fail to
-#: decompile outright).
-_IDATUI_PC_FMT_CYCLE = ("hex", "dec", "oct", "char", "default")
-
-
-def _idatui_compact(line):
- """The ida-pro-mcp whitespace collapse the pseudocode is served through, so
- a column in what the client SHOWS can be mapped back to Hex-Rays' line."""
- try:
- from ida_pro_mcp.ida_mcp.utils import compact_whitespace
- return compact_whitespace(line)
- except Exception:
- import re as _re
- stripped = line.lstrip(" \t")
- lead = line[: len(line) - len(stripped)]
- return lead + _re.sub(r"[ \t]{2,}", " ", stripped)
-
-
-def _idatui_compact_col(plain, compact, col):
- """The inverse of ``_idatui_uncompact_col``: a column in Hex-Rays' own line,
- expressed in the collapsed line the client shows."""
- j = 0
- for i in range(min(int(col), len(plain))):
- if j < len(compact) and plain[i] == compact[j]:
- j += 1
- return j
-
-
-def _idatui_uncompact_col(plain, compact, col):
- """Map a column in the collapsed line back to the same character in the
- original. The transform only ever DELETES spaces, so walking both in step
- and skipping what vanished is exact."""
- i = 0
- for j in range(min(int(col), len(compact))):
- c = compact[j]
- while i < len(plain) and plain[i] != c:
- i += 1
- i += 1
- return min(i, max(len(plain) - 1, 0))
-
-
-#: Characters that can be part of a C number literal as Hex-Rays prints one
-#: (digits, hex letters, the 0x prefix, u/L suffixes).
-_IDATUI_LIT_CHARS = frozenset("0123456789abcdefABCDEFxXuUlL")
-
-
-def _idatui_lit_extent(plain, x):
- """The [start, end) of the literal token containing column ``x``.
-
- Hex-Rays says WHICH item a column belongs to, but not how wide the printed
- literal is -- and it attributes neighbouring punctuation to the same item,
- so ``if ( a1 > 1 )`` reports the closing paren as part of the number. The
- identity comes from the ctree; the extent is the run of literal characters
- around the column, which cannot reach a ``)`` or a space."""
- if x >= len(plain):
- return None
- if plain[x] == "'": # a character constant: '-'
- end = plain.find("'", x + 1)
- return (x, end + 1) if end > x else None
- lo = plain.rfind("'", 0, x)
- if lo >= 0 and plain.find("'", x) > x and "'" in plain[lo:x] and \
- plain[lo:x].count("'") == 1 and " " not in plain[lo:x]:
- return (lo, plain.find("'", x) + 1) # inside 'c'
- if plain[x] not in _IDATUI_LIT_CHARS:
- return None
- lo = x
- while lo > 0 and plain[lo - 1] in _IDATUI_LIT_CHARS:
- lo -= 1
- hi = x
- while hi < len(plain) and plain[hi] in _IDATUI_LIT_CHARS:
- hi += 1
- if lo > 0 and plain[lo - 1] == "-": # a unary minus is part of it
- lo -= 1
- return (lo, hi)
-
-
-def _idatui_pc_nums(cf, sl):
- """Every number literal on one pseudocode line, as
- [{x0, x1, ea, opnum, value, nbytes, fmt}].
-
- Asks Hex-Rays what each column belongs to rather than pattern-matching the
- text: a regex over ``v6 = a1 - 1;`` has to guess which of those characters
- are a literal, and ``v11`` looks like one."""
- import ida_bytes
- import ida_hexrays
- import ida_lines
- import idaapi
-
- plain = ida_lines.tag_remove(sl.line)
- out = []
- x = 0
- while x < len(plain):
- ch = plain[x]
- if ch not in _IDATUI_LIT_CHARS and ch != "'":
- x += 1
- continue
- head, item, tail = (ida_hexrays.ctree_item_t() for _ in range(3))
- if not cf.get_line_item(sl.line, x, True, head, item, tail):
- x += 1
- continue
- if item.citype != ida_hexrays.VDI_EXPR:
- x += 1
- continue
- e = item.e
- if e.op != ida_hexrays.cot_num:
- x += 1
- continue
- extent = _idatui_lit_extent(plain, x)
- if extent is None:
- x += 1
- continue
- nf = e.n.nf
- opnum = ord(nf.opnum) if isinstance(nf.opnum, str) else int(nf.opnum)
- nbytes = (ord(nf.org_nbytes) if isinstance(nf.org_nbytes, str)
- else int(nf.org_nbytes))
- ea = int(e.ea)
- if ea == idaapi.BADADDR:
- x = extent[1]
- continue # synthesised: nothing to key on
- nib = (nf.flags >> ida_bytes.get_operand_type_shift(opnum)) & 0xF
- # Whether this format is the USER's or Hex-Rays' own guess. The nibble
- # can't say: an untouched number reads back as whatever it happens to
- # be printed as, and cycling from there would skip that stop forever
- # (default already looks like it) and never come back to it.
- loc = ida_hexrays.operand_locator_t(ea, opnum)
- user = (ida_hexrays.user_numforms_find(cf.numforms, loc)
- != ida_hexrays.user_numforms_end(cf.numforms))
- out.append({"x0": extent[0], "x1": extent[1], "ea": ea,
- "opnum": opnum, "value": int(e.n._value),
- "nbytes": nbytes, "user": user,
- "fmt": _idatui_fmt_name(nib) if user else "default",
- "shown": _idatui_fmt_name(nib)})
- x = extent[1] # past this literal, not into it
- return out
-
-
-@tool
-@idasync
-def pc_nums(
- addr: Annotated[str, "Function address (or any address inside it)"],
-) -> dict:
- """Every number literal in a function's pseudocode, as
- [{line, x0, x1, ea, opnum, value, fmt, user}].
-
- One call per decompilation, so a client can show WHICH literal the cursor is
- on (and reformat exactly that one) without a round trip per cursor move.
- Columns are in the same collapsed coordinates the decompile tool serves its
- text in, i.e. what the client actually displays."""
- import ida_hexrays
- import ida_lines
- import idaapi
-
- if not ida_hexrays.init_hexrays_plugin():
- return {"addr": str(addr), "error": "no decompiler", "nums": []}
- try:
- f = idaapi.get_func(parse_address(addr))
- except Exception as e:
- return {"addr": str(addr), "error": str(e), "nums": []}
- if f is None:
- return {"addr": str(addr), "error": "no function here", "nums": []}
- try:
- cf = ida_hexrays.decompile(f.start_ea)
- except Exception as e:
- return {"addr": hex(f.start_ea), "error": f"decompile failed: {e}",
- "nums": []}
- if cf is None:
- return {"addr": hex(f.start_ea), "error": "decompilation failed",
- "nums": []}
- sv = cf.get_pseudocode()
- out = []
- for i in range(len(sv)):
- plain = ida_lines.tag_remove(sv[i].line)
- compact = _idatui_compact(plain)
- for rec in _idatui_pc_nums(cf, sv[i]):
- out.append({
- "line": i,
- "x0": _idatui_compact_col(plain, compact, rec["x0"]),
- "x1": _idatui_compact_col(plain, compact, rec["x1"]),
- "ea": hex(rec["ea"]), "opnum": rec["opnum"],
- "value": hex(rec["value"]), "fmt": rec["fmt"],
- "shown": rec["shown"], "user": bool(rec["user"]),
- })
- return {"addr": hex(f.start_ea), "nums": out, "lines": len(sv)}
-
-
-@tool
-@idasync
-def pc_num_format(
- addr: Annotated[str, "Function address (or any address inside it)"],
- mode: Annotated[str, "cycle | back | show | hex | dec | oct | char | default"] = "cycle",
- line: Annotated[int, "0-based pseudocode line index"] = -1,
- col: Annotated[int, "Cursor column in the DISPLAYED line (-1: first literal)"] = -1,
- ea: Annotated[str, "Address of the number instead of line/col"] = "",
- opnum: Annotated[int, "Operand number, with ``ea``"] = -1,
-) -> dict:
- """Change how a number is displayed in the DECOMPILATION (Hex-Rays keeps its
- own number formats, per (address, operand), independent of the listing).
-
- Same stops as ``op_format`` minus the two C can't express: binary (no such
- literal -- IDA takes the format and prints decimal anyway) and offset (it
- makes the function stop decompiling). Returns the re-rendered line, and
- marks the function dirty so the next decompile is the new text."""
- import ida_hexrays
- import ida_lines
- import idaapi
-
- if not ida_hexrays.init_hexrays_plugin():
- return {"addr": str(addr), "error": "no decompiler"}
- try:
- f = idaapi.get_func(parse_address(addr))
- except Exception as e:
- return {"addr": str(addr), "error": str(e)}
- if f is None:
- return {"addr": str(addr), "error": "no function here"}
- 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"}
-
- sv = cf.get_pseudocode()
- line = int(line)
- target = None
- if ea:
- try:
- want_ea = parse_address(ea)
- except Exception as e:
- return {"addr": hex(f.start_ea), "error": str(e)}
- for i in range(len(sv)):
- for rec in _idatui_pc_nums(cf, sv[i]):
- if rec["ea"] == want_ea and (int(opnum) < 0
- or rec["opnum"] == int(opnum)):
- target, line = rec, i
- break
- if target:
- break
- elif 0 <= line < len(sv):
- nums = _idatui_pc_nums(cf, sv[line])
- if nums:
- if int(col) >= 0:
- plain = ida_lines.tag_remove(sv[line].line)
- x = _idatui_uncompact_col(plain, _idatui_compact(plain), int(col))
- target = next((r for r in nums if r["x0"] <= x < r["x1"]), None)
- target = target or nums[0]
- else:
- return {"addr": hex(f.start_ea),
- "error": f"line {line} is outside the {len(sv)}-line decompilation"}
- if target is None:
- return {"addr": hex(f.start_ea), "line": line,
- "text": (ida_lines.tag_remove(sv[line].line).strip()
- if 0 <= line < len(sv) else ""),
- "error": "no number literal on this line"}
-
- cur, value = target["fmt"], target["value"]
- choices = [c for c in _IDATUI_PC_FMT_CYCLE
- if c != "char" or _idatui_printable(value)]
- # Same rule as the listing: one ring per literal, every step. A format the
- # ring can't hold (an enum set in the GUI) is reported on the way out
- # instead of being kept for one lap and then lost.
- lossy = cur not in choices and cur != "default"
- out = {"addr": hex(f.start_ea), "ea": hex(target["ea"]),
- "opnum": target["opnum"], "line": line, "prev": cur,
- "format": cur, "shown": target["shown"], "choices": choices,
- "value": hex(value),
- "before": ida_lines.tag_remove(sv[line].line).strip()}
-
- mode = str(mode or "cycle").lower()
- if mode == "show":
- out["text"] = out["before"]
- out["applied"] = False
- return out
- if mode in ("cycle", "back"):
- step = 1 if mode == "cycle" else -1
- if cur in choices:
- want = choices[(choices.index(cur) + step) % len(choices)]
- else:
- want = choices[0] if step > 0 else choices[-1]
- else:
- want = mode
- if want in ("bin", "offset", "stack", "seg", "float"):
- out["error"] = (f"Hex-Rays has no {want} format for a number "
- f"-- set it on the listing instead")
- out["text"] = out["before"]
- return out
- if want not in ("hex", "dec", "oct", "char", "default"):
- out["error"] = (f"unknown format {mode!r}; one of hex, dec, oct, "
- f"char, default")
- out["text"] = out["before"]
- return out
-
- loc = ida_hexrays.operand_locator_t(target["ea"], target["opnum"])
- it = ida_hexrays.user_numforms_find(cf.numforms, loc)
- if it != ida_hexrays.user_numforms_end(cf.numforms):
- # std::map::insert is a no-op on an existing key, so a format already
- # set here would silently win over the new one.
- ida_hexrays.user_numforms_erase(cf.numforms, it)
- if want != "default":
- import ida_bytes
- nf = ida_hexrays.number_format_t(target["opnum"])
- nf.flags = ida_bytes.get_operand_flag(_idatui_fmt_nibbles()[want],
- target["opnum"])
- try:
- nf.org_nbytes = target["nbytes"]
- except Exception:
- pass
- ida_hexrays.user_numforms_insert(cf.numforms, loc, nf)
- cf.save_user_numforms()
- try:
- ida_hexrays.mark_cfunc_dirty(f.start_ea)
- except Exception:
- pass
-
- out["format"] = want
- out["applied"] = True
- if lossy:
- out["warn"] = (f"this number was {cur}, which names a type a radix "
- f"can't put back -- reassign it in IDA")
- try:
- cf2 = ida_hexrays.decompile(f.start_ea,
- flags=ida_hexrays.DECOMP_NO_CACHE)
- sv2 = cf2.get_pseudocode() if cf2 is not None else None
- out["text"] = (ida_lines.tag_remove(sv2[line].line).strip()
- if sv2 is not None and line < len(sv2) else out["before"])
- except Exception as e:
- out["text"] = out["before"]
- out["warn"] = f"re-render failed: {e}"
- return out
-
-
-@tool
-@idasync
-def flowchart(
- addr: Annotated[str, "Address or name inside the function to chart"],
-) -> dict:
- """Basic-block control-flow graph of the function containing ``addr``.
-
- Returns the blocks and the edges between them -- NOT their text: the block
- body is just an address range, which the client already knows how to render
- with ``heads``. Keeping text out means the graph view reuses the exact same
- listing rows (colours, operand marks and all) instead of growing a second
- disassembly renderer.
-
- Edge ``kind`` is what the graph view colours by:
- * ``fall`` -- control falls through to the next address (IDA draws red)
- * ``jump`` -- a taken conditional branch (green)
- * ``uncond`` -- the block's only successor (blue)
- * ``switch`` -- one of an n-way dispatch
- """
- import ida_funcs
- import ida_gdl
-
- try:
- ea = parse_address(addr)
- except Exception as e:
- return {"addr": str(addr), "error": str(e), "blocks": []}
- fn = ida_funcs.get_func(ea)
- if fn is None:
- return {"addr": str(addr), "error": "no function at that address",
- "blocks": []}
-
- fc = ida_gdl.FlowChart(fn, flags=ida_gdl.FC_PREDS)
- index = {}
- order = []
- for bb in fc:
- index[bb.start_ea] = len(order)
- order.append(bb)
- blocks = []
- for bb in order:
- sl = [s for s in bb.succs() if s.start_ea in index]
- succs = []
- for s in sl:
- if len(sl) > 2:
- kind = "switch"
- elif s.start_ea == bb.end_ea:
- kind = "fall"
- else:
- kind = "jump"
- succs.append([index[s.start_ea], kind])
- blocks.append({
- "id": index[bb.start_ea],
- "start": hex(bb.start_ea),
- "end": hex(bb.end_ea),
- "succs": succs,
- })
- return {
- "addr": hex(ea),
- "func": {"addr": hex(fn.start_ea), "end": hex(fn.end_ea),
- "name": ida_funcs.get_func_name(fn.start_ea)},
- "entry": index.get(fn.start_ea, 0),
- "blocks": blocks,
- }
-'''
-
-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/_fixtures.py b/tests/_fixtures.py
index 766ecd5..c7a45d3 100644
--- a/tests/_fixtures.py
+++ b/tests/_fixtures.py
@@ -50,7 +50,13 @@ async def build_pristine(binary: str, cache: str, app_factory) -> None:
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)
diff --git a/tests/run.py b/tests/run.py
index 4920e54..b38a707 100755
--- a/tests/run.py
+++ b/tests/run.py
@@ -51,8 +51,8 @@ import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TESTS = os.path.join(ROOT, "tests")
-#: The IDA-capable interpreter. The pilot tests need textual AND idapro in one
-#: python; the worker python is auto-detected separately by WorkerClient.
+#: The IDA-capable interpreter. The pilot tests need textual AND the Code Mode
+#: library in one python; the database process is Code Mode's to place.
DEFAULT_PY = os.path.expanduser("~/ida-venv/bin/python")
#: Both shapes the suites print: "N passed, M failed" and "N checks, M failed".
diff --git a/tests/test_codemode_client.py b/tests/test_codemode_client.py
new file mode 100644
index 0000000..0954918
--- /dev/null
+++ b/tests/test_codemode_client.py
@@ -0,0 +1,140 @@
+"""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
+
+#: Pure: fakes the DatabaseHandle, never touches IDA or the Code Mode library.
+NEEDS_IDA = False
+
+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_launch.py b/tests/test_launch.py
index c6ed8f1..d57ee5f 100644
--- a/tests/test_launch.py
+++ b/tests/test_launch.py
@@ -1,12 +1,14 @@
#!/usr/bin/env python3
-"""The launcher's file handling -- the part that deletes things.
+"""The launcher's option handling, and the file handling it must NOT do.
-`_sweep_locks` runs automatically when a database fails to open, and it removes
-files next to the user's binary. That is exactly the kind of code that must not
-be tested by trying it, so it is tested here: which files it takes, which it
-must never take, and what it reports.
+The old `_sweep_locks` deleted `.id0/.id1/.id2/.nam/.til` next to the user's
+binary when a database failed to open. That was only defensible while the TUI
+exclusively owned a private worker; under Code Mode a GUI or another client may
+own the database, so the sweep is gone. Its tests are replaced by one that keeps
+it gone -- deleting a shared database's working files is unrecoverable, and this
+is the cheapest guard against someone reintroducing the "helpful" cleanup.
-Pure: no IDA, no worker, no Textual.
+Pure: no IDA, no Code Mode library, no Textual.
"""
from __future__ import annotations
@@ -20,7 +22,8 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
NEEDS_IDA = False
-from idatui.launch import _LOCK_SUFFIXES, _load_args, _sweep_locks # noqa: E402
+import idatui.launch as launch # noqa: E402
+from idatui.launch import _load_args # noqa: E402
PASS = FAIL = 0
@@ -41,100 +44,18 @@ def touch(*paths):
fh.write(b"x")
-def t_sweeps_the_scratch_files():
- """IDA unpacks a .i64 into .id0/.id1/.id2/.nam/.til while it is open; a
- hard-killed worker leaves them and the .i64 then refuses to reopen."""
- with tempfile.TemporaryDirectory() as d:
- binary = os.path.join(d, "echo")
- touch(binary, *[binary + s for s in _LOCK_SUFFIXES])
- n = _sweep_locks(binary)
- check("every unpacked scratch file is swept", n == len(_LOCK_SUFFIXES),
- f"swept {n} of {len(_LOCK_SUFFIXES)}")
- check("none of them survive",
- not any(os.path.exists(binary + s) for s in _LOCK_SUFFIXES))
- check("the binary itself is untouched", os.path.exists(binary))
+def t_no_lock_sweeping():
+ """The launcher must not delete database working files any more.
-
-def t_sweeps_by_stem_too():
- """IDA keys the scratch on the full name or the stem depending on how the
- database was created, so both are swept."""
- with tempfile.TemporaryDirectory() as d:
- binary = os.path.join(d, "prog.elf")
- stem = os.path.join(d, "prog")
- touch(binary, stem + ".id0", stem + ".nam", binary + ".id1")
- n = _sweep_locks(binary)
- check("scratch named after the stem is swept too", n == 3, f"n={n}")
- check("stem-keyed files are gone",
- not os.path.exists(stem + ".id0")
- and not os.path.exists(stem + ".nam"))
- check("full-name-keyed files are gone", not os.path.exists(binary + ".id1"))
- check("the binary itself is untouched", os.path.exists(binary))
-
-
-def t_never_the_database():
- """The .i64 IS the database. Nothing is saved unless idb_save was called, so
- deleting it throws away every rename and comment in the session."""
- with tempfile.TemporaryDirectory() as d:
- binary = os.path.join(d, "echo")
- db = binary + ".i64"
- stem_db = os.path.join(d, "echo.i64")
- touch(binary, db, binary + ".id0")
- _sweep_locks(binary)
- check("the .i64 is never swept", os.path.exists(db))
- check("nor the stem-keyed .i64", os.path.exists(stem_db))
- check(".i64 is not in the suffix list", ".i64" not in _LOCK_SUFFIXES,
- str(_LOCK_SUFFIXES))
-
-
-def t_never_the_input_itself():
- """`.til` is both an unpacked-DB suffix and the extension of an IDA type
- library, so `ida-tui mylib.til` used to sweep its own argument out of
- existence -- irreversibly, on a path that runs automatically when an open
- fails. Same for anything named *.id0/*.id1/*.id2/*.nam.
+ Code Mode's registry locks, health probes and IDA itself arbitrate database
+ ownership now. A sweep here would delete files out from under a live GUI.
"""
- for suf in _LOCK_SUFFIXES:
- with tempfile.TemporaryDirectory() as d:
- binary = os.path.join(d, "mylib" + suf)
- touch(binary)
- _sweep_locks(binary)
- check(f"a binary named *{suf} is not deleted by its own sweep",
- os.path.exists(binary), f"{binary} was removed")
-
-
-def t_relative_path_is_still_the_input():
- """The guard compares absolute paths -- a relative argument names the same
- file and must be protected the same way."""
- with tempfile.TemporaryDirectory() as d:
- cwd = os.getcwd()
- try:
- os.chdir(d)
- touch("mylib.til")
- _sweep_locks("mylib.til")
- check("a relative path to the input is protected too",
- os.path.exists("mylib.til"))
- finally:
- os.chdir(cwd)
-
-
-def t_missing_files_are_fine():
- with tempfile.TemporaryDirectory() as d:
- binary = os.path.join(d, "nothing-here")
- touch(binary)
- n = _sweep_locks(binary)
- check("sweeping with nothing to sweep reports 0", n == 0, f"n={n}")
- check("and does not raise", True)
-
-
-def t_leaves_the_neighbours_alone():
- with tempfile.TemporaryDirectory() as d:
- binary = os.path.join(d, "echo")
- other = os.path.join(d, "other.id0") # another binary's scratch
- src = os.path.join(d, "echo.c")
- touch(binary, other, src, binary + ".id0")
- _sweep_locks(binary)
- check("another binary's scratch is left alone", os.path.exists(other))
- check("unrelated neighbours are left alone", os.path.exists(src))
- check("our own scratch is still swept", not os.path.exists(binary + ".id0"))
+ check("_sweep_locks is gone", not hasattr(launch, "_sweep_locks"))
+ check("the scratch-suffix list is gone", not hasattr(launch, "_LOCK_SUFFIXES"))
+ src = open(launch.__file__, encoding="utf-8").read()
+ check("the launcher does not remove files at all",
+ "os.remove" not in src and "shutil.rmtree" not in src,
+ "launch.py deletes something again")
def t_load_args():
@@ -154,10 +75,7 @@ def t_load_args():
def main() -> int:
- for fn in (t_sweeps_the_scratch_files, t_sweeps_by_stem_too,
- t_never_the_database, t_never_the_input_itself,
- t_relative_path_is_still_the_input, t_missing_files_are_fine,
- t_leaves_the_neighbours_alone, t_load_args):
+ for fn in (t_no_lock_sweeping, t_load_args):
print(f"\n{fn.__name__}")
try:
fn()
diff --git a/tests/test_pool.py b/tests/test_pool.py
index 94e197b..9fc1446 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
"""
@@ -15,7 +14,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
@@ -32,11 +31,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
@@ -45,10 +45,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
@@ -76,15 +75,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 ---------------------------------------------------- #
@@ -100,9 +99,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}")
@@ -116,7 +115,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")
@@ -143,7 +142,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)
@@ -155,8 +154,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 ------ #
@@ -169,7 +168,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]
@@ -188,6 +187,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 822002f..690f360 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 9b2cc7e..e19ea11 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -694,18 +694,18 @@ async def s_split_view(c: Ctx):
# to count. The bound is loose because the bug was three orders of magnitude
# out, not a near miss.
_lookups = {"n": 0}
- _orig_call = c.prog.client.call
+ _orig_call = c.prog.client.invoke
def _counting(name, *a, **kw):
if name == "lookup_funcs":
_lookups["n"] += 1
return _orig_call(name, *a, **kw)
- c.prog.client.call = _counting
+ c.prog.client.invoke = _counting
try:
await _split_view_body(c, app, lst, dec)
finally:
- c.prog.client.call = _orig_call
+ c.prog.client.invoke = _orig_call
c.check("split view doesn't storm the worker with function lookups",
_lookups["n"] < 500, f"{_lookups['n']} lookup_funcs calls")
@@ -1586,14 +1586,14 @@ async def s_decomp_nav(c: Ctx):
old_ea = dec._line_ea(drow)
if old_ea is not None and dsym in old_line:
tmp = f"stale_{os.getpid()}"
- app.program.client.call("rename", batch={"func": {"addr": hex(dstale), "name": tmp}})
+ app.program.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": tmp}})
app.program.bump_names()
d2 = len(app._nav)
app._follow_decomp(old_line, dsym, old_ea)
await c.wait(lambda: len(app._nav) > d2, 25)
c.check("decomp follow works with a stale name (ea-marker fallback)",
app._cur.ea == dstale, f"cur={app._cur.ea:#x} want={dstale:#x}")
- app.program.client.call("rename", batch={"func": {"addr": hex(dstale), "name": dsym}})
+ app.program.client.invoke("rename", batch={"func": {"addr": hex(dstale), "name": dsym}})
app.program.bump_names()
@@ -1674,7 +1674,7 @@ async def s_rename(c: Ctx):
c.check("rename updates the function name",
app._func_index.by_addr(dtarget).name == newname,
app._func_index.by_addr(dtarget).name)
- rr = app.program.client.call("rename", batch={"func": {"addr": hex(dtarget), "name": dsym}})
+ rr = app.program.client.invoke("rename", batch={"func": {"addr": hex(dtarget), "name": dsym}})
c.check("rename reverted cleanly",
rr.get("summary", {}).get("ok", 0) == 1, str(rr.get("summary")))
# goto label refuse
@@ -1724,7 +1724,7 @@ async def s_rename(c: Ctx):
and any(cnote in t for t in dec._texts), 25)
c.check("comment appears in the pseudocode after ';'",
any(cnote in t for t in dec._texts), "comment not shown")
- app.program.client.call("set_comments", items=[{"addr": hex(cea), "comment": ""}])
+ app.program.client.invoke("set_comments", items=[{"addr": hex(cea), "comment": ""}])
else:
c.check("found a pseudocode line to comment", False, "no marker line")
@@ -1766,7 +1766,7 @@ async def s_comment_func(c: Ctx):
la is not None and lb is not None and lb > la
and a not in dec._texts[lb],
f"la={la} lb={lb}")
- app.program.client.call("set_comments", items=[{"addr": hex(fn.addr), "comment": ""}])
+ app.program.client.invoke("set_comments", items=[{"addr": hex(fn.addr), "comment": ""}])
@scenario("retype")
@@ -2031,7 +2031,7 @@ async def s_rename_history(c: Ctx):
await c.pause(0.15)
c.check("caller pseudocode shows renamed callee after 'back'",
any(hnew in tx for tx in dec._texts), "pseudocode still stale")
- app.program.client.call("rename", batch={"func": {"addr": hex(htarget), "name": hsym}})
+ app.program.client.invoke("rename", batch={"func": {"addr": hex(htarget), "name": hsym}})
@scenario("region_define")
@@ -2250,7 +2250,7 @@ async def s_listing_name_addr(c: Ctx):
finally:
# revert: drop the label and restore raw bytes at A
try:
- c.prog.client.call("rename", batch={"data": {"addr": hex(A + 1), "new": ""}})
+ c.prog.client.invoke("rename", batch={"data": {"addr": hex(A + 1), "new": ""}})
except Exception: # noqa: BLE001
pass
c.prog.undefine(A, size=8)
@@ -2327,7 +2327,7 @@ async def s_listing_struct_expand(c: Ctx):
c.check("found a data address for the struct test", False)
return
try:
- c.prog.client.call(
+ c.prog.client.invoke(
"declare_type",
decls=["struct TuiExpandS { int a; char b[4]; short c; };"])
c.prog.make_data(A, "TuiExpandS")
@@ -3206,7 +3206,7 @@ async def s_graph_rename(c: Ctx):
f"resolve({new}) -> {got if got is None else hex(got)} want {ea:#x}")
# revert, so the suite stays idempotent
if got is not None:
- app.program.client.call(
+ app.program.client.invoke(
"rename", batch={"data": {"addr": hex(ea), "new": ""}})
app.program.bump_names()
@@ -3259,7 +3259,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)
@@ -3279,6 +3279,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/tests/test_worker_client.py b/tests/test_worker_client.py
deleted file mode 100644
index cd99377..0000000
--- a/tests/test_worker_client.py
+++ /dev/null
@@ -1,447 +0,0 @@
-#!/usr/bin/env python3
-"""WorkerClient: spawn, transport, failure reporting, shutdown.
-
-This is the layer between the app and idalib, and it had no tests -- which is
-awkward, because it is where the failures are silent and expensive. A worker
-that dies during startup, a socket that drops mid-call, two UI threads sharing
-one socket: none of those look like a bug from the outside, they look like the
-TUI hanging or showing stale data.
-
-None of it needs IDA. The client spawns whatever ``_WORKER_PY`` points at, so
-these tests point it at a fake that speaks the same length-prefixed pickle
-protocol and can be told to misbehave on demand.
-"""
-from __future__ import annotations
-
-import os
-import sys
-import threading
-import time
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-#: pure: a fake worker over a unix socket, no idalib anywhere.
-#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
-NEEDS_IDA = False
-
-from idatui import worker_client as wc # noqa: E402
-from idatui.errors import IDAConnectionError, IDAToolError # noqa: E402
-
-PASS = FAIL = 0
-
-
-def check(name, ok, detail=""):
- global PASS, FAIL
- if ok:
- PASS += 1
- print(f" ok {name}")
- else:
- FAIL += 1
- print(f" FAIL {name} {detail}")
-
-
-# --------------------------------------------------------------------------- #
-# A worker that isn't IDA
-# --------------------------------------------------------------------------- #
-#: Speaks the real protocol (idatui.worker.send/recv) and implements a handful
-#: of tools whose only job is to be predictable, plus the misbehaviours we need:
-#: dying at startup, dropping the socket mid-conversation, taking its time.
-FAKE_WORKER = r'''
-import os, socket, sys, time
-sys.path.insert(0, %(repo)r)
-from idatui.worker import send, recv
-
-sock_path, binary = sys.argv[1], sys.argv[2]
-mode = os.environ.get("FAKE_MODE", "ok")
-
-if mode == "die":
- # A startup crash, the way the real worker reports one.
- print("IDA Pro: thank you for using it") # banner noise, must be skipped
- print("WORKER-FATAL: could not open database: it is wedged")
- sys.stdout.flush()
- sys.exit(3)
-if mode == "hang":
- time.sleep(60) # never binds: connect() must time out
- sys.exit(0)
-
-srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
-if os.path.exists(sock_path):
- os.unlink(sock_path)
-srv.bind(sock_path)
-srv.listen(1)
-conn, _ = srv.accept()
-served = 0
-while True:
- msg = recv(conn)
- if msg is None:
- break
- tool, args = msg
- if tool == "__shutdown__":
- # The real worker closes its database here; a clean exit is the signal
- # the client waits for rather than killing us.
- open(sock_path + ".clean", "w").write("shutdown")
- break
- served += 1
- if tool == "drop":
- conn.close() # vanish mid-conversation
- break
- if tool == "boom":
- send(conn, (False, "the tool exploded"))
- continue
- if tool == "slow":
- time.sleep(float(args.get("secs", 0.2)))
- send(conn, (True, {"tool": tool, "args": args, "n": served}))
- continue
- send(conn, (True, {"tool": tool, "args": args, "n": served,
- "binary": os.path.basename(binary)}))
-sys.exit(0)
-'''
-
-
-def _install_fake(tmpdir: str) -> str:
- repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- path = os.path.join(tmpdir, "fake_worker.py")
- with open(path, "w", encoding="utf-8") as fh:
- fh.write(FAKE_WORKER % {"repo": repo})
- wc._WORKER_PY = path
- return path
-
-
-def client(tmpdir, **kw):
- """A client wired to the fake worker, running under THIS interpreter.
-
- ``python=`` matters: the real constructor probes three interpreters for
- ``import ida_pro_mcp`` and that is both slow and beside the point here.
- """
- binary = os.path.join(tmpdir, "target.bin")
- if not os.path.exists(binary):
- with open(binary, "wb") as fh:
- fh.write(b"\x7fELF" + b"\0" * 60)
- return wc.WorkerClient(binary, python=sys.executable, **kw)
-
-
-# --------------------------------------------------------------------------- #
-def t_roundtrip(tmp):
- c = client(tmp)
- try:
- c.connect(timeout=30)
- r = c.call("survey_binary", depth=2)
- check("a call round-trips through the socket",
- r["tool"] == "survey_binary" and r["args"] == {"depth": 2}, str(r))
- check("the worker got the binary path we asked for",
- r["binary"] == "target.bin", str(r))
- check("pid is exposed for memory accounting", isinstance(c.pid, int))
- r2 = c.call("second")
- check("the connection is reused, not respawned per call",
- r2["n"] == 2, f"n={r2['n']}")
- finally:
- c.close(grace=5)
-
-
-def t_envelope(tmp):
- """domain.decompile reads result.structuredContent -- keep that shape."""
- c = client(tmp)
- try:
- c.connect(timeout=30)
- env = c.call_envelope("decompile", addr="0x1000")
- inner = env["result"]["structuredContent"]
- check("call_envelope wraps the payload the way domain.py unwraps it",
- inner["tool"] == "decompile" and inner["args"] == {"addr": "0x1000"},
- str(env))
- finally:
- c.close(grace=5)
-
-
-def t_tool_error(tmp):
- c = client(tmp)
- try:
- c.connect(timeout=30)
- try:
- c.call("boom")
- check("a failing tool raises IDAToolError", False, "no exception")
- except IDAToolError as e:
- check("a failing tool raises IDAToolError", True)
- check("the error names the tool", e.tool == "boom", f"tool={e.tool!r}")
- check("and carries the worker's message",
- "exploded" in e.message, e.message)
- # A tool error is not a transport error: the connection must survive it,
- # or one bad decompile would tear down the session.
- r = c.call("after")
- check("the connection survives a tool error", r["tool"] == "after", str(r))
- finally:
- c.close(grace=5)
-
-
-def t_dropped_socket(tmp):
- """The reconnect trigger. The app catches IDAConnectionError and reconnects;
- if a drop raised something else, or left _sock set, it would instead surface
- as a crash or as every later call failing."""
- c = client(tmp)
- try:
- c.connect(timeout=30)
- try:
- c.call("drop")
- check("a dropped socket raises IDAConnectionError", False,
- "no exception")
- except IDAConnectionError:
- check("a dropped socket raises IDAConnectionError", True)
- except Exception as e: # noqa: BLE001
- check("a dropped socket raises IDAConnectionError", False,
- f"got {type(e).__name__}: {e}")
- check("the dead socket is cleared, so a retry can reconnect",
- c._sock is None)
- finally:
- c.close(grace=5)
-
-
-def t_startup_crash(tmp):
- """A worker that dies before binding must say WHY.
-
- 'worker exited (code 3)' on its own is indistinguishable from a bug in the
- app; the real cause is the last meaningful line of its log, and the licence
- banner must not be mistaken for it.
- """
- os.environ["FAKE_MODE"] = "die"
- try:
- c = client(tmp)
- try:
- c.connect(timeout=30)
- check("a worker that exits during startup raises", False,
- "connect() returned")
- except IDAConnectionError as e:
- msg = str(e)
- check("a worker that exits during startup raises", True)
- check("the exit code is reported", "code 3" in msg, msg)
- check("the WORKER-FATAL line is surfaced",
- "wedged" in msg, msg)
- check("the licence banner is not mistaken for the error",
- "thank you" not in msg.lower(), msg)
- check("the full log path is offered", ".log" in msg, msg)
- finally:
- os.environ.pop("FAKE_MODE", None)
-
-
-def t_connect_timeout(tmp):
- """A worker that never binds must give up, not block the UI forever."""
- os.environ["FAKE_MODE"] = "hang"
- try:
- c = client(tmp)
- t0 = time.time()
- try:
- c.connect(timeout=0.4)
- check("connect() gives up on a worker that never binds", False,
- "returned")
- except IDAConnectionError as e:
- took = time.time() - t0
- check("connect() gives up on a worker that never binds", True)
- check("it honours the timeout it was given", took < 10, f"{took:.1f}s")
- check("and says so", "in time" in str(e), str(e))
- finally:
- # grace=0: it is sleeping by design, don't wait it out.
- c.close(grace=0)
- finally:
- os.environ.pop("FAKE_MODE", None)
-
-
-def t_progress(tmp):
- """connect() reports progress while analysis runs -- that callback is the
- only thing on screen during a long open."""
- os.environ["FAKE_MODE"] = "hang"
- seen = []
- try:
- c = client(tmp)
- try:
- c.connect(timeout=0.6, progress=seen.append)
- except IDAConnectionError:
- pass
- finally:
- c.close(grace=0)
- finally:
- os.environ.pop("FAKE_MODE", None)
- check("connect() reports progress while waiting", bool(seen),
- f"{len(seen)} callbacks")
- check("progress names the binary being analysed",
- any("target.bin" in s for s in seen), str(seen[:1]))
-
-
-def t_serialized(tmp):
- """One socket, many UI threads.
-
- The app fires calls from several worker threads over one client. The frames
- are length-prefixed pickle with no request ids, so if two calls interleaved
- on the wire each would read the other's reply -- silently, as wrong data
- rather than an error. The lock is the only thing preventing that, so this
- checks every thread gets its own answer back.
- """
- c = client(tmp)
- try:
- c.connect(timeout=30)
- out, errs = {}, []
-
- def go(i):
- try:
- out[i] = c.call("slow", secs=0.05, tag=i)
- except Exception as e: # noqa: BLE001
- errs.append(e)
-
- threads = [threading.Thread(target=go, args=(i,)) for i in range(8)]
- t0 = time.time()
- for t in threads:
- t.start()
- for t in threads:
- t.join(30)
- took = time.time() - t0
- check("concurrent calls all completed", len(out) == 8 and not errs,
- f"{len(out)} results, errors={errs[:1]}")
- check("each thread got ITS OWN reply, not another's",
- all(out[i]["args"]["tag"] == i for i in out),
- str({i: out[i]["args"].get("tag") for i in sorted(out)}))
- check("calls were serialized, not interleaved",
- took >= 8 * 0.05, f"{took:.2f}s for 8 x 0.05s")
- check("the worker saw every call exactly once",
- sorted(r["n"] for r in out.values()) == list(range(1, 9)),
- str(sorted(r["n"] for r in out.values())))
- finally:
- c.close(grace=5)
-
-
-def t_clean_shutdown(tmp):
- """close() must let the worker close its database.
-
- A hard kill leaves the .i64 unpacked into .id0/.id1/... and the database
- then fails to reopen. So close() sends __shutdown__ and WAITS; only a truly
- stuck worker gets signalled.
- """
- c = client(tmp)
- c.connect(timeout=30)
- sock_path = c._sock_path
- proc = c._proc
- c.close(grace=15)
- check("close() sends __shutdown__ rather than killing",
- os.path.exists(sock_path + ".clean"))
- check("and waits for the worker to exit on its own",
- proc.poll() == 0, f"returncode={proc.poll()}")
-
-
-def t_call_after_close(tmp):
- """A closed client must stay closed.
-
- call() reconnects when _sock is None, which is what makes a dropped socket
- recoverable -- but it made an explicitly CLOSED client resurrect too, and
- spawn a whole new idalib worker to serve one stray call. Teardown and
- binary-switch both close while @work threads are in flight, so quitting
- during a decompile left a fresh process re-opening the .i64 we had just
- released. (Verified before the fix: pid 1066961 -> 1066962.)
- """
- c = client(tmp)
- c.connect(timeout=30)
- pid = c.pid
- c.close(grace=5)
- try:
- c.call("zombie")
- check("a call after close() does not resurrect the worker", False,
- f"call succeeded; pid {pid} -> {c.pid}")
- except IDAConnectionError as e:
- check("a call after close() does not resurrect the worker", True)
- check("and says the client was closed", "closed" in str(e), str(e))
- check("no second worker was spawned", c.pid == pid, f"{pid} -> {c.pid}")
- # ... but an explicit reconnect still revives it: that is how the app
- # recovers from a worker that segfaulted.
- c.connect(timeout=30)
- r = c.call("revived")
- check("connect() revives a closed client", r["tool"] == "revived", str(r))
- c.close(grace=5)
-
-
-def t_worker_python_override(tmp):
- """$IDATUI_WORKER_PYTHON wins, and the answer is cached.
-
- Without the override the constructor probes interpreters with a subprocess
- each, which is why the override exists at all.
- """
- wc._worker_python_cache = None
- os.environ["IDATUI_WORKER_PYTHON"] = sys.executable
- try:
- got = wc._find_worker_python()
- check("$IDATUI_WORKER_PYTHON is honoured", got == sys.executable, got)
- finally:
- os.environ.pop("IDATUI_WORKER_PYTHON", None)
- wc._worker_python_cache = None
- missing = "/nonexistent/python-that-is-not-there"
- os.environ["IDATUI_WORKER_PYTHON"] = missing
- try:
- got = wc._find_worker_python()
- check("an override that doesn't exist falls back instead of crashing",
- got != missing and os.path.exists(got), got)
- finally:
- os.environ.pop("IDATUI_WORKER_PYTHON", None)
- wc._worker_python_cache = None
-
-
-def t_session_shims(tmp):
- """The single-DB worker still has to answer the session questions the app
- inherited from the old multi-session HTTP client."""
- c = client(tmp)
- try:
- c.connect(timeout=30)
- sess = c.list_sessions()
- check("list_sessions describes the one open database",
- len(sess) == 1 and sess[0].filename == "target.bin"
- and sess[0].is_active, str(sess))
- c.set_db("chosen")
- check("set_db/resolve_db round-trip", c.resolve_db() == "chosen")
- ka = c.keepalive()
- ka.start()
- ka.stop()
- check("keepalive is a no-op the app can still drive",
- ka.beats == 0 and ka.failures == 0)
- h = c.health()
- check("health answers even though the fake has no server_health tool",
- isinstance(h, dict) and h, str(h))
- finally:
- c.close(grace=5)
-
-
-def t_log_tail(tmp):
- """_log_tail picks the real error out of IDA's noise."""
- c = client(tmp)
- with open(c._log_path, "w", encoding="utf-8") as fh:
- fh.write("Thank you for using IDA\n"
- "Licensed to: somebody\n"
- "[MCP] registering tools\n"
- "WORKER-FATAL: Failed to open database\n")
- check("_log_tail surfaces WORKER-FATAL over the banner",
- c._log_tail() == "Failed to open database", repr(c._log_tail()))
- with open(c._log_path, "w", encoding="utf-8") as fh:
- fh.write("Thank you for using IDA\nsomething odd happened\n")
- tail = c._log_tail()
- check("without a FATAL line it skips the banner and keeps the rest",
- "odd happened" in tail and "Thank you" not in tail, repr(tail))
- os.unlink(c._log_path)
- check("a missing log is reported, not raised",
- "no worker log" in c._log_tail(), repr(c._log_tail()))
-
-
-def main() -> int:
- import tempfile
- tests = [t_roundtrip, t_envelope, t_tool_error, t_dropped_socket,
- t_startup_crash, t_connect_timeout, t_progress, t_serialized,
- t_clean_shutdown, t_call_after_close, t_worker_python_override,
- t_session_shims, t_log_tail]
- with tempfile.TemporaryDirectory(prefix="idatui-wc-") as tmp:
- _install_fake(tmp)
- for fn in tests:
- print(f"\n{fn.__name__}")
- try:
- fn(tmp)
- except Exception as e: # noqa: BLE001 -- isolate one test's crash
- import traceback
- check(f"{fn.__name__} did not crash", False,
- f"{type(e).__name__}: {e}")
- traceback.print_exc()
- print(f"\n{PASS} passed, {FAIL} failed")
- return 1 if FAIL else 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/uv.lock b/uv.lock
index 91414b5..392ebf0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -12,26 +12,68 @@ wheels = [
]
[[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" },
+]