Skip to content

Add acquisitionctl for safe local inventory inspection and refresh control - #192

Open
aurokin wants to merge 49 commits into
gerwaric:masterfrom
aurokin:private-league-support
Open

Add acquisitionctl for safe local inventory inspection and refresh control#192
aurokin wants to merge 49 commits into
gerwaric:masterfrom
aurokin:private-league-support

Conversation

@aurokin

@aurokin aurokin commented Aug 3, 2026

Copy link
Copy Markdown

Author Note: I was going to do smaller PRs but this is a pretty big shift, feel free to reference what you like, or decline all of it! The goal here is to add a surface for agents to drive acquisition so they can read data themselves, and manually queue refreshes. Instead of more hacky solutions this should work long term. I haven't tested AT ALL on linux or windows.


Summary

This adds acquisitionctl, a versioned command-line interface to an already-running Acquisition GUI.

The CLI lets users, scripts, and coding agents:

  • inspect application and login state;
  • list stash tabs and character inventories;
  • retrieve normalized item data and effective prices;
  • start a refresh;
  • reconnect later to inspect or wait for that refresh.

The GUI remains the sole owner of OAuth credentials, POESESSID, persistence, rate limiting, refresh execution, and existing automatic-shop behavior.

Why

Automating Acquisition previously required one of several undesirable approaches:

  • scraping the GUI;
  • reading internal databases or settings files;
  • accessing credentials directly;
  • starting another synchronization process;
  • duplicating refresh and rate-limit behavior outside Acquisition.

This change instead exposes a narrow local interface over the state Acquisition has already published to its UI. It avoids creating a second source of truth, another network client, or an agent-specific protocol inside the application.

User-facing commands

Command Purpose
acquisitionctl status Reports startup, login, inventory, and refresh state
acquisitionctl tabs Lists stash and character locations using bounded pagination
acquisitionctl items Lists normalized items, optionally filtered by location
acquisitionctl item <id> Retrieves one published item by stable item ID
acquisitionctl refresh start Requests the same full refresh used by the GUI
acquisitionctl refresh status <id> Inspects an application-owned refresh
acquisitionctl refresh wait <id> Polls until completion without owning or cancelling the work

Successful commands emit a versioned JSON envelope. Human diagnostics go to stderr. --data-dir selects the same application instance as the GUI.

Example:

acquisitionctl status --json
acquisitionctl tabs --limit 50 --json
acquisitionctl items --limit 50 --json
acquisitionctl item <item-id> --json
acquisitionctl refresh start --json
acquisitionctl refresh status <operation-id> --json
acquisitionctl refresh wait <operation-id> --timeout 300 --json

Inventory contract

Inventory responses come from ItemsManager, not repository tables. Effective prices come from BuyoutManager, matching the data consumed by the GUI.

Items expose normalized fields including:

  • identity, name, type, category, item level, stack count, and frame type;
  • identification and influence flags;
  • sockets, properties, requirements, and parsed modifiers;
  • display location and underlying fetch-source identity;
  • effective price, currency, source, inheritance, and update time.

The implementation adds maintained item-ID and per-location count indexes to ItemsManager. This avoids flattening or scanning the entire inventory for single-item lookups and tab counts.

Consistent, bounded pagination

Large inventories are traversed using opaque authenticated cursors.

The first page captures an instance_id and inventory_revision. Later pages return revision_changed if the published inventory changes, preventing clients from silently combining pages from different snapshots.

Bounds include:

  • at most 100 returned entries per page;
  • bounded source scanning for sparse filters;
  • bounded request, response, cursor, and socket buffers;
  • a 32 KiB pre-serialization cursor limit;
  • a conservative 4 MiB response limit.

Cursors contain the original filters and pagination state and are authenticated with a process-private HMAC. Clients cannot modify a cursor to change its query.

A sparse filtered page may be empty while still returning a continuation cursor. This keeps individual requests bounded without incorrectly declaring the traversal complete.

