pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11

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 (133) hide show
  1. package/AGENTS.global.md +206 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +350 -0
  4. package/README.md +561 -52
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +70 -4
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +24 -17
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +428 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/backup.mjs +475 -0
  22. package/scripts/install/cli-ui.mjs +6 -1
  23. package/scripts/install/config-migration.mjs +317 -4
  24. package/scripts/install/exit-codes.mjs +26 -0
  25. package/scripts/install/health-check.mjs +21 -19
  26. package/scripts/install/interactive.mjs +16 -2
  27. package/scripts/install/migrate.mjs +41 -22
  28. package/scripts/install/opencode-version.mjs +246 -4
  29. package/scripts/install/opencode.mjs +1175 -215
  30. package/scripts/install/plan.mjs +287 -0
  31. package/scripts/install/plugin.mjs +62 -14
  32. package/scripts/install/shared.mjs +190 -48
  33. package/scripts/install/strings.mjs +20 -0
  34. package/scripts/install/venv.mjs +13 -3
  35. package/scripts/install/vision-prompt.mjs +171 -0
  36. package/scripts/install-mcp.mjs +28 -9
  37. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  38. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  39. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  40. package/scripts/probe-context-rehydrate.mjs +8 -2
  41. package/scripts/probe-pantheon-cost.mjs +71 -15
  42. package/scripts/prune.mjs +9 -3
  43. package/scripts/restore.mjs +222 -0
  44. package/scripts/run-node-tests.mjs +189 -0
  45. package/scripts/sync-artifacts.mjs +26 -13
  46. package/scripts/sync-tui.mjs +24 -3
  47. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  48. package/scripts/validate-routing.mjs +3 -3
  49. package/scripts/validate-sandbox-install.mjs +138 -0
  50. package/scripts/versioning.mjs +13 -1
  51. package/scripts/vision-status.mjs +682 -0
  52. package/skills-lock.json +8 -8
  53. package/src/agents/aphrodite.md +20 -37
  54. package/src/agents/apollo.md +7 -3
  55. package/src/agents/athena.md +63 -35
  56. package/src/agents/demeter.md +8 -28
  57. package/src/agents/gaia.md +1 -1
  58. package/src/agents/hephaestus.md +10 -21
  59. package/src/agents/hermes.md +15 -38
  60. package/src/agents/iris.md +7 -1
  61. package/src/agents/mnemosyne.md +18 -54
  62. package/src/agents/nyx.md +1 -1
  63. package/src/agents/prometheus.md +16 -20
  64. package/src/agents/talos.md +10 -2
  65. package/src/agents/themis.md +5 -5
  66. package/src/agents/zeus.md +99 -172
  67. package/src/instructions/agent-return-format.instructions.md +34 -17
  68. package/src/instructions/backend-standards.instructions.md +2 -0
  69. package/src/instructions/frontend-standards.instructions.md +3 -1
  70. package/src/instructions/memory-protocol.instructions.md +12 -110
  71. package/src/instructions/test-economy.instructions.md +21 -0
  72. package/src/instructions/zeus-anti-stall.instructions.md +20 -67
  73. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  74. package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
  75. package/src/instructions/zeus-memory-operations.instructions.md +30 -0
  76. package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
  77. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  78. package/src/mcp/eval_store.py +2 -2
  79. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  80. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  81. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  82. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  83. package/src/mcp/requirements-mcp.txt +6 -4
  84. package/src/mcp/requirements-vision.txt +2 -2
  85. package/src/pantheon/cost-command.ts +403 -102
  86. package/src/pantheon/delegation-enforce.ts +10 -1
  87. package/src/pantheon/model-agents.ts +19 -0
  88. package/src/pantheon/model-command.ts +218 -56
  89. package/src/pantheon/presets.d.mts +0 -8
  90. package/src/pantheon/presets.mjs +5 -32
  91. package/src/pantheon/v2-bounded-read.ts +640 -0
  92. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  93. package/src/pantheon/v2-execute-after.ts +186 -0
  94. package/src/pantheon/v2-model-overrides.ts +275 -0
  95. package/src/pantheon/v2-tools.ts +418 -0
  96. package/src/pantheon/v2-unsupported.d.mts +11 -0
  97. package/src/pantheon/v2-unsupported.mjs +90 -0
  98. package/src/pantheon/v2-vision.ts +622 -0
  99. package/src/pantheon/vision-local.ts +531 -0
  100. package/src/pantheon/vision.ts +1160 -115
  101. package/src/plugin-v2.ts +1158 -219
  102. package/src/plugin.ts +12 -9
  103. package/src/plugins/tui/dist/server.d.ts +1 -2
  104. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  105. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  106. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  107. package/src/plugins/tui/dist/tui-v2.js +659 -0
  108. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  109. package/src/plugins/tui/dist/tui.d.ts +85 -82
  110. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  111. package/src/plugins/tui/dist/tui.js +72 -59
  112. package/src/plugins/tui/index-v2.ts +6 -0
  113. package/src/plugins/tui/package-lock.json +5516 -1353
  114. package/src/plugins/tui/package.json +22 -12
  115. package/src/plugins/tui/src/index-v2.tsx +734 -0
  116. package/src/plugins/tui/src/index.tsx +106 -85
  117. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  118. package/src/plugins/tui/tsconfig.build.json +5 -0
  119. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  120. package/src/plugins/tui/tsdown.config.ts +22 -2
  121. package/src/plugins/tui/tui-v2.ts +2 -0
  122. package/src/plugins/tui/tui.ts +3 -0
  123. package/src/routing.yml +11 -21
  124. package/src/skills/README.md +3 -3
  125. package/src/skills/agent-coordination/SKILL.md +7 -2
  126. package/src/skills/artifact-management/SKILL.md +12 -5
  127. package/src/skills/auto-continue/SKILL.md +52 -35
  128. package/src/skills/code-review-checklist/SKILL.md +5 -5
  129. package/src/skills/context-compression/SKILL.md +26 -879
  130. package/src/skills/session-goal/SKILL.md +14 -5
  131. package/src/skills/spec-driven-development/SKILL.md +3 -1
  132. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  133. package/opencode.json +0 -58
