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.
Files changed (65) hide show
  1. agentstreamdeck-2.1.1.dist-info/METADATA +1013 -0
  2. agentstreamdeck-2.1.1.dist-info/RECORD +65 -0
  3. agentstreamdeck-2.1.1.dist-info/WHEEL +5 -0
  4. agentstreamdeck-2.1.1.dist-info/entry_points.txt +2 -0
  5. agentstreamdeck-2.1.1.dist-info/licenses/LICENSE +201 -0
  6. agentstreamdeck-2.1.1.dist-info/top_level.txt +1 -0
  7. ocdeck/__init__.py +1 -0
  8. ocdeck/__main__.py +307 -0
  9. ocdeck/alerts.py +120 -0
  10. ocdeck/appearance.py +133 -0
  11. ocdeck/appearance_io.py +70 -0
  12. ocdeck/art.py +181 -0
  13. ocdeck/assets/logos/OCTICONS-LICENSE.txt +21 -0
  14. ocdeck/assets/logos/claude.png +0 -0
  15. ocdeck/assets/logos/copilot.png +0 -0
  16. ocdeck/assets/logos/copilot.svg +1 -0
  17. ocdeck/assets/logos/cursor.png +0 -0
  18. ocdeck/assets/logos/gemini.png +0 -0
  19. ocdeck/assets/logos/opencode.png +0 -0
  20. ocdeck/assets/logos/sources.json +27 -0
  21. ocdeck/broker.py +273 -0
  22. ocdeck/common.py +82 -0
  23. ocdeck/device.py +247 -0
  24. ocdeck/diagnostics.py +227 -0
  25. ocdeck/errors.py +22 -0
  26. ocdeck/focus.py +156 -0
  27. ocdeck/hardware_check.py +66 -0
  28. ocdeck/harness.py +203 -0
  29. ocdeck/launcher.py +203 -0
  30. ocdeck/model.py +177 -0
  31. ocdeck/observability.py +57 -0
  32. ocdeck/runtime/plugins/core.mjs +122 -0
  33. ocdeck/runtime/plugins/harnesses/bridge.mjs +52 -0
  34. ocdeck/runtime/plugins/harnesses/hook.mjs +38 -0
  35. ocdeck/runtime/plugins/harnesses/install.mjs +83 -0
  36. ocdeck/runtime/plugins/harnesses/profiles.mjs +107 -0
  37. ocdeck/runtime/plugins/server.mjs +67 -0
  38. ocdeck/runtime/plugins/tui.mjs +44 -0
  39. ocdeck/runtime/scripts/Install-Harness.ps1 +20 -0
  40. ocdeck/runtime/scripts/Install.ps1 +79 -0
  41. ocdeck/runtime/scripts/Launch-Agent.bat +7 -0
  42. ocdeck/runtime/scripts/Launch-Claude.bat +7 -0
  43. ocdeck/runtime/scripts/Launch-Codex.bat +7 -0
  44. ocdeck/runtime/scripts/Launch-Copilot-VSCode.bat +7 -0
  45. ocdeck/runtime/scripts/Launch-Copilot.bat +7 -0
  46. ocdeck/runtime/scripts/Launch-Cursor.bat +7 -0
  47. ocdeck/runtime/scripts/Launch-Gemini.bat +7 -0
  48. ocdeck/runtime/scripts/Remove-Integration.ps1 +35 -0
  49. ocdeck/runtime/scripts/Run-OpenCode.ps1 +7 -0
  50. ocdeck/runtime/scripts/Test.ps1 +11 -0
  51. ocdeck/runtime/scripts/Uninstall.ps1 +11 -0
  52. ocdeck/runtime/scripts/Verify-Windows.ps1 +13 -0
  53. ocdeck/runtime/scripts/check-js.py +8 -0
  54. ocdeck/runtime/scripts/examples/Claude-Cloud.bat +6 -0
  55. ocdeck/runtime/scripts/examples/Claude-Local.bat +21 -0
  56. ocdeck/runtime/scripts/examples/HomeAILab-Claude-5090.bat +8 -0
  57. ocdeck/runtime/scripts/examples/HomeAILab-Claude-Cluster.bat +8 -0
  58. ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-5090.bat +8 -0
  59. ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-Spark.bat +8 -0
  60. ocdeck/runtime/scripts/examples/OpenCode-Cloud.bat +8 -0
  61. ocdeck/runtime/scripts/render-gallery.py +94 -0
  62. ocdeck/security.py +38 -0
  63. ocdeck/settings.py +42 -0
  64. ocdeck/uninstall.py +100 -0
  65. 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
+ }
@@ -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)