Application-owned refreshes

Refreshes use the existing ItemsManager::Update(TabSelection::All) path rather than introducing another synchronization implementation.

A refresh-start request ID also serves as:

  • the operation ID;
  • the idempotency key;
  • the identifier used by later status and wait commands.

The application retains the 32 most recent operations. Disconnecting the CLI or timing out while waiting does not cancel an accepted refresh.

Terminal results distinguish:

  • clean completion;
  • completion with structured skipped sources;
  • typed failure.

If a start response is lost after transmission, the CLI retries once with the same ID. If the result remains ambiguous, it reports that operation ID instead of risking a duplicate refresh.

Existing automatic-shop settings remain in effect. Refresh completion describes inventory refresh only; it does not claim that asynchronous forum posting has completed.

Local transport and security

The protocol uses QLocalServer and QLocalSocket. It does not open a TCP port or provide remote control.

Messages are length-prefixed, versioned JSON with strict request validation. The server limits concurrent connections, request time, frame size, buffered input, and response size.

Endpoint identity is derived from the canonical data-directory path, so separate Acquisition data directories remain independently addressable.

On Unix:

  • an owner-only OS runtime directory is preferred;
  • a validated passwd-home directory is the fallback;
  • socket paths are checked against sockaddr_un::sun_path;
  • deterministic native locks serialize ownership;
  • stale endpoints are probed before removal;
  • clients and servers use the same ordered candidate list.

On Windows:

  • the endpoint is a named pipe;
  • a global named mutex serializes ownership;
  • the client compares the server process token SID with its own before transmitting a request.

A secondary GUI retries ownership periodically, allowing it to take over if the original endpoint owner exits without changing Acquisition's existing multi-instance policy.

Packaging

Release packaging adds the CLI alongside the GUI:

  • Windows: acquisitionctl.exe beside acquisition.exe;
  • macOS: inside the Acquisition application bundle;
  • Linux: a separate acquisitionctl AppImage artifact.

Local builds produce build/acquisitionctl.

The CLI is intentionally release-matched with the GUI rather than installed as an unrelated system-wide command.

Consumer agent skill

The PR includes an optional consumer-facing agent skill at:

skills/acquisition-cli/

This is similar to the reusable skill shipped by Diffwarden. It teaches coding agents how to operate an installed Acquisition application safely.

Consumers can install it with the Skills CLI:

npx skills add gerwaric/acquisition \
  --global \
  --skill acquisition-cli \
  --agent codex claude-code \
  --full-depth

The skill covers:

  • locating a release-matched acquisitionctl;
  • using --data-dir consistently;
  • checking service readiness before inventory access;
  • revision-safe pagination;
  • using normalized effective prices instead of parsing notes;
  • retaining refresh operation IDs;
  • treating wait timeouts as observation timeouts rather than cancellation;
  • distinguishing clean, skipped, and failed refreshes;
  • avoiding credentials, settings, databases, and unsupported mutations.

The skill is for consumers using Acquisition, not contributors developing the repository. It is plain Markdown plus agent interface metadata and introduces no runtime dependency or embedded agent protocol.

Installing the skill does not install Acquisition or acquisitionctl; users install an Acquisition release separately.

Deliberate non-goals

This does not add:

  • headless or daemon synchronization;
  • HTTP, TCP, MCP, or remote-machine control;
  • credential access;
  • buyout, settings, search, or forum-thread mutation;
  • refresh cancellation;
  • a second inventory query language;
  • changes to existing multi-GUI or automatic-shop policy.

Existing-code impact

Most of the change is isolated in new control, CLI, test, documentation, and skill files:

Area Diff
Control implementation and CLI +3,101
Tests and benchmark +1,823
Documentation +467
Consumer agent skill +183
Build and packaging +67 / −2
Existing application integration +235 / −6

