@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.
Files changed (45) hide show
  1. package/README.md +20 -6
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +5283 -698
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +90 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_history_repair.py +79 -14
  14. package/server/codex_provider.py +160 -77
  15. package/server/codex_side_question.py +154 -57
  16. package/server/cursor_agent_client.py +27 -1
  17. package/server/cursor_api_key.py +64 -0
  18. package/server/cursor_history.py +219 -0
  19. package/server/cursor_provider_mcp.py +290 -0
  20. package/server/execution_durability.py +10 -6
  21. package/server/execution_preparation.py +6 -2
  22. package/server/install.sh +282 -78
  23. package/server/instances.sh +15 -0
  24. package/server/interactive_chat_native.py +4 -3
  25. package/server/interactive_chat_projection.py +6 -19
  26. package/server/interactive_chat_share_routes.py +71 -45
  27. package/server/interactive_chat_share_web.py +2 -2
  28. package/server/interactive_chat_shares.py +6 -11
  29. package/server/local_session_ownership.py +232 -0
  30. package/server/opencode_agent_client.py +685 -0
  31. package/server/provider_commands.py +579 -0
  32. package/server/provider_connections.py +665 -0
  33. package/server/provider_usage.py +241 -0
  34. package/server/public_chat_shares.py +60 -62
  35. package/server/public_chat_transcript.py +9 -31
  36. package/server/pyproject.toml +1 -0
  37. package/server/server_instances.py +931 -0
  38. package/server/shared_chat_video_stream.py +21 -2
  39. package/server/shared_chat_videos.py +54 -24
  40. package/server/side_questions.py +493 -10
  41. package/server/title_generation.py +4 -1
  42. package/server/uninstall.sh +95 -17
  43. package/server/update_preparation.py +4 -1
  44. package/server/update_runner.py +16 -0
  45. 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