@@ -15,6 +15,7 @@ import json
15
15
  import logging
16
16
  import mimetypes
17
17
  import os
18
+ import re
18
19
  import sqlite3
19
20
  import struct
20
21
  import sys
@@ -24,7 +25,7 @@ from typing import Any
24
25
  from urllib.parse import unquote, urlparse
25
26
 
26
27
  import httpx
27
- from mcp.server.fastmcp import FastMCP
28
+ from fastmcp import FastMCP
28
29
 
29
30
  mcp = FastMCP("pantheon-vision")
30
31
 
@@ -59,9 +60,24 @@ if not _logger.handlers:
59
60
  MAX_IMAGE_BYTES = 25 * 1024 * 1024
60
61
  REQUEST_TIMEOUT_SECONDS = 20.0
61
62
  HTTP_ERROR_STATUS = 400
63
+ HTTP_BAD_REQUEST = 400
64
+ HTTP_UNAUTHORIZED = 401
65
+ HTTP_FORBIDDEN = 403
62
66
  DEFAULT_MODEL = "opencode-go/mimo-v2.5"
63
67
  GO_ENDPOINT = "https://opencode.ai/zen/go/v1/chat/completions"
64
68
  OPENCODE_ENDPOINT = "https://opencode.ai/zen/v1/chat/completions"
69
+ # The Zen gateway rejects a sessionless request with HTTP 400 MissingSessionID.
70
+ # Only Zen endpoints accept (and require) this header; it is never sent
71
+ # elsewhere.
72
+ SESSION_HEADER = "x-opencode-session"
73
+ _ZEN_ENDPOINT_PREFIX = "https://opencode.ai/zen/"
74
+ # A session id is an opaque OpenCode token. Only the characters real ids use
75
+ # (`ses_...`) plus the separators existing callers already pass are accepted;
76
+ # the length is bounded. Anything else — empty, whitespace, control characters,
77
+ # quotes, CR/LF — is rejected instead of being copied into the header, where a
78
+ # CRLF would split it and let a caller inject extra headers/requests.
79
+ _SESSION_ID_MAX_LENGTH = 128
80
+ _SESSION_ID_PATTERN = re.compile(r"[A-Za-z0-9._:-]+")
65
81
  _ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}
