diff options
| author | blasty <blasty@local> | 2026-08-07 15:18:56 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-07 15:18:56 +0200 |
| commit | 4f0e17c69c5424f307ecf2ef4ffb278e775c8349 (patch) | |
| tree | 43d561beb58b18a1859beb915fed84ed495e0c0d | |
| parent | Merge the IDA Code Mode port (diff) | |
| download | ida-tui-4f0e17c69c5424f307ecf2ef4ffb278e775c8349.tar.gz ida-tui-4f0e17c69c5424f307ecf2ef4ffb278e775c8349.tar.xz ida-tui-4f0e17c69c5424f307ecf2ef4ffb278e775c8349.zip | |
Stop tracking the upstream port patch
It was swept into the tree by a broad 'git add -A' during the port. The merge
itself plus docs/CODEMODE_PORT.md are the record; a 251KB patch of superseded
content is not. Still on disk, now ignored.
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | ida-codemode-mcp.patch | 5848 |
2 files changed, 3 insertions, 5848 deletions
@@ -21,3 +21,6 @@ core core.* .fastfeedback/ tests/.synthetic/ + +# the upstream port patch, kept locally for reference only +ida-codemode-mcp.patch diff --git a/ida-codemode-mcp.patch b/ida-codemode-mcp.patch deleted file mode 100644 index 82a9b3b..0000000 --- a/ida-codemode-mcp.patch +++ /dev/null @@ -1,5848 +0,0 @@ -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 - |
