@agentsdock/server 1.0.7-beta.5 → 1.0.8-beta.2
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.
- package/README.md +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +282 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/pyproject.toml +1 -0
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- package/server/uv.lock +3 -1
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""OpenCode CLI translation and command construction for AgentsServer.
|
|
2
|
+
|
|
3
|
+
The parser normalizes the newline-delimited JSON that ``opencode run
|
|
4
|
+
--format json`` emits into the same provider-neutral event shape the Cursor
|
|
5
|
+
backend already produces, so the production turn runner can reuse the existing
|
|
6
|
+
timeline vocabulary rather than inventing a fourth one. Fixtures in
|
|
7
|
+
test_opencode_agent_client.py are captured verbatim from a real run of
|
|
8
|
+
opencode 1.18.29, not inferred from documentation.
|
|
9
|
+
|
|
10
|
+
Several behaviours here exist because they were measured, not assumed:
|
|
11
|
+
|
|
12
|
+
- OpenCode allows every tool by default, including ``bash``, with no
|
|
13
|
+
prompt. Restricting a turn therefore means *adding* deny rules rather
|
|
14
|
+
than withholding an allow flag, which is the opposite of Cursor.
|
|
15
|
+
- Denying one tool does not deny the outcome: with only ``bash`` denied a
|
|
16
|
+
model reached for ``write`` and produced the same file. A read-only mode
|
|
17
|
+
has to deny the whole mutating set.
|
|
18
|
+
|
|
19
|
+
- The workspace is passed explicitly as ``--dir`` as well as the child
|
|
20
|
+
process cwd, avoiding reliance on implicit CLI workspace resolution.
|
|
21
|
+
- Config sources deep-merge, and managed config loads after
|
|
22
|
+
``OPENCODE_CONFIG_CONTENT``. Per-turn restrictions therefore live on a
|
|
23
|
+
cryptographically unique primary agent selected with ``--agent``. OpenCode
|
|
24
|
+
appends that selected agent's permissions after global/user permissions,
|
|
25
|
+
while later config sources cannot predict the random agent key to replace
|
|
26
|
+
it.
|
|
27
|
+
|
|
28
|
+
The default mode deliberately matches OpenCode's own behaviour rather than
|
|
29
|
+
Cursor's, so an operator who already runs ``opencode`` gets the same agent
|
|
30
|
+
through AgentsDock that they get from their terminal.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import secrets
|
|
39
|
+
from typing import Any, Iterator
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpenCodeEventParseError(ValueError):
|
|
43
|
+
"""A stream line was not valid JSON or had an unrecognized shape."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
OPENCODE_SESSION_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
|
|
47
|
+
OPENCODE_CALL_ID_MAX_CHARS = 240
|
|
48
|
+
|
|
49
|
+
OPENCODE_PERMISSION_MODES = ("default", "full_access", "plan")
|
|
50
|
+
OPENCODE_DEFAULT_PERMISSION_MODE = "default"
|
|
51
|
+
|
|
52
|
+
# Every tool that can change the workspace or the machine. Denying only one of
|
|
53
|
+
# these is not a restriction: a model blocked from `bash` will use `write` to
|
|
54
|
+
# reach the same result (observed directly against the real CLI).
|
|
55
|
+
OPENCODE_MUTATING_TOOLS = ("bash", "write", "edit", "patch")
|
|
56
|
+
OPENCODE_DELEGATION_TOOL = "task"
|
|
57
|
+
|
|
58
|
+
# Not a real tool: OpenCode reports a call to a withheld tool under this name.
|
|
59
|
+
OPENCODE_UNAVAILABLE_TOOL_NAME = "invalid"
|
|
60
|
+
OPENCODE_ENFORCED_AGENT_RE = re.compile(
|
|
61
|
+
r"^agentsdock-turn-[0-9a-f]{64}$"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def new_opencode_enforced_agent_name() -> str:
|
|
66
|
+
"""Return an unguessable one-turn primary-agent config key."""
|
|
67
|
+
|
|
68
|
+
return f"agentsdock-turn-{secrets.token_hex(32)}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def canonical_opencode_session_id(value: Any) -> str | None:
|
|
72
|
+
"""Return one bounded resume id, or reject protocol/schema drift."""
|
|
73
|
+
|
|
74
|
+
if value is None:
|
|
75
|
+
return None
|
|
76
|
+
if not isinstance(value, str):
|
|
77
|
+
raise OpenCodeEventParseError("OpenCode sessionID must be a string")
|
|
78
|
+
clean = value.strip()
|
|
79
|
+
if not OPENCODE_SESSION_IDENTIFIER_RE.fullmatch(clean):
|
|
80
|
+
raise OpenCodeEventParseError(
|
|
81
|
+
"OpenCode sessionID is not a valid bounded local identifier"
|
|
82
|
+
)
|
|
83
|
+
return clean
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def canonical_opencode_call_id(value: Any) -> str:
|
|
87
|
+
"""Validate the opaque correlation key used by OpenCode tool events."""
|
|
88
|
+
|
|
89
|
+
if not isinstance(value, str):
|
|
90
|
+
raise OpenCodeEventParseError("tool callID must be a string")
|
|
91
|
+
if (
|
|
92
|
+
not value
|
|
93
|
+
or value != value.strip()
|
|
94
|
+
or len(value) > OPENCODE_CALL_ID_MAX_CHARS
|
|
95
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
96
|
+
):
|
|
97
|
+
raise OpenCodeEventParseError(
|
|
98
|
+
"tool callID must be a bounded nonempty identifier"
|
|
99
|
+
)
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _bounded_int(value: Any) -> int | None:
|
|
104
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
105
|
+
return None
|
|
106
|
+
if value < 0 or value > 2_147_483_647:
|
|
107
|
+
return None
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def normalize_opencode_usage(tokens: Any, cost: Any) -> dict[str, Any]:
|
|
112
|
+
"""Project the token/cost accounting a step_finish carries."""
|
|
113
|
+
|
|
114
|
+
usage: dict[str, Any] = {}
|
|
115
|
+
if isinstance(tokens, dict):
|
|
116
|
+
for source, target in (
|
|
117
|
+
("input", "input_tokens"),
|
|
118
|
+
("output", "output_tokens"),
|
|
119
|
+
("reasoning", "reasoning_tokens"),
|
|
120
|
+
("total", "total_tokens"),
|
|
121
|
+
):
|
|
122
|
+
bounded = _bounded_int(tokens.get(source))
|
|
123
|
+
if bounded is not None:
|
|
124
|
+
usage[target] = bounded
|
|
125
|
+
cache = tokens.get("cache")
|
|
126
|
+
if isinstance(cache, dict):
|
|
127
|
+
for source, target in (
|
|
128
|
+
("read", "cache_read_tokens"),
|
|
129
|
+
("write", "cache_write_tokens"),
|
|
130
|
+
):
|
|
131
|
+
bounded = _bounded_int(cache.get(source))
|
|
132
|
+
if bounded is not None:
|
|
133
|
+
usage[target] = bounded
|
|
134
|
+
if isinstance(cost, (int, float)) and not isinstance(cost, bool):
|
|
135
|
+
if 0 <= float(cost) < 1_000_000:
|
|
136
|
+
usage["cost"] = float(cost)
|
|
137
|
+
return usage
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def opencode_error_message(error: Any) -> str:
|
|
141
|
+
"""Extract a human message from OpenCode's nested error payload.
|
|
142
|
+
|
|
143
|
+
Real shape: ``{"name": "UnknownError", "data": {"message": ..., "ref": ...}}``.
|
|
144
|
+
The name is kept when a message is absent so a failure is never reported as
|
|
145
|
+
an empty string. Text is provider-controlled; the caller bounds it.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
if isinstance(error, str):
|
|
149
|
+
return error
|
|
150
|
+
if not isinstance(error, dict):
|
|
151
|
+
return ""
|
|
152
|
+
data = error.get("data")
|
|
153
|
+
message = ""
|
|
154
|
+
if isinstance(data, dict):
|
|
155
|
+
message = str(data.get("message") or "")
|
|
156
|
+
if not message:
|
|
157
|
+
message = str(error.get("message") or "")
|
|
158
|
+
name = str(error.get("name") or "")
|
|
159
|
+
if message and name:
|
|
160
|
+
return f"{name}: {message}"
|
|
161
|
+
return message or name
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def normalize_opencode_stream_event(raw_line: str) -> dict[str, Any] | None:
|
|
165
|
+
"""Parse one ``--format json`` line into a provider-neutral event.
|
|
166
|
+
|
|
167
|
+
Returns None for lines that carry no timeline projection. Raises
|
|
168
|
+
OpenCodeEventParseError for anything unrecognized, deliberately: silently
|
|
169
|
+
dropping an unknown event type would hide a real schema change in a future
|
|
170
|
+
OpenCode release, which is exactly the failure mode that made a Codex
|
|
171
|
+
regression invisible.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
raw_line = raw_line.strip()
|
|
175
|
+
if not raw_line:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
event = json.loads(raw_line)
|
|
179
|
+
except json.JSONDecodeError as exc:
|
|
180
|
+
raise OpenCodeEventParseError("OpenCode stream line is not valid JSON") from exc
|
|
181
|
+
if not isinstance(event, dict):
|
|
182
|
+
raise OpenCodeEventParseError("OpenCode stream line must be a JSON object")
|
|
183
|
+
|
|
184
|
+
event_type = event.get("type")
|
|
185
|
+
session_id = canonical_opencode_session_id(event.get("sessionID"))
|
|
186
|
+
|
|
187
|
+
if event_type == "error":
|
|
188
|
+
# Captured shape: an error event carries `error`, not `part`. Requiring
|
|
189
|
+
# `part` here would turn every provider failure into a parse failure
|
|
190
|
+
# and report the wrong cause for the turn.
|
|
191
|
+
return {
|
|
192
|
+
"kind": "turn_error",
|
|
193
|
+
"session_id": session_id,
|
|
194
|
+
"message": opencode_error_message(event.get("error")),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
part = event.get("part")
|
|
198
|
+
if not isinstance(part, dict):
|
|
199
|
+
raise OpenCodeEventParseError("OpenCode event is missing its part payload")
|
|
200
|
+
|
|
201
|
+
part_session_id = canonical_opencode_session_id(part.get("sessionID"))
|
|
202
|
+
if session_id and part_session_id and session_id != part_session_id:
|
|
203
|
+
raise OpenCodeEventParseError(
|
|
204
|
+
"OpenCode event and part carry different sessionIDs"
|
|
205
|
+
)
|
|
206
|
+
session_id = session_id or part_session_id
|
|
207
|
+
|
|
208
|
+
expected_part_types = {
|
|
209
|
+
"step_start": "step-start",
|
|
210
|
+
"text": "text",
|
|
211
|
+
"reasoning": "reasoning",
|
|
212
|
+
"tool_use": "tool",
|
|
213
|
+
"step_finish": "step-finish",
|
|
214
|
+
}
|
|
215
|
+
expected_part_type = expected_part_types.get(event_type)
|
|
216
|
+
part_type = part.get("type")
|
|
217
|
+
if (
|
|
218
|
+
expected_part_type is not None
|
|
219
|
+
and part_type is not None
|
|
220
|
+
and part_type != expected_part_type
|
|
221
|
+
):
|
|
222
|
+
raise OpenCodeEventParseError(
|
|
223
|
+
"OpenCode event type does not match its part payload"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if event_type == "step_start":
|
|
227
|
+
# A step boundary carries no user-visible content of its own.
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
if event_type == "text":
|
|
231
|
+
text = part.get("text")
|
|
232
|
+
if not isinstance(text, str):
|
|
233
|
+
raise OpenCodeEventParseError("OpenCode text payload must be a string")
|
|
234
|
+
return {
|
|
235
|
+
"kind": "assistant_text",
|
|
236
|
+
"session_id": session_id,
|
|
237
|
+
"text": text,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if event_type == "reasoning":
|
|
241
|
+
text = part.get("text")
|
|
242
|
+
if not isinstance(text, str):
|
|
243
|
+
raise OpenCodeEventParseError(
|
|
244
|
+
"OpenCode reasoning payload must be a string"
|
|
245
|
+
)
|
|
246
|
+
return {
|
|
247
|
+
"kind": "reasoning_delta",
|
|
248
|
+
"session_id": session_id,
|
|
249
|
+
"text": text,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if event_type == "tool_use":
|
|
253
|
+
return _normalize_tool_use(session_id, part)
|
|
254
|
+
|
|
255
|
+
if event_type == "step_finish":
|
|
256
|
+
reason = part.get("reason")
|
|
257
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
258
|
+
raise OpenCodeEventParseError(
|
|
259
|
+
"OpenCode step_finish reason must be a nonempty string"
|
|
260
|
+
)
|
|
261
|
+
return {
|
|
262
|
+
"kind": "step_finished",
|
|
263
|
+
"session_id": session_id,
|
|
264
|
+
"reason": reason.strip(),
|
|
265
|
+
"usage": normalize_opencode_usage(part.get("tokens"), part.get("cost")),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
raise OpenCodeEventParseError("unrecognized OpenCode stream event type")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _normalize_tool_use(session_id: str | None, part: dict[str, Any]) -> dict[str, Any]:
|
|
272
|
+
tool = str(part.get("tool") or "tool")
|
|
273
|
+
call_id = canonical_opencode_call_id(part.get("callID"))
|
|
274
|
+
state = part.get("state")
|
|
275
|
+
if not isinstance(state, dict):
|
|
276
|
+
raise OpenCodeEventParseError("tool event is missing its state payload")
|
|
277
|
+
status = str(state.get("status") or "")
|
|
278
|
+
|
|
279
|
+
if tool == OPENCODE_UNAVAILABLE_TOOL_NAME:
|
|
280
|
+
# Second observed denial shape: when a tool is withheld by config,
|
|
281
|
+
# OpenCode drops it from the model's toolset, and a model that calls
|
|
282
|
+
# it anyway gets back a synthetic tool literally named "invalid" with
|
|
283
|
+
# status "completed". Classifying that as a successful tool call would
|
|
284
|
+
# report a blocked action as having run.
|
|
285
|
+
return {
|
|
286
|
+
"kind": "tool_rejected",
|
|
287
|
+
"session_id": session_id,
|
|
288
|
+
"call_id": call_id,
|
|
289
|
+
"tool": tool,
|
|
290
|
+
"args": state.get("input") if isinstance(state.get("input"), dict) else {},
|
|
291
|
+
"reason": str(state.get("output") or "") or None,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
common = {
|
|
295
|
+
"session_id": session_id,
|
|
296
|
+
"call_id": call_id,
|
|
297
|
+
"tool": tool,
|
|
298
|
+
"args": state.get("input") if isinstance(state.get("input"), dict) else {},
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if status in {"pending", "running"}:
|
|
302
|
+
return {"kind": "tool_started", **common}
|
|
303
|
+
if status == "completed":
|
|
304
|
+
return {
|
|
305
|
+
"kind": "tool_finished",
|
|
306
|
+
**common,
|
|
307
|
+
"result": state.get("output"),
|
|
308
|
+
}
|
|
309
|
+
if status in {"invalid", "denied"}:
|
|
310
|
+
# OpenCode reports a configuration-denied tool as `invalid` rather
|
|
311
|
+
# than erroring the turn, so this must stay distinguishable from a
|
|
312
|
+
# tool that ran and failed.
|
|
313
|
+
return {
|
|
314
|
+
"kind": "tool_rejected",
|
|
315
|
+
**common,
|
|
316
|
+
"reason": str(state.get("error") or "") or None,
|
|
317
|
+
}
|
|
318
|
+
if status == "error":
|
|
319
|
+
return {
|
|
320
|
+
"kind": "tool_failed",
|
|
321
|
+
**common,
|
|
322
|
+
"reason": str(state.get("error") or "") or None,
|
|
323
|
+
}
|
|
324
|
+
raise OpenCodeEventParseError("unrecognized OpenCode tool status")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def normalize_opencode_stream(lines: Iterator[str]) -> Iterator[dict[str, Any]]:
|
|
328
|
+
"""Normalize a whole stream, skipping events with no projection."""
|
|
329
|
+
|
|
330
|
+
for raw_line in lines:
|
|
331
|
+
normalized = normalize_opencode_stream_event(raw_line)
|
|
332
|
+
if normalized is not None:
|
|
333
|
+
yield normalized
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def opencode_permission_config(mode: str) -> dict[str, Any] | None:
|
|
337
|
+
"""Build the server-enforced subset for one turn's tool permissions.
|
|
338
|
+
|
|
339
|
+
OpenCode only documents a global `opencode.json`, which would be useless
|
|
340
|
+
for per-session modes, but the binary also honours an inline
|
|
341
|
+
`OPENCODE_CONFIG_CONTENT` environment variable - verified live to actually
|
|
342
|
+
block a denied tool. Because AgentsServer already composes a fresh
|
|
343
|
+
environment per turn, that gives real per-session permission modes without
|
|
344
|
+
touching the operator's own config file.
|
|
345
|
+
|
|
346
|
+
Returns None for the default mode. ``build_opencode_env_overrides`` places
|
|
347
|
+
this subset on a random selected primary agent instead of in global
|
|
348
|
+
permission config. The default mode names nothing, leaving OpenCode and
|
|
349
|
+
operator permission rules intact.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
if mode == "full_access":
|
|
353
|
+
# An explicit override, unlike the default: this one still applies
|
|
354
|
+
# when the operator's own config denies something.
|
|
355
|
+
return {"permission": {tool: "allow" for tool in OPENCODE_MUTATING_TOOLS}}
|
|
356
|
+
if mode == "plan":
|
|
357
|
+
return {
|
|
358
|
+
"permission": {
|
|
359
|
+
**{tool: "deny" for tool in OPENCODE_MUTATING_TOOLS},
|
|
360
|
+
# OpenCode task children do not inherit the selected primary
|
|
361
|
+
# agent's permission object. Without this boundary a Plan
|
|
362
|
+
# agent could delegate the same bash/write operation.
|
|
363
|
+
OPENCODE_DELEGATION_TOOL: "deny",
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
# "default" and any unrecognized mode: defer entirely to OpenCode. It
|
|
367
|
+
# allows every tool including bash with no prompt, which is broader than
|
|
368
|
+
# the Cursor default, and that difference is intentional - the backend
|
|
369
|
+
# behaves the way its own users already expect it to.
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def build_opencode_env_overrides(
|
|
374
|
+
mode: str,
|
|
375
|
+
existing_config: str | None = None,
|
|
376
|
+
instruction_paths: list[str] | tuple[str, ...] = (),
|
|
377
|
+
deny_skill_tool: bool = False,
|
|
378
|
+
enforced_agent_name: str | None = None,
|
|
379
|
+
) -> dict[str, str]:
|
|
380
|
+
"""Return the environment additions that carry the permission mode.
|
|
381
|
+
|
|
382
|
+
Empty for the pure default mode, so the operator's own config and default
|
|
383
|
+
agent stay in force. Enforced Plan/full-access and selected-skill rules are
|
|
384
|
+
attached to one cryptographically unique primary agent. OpenCode resolves
|
|
385
|
+
an unknown selected agent from defaults plus global/user configuration,
|
|
386
|
+
then appends that agent's permissions last. Project, global, environment,
|
|
387
|
+
and managed wildcard permissions therefore cannot supersede the exact
|
|
388
|
+
one-turn rules, and a later managed source cannot predict the random key.
|
|
389
|
+
|
|
390
|
+
Attachments are deliberately absent from permission composition.
|
|
391
|
+
AgentsServer bounds them before turn acceptance, and native ``--file``
|
|
392
|
+
ingestion bypasses the ``external_directory`` permission check.
|
|
393
|
+
Selected-skill resources likewise receive no automatic read grant: the
|
|
394
|
+
validated SKILL.md body is injected, while referenced files remain subject
|
|
395
|
+
to the operator's existing OpenCode permissions until a no-symlink bounded
|
|
396
|
+
resource snapshot is implemented.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
permission_config = opencode_permission_config(mode)
|
|
400
|
+
enforced_permissions: dict[str, Any] = {}
|
|
401
|
+
if permission_config is not None:
|
|
402
|
+
enforced_permissions.update(permission_config["permission"])
|
|
403
|
+
if deny_skill_tool:
|
|
404
|
+
enforced_permissions["skill"] = "deny"
|
|
405
|
+
# A selected-skill turn must not escape its skill=deny boundary by
|
|
406
|
+
# creating an OpenCode subagent, whose permissions are resolved anew.
|
|
407
|
+
enforced_permissions[OPENCODE_DELEGATION_TOOL] = "deny"
|
|
408
|
+
|
|
409
|
+
if enforced_permissions:
|
|
410
|
+
if (
|
|
411
|
+
not isinstance(enforced_agent_name, str)
|
|
412
|
+
or OPENCODE_ENFORCED_AGENT_RE.fullmatch(enforced_agent_name) is None
|
|
413
|
+
):
|
|
414
|
+
raise ValueError(
|
|
415
|
+
"OpenCode enforced turns require a valid one-turn agent name"
|
|
416
|
+
)
|
|
417
|
+
elif enforced_agent_name is not None:
|
|
418
|
+
raise ValueError(
|
|
419
|
+
"OpenCode default turns cannot select an enforced agent"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
normalized_instruction_paths: list[str] = []
|
|
423
|
+
for value in instruction_paths:
|
|
424
|
+
raw = str(value or "").strip()
|
|
425
|
+
if not raw or "\x00" in raw:
|
|
426
|
+
raise ValueError("OpenCode instruction paths must be absolute files")
|
|
427
|
+
normalized = os.path.normpath(os.path.expanduser(raw))
|
|
428
|
+
if not os.path.isabs(normalized):
|
|
429
|
+
raise ValueError("OpenCode instruction paths must be absolute files")
|
|
430
|
+
if normalized not in normalized_instruction_paths:
|
|
431
|
+
normalized_instruction_paths.append(normalized)
|
|
432
|
+
|
|
433
|
+
if (
|
|
434
|
+
permission_config is None
|
|
435
|
+
and not normalized_instruction_paths
|
|
436
|
+
and not deny_skill_tool
|
|
437
|
+
):
|
|
438
|
+
return {}
|
|
439
|
+
|
|
440
|
+
config: dict[str, Any] = {}
|
|
441
|
+
if existing_config is not None:
|
|
442
|
+
try:
|
|
443
|
+
decoded = json.loads(existing_config)
|
|
444
|
+
except (TypeError, json.JSONDecodeError) as exc:
|
|
445
|
+
raise ValueError(
|
|
446
|
+
"existing OpenCode inline config is not valid JSON"
|
|
447
|
+
) from exc
|
|
448
|
+
if not isinstance(decoded, dict):
|
|
449
|
+
raise ValueError(
|
|
450
|
+
"existing OpenCode inline config must be a JSON object"
|
|
451
|
+
)
|
|
452
|
+
config.update(decoded)
|
|
453
|
+
|
|
454
|
+
if enforced_permissions:
|
|
455
|
+
existing_agents = config.get("agent", {})
|
|
456
|
+
if not isinstance(existing_agents, dict):
|
|
457
|
+
raise ValueError(
|
|
458
|
+
"existing OpenCode inline agents must be a JSON object"
|
|
459
|
+
)
|
|
460
|
+
agents = dict(existing_agents)
|
|
461
|
+
# A generated name has 256 bits of entropy, but remove/reinsert anyway
|
|
462
|
+
# so a deterministic test name or an astronomically unlikely collision
|
|
463
|
+
# cannot retain any inherited fields.
|
|
464
|
+
agents.pop(enforced_agent_name, None)
|
|
465
|
+
agents[enforced_agent_name] = {
|
|
466
|
+
"description": "AgentsDock one-turn permission boundary",
|
|
467
|
+
"mode": "primary",
|
|
468
|
+
"permission": dict(enforced_permissions),
|
|
469
|
+
}
|
|
470
|
+
config["agent"] = agents
|
|
471
|
+
if normalized_instruction_paths:
|
|
472
|
+
existing_instructions = config.get("instructions", [])
|
|
473
|
+
if not isinstance(existing_instructions, list) or not all(
|
|
474
|
+
isinstance(value, str) for value in existing_instructions
|
|
475
|
+
):
|
|
476
|
+
raise ValueError(
|
|
477
|
+
"existing OpenCode inline instructions must be a string array"
|
|
478
|
+
)
|
|
479
|
+
merged_instructions = list(existing_instructions)
|
|
480
|
+
for path in normalized_instruction_paths:
|
|
481
|
+
if path not in merged_instructions:
|
|
482
|
+
merged_instructions.append(path)
|
|
483
|
+
config["instructions"] = merged_instructions
|
|
484
|
+
return {
|
|
485
|
+
"OPENCODE_CONFIG_CONTENT": json.dumps(config, separators=(",", ":"))
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def build_opencode_mcp_overrides(
|
|
490
|
+
existing_config: str | None, *, name: str, command: list[str],
|
|
491
|
+
environment: dict[str, str], enforced_agent_name: str | None = None,
|
|
492
|
+
) -> dict[str, str]:
|
|
493
|
+
"""Add one unpredictable, run-bound tool without granting shell/MCP wildcards.
|
|
494
|
+
|
|
495
|
+
Merge after the endpoint and permission configuration. Never persist this
|
|
496
|
+
configuration, credentials, or permission to the user's OpenCode files.
|
|
497
|
+
Default mode keeps the native agent and any higher-priority managed rules.
|
|
498
|
+
"""
|
|
499
|
+
if not re.fullmatch(r"agentsdock_[0-9a-f]{32}", name):
|
|
500
|
+
raise ValueError("invalid OpenCode provider tool name")
|
|
501
|
+
config = json.loads(existing_config) if existing_config else {}
|
|
502
|
+
if not isinstance(config, dict):
|
|
503
|
+
raise ValueError("OpenCode inline config must be an object")
|
|
504
|
+
mcp = config.setdefault("mcp", {})
|
|
505
|
+
if not isinstance(mcp, dict) or name in mcp:
|
|
506
|
+
raise ValueError("OpenCode provider MCP configuration conflicts")
|
|
507
|
+
mcp[name] = {"type": "local", "command": command,
|
|
508
|
+
"environment": environment, "enabled": True}
|
|
509
|
+
permissions = config.get("permission", {})
|
|
510
|
+
if isinstance(permissions, str):
|
|
511
|
+
permissions = {"*": permissions}
|
|
512
|
+
if not isinstance(permissions, dict):
|
|
513
|
+
raise ValueError("OpenCode permissions must be an object or action")
|
|
514
|
+
config["permission"] = {**permissions, name + "_run": "allow"}
|
|
515
|
+
if enforced_agent_name:
|
|
516
|
+
agent = config["agent"][enforced_agent_name]
|
|
517
|
+
agent["permission"][name + "_run"] = "allow"
|
|
518
|
+
return {"OPENCODE_CONFIG_CONTENT": json.dumps(config, separators=(",", ":"))}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def build_opencode_cmd(
|
|
522
|
+
sess: dict[str, Any],
|
|
523
|
+
prompt: str,
|
|
524
|
+
*,
|
|
525
|
+
opencode_bin: str = "opencode",
|
|
526
|
+
workdir: str | None = None,
|
|
527
|
+
attachment_paths: list[str] | tuple[str, ...] = (),
|
|
528
|
+
enforced_agent_name: str | None = None,
|
|
529
|
+
) -> list[str]:
|
|
530
|
+
"""Build one `opencode run` argv from a session dict and composed prompt.
|
|
531
|
+
|
|
532
|
+
Verified live: `opencode run --format json` streams newline-delimited
|
|
533
|
+
events, every one of which already carries the sessionID, and passing that
|
|
534
|
+
id back via `-s <id>` on a later call resumes the conversation (a number
|
|
535
|
+
stated in the first turn was recalled in the second).
|
|
536
|
+
|
|
537
|
+
`workdir` is passed explicitly as ``--dir`` so workspace selection does not
|
|
538
|
+
depend on implicit CLI cwd or configuration resolution. Each validated
|
|
539
|
+
current-turn upload is attached through OpenCode's repeatable ``--file``
|
|
540
|
+
option, which is required for true multimodal/image input.
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
cmd = [opencode_bin, "run", "--format", "json"]
|
|
544
|
+
workdir = workdir or str(sess.get("cwd") or "").strip()
|
|
545
|
+
if workdir:
|
|
546
|
+
cmd += ["--dir", workdir]
|
|
547
|
+
resume_id = sess.get("opencode_session_id")
|
|
548
|
+
if resume_id:
|
|
549
|
+
cmd += ["-s", str(resume_id)]
|
|
550
|
+
# Session-level permission rules are applied after agent rules. Forking
|
|
551
|
+
# an enforced resume preserves message history but intentionally does
|
|
552
|
+
# not copy those mutable session permissions.
|
|
553
|
+
if enforced_agent_name is not None:
|
|
554
|
+
cmd.append("--fork")
|
|
555
|
+
model = sess.get("model")
|
|
556
|
+
if model:
|
|
557
|
+
cmd += ["-m", str(model)]
|
|
558
|
+
variant = sess.get("effort")
|
|
559
|
+
if variant:
|
|
560
|
+
cmd += ["--variant", str(variant)]
|
|
561
|
+
if enforced_agent_name is not None:
|
|
562
|
+
if OPENCODE_ENFORCED_AGENT_RE.fullmatch(enforced_agent_name) is None:
|
|
563
|
+
raise ValueError("invalid OpenCode one-turn agent name")
|
|
564
|
+
cmd += ["--agent", enforced_agent_name]
|
|
565
|
+
for value in attachment_paths:
|
|
566
|
+
raw = str(value or "").strip()
|
|
567
|
+
if not raw or "\x00" in raw:
|
|
568
|
+
raise ValueError("OpenCode attachment paths must be absolute files")
|
|
569
|
+
normalized = os.path.normpath(os.path.expanduser(raw))
|
|
570
|
+
if not os.path.isabs(normalized):
|
|
571
|
+
raise ValueError("OpenCode attachment paths must be absolute files")
|
|
572
|
+
# A separate argv element keeps whitespace, leading dashes in the file
|
|
573
|
+
# name, and shell metacharacters inert.
|
|
574
|
+
cmd += ["--file", normalized]
|
|
575
|
+
# ``--file`` is variadic in OpenCode 1.18.29, so a following positional
|
|
576
|
+
# prompt is otherwise consumed as one more filename. Terminate option
|
|
577
|
+
# parsing when this helper is used with both files and a non-empty prompt.
|
|
578
|
+
# The production runner sends its prompt over stdin and removes the empty
|
|
579
|
+
# positional placeholder, so that path deliberately needs no sentinel.
|
|
580
|
+
if attachment_paths and prompt:
|
|
581
|
+
cmd.append("--")
|
|
582
|
+
# The prompt is positional and must stay last so no later flag can be
|
|
583
|
+
# parsed out of provider-controlled text.
|
|
584
|
+
cmd.append(prompt)
|
|
585
|
+
return cmd
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def parse_opencode_models_list(output: str) -> list[dict[str, Any]]:
|
|
589
|
+
"""Parse `opencode models` output: one `provider/model` id per line."""
|
|
590
|
+
|
|
591
|
+
models: list[dict[str, Any]] = []
|
|
592
|
+
seen: set[str] = set()
|
|
593
|
+
for raw_line in output.splitlines():
|
|
594
|
+
line = raw_line.strip()
|
|
595
|
+
if not line or "/" not in line or " " in line:
|
|
596
|
+
continue
|
|
597
|
+
if line in seen:
|
|
598
|
+
continue
|
|
599
|
+
seen.add(line)
|
|
600
|
+
provider, _, name = line.partition("/")
|
|
601
|
+
models.append({
|
|
602
|
+
"id": line,
|
|
603
|
+
"provider": provider,
|
|
604
|
+
"label": name.replace("-", " ").strip() or line,
|
|
605
|
+
"is_free": name.endswith("-free"),
|
|
606
|
+
})
|
|
607
|
+
return models
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
OPENCODE_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
611
|
+
|
|
612
|
+
# Captured verbatim: resuming an unknown session exits 1 with this on stderr
|
|
613
|
+
# and not a single JSON event on stdout, so the resume failure is invisible to
|
|
614
|
+
# the stream parser and has to be recognised here.
|
|
615
|
+
OPENCODE_RESUME_FAILURE_MARKERS = (
|
|
616
|
+
"session not found",
|
|
617
|
+
"no such session",
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def strip_opencode_ansi(text: str) -> str:
|
|
622
|
+
"""Remove the colour codes OpenCode writes to a non-tty stderr."""
|
|
623
|
+
|
|
624
|
+
return OPENCODE_ANSI_ESCAPE_RE.sub("", str(text or ""))
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def opencode_stderr_diagnostic(stderr: bytes | str) -> str:
|
|
628
|
+
"""Return a short, decoded, colour-free tail of OpenCode's stderr."""
|
|
629
|
+
|
|
630
|
+
if isinstance(stderr, bytes):
|
|
631
|
+
stderr = stderr.decode("utf-8", "replace")
|
|
632
|
+
clean = strip_opencode_ansi(stderr)
|
|
633
|
+
lines = [line.strip() for line in clean.splitlines() if line.strip()]
|
|
634
|
+
if not lines:
|
|
635
|
+
return ""
|
|
636
|
+
return " / ".join(lines[-3:])[:500]
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def opencode_resume_failure(stderr: bytes | str) -> bool:
|
|
640
|
+
"""Report whether a turn failed because the resumed session is gone.
|
|
641
|
+
|
|
642
|
+
OpenCode does not fall back to a new session; it exits nonzero having done
|
|
643
|
+
nothing. Treating that as a generic failure would strand the chat on a
|
|
644
|
+
dead session id forever, so the runner needs to tell this case apart.
|
|
645
|
+
"""
|
|
646
|
+
|
|
647
|
+
if isinstance(stderr, bytes):
|
|
648
|
+
stderr = stderr.decode("utf-8", "replace")
|
|
649
|
+
lowered = strip_opencode_ansi(stderr).lower()
|
|
650
|
+
return any(marker in lowered for marker in OPENCODE_RESUME_FAILURE_MARKERS)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def merge_opencode_usage(
|
|
654
|
+
total: dict[str, Any], step_usage: dict[str, Any]
|
|
655
|
+
) -> dict[str, Any]:
|
|
656
|
+
"""Accumulate per-step usage into one per-turn total.
|
|
657
|
+
|
|
658
|
+
Unlike Cursor, which reports usage once at the end of a turn, OpenCode
|
|
659
|
+
emits token counts on every `step_finish`. Taking only the last one would
|
|
660
|
+
under-report a multi-step turn.
|
|
661
|
+
"""
|
|
662
|
+
|
|
663
|
+
merged = dict(total)
|
|
664
|
+
for key, value in step_usage.items():
|
|
665
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
666
|
+
continue
|
|
667
|
+
merged[key] = merged.get(key, 0) + value
|
|
668
|
+
return merged
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def opencode_live_event_summary(event: dict[str, Any]) -> str:
|
|
672
|
+
"""One short line describing a normalized event for the live stdout tail."""
|
|
673
|
+
|
|
674
|
+
kind = str(event.get("kind") or "")
|
|
675
|
+
if kind in {
|
|
676
|
+
"tool_started", "tool_finished", "tool_rejected", "tool_failed"
|
|
677
|
+
}:
|
|
678
|
+
return f"{kind}: {str(event.get('tool') or 'tool')[:80]}"
|
|
679
|
+
if kind in {"assistant_text", "reasoning_delta"}:
|
|
680
|
+
return f"{kind}: {str(event.get('text') or '')[:120]}"
|
|
681
|
+
if kind == "step_finished":
|
|
682
|
+
return f"step_finished: {str(event.get('reason') or '')[:40]}"
|
|
683
|
+
if kind == "turn_error":
|
|
684
|
+
return f"turn_error: {str(event.get('message') or '')[:120]}"
|
|
685
|
+
return kind
|