66
82
  _EXTENSION_MIME_TYPES = {
67
83
  ".png": "image/png",
@@ -269,6 +285,36 @@ def _endpoint_for_model(model: str) -> str:
269
285
  return OPENCODE_ENDPOINT if model.startswith("opencode/") else GO_ENDPOINT
270
286
 
271
287
 
288
+ def _is_zen_endpoint(endpoint: str) -> bool:
289
+ """Return whether ``endpoint`` is an OpenCode Zen gateway URL.
290
+
291
+ The destination host is the security boundary for the
292
+ ``x-opencode-session`` header: the session identity must reach a Zen
293
+ endpoint and nothing else. This is a prefix check (not equality) so a
294
+ self-hosted Zen path still qualifies while a non-Zen URL never does.
295
+ """
296
+ return endpoint.startswith(_ZEN_ENDPOINT_PREFIX)
297
+
298
+
299
+ def _validated_session_id(value: object) -> str | None:
300
+ """Return a session id safe to send in the Zen header, or None.
301
+
302
+ The value arrives from a tool parameter the plugin forwards (a local MCP
303
+ process is not handed a session id by the host), so it is untrusted input:
304
+ only a trimmed, bounded, token-shaped value is accepted. An empty or
305
+ whitespace-only string, a control character, a quote, or an embedded
306
+ CR/LF yields None so the header is simply omitted — never copied verbatim.
307
+ """
308
+ if not isinstance(value, str):
309
+ return None
310
+ candidate = value.strip()
311
+ if not candidate or len(candidate) > _SESSION_ID_MAX_LENGTH:
312
+ return None
313
+ if _SESSION_ID_PATTERN.fullmatch(candidate) is None:
314
+ return None
315
+ return candidate
316
+
317
+
272
318
  def _strip_provider_prefix(model: str) -> str:
273
319
  """Return the model ID without its provider prefix.
274
320
 
@@ -283,19 +329,39 @@ def _strip_provider_prefix(model: str) -> str:
283
329
 
284
330
 
285
331
  def _opencode_data_dir() -> Path:
286
- """Locate the OpenCode data directory on either V1 or V2 hosts."""
287
- return Path.home() / ".local" / "share" / "opencode"
332
+ """Locate the OpenCode data directory on either V1 or V2 hosts.
333
+
334
+ Honors ``XDG_DATA_HOME`` (``$XDG_DATA_HOME/opencode`` when set, else
335
+ ``~/.local/share/opencode``) so a relocated data root resolves the same
336
+ stores as the plugin. ``OPENCODE_DB`` still names the exact credential db.
337
+ """
338
+ data_home = os.getenv("XDG_DATA_HOME", "").strip()
339
+ base = Path(data_home) if data_home else Path.home() / ".local" / "share"
340
+ return base / "opencode"
341
+
342
+
343
+ def _credential_db_path() -> Path:
344
+ """Path to the OpenCode V2 credential database.
345
+
346
+ ``OPENCODE_DB`` names the exact file when the host relocated it (e.g. a
347
+ sandbox with its own db); otherwise the default ``<data dir>/opencode.db``
348
+ sibling of the V1 ``auth.json`` is used.
349
+ """
350
+ override = os.getenv("OPENCODE_DB", "").strip()
351
+ if override:
352
+ return Path(override)
353
+ return _opencode_data_dir() / "opencode.db"
288
354
 
289
355
 
290
356
  def _key_from_credential_db(provider: str) -> str | None:
291
357
  """Read a provider key from the V2 SQLite credential store.
292
358
 
293
359
  OpenCode V2 moved credentials out of ``<data dir>/auth.json`` and into a
294
- ``credential`` table in ``<data dir>/opencode.db``; the V1 JSON file no
295
- longer exists on a V2 host, so reading it alone left auth silently
296
- unresolvable. The value is plaintext JSON of the form
297
- ``{"type": "key", "key": "..."}``, so the stdlib is sufficient — no new
298
- dependency.
360
+ ``credential`` table in the OpenCode database (``OPENCODE_DB``, else
361
+ ``<data dir>/opencode.db``); the V1 JSON file no longer exists on a V2
362
+ host, so reading it alone left auth silently unresolvable. The value is
363
+ plaintext JSON of the form ``{"type": "key", "key": "..."}``, so the
364
+ stdlib is sufficient — no new dependency.
299
365
 
300
366
  The database is opened read-only via a URI so a missing or locked file
301
367
  cannot be created or mutated by a health check.
@@ -306,7 +372,7 @@ def _key_from_credential_db(provider: str) -> str | None:
306
372
  Returns:
307
373
  The stored key, or None when the store, table, row, or key is absent.
308
374
  """
309
- db_path = _opencode_data_dir() / "opencode.db"
375
+ db_path = _credential_db_path()
310
376
  if not db_path.is_file():
311
377
  return None
312
378
  uri = f"file:{db_path}?mode=ro"
@@ -343,9 +409,9 @@ def _resolve_auth() -> tuple[str | None, str]:
343
409
  4. a legacy V1 ``<data dir>/auth.json`` file, for hosts that still have one
344
410
 
345
411
  The V1 file is checked last and only when it exists; it is absent on a V2
346
- host. When no source yields a key this returns None and the caller raises,
347
- so the failure names the paths and env vars that were tried rather than
348
- surfacing as a confusing 401 from the gateway.
412
+ host. When no source yields a key this returns ``None`` silently: the
413
+ graceful callers (``vision_describe`` / ``vision_ocr``) degrade to local
414
+ extraction, and only the fatal gateway path logs the paths/env vars tried.
349
415
  """
350
416
  model = os.getenv("PANTHEON_VISION_MODEL") or DEFAULT_MODEL
351
417
  env_key = os.getenv("PANTHEON_OPENCODE_API_KEY") or os.getenv("OPENCODE_API_KEY")
@@ -375,14 +441,6 @@ def _resolve_auth() -> tuple[str | None, str]:
375
441
  if key:
376
442
  return key, endpoint
377
443
 
378
- _logger.error(
379
- "No OpenCode credential found. Set PANTHEON_OPENCODE_API_KEY (or "
380
- "OPENCODE_API_KEY), or store a key for 'opencode-go' in %s "
381
- "(tried: env, the credential table in %s, and the legacy %s).",
382
- _opencode_data_dir() / "opencode.db",
383
- _opencode_data_dir() / "opencode.db",
384
- auth_path,
385
- )
386
444
  return None, _endpoint_for_model(model)
387
445
 
388
446
 
@@ -405,6 +463,86 @@ def _metadata(image: ImageInput) -> dict[str, Any]:
405
463
  }