The main integration changes are:

  • Application owns and wires the control server and service;
  • ItemsManager maintains item-ID and per-location indexes;
  • ItemsManagerWorker exposes its application-thread readiness state;
  • ItemLocation exposes already-stored projection fields;
  • data-directory changes reset directory-scoped control state and rebind the endpoint.

No persistence schema or credential-handling code is exposed through the control service.

Suggested review order

1. Contract and scope

  • docs/design/local-control.md
  • README.md
  • skills/acquisition-cli/SKILL.md

These establish the intended behavior, security boundary, non-goals, and consumer workflow.

2. Protocol and endpoint ownership

  • src/control/controlprotocol.*
  • src/control/controlendpoint.*
  • src/control/localcontrolserver.*
  • src/control/localcontrolclient.*

Review framing, bounds, endpoint discovery, same-user ownership, stale recovery, and deadlines here.

3. Published data and refresh behavior

  • src/control/controlservice.*
  • src/control/viewprojection.*

Review the JSON projection, pagination contract, cursor authentication, revision checks, and refresh-operation lifecycle here.

4. Existing application integration

  • src/application.*
  • src/itemsmanager.*
  • src/itemsmanagerworker.*
  • src/itemlocation.h

This is the relatively small portion that changes existing application behavior.

5. CLI and packaging

  • src/acquisitionctl.cpp
  • CMakeLists.txt
  • .github/workflows/build-*.yml
  • acquisitionctl.desktop

Review command parsing, exit statuses, release locations, and artifacts here.

6. Tests and scale coverage

  • tests/tst_controlprotocol.cpp
  • tests/tst_localcontrolserver.cpp
  • tests/tst_controlservice.cpp
  • tests/tst_acquisitionctl.cpp
  • tests/control_benchmark.cpp

Reviewing the final diff by these layers is easier than following the development commit sequence, where later commits harden behavior introduced by earlier commits.

Validation

The branch includes current upstream master and is zero commits behind it.

Completed locally:

  • clean RelWithDebInfo build;
  • all 39/39 tests passed;
  • all four control-focused tests passed with AddressSanitizer;
  • GUI/CLI process smoke testing with a temporary data directory;
  • consumer skill validation;
  • focused and unscoped Diffwarden reviews with zero findings;
  • post-commit Diffwarden review of the final skill relocation and installation documentation with zero findings.

Release benchmark on an Apple M4 Max:

  • 101,048 items across 1,011 pages: 1,377.900 ms total, 1.710 ms maximum page;
  • 975,711 items across 9,758 pages: 13,295.001 ms total, 4.765 ms maximum page.

Remaining validation gaps

The following require native CI or platform testing:

  • Windows named-pipe SID authentication and global mutex behavior;
  • Windows installer integration;
  • Linux GUI and CLI AppImage packaging;
  • macOS release DMG packaging.

A live authenticated refresh and forum update were deliberately not run against user data during local verification.

aurokin and others added 30 commits July 30, 2026 18:35
Reuses the stored OAuth token (refreshed at startup by OAuthManager),
runs the same session/update path as the GUI login flow with no windows
shown, prints a parseable HEADLESS_SYNC_RESULT line, and exits.

Exit codes: 0 ok, 2 needs interactive login (no token/league/account),
3 token refresh failed, 4 another instance running, 5 sync error,
6 watchdog timeout (10 min).

Every run now holds a QLockFile in the data dir; headless refuses to
start alongside any other instance, the GUI only warns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtLF9xL3JK3wez4d7ffE57
@gerwaric

gerwaric commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Nice work. I'll spend time evaluating the design this week, but at first glance I agree with your approach.

I'm guessing you're using this already. If so, would you mind incrementing the version and adding an alpha or beta postfix in CMakeLists.txt? That number makes it into the GGG-visible User-Agent header, which they monitor, but don't change the application name, because I think GGG white-lists apps by name.

(If you're not using this yet, let me know and I'll prioritize releasing this as an alpha).

@aurokin

aurokin commented Aug 3, 2026

Copy link
Copy Markdown
Author

