agentstreamdeck 2.1.1__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.
- agentstreamdeck-2.1.1.dist-info/METADATA +1013 -0
- agentstreamdeck-2.1.1.dist-info/RECORD +65 -0
- agentstreamdeck-2.1.1.dist-info/WHEEL +5 -0
- agentstreamdeck-2.1.1.dist-info/entry_points.txt +2 -0
- agentstreamdeck-2.1.1.dist-info/licenses/LICENSE +201 -0
- agentstreamdeck-2.1.1.dist-info/top_level.txt +1 -0
- ocdeck/__init__.py +1 -0
- ocdeck/__main__.py +307 -0
- ocdeck/alerts.py +120 -0
- ocdeck/appearance.py +133 -0
- ocdeck/appearance_io.py +70 -0
- ocdeck/art.py +181 -0
- ocdeck/assets/logos/OCTICONS-LICENSE.txt +21 -0
- ocdeck/assets/logos/claude.png +0 -0
- ocdeck/assets/logos/copilot.png +0 -0
- ocdeck/assets/logos/copilot.svg +1 -0
- ocdeck/assets/logos/cursor.png +0 -0
- ocdeck/assets/logos/gemini.png +0 -0
- ocdeck/assets/logos/opencode.png +0 -0
- ocdeck/assets/logos/sources.json +27 -0
- ocdeck/broker.py +273 -0
- ocdeck/common.py +82 -0
- ocdeck/device.py +247 -0
- ocdeck/diagnostics.py +227 -0
- ocdeck/errors.py +22 -0
- ocdeck/focus.py +156 -0
- ocdeck/hardware_check.py +66 -0
- ocdeck/harness.py +203 -0
- ocdeck/launcher.py +203 -0
- ocdeck/model.py +177 -0
- ocdeck/observability.py +57 -0
- ocdeck/runtime/plugins/core.mjs +122 -0
- ocdeck/runtime/plugins/harnesses/bridge.mjs +52 -0
- ocdeck/runtime/plugins/harnesses/hook.mjs +38 -0
- ocdeck/runtime/plugins/harnesses/install.mjs +83 -0
- ocdeck/runtime/plugins/harnesses/profiles.mjs +107 -0
- ocdeck/runtime/plugins/server.mjs +67 -0
- ocdeck/runtime/plugins/tui.mjs +44 -0
- ocdeck/runtime/scripts/Install-Harness.ps1 +20 -0
- ocdeck/runtime/scripts/Install.ps1 +79 -0
- ocdeck/runtime/scripts/Launch-Agent.bat +7 -0
- ocdeck/runtime/scripts/Launch-Claude.bat +7 -0
- ocdeck/runtime/scripts/Launch-Codex.bat +7 -0
- ocdeck/runtime/scripts/Launch-Copilot-VSCode.bat +7 -0
- ocdeck/runtime/scripts/Launch-Copilot.bat +7 -0
- ocdeck/runtime/scripts/Launch-Cursor.bat +7 -0
- ocdeck/runtime/scripts/Launch-Gemini.bat +7 -0
- ocdeck/runtime/scripts/Remove-Integration.ps1 +35 -0
- ocdeck/runtime/scripts/Run-OpenCode.ps1 +7 -0
- ocdeck/runtime/scripts/Test.ps1 +11 -0
- ocdeck/runtime/scripts/Uninstall.ps1 +11 -0
- ocdeck/runtime/scripts/Verify-Windows.ps1 +13 -0
- ocdeck/runtime/scripts/check-js.py +8 -0
- ocdeck/runtime/scripts/examples/Claude-Cloud.bat +6 -0
- ocdeck/runtime/scripts/examples/Claude-Local.bat +21 -0
- ocdeck/runtime/scripts/examples/HomeAILab-Claude-5090.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-Claude-Cluster.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-5090.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-Spark.bat +8 -0
- ocdeck/runtime/scripts/examples/OpenCode-Cloud.bat +8 -0
- ocdeck/runtime/scripts/render-gallery.py +94 -0
- ocdeck/security.py +38 -0
- ocdeck/settings.py +42 -0
- ocdeck/uninstall.py +100 -0
- ocdeck/updates.py +46 -0
ocdeck/diagnostics.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Read-only diagnostics and an allowlisted, redacted issue bundle."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import zipfile
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .common import home, read_json, request, load_config
|
|
12
|
+
from .observability import tail
|
|
13
|
+
from .security import scrub
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def versions():
|
|
17
|
+
node = shutil.which("node")
|
|
18
|
+
try:
|
|
19
|
+
node_version = (
|
|
20
|
+
subprocess.run([node, "--version"], capture_output=True, text=True, timeout=3, check=True).stdout.strip()
|
|
21
|
+
if node
|
|
22
|
+
else "missing"
|
|
23
|
+
)
|
|
24
|
+
except (OSError, subprocess.SubprocessError):
|
|
25
|
+
node_version = "unavailable"
|
|
26
|
+
return {
|
|
27
|
+
"agentdeck": __version__,
|
|
28
|
+
"python": platform.python_version(),
|
|
29
|
+
"node": node_version,
|
|
30
|
+
"os": platform.platform(),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def doctor(project=".", no_device=False, root=None):
|
|
35
|
+
root = Path(root or home())
|
|
36
|
+
project = Path(project).resolve()
|
|
37
|
+
rows = []
|
|
38
|
+
|
|
39
|
+
def add(check, ok, detail, fix):
|
|
40
|
+
rows.append(
|
|
41
|
+
{
|
|
42
|
+
"check": check,
|
|
43
|
+
"result": "PASS" if ok is True else "FAIL" if ok is False else "MANUAL",
|
|
44
|
+
"detail": detail,
|
|
45
|
+
"fix": fix,
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
ver = versions()
|
|
50
|
+
try:
|
|
51
|
+
node_ok = int(ver["node"].lstrip("v").split(".")[0]) >= 20
|
|
52
|
+
except ValueError:
|
|
53
|
+
node_ok = False
|
|
54
|
+
add("node", node_ok, ver["node"], "Install Node.js 20+, then restart the terminal.")
|
|
55
|
+
config_file = root / "config.json"
|
|
56
|
+
try:
|
|
57
|
+
config = load_config(root)
|
|
58
|
+
except (ValueError, OSError):
|
|
59
|
+
config = None
|
|
60
|
+
add(
|
|
61
|
+
"config",
|
|
62
|
+
isinstance(config, dict),
|
|
63
|
+
"JSON configuration",
|
|
64
|
+
"Repair config.json as a JSON object; ocdeck appearance --dry-run",
|
|
65
|
+
)
|
|
66
|
+
from .settings import validate_config
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
validate_config(config)
|
|
70
|
+
add("config-values", True, "Validated broker and appearance settings", "ocdeck appearance --dry-run")
|
|
71
|
+
except (ValueError, TypeError) as error:
|
|
72
|
+
add("config-values", False, str(error), "Repair config.json; ocdeck appearance --dry-run")
|
|
73
|
+
if os.name == "nt":
|
|
74
|
+
add(
|
|
75
|
+
"windows-terminal",
|
|
76
|
+
bool(shutil.which("wt.exe")),
|
|
77
|
+
"Dedicated-window launcher",
|
|
78
|
+
"Install Windows Terminal; restart the terminal.",
|
|
79
|
+
)
|
|
80
|
+
from .harness import SOURCE
|
|
81
|
+
|
|
82
|
+
add(
|
|
83
|
+
"runtime-assets",
|
|
84
|
+
(SOURCE / "plugins/harnesses/hook.mjs").is_file() and (SOURCE / "scripts/Run-OpenCode.ps1").is_file(),
|
|
85
|
+
"Packaged adapter and launcher sources",
|
|
86
|
+
"python -m pip install --force-reinstall agentstreamdeck",
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
status = request("GET", "/v1/status", root=root)
|
|
90
|
+
add("broker", True, "Local authenticated API responds", "ocdeck broker")
|
|
91
|
+
except Exception:
|
|
92
|
+
status = {}
|
|
93
|
+
add("broker", False, "Local broker unavailable", "ocdeck broker")
|
|
94
|
+
add(
|
|
95
|
+
"capacity",
|
|
96
|
+
not status.get("overflow"),
|
|
97
|
+
f"Overflow: {status.get('overflow', 0)}",
|
|
98
|
+
"Close unused sessions or attach a larger deck.",
|
|
99
|
+
)
|
|
100
|
+
if not no_device:
|
|
101
|
+
from .device import enumerate_devices, elgato_running
|
|
102
|
+
|
|
103
|
+
add(
|
|
104
|
+
"elgato",
|
|
105
|
+
not elgato_running(),
|
|
106
|
+
"Elgato process ownership check",
|
|
107
|
+
"Quit Stream Deck from its tray menu; ocdeck devices",
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
devices = enumerate_devices()
|
|
111
|
+
device_config = config if isinstance(config, dict) else {}
|
|
112
|
+
selected = [
|
|
113
|
+
d
|
|
114
|
+
for d in devices
|
|
115
|
+
if not device_config.get("serial") or d.get("serial_number") == device_config["serial"]
|
|
116
|
+
]
|
|
117
|
+
add(
|
|
118
|
+
"device",
|
|
119
|
+
len(selected) == 1,
|
|
120
|
+
f"{len(selected)} matching devices",
|
|
121
|
+
"ocdeck devices; select serial in config.json if multiple.",
|
|
122
|
+
)
|
|
123
|
+
if status:
|
|
124
|
+
add(
|
|
125
|
+
"device-online",
|
|
126
|
+
status.get("device", {}).get("online") is True,
|
|
127
|
+
status.get("device", {}).get("error", ""),
|
|
128
|
+
"Release Elgato ownership and reconnect USB; ocdeck broker",
|
|
129
|
+
)
|
|
130
|
+
except Exception as error:
|
|
131
|
+
add("device", False, str(error), "python -m pip install --upgrade agentstreamdeck; ocdeck devices")
|
|
132
|
+
receipts = list((project / ".agentdeck").glob("*.json"))
|
|
133
|
+
add(
|
|
134
|
+
"hooks-installed",
|
|
135
|
+
bool(receipts),
|
|
136
|
+
f"{len(receipts)} receipts in project",
|
|
137
|
+
"ocdeck harness-install PROFILE --project PROJECT",
|
|
138
|
+
)
|
|
139
|
+
for receipt in receipts:
|
|
140
|
+
saved = read_json(receipt)
|
|
141
|
+
target = Path(saved.get("target", "")) if isinstance(saved, dict) else Path()
|
|
142
|
+
from .harness import PROFILES
|
|
143
|
+
|
|
144
|
+
profile = receipt.stem
|
|
145
|
+
expected = {
|
|
146
|
+
"claude": ".claude/settings.local.json",
|
|
147
|
+
"gemini": ".gemini/settings.json",
|
|
148
|
+
"cursor": ".cursor/hooks.json",
|
|
149
|
+
"copilot-cli": ".github/hooks/agentdeck-copilot-cli.json",
|
|
150
|
+
"copilot-vscode": ".github/hooks/agentdeck-copilot-vscode.json",
|
|
151
|
+
"codex": ".codex/hooks.json",
|
|
152
|
+
}.get(profile)
|
|
153
|
+
good = bool(expected) and target == project / expected
|
|
154
|
+
actual = read_json(target) if good else None
|
|
155
|
+
good = good and isinstance(actual, dict) and isinstance(actual.get("hooks"), dict)
|
|
156
|
+
if good and isinstance(saved, dict) and isinstance(actual, dict):
|
|
157
|
+
for event, entries in saved.get("configuration", {}).get("hooks", {}).items():
|
|
158
|
+
good = good and all(entry in actual["hooks"].get(event, []) for entry in entries)
|
|
159
|
+
add(
|
|
160
|
+
"hooks-" + profile,
|
|
161
|
+
bool(good),
|
|
162
|
+
"Receipt target and installed entries",
|
|
163
|
+
f'ocdeck harness-install {profile} --project "{project}" --dry-run',
|
|
164
|
+
)
|
|
165
|
+
details = [s.get("detail", "") for s in status.get("slots", [])]
|
|
166
|
+
add(
|
|
167
|
+
"hook-delivery",
|
|
168
|
+
not any("failed" in d.lower() for d in details),
|
|
169
|
+
"Hook delivery failure latch",
|
|
170
|
+
"Restart the managed session after fixing native hook errors.",
|
|
171
|
+
)
|
|
172
|
+
add(
|
|
173
|
+
"first-hook",
|
|
174
|
+
not any("Waiting for first" in d for d in details),
|
|
175
|
+
"First native hook received",
|
|
176
|
+
"Submit a prompt; review native hooks and workspace trust.",
|
|
177
|
+
)
|
|
178
|
+
focus = status.get("lastFocus")
|
|
179
|
+
add(
|
|
180
|
+
"focus",
|
|
181
|
+
focus.get("ok") if focus else None,
|
|
182
|
+
"Last focus result" if focus else "No focus attempt recorded",
|
|
183
|
+
"ocdeck focus 1; use a dedicated managed window under the same Windows user.",
|
|
184
|
+
)
|
|
185
|
+
# Runtime policy and interactive behavior cannot be proved from static files.
|
|
186
|
+
manual = {
|
|
187
|
+
"approval-coverage": "Inspect HARNESSES.md; unpaired approval notifications cannot establish counts.",
|
|
188
|
+
"stale-native-hooks": "Verify prompt/tool/stop hooks in the native runtime; heartbeat does not prove hook coverage.",
|
|
189
|
+
"bridge-restart": "After a broker restart, verify managed Node bridge and supervisor processes remain alive.",
|
|
190
|
+
"foreground-policy": "Test minimized windows; run broker and agents at the same elevation.",
|
|
191
|
+
"vscode-signin": "Sign in and enable Copilot in the isolated managed VS Code profile.",
|
|
192
|
+
"vscode-trust": "Inspect Chat: Configure Hooks and workspace trust.",
|
|
193
|
+
"vscode-title": "Preserve the managed window.title and launcher suffix.",
|
|
194
|
+
"jsonc": "Preserve comments; reconcile JSONC manually before harness-install.",
|
|
195
|
+
"moved-project": "If moved, reconcile old hooks and receipt; do not blindly rewrite the recorded target.",
|
|
196
|
+
"powershell-policy": "Use the trusted FIRST-RUN.md invocation subject to device policy.",
|
|
197
|
+
"opencode-shim": "Run Get-Command opencode in PowerShell; use ocdeck launch if an alias wins.",
|
|
198
|
+
"deleted-checkout": "Keep the installed source or reinstall hooks to the new source before removing it.",
|
|
199
|
+
"homeailab-focus": "Use the managed wrapper and retain the Windows Terminal managed tab title.",
|
|
200
|
+
"notifications": "Enable AgentStreamDeck notifications in Windows Settings; test a real identified question with Focus Assist on and off.",
|
|
201
|
+
}
|
|
202
|
+
for key, fix in manual.items():
|
|
203
|
+
add(key, None, "Interactive verification required", fix)
|
|
204
|
+
return scrub(rows)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def report(output, lines=100, root=None):
|
|
208
|
+
root = Path(root or home())
|
|
209
|
+
try:
|
|
210
|
+
token = (root / "token").read_text().strip()
|
|
211
|
+
except OSError:
|
|
212
|
+
token = ""
|
|
213
|
+
try:
|
|
214
|
+
status = request("GET", "/v1/status", root=root)
|
|
215
|
+
except Exception:
|
|
216
|
+
status = {"error": "Broker unavailable"}
|
|
217
|
+
values = {
|
|
218
|
+
"status.json": status,
|
|
219
|
+
"versions.json": versions(),
|
|
220
|
+
"config.json": read_json(root / "config.json", {}),
|
|
221
|
+
"logs.json": tail(root, lines),
|
|
222
|
+
}
|
|
223
|
+
# Deliberately exclude token, discovery, launch descriptors and native configs.
|
|
224
|
+
with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
225
|
+
for name, value in values.items():
|
|
226
|
+
archive.writestr(name, json.dumps(scrub(value, (token,)), indent=2))
|
|
227
|
+
return str(Path(output).resolve())
|
ocdeck/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Stable user-facing errors, actionable commands and documentation anchors."""
|
|
2
|
+
|
|
3
|
+
CATALOG = {
|
|
4
|
+
"AD001": ("Elgato Stream Deck may own the device", "Quit Stream Deck from the tray; ocdeck doctor"),
|
|
5
|
+
"AD002": ("Device unavailable or disconnected", "ocdeck devices"),
|
|
6
|
+
"AD003": ("Broker unavailable", "ocdeck broker"),
|
|
7
|
+
"AD004": ("Invalid configuration or request", "ocdeck doctor --no-device"),
|
|
8
|
+
"AD005": ("Stale session registration", "ocdeck status --json; restart the managed agent launcher"),
|
|
9
|
+
"AD006": ("Focus failed", "ocdeck doctor; ocdeck focus 1"),
|
|
10
|
+
"AD007": (
|
|
11
|
+
"Local authentication rejected",
|
|
12
|
+
"ocdeck doctor --no-device; restart the managed launcher under the broker user",
|
|
13
|
+
),
|
|
14
|
+
"AD008": ("Broker lock unavailable", "ocdeck status --json"),
|
|
15
|
+
"AD500": ("Unexpected broker error", "ocdeck report --output agentdeck-report.zip"),
|
|
16
|
+
}
|
|
17
|
+
DOC = "https://github.com/darkmatter2222/AgentStreamDeck/blob/main/docs/TROUBLESHOOTING.md"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def message(code: str, detail: str = "") -> str:
|
|
21
|
+
title, fix = CATALOG[code]
|
|
22
|
+
return f"{code}: {title}. {detail} Fix/check: {fix}. {DOC}"
|
ocdeck/focus.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Focus existing Windows top-level windows; never launch from a press."""
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
from ctypes import wintypes
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def activate(record):
|
|
10
|
+
if os.name != "nt":
|
|
11
|
+
return {"ok": False, "reason": "Windows host required"}
|
|
12
|
+
u = ctypes.WinDLL("user32", use_last_error=True)
|
|
13
|
+
u.GetForegroundWindow.restype = wintypes.HWND
|
|
14
|
+
u.IsWindowVisible.argtypes = [wintypes.HWND]
|
|
15
|
+
u.IsIconic.argtypes = [wintypes.HWND]
|
|
16
|
+
u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
|
|
17
|
+
u.SetForegroundWindow.argtypes = [wintypes.HWND]
|
|
18
|
+
u.GetWindowTextLengthW.argtypes = [wintypes.HWND]
|
|
19
|
+
u.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
|
|
20
|
+
u.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
|
|
21
|
+
u.GetWindowThreadProcessId.restype = wintypes.DWORD
|
|
22
|
+
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
23
|
+
u.EnumWindows.argtypes = [callback_type, wintypes.LPARAM]
|
|
24
|
+
# Declare every HWND argument/result explicitly for 64-bit Python.
|
|
25
|
+
for name in ("IsWindow", "IsHungAppWindow"):
|
|
26
|
+
getattr(u, name).argtypes = [wintypes.HWND]
|
|
27
|
+
getattr(u, name).restype = wintypes.BOOL
|
|
28
|
+
u.IsChild.argtypes = [wintypes.HWND, wintypes.HWND]
|
|
29
|
+
u.IsChild.restype = wintypes.BOOL
|
|
30
|
+
u.ShowWindowAsync.argtypes = [wintypes.HWND, ctypes.c_int]
|
|
31
|
+
u.ShowWindowAsync.restype = wintypes.BOOL
|
|
32
|
+
u.GetGUIThreadInfo.argtypes = [wintypes.DWORD, ctypes.POINTER(GUIThreadInfo)]
|
|
33
|
+
u.GetGUIThreadInfo.restype = wintypes.BOOL
|
|
34
|
+
u.PeekMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT, wintypes.UINT]
|
|
35
|
+
u.PeekMessageW.restype = wintypes.BOOL
|
|
36
|
+
u.AttachThreadInput.argtypes = [wintypes.DWORD, wintypes.DWORD, wintypes.BOOL]
|
|
37
|
+
u.AttachThreadInput.restype = wintypes.BOOL
|
|
38
|
+
u.BringWindowToTop.argtypes = [wintypes.HWND]
|
|
39
|
+
u.BringWindowToTop.restype = wintypes.BOOL
|
|
40
|
+
for name in ("SetFocus", "SetActiveWindow"):
|
|
41
|
+
getattr(u, name).argtypes = [wintypes.HWND]
|
|
42
|
+
getattr(u, name).restype = wintypes.HWND
|
|
43
|
+
token = record.get("windowToken", "")
|
|
44
|
+
candidates = []
|
|
45
|
+
|
|
46
|
+
@callback_type
|
|
47
|
+
def collect(hwnd, _):
|
|
48
|
+
if not u.IsWindowVisible(hwnd):
|
|
49
|
+
return True
|
|
50
|
+
text = ctypes.create_unicode_buffer(u.GetWindowTextLengthW(hwnd) + 1)
|
|
51
|
+
u.GetWindowTextW(hwnd, text, len(text))
|
|
52
|
+
pid = wintypes.DWORD()
|
|
53
|
+
u.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
54
|
+
if (token and text.value == token) or (not token and pid.value == record["process"]["pid"]):
|
|
55
|
+
candidates.append(hwnd)
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
u.EnumWindows(collect, 0)
|
|
59
|
+
if len(candidates) != 1:
|
|
60
|
+
return {
|
|
61
|
+
"ok": False,
|
|
62
|
+
"reason": "Window mapping ambiguous or absent; use ocdeck launch for a dedicated window",
|
|
63
|
+
"matches": len(candidates),
|
|
64
|
+
}
|
|
65
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
66
|
+
kernel.GetCurrentThreadId.restype = wintypes.DWORD
|
|
67
|
+
return focus_window(u, kernel.GetCurrentThreadId(), candidates[0])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GUIThreadInfo(ctypes.Structure):
|
|
71
|
+
_fields_ = [
|
|
72
|
+
("cbSize", wintypes.DWORD),
|
|
73
|
+
("flags", wintypes.DWORD),
|
|
74
|
+
("hwndActive", wintypes.HWND),
|
|
75
|
+
("hwndFocus", wintypes.HWND),
|
|
76
|
+
("hwndCapture", wintypes.HWND),
|
|
77
|
+
("hwndMenuOwner", wintypes.HWND),
|
|
78
|
+
("hwndMoveSize", wintypes.HWND),
|
|
79
|
+
("hwndCaret", wintypes.HWND),
|
|
80
|
+
("rcCaret", wintypes.RECT),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def focus_window(u, current, hwnd, sleep=time.sleep):
|
|
85
|
+
"""Activate a resolved window; input attachment is temporary and always undone."""
|
|
86
|
+
target = u.GetWindowThreadProcessId(hwnd, None)
|
|
87
|
+
if not target or not u.IsWindow(hwnd):
|
|
88
|
+
return {"ok": False, "reason": "Target window closed", "hwnd": int(hwnd)}
|
|
89
|
+
if u.IsHungAppWindow(hwnd):
|
|
90
|
+
return {"ok": False, "reason": "Target window is not responding", "hwnd": int(hwnd)}
|
|
91
|
+
|
|
92
|
+
def focused_child():
|
|
93
|
+
info = GUIThreadInfo()
|
|
94
|
+
info.cbSize = ctypes.sizeof(info)
|
|
95
|
+
if u.GetGUIThreadInfo(target, ctypes.byref(info)):
|
|
96
|
+
child = info.hwndFocus
|
|
97
|
+
if child and (child == hwnd or u.IsChild(hwnd, child)):
|
|
98
|
+
return child
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
def confirmed():
|
|
102
|
+
return u.GetForegroundWindow() == hwnd and not u.IsIconic(hwnd) and bool(focused_child())
|
|
103
|
+
|
|
104
|
+
# Remember the editor/terminal input child before activation, rather than
|
|
105
|
+
# unconditionally moving keyboard focus to its top-level frame.
|
|
106
|
+
child = focused_child()
|
|
107
|
+
if u.IsIconic(hwnd):
|
|
108
|
+
u.ShowWindowAsync(hwnd, 9) # SW_RESTORE
|
|
109
|
+
accepted = bool(u.SetForegroundWindow(hwnd))
|
|
110
|
+
for _ in range(5):
|
|
111
|
+
if confirmed():
|
|
112
|
+
return {"ok": True, "hwnd": int(hwnd), "method": "SetForegroundWindow", "keyboardFocus": True}
|
|
113
|
+
sleep(0.04)
|
|
114
|
+
|
|
115
|
+
# A broker worker is not a GUI thread. Explicitly create its message queue
|
|
116
|
+
# before attaching, and attach BOTH the foreground and destination threads.
|
|
117
|
+
message = wintypes.MSG()
|
|
118
|
+
u.PeekMessageW(ctypes.byref(message), None, 0, 0, 0) # PM_NOREMOVE
|
|
119
|
+
foreground = u.GetForegroundWindow()
|
|
120
|
+
other = u.GetWindowThreadProcessId(foreground, None) if foreground else 0
|
|
121
|
+
attached, failed = [], []
|
|
122
|
+
try:
|
|
123
|
+
for thread in dict.fromkeys((other, target)):
|
|
124
|
+
if not thread or thread == current:
|
|
125
|
+
continue
|
|
126
|
+
if u.AttachThreadInput(current, thread, True):
|
|
127
|
+
attached.append(thread)
|
|
128
|
+
else:
|
|
129
|
+
failed.append(thread)
|
|
130
|
+
if u.IsWindow(hwnd):
|
|
131
|
+
u.BringWindowToTop(hwnd)
|
|
132
|
+
u.SetForegroundWindow(hwnd)
|
|
133
|
+
if target == current or target in attached:
|
|
134
|
+
u.SetActiveWindow(hwnd)
|
|
135
|
+
# Prefer the application's current focus after activation, then
|
|
136
|
+
# its previously focused child if it still belongs to this frame.
|
|
137
|
+
focus = focused_child()
|
|
138
|
+
if not focus and child and u.IsWindow(child) and u.IsChild(hwnd, child):
|
|
139
|
+
focus = child
|
|
140
|
+
u.SetFocus(focus or hwnd)
|
|
141
|
+
finally:
|
|
142
|
+
for thread in reversed(attached):
|
|
143
|
+
u.AttachThreadInput(current, thread, False)
|
|
144
|
+
for _ in range(5):
|
|
145
|
+
if confirmed():
|
|
146
|
+
return {"ok": True, "hwnd": int(hwnd), "method": "AttachThreadInput", "keyboardFocus": True}
|
|
147
|
+
sleep(0.04)
|
|
148
|
+
return {
|
|
149
|
+
"ok": False,
|
|
150
|
+
"reason": "Windows denied foreground or keyboard focus",
|
|
151
|
+
"hwnd": int(hwnd),
|
|
152
|
+
"apiAccepted": accepted,
|
|
153
|
+
"foreground": int(u.GetForegroundWindow() or 0),
|
|
154
|
+
"keyboardFocus": bool(focused_child()),
|
|
155
|
+
"attachmentFailures": failed,
|
|
156
|
+
}
|
ocdeck/hardware_check.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Manual six-key diagnostic with actual USB events, isolated from the broker."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import queue
|
|
5
|
+
import time
|
|
6
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
7
|
+
from .broker import InstanceLock
|
|
8
|
+
from .common import home, read_json, atomic_json
|
|
9
|
+
from .device import enumerate_devices, device_types, elgato_running, WheelTransport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run():
|
|
13
|
+
from StreamDeck.ImageHelpers import PILHelper
|
|
14
|
+
|
|
15
|
+
root = home()
|
|
16
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
with InstanceLock(root):
|
|
18
|
+
if elgato_running():
|
|
19
|
+
raise RuntimeError("Quit Elgato Stream Deck, then run ocdeck doctor")
|
|
20
|
+
devices = enumerate_devices()
|
|
21
|
+
serial = read_json(root / "config.json", {}).get("serial")
|
|
22
|
+
if serial:
|
|
23
|
+
devices = [d for d in devices if d.get("serial_number") == serial]
|
|
24
|
+
if len(devices) != 1:
|
|
25
|
+
raise RuntimeError("Expected one deck; select a serial in config.json")
|
|
26
|
+
deck = device_types()[devices[0]["product_id"]](WheelTransport(devices[0]))
|
|
27
|
+
count = deck.key_count()
|
|
28
|
+
presses = queue.Queue()
|
|
29
|
+
observed = []
|
|
30
|
+
try:
|
|
31
|
+
deck.open()
|
|
32
|
+
deck.set_brightness(45)
|
|
33
|
+
for key in range(count):
|
|
34
|
+
im = Image.new("RGB", (80, 80), "#123b5c")
|
|
35
|
+
ImageDraw.Draw(im).text(
|
|
36
|
+
(40, 40), str(key + 1), font=ImageFont.load_default(size=36), fill="white", anchor="mm"
|
|
37
|
+
)
|
|
38
|
+
deck.set_key_image(key, PILHelper.to_native_key_format(deck, im))
|
|
39
|
+
deck.set_key_callback(lambda d, k, pressed: presses.put(k + 1) if pressed else None)
|
|
40
|
+
print(f"{count} numbered images submitted. Confirm physical key order.")
|
|
41
|
+
print(f"Press physical keys 1 through {count} in order within 60 seconds.")
|
|
42
|
+
deadline = time.monotonic() + 60
|
|
43
|
+
while len(observed) < count and time.monotonic() < deadline:
|
|
44
|
+
try:
|
|
45
|
+
key = presses.get(timeout=min(1, max(0.01, deadline - time.monotonic())))
|
|
46
|
+
observed.append(key)
|
|
47
|
+
print(f"Physical key-down received: {key}", flush=True)
|
|
48
|
+
except queue.Empty:
|
|
49
|
+
pass
|
|
50
|
+
result = {
|
|
51
|
+
"time": time.time(),
|
|
52
|
+
"serial": devices[0].get("serial_number"),
|
|
53
|
+
"physicalKeys": observed,
|
|
54
|
+
"keyOrderPass": observed == list(range(1, count + 1)),
|
|
55
|
+
"displayVisuallyConfirmed": False,
|
|
56
|
+
"note": "Display appearance requires human confirmation; API writes are not camera evidence.",
|
|
57
|
+
}
|
|
58
|
+
atomic_json(root / "hardware-check.json", result)
|
|
59
|
+
print(json.dumps(result, indent=2))
|
|
60
|
+
finally:
|
|
61
|
+
try:
|
|
62
|
+
blank = PILHelper.to_native_key_format(deck, Image.new("RGB", (80, 80), "black"))
|
|
63
|
+
for key in range(count):
|
|
64
|
+
deck.set_key_image(key, blank)
|
|
65
|
+
finally:
|
|
66
|
+
deck.close()
|
ocdeck/harness.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Managed hook-harness supervisor. The existing broker and identity rules are unchanged."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from .common import home, identity, atomic_json, read_json, request
|
|
11
|
+
|
|
12
|
+
PROFILES = {
|
|
13
|
+
"codex": "codex",
|
|
14
|
+
"claude": "claude",
|
|
15
|
+
"copilot-cli": "copilot",
|
|
16
|
+
"copilot-vscode": "code",
|
|
17
|
+
"gemini": "gemini",
|
|
18
|
+
"cursor": "agent",
|
|
19
|
+
}
|
|
20
|
+
SOURCE = Path(__file__).resolve().parent.parent
|
|
21
|
+
if not (SOURCE / "plugins/harnesses/install.mjs").is_file():
|
|
22
|
+
SOURCE = Path(__file__).resolve().parent / "runtime"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def install(profile, project, remove=False, dry_run=False):
|
|
26
|
+
node = shutil.which("node")
|
|
27
|
+
if not node:
|
|
28
|
+
raise RuntimeError("Node.js 20+ must be on PATH for hook adapters")
|
|
29
|
+
result = subprocess.call(
|
|
30
|
+
[
|
|
31
|
+
node,
|
|
32
|
+
str(SOURCE / "plugins/harnesses/install.mjs"),
|
|
33
|
+
"--cli",
|
|
34
|
+
profile,
|
|
35
|
+
str(Path(project).resolve()),
|
|
36
|
+
*(["--remove"] if remove else []),
|
|
37
|
+
*(["--dry-run"] if dry_run else []),
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
if result == 0 and not dry_run:
|
|
41
|
+
projects = read_json(home() / "projects.json", []) or []
|
|
42
|
+
resolved = str(Path(project).resolve())
|
|
43
|
+
if resolved not in projects:
|
|
44
|
+
projects.append(resolved)
|
|
45
|
+
atomic_json(home() / "projects.json", projects)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def launch(profile, args, executable=None, current_window=False):
|
|
50
|
+
executable = shutil.which(executable or PROFILES[profile])
|
|
51
|
+
if not executable:
|
|
52
|
+
raise RuntimeError("Harness executable not found; install it or pass --executable")
|
|
53
|
+
if not shutil.which("node"):
|
|
54
|
+
raise RuntimeError("Node.js 20+ must be on PATH")
|
|
55
|
+
key = str(uuid.uuid4())
|
|
56
|
+
spec = {
|
|
57
|
+
"id": key,
|
|
58
|
+
"profile": profile,
|
|
59
|
+
"executable": executable,
|
|
60
|
+
"cwd": os.getcwd(),
|
|
61
|
+
"args": args,
|
|
62
|
+
"windowToken": "" if current_window or os.name != "nt" else f"AgentDeck [{key}]",
|
|
63
|
+
}
|
|
64
|
+
if os.name != "nt" or current_window:
|
|
65
|
+
return worker(spec)
|
|
66
|
+
wt = shutil.which("wt.exe")
|
|
67
|
+
if not wt:
|
|
68
|
+
raise RuntimeError("Windows Terminal required; use --current-window for status-only testing")
|
|
69
|
+
file = home() / "launches" / (key + ".harness.json")
|
|
70
|
+
atomic_json(file, spec)
|
|
71
|
+
try:
|
|
72
|
+
terminal_title = spec["windowToken"] + (" launcher" if profile == "copilot-vscode" else "")
|
|
73
|
+
subprocess.Popen(
|
|
74
|
+
[
|
|
75
|
+
wt,
|
|
76
|
+
"-w",
|
|
77
|
+
key,
|
|
78
|
+
"new-tab",
|
|
79
|
+
"--title",
|
|
80
|
+
terminal_title,
|
|
81
|
+
"--suppressApplicationTitle",
|
|
82
|
+
"--inheritEnvironment",
|
|
83
|
+
sys.executable,
|
|
84
|
+
"-m",
|
|
85
|
+
"ocdeck",
|
|
86
|
+
"harness-worker",
|
|
87
|
+
str(file),
|
|
88
|
+
]
|
|
89
|
+
)
|
|
90
|
+
except Exception:
|
|
91
|
+
file.unlink(missing_ok=True)
|
|
92
|
+
raise
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def worker(spec):
|
|
97
|
+
spec_file = None
|
|
98
|
+
if not isinstance(spec, dict):
|
|
99
|
+
spec_file = Path(spec)
|
|
100
|
+
spec = read_json(spec_file)
|
|
101
|
+
if not isinstance(spec, dict):
|
|
102
|
+
raise ValueError("Invalid managed launch specification")
|
|
103
|
+
node = shutil.which("node")
|
|
104
|
+
if not node:
|
|
105
|
+
raise RuntimeError("Install Node.js 20+ and restart the terminal")
|
|
106
|
+
root = home().resolve()
|
|
107
|
+
directory = root / "launches" / (spec["id"] + ".hooks")
|
|
108
|
+
directory.mkdir(parents=True, mode=0o700)
|
|
109
|
+
binding, descriptor = directory / "binding.json", directory / "hook.json"
|
|
110
|
+
reg = {
|
|
111
|
+
"id": spec["id"],
|
|
112
|
+
"process": identity(),
|
|
113
|
+
"windowToken": spec["windowToken"],
|
|
114
|
+
"harness": spec["profile"],
|
|
115
|
+
"label": spec["profile"] + ":" + Path(spec["cwd"]).name,
|
|
116
|
+
"managed": True,
|
|
117
|
+
}
|
|
118
|
+
atomic_json(binding, reg)
|
|
119
|
+
env = dict(os.environ, OCDECK_HOME=str(root), AGENTDECK_HOOK_BINDING=str(descriptor))
|
|
120
|
+
# Do not let nested OpenCode instances claim an unrelated managed launch.
|
|
121
|
+
env.pop("OCDECK_BINDING", None)
|
|
122
|
+
bridge = child = None
|
|
123
|
+
try:
|
|
124
|
+
bridge = subprocess.Popen(
|
|
125
|
+
[
|
|
126
|
+
node,
|
|
127
|
+
str(SOURCE / "plugins/harnesses/bridge.mjs"),
|
|
128
|
+
"--worker",
|
|
129
|
+
spec["profile"],
|
|
130
|
+
str(binding),
|
|
131
|
+
str(descriptor),
|
|
132
|
+
],
|
|
133
|
+
env=env,
|
|
134
|
+
stdin=subprocess.PIPE,
|
|
135
|
+
stdout=subprocess.DEVNULL,
|
|
136
|
+
)
|
|
137
|
+
deadline = time.monotonic() + 5
|
|
138
|
+
while not descriptor.exists():
|
|
139
|
+
if bridge.poll() is not None or time.monotonic() > deadline:
|
|
140
|
+
raise RuntimeError("Hook bridge did not start")
|
|
141
|
+
time.sleep(0.025)
|
|
142
|
+
command = [spec["executable"], *spec["args"]]
|
|
143
|
+
if spec["profile"] == "copilot-vscode":
|
|
144
|
+
# A separate VS Code application instance is required for environment inheritance.
|
|
145
|
+
# Unique data avoids silently reusing an existing editor with a stale binding.
|
|
146
|
+
editor_key = spec["id"]
|
|
147
|
+
editor_data = root / "editors" / editor_key
|
|
148
|
+
if spec["windowToken"]:
|
|
149
|
+
atomic_json(editor_data / "User/settings.json", {"window.title": spec["windowToken"]})
|
|
150
|
+
command += ["--new-window", "--wait", "--user-data-dir", str(editor_data), spec["cwd"]]
|
|
151
|
+
# Native executables use CreateProcess argv quoting directly. Legacy
|
|
152
|
+
# PowerShell argument binding drops embedded quotes on Windows.
|
|
153
|
+
if os.name == "nt" and Path(command[0]).suffix.lower() in (".bat", ".cmd", ".ps1"):
|
|
154
|
+
launch_file = directory / "command.json"
|
|
155
|
+
atomic_json(launch_file, {"executable": command[0], "args": command[1:], "cwd": spec["cwd"]})
|
|
156
|
+
command = [
|
|
157
|
+
"powershell.exe",
|
|
158
|
+
"-NoLogo",
|
|
159
|
+
"-NoProfile",
|
|
160
|
+
"-ExecutionPolicy",
|
|
161
|
+
"Bypass",
|
|
162
|
+
"-File",
|
|
163
|
+
str(SOURCE / "scripts/Run-OpenCode.ps1"),
|
|
164
|
+
"-LaunchFile",
|
|
165
|
+
str(launch_file),
|
|
166
|
+
]
|
|
167
|
+
child = subprocess.Popen(command, cwd=spec["cwd"], env=env)
|
|
168
|
+
warned = False
|
|
169
|
+
while child.poll() is None:
|
|
170
|
+
if bridge.poll() is not None and not warned:
|
|
171
|
+
print(
|
|
172
|
+
"AgentStreamDeck bridge exited; status will become unknown. Restart this launch to reconnect.",
|
|
173
|
+
file=sys.stderr,
|
|
174
|
+
)
|
|
175
|
+
warned = True
|
|
176
|
+
try:
|
|
177
|
+
time.sleep(0.2)
|
|
178
|
+
except KeyboardInterrupt:
|
|
179
|
+
pass # Child shares console and receives Ctrl+C; keep watching until it exits.
|
|
180
|
+
return child.returncode
|
|
181
|
+
finally:
|
|
182
|
+
if child and child.poll() is None:
|
|
183
|
+
child.terminate()
|
|
184
|
+
try:
|
|
185
|
+
child.wait(timeout=3)
|
|
186
|
+
except subprocess.TimeoutExpired:
|
|
187
|
+
child.kill()
|
|
188
|
+
child.wait()
|
|
189
|
+
if bridge:
|
|
190
|
+
if bridge.stdin:
|
|
191
|
+
bridge.stdin.close()
|
|
192
|
+
try:
|
|
193
|
+
bridge.wait(timeout=4)
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
bridge.kill()
|
|
196
|
+
bridge.wait()
|
|
197
|
+
try:
|
|
198
|
+
request("DELETE", "/v1/instances/" + reg["id"], root=root)
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
202
|
+
if spec_file:
|
|
203
|
+
spec_file.unlink(missing_ok=True)
|