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/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)
|
ocdeck/observability.py
ADDED
|
@@ -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
|
+
}
|