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/launcher.py ADDED
@@ -0,0 +1,203 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ import uuid
9
+ from .common import home, atomic_json, read_json, identity, request
10
+
11
+
12
+ def route(args):
13
+ """Global opencode shim: wrap interactive launches; pass CLI utilities through."""
14
+ commands = {
15
+ "run",
16
+ "serve",
17
+ "web",
18
+ "acp",
19
+ "auth",
20
+ "models",
21
+ "upgrade",
22
+ "uninstall",
23
+ "mcp",
24
+ "session",
25
+ "export",
26
+ "import",
27
+ "github",
28
+ "pr",
29
+ "stats",
30
+ "debug",
31
+ "agent",
32
+ "completion",
33
+ "db",
34
+ "plugin",
35
+ }
36
+ passthrough = any(a in ("--help", "-h", "--version", "-v") for a in args) or (args and args[0] in commands)
37
+ # A HomeAILab BAT may resolve the global shim again inside our worker.
38
+ # Keep its existing managed identity instead of spawning a second window.
39
+ passthrough = passthrough or bool(os.environ.get("OCDECK_BINDING"))
40
+ if not passthrough:
41
+ launch(args)
42
+ return 0
43
+ root = home()
44
+ install = read_json(root / "install.json")
45
+ if not isinstance(install, dict):
46
+ raise RuntimeError("Run scripts/Install.ps1 first")
47
+ spec = root / "launches" / (str(uuid.uuid4()) + ".json")
48
+ atomic_json(spec, {"cwd": os.getcwd(), "args": args, "executable": install["opencode"]})
49
+ try:
50
+ return subprocess.call(
51
+ [
52
+ "powershell.exe",
53
+ "-NoLogo",
54
+ "-NoProfile",
55
+ "-ExecutionPolicy",
56
+ "Bypass",
57
+ "-File",
58
+ str(Path(install["source"]) / "scripts" / "Run-OpenCode.ps1"),
59
+ "-LaunchFile",
60
+ str(spec),
61
+ ]
62
+ )
63
+ finally:
64
+ spec.unlink(missing_ok=True)
65
+
66
+
67
+ def launch(args, cwd=None, executable=None):
68
+ if os.name != "nt":
69
+ raise RuntimeError("Managed launcher requires Windows; see docs/REMOTE-AND-WSL.md")
70
+ root = home()
71
+ install = read_json(root / "install.json")
72
+ if not isinstance(install, dict):
73
+ raise RuntimeError("Run scripts/Install.ps1 first")
74
+ if not install:
75
+ raise RuntimeError("Run scripts/Install.ps1 first")
76
+ executable = shutil.which(executable) if executable else install["opencode"]
77
+ if not executable:
78
+ raise RuntimeError("Launcher executable not found")
79
+ wt = shutil.which("wt.exe")
80
+ if not wt:
81
+ raise RuntimeError("Windows Terminal (wt.exe) is required for managed windows")
82
+ key = str(uuid.uuid4())
83
+ spec = root / "launches" / (key + ".json")
84
+ token = "OpenCode [" + key + "]"
85
+ atomic_json(
86
+ spec, {"id": key, "args": args, "cwd": cwd or os.getcwd(), "windowToken": token, "executable": executable}
87
+ )
88
+ subprocess.Popen(
89
+ [
90
+ wt,
91
+ "-w",
92
+ key,
93
+ "new-tab",
94
+ "--title",
95
+ token,
96
+ "--suppressApplicationTitle",
97
+ "--inheritEnvironment",
98
+ sys.executable,
99
+ "-m",
100
+ "ocdeck",
101
+ "worker",
102
+ str(spec),
103
+ ],
104
+ close_fds=True,
105
+ )
106
+
107
+
108
+ def worker(spec_path):
109
+ root = home()
110
+ spec_path = Path(spec_path)
111
+ spec = read_json(spec_path)
112
+ if not isinstance(spec, dict):
113
+ raise ValueError("Invalid managed launch specification")
114
+ install = read_json(root / "install.json")
115
+ if not isinstance(install, dict):
116
+ raise RuntimeError("Run scripts/Install.ps1 first")
117
+ binding_path = spec_path.with_suffix(".binding.json")
118
+ reg = {
119
+ "id": spec["id"],
120
+ "process": identity(),
121
+ "windowToken": spec["windowToken"],
122
+ "label": Path(spec["cwd"]).name or "OpenCode",
123
+ "managed": True,
124
+ }
125
+ atomic_json(binding_path, reg)
126
+ env = dict(os.environ, OCDECK_HOME=str(root), OCDECK_BINDING=str(binding_path))
127
+ # Arguments are read from JSON by PowerShell, never interpolated into script text.
128
+ spec.setdefault("executable", install["opencode"])
129
+ atomic_json(spec_path, spec)
130
+ process = subprocess.Popen(
131
+ [
132
+ "powershell.exe",
133
+ "-NoLogo",
134
+ "-NoProfile",
135
+ "-ExecutionPolicy",
136
+ "Bypass",
137
+ "-File",
138
+ str(Path(install["source"]) / "scripts" / "Run-OpenCode.ps1"),
139
+ "-LaunchFile",
140
+ str(spec_path),
141
+ ],
142
+ cwd=spec["cwd"],
143
+ env=env,
144
+ )
145
+ try:
146
+ while process.poll() is None:
147
+ try:
148
+ request("POST", "/v1/register", reg)
149
+ except Exception:
150
+ pass # OpenCode is independent of device availability.
151
+ time.sleep(0.5)
152
+ return process.returncode
153
+ finally:
154
+ try:
155
+ request("DELETE", "/v1/instances/" + reg["id"])
156
+ except Exception:
157
+ pass
158
+ for p in (spec_path, binding_path, Path(str(binding_path) + ".claim")):
159
+ p.unlink(missing_ok=True)
160
+
161
+
162
+ def install_plugin(mode="server", config_dir=None):
163
+ root = home()
164
+ install = read_json(root / "install.json")
165
+ if not isinstance(install, dict):
166
+ raise RuntimeError("Run scripts/Install.ps1 first")
167
+ config = Path(
168
+ config_dir
169
+ or os.environ.get("OPENCODE_CONFIG_DIR")
170
+ or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "opencode"
171
+ )
172
+ source = Path(install["source"]) / "plugins"
173
+ entry = config / "plugins" / "ocdeck.js"
174
+ marker = "// Managed by OpenCode Deck installer\n"
175
+ entry.parent.mkdir(parents=True, exist_ok=True)
176
+ if mode == "server":
177
+ if entry.exists() and not entry.read_text(encoding="utf-8").startswith(marker):
178
+ raise RuntimeError("Refusing to replace an unrelated ocdeck.js")
179
+ entry.write_text(
180
+ marker + "export { DeckBridge } from " + json.dumps((source / "server.mjs").as_uri()) + ";\n",
181
+ encoding="utf-8",
182
+ )
183
+ else:
184
+ # JSONC can contain comments; do not silently destroy a user's configuration.
185
+ if (config / "tui.jsonc").exists():
186
+ raise RuntimeError("tui.jsonc exists: follow docs to merge the TUI plugin entry manually")
187
+ tui = config / "tui.json"
188
+ try:
189
+ value = json.loads(tui.read_text(encoding="utf-8-sig")) if tui.exists() else {}
190
+ except ValueError:
191
+ raise RuntimeError("tui.json contains JSONC: merge the plugin entry manually")
192
+ uri = (source / "tui.mjs").as_uri()
193
+ plugins = value.setdefault("plugin", [])
194
+ if uri not in plugins:
195
+ plugins.append(uri)
196
+ if tui.exists():
197
+ shutil.copy2(tui, tui.with_suffix(".json.ocdeck-backup"))
198
+ atomic_json(tui, value)
199
+ if entry.exists() and entry.read_text(encoding="utf-8").startswith(marker):
200
+ entry.unlink()
201
+ install["pluginMode"], install["configDir"] = mode, str(config)
202
+ atomic_json(root / "install.json", install)
203
+ print(f"Installed {mode} adapter globally in {config}. Restart OpenCode instances.")
ocdeck/model.py ADDED
@@ -0,0 +1,177 @@
1
+ """Serialized registry. Color is a projection; button presses never mutate it."""
2
+
3
+ import copy
4
+ import threading
5
+ import time
6
+ import uuid
7
+ from .security import scrub_text
8
+
9
+
10
+ class Registry:
11
+ def __init__(self, probe, clock=time.monotonic, stale_after=10, slots=6, secrets=()):
12
+ self.probe, self.clock, self.stale_after = probe, clock, stale_after
13
+ self.secrets = secrets
14
+ if slots not in (6, 15, 32):
15
+ raise ValueError("slots must be 6, 15 or 32")
16
+ self.lock = threading.RLock()
17
+ self.records = {}
18
+ self.slots: list[str | None] = [None] * slots
19
+ self.generations = [0] * slots
20
+ self.epoch = str(uuid.uuid4())
21
+
22
+ def resize(self, count):
23
+ if count not in (6, 15, 32):
24
+ raise ValueError("slots must be 6, 15 or 32")
25
+ with self.lock:
26
+ if count == len(self.slots):
27
+ return
28
+ old_generations = self.generations
29
+ self.slots = [None] * count
30
+ self.generations = [(old_generations[i] if i < len(old_generations) else 0) + 1 for i in range(count)]
31
+ for i, record in enumerate(self.records.values()):
32
+ record["slot"] = i if i < count else None
33
+ if i < count:
34
+ self.slots[i] = record["id"]
35
+
36
+ def upsert(self, data):
37
+ key = data.get("id", "")
38
+ if not isinstance(key, str) or not 1 <= len(key) <= 128:
39
+ raise ValueError("id must be 1..128 characters")
40
+ with self.lock:
41
+ record = self.records.get(key)
42
+ if record is None:
43
+ process = data.get("process")
44
+ if not isinstance(process, dict) or self.probe(process) is not True:
45
+ raise ValueError("process identity is not live on this host")
46
+ slot = next((i for i, value in enumerate(self.slots) if value is None), None)
47
+ if slot is not None:
48
+ self.generations[slot] += 1
49
+ self.slots[slot] = key
50
+ record = {
51
+ "id": key,
52
+ "process": process,
53
+ "slot": slot,
54
+ "label": scrub_text(str(data.get("label", "OpenCode")), self.secrets)[:100],
55
+ "harness": scrub_text(str(data.get("harness", "")), self.secrets)[:40],
56
+ "windowToken": str(data.get("windowToken", ""))[:128],
57
+ "seq": -1,
58
+ "producer": None,
59
+ "retired": [],
60
+ "status": "unknown",
61
+ "pending": 0,
62
+ "lastState": 0,
63
+ "lastSeen": self.clock(),
64
+ "detail": "Connecting",
65
+ }
66
+ self.records[key] = record
67
+ elif data.get("process") != record["process"]:
68
+ raise ValueError("instance process identity cannot change")
69
+ record["lastSeen"] = self.clock()
70
+ return copy.deepcopy(record)
71
+
72
+ def snapshot(self, key, data):
73
+ status = data.get("status")
74
+ if status not in ("idle", "busy", "retry", "unknown"):
75
+ raise ValueError("invalid status")
76
+ seq, producer = data.get("seq"), data.get("producer")
77
+ if not isinstance(seq, int) or seq < 0 or not isinstance(producer, str) or not producer or len(producer) > 128:
78
+ raise ValueError("invalid sequence/producer")
79
+ pending = data.get("pending", 0)
80
+ if type(pending) is not int or not 0 <= pending <= 10000:
81
+ raise ValueError("invalid pending count")
82
+ requests = data.get("requestIds", [])
83
+ if (
84
+ not isinstance(requests, list)
85
+ or len(requests) > 10000
86
+ or any(
87
+ not isinstance(x, str) or len(x) != 64 or any(c not in "0123456789abcdef" for c in x) for x in requests
88
+ )
89
+ ):
90
+ raise ValueError("requestIds must contain SHA256 metadata identities")
91
+ waiting = data.get("inputNeeded", False)
92
+ known = data.get("pendingKnown", True)
93
+ if type(waiting) is not bool or type(known) is not bool:
94
+ raise ValueError("inputNeeded/pendingKnown must be boolean")
95
+ with self.lock:
96
+ r = self.records[key]
97
+ if producer in r["retired"]:
98
+ return False
99
+ if r["producer"] != producer:
100
+ if r["producer"]:
101
+ r["retired"].append(r["producer"])
102
+ r["producer"], r["seq"] = producer, -1
103
+ if seq <= r["seq"]:
104
+ return False
105
+ r.update(
106
+ seq=seq,
107
+ status=status,
108
+ pending=pending,
109
+ inputNeeded=waiting,
110
+ pendingKnown=known,
111
+ requestIds=list(set(requests)),
112
+ detail=scrub_text(str(data.get("detail", "")), self.secrets)[:200],
113
+ lastState=self.clock(),
114
+ lastSeen=self.clock(),
115
+ )
116
+ return True
117
+
118
+ def remove(self, key):
119
+ with self.lock:
120
+ r = self.records.pop(key, None)
121
+ if r and r["slot"] is not None:
122
+ slot = r["slot"]
123
+ self.slots[slot] = None
124
+ self.generations[slot] += 1
125
+ # Overflow agents take a freed slot in registration order.
126
+ for candidate in self.records.values():
127
+ if candidate["slot"] is None and None in self.slots:
128
+ slot = self.slots.index(None)
129
+ self.generations[slot] += 1
130
+ self.slots[slot], candidate["slot"] = candidate["id"], slot
131
+
132
+ def sweep(self):
133
+ with self.lock:
134
+ for key, record in list(self.records.items()):
135
+ if self.probe(record["process"]) is False:
136
+ self.remove(key)
137
+
138
+ def view(self):
139
+ with self.lock:
140
+ result = []
141
+ for slot, key in enumerate(self.slots):
142
+ r = self.records.get(key)
143
+ state = "off"
144
+ if r:
145
+ if self.clock() - r["lastState"] > self.stale_after or r["status"] == "unknown":
146
+ state = "unknown"
147
+ elif r["pending"] or r.get("inputNeeded"):
148
+ state = "input"
149
+ elif r["status"] in ("busy", "retry"):
150
+ state = "running"
151
+ else:
152
+ state = "idle"
153
+ result.append(
154
+ {
155
+ "slot": slot,
156
+ "generation": self.generations[slot],
157
+ "id": key,
158
+ "state": state,
159
+ "label": r["label"] if r else "",
160
+ "detail": r["detail"] if r else "",
161
+ "harness": r["harness"] if r else "",
162
+ "pending": r["pending"] if r and r.get("pendingKnown", True) else None,
163
+ "requestIds": r.get("requestIds", []) if r else [],
164
+ }
165
+ )
166
+ return result
167
+
168
+ def resolve(self, slot, generation, key):
169
+ with self.lock:
170
+ if type(slot) is not int or not 0 <= slot < len(self.slots):
171
+ return None
172
+ if self.slots[slot] != key or self.generations[slot] != generation:
173
+ return None
174
+ r = self.records.get(key)
175
+ if not r or self.probe(r["process"]) is not True:
176
+ return None
177
+ return copy.deepcopy(r)
@@ -0,0 +1,57 @@
1
+ """Bounded JSON logs with request correlation and final-output redaction."""
2
+
3
+ from contextvars import ContextVar
4
+ import json
5
+ import logging
6
+ from logging.handlers import RotatingFileHandler
7
+ from pathlib import Path
8
+ from collections import deque
9
+ from .security import scrub
10
+
11
+ correlation = ContextVar("correlation", default="background")
12
+
13
+
14
+ class JsonFormatter(logging.Formatter):
15
+ def __init__(self, secrets=()):
16
+ super().__init__()
17
+ self.secrets = secrets
18
+
19
+ def format(self, record):
20
+ value = {
21
+ "time": self.formatTime(record),
22
+ "level": record.levelname,
23
+ "logger": record.name,
24
+ "correlationId": correlation.get(),
25
+ "message": record.getMessage(),
26
+ }
27
+ if record.exc_info:
28
+ value["exception"] = self.formatException(record.exc_info)
29
+ return json.dumps(scrub(value, self.secrets), ensure_ascii=True)
30
+
31
+
32
+ def configure(root):
33
+ root = Path(root)
34
+ try:
35
+ token = (root / "token").read_text().strip()
36
+ except OSError:
37
+ token = ""
38
+ handler = RotatingFileHandler(root / "broker.log", maxBytes=2_000_000, backupCount=3, encoding="utf-8")
39
+ handler.setFormatter(JsonFormatter((token,)))
40
+ logging.basicConfig(level=logging.INFO, handlers=[handler], force=True)
41
+
42
+
43
+ def tail(root, count=100, errors_only=False):
44
+ count = max(0, min(2000, count))
45
+ rows = deque(maxlen=count)
46
+ try:
47
+ with (Path(root) / "broker.log").open(encoding="utf-8", errors="replace") as stream:
48
+ for line in stream:
49
+ if not errors_only or any(x in line for x in ('"WARNING"', '"ERROR"', '"CRITICAL"')):
50
+ rows.append(line[:8192].rstrip())
51
+ except OSError:
52
+ pass
53
+ try:
54
+ token = (Path(root) / "token").read_text().strip()
55
+ except OSError:
56
+ token = ""
57
+ return scrub(list(rows), (token,))
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ export class Facts {
7
+ constructor() { this.sessions = new Map(); this.trusted = true; this.detail = ''; }
8
+ session(id) {
9
+ if (!this.sessions.has(id)) this.sessions.set(id, {status: 'idle', permissions: new Set(), questions: new Set()});
10
+ return this.sessions.get(id);
11
+ }
12
+ event(event) {
13
+ const p = event.properties || {};
14
+ const id = p.sessionID || p.info?.id;
15
+ if (!id) return;
16
+ const s = this.session(id);
17
+ switch (event.type) {
18
+ case 'session.status': s.status = p.status?.type || 'unknown'; break;
19
+ case 'session.idle': s.status = 'idle'; break;
20
+ case 'permission.asked': s.permissions.add(p.id); break;
21
+ case 'permission.replied': s.permissions.delete(p.requestID); break;
22
+ case 'question.asked': s.questions.add(p.id); break;
23
+ case 'question.replied': case 'question.rejected': s.questions.delete(p.requestID); break;
24
+ case 'session.deleted': this.sessions.delete(id); break;
25
+ case 'session.error': this.detail = 'Session error; inspect terminal'; break;
26
+ }
27
+ }
28
+ snapshot() {
29
+ let pending = 0, status = 'idle';
30
+ for (const s of this.sessions.values()) {
31
+ pending += s.permissions.size + s.questions.size;
32
+ if (s.status === 'unknown') status = 'unknown';
33
+ else if (status !== 'unknown' && ['busy', 'retry'].includes(s.status)) status = 'busy';
34
+ }
35
+ const requestIds = [];
36
+ for (const [session, s] of this.sessions) {
37
+ for (const kind of ['permissions','questions']) for (const id of s[kind]) {
38
+ if (typeof id === 'string') requestIds.push(crypto.createHash('sha256').update(JSON.stringify([session,kind,id])).digest('hex'));
39
+ }
40
+ }
41
+ return {status: this.trusted ? status : 'unknown', pending, detail: this.detail, pendingKnown:true, requestIds};
42
+ }
43
+ }
44
+
45
+ export class Bridge {
46
+ constructor({root, registration, readSnapshot, onError = () => {}}) {
47
+ this.root = root || process.env.OCDECK_HOME || path.join(os.homedir(), '.opencode-deck');
48
+ this.registration = registration;
49
+ this.readSnapshot = readSnapshot;
50
+ this.onError = onError;
51
+ this.producer = crypto.randomUUID(); this.seq = 0;
52
+ this.closed = false; this.busy = false; this.dirty = false; this.timer = null;
53
+ }
54
+ async call(method, route, body) {
55
+ const discovery = JSON.parse(await fs.readFile(path.join(this.root, 'discovery.json'), 'utf8'));
56
+ const token = (await fs.readFile(path.join(this.root, 'token'), 'utf8')).trim();
57
+ if (!Number.isInteger(discovery.port) || discovery.port < 1 || discovery.port > 65535) throw Error('Bad discovery port');
58
+ const response = await fetch(`http://127.0.0.1:${discovery.port}${route}`, {
59
+ method, headers: {Authorization: `Bearer ${token}`, 'Content-Type': 'application/json'},
60
+ body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(1200)
61
+ });
62
+ if (!response.ok) throw Error(`Broker HTTP ${response.status}`);
63
+ return response.json();
64
+ }
65
+ start() {
66
+ this.timer = setInterval(() => void this.flush(), 2000);
67
+ this.timer.unref?.();
68
+ void this.flush();
69
+ }
70
+ async flush() {
71
+ if (this.closed) return;
72
+ if (this.busy) { this.dirty = true; return; }
73
+ this.busy = true;
74
+ try {
75
+ await this.call('POST', '/v1/register', this.registration);
76
+ const value = await this.readSnapshot();
77
+ if (!this.closed) await this.call('PUT', `/v1/instances/${encodeURIComponent(this.registration.id)}`,
78
+ {...value, producer: this.producer, seq: ++this.seq});
79
+ } catch (e) { this.onError(String(e)); }
80
+ finally {
81
+ this.busy = false;
82
+ if (this.dirty && !this.closed) {
83
+ this.dirty = false;
84
+ setTimeout(() => void this.flush(), 0);
85
+ }
86
+ }
87
+ }
88
+ async close() {
89
+ this.closed = true; clearInterval(this.timer);
90
+ // For wrapper-managed instances the process supervisor owns removal.
91
+ if (!this.registration.managed) {
92
+ try { await this.call('DELETE', `/v1/instances/${encodeURIComponent(this.registration.id)}`); } catch {}
93
+ }
94
+ }
95
+ }
96
+
97
+ export async function registration() {
98
+ // Launcher writes identity using psutil's exact process creation timestamp.
99
+ const root = process.env.OCDECK_HOME || path.join(os.homedir(), '.opencode-deck');
100
+ if (process.env.OCDECK_BINDING) {
101
+ const r = JSON.parse(await fs.readFile(process.env.OCDECK_BINDING, 'utf8'));
102
+ // First OpenCode runtime claims this launch. Nested headless OpenCode jobs
103
+ // inherit environment variables but cannot claim the parent's button.
104
+ const claim = process.env.OCDECK_BINDING + '.claim';
105
+ try {
106
+ const f = await fs.open(claim, 'wx');
107
+ await f.writeFile(String(process.pid)); await f.close();
108
+ } catch (e) {
109
+ if (e.code !== 'EEXIST') throw e;
110
+ const owner = Number(await fs.readFile(claim, 'utf8'));
111
+ if (owner !== process.pid) return null;
112
+ }
113
+ return {...r, managed: true};
114
+ }
115
+ // Outside a managed launch, use the Python helper solely for exact PID identity.
116
+ const install = JSON.parse((await fs.readFile(path.join(root, 'install.json'), 'utf8')).replace(/^\uFEFF/, ''));
117
+ const {execFile} = await import('node:child_process');
118
+ const info = await new Promise((resolve, reject) => execFile(install.python,
119
+ ['-m', 'ocdeck', 'identity', String(process.pid)], {timeout: 2500, windowsHide: true},
120
+ (error, stdout) => error ? reject(error) : resolve(JSON.parse(stdout))));
121
+ return {id: crypto.randomUUID(), process: info, label: path.basename(process.cwd()) || 'OpenCode', windowToken: ''};
122
+ }
@@ -0,0 +1,52 @@
1
+ // One persistent producer per managed launch, all descendant sessions share its slot.
2
+ import fs from 'node:fs/promises';
3
+ import http from 'node:http';
4
+ import crypto from 'node:crypto';
5
+ import {Bridge} from '../core.mjs';
6
+ import {HookFacts, profiles} from './profiles.mjs';
7
+
8
+ export async function serve({profile, registration, descriptor, root}) {
9
+ const facts = new HookFacts(profile);
10
+ const token = crypto.randomBytes(32).toString('hex');
11
+ const bridge = new Bridge({root, registration, readSnapshot:async () => {
12
+ try { await fs.access(descriptor + '.failed'); facts.broken = true; } catch {}
13
+ return facts.snapshot();
14
+ }});
15
+ const server = http.createServer(async (req, res) => {
16
+ const reply = code => { res.writeHead(code); res.end(); };
17
+ if (req.headers.origin || req.headers.authorization !== `Bearer ${token}`) return reply(403);
18
+ if (req.method !== 'POST' || req.url !== '/event') return reply(404);
19
+ try {
20
+ let raw = '', size = 0;
21
+ for await (const c of req) { size += c.length; if (size > 8192) return reply(413); raw += c; }
22
+ const e = JSON.parse(raw);
23
+ if (!e || Array.isArray(e) || typeof e.session !== 'string' || !e.session || e.session.length > 512 ||
24
+ !Object.hasOwn(profiles[profile].events, e.event)) return reply(400);
25
+ facts.event(e); reply(204); void bridge.flush();
26
+ } catch { reply(400); }
27
+ });
28
+ server.requestTimeout = 2000;
29
+ server.headersTimeout = 2000;
30
+ server.on('connection', socket => socket.setTimeout(2000, () => socket.destroy()));
31
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
32
+ try {
33
+ await fs.writeFile(descriptor + '.tmp', JSON.stringify({profile, port:server.address().port, token}), {mode:0o600});
34
+ await fs.rename(descriptor + '.tmp', descriptor);
35
+ } catch (error) { server.close(); throw error; }
36
+ bridge.start();
37
+ return {facts, bridge, close: async () => {
38
+ await bridge.close(); server.close(); server.closeAllConnections();
39
+ await fs.rm(descriptor, {force:true}); await fs.rm(descriptor + '.failed', {force:true});
40
+ }};
41
+ }
42
+
43
+ if (process.argv[2] === '--worker') {
44
+ const [profile, binding, descriptor] = process.argv.slice(3);
45
+ const registration = JSON.parse(await fs.readFile(binding, 'utf8'));
46
+ const service = await serve({profile, registration, descriptor});
47
+ let closing = false;
48
+ const close = async () => { if (closing) return; closing = true; await service.close(); process.exit(0); };
49
+ // Parent owns stdin. EOF also cleans up after an abrupt supervisor exit.
50
+ process.stdin.resume(); process.stdin.on('end', close);
51
+ process.on('SIGTERM', close); process.on('SIGINT', close);
52
+ }
@@ -0,0 +1,38 @@
1
+ // Short-lived observer: no approval decisions, no diagnostics on stdout, always exit 0.
2
+ import fs from 'node:fs/promises';
3
+ import {writeFileSync} from 'node:fs';
4
+ import {normalize} from './profiles.mjs';
5
+ const [profile, event] = process.argv.slice(2);
6
+ const descriptor = process.env.AGENTDECK_HOOK_BINDING;
7
+ const timer = setTimeout(() => {
8
+ if (descriptor) { try { writeFileSync(descriptor + '.failed', '1', {mode:0o600}); } catch {} }
9
+ if (profile === 'gemini') process.stdout.write('{}\n');
10
+ process.exit(0);
11
+ }, 1800);
12
+ try {
13
+ if (descriptor) {
14
+ const d = JSON.parse(await fs.readFile(descriptor, 'utf8'));
15
+ if (d.profile === profile && Number.isInteger(d.port) && d.port > 0 && d.port <= 65535) {
16
+ process.stdin.setEncoding('utf8');
17
+ let input = '', size = 0;
18
+ for await (const chunk of process.stdin) {
19
+ size += Buffer.byteLength(chunk);
20
+ if (size > 4 * 1024 * 1024) throw Error('Oversized hook input');
21
+ input += chunk;
22
+ }
23
+ const value = normalize(profile, event, JSON.parse(input));
24
+ if (!value) throw Error('Invalid hook input');
25
+ const response = await fetch(`http://127.0.0.1:${d.port}/event`, {method:'POST',
26
+ headers:{Authorization:`Bearer ${d.token}`, 'Content-Type':'application/json'},
27
+ body:JSON.stringify(value), signal:AbortSignal.timeout(800)});
28
+ if (!response.ok) throw Error('Delivery failed');
29
+ }
30
+ }
31
+ } catch {
32
+ // Persist only a failure marker. A later heartbeat must not make lost events look reliable.
33
+ if (descriptor) { try { await fs.writeFile(descriptor + '.failed', '1', {mode:0o600}); } catch {} }
34
+ } finally {
35
+ clearTimeout(timer);
36
+ if (profile === 'gemini') process.stdout.write('{}\n');
37
+ process.exit(0);
38
+ }