Yes, I'm using it lightly against my real account. I'm a new PoE player, and my main use so far is letting agents inspect my gear and inventory so they can help me learn the game; I also use it to manually queue refreshes.

I've bumped the branch to 0.19.0-alpha.1 in CMakeLists.txt and left the application name unchanged. The generated metadata now reports APP_NAME as acquisition and APP_VERSION_STRING as 0.19.0-alpha.1. I also reran the clean build and all 39 tests locally after the version change.

@gerwaric

gerwaric commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Welcome to a very complicated game, and thank you--GGG has contacted me based off the User-Agent header, so I know they are looking at it when things go wrong.

For background, most of the users I've heard from since I forked acquisition in 2023 are long-term players with thousands of tab in standard, or people who use forum shops to list items on the trade site instead of merchant tabs.

I have more internal rework planned for 0.18.x, but it shouldn't impact the CLI design and I love the idea of making acquisition AI-friendly for the next generation of players.

@aurokin

aurokin commented Aug 3, 2026

Copy link
Copy Markdown
Author

Thank you! My friends are long term players like you are describing so I have some help but I like offloading some of the simple questions because, theres a lot of them. I'm building a CLI that works similar to my Warcraft CLI to connect an agent to multiple PoE surfaces! I'll make it public when I can.

I'll stop using it until the User-Agent stuff is all handled I don't want you to get in trouble with GGG!

Edit: Also wanted to note I have access to linux + windows hosts along with OSX so if you need me to test / dogfood on a platform I can!

@gerwaric

gerwaric commented Aug 3, 2026

Copy link
Copy Markdown
Owner

No, please keep using this! Your feedback and thinking on what to build here is valuable. The User-Agent is just a way to make sure GGG has some visibility.

@gerwaric

gerwaric commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Actually, if you're interested, are you ok talking about this sometime?

@aurokin

aurokin commented Aug 3, 2026

Copy link
Copy Markdown
Author

Sure I sent a friend request on discord!

@gerwaric gerwaric linked an issue Aug 6, 2026 that may be closed by this pull request
gerwaric added a commit that referenced this pull request Aug 9, 2026
Patch release staging for the fixes on master since v0.18.0: the
buyout price formatting fix (#150), the F30 pacing-message
downgrade, the PR #193 cleanups, and the PR #194 mechanical
credential-safety findings (F68-F73, F76). Cut ahead of merging
PR #192 so the 0.19.0-alpha.1 feature release stays separate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gerwaric

gerwaric commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Here's a proper review. as we get through the redesign I'm guessing the control surface will change, but for the application as it stands now, this looks like a good candidate for 0.19.0-alpha.1. --@gerwaric

Review: recommend merge

This is unusually careful work, and I'd like to land it. I did a full review with Claude's help — spec first, then the diff, then building the branch and driving both binaries hard on Windows, which is the platform the spec itself flags as its validation gap. Everything below is either resolved, a genuine question, or a nit; nothing is a merge condition.

What was verified locally (Windows 11, MSVC 2022, Qt 6.11.1)

  • Clean configure/build; 39/39 ctest, including the four new control suites.
  • Runtime exercise against a copy of my real Standard-league data (40,906 items, 379 tabs): tabs/items pagination, single-item lookup, tab filtering (filtered page count matched item_count exactly), garbage/tampered cursors → invalid_cursor, and the full refresh lifecycle — accept, busy + active_refresh_id on second start, revision streaming during deltas, a pre-refresh cursor correctly answering revision_changed mid-refresh, refresh wait timeout semantics, operation_not_found.
  • Raw named-pipe pokes (unknown command, protocol 2, invalid JSON, zero-length frame, 999 MB length header) all returned the exact specced error shapes and the GUI stayed up.
  • The Windows machinery the spec lists as unverified — named-pipe endpoint, bind mutex, server-token SID check — all works. I also re-derived the endpoint name in an independent PowerShell client (canonical path → SHA-256 → first 24 hex) and it connected: endpoint identity is reproducible outside Qt, which matters for where this protocol may go long-term.

Two questions I can already resolve

Data-dir default (main.cpp:65). I measured the old behavior: the released 0.18.1 AppImage derives its data dir from the AppImage filename (~/.local/share/acquisition-0.18.1-x86_64.AppImage/), so the old Linux default was never stable across releases or renames — your pinned ~/.local/share/acquisition is a fix, not a regression. You've confirmed macOS, and Windows resolves identically (verified here). All three platforms accounted for. One ask: a release-note line telling Linux users their data dir moves one last time.

The committed 0.19.0-alpha.1 (CMakeLists.txt:12,21). Keeping it. Plan: merge, tag 0.19.0-alpha.1 as a prerelease, and graduate to a regular 0.19.0 once the spec's status line becomes an explicit v1 promise (see the doc asks at the end). The alpha window is deliberate — it keeps license to adjust protocol shapes (Q5/Q6 below) cheaply while the CLI gets real agent usage.

Open questions (your call on each)

Q3 — The spec's Verification contract claims a bit more than the automated tests deliver. Four deltas: a FailedRefresh outcome is never driven through the control service (tst_controlservice.cpp:507,604-637 cover clean/skipped only); "revision increments for snapshots, deltas, reconciliations, and buyout changes" is asserted only via a buyout change (tst_controlservice.cpp:318-322); "no secret fields in any response" has no test (true by construction today, but it's exactly the promise a regression test should pin); "stale endpoint recovery" tests the live-listener half but never leaves crash residue and verifies Listen recovers over it (localcontrolserver.cpp:334-336 is the untested path). Either align the spec wording ("exercised manually") or add the tests — the no-secrets and FailedRefresh ones look cheap.