406
464
 
407
465
 
466
+ def _local_fallback_note() -> str:
467
+ """Local-fallback guidance naming the EFFECTIVE credential database path.
468
+
469
+ Built at call time (not a module constant) so a relocated host — via
470
+ ``OPENCODE_DB`` or ``XDG_DATA_HOME`` — points the operator at the file the
471
+ resolver actually reads, matching the ``_gateway`` diagnostics.
472
+ """
473
+ return (
474
+ "Gateway de visão indisponível (sem credencial). Usando extração local "
475
+ "(metadados + best-effort), sem conteúdo inventado. Rode `opencode auth "
476
+ "login`, configure PANTHEON_OPENCODE_API_KEY ou OPENCODE_API_KEY, ou "
477
+ f"guarde uma chave para 'opencode-go' em {_credential_db_path()}."
478
+ )
479
+
480
+
481
+ def _image_bytes(image: ImageInput) -> bytes | None:
482
+ """Return the decoded bytes of an in-memory image source, else None.
483
+
484
+ Only data URIs carry bytes locally. Remote URLs are intentionally not
485
+ fetched here: the local fallback must stay offline and deterministic.
486
+ """
487
+ if not image.source.startswith("data:"):
488
+ return None
489
+ _header, separator, encoded = image.source.partition(",")
490
+ if not separator:
491
+ return None
492
+ try:
493
+ return base64.b64decode(encoded, validate=True)
494
+ except (ValueError, binascii.Error):
495
+ return None
496
+
497
+
498
+ def _ocr_bytes(data: bytes) -> str | None:
499
+ """Best-effort OCR via pytesseract when it is installed.
500
+
501
+ The optional dependency is import-guarded: no pytesseract/Pillow is required
502
+ (neither is added to requirements-vision.txt). Any failure yields None so
503
+ the caller degrades to metadata-only instead of inventing text.
504
+ """
505
+ try:
506
+ import io # noqa: PLC0415
507
+
508
+ import pytesseract # noqa: PLC0415
509
+ from PIL import Image as PILImage # noqa: PLC0415
510
+ except ImportError:
511
+ return None
512
+ try:
513
+ with PILImage.open(io.BytesIO(data)) as opened:
514
+ text = pytesseract.image_to_string(opened)
515
+ except Exception:
516
+ return None
517
+ stripped = text.strip() if isinstance(text, str) else ""
518
+ return stripped or None
519
+
520
+
521
+ def _local_vision_text(image: ImageInput) -> str:
522
+ """Deterministic local fallback: metadata, plus OCR when available.
523
+
524
+ Never fabricates a description; when pytesseract is absent it states so.
525
+ """
526
+ metadata = _metadata(image)
527
+ dims = (
528
+ f"{metadata['width']}x{metadata['height']}"
529
+ if metadata["dimensions"]
530
+ else "dimensões desconhecidas"
531
+ )
532
+ size = metadata["size"]
533
+ size_text = f"{size} bytes" if isinstance(size, int) else "tamanho desconhecido"
534
+ data = _image_bytes(image)
535
+ ocr_text = _ocr_bytes(data) if data is not None else None
536
+ lines = [
537
+ f"Imagem local: {dims} {metadata['mime']}, {size_text}.",
538
+ f"Texto OCR: {ocr_text}"
539
+ if ocr_text
540
+ else "Texto OCR: indisponível (tesseract/pytesseract não instalado).",
541
+ _local_fallback_note(),
542
+ ]
543
+ return "\n".join(lines)
544
+
545
+
408
546
  def _content_text(value: Any) -> str:
