witbitz-code 1.2.0__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.
witbitz_code/pair.py ADDED
@@ -0,0 +1,222 @@
1
+ """PAIR this computer with your Spaces account, so the Code section on every device you are signed in on reaches the
2
+ OpenCode running here — through the sealed relay, with nothing typed. The flows of tools/opencode-pair.mjs.
3
+
4
+ What a pairing is (docs/opencode-relay.md §3): a random 32-byte SECRET per (computer, account), stored here in
5
+ ~/.witbitz/code/pairings.json (0600) and published into the account's sealed `computers` registry, which every signed-in
6
+ device reads. The relay channel and both encryption keys derive from it; the relay never sees it.
7
+
8
+ Several accounts can pair the same computer — each is a separate entry, secret and channel (§10). OpenCode has no users,
9
+ so every paired account reaches the same sessions, files and shell; this tool says so when a second account pairs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from collections.abc import Awaitable, Callable
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from . import account as acct
20
+ from . import link, net
21
+ from . import pairings as pp
22
+ from ._js import UNDEFINED, number_str, to_number, truthy
23
+ from .connector import START_HINT
24
+
25
+ LinkFn = Callable[[], Awaitable[Any]]
26
+
27
+
28
+ def _out(msg: str) -> None:
29
+ print(msg, flush=True)
30
+
31
+
32
+ def _err(msg: str) -> None:
33
+ print(msg, file=sys.stderr, flush=True)
34
+
35
+
36
+ class UsageError(Exception):
37
+ pass
38
+
39
+
40
+ @dataclass
41
+ class Args:
42
+ rotate: bool = False
43
+ dry: bool = False
44
+ unpair: bool = False
45
+ status: bool = False
46
+ name: str = ""
47
+ account: str = ""
48
+ opencode_url: str = ""
49
+
50
+
51
+ def parse_args(argv: list[str]) -> Args:
52
+ a = Args()
53
+ i = 0
54
+ while i < len(argv):
55
+ x = argv[i]
56
+ nxt = argv[i + 1] if i + 1 < len(argv) else ""
57
+ if x == "--rotate":
58
+ a.rotate = True
59
+ elif x == "--dry-run":
60
+ a.dry = True
61
+ elif x == "--unpair":
62
+ a.unpair = True
63
+ elif x == "--status":
64
+ a.status = True
65
+ elif x in ("--name", "--account", "--opencode-url", "--port"):
66
+ i += 1
67
+ if x == "--name":
68
+ a.name = nxt
69
+ elif x == "--account":
70
+ a.account = nxt
71
+ elif x == "--opencode-url":
72
+ a.opencode_url = nxt
73
+ else:
74
+ port = to_number(nxt)
75
+ if truthy(port):
76
+ a.opencode_url = f"http://127.0.0.1:{number_str(port)}"
77
+ else:
78
+ raise UsageError(f"witbitz-code: unknown argument {x}")
79
+ i += 1
80
+ return a
81
+
82
+
83
+ def ensure_local_password(out: Callable[[str], Any] = _out) -> None:
84
+ path = pp.env_path()
85
+ try:
86
+ text = path.read_text(encoding="utf-8")
87
+ except FileNotFoundError:
88
+ text = ""
89
+ if pp.env_get(text, "OPENCODE_SERVER_PASSWORD"):
90
+ return
91
+ pp.write_in_place(path, pp.env_set(text, "OPENCODE_SERVER_PASSWORD", pp.new_password()))
92
+ out(f"witbitz-code: ✓ minted a local OpenCode password in {path} (0600) — it never leaves this computer")
93
+
94
+
95
+ async def _publish_entry(call: net.ApiCall, e: dict, out: Callable, err: Callable) -> bool:
96
+ r = await acct.publish_computer(call=call, idx=e["idx"], computer_id=e["computerId"], name=e["name"], relay=e["relay"],
97
+ secret=e["secret"])
98
+ who = e["account"] or "account"
99
+ if not r["ok"]:
100
+ err(f"witbitz-code: ✖ {who}: {r['why']}")
101
+ return False
102
+ out(f"witbitz-code: ✓ \"{e['name']}\" is in {e['account'] or 'the account'}'s computers ({len(r['computers'])} paired)")
103
+ lg = await acct.clear_legacy_direct(call=call, idx=e["idx"])
104
+ if not lg["ok"]:
105
+ out(f"witbitz-code: ⚠ could not clear the old direct-server settings ({lg['why']}) — harmless while a computer is paired")
106
+ elif not lg.get("noop"):
107
+ out("witbitz-code: ✓ cleared the old direct-server address from the account (devices use the relay now)")
108
+ return True
109
+
110
+
111
+ async def _link_by_qr(link_fn: LinkFn | None, out: Callable, err: Callable) -> dict | None:
112
+ """The QR ritual: show the Add-a-device QR and wait for a signed-in device to seal the account to this computer. The
113
+ account stays in memory; an expired or failed scan changes nothing."""
114
+ out("witbitz-code: on your phone open Spaces → Settings → Back up & recovery → Add a device, and scan the QR below "
115
+ "with the account to pair.\n")
116
+ account = await (link_fn() if link_fn else link.link_node())
117
+ if not account:
118
+ return None
119
+ idx = account.get("idx")
120
+ if not isinstance(idx, dict) or not truthy(idx.get("room")) or not truthy(idx.get("mk")):
121
+ err("witbitz-code: the scan carried no index-room pointer, so there is nowhere to publish — nothing changed.")
122
+ return None
123
+ return account
124
+
125
+
126
+ async def main(argv: list[str], *, call: net.ApiCall | None = None, link_fn: LinkFn | None = None,
127
+ out: Callable[[str], Any] = _out, err: Callable[[str], Any] = _err) -> int:
128
+ """pair / --status / --unpair / --rotate. Returns the exit code."""
129
+ try:
130
+ args = parse_args(argv)
131
+ except UsageError as e:
132
+ err(str(e))
133
+ return 2
134
+ client = None
135
+ if call is None and not args.status:
136
+ client = net.new_client()
137
+ call = net.make_api_call(client)
138
+ try:
139
+ return await _run(args, call, link_fn, out, err)
140
+ finally:
141
+ if client is not None:
142
+ await client.aclose()
143
+
144
+
145
+ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err: Callable) -> int:
146
+ doc = pp.read_pairings()
147
+
148
+ if args.status:
149
+ if not doc["pairings"]:
150
+ out("witbitz-code: not paired")
151
+ for p in doc["pairings"]:
152
+ out(f"· \"{p.get('name')}\" → {p.get('account') or '(account)'} · OpenCode {p.get('opencodeUrl')} · relay {p.get('relay')}")
153
+ return 0
154
+
155
+ if args.unpair:
156
+ targets = [p for p in doc["pairings"] if not args.account or p.get("account") == args.account]
157
+ if not targets:
158
+ err(f"witbitz-code: no pairing{' for ' + args.account if args.account else ''} on this computer")
159
+ return 1
160
+ if len(targets) > 1 and not args.account:
161
+ names = ", ".join(str(p.get("account")) for p in targets)
162
+ err(f"witbitz-code: this computer is paired with {len(targets)} accounts ({names}) — pass --account <email>")
163
+ return 1
164
+ if args.dry:
165
+ out(f"witbitz-code: --dry-run — would remove \"{targets[0].get('name')}\" from {targets[0].get('account')}")
166
+ return 0
167
+ ok = True
168
+ for p in targets:
169
+ r = await acct.unpublish_computer(call=call, idx=p["idx"], computer_id=p["computerId"])
170
+ if not r["ok"]:
171
+ ok = False
172
+ err(f"witbitz-code: ✖ {p.get('account')}: {r['why']} — kept the local pairing so you can retry")
173
+ continue
174
+ note = " (it was not listed)" if r.get("noop") else ""
175
+ out(f"witbitz-code: ✓ removed \"{p.get('name')}\" from {p.get('account')}{note} — every device drops it on its next sync")
176
+ pp.write_pairings(pp.remove_pairings(pp.read_pairings(), p.get("account") or "")["doc"])
177
+ out(f"witbitz-code: restart the connector ({START_HINT}) so it stops answering on the old channel.")
178
+ return 0 if ok else 1
179
+
180
+ if args.rotate:
181
+ if not doc["pairings"]:
182
+ err("witbitz-code: nothing to rotate — pair first")
183
+ return 1
184
+ to_rotate = [x for x in doc["pairings"] if not args.account or x.get("account") == args.account]
185
+ if args.dry:
186
+ out(f"witbitz-code: --dry-run — would rotate {len(to_rotate)} pairing(s)")
187
+ return 0
188
+ cur, ok = doc, True
189
+ for p in to_rotate:
190
+ u = pp.upsert_pairing(cur, account=p.get("account") or "", idx=p["idx"], name=p.get("name") or "",
191
+ relay=p.get("relay", UNDEFINED), opencode_url=p.get("opencodeUrl", UNDEFINED), rotate=True)
192
+ if await _publish_entry(call, u["entry"], out, err):
193
+ cur = u["doc"]
194
+ pp.write_pairings(cur)
195
+ else:
196
+ ok = False
197
+ out("witbitz-code: restart the connector so it listens on the new channel(s).")
198
+ return 0 if ok else 1
199
+
200
+ # PAIR — the QR ritual
201
+ if args.dry:
202
+ out("witbitz-code: --dry-run — would show the QR, then publish this computer to the scanning account")
203
+ return 0
204
+ account = await _link_by_qr(link_fn, out, err)
205
+ if not account:
206
+ err("witbitz-code: pairing did not complete — nothing changed.")
207
+ return 1
208
+ u = pp.upsert_pairing(doc, account=account.get("email") or "", idx=account["idx"], name=args.name,
209
+ opencode_url=args.opencode_url or UNDEFINED)
210
+ ensure_local_password(out)
211
+ if not await _publish_entry(call, u["entry"], out, err):
212
+ err("witbitz-code: nothing saved locally — scan again to retry.")
213
+ return 1
214
+ pp.write_pairings(u["doc"])
215
+ e = u["entry"]
216
+ out(f"witbitz-code: ✓ {'paired' if u['isNew'] else 'refreshed'} \"{e['name']}\" with {e['account'] or 'your account'} ({pp.pairings_path()})")
217
+ if u["sharedWith"]:
218
+ out(f"witbitz-code: ⚠ this computer's OpenCode ({e['opencodeUrl']}) is now reachable from {e['account']} AND {', '.join(u['sharedWith'])}.")
219
+ out(" OpenCode has no users: they share every session, file and shell. Fine for your own accounts;")
220
+ out(" for another person run a separate OpenCode (another port and OS user) and pair with --opencode-url.")
221
+ out(f"witbitz-code: next — start OpenCode and the connector: {START_HINT}")
222
+ return 0
@@ -0,0 +1,214 @@
1
+ """This computer's pairings file and the local OpenCode env file — the pure half of tools/opencode-pair.mjs plus the
2
+ connector's loader.
3
+
4
+ ~/.witbitz/code/pairings.json (0600) is shared with the JS tools, so it is written byte-for-byte as they write it
5
+ (`JSON.stringify(doc, null, 1) + "\\n"`): either implementation can pair a computer and the other can serve or unpair it.
6
+
7
+ { "v": 1, "pairings": [ { account, idx: {room, mk}, computerId, secret, name, relay, opencodeUrl } ] }
8
+
9
+ One entry per (computer, account) — several accounts can pair the same computer, each with its own secret and channel.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import secrets
17
+ import socket
18
+ import sys
19
+ import tempfile
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from . import _js
25
+ from ._js import NOT_LT, UNDEFINED, WS, strict_eq, truthy
26
+ from .relay import RELAY_URL, new_relay_secret
27
+
28
+ DEFAULT_OPENCODE_URL = "http://127.0.0.1:4096"
29
+
30
+
31
+ def pairings_path() -> Path:
32
+ env = os.environ.get("WITBITZ_CODE_PAIRINGS")
33
+ return Path(env) if env else Path.home() / ".witbitz" / "code" / "pairings.json"
34
+
35
+
36
+ def env_path() -> Path:
37
+ env = os.environ.get("OPENCODE_ENV_FILE")
38
+ return Path(env) if env else Path.home() / ".opencode-server.env"
39
+
40
+
41
+ # ── the env file ──────────────────────────────────────────────────────────────────────────────────────────────────────
42
+ def _assignment(key: str) -> re.Pattern:
43
+ # JS: /^\s*(?:export\s+)?KEY=(.*)$/ on each line split at /\r?\n/ (a Windows-edited file keeps its password). JS `.`
44
+ # still stops at a lone \r or U+2028, so NOT_LT keeps the two tools agreeing on those.
45
+ return re.compile(f"[{WS}]*(?:export[{WS}]+)?{re.escape(key)}=({NOT_LT}*)")
46
+
47
+
48
+ def env_get(text: Any, key: str) -> str | None:
49
+ """The value of KEY in an env file — the LAST assignment wins, as when the file is sourced. Quotes are stripped."""
50
+ rx = _assignment(key)
51
+ v = None
52
+ for line in re.split(r"\r?\n", _js.js_string(text if truthy(text) else "")):
53
+ m = rx.fullmatch(line)
54
+ if m:
55
+ q = re.fullmatch(f"(['\"])({NOT_LT}*)\\1", _js.trim(m.group(1)))
56
+ v = q.group(2) if q else _js.trim(m.group(1))
57
+ return v
58
+
59
+
60
+ def env_set(text: Any, key: str, value: str) -> str:
61
+ """Set KEY=value: every existing assignment of KEY is removed, one is appended, every other line is kept verbatim."""
62
+ rx = re.compile(f"[{WS}]*(?:export[{WS}]+)?{re.escape(key)}=")
63
+ keep = [ln for ln in re.split(r"\r?\n", _js.js_string(text if truthy(text) else "")) if not rx.match(ln)]
64
+ while keep and keep[-1] == "":
65
+ keep.pop()
66
+ keep.append(f"{key}={value}")
67
+ return "\n".join(keep) + "\n"
68
+
69
+
70
+ def parse_env_password(text: Any) -> str:
71
+ """OPENCODE_SERVER_PASSWORD from an env file ('' when absent)."""
72
+ return env_get(text, "OPENCODE_SERVER_PASSWORD") or ""
73
+
74
+
75
+ def read_env_password(path: Path | None = None) -> str:
76
+ p = path or env_path()
77
+ try:
78
+ return parse_env_password(p.read_text(encoding="utf-8"))
79
+ except OSError:
80
+ return ""
81
+
82
+
83
+ def new_password() -> str:
84
+ """32 URL-safe characters (192 bits) — the LOCAL OpenCode password; it never leaves this computer."""
85
+ return secrets.token_urlsafe(24)
86
+
87
+
88
+ def new_computer_id() -> str:
89
+ """16 random bytes, base64url. One per (computer, account) pairing."""
90
+ return secrets.token_urlsafe(16)
91
+
92
+
93
+ def hostname() -> str:
94
+ return socket.gethostname()
95
+
96
+
97
+ # ── the pairings doc (pure) ───────────────────────────────────────────────────────────────────────────────────────────
98
+ def _usable(p: Any) -> bool:
99
+ idx = p.get("idx") if isinstance(p, dict) else None
100
+ return (isinstance(idx, dict) and truthy(idx.get("room")) and truthy(idx.get("mk"))
101
+ and isinstance(p.get("secret"), str) and isinstance(p.get("computerId"), str))
102
+
103
+
104
+ def norm_pairings(doc: Any) -> dict:
105
+ """The pairings file as a list-holder; tolerant of a missing or broken file."""
106
+ lst = doc.get("pairings") if isinstance(doc, dict) else None
107
+ return {"v": 1, "pairings": [p for p in lst if _usable(p)] if isinstance(lst, list) else []}
108
+
109
+
110
+ def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", relay: Any = UNDEFINED,
111
+ opencode_url: Any = UNDEFINED, rotate: bool = False, mint_id: Callable[[], str] = new_computer_id,
112
+ mint_secret: Callable[[], str] = new_relay_secret) -> dict:
113
+ """Add this computer for an account, or refresh the existing entry (same id + secret unless `rotate`). Pure.
114
+ → {doc, entry, isNew, sharedWith}: sharedWith names the other accounts that reach the same OpenCode."""
115
+ relay = RELAY_URL if relay is UNDEFINED else relay
116
+ cur = norm_pairings(doc)
117
+ i = next((k for k, p in enumerate(cur["pairings"]) if strict_eq(p["idx"].get("room"), idx.get("room"))), -1)
118
+ prev = cur["pairings"][i] if i >= 0 else None
119
+
120
+ def pick(v: Any, key: str, dflt: Any) -> Any:
121
+ if truthy(v):
122
+ return v
123
+ return prev[key] if prev is not None and truthy(prev.get(key)) else dflt
124
+
125
+ entry = {
126
+ "account": pick(account, "account", ""),
127
+ "idx": {"room": idx.get("room"), "mk": idx.get("mk")},
128
+ "computerId": prev["computerId"] if prev is not None else mint_id(),
129
+ "secret": prev["secret"] if prev is not None and not rotate else mint_secret(),
130
+ "name": pick(name, "name", None) or hostname(),
131
+ "relay": relay,
132
+ # An OpenCode asked for (`pair --port 4097`) wins; not asked → the entry keeps the one it had (tools/opencode-pair.mjs).
133
+ "opencodeUrl": pick(None if opencode_url is UNDEFINED else opencode_url, "opencodeUrl", DEFAULT_OPENCODE_URL),
134
+ }
135
+ pairings = list(cur["pairings"])
136
+ if i >= 0:
137
+ pairings[i] = entry
138
+ else:
139
+ pairings.append(entry)
140
+ others = [p for p in pairings if not strict_eq(p["idx"].get("room"), idx.get("room"))
141
+ and strict_eq(p.get("opencodeUrl", UNDEFINED), entry["opencodeUrl"])]
142
+ return {"doc": {"v": 1, "pairings": pairings}, "entry": entry, "isNew": prev is None,
143
+ "sharedWith": [p["account"] if truthy(p.get("account")) else "(another account)" for p in others]}
144
+
145
+
146
+ def remove_pairings(doc: Any, account: str = "") -> dict:
147
+ """Drop the entries matching `account` (email) — or every entry when `account` is empty. Pure."""
148
+ cur = norm_pairings(doc)
149
+ gone = [p for p in cur["pairings"] if not truthy(account) or strict_eq(p.get("account", UNDEFINED), account)]
150
+ kept = [p for p in cur["pairings"] if not any(p is g for g in gone)]
151
+ return {"doc": {"v": 1, "pairings": kept}, "removed": gone}
152
+
153
+
154
+ # ── files ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
155
+ def read_pairings(path: Path | None = None) -> dict:
156
+ try:
157
+ return norm_pairings(_js.parse((path or pairings_path()).read_text(encoding="utf-8")))
158
+ except (OSError, ValueError):
159
+ return norm_pairings(None)
160
+
161
+
162
+ def write_private(path: Path, text: str, dir_mode: int = 0o700) -> None:
163
+ """Write a 0600 file atomically: the secret is never readable by others, not even for the moment between write and
164
+ chmod, and a crash never leaves half a file."""
165
+ path.parent.mkdir(parents=True, exist_ok=True, mode=dir_mode)
166
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
167
+ try:
168
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
169
+ f.write(text)
170
+ os.chmod(tmp, 0o600)
171
+ os.replace(tmp, path)
172
+ except BaseException:
173
+ try:
174
+ os.unlink(tmp)
175
+ except OSError:
176
+ pass
177
+ raise
178
+
179
+
180
+ def write_in_place(path: Path, text: str) -> None:
181
+ """Rewrite a file the user may manage (the env file — possibly a symlink): atomically at its real location, 0600
182
+ from the first byte, so there is no moment when a new password sits in a world-readable file and no crash that
183
+ leaves the user's other lines half-written."""
184
+ write_private(Path(os.path.realpath(path)), text)
185
+
186
+
187
+ def serialize_pairings(doc: dict) -> str:
188
+ return _js.stringify(doc, 1) + "\n"
189
+
190
+
191
+ def write_pairings(doc: dict, path: Path | None = None) -> None:
192
+ write_private(path or pairings_path(), serialize_pairings(doc))
193
+
194
+
195
+ def load_pairings(path: Path | None = None, log: Callable[[str], Any] | None = None) -> list[dict]:
196
+ """The connector's view: the list (empty when absent). Entries without a secret are skipped, loudly."""
197
+ log = log or (lambda m: print(m, file=sys.stderr, flush=True))
198
+ p = path or pairings_path()
199
+ if not p.exists():
200
+ return []
201
+ try:
202
+ doc = _js.parse(p.read_text(encoding="utf-8"))
203
+ except (OSError, ValueError) as e:
204
+ log(f"witbitz-code: {p} is not valid JSON ({e})")
205
+ return []
206
+ lst = doc.get("pairings") if isinstance(doc, dict) else None
207
+ out = []
208
+ for entry in lst if isinstance(lst, list) else []:
209
+ secret = entry.get("secret") if isinstance(entry, dict) else None
210
+ if isinstance(secret, str) and _js.utf16_len(secret) >= 43:
211
+ out.append(entry)
212
+ else:
213
+ log("witbitz-code: skipping a pairing with no secret")
214
+ return out
@@ -0,0 +1,140 @@
1
+ """Our OpenCode plugins — project notes and the agent's progress updates — for a computer running the Python connector.
2
+
3
+ The twin of tools/opencode-plugins/install.mjs: the same three files (package data in opencode_plugins/, copied from the
4
+ sources by `node tools/opencode-plugins/embed.mjs`; a test fails when a copy is stale) are written into OpenCode's config
5
+ folder on every `serve`, where OpenCode loads {plugin,plugins}/*.js and {command,commands}/**.md at boot.
6
+
7
+ With the notes plugin installed, the connector allows each session to read its project's notes folder and write into
8
+ notes/ (connector.py, the twin of ruleNotes in tools/opencode-connector.mjs): the plugin makes the agent keep notes at the
9
+ end of a turn, and one note used to cost four approvals.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import os
16
+ import re
17
+ import shutil
18
+ from importlib import resources
19
+ from pathlib import Path
20
+
21
+ # Installed path (under OpenCode's config folder) → the packaged file.
22
+ FILES = {
23
+ "plugins/witbitz-notes.js": "witbitz-notes.js",
24
+ "plugins/witbitz-progress.js": "witbitz-progress.js",
25
+ "commands/notes-init.md": "notes-init.md",
26
+ }
27
+
28
+
29
+ def opencode_config_dir(env: dict | None = None) -> Path:
30
+ """OpenCode's config folder, as OpenCode finds it: $XDG_CONFIG_HOME/opencode, else ~/.config/opencode (Windows too)."""
31
+ env = os.environ if env is None else env
32
+ return Path(env.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "opencode"
33
+
34
+
35
+ def packaged(name: str) -> str:
36
+ return resources.files("witbitz_code").joinpath("opencode_plugins", name).read_text(encoding="utf-8")
37
+
38
+
39
+ def install(config_dir: Path | None = None) -> list[Path]:
40
+ """Write the plugin files into OpenCode's config folder; → the installed paths that changed. An identical file is left
41
+ alone; the notes' old confidential-model list is removed (the notes no longer split by model)."""
42
+ root = Path(config_dir) if config_dir else opencode_config_dir()
43
+ changed = []
44
+ for rel, name in FILES.items():
45
+ target = root / rel
46
+ text = packaged(name)
47
+ try:
48
+ if target.read_text(encoding="utf-8") == text:
49
+ continue
50
+ except (OSError, UnicodeDecodeError):
51
+ pass
52
+ target.parent.mkdir(parents=True, exist_ok=True)
53
+ target.write_text(text, encoding="utf-8", newline="")
54
+ changed.append(target)
55
+ try:
56
+ (root / "witbitz-confidential-models.json").unlink()
57
+ except FileNotFoundError:
58
+ pass
59
+ return changed
60
+
61
+
62
+ # ── witbitz-media: image/PDF/video/audio tools as one stdio MCP server (tools/media-mcp/server.mjs; docs/media-mcp.md) ───────
63
+ # The twin of install.mjs installMedia/mediaMcpConfig. The server is JavaScript, so it runs only where `node` is on PATH
64
+ # (OpenCode installed with npm brings it; the standalone OpenCode binary does not) — without node the agent works without it.
65
+ MEDIA_REL = "witbitz-media/server.mjs"
66
+
67
+
68
+ def install_media(config_dir: Path | None = None) -> Path:
69
+ """Write the media server into OpenCode's config folder (an identical file is left alone); → its path."""
70
+ target = (Path(config_dir) if config_dir else opencode_config_dir()) / MEDIA_REL
71
+ text = packaged("witbitz-media-server.mjs")
72
+ try:
73
+ if target.read_text(encoding="utf-8") == text:
74
+ return target
75
+ except (OSError, UnicodeDecodeError):
76
+ pass
77
+ target.parent.mkdir(parents=True, exist_ok=True)
78
+ target.write_text(text, encoding="utf-8", newline="")
79
+ return target
80
+
81
+
82
+ def media_mcp_config(server: Path | str, node: str) -> dict:
83
+ """The OpenCode config block that runs it (merged into OPENCODE_CONFIG_CONTENT; the user's opencode.json is never written)."""
84
+ return {"mcp": {"witbitz-media": {"type": "local", "command": [node, str(server)], "enabled": True}}}
85
+
86
+
87
+ def media_config(config_dir: Path | None = None, which=shutil.which) -> tuple[dict, str]:
88
+ """→ (the mcp block or {}, a line for the log, or "")."""
89
+ node = which("node")
90
+ if not node:
91
+ return {}, "witbitz-code: no node on PATH — the agent works without the media tools (images, PDFs, video, transcription)"
92
+ try:
93
+ return media_mcp_config(install_media(config_dir), node), ""
94
+ except OSError as e:
95
+ return {}, f"witbitz-code: could not install the media tools ({e}) — the agent works without them"
96
+
97
+
98
+ # ── project notes: which folder, and the rules that let a session use it (witbitz-notes.js helpers, ported) ──────────────
99
+ def notes_plugin_path(env: dict | None = None) -> Path:
100
+ return opencode_config_dir(env) / "plugins" / "witbitz-notes.js"
101
+
102
+
103
+ def notes_root(env: dict | None = None) -> str:
104
+ env = os.environ if env is None else env
105
+ return env.get("WITBITZ_NOTES_DIR") or str(Path.home() / ".local" / "share" / "witbitz-notes")
106
+
107
+
108
+ def notes_key(root: str) -> str:
109
+ """`<folder name>-<sha1(root)[:8]>`, exactly as the plugin names it: the JS regex replaces each UTF-16 code unit that is
110
+ not [A-Za-z0-9._-] with one "_" — so a character outside the BMP becomes two."""
111
+ base = os.path.basename(root) or "root"
112
+ safe = "".join(ch if re.fullmatch(r"[A-Za-z0-9._-]", ch) else "_" * (len(ch.encode("utf-16-le")) // 2) for ch in base)
113
+ return f"{safe}-{hashlib.sha1(root.encode('utf-8')).hexdigest()[:8]}"
114
+
115
+
116
+ def root_from_session(s: dict | None) -> str:
117
+ """A session record → its project root: directory minus its `path` (a git session's path is "" or the subfolder, a plain
118
+ folder's path is its directory without the leading "/")."""
119
+ directory = (s or {}).get("directory")
120
+ if not isinstance(directory, str) or not directory:
121
+ return ""
122
+ rel = str((s or {}).get("path") or "")
123
+ if not rel or directory == "/" + rel:
124
+ return directory
125
+ return (directory[: -(len(rel) + 1)] or "/") if directory.endswith("/" + rel) else directory
126
+
127
+
128
+ def notes_rules(s: dict, root_dir: str) -> list[dict]:
129
+ """The rules a session needs for its project's notes: reads of the folder, writes into notes/ (never AGENTS.md). The
130
+ edit tool's pattern is relative to the WORKTREE — the git root, or "/" for a plain folder (measured)."""
131
+ root = root_from_session(s)
132
+ if not root:
133
+ return []
134
+ folder = f"{root_dir}/{notes_key(os.path.abspath(root))}"
135
+ plain = bool(s.get("path")) and s.get("directory") == "/" + str(s.get("path"))
136
+ rel = os.path.relpath(folder, "/" if plain else os.path.abspath(root))
137
+ return [
138
+ {"permission": "external_directory", "pattern": f"{folder}/*", "action": "allow"},
139
+ {"permission": "edit", "pattern": f"{rel}/notes/*", "action": "allow"},
140
+ ]
witbitz_code/policy.py ADDED
@@ -0,0 +1,48 @@
1
+ """The OpenCode config the Code section's approvals depend on — twin of tools/code-opencode-policy.mjs.
2
+
3
+ Read that file's header for the two findings (OpenCode 1.18.30): a subagent does NOT inherit its parent session's `ask`
4
+ rules, so the built-in subagents are told to ask here, per agent and only for what each one ALLOWS (never loosening a
5
+ deny); and a denied tool call ends the turn unless `experimental.continue_loop_on_deny` is set. tests/test_policy.py
6
+ holds this object identical to the JS one.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ from typing import Any
13
+
14
+ SUBAGENT_ASK: dict[str, dict[str, str]] = {
15
+ "explore": {"bash": "ask", "webfetch": "ask", "websearch": "ask"},
16
+ "general": {"bash": "ask", "edit": "ask", "webfetch": "ask", "websearch": "ask", "task": "ask", "skill": "ask"},
17
+ }
18
+
19
+ SUBAGENT_GATED: tuple[str, ...] = ("bash", "edit", "webfetch", "websearch")
20
+
21
+
22
+ def policy_config() -> dict[str, Any]:
23
+ """The config object, fresh each call."""
24
+ return {
25
+ "experimental": {"continue_loop_on_deny": True},
26
+ "agent": {name: {"permission": dict(perms)} for name, perms in SUBAGENT_ASK.items()},
27
+ }
28
+
29
+
30
+ def merge_config(a: dict, b: dict | None) -> dict:
31
+ """Deep-merge plain dicts (b wins), without mutating either."""
32
+ out = copy.deepcopy(a)
33
+ for k, v in (b or {}).items():
34
+ out[k] = merge_config(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) else copy.deepcopy(v)
35
+ return out
36
+
37
+
38
+ def config_content(existing: str | None = None, extra: dict | None = None) -> str:
39
+ """OPENCODE_CONFIG_CONTENT for the OpenCode `serve` starts: `extra` (the media tools' mcp block) and the policy, under
40
+ anything the environment already set."""
41
+ base: dict = {}
42
+ if existing:
43
+ try:
44
+ parsed = json.loads(existing)
45
+ base = parsed if isinstance(parsed, dict) else {}
46
+ except ValueError:
47
+ base = {}
48
+ return json.dumps(merge_config(merge_config(base, extra), policy_config()))