Q4 — Busy-recovery with a fresh request id is untested. The busy-retry test reuses request id "start" (tst_controlservice.cpp:585-601), which exercises the memoized replay (controlservice.cpp:730-743), not that a new id is accepted once readiness returns. The replay is correct and deliberate; observed working live with fresh UUIDs. Worth a test, and worth a docs note that a busy rejection is replayed forever for that id (by design, a client retrying a rejected start with the same id never gets in).

Q5 — Should refresh wait tolerate transient transport failures? The wait loop exits on the first failed poll (acquisitionctl.cpp:317-324). Observed live: one poll that hit the 2 s client timeout while the GUI thread was briefly busy ended a wait with exit 4 while the refresh kept running and the server answered again seconds later. A small bounded retry inside the loop — or skill guidance to re-run refresh wait with the same operation id, which works — would make long unattended waits robust. Was single-shot deliberate?

Q6 — After a GUI restart, a stale cursor reports invalid_cursor, not revision_changed. The cursor HMAC key is per-instance (controlservice.cpp:320-321,353-354), so a cursor from a previous process fails signature verification before the revision comparison is reached — same shape as a corrupt cursor. The skill teaches restart-on-revision_changed (SKILL.md:117) but is silent on invalid_cursor, so an agent that held a cursor across an app restart may conclude it has a client-side bug. Cheapest fix: one sentence in the skill ("treat invalid_cursor on a previously-valid cursor as a restart signal").

Q7 — The install command targets two agents (SKILL.md:15: --agent codex claude-code) but only agents/openai.yaml exists. If the skills CLI needs no Claude-side file, fine — worth one line saying so. (The skill content itself checks out: every flag and all eight exit codes match the implementation exactly.)