409
547
  """Extract text from OpenAI-compatible message content."""
410
548
  if isinstance(value, str):
@@ -465,14 +603,66 @@ def _parse_json_object(value: str) -> dict[str, Any] | None:
465
603
  return None
466
604
 
467
605
 
468
- async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False) -> str:
469
- """Send one multimodal request to the selected OpenCode gateway."""
606
+ def _gateway_http_error(status: int) -> str:
607
+ """Actionable, non-sensitive VisionError text for a gateway HTTP failure.
608
+
609
+ Maps the known Zen failures: 400 ``MissingSessionID`` (session header
610
+ absent), 401 (credential/model), 403 (free-tier model usable only from
611
+ inside OpenCode, or no paid access). Never includes the credential, image
612
+ payload, or session id.
613
+ """
614
+ if status == HTTP_BAD_REQUEST:
615
+ return (
616
+ "Vision gateway rejected the request (HTTP 400 MissingSessionID): "
617
+ "the x-opencode-session header is missing — call the tool from an "
618
+ "OpenCode session so the session id can be forwarded."
619
+ )
620
+ if status == HTTP_UNAUTHORIZED:
621
+ return "Vision gateway rejected the credential (HTTP 401)."
622
+ if status == HTTP_FORBIDDEN:
623
+ return (
624
+ "Vision gateway denied access (HTTP 403): the model may be "
625
+ "free-tier (usable only from inside OpenCode) or this account has "
626
+ "no paid access to it."
627
+ )
628
+ return "Vision gateway returned an HTTP error."
629
+
630
+
631
+ async def _gateway(
632
+ image: ImageInput,
633
+ prompt: str,
634
+ *,
635
+ structured: bool = False,
636
+ session_id: str | None = None,
637
+ ) -> str:
638
+ """Send one multimodal request to the selected OpenCode gateway.
639
+
640
+ ``session_id`` (when present and valid — see ``_validated_session_id``) is
641
+ sent as the ``x-opencode-session`` header — and ONLY for a Zen endpoint,
642
+ which rejects the request with HTTP 400 ``MissingSessionID`` otherwise. An
643
+ unusable value (empty, whitespace, control characters, CR/LF, quotes) is
644
+ dropped rather than copied into the header. It is never logged and never
645
+ sent to a non-Zen host.
646
+ """
470
647
  key, endpoint = _resolve_auth()
471
648
  if not key:
649
+ # Missing credential is FATAL here (unlike the graceful describe/ocr
650
+ # local fallback), so this is the one place that logs the sources tried.
651
+ db_path = _credential_db_path()
652
+ auth_path = _opencode_data_dir() / "auth.json"
653
+ _logger.error(
654
+ "No OpenCode credential found. Run `opencode auth login`, set "
655
+ "PANTHEON_OPENCODE_API_KEY (or OPENCODE_API_KEY), or store a key "
656
+ "for 'opencode-go' in %s (tried: env, the credential table in %s, "
657
+ "and the legacy %s).",
658
+ db_path,
659
+ db_path,
660
+ auth_path,
661
+ )
472
662
  raise VisionError(
473
- "OpenCode API key not configured. Set PANTHEON_OPENCODE_API_KEY or "
474
- "OPENCODE_API_KEY, or store a key for 'opencode-go' in the OpenCode "
475
- "credential database (~/.local/share/opencode/opencode.db). "
663
+ "OpenCode credential not configured. Run `opencode auth login`, "
664
+ "set PANTHEON_OPENCODE_API_KEY or OPENCODE_API_KEY, or store a key "
665
+ f"for 'opencode-go' in the OpenCode credential database ({db_path}). "
476
666
  "See the pantheon.vision error log for the paths tried."
477
667
  )
