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
@@ -0,0 +1,8 @@
1
+ @echo off
2
+ setlocal
3
+ REM Uses providers in your normal OpenCode config; pass --model provider/model.
4
+ set "OPENCODE_CONFIG="
5
+ set "OPENCODE_CONFIG_CONTENT="
6
+ set "OPENCODE_CONFIG_DIR="
7
+ call "%~dp0..\Launch-Agent.bat" --profile opencode -- %*
8
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,94 @@
1
+ """Regenerate README examples from the actual device renderer (no hardware needed)."""
2
+ from pathlib import Path
3
+ import sys
4
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
5
+ from dataclasses import replace
6
+ from PIL import Image, ImageDraw, ImageFont
7
+ from ocdeck.art import frame
8
+ from ocdeck.appearance import Appearance, THEMES, animation_phase
9
+
10
+ OUT = Path(__file__).resolve().parents[1] / 'docs' / 'visuals'
11
+ OUT.mkdir(parents=True, exist_ok=True)
12
+ BG = '#101620'
13
+ FONT = ImageFont.load_default(size=16)
14
+ SMALL = ImageFont.load_default(size=12)
15
+ BASE = Appearance(layout='harness', theme='aurora', show_slot=False)
16
+ STATES = ['running','idle','input','unknown','ready','off']
17
+ HARNESSES = ['opencode','claude','copilot','copilot-vscode','gemini','cursor']
18
+
19
+
20
+ def panel(title, rows, phase=24):
21
+ """Each row: name and (state, style, harness, label, caption) examples."""
22
+ im = Image.new('RGB', (840, 60 + len(rows)*150), BG)
23
+ d = ImageDraw.Draw(im)
24
+ d.text((22,18), title, font=FONT, fill='white')
25
+ for r, (name, examples) in enumerate(rows):
26
+ y = 60 + r*150
27
+ d.text((22,y+44), name, font=SMALL, fill='#a9b8cb')
28
+ for k, (state, style, harness, label, caption) in enumerate(examples):
29
+ x = 140 + k*114
30
+ im.paste(frame(state,label,k,phase,100,style,harness,'Review changes'),(x,y))
31
+ d.text((x+50,y+113),caption,font=SMALL,fill='#c6d2e3',anchor='mt')
32
+ return im
33
+
34
+
35
+ def save(name, title, rows):
36
+ panel(title,rows).save(OUT / (name+'.png'), optimize=True)
37
+
38
+
39
+ def row(style, states=STATES):
40
+ return [(state,style,HARNESSES[k],'AgentStreamDeck',state.upper()) for k,state in enumerate(states)]
41
+
42
+ save('layouts','THREE LAYOUTS / the same six states',[(name.title(),row(replace(BASE,layout=name))) for name in ['classic','harness','minimal']])
43
+ save('themes','FIVE PALETTES / consistent state labels',[(name.title(),row(replace(BASE,theme=name))) for name in THEMES])
44
+ save('harnesses','SIX ADAPTERS / one visual language',[('Harness',[( 'running',replace(BASE,primary='harness',secondary='status'),h,'AgentStreamDeck',n) for h,n in zip(HARNESSES,['OpenCode','Claude','Copilot CLI','Copilot VS Code','Gemini','Cursor'])])])
45
+ text_options=[('status','project','State + project'),('project','status','Project + state'),('harness','status','Harness + state'),('detail','project','Detail + project'),('custom','status','Custom + state'),('none','none','Icon only')]
46
+ save('labels','CHOOSE WHAT GOES UNDER THE ICON',[('Text lines', [('running',replace(BASE,primary=p,secondary=s,custom_text='Code review'),'claude','HomeAILab',caption) for p,s,caption in text_options])])
47
+ save('brightness','PER-BUTTON DIMMING / rendered pixels, fixed device backlight',[('Brightness',[('running',replace(BASE,brightness=b),'gemini','AgentStreamDeck',f'{int(b*100)}%') for b in [.15,.3,.45,.6,.8,1]])])
48
+ styles=[replace(BASE,theme='classic',primary='project',secondary='status'),replace(BASE,theme='ocean',primary='custom',custom_text='Code review'),replace(BASE,layout='minimal',theme='accessible'),replace(BASE,theme='aurora',primary='harness'),replace(BASE,theme='mono',effect='steady'),replace(BASE,theme='ocean',brightness=.6)]
49
+ save('mixed','MIX AND MATCH / six independent button styles',[('Per key',[(s,a,h,'HomeAILab',f'KEY {k+1}') for k,(s,a,h) in enumerate(zip(['running','input','idle','running','unknown','idle'],styles,HARNESSES))])])
50
+ # Compact, fixed-palette animated strips keep the README lightweight.
51
+ for name, title, specs in [
52
+ ('effects','ANIMATION EFFECTS / same state, different motion', [(e,replace(BASE,layout='classic',effect=e,intensity=1),'input') for e in ['breathe','glow','steady']]),
53
+ ('speeds','PULSE SPEED / slow, standard, fast', [(f'{v}x',replace(BASE,layout='classic',speed=v,intensity=1),'running') for v in [.5,1,2]])]:
54
+ frames=[]
55
+ for tick in range(96):
56
+ im=Image.new('RGB',(510,210),BG); d=ImageDraw.Draw(im)
57
+ d.text((18,15),title,font=SMALL,fill='white')
58
+ for k,(caption,a,state) in enumerate(specs):
59
+ im.paste(frame(state,'AgentStreamDeck',k,animation_phase(tick/24,a),120,a,'claude'),(25+k*165,52))
60
+ d.text((85+k*165,185),caption,font=SMALL,fill='white',anchor='mt')
61
+ frames.append(im)
62
+ palette = frames[0].quantize(colors=64)
63
+ frames = [im.quantize(palette=palette,dither=Image.Dither.NONE) for im in frames]
64
+ frames[0].save(OUT/(name+'.gif'),save_all=True,append_images=frames[1:],duration=[40,40,40,40,40,50]*16,loop=0,optimize=True)
65
+ print('\n'.join(f'{p.name}: {p.stat().st_size:,} bytes' for p in sorted(OUT.iterdir())))
66
+
67
+ # Extended customization examples, also drawn by the shipped renderer.
68
+ from ocdeck.appearance import PRESETS
69
+ save('presets','ONE-COMMAND PRESETS / real harness marks', [('Presets', [
70
+ ('running',Appearance(**{**options,'alias':'My agent'}),h,'AgentStreamDeck',name.title())
71
+ for (name,options),h in zip(PRESETS.items(),HARNESSES)])])
72
+ save('details','SMALL DETAILS / make each key recognizable', [
73
+ ('Badges', [('input',replace(BASE,badge=b),h,'AgentStreamDeck',b.title()) for b,h in zip(['dot','ring','pill'],HARNESSES)]),
74
+ ('Borders', [('running',replace(BASE,border=b),'gemini','AgentStreamDeck',b.title()) for b in ['solid','double','corners','none']]),
75
+ ('Backgrounds', [('idle',replace(BASE,background=b),'claude','AgentStreamDeck',b.title()) for b in ['solid','gradient','grid']]),
76
+ ('Logo size', [('running',replace(BASE,logo_size=b),'cursor','AgentStreamDeck',b.title()) for b in ['small','normal','large']]),
77
+ ])
78
+ save('typography','ALIASES AND TYPOGRAPHY / labels that work for you', [
79
+ ('Alias', [('running',replace(BASE,alias=a,primary='alias',secondary='status'),'claude','AgentStreamDeck',caption) for a,caption in [('Reviewer','Review agent'),('Builder','Build agent'),('Docs','Writing agent')]]),
80
+ ('Text size', [('running',replace(BASE,text_size=b,alias='Builder'),'copilot','AgentStreamDeck',b.title()) for b in ['small','normal','large']]),
81
+ ('Alignment', [('idle',replace(BASE,text_align=b,alias='Docs'),'opencode','AgentStreamDeck',b.title()) for b in ['left','center','right']]),
82
+ ])
83
+ frames=[]
84
+ for tick in range(96):
85
+ im=Image.new('RGB',(510,210),BG);d=ImageDraw.Draw(im)
86
+ d.text((18,15),'TEXT EFFECTS / clipped scrolling and shimmer',font=SMALL,fill='white')
87
+ for k,effect in enumerate(['none','scroll','shimmer']):
88
+ a=replace(BASE,alias='Backend review agent',primary='alias',secondary='status',text_effect=effect,intensity=0)
89
+ im.paste(frame('running','AgentStreamDeck',k,tick,120,a,'claude'),(25+k*165,52))
90
+ d.text((85+k*165,185),effect.upper(),font=SMALL,fill='white',anchor='mt')
91
+ frames.append(im)
92
+ palette=frames[0].quantize(colors=96)
93
+ frames=[im.quantize(palette=palette,dither=Image.Dither.NONE) for im in frames]
94
+ frames[0].save(OUT/'text-effects.gif',save_all=True,append_images=frames[1:],duration=[80,80,80,80,80,100]*16,loop=0,optimize=True)
ocdeck/security.py ADDED
@@ -0,0 +1,38 @@
1
+ """Defense in depth for metadata, diagnostics and logs; never collect prompt bodies."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ REDACTED = "[REDACTED]"
7
+ KEY = re.compile(r"token|secret|password|passwd|credential|authorization|api.?key|cookie|private.?key", re.I)
8
+ PATTERNS = [
9
+ re.compile(
10
+ r"""(?i)(?:[\w.-]*(?:token|secret|password|passwd|credential|api[_-]?key)[\w.-]*)[\s"']*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*')"""
11
+ ),
12
+ re.compile(r"-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----"),
13
+ re.compile(r"\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+", re.I),
14
+ re.compile(
15
+ r"\b(?:sk-[\w-]{8,}|gh[pousr]_[\w]{8,}|github_pat_[\w]{8,}|AKIA[A-Z0-9]{16}|eyJ[\w-]+\.[\w-]+\.[\w-]+)\b"
16
+ ),
17
+ re.compile(
18
+ r"(?i)(?:[\w.-]*(?:token|secret|password|passwd|credential|api[_-]?key)[\w.-]*)[\s\"\']*[:=][\s\"\']*[^\s,;\"\'&}]+"
19
+ ),
20
+ re.compile(r"https?://[^\s/@:]+:[^\s/@]+@"),
21
+ ]
22
+
23
+
24
+ def scrub_text(value: str, secrets: tuple[str, ...] = ()) -> str:
25
+ for secret in secrets:
26
+ if secret:
27
+ value = value.replace(secret, REDACTED)
28
+ for pattern in PATTERNS:
29
+ value = pattern.sub(REDACTED, value)
30
+ return value
31
+
32
+
33
+ def scrub(value: Any, secrets: tuple[str, ...] = ()) -> Any:
34
+ if isinstance(value, dict):
35
+ return {str(k): REDACTED if KEY.search(str(k)) else scrub(v, secrets) for k, v in value.items()}
36
+ if isinstance(value, (list, tuple)):
37
+ return [scrub(v, secrets) for v in value]
38
+ return scrub_text(value, secrets) if isinstance(value, str) else value
ocdeck/settings.py ADDED
@@ -0,0 +1,42 @@
1
+ """Validate optional broker features before starting background workers."""
2
+
3
+ import math
4
+ from .appearance_io import export_settings
5
+
6
+
7
+ def validate_config(config):
8
+ if not isinstance(config, dict):
9
+ raise ValueError("config.json must contain an object")
10
+ export_settings(config)
11
+ if type(config.get("slots", 6)) is not int or config.get("slots", 6) not in (6, 15, 32):
12
+ raise ValueError("slots must be 6, 15 or 32 (mock capacity; physical deck auto-detects)")
13
+ for name in ("check_updates", "allow_elgato", "animations", "ready"):
14
+ if name in config and type(config[name]) is not bool:
15
+ raise ValueError(name + " must be boolean")
16
+ if type(config.get("brightness", 45)) is not int or not 0 <= config.get("brightness", 45) <= 100:
17
+ raise ValueError("brightness must be 0..100")
18
+ alerts = config.get("alerts", {})
19
+ if not isinstance(alerts, dict) or set(alerts) - {
20
+ "sound",
21
+ "toast",
22
+ "sound_file",
23
+ "states",
24
+ "cooldown_seconds",
25
+ "muted_slots",
26
+ }:
27
+ raise ValueError("Unknown or invalid alerts configuration")
28
+ for name in ("sound", "toast"):
29
+ if type(alerts.get(name, False)) is not bool:
30
+ raise ValueError("alerts." + name + " must be boolean")
31
+ states = alerts.get("states", ["input"])
32
+ if not isinstance(states, list) or any(x not in ("input", "running", "idle", "unknown") for x in states):
33
+ raise ValueError("alerts.states must contain input, running, idle or unknown")
34
+ cooldown = alerts.get("cooldown_seconds", 10)
35
+ if type(cooldown) not in (int, float) or not math.isfinite(cooldown) or not 0 <= cooldown <= 3600:
36
+ raise ValueError("alerts.cooldown_seconds must be 0..3600")
37
+ muted = alerts.get("muted_slots", [])
38
+ if not isinstance(muted, list) or any(x not in [str(i) for i in range(1, 33)] for x in muted):
39
+ raise ValueError("alerts.muted_slots must be strings 1..32")
40
+ if "sound_file" in alerts and not isinstance(alerts["sound_file"], str):
41
+ raise ValueError("alerts.sound_file must be a WAV file path")
42
+ return config
ocdeck/uninstall.py ADDED
@@ -0,0 +1,100 @@
1
+ """Receipt-scoped removal; never delete a project or an environment wholesale."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+ from .common import home, read_json, request
10
+ from .harness import install, PROFILES, SOURCE
11
+
12
+
13
+ def discover(roots):
14
+ found = set()
15
+ for root in roots:
16
+ root = Path(root).expanduser().resolve()
17
+ if not root.is_dir():
18
+ continue
19
+ for directory, children, files in os.walk(root, followlinks=False):
20
+ children[:] = [
21
+ c
22
+ for c in children
23
+ if c not in (".git", "node_modules", ".venv", "venv") and not (Path(directory) / c).is_symlink()
24
+ ]
25
+ if Path(directory).name == ".agentdeck":
26
+ if any(f == p + ".json" for p in PROFILES for f in files):
27
+ found.add(str(Path(directory).parent))
28
+ children[:] = []
29
+ return sorted(found)
30
+
31
+
32
+ def uninstall(scan=(), dry_run=False):
33
+ root = home().resolve()
34
+ registered = read_json(root / "projects.json", []) or []
35
+ projects = sorted(set([*registered, *discover(scan or [Path.cwd()])]))
36
+ plans = []
37
+ for project in projects:
38
+ for profile in PROFILES:
39
+ if (Path(project) / ".agentdeck" / (profile + ".json")).exists():
40
+ plans.append((profile, project))
41
+ print(
42
+ json.dumps(
43
+ {
44
+ "projects": plans,
45
+ "local": ["scheduled task", "managed OpenCode plugin", "managed shims", "configuration"],
46
+ "backups": "Retained under " + str(root / "backups"),
47
+ "dryRun": dry_run,
48
+ },
49
+ indent=2,
50
+ )
51
+ )
52
+ # Preflight every receipt before changing anything. The native installer refuses
53
+ # moved targets, malformed JSON and shell metacharacters in installed paths.
54
+ for profile, project in plans:
55
+ if install(profile, project, remove=True, dry_run=True):
56
+ raise RuntimeError("Hook removal preflight failed; no uninstall changes applied")
57
+ if dry_run:
58
+ return 0
59
+ try:
60
+ status = request("GET", "/v1/status")
61
+ except Exception:
62
+ status = {}
63
+ if any(v.get("id") for v in status.get("slots", [])) or status.get("overflow"):
64
+ raise RuntimeError("Close managed agent sessions before uninstalling; run ocdeck status")
65
+ for profile, project in plans:
66
+ if install(profile, project, remove=True):
67
+ raise RuntimeError("Project hook removal failed; local installation retained")
68
+ metadata = read_json(root / "install.json", {}) or {}
69
+ if os.name == "nt":
70
+ script = SOURCE / "scripts" / "Remove-Integration.ps1"
71
+ subprocess.run(["powershell.exe", "-NoProfile", "-File", str(script), "-Data", str(root)], check=True)
72
+ try:
73
+ request("POST", "/v1/stop")
74
+ except Exception:
75
+ pass
76
+ # Give the broker time to release discovery/log files; never move its token live.
77
+ for _ in range(30):
78
+ try:
79
+ request("GET", "/v1/status", timeout=0.2)
80
+ except Exception:
81
+ break
82
+ time.sleep(0.1)
83
+ else:
84
+ raise RuntimeError("Broker did not stop; local files retained. Run ocdeck stop")
85
+ backup = root / "backups" / str(time.time_ns())
86
+ backup.mkdir(parents=True, exist_ok=False)
87
+ for name in ("config.json", "install.json", "projects.json", "update.json", "token", "discovery.json"):
88
+ path = root / name
89
+ if path.exists():
90
+ shutil.move(str(path), str(backup / name))
91
+ # Remove only exact known shims after verifying their runtime/command marker.
92
+ runtime = metadata.get("python")
93
+ for name in ("ocdeck.cmd", "oc.cmd", "opencode.cmd"):
94
+ path = root / "bin" / name
95
+ if path.exists() and runtime:
96
+ text = path.read_text(errors="replace")
97
+ if runtime in text and " -m ocdeck " in text:
98
+ shutil.move(str(path), str(backup / name))
99
+ print("Uninstalled managed hooks, integration and configuration. Backups and Python environment retained.")
100
+ return 0
ocdeck/updates.py ADDED
@@ -0,0 +1,46 @@
1
+ """One bounded, optional release lookup per broker start; never installs software."""
2
+
3
+ import json
4
+ import logging
5
+ import urllib.request
6
+ from packaging.version import Version, InvalidVersion
7
+ from . import __version__
8
+ from .common import read_json, atomic_json
9
+ from .security import scrub
10
+
11
+ URL = "https://api.github.com/repos/darkmatter2222/AgentStreamDeck/releases/latest"
12
+ LOG = logging.getLogger(__name__)
13
+
14
+
15
+ def check(root, config, fetch=None):
16
+ if not config.get("check_updates", True):
17
+ return None
18
+ try:
19
+ if fetch is None:
20
+ req = urllib.request.Request(
21
+ URL, headers={"User-Agent": "AgentStreamDeck", "Accept": "application/vnd.github+json"}
22
+ )
23
+ with urllib.request.urlopen(req, timeout=3) as response:
24
+ raw = response.read(262145)
25
+ if len(raw) > 262144:
26
+ raise ValueError("Release response too large")
27
+ release = json.loads(raw)
28
+ else:
29
+ release = fetch()
30
+ version = str(release["tag_name"])
31
+ if release.get("draft") or release.get("prerelease") or Version(version) <= Version(__version__):
32
+ return None
33
+ info = {
34
+ "version": version,
35
+ "url": "https://github.com/darkmatter2222/AgentStreamDeck/releases",
36
+ "notes": scrub(str(release.get("body", ""))[:8000]),
37
+ }
38
+ state = read_json(root / "update.json", {}) or {}
39
+ if state.get("version") != version:
40
+ LOG.info("Update available: %s — %s", version, info["url"])
41
+ print(f"AgentStreamDeck {version} available: {info['url']}\n{info['notes']}", flush=True)
42
+ atomic_json(root / "update.json", info)
43
+ return info
44
+ except (OSError, ValueError, KeyError, TypeError, InvalidVersion):
45
+ LOG.info("Update check unavailable; continuing offline")
46
+ return None