Nits (batch at will; none urgent)

  • N1 .github/workflows/build-linux.yml:87-93 — CI artifact still named as one AppImage but now contains two. (Release asset globbing is unaffected.)
  • N2 .github/workflows/build-windows.yml:60-66windeployqt runs only against acquisition.exe; acquisitionctl.exe rides on the GUI's deployed DLLs. Works, but passing both exes makes the dependency explicit.
  • N3 docs/README.md still describes the control work as "PR boundaries have not yet been chosen" — stale on merge.
  • N4 acquisitionctl.desktop:9Categories=Utility lacks the trailing semicolon (matches the pre-existing acquisition.desktop, so cosmetic).
  • N5 MSVC /W4 applies to acquisition_core but not the new acquisition_control/acquisitionctl targets.
  • N6 On Windows, Listen requires and creates <data-dir>/.acquisition-control/ for a lock path (localcontrolserver.cpp:263-268, controlendpoint.cpp:186-187) that the Windows BindLock never touches — it uses the named mutex (localcontrolserver.cpp:156-165). Result: a stray empty directory in every user's data dir and an error message guarding a file nobody creates.
  • N7 VerifyCursor compares HMACs with != (controlservice.cpp:152) — not constant-time. Same-user local IPC makes this academic; noting for completeness.
  • N8 The not_running error carries Qt's raw "QLocalSocket::connectToServer: Invalid name" as its message — correct code and exit status, cosmetically odd for first-time users.
  • N9 Test hygiene: endpointIsUserScoped mutates HOME/XDG_RUNTIME_DIR without a restore guard on early failure (tst_controlprotocol.cpp:153-167); connectionLimitIsEnforced has a can't-fail QTRY_VERIFY (tst_localcontrolserver.cpp:365); refreshStartRetriesWithSameRequestId couples a 2200 ms sleep to the CLI's private 2000 ms timeout (tst_acquisitionctl.cpp:189-191) — fails loudly rather than false-passes, so acceptable.

Two small doc asks, at merge time

Both come from the architecture side (a fuller contract-focused review of the protocol as the long-term UI/core seam is coming separately on the redesign branch — none of it is a merge objection):

  1. Add one sentence to local-control.md stating the standing constraint the implementation already enforces structurally: no endpoint may ever return all matching items across IPC — so a future convenience endpoint can't reintroduce the unbounded path by being individually reasonable.
  2. On merge, flip the spec's status line from "not an upstream commitment" to an explicit v1 promise (semantics frozen, evolution additive within protocol 1, new semantics cost a version) plus a short explicitly-unstable list — the cosmetic progress string, the latest_refresh retention depth (32), and the not_started outcome kind that dispatch-time failures produce (controlservice.cpp:810-833), which the spec's outcome section doesn't currently mention.

That second item is also the graduation gate for dropping the -alpha.1 suffix.

gerwaric added a commit that referenced this pull request Aug 9, 2026
…nd 1)

The A0 seam review migration-order names under step zero. Verdict:
good seed, cheap to walk back. The generation token the windowed
protocol needs already exists as (instance_id, revision); pages are
bounded three independent ways (write it down as a standing
constraint, R1-2); the refresh terminal outcome answers
shop-write-path §5's open question with yes (R1-3); refresh.start
establishes the idempotent command semantics the A0 write addendum
can reuse (R1-5); an internals-leakage audit found nothing a Rust
core could not reproduce, including the endpoint identity re-derived
from a non-Qt client (R1-6). The one structural gap is the missing
notification path — v1 is strictly poll-based, right for a CLI,
insufficient for a live UI (R1-7); that plus a windowed-read command
is what A0 still owes. Verified by build-and-drive on Windows
against scrubbed real data (39/39 tests; mid-refresh
revision_changed observed live).

Answers the open questions in shop-write-path (refresh outcome) and
migration-order (versioning story; superseded by the sharper R1-7).
No new correctness findings in untouched code this round. Deletes
the working brief per docs/redesign conventions (precedent:
2d27655, 8a3f751). The merge-side review is a separate untracked
deliverable for Tom (pr192-merge-review.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aurokin

aurokin commented Aug 9, 2026

Copy link
Copy Markdown
Author

@gerwaric do you think the redesign branch is a better target for this?

@gerwaric

gerwaric commented Aug 9, 2026

Copy link
Copy Markdown
Owner

@aurokin I don't have a strong opinion, but redesign makes sense

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Headless Support

2 participants