478
668
  safe_prompt = prompt.replace(key, "[redacted]")
@@ -486,21 +676,25 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
486
676
  }
487
677
  if structured:
488
678
  body["response_format"] = {"type": "json_object"}
679
+ headers = {
680
+ "Authorization": f"Bearer {key}",
681
+ "Content-Type": "application/json",
682
+ }
683
+ safe_session_id = _validated_session_id(session_id)
684
+ if safe_session_id is not None and _is_zen_endpoint(endpoint):
685
+ headers[SESSION_HEADER] = safe_session_id
489
686
  try:
490
687
  async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client:
491
688
  response = await client.post(
492
689
  endpoint,
493
- headers={
494
- "Authorization": f"Bearer {key}",
495
- "Content-Type": "application/json",
496
- },
690
+ headers=headers,
497
691
  json=body,
498
692
  )
499
693
  if response.status_code >= HTTP_ERROR_STATUS:
500
694
  # Sanitized diagnostic: status, endpoint, bare model, truncated
501
695
  # gateway detail (scrubbed), and a body summary with no keys or
502
696
  # image bytes. Logged to stderr so the MCP stdio protocol is
503
- # never corrupted.
697
+ # never corrupted. The session id is NEVER logged.
504
698
  detail = _scrub(str(getattr(response, "text", "")), key)
505
699
  _logger.warning(
506
700
  "vision gateway error status=%s endpoint=%s model=%s detail=%s body=%s",
@@ -510,15 +704,30 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
510
704
  detail[:200] or "no detail",
511
705
  json.dumps(_sanitized_gateway_body(body)),
512
706
  )
513
- raise VisionError("Vision gateway returned an HTTP error.")
707
+ raise VisionError(_gateway_http_error(response.status_code))
514
708
  payload = response.json()
515
709
  choices = payload.get("choices") if isinstance(payload, dict) else None
516
- message = choices[0].get("message") if choices else None
710
+ first_choice = choices[0] if isinstance(choices, list) and choices else None
711
+ message = (
712
+ first_choice.get("message") if isinstance(first_choice, dict) else None
713
+ )
517
714
  text = _content_text(
518
715
  message.get("content") if isinstance(message, dict) else ""
519
716
  )
520
717
  if not text:
521
- raise VisionError("Vision gateway returned an empty response.")
718
+ # Surface finish_reason (e.g. `length`) so a reasoning model cut off
719
+ # by max_tokens yields an actionable message, not a bare "empty".
720
+ finish = (
721
+ first_choice.get("finish_reason")
722
+ if isinstance(first_choice, dict)
723
+ else None
724
+ )
725
+ suffix = (
726
+ f" (finish_reason: {finish})"
727
+ if isinstance(finish, str) and finish
728
+ else ""
729
+ )
730
+ raise VisionError(f"Vision gateway returned an empty response{suffix}.")
522
731
  return _scrub(text, key)
523
732
  except VisionError:
524
733
  raise
