py-ib-controller 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ # ibcontroller configuration file -- flat, no [section] headers.
2
+ #
3
+ # Precedence (highest to lowest):
4
+ # 1. Environment variables (override everything)
5
+ # 2. This config file
6
+ # 3. Built-in defaults
7
+ #
8
+ # Credentials are environment-variable-only and must never appear in this file --
9
+ # ibcontroller.config.load_config() refuses to start if it finds one here. They use a
10
+ # single pair regardless of trading_mode below (the mode says which account to
11
+ # authenticate as; it does not select different credential variables):
12
+ # IBC_USERID / IBC_PASSWORD
13
+ # Each also accepts a _FILE-suffixed variant (e.g. IBC_PASSWORD_FILE=/run/secrets/pass)
14
+ # that reads the value from a file instead -- Docker/Compose secrets, not the env var itself.
15
+ #
16
+ # A .env file also works, via `ibcontroller run --dotenv <path>` -- it only fills in
17
+ # environment variables that aren't already set (python-dotenv, override=False), so it's a
18
+ # convenience for populating the environment layer above, not a fourth layer of its own.
19
+ #
20
+ # This file is copied here by `ibcontroller init` (or automatically by `ibcontroller run`
21
+ # the first time it's missing) with every key commented out below -- as shipped, it
22
+ # changes nothing versus built-in defaults. Only IBC_USERID/IBC_PASSWORD
23
+ # are actually required to run -- everything else here has a safe default or is
24
+ # auto-detected from the real install.
25
+ #
26
+ # IMPORTANT: this schema is closed. Every key here must already be a real field in
27
+ # ibcontroller/config.py -- an unknown key is rejected at startup (a clean error
28
+ # message + non-zero exit, not silently ignored, not a crash-with-traceback). You
29
+ # cannot add a new TWS/Gateway-settings toggle by just adding a key here; that
30
+ # always needs a config.py code change first (a new Config field), plus adding it
31
+ # to settings.py's own allow-list if it's meant to drive a declarative setting via
32
+ # "value_from_config" -- see ibkr_settings.toml.example.
33
+
34
+ # Socket/instance name (env: IBC_INSTANCE). Optional: when unset,
35
+ # defaults to "{program}-{trading_mode}" (e.g. "gateway-paper").
36
+ # instance = "dev"
37
+
38
+ # "gateway" or "tws" (env: IBC_PROGRAM)
39
+ # Default "gateway"
40
+ # program = "gateway"
41
+
42
+ # Installed TWS/Gateway version, e.g. "10.50" (env: IBC_TWS_VERSION). Omit this
43
+ # key (the default) to auto-detect it from tws_path -- if more than one version is
44
+ # installed, the greatest is picked (optionally narrowed by tws_channel below); set this
45
+ # explicitly to pin one instead.
46
+ # tws_version = "10.50"
47
+
48
+ # Which update channel to prefer when auto-detecting tws_version -- "stable" or "latest"
49
+ # (env: IBC_TWS_CHANNEL), as labeled by the install itself
50
+ # (.install4j/i4jparams.conf). Ignored when tws_version is set explicitly.
51
+ # Default "stable"
52
+ # tws_channel = "stable"
53
+
54
+ # Override the install path launcher.py would otherwise infer (env: IBC_TWS_PATH)
55
+ # tws_path = "~/Jts"
56
+ # tws_path = ""
57
+
58
+ # Where TWS/Gateway itself stores its settings, deliberately NOT the same as tws_path
59
+ # (the install location). None defaults, {instance}={program}-{trading_mode}
60
+ # ie, ~/Jts/gateway-live
61
+ # tws_settings_path =
62
+
63
+ # TWS/Gateway is "live" or "paper" -- defaults to "paper" (the safe, non-monetary mode)
64
+ # when unset (env: IBC_TRADING_MODE)
65
+ # Default "paper"
66
+ # trading_mode = "paper"
67
+
68
+ # Which ibkr_settings.toml to apply at startup (settings.py's own
69
+ # apply_settings_from_file) -- see ibkr_settings.toml.example. Omit this key (the
70
+ # default) to apply nothing declaratively.
71
+ # (env: IBC_SETTINGS_FILE)
72
+ # settings_file = "~/.config/ibkr_settings.toml"
73
+
74
+ # Where ibcontroller's own log and trace files live (env: IBC_LOG_DIR).
75
+ # Optional: when unset, defaults to the platformdirs user-log dir (or
76
+ # $IBC_APP_DIR/log in docker mode). Every instance's files (log, trace,
77
+ # gateway stdout) land flat in this one directory.
78
+ # log_dir = "~/.local/share/ibcontroller"
79
+ # log_dir = ""
80
+
81
+ # Where ibcontroller's own log and Gateway/TWS's own console output go: "std"
82
+ # (console only) or "file" (only, under log_dir) -- exclusive, not both. Doesn't
83
+ # affect the wire trace above, which stays file-only regardless of this setting.
84
+ # (env: IBC_LOG_SINK)
85
+ # Default "std"
86
+ # log_sink = "std"
87
+
88
+ # Diagnostics -- off by default. When enabled, logs a component structure dump
89
+ # (the same primitive settings.py/recognisers.py use to find controls) for each
90
+ # matching window event, to ibcontroller-{instance}.log at INFO. Built-in
91
+ # replacement for poking the agent socket directly with socat/nc.
92
+ # diagnostic_scope: which windows qualify -- "known" (recognised by a built-in or
93
+ # declarative recogniser), "unknown", or "all" (env: IBC_DIAGNOSTIC_SCOPE)
94
+ # Default "known"
95
+ # diagnostic_scope = "known"
96
+ # diagnostic_when: which events trigger a dump -- "open", "openclose", or "never"
97
+ # (off) (env: IBC_DIAGNOSTIC_WHEN)
98
+ # Default "never"
99
+ # diagnostic_when = "open"
100
+
101
+ # IBC: ReadOnlyLogin -- TWS only (IBC disables it on Gateway); NOT YET WIRED to any real
102
+ # behavior in ibcontroller (loaded, but nothing acts on it yet -- see TODO.md).
103
+ # Default false
104
+ # read_only_login = false
105
+
106
+ # IBC: ReadOnlyApi -- true/false sets it
107
+ # (env: IBC_READ_ONLY_API)
108
+ # Default "false"
109
+ # read_only_api = false
110
+
111
+ # IBC: ExistingSessionDetectedAction -- how ibcontroller itself reacts to Gateway's
112
+ # "existing session detected" dialog: manual/primary/primaryoverride/secondary
113
+ # (env: IBC_EXISTING_SESSION_ACTION)
114
+ # Default "manual"
115
+ # existing_session_action = "manual"
116
+
117
+ # IBC: AcceptIncomingConnectionAction -- how ibcontroller reacts to TWS/Gateway's
118
+ # "Accept incoming connection" dialog, shown when an API client connects.
119
+ # If set to 'accept', ibcontroller automatically accepts incoming API connection
120
+ # dialogs. If set to 'reject', ibcontroller automatically rejects incoming API
121
+ # connection dialogs. If set to 'manual', the user must decide whether to accept
122
+ # or reject incoming API connection dialogs. The default is 'manual'.
123
+ # (env: IBC_ACCEPT_INCOMING_CONNECTIONS)
124
+ # accept_incoming_connections = "manual"
125
+
126
+ # --- Login/2FA timeout and retry settings -- ibcontroller's own policy, not written to
127
+ # --- Gateway/TWS itself (though mfa_timeout mirrors IBKR's
128
+ # --- real external 2FA window).
129
+ # IBC: LoginDialogDisplayTimeout, default 60
130
+ # login_dialog_display_timeout = 60.0
131
+ # IBC: SecondFactorAuthenticationTimeout, default 180
132
+ # mfa_timeout = 180.0
133
+ # IBC: ReloginAfterSecondFactorAuthenticationTimeout, default false
134
+ # relogin_after_mfa_timeout = false
135
+ # IBC: SecondFactorAuthenticationExitInterval, default 60
136
+ # mfa_exit_interval = 60.0
137
+
138
+ # IBC: AutoRestartTime -- same name, same "hh:mm AM/PM" format (e.g. "08:00 AM"), a
139
+ # single space between the two parts. Drives settings.py's "auto_restart_time" action
140
+ # (see ibkr_settings.toml.example) -- omit this key (the default) to leave Gateway's
141
+ # existing Auto Restart setting unchanged (env: IBC_AUTO_RESTART_TIME)
142
+ # Default None - not set
143
+ # auto_restart_time = "08:00 AM"
144
+
145
+ # IBC: AutoLogoffTime -- same "hh:mm AM/PM" format, same Lock and Exit control as
146
+ # auto_restart_time above (a shared radio-button pair: "Auto restart"/"Auto logoff").
147
+ # If you set BOTH keys, auto_restart_time wins (builtin_settings.toml applies it
148
+ # last, matching IBC's own precedence) -- set only one of the two in practice.
149
+ # Omit this key (the default) to leave the existing setting unchanged
150
+ # (env: IBC_AUTO_LOGOFF_TIME)
151
+ # Default None - not set
152
+ # auto_logoff_time = "08:00 AM"
153
+
154
+ # --- Applies to TWS and Gateway alike (ported from IBC's IbcTws.java, the shared
155
+ # --- base class both programs use). Both are ibcontroller's own scheduled actions,
156
+ # --- not GUI settings written to TWS/Gateway -- see control_loop.py. If both are
157
+ # --- set, whichever occurs first wins.
158
+ # IBC: ColdRestartTime -- "HH:MM", 24-hour, local time. Every Sunday at this time,
159
+ # ibcontroller closes the instance tidily and relaunches with a full fresh login
160
+ # (via ibcontroller's own launch_instance, not IBC's native-launcher File > Restart),
161
+ # forcing the weekly reauth IBKR requires around Sunday 01:00 US/Eastern token
162
+ # invalidation (env: IBC_COLD_RESTART_TIME)
163
+ # Default None - not set
164
+ # cold_restart_time = "07:05"
165
+
166
+ # IBC: ClosedownAt -- "HH:MM" (every day) or "<Weekday> HH:MM" (one day a week,
167
+ # Weekday a full English name, Monday..Sunday). At this time, ibcontroller closes
168
+ # the instance tidily with no relaunch -- you're responsible for restarting it
169
+ # yourself (env: IBC_CLOSEDOWN_AT)
170
+ # Default None - not set
171
+ # closedown_at = "22:00"
172
+ # closedown_at = "Friday 22:00"
173
+
174
+ # JVM heap for TWS/Gateway at launch, e.g. "1024m"/"4g". Overrides the -Xmx
175
+ # line in the installed .vmoptions file (ibgateway.vmoptions/tws.vmoptions);
176
+ # bare size, no -Xmx prefix (env: IBC_JAVA_HEAP_SIZE)
177
+ # Default None - leave the installed file's value untouched
178
+ # java_heap_size = "2g"
@@ -0,0 +1,135 @@
1
+ # Example ibkr_settings.toml -- declarative Gateway/TWS Global Configuration settings,
2
+ # applied once at instance startup by settings.py's apply_settings_from_file, layered
3
+ # on top of the bundled built-in entries (data/builtin_settings.toml -- read_only_api/
4
+ # auto_restart_time, applied from ibcontroller.toml/env alone, no file needed).
5
+ #
6
+ #
7
+ # --- Overriding a built-in entry ------------------------------------------------------
8
+ # If a future TWS/Gateway release moves a built-in control (a different tree_path or
9
+ # label), add an entry here with the SAME value_from_config as the built-in you're
10
+ # replacing -- settings.py's merge rule matches on that field and uses your entry
11
+ # instead of the bundled one, rather than applying both. Example (commented out --
12
+ # read_only_api's real navigation hasn't actually changed; this just shows the shape):
13
+ #
14
+ # [[settings]]
15
+ # tree_path = "API/Settings/Read-Only" # a hypothetical new location
16
+ # action = "toggle"
17
+ # label = "Read-Only API Access" # a hypothetical new label
18
+ # value_from_config = "read_only_api" # same key as the built-in -- this replaces it
19
+ #
20
+ # Each [[settings]] entry:
21
+ # tree_path -- Global Configuration's own JTree path, e.g. "API/Settings"
22
+ # action -- "toggle" (a checkbox/radio), "type_text", "type_text_near_label"
23
+ # (a field with no accessible name, found by position -- see
24
+ # field_index below), or "auto_restart_time" (a composite
25
+ # action, see its own section below -- takes no label/label_ref
26
+ # at all).
27
+ # label / label_ref -- the widget's accessible label, either literal ("label") or a
28
+ # name resolved through labels.json's settings.controls map
29
+ # ("label_ref") -- use label_ref for anything already named
30
+ # there (version-proofed against IBKR renaming it), label for
31
+ # anything not worth naming yet. Not used by "auto_restart_time".
32
+ # value / value_from_config -- the wanted value, either literal ("value") or an
33
+ # attribute name looked up on the already-loaded Config at
34
+ # apply time ("value_from_config") -- this is how
35
+ # Config.read_only_api flows down to the real Gateway
36
+ # setting without a second, redundant declaration. A value
37
+ # that resolves to None (either source) skips the entry
38
+ # entirely -- "leave unchanged", matching IBC's own "" ==
39
+ # leave-unchanged semantics.
40
+ #
41
+ # IMPORTANT: value_from_config only works for the fixed,
42
+ # closed set of Config fields settings.py already knows about
43
+ # (today: read_only_api, auto_restart_time) -- the override
44
+ # case above is the only reason to use it here. Config's own
45
+ # schema is closed (an unknown key in ibcontroller.toml itself
46
+ # fails at startup -- see ibcontroller.toml.example), so
47
+ # value_from_config can never be pointed at a new, made-up
48
+ # field just by editing this file. Naming anything else here
49
+ # logs a warning at load time and is silently skipped forever
50
+ # at apply time -- never fatal, but never applies either. For
51
+ # any setting that isn't one of the two above (like the
52
+ # api_precautions_* entries below), use a literal "value".
53
+ # field_index -- only used by "type_text_near_label"; which text field (by
54
+ # position, 0-based) within the container below the matched
55
+ # label. Defaults to 0.
56
+ #
57
+ # Known gap, not urgent (2026-09-06): some Global Configuration screens use dropdown
58
+ # (JComboBox) controls -- not supported by any action here yet. Nothing declared below
59
+ # needs one; revisit if a real deployment does.
60
+
61
+ # --- API precautions (API/Precautions) ------------------------------------------------
62
+ # Live-checked against a real paper Gateway 10.50 account, 2026-09-06 -- NOT identical
63
+ # to IBC's own ConfigureApiPrecautionsTask.java list (see labels.py's own docstring for
64
+ # the full finding). Two real corrections from that live check:
65
+ # - api_precautions_bypass_us_stocks_market_data_in_shares is NOT a real control on
66
+ # this account/build at all (raises ElementNotFoundError if declared) -- dropped
67
+ # here. Likely account/market-data-entitlement gated, not a stale label; a
68
+ # different account could plausibly have it.
69
+ # - api_precautions_bypass_route_marketable_to_bbo exists on the real panel but isn't
70
+ # in IBC's own list -- added below, set to false (disabled) by default per the
71
+ # user's own instruction, everything else enabled.
72
+ # All 8 entries below are live-confirmed: each one actually landed (selected=True/False
73
+ # read back via a real dump) before Apply was clicked to commit them.
74
+
75
+ # [[settings]]
76
+ # tree_path = "API/Precautions"
77
+ # action = "toggle"
78
+ # label_ref = "api_precautions_bypass_us_stocks_market_data_in_shares"
79
+ # value = true
80
+ # Commented out, not dropped from labels.json: confirmed live 2026-09-06 that this
81
+ # control does not exist on this paper account/Gateway 10.50 build at all.
82
+
83
+ [[settings]]
84
+ tree_path = "API/Precautions"
85
+ action = "toggle"
86
+ label_ref = "api_precautions_bypass_order_precautions"
87
+ value = true
88
+
89
+ [[settings]]
90
+ tree_path = "API/Precautions"
91
+ action = "toggle"
92
+ label_ref = "api_precautions_bypass_bond_warning"
93
+ value = true
94
+
95
+ [[settings]]
96
+ tree_path = "API/Precautions"
97
+ action = "toggle"
98
+ label_ref = "api_precautions_bypass_negative_yield_to_worst"
99
+ value = true
100
+
101
+ [[settings]]
102
+ tree_path = "API/Precautions"
103
+ action = "toggle"
104
+ label_ref = "api_precautions_bypass_called_bond_warning"
105
+ value = true
106
+
107
+ [[settings]]
108
+ tree_path = "API/Precautions"
109
+ action = "toggle"
110
+ label_ref = "api_precautions_bypass_same_action_pair_trade"
111
+ value = true
112
+
113
+ [[settings]]
114
+ tree_path = "API/Precautions"
115
+ action = "toggle"
116
+ label_ref = "api_precautions_bypass_price_based_volatility"
117
+ value = true
118
+
119
+ [[settings]]
120
+ tree_path = "API/Precautions"
121
+ action = "toggle"
122
+ label_ref = "api_precautions_bypass_redirect_order_warning"
123
+ value = true
124
+
125
+ [[settings]]
126
+ tree_path = "API/Precautions"
127
+ action = "toggle"
128
+ label_ref = "api_precautions_bypass_no_overfill_protection"
129
+ value = true
130
+
131
+ [[settings]]
132
+ tree_path = "API/Precautions"
133
+ action = "toggle"
134
+ label_ref = "api_precautions_bypass_route_marketable_to_bbo"
135
+ value = false
@@ -0,0 +1,81 @@
1
+ {
2
+ "login": {
3
+ "gateway_titles": ["IBKR Gateway", "IB Gateway", "Interactive Brokers Gateway"],
4
+ "tws_titles": ["New Login", "Login"],
5
+ "login_buttons": ["Log In", "Paper Log In", "Login"],
6
+ "api_type_ib_api": "IB API",
7
+ "api_type_fix": "FIX CTCI",
8
+ "trading_mode_live": "Live Trading",
9
+ "trading_mode_paper": "Paper Trading",
10
+ "username_field": "Username",
11
+ "password_field": "Password",
12
+ "starting_application_title": "Starting application...",
13
+ "tws_main_window_menu_item": "File/Lock Application"
14
+ },
15
+ "mfa": {
16
+ "title": "Second Factor Authentication"
17
+ },
18
+ "existing_session": {
19
+ "title": "Existing session detected",
20
+ "continue_buttons": ["OK", "Continue Login", "Reconnect This Session"],
21
+ "cancel_buttons": ["Cancel", "Exit Application"]
22
+ },
23
+ "accept_incoming_connection": {
24
+ "title": "Accept incoming connection",
25
+ "accept_buttons": ["OK", "Yes"],
26
+ "reject_buttons": ["No"]
27
+ },
28
+ "login_failed": {
29
+ "title": "Login failed",
30
+ "dismiss_button": "OK"
31
+ },
32
+ "dismiss_rules": [
33
+ {
34
+ "name": "non_brokerage_account",
35
+ "match_text": "This is not a brokerage account",
36
+ "click": "I understand and accept"
37
+ },
38
+ {
39
+ "name": "auto_restart_confirmation",
40
+ "match_text": "trading platform restart automatically",
41
+ "click": "OK"
42
+ }
43
+ ],
44
+ "too_many_failed_login_attempts": {
45
+ "message_prefix": "Too many failed login attempts",
46
+ "dismiss_button": "OK"
47
+ },
48
+ "shutdown": {
49
+ "gateway_menu_path": "File/Close",
50
+ "tws_menu_path": "File/Exit"
51
+ },
52
+ "settings": {
53
+ "gateway_menu_path": "Configure/Settings",
54
+ "tws_menu_path": "File/Global Configuration...",
55
+ "tws_menu_path_classic": "Edit/Global Configuration...",
56
+ "dialog_title_marker": "Configuration",
57
+ "apply_button": "Apply",
58
+ "cancel_button": "Cancel",
59
+ "ok_button": "OK",
60
+ "splash_title_marker": "Starting application...",
61
+ "controls": {
62
+ "read_only_api": "Read-Only API",
63
+ "api_precautions_bypass_order_precautions": "Bypass Order Precautions for API Orders",
64
+ "api_precautions_bypass_bond_warning": "Bypass Bond warning for API Orders.",
65
+ "api_precautions_bypass_negative_yield_to_worst": "Bypass negative yield to worst confirmation for API Orders.",
66
+ "api_precautions_bypass_called_bond_warning": "Bypass Called Bond warning for API Orders",
67
+ "api_precautions_bypass_same_action_pair_trade": "Bypass \"same action pair trade\" warning for API orders.",
68
+ "api_precautions_bypass_price_based_volatility": "Bypass price-based volatility risk warning for API Orders.",
69
+ "api_precautions_bypass_us_stocks_market_data_in_shares": "Bypass US Stocks market data in shares warning for API Orders",
70
+ "api_precautions_bypass_redirect_order_warning": "Bypass Redirect Order warning for Stock API Orders",
71
+ "api_precautions_bypass_no_overfill_protection": "Bypass No Overfill Protection precaution for destinations where implied natively.",
72
+ "api_precautions_bypass_route_marketable_to_bbo": "Bypass Route Marketable to BBO warning for API orders.",
73
+ "auto_logoff_time_label": "Set Auto Log Off Time (HH:MM)",
74
+ "auto_restart_time_label": "Set Auto Restart Time (HH:MM)",
75
+ "auto_logoff_radio": "Auto logoff",
76
+ "auto_restart_radio": "Auto restart",
77
+ "am_radio": "AM",
78
+ "pm_radio": "PM"
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "_readme": [
3
+ "Example override for {config_dir}/labels.json -- ibcontroller init/run copy this",
4
+ "file as labels.json.example, NOT as labels.json: labels.load_labels() applies",
5
+ "{config_dir}/labels.json unconditionally the moment it exists, so activating an",
6
+ "override is a deliberate rename, never automatic.",
7
+ "",
8
+ "labels.py's own _deep_merge overrides one field at a time -- you only need to name",
9
+ "the label that actually changed, not repeat every sibling field or every other",
10
+ "domain untouched. This file is trimmed down to two illustrative examples, not a",
11
+ "full copy of the bundled data/labels.json:",
12
+ "",
13
+ " - login.gateway_titles: shows how to add a title variant IBKR renamed/localized",
14
+ " (the override REPLACES the whole list, so include every title you still want,",
15
+ " not just the new one).",
16
+ " - dismiss_rules: this one is a list, not a nested object, so an override replaces",
17
+ " the whole list rather than merging entry by entry -- copy the bundled entries",
18
+ " here (as done below) before adding your own, or you'll lose the built-in ones.",
19
+ "",
20
+ "Remove this _readme key before using the file for real -- it is not a real Labels",
21
+ "field and is harmless where cattrs ignores unknown keys, but it's not meant to ship.",
22
+ "See labels.py's own module docstring and DismissRule's docstring for the full",
23
+ "mechanism."
24
+ ],
25
+ "login": {
26
+ "gateway_titles": [
27
+ "IBKR Gateway",
28
+ "IB Gateway",
29
+ "Interactive Brokers Gateway"
30
+ ]
31
+ },
32
+ "dismiss_rules": [
33
+ {
34
+ "name": "non_brokerage_account",
35
+ "match_text": "This is not a brokerage account",
36
+ "click": "I understand and accept"
37
+ },
38
+ {
39
+ "name": "auto_restart_confirmation",
40
+ "match_text": "trading platform restart automatically",
41
+ "click": "OK"
42
+ }
43
+ ]
44
+ }
@@ -0,0 +1,119 @@
1
+ """Built-in replacement for poking the agent socket by hand (`socat`/`nc`)
2
+ during layer-by-layer development: `watch_for_diagnostics` runs for the
3
+ agent's whole lifetime, dumping component structure for windows matching
4
+ `Config.diagnostic_scope`/`diagnostic_when` and handing the formatted text
5
+ to a `Sink`.
6
+
7
+ Deliberately split into capture (`format_dump`, built on the existing
8
+ `actions.dump`), trigger (`watch_for_diagnostics`, modeled directly on
9
+ `recognisers.watch_for_unprompted_windows`), and sink (a plain callable) --
10
+ the log sink below (`log_sink`) is the only one shipped now, but an
11
+ interactive `diagnose` CLI or a control-API handler can pass a different
12
+ sink later without touching capture or trigger logic.
13
+
14
+ Off by default (`Config.diagnostic_when` defaults to `NEVER`)."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ from collections.abc import Callable
21
+
22
+ from ibcontroller.actions import dump
23
+ from ibcontroller.agent_client import Component, WindowEvent, WindowGoneError
24
+ from ibcontroller.config import DiagnosticScope, DiagnosticWhen
25
+ from ibcontroller.dispatch import Dispatcher
26
+ from ibcontroller.recognisers import RecognizerRegistry
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ Sink = Callable[[WindowEvent, str], None]
31
+ """Receives the triggering event and the formatted dump text (or a
32
+ one-line fallback, see `watch_for_diagnostics`). `log_sink` is the only
33
+ implementation shipped now."""
34
+
35
+
36
+ def log_sink(event: WindowEvent, text: str) -> None:
37
+ logger.info("diagnostic dump (%s):\n%s", event.kind, text)
38
+
39
+
40
+ def format_dump(event: WindowEvent, components: list[Component]) -> str:
41
+ """One line per component: class, name/accessible_name/text (when
42
+ present -- `dump` already omits `text` for credential fields, nothing
43
+ extra to redact here), enabled/visible."""
44
+ lines = [
45
+ f"{event.kind} {event.window.class_} {event.window.title!r} "
46
+ f"window_id={event.window.window_id}"
47
+ ]
48
+ for component in components:
49
+ parts = [component.class_]
50
+ if component.name:
51
+ parts.append(f"name={component.name!r}")
52
+ if component.accessible_name:
53
+ parts.append(f"accessible_name={component.accessible_name!r}")
54
+ if component.text:
55
+ parts.append(f"text={component.text!r}")
56
+ parts.append(f"enabled={component.enabled}")
57
+ parts.append(f"visible={component.visible}")
58
+ lines.append(" " + " ".join(parts))
59
+ return "\n".join(lines)
60
+
61
+
62
+ async def watch_for_diagnostics(
63
+ registry: RecognizerRegistry,
64
+ dispatcher: Dispatcher,
65
+ scope: DiagnosticScope,
66
+ when: DiagnosticWhen,
67
+ sink: Sink = log_sink,
68
+ ) -> None:
69
+ """Runs for the agent's whole lifetime (meant to be started via
70
+ `asyncio.ensure_future`, like `watch_for_unprompted_windows`; never
71
+ returns on its own). A no-op if `when` is `NEVER`, so scheduling this
72
+ unconditionally is safe.
73
+
74
+ `scope` is checked against `registry.recognises()` -- the same
75
+ recognisers `watch_for_unprompted_windows` dispatches through, not a
76
+ separate classification. A `window_closed` event can't be dumped (the
77
+ window is already gone by the time this task reacts to the push over
78
+ the socket -- no in-process synchronous hook the way IBC's own
79
+ same-JVM listener has); `sink` still gets called, with a one-line
80
+ fallback noting the window's identity instead of its structure."""
81
+ if when is DiagnosticWhen.NEVER:
82
+ return
83
+
84
+ wanted_kinds = (
85
+ {"window_opened"}
86
+ if when is DiagnosticWhen.OPEN
87
+ else {"window_opened", "window_closed"}
88
+ )
89
+
90
+ queue: asyncio.Queue[WindowEvent] = asyncio.Queue()
91
+
92
+ def on_event(event: WindowEvent) -> None:
93
+ if event.kind in wanted_kinds:
94
+ queue.put_nowait(event)
95
+
96
+ dispatcher.window_events.connect(on_event)
97
+ try:
98
+ while True:
99
+ event = await queue.get()
100
+ try:
101
+ components = await dump(
102
+ dispatcher,
103
+ window=event.window.title,
104
+ window_id=event.window.window_id,
105
+ )
106
+ except WindowGoneError:
107
+ sink(
108
+ event,
109
+ f"{event.kind} {event.window.class_} {event.window.title!r} "
110
+ "(window already gone, no structure)",
111
+ )
112
+ continue
113
+ if scope is not DiagnosticScope.ALL:
114
+ known = registry.recognises(event, components)
115
+ if (scope is DiagnosticScope.KNOWN) != known:
116
+ continue
117
+ sink(event, format_dump(event, components))
118
+ finally:
119
+ dispatcher.window_events.disconnect(on_event)