@@ -563,21 +772,50 @@ _ANALYZE_PROMPT = (
563
772
 
564
773
 
565
774
  @mcp.tool(description="Describe an image faithfully using the Pantheon vision gateway.")
566
- async def vision_describe(path: str, prompt: str | None = None) -> str:
567
- """Describe image content, text, layout, objects, and uncertainty."""
775
+ async def vision_describe(
776
+ path: str, prompt: str | None = None, session_id: str | None = None
777
+ ) -> str:
778
+ """Describe image content, text, layout, objects, and uncertainty.
779
+
780
+ When no gateway credential is available the tool degrades to a
781
+ deterministic local extraction (metadata + best-effort OCR) instead of
782
+ failing outright; it never returns an invented description.
783
+
784
+ ``session_id`` is the caller's OpenCode session id. The Zen gateway rejects
785
+ a sessionless request (HTTP 400 MissingSessionID); the plugin forwards it
786
+ through the tool call because a local MCP process is not handed one by the
787
+ host. It is sent only to a Zen endpoint and never logged.
788
+ """
568
789
  try:
569
790
  image = await _prepare_input(path)
570
- return await _gateway(image, prompt or _DESCRIBE_PROMPT)
791
+ except Exception as exc:
792
+ return _error_message(exc)
793
+ key, _endpoint = _resolve_auth()
794
+ if not key:
795
+ return _local_vision_text(image)
796
+ try:
797
+ return await _gateway(image, prompt or _DESCRIBE_PROMPT, session_id=session_id)
571
798
  except Exception as exc:
572
799
  return _error_message(exc)
573
800
 
574
801
 
575
802
  @mcp.tool(description="Extract visible text from an image with OCR.")
576
- async def vision_ocr(path: str) -> str:
577
- """Transcribe visible image text while preserving line formatting."""
803
+ async def vision_ocr(path: str, session_id: str | None = None) -> str:
804
+ """Transcribe visible image text while preserving line formatting.
805
+
806
+ Falls back to local, best-effort OCR (metadata + pytesseract when installed)
807
+ when no gateway credential is configured. ``session_id`` reaches the Zen
808
+ gateway as the ``x-opencode-session`` header (see ``vision_describe``).
809
+ """
578
810
  try:
579
811
  image = await _prepare_input(path)
580
- return await _gateway(image, _OCR_PROMPT)
812
+ except Exception as exc:
813
+ return _error_message(exc)
814
+ key, _endpoint = _resolve_auth()
815
+ if not key:
816
+ return _local_vision_text(image)
817
+ try:
818
+ return await _gateway(image, _OCR_PROMPT, session_id=session_id)
581
819
  except Exception as exc:
582
820
  return _error_message(exc)
583
821
 
@@ -585,11 +823,17 @@ async def vision_ocr(path: str) -> str:
585
823
  @mcp.tool(
586
824
  description="Analyze an image and return metadata, description, and OCR as JSON."
587
825
  )
588
- async def vision_analyze(path: str) -> str:
589
- """Return local metadata plus one structured description/OCR gateway call."""
826
+ async def vision_analyze(path: str, session_id: str | None = None) -> str:
827
+ """Return local metadata plus one structured description/OCR gateway call.
828
+
829
+ ``session_id`` reaches the Zen gateway as the ``x-opencode-session`` header
830
+ (see ``vision_describe``).
831
+ """
590
832
  try:
591
833
  image = await _prepare_input(path)
592
- content = await _gateway(image, _ANALYZE_PROMPT, structured=True)
834
+ content = await _gateway(
835
+ image, _ANALYZE_PROMPT, structured=True, session_id=session_id
836
+ )
593
837
  parsed = _parse_json_object(content)
594
838
  if parsed is None:
595
839
  parsed = {"description": content, "ocr": "No structured OCR was returned."}
@@ -1,10 +1,12 @@
1
1
  # Pantheon MCP runtime dependencies — EXACT pins (issue #21)
2
- # Pinned to the versions validated in the sandbox venv (2026-08-05).
3
- # Update deliberately via: pip install -U <pkg> && pip freeze | grep -iE '^(mcp|fastmcp|pyyaml)'
2
+ # Coordinated MCP SDK/FastMCP major migration (Dependabot PRs #140 and #188).
3
+ # Update deliberately via: pip install -U <pkg> && pip freeze | grep -iE '^(mcp|fastmcp|pyyaml|httpx)=='
4
4
  # Memory search is FTS5 (SQLite stdlib): sqlite-vec and fastembed were removed
5
5
  # with the vector pipeline — the server no longer imports either.
6
- mcp==1.29.0
7
- fastmcp==3.4.6
6
+ mcp==2.2.0
7
+ fastmcp==4.0.10
8
8
  pydantic==2.12.5
9
9
  pydantic-settings==2.14.2
10
10
  pyyaml==6.0.3
11
+ # Vision imports httpx; MCP 2.2.0 provides httpx2, a different package.
12
+ httpx==0.28.1
@@ -1,5 +1,5 @@
1
- mcp==1.29.0
2
- fastmcp==3.4.6
1
+ mcp==2.2.0
2
+ fastmcp==4.0.10
3
3
  pydantic==2.12.5
4
4
  pydantic-settings==2.14.2
5
5
  httpx==0.28.1