code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
"""OpenCodeProvider: the ``opencode run`` concrete provider.
|
|
2
|
+
|
|
3
|
+
OpenCode (https://github.com/sst/opencode) is an open-source terminal coding
|
|
4
|
+
assistant that supports multiple AI backends (Anthropic, OpenAI, etc.) via the
|
|
5
|
+
user's own API keys. This provider wraps ``opencode run`` for use as a coord
|
|
6
|
+
worker backend.
|
|
7
|
+
|
|
8
|
+
**CORRECTED AGAINST REAL CAPTURED OUTPUT (#1704).** #1703 ran a real
|
|
9
|
+
``opencode`` 1.18.11 binary against real models (free and paid) and recorded
|
|
10
|
+
every finding in ``docs/OPENCODE_VERIFICATION.md``, replacing
|
|
11
|
+
``tests/fixtures/opencode_run_sample.jsonl`` with a verbatim successful
|
|
12
|
+
capture and adding ``tests/fixtures/opencode_run_failure_sample.jsonl`` for a
|
|
13
|
+
verbatim failing one. This module is a follow-up correction: every
|
|
14
|
+
behavioural detail below cites that document.
|
|
15
|
+
|
|
16
|
+
**#1705 — per-spec-type agent definitions, deny-list enforcement PROVEN.**
|
|
17
|
+
#1704 left two things explicitly unfinished; both are done now:
|
|
18
|
+
|
|
19
|
+
* The concrete opencode agent definitions ``--agent`` references by name now
|
|
20
|
+
exist, one committed markdown file per ``spec.type``, under
|
|
21
|
+
:data:`AGENTS_ROOT` (``coord/agents/opencode/agents/<spec.type>.md``).
|
|
22
|
+
Only ``work`` is authored so far (scope of #1705 — ``review`` and other
|
|
23
|
+
types are separate issues, see :func:`_agent_definition_path`). Dispatching
|
|
24
|
+
an unauthored ``spec.type`` now raises :class:`OpenCodeAgentNotFoundError`
|
|
25
|
+
from :meth:`OpenCodeProvider.build_command` — a hard, named Python-side
|
|
26
|
+
error instead of a silent permissive fallback or an opaque CLI failure.
|
|
27
|
+
* ``capabilities().enforces_deny_list`` is now ``True`` for the reason
|
|
28
|
+
:meth:`capabilities`'s docstring gives in full: real opencode 1.18.11 runs
|
|
29
|
+
(not argv assertions) proved a ``gh`` bash call, an edit outside the
|
|
30
|
+
worktree, and an edit under ``tests/acceptance/**`` are all genuinely
|
|
31
|
+
blocked by ``coord/agents/opencode/agents/work.md``'s permission block —
|
|
32
|
+
see the named tests in ``tests/test_providers.py``.
|
|
33
|
+
|
|
34
|
+
**Agent-file discovery mechanism, empirically verified (not guessed):**
|
|
35
|
+
:meth:`env` sets ``OPENCODE_CONFIG_DIR`` to :data:`AGENTS_ROOT` so opencode
|
|
36
|
+
discovers ``agents/<spec.type>.md`` there. Two things were confirmed against
|
|
37
|
+
the real binary before relying on this, because both are safety-relevant:
|
|
38
|
+
|
|
39
|
+
1. A *flat* ``<dir>/<name>.md`` layout (no ``agents/`` subdirectory) is
|
|
40
|
+
**silently invisible** to ``--agent`` — opencode only scans
|
|
41
|
+
``<OPENCODE_CONFIG_DIR>/agents/*.md`` (and the singular ``agent/`` alias).
|
|
42
|
+
This is why the committed file lives at
|
|
43
|
+
``coord/agents/opencode/agents/work.md`` rather than the flatter
|
|
44
|
+
``coord/agents/opencode/work.md`` a first guess might reach for.
|
|
45
|
+
2. ``OPENCODE_CONFIG_DIR`` outranks a *worktree-local* ``.opencode/agents/``
|
|
46
|
+
directory of the same agent name. Verified by planting a conflicting
|
|
47
|
+
``.opencode/agents/work.md`` (wide-open ``bash``/``edit``/
|
|
48
|
+
``external_directory`` permissions) in a throwaway worktree and
|
|
49
|
+
confirming the resolved rule list — and a live ``gh issue list`` call —
|
|
50
|
+
were byte-for-byte unaffected. This matters because the worktree a
|
|
51
|
+
worker runs in is checked out from a repo coord does not fully control;
|
|
52
|
+
without this precedence a target repo could ship its own permissive
|
|
53
|
+
``work`` agent and silently shadow coord's deny-baseline one.
|
|
54
|
+
|
|
55
|
+
Differences from :class:`~.claude.ClaudeProvider`:
|
|
56
|
+
|
|
57
|
+
* ``build_command()`` invokes
|
|
58
|
+
``opencode run --format json [--attach URL] [--model M] [--session S]
|
|
59
|
+
--agent NAME --auto BRIEFING``. No ``-p``, no ``--input-format``, no
|
|
60
|
+
stream-json flags — see the method docstring for exactly why each flag is
|
|
61
|
+
there and what it replaces.
|
|
62
|
+
* ``initial_input()`` returns ``b""`` — no stdin payload needed since the
|
|
63
|
+
briefing travels on argv (confirmed: ``run [message..]`` is a genuine
|
|
64
|
+
positional argv message, no stdin protocol observed —
|
|
65
|
+
``OPENCODE_VERIFICATION.md`` "Flag surface").
|
|
66
|
+
* ``capabilities().enforces_deny_list=True`` — see above and
|
|
67
|
+
:meth:`capabilities`.
|
|
68
|
+
* ``capabilities().billing_mode="byo_key"`` — confirmed: OpenCode bills
|
|
69
|
+
against the operator's own configured provider credentials, not
|
|
70
|
+
Anthropic's ``claude -p`` credit pool (``OPENCODE_VERIFICATION.md``
|
|
71
|
+
"Flag surface" / "every ASSUMPTION" table).
|
|
72
|
+
* ``capabilities().cost_reporting=True`` and
|
|
73
|
+
``capabilities().true_system_prompt=True`` — both flipped from the
|
|
74
|
+
first-pass ``False`` now that real evidence backs them; see
|
|
75
|
+
:meth:`capabilities` for the citations.
|
|
76
|
+
* ``env()`` always sets ``OPENCODE_CONFIG_DIR`` (agent-file discovery, see
|
|
77
|
+
above) and ``OPENCODE_CONFIG`` (the OpenRouter upstream-routing pin, see
|
|
78
|
+
``coord/agents/opencode/routing.jsonc`` and :data:`ROUTING_PIN_PATH`).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
from __future__ import annotations
|
|
82
|
+
|
|
83
|
+
import json
|
|
84
|
+
from pathlib import Path
|
|
85
|
+
from typing import TYPE_CHECKING
|
|
86
|
+
|
|
87
|
+
from coord.providers.base import Capabilities, Provider, WorkerSummary
|
|
88
|
+
|
|
89
|
+
if TYPE_CHECKING:
|
|
90
|
+
from coord.agent import AssignmentSpec
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Module-level constants ─────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
#: Default binary name for the OpenCode CLI.
|
|
96
|
+
#:
|
|
97
|
+
#: Confirmed: binary name ``opencode`` is correct
|
|
98
|
+
#: (``OPENCODE_VERIFICATION.md`` "Machine / version"). Override via
|
|
99
|
+
#: ``ProviderDef(type="opencode", binary="/path/to/opencode")``.
|
|
100
|
+
DEFAULT_OPENCODE_BINARY = "opencode"
|
|
101
|
+
|
|
102
|
+
#: Sentinel string whose presence in the log signals successful completion.
|
|
103
|
+
#:
|
|
104
|
+
#: CORRECTED (#1704): there is no ``session.complete`` event — confirmed by
|
|
105
|
+
#: running the *unmodified* first-pass provider against the real fixture in
|
|
106
|
+
#: #1703 (it extracted nothing). The real terminal signal for a successful
|
|
107
|
+
#: run is the last ``step_finish`` event, whose nested ``part.reason`` is
|
|
108
|
+
#: ``"stop"`` (as opposed to ``"tool-calls"``, which ends every intermediate
|
|
109
|
+
#: turn and is followed by another ``step_start``). See
|
|
110
|
+
#: ``OPENCODE_VERIFICATION.md`` "The real terminal/completion signal".
|
|
111
|
+
#:
|
|
112
|
+
#: This is a plain substring match — ``docs/OPENCODE_VERIFICATION.md``
|
|
113
|
+
#: explicitly flags that as fragile against key reordering and recommends a
|
|
114
|
+
#: structural check (parse the line, test ``part.reason == "stop"``)
|
|
115
|
+
#: instead. :func:`_update_opencode_summary` below *does* do the structural
|
|
116
|
+
#: check for :attr:`WorkerSummary.stop_reason`; this constant is only used
|
|
117
|
+
#: by the reap thread's log-tailing early-completion heuristic (see
|
|
118
|
+
#: ``coord.agent._reap``), where the accepted risk is a missed/late
|
|
119
|
+
#: detection (the reap loop falls back to ``proc.wait()`` returning when the
|
|
120
|
+
#: process actually exits), never a wrong pass/fail verdict.
|
|
121
|
+
#:
|
|
122
|
+
#: A failing run has **no** terminal ``step_finish`` at all — it ends with a
|
|
123
|
+
#: top-level ``error`` event instead — so this marker deliberately does not
|
|
124
|
+
#: (and structurally cannot) match a failure. Failure detection is the
|
|
125
|
+
#: process exit code plus :func:`_update_opencode_summary`'s handling of the
|
|
126
|
+
#: ``error`` event, not this marker.
|
|
127
|
+
RESULT_MARKER = '"reason":"stop"'
|
|
128
|
+
|
|
129
|
+
#: Directory holding coord's committed opencode agent-discovery artifacts:
|
|
130
|
+
#: ``agents/<spec.type>.md`` (per-spec-type agent definitions) and
|
|
131
|
+
#: ``routing.jsonc`` (the OpenRouter upstream-routing pin). Computed from
|
|
132
|
+
#: this module's own file location so it resolves correctly regardless of
|
|
133
|
+
#: where coord itself is installed/checked out — mirrors the pattern
|
|
134
|
+
#: ``tests/fixtures/`` paths use relative to ``tests/``.
|
|
135
|
+
#:
|
|
136
|
+
#: **Why an env var pointing here, not a copy into the worker's worktree**
|
|
137
|
+
#: (the other option the #1705 issue named): the worktree belongs to a
|
|
138
|
+
#: repo coord does not fully control, and ``OPENCODE_CONFIG_DIR`` was
|
|
139
|
+
#: empirically confirmed to outrank a same-named agent in that worktree's
|
|
140
|
+
#: own ``.opencode/agents/`` (see the module docstring) — so pointing at
|
|
141
|
+
#: this directory is both simpler (no per-dispatch file copy / no need to
|
|
142
|
+
#: touch the worktree-setup code path in ``coord/agent.py``) and safer (a
|
|
143
|
+
#: target repo cannot shadow it).
|
|
144
|
+
AGENTS_ROOT = Path(__file__).resolve().parent.parent / "agents" / "opencode"
|
|
145
|
+
|
|
146
|
+
#: The OpenRouter upstream-routing pin (#1705 added scope) — see that
|
|
147
|
+
#: file's own header comment for the full mechanism citation. Threaded
|
|
148
|
+
#: onto the worker's environment as ``OPENCODE_CONFIG`` by :meth:`env`.
|
|
149
|
+
ROUTING_PIN_PATH = AGENTS_ROOT / "routing.jsonc"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class OpenCodeAgentNotFoundError(RuntimeError):
|
|
153
|
+
"""Raised when no committed opencode agent definition exists for a
|
|
154
|
+
``spec.type`` being dispatched.
|
|
155
|
+
|
|
156
|
+
#1705: dispatching an unauthored spec type must be a **hard, named
|
|
157
|
+
error**, never a silent permissive fallback (e.g. running with
|
|
158
|
+
opencode's allow-everything built-in default agent) and never a bare
|
|
159
|
+
CLI-level failure a few seconds into a worker's log that's hard to
|
|
160
|
+
triage. Only ``work`` is authored as of #1705 — other write-capable
|
|
161
|
+
spec types (``review``, ``smoke``, ``conflict-fix``, ...) are separate,
|
|
162
|
+
deliberately un-sped-up follow-up issues (see the module docstring).
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, spec_type: str, expected_path: Path) -> None:
|
|
166
|
+
self.spec_type = spec_type
|
|
167
|
+
self.expected_path = expected_path
|
|
168
|
+
super().__init__(
|
|
169
|
+
f"no opencode agent definition for spec.type={spec_type!r}: "
|
|
170
|
+
f"expected {expected_path} to exist. Author it under "
|
|
171
|
+
f"{AGENTS_ROOT}/agents/ before dispatching this spec type "
|
|
172
|
+
"through the opencode provider — see coord/agents/opencode/agents/work.md "
|
|
173
|
+
"for the reference shape (deny-baseline permission block + "
|
|
174
|
+
"system prompt) and #1705 for the naming/discovery contract."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _agent_definition_path(spec_type: str) -> Path:
|
|
179
|
+
"""Return the expected committed agent-file path for *spec_type*.
|
|
180
|
+
|
|
181
|
+
Pure path computation, no filesystem access — split out from
|
|
182
|
+
:func:`_agent_name_for_type` so tests can assert on the expected path
|
|
183
|
+
independently of the existence check.
|
|
184
|
+
"""
|
|
185
|
+
return AGENTS_ROOT / "agents" / f"{spec_type}.md"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _agent_name_for_type(spec_type: str) -> str:
|
|
189
|
+
"""Map ``spec.type`` to the opencode ``--agent`` name coord will pass.
|
|
190
|
+
|
|
191
|
+
**Naming contract (#1705, corrected from #1704's provisional
|
|
192
|
+
``coord-<type>`` guess): the ``--agent`` value is exactly ``spec_type``**
|
|
193
|
+
(e.g. ``"work"``), because opencode's markdown agent-discovery derives
|
|
194
|
+
the agent name from the filename minus its extension (confirmed against
|
|
195
|
+
a real opencode 1.18.11 binary: a file at ``<OPENCODE_CONFIG_DIR>/agents/
|
|
196
|
+
work.md`` is discovered as an agent literally named ``work``, not
|
|
197
|
+
``coord-work``) — see :data:`AGENTS_ROOT` and the module docstring.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
OpenCodeAgentNotFoundError: when ``coord/agents/opencode/agents/
|
|
201
|
+
<spec_type>.md`` does not exist. This is a **hard error**, not
|
|
202
|
+
a silent fallback — see that exception's docstring. As of
|
|
203
|
+
#1705 only ``spec_type == "work"`` has a committed file; every
|
|
204
|
+
other spec type raises until its own follow-up issue lands one.
|
|
205
|
+
"""
|
|
206
|
+
path = _agent_definition_path(spec_type)
|
|
207
|
+
if not path.is_file():
|
|
208
|
+
raise OpenCodeAgentNotFoundError(spec_type, path)
|
|
209
|
+
return spec_type
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class OpenCodeProvider(Provider):
|
|
213
|
+
"""Concrete provider for ``opencode run`` (OpenCode workers).
|
|
214
|
+
|
|
215
|
+
Corrected against a real ``opencode`` 1.18.11 capture (#1703 →
|
|
216
|
+
``docs/OPENCODE_VERIFICATION.md``). #1705 finished what #1704 left
|
|
217
|
+
open: the ``work`` agent definition is authored and its deny-list
|
|
218
|
+
enforcement is proven against a real binary (see the module docstring
|
|
219
|
+
and :meth:`capabilities`); ``enforces_deny_list`` is now ``True``.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
binary: Override the worker binary name/path. ``None`` falls back to
|
|
223
|
+
:data:`DEFAULT_OPENCODE_BINARY` (``"opencode"``).
|
|
224
|
+
attach_url: When set, passes ``--attach <attach_url>`` so the worker
|
|
225
|
+
connects to an already-running OpenCode server instead of starting
|
|
226
|
+
a new session. Corresponds to ``ProviderDef.attach_url`` in
|
|
227
|
+
``coordinator.yml``. ``None`` omits the flag (default headless
|
|
228
|
+
``opencode run`` starts its own session). Confirmed end-to-end
|
|
229
|
+
against a real ``opencode serve`` (``OPENCODE_VERIFICATION.md``
|
|
230
|
+
"Flag surface").
|
|
231
|
+
model: Fallback model id from the provider definition
|
|
232
|
+
(``ProviderDef.model``) — e.g. an opencode ``provider/model``
|
|
233
|
+
string such as ``"zhipuai/glm-4.6"``. Used only when neither an
|
|
234
|
+
explicit ``resolved_model`` nor ``spec.model`` is set — see
|
|
235
|
+
:meth:`build_command`.
|
|
236
|
+
env: Extra environment variables from the provider definition
|
|
237
|
+
(``ProviderDef.env``, already ``${VAR}``-expanded by config
|
|
238
|
+
parsing) — this is how an operator points OpenCode's own
|
|
239
|
+
provider config (e.g. API keys) at a specific backend. Merged
|
|
240
|
+
with the (non-overridable — see :meth:`env`)
|
|
241
|
+
``OPENCODE_CONFIG_DIR`` / ``OPENCODE_CONFIG`` entries by
|
|
242
|
+
:meth:`env`.
|
|
243
|
+
extra_args: Additional argv entries from the provider definition
|
|
244
|
+
(``ProviderDef.extra_args``). Inserted after this method's own
|
|
245
|
+
flags and before the trailing positional briefing argument.
|
|
246
|
+
**Caveat confirmed in #1703:** array-typed opencode flags (e.g.
|
|
247
|
+
``--file``) greedily consume argv tokens the way yargs does —
|
|
248
|
+
``opencode run --file X "message"`` fails ("File not found:
|
|
249
|
+
<message text>") because ``--file`` swallows the message. Such a
|
|
250
|
+
flag only works placed *after* the briefing. If a future
|
|
251
|
+
``extra_args`` entry needs an array-typed flag, it cannot go
|
|
252
|
+
through this constructor as-is; this is flagged here rather than
|
|
253
|
+
silently mis-ordered.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self,
|
|
258
|
+
binary: str | None = None,
|
|
259
|
+
*,
|
|
260
|
+
attach_url: str | None = None,
|
|
261
|
+
model: str | None = None,
|
|
262
|
+
env: dict[str, str] | None = None,
|
|
263
|
+
extra_args: list[str] | None = None,
|
|
264
|
+
) -> None:
|
|
265
|
+
self._binary = binary
|
|
266
|
+
self._attach_url = attach_url
|
|
267
|
+
self._model = model
|
|
268
|
+
self._env = dict(env) if env else {}
|
|
269
|
+
self._extra_args = list(extra_args) if extra_args else []
|
|
270
|
+
|
|
271
|
+
# ── Capabilities ──────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
def capabilities(self) -> Capabilities:
|
|
274
|
+
"""Capabilities corrected against real captured evidence (#1703/#1704).
|
|
275
|
+
|
|
276
|
+
Chosen values and rationale
|
|
277
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
278
|
+
|
|
279
|
+
``resume=True``
|
|
280
|
+
Confirmed: ``--session <id>`` resumes a prior session (the
|
|
281
|
+
resumed run's ``sessionID`` matched exactly and the model
|
|
282
|
+
demonstrably had context of the prior turn); ``--continue``
|
|
283
|
+
resumes the most recent session the same way. See
|
|
284
|
+
``OPENCODE_VERIFICATION.md`` "Session id / resume".
|
|
285
|
+
|
|
286
|
+
``inject=False``
|
|
287
|
+
**Still unknown** — mid-session stdin message injection into an
|
|
288
|
+
already-running ``run --format json`` process was not exercised
|
|
289
|
+
in the verification pass (see "Remaining unknowns"). Stays
|
|
290
|
+
conservatively ``False`` until a future pass tests it.
|
|
291
|
+
|
|
292
|
+
``cost_reporting=True``
|
|
293
|
+
Confirmed: cost is real and present on every ``step_finish``
|
|
294
|
+
event (``part.cost``), verified non-zero across a real paid
|
|
295
|
+
model (``deepseek/deepseek-chat``, 4 steps,
|
|
296
|
+
``0.00106932``/``0.0000449456``/``0.0000837256``/``0.0000344624``).
|
|
297
|
+
The committed fixture uses a free-tier model so its own
|
|
298
|
+
``cost`` values are ``0`` — that's the model, not a parsing
|
|
299
|
+
gap: :func:`_update_opencode_summary` sums ``part.cost`` across
|
|
300
|
+
every ``step_finish`` event (there is no cumulative field), and
|
|
301
|
+
the free-tier fixture's sum is correctly ``0.0``. See
|
|
302
|
+
``OPENCODE_VERIFICATION.md`` "Token usage and cost — field
|
|
303
|
+
paths".
|
|
304
|
+
|
|
305
|
+
``true_system_prompt=True``
|
|
306
|
+
Confirmed via the live-fetched ``https://opencode.ai/config.json``
|
|
307
|
+
schema: ``--agent <name>`` selects an agent definition whose
|
|
308
|
+
``AgentConfig.prompt: string`` field is a genuine system-prompt
|
|
309
|
+
equivalent — not merely prepended user-message text. See
|
|
310
|
+
``OPENCODE_VERIFICATION.md`` "every ASSUMPTION" table. As of
|
|
311
|
+
#1705 this is no longer just a mechanism claim: ``work.md``'s
|
|
312
|
+
``prompt`` body is what a real dispatched worker actually runs
|
|
313
|
+
under (see :func:`_agent_name_for_type`).
|
|
314
|
+
|
|
315
|
+
``enforces_deny_list=True``
|
|
316
|
+
**SAFETY GATE — flipped in #1705, justified by real runs, not
|
|
317
|
+
argv assertions.** OpenCode has a real per-tool
|
|
318
|
+
``allow``/``ask``/``deny`` permission system with genuine bash
|
|
319
|
+
command pattern matching, empirically proven in #1703
|
|
320
|
+
(``OPENCODE_VERIFICATION.md`` "Permission enforcement —
|
|
321
|
+
empirical evidence"). #1705 closed the two gaps #1704 left
|
|
322
|
+
open:
|
|
323
|
+
|
|
324
|
+
1. ``coord/agents/opencode/agents/work.md`` is a committed
|
|
325
|
+
deny-baseline agent config (catch-all rules first, specific
|
|
326
|
+
overrides after — #1703's last-match-wins ordering trap,
|
|
327
|
+
respected).
|
|
328
|
+
2. Enforcement is proven **end-to-end against the real
|
|
329
|
+
opencode 1.18.11 binary**, not asserted from argv, by named
|
|
330
|
+
tests in ``tests/test_providers.py`` that each ``opencode
|
|
331
|
+
run`` a real task through :meth:`build_command`/:meth:`env`
|
|
332
|
+
(the actual production argv/env, not a hand-rolled one) and
|
|
333
|
+
inspect the resulting NDJSON log for a genuine tool-call
|
|
334
|
+
denial: ``test_opencode_work_agent_blocks_gh_end_to_end`` (a
|
|
335
|
+
``gh issue list`` bash call),
|
|
336
|
+
``test_opencode_work_agent_blocks_external_directory_access_end_to_end``
|
|
337
|
+
(a read outside the worktree), and
|
|
338
|
+
``test_opencode_work_agent_blocks_tests_acceptance_edit_end_to_end``
|
|
339
|
+
(an edit under the sealed oracle prefix) — plus a positive
|
|
340
|
+
control, ``test_opencode_work_agent_allows_normal_edit_and_git_end_to_end``,
|
|
341
|
+
confirming the deny rules don't collaterally block legitimate
|
|
342
|
+
edit/git use. All are skipped (not failed) when the
|
|
343
|
+
``opencode`` binary isn't on ``PATH``, matching this repo's
|
|
344
|
+
existing convention for optional real-binary tests (see
|
|
345
|
+
e.g. ``tests/test_graph_health.py``).
|
|
346
|
+
|
|
347
|
+
**Load-bearing operational finding from authoring those
|
|
348
|
+
tests, not covered by #1703's flag-surface table: opencode
|
|
349
|
+
resolves its working directory from the inherited ``PWD``
|
|
350
|
+
environment variable, not the real process cwd.** A bare
|
|
351
|
+
``subprocess.Popen(argv, cwd=X)`` with a stale ``PWD`` (e.g.
|
|
352
|
+
copied from a long-running daemon's own environment, exactly
|
|
353
|
+
what ``coord.agent._worker_subprocess_env`` does via
|
|
354
|
+
``dict(os.environ)``) makes every opencode tool call operate
|
|
355
|
+
against the stale directory, not ``X`` — verified directly
|
|
356
|
+
against the real binary. Production dispatch is unaffected
|
|
357
|
+
only because ``coord.agent._maybe_bash_wrap``'s
|
|
358
|
+
``bash -c 'exec ...'`` wrapper (``bash_wrap_spawn``, default
|
|
359
|
+
``True``, added for the unrelated #299 daemon-spawn-freeze
|
|
360
|
+
mitigation) resets ``$PWD`` before ``exec``-ing into
|
|
361
|
+
opencode — also verified directly. This makes opencode
|
|
362
|
+
dispatch silently depend on a flag whose docstring gives no
|
|
363
|
+
hint that opencode needs it; flagged here rather than
|
|
364
|
+
silently relied upon. Out of scope to fix here: the fix (a
|
|
365
|
+
``--dir`` flag or explicit ``PWD`` correction) needs either
|
|
366
|
+
the assignment's worktree path threaded into
|
|
367
|
+
:meth:`build_command` or a ``PWD`` correction at the
|
|
368
|
+
:meth:`env` call site, and both require the worktree path,
|
|
369
|
+
which :class:`~coord.agent.AssignmentSpec` does not carry —
|
|
370
|
+
only ``coord.agent.AgentServer._spawn`` (forbidden to touch
|
|
371
|
+
under #1705's briefing) knows it. Reported to the
|
|
372
|
+
coordinator rather than worked around.
|
|
373
|
+
|
|
374
|
+
Scope stays exactly what #1705 asked for: only ``spec.type ==
|
|
375
|
+
"work"`` has a committed agent file. Every other write-capable
|
|
376
|
+
type (``review``, ``conflict-fix``, ``smoke``, ...) still
|
|
377
|
+
cannot dispatch through this provider — not because of this
|
|
378
|
+
capability flag anymore, but because
|
|
379
|
+
:func:`_agent_name_for_type` raises
|
|
380
|
+
:class:`OpenCodeAgentNotFoundError` for any ``spec.type``
|
|
381
|
+
without a committed file, which :meth:`build_command` does not
|
|
382
|
+
catch. ``coord.agent.AgentServer.assign``'s
|
|
383
|
+
``WRITE_CAPABLE_SPEC_TYPES`` gate (unchanged by #1705) now
|
|
384
|
+
passes this provider through for ``work``; the per-type file
|
|
385
|
+
gap is what actually still blocks the rest, deliberately.
|
|
386
|
+
|
|
387
|
+
``billing_mode="byo_key"``
|
|
388
|
+
Confirmed: OpenCode uses the operator's own provider credentials
|
|
389
|
+
(``opencode providers login`` / ``opencode stats`` showing real
|
|
390
|
+
accumulated cost against those credentials). Not subject to
|
|
391
|
+
the 2026-06-15 Anthropic metering change (#322).
|
|
392
|
+
|
|
393
|
+
``human_attended_only=False``
|
|
394
|
+
Confirmed by inference, not a ToS determination: every capture
|
|
395
|
+
in #1703 (``run --format json``, no TTY) behaved as pure batch
|
|
396
|
+
automation — no prompts blocking on a TTY, and an ``ask``
|
|
397
|
+
permission rule with no ``--auto`` fails *closed* (auto-rejects
|
|
398
|
+
with a stderr line) rather than hanging. See
|
|
399
|
+
``OPENCODE_VERIFICATION.md`` "``--auto`` semantics" scenario 1.
|
|
400
|
+
"""
|
|
401
|
+
return Capabilities(
|
|
402
|
+
resume=True,
|
|
403
|
+
inject=False,
|
|
404
|
+
cost_reporting=True,
|
|
405
|
+
true_system_prompt=True,
|
|
406
|
+
# SAFETY: flipped in #1705 — see docstring above for the named
|
|
407
|
+
# end-to-end tests this is justified by.
|
|
408
|
+
enforces_deny_list=True,
|
|
409
|
+
billing_mode="byo_key",
|
|
410
|
+
human_attended_only=False,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# ── Core methods ──────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
def build_command(
|
|
416
|
+
self,
|
|
417
|
+
spec: "AssignmentSpec",
|
|
418
|
+
*,
|
|
419
|
+
resolved_model: str | None = None,
|
|
420
|
+
system_prompt: str | None = None,
|
|
421
|
+
allowed_tools: str | None = None,
|
|
422
|
+
permission_mode: str = "acceptEdits",
|
|
423
|
+
) -> list[str]:
|
|
424
|
+
"""Build the ``opencode run`` argv for *spec*.
|
|
425
|
+
|
|
426
|
+
Confirmed shape (#1703, ``OPENCODE_VERIFICATION.md`` "Flag
|
|
427
|
+
surface")::
|
|
428
|
+
|
|
429
|
+
opencode run --format json [--attach URL] [--model MODEL]
|
|
430
|
+
[--session SESSION_ID] --agent NAME --auto BRIEFING
|
|
431
|
+
|
|
432
|
+
``BRIEFING`` is the final positional argument (a real positional
|
|
433
|
+
argv message, confirmed — not a stdin protocol; see
|
|
434
|
+
:meth:`initial_input`).
|
|
435
|
+
|
|
436
|
+
Flags, each cited against captured evidence:
|
|
437
|
+
|
|
438
|
+
* ``--format json`` — always. Confirmed: ``--format default``
|
|
439
|
+
emits "a human-formatted TUI-style transcript, not usable by a
|
|
440
|
+
parser"; only ``json`` emits the NDJSON event stream
|
|
441
|
+
:meth:`parse_log` understands. Without it :meth:`result_marker`
|
|
442
|
+
has nothing to match and :meth:`parse_log` extracts nothing.
|
|
443
|
+
* ``--attach <url>`` — unchanged from the first pass, confirmed
|
|
444
|
+
end-to-end against a real ``opencode serve``.
|
|
445
|
+
* ``--model <value>`` — unchanged from the first pass, confirmed
|
|
446
|
+
with two real ``provider/model`` strings. Precedence: explicit
|
|
447
|
+
*resolved_model* > ``spec.model`` > the provider definition's
|
|
448
|
+
``model`` (threaded in via ``__init__``, #1706). Omitted when
|
|
449
|
+
all three are ``None``.
|
|
450
|
+
* ``--session <id>`` — unchanged from the first pass, confirmed to
|
|
451
|
+
resume by id with matching ``sessionID`` and restored context.
|
|
452
|
+
Omitted when ``spec.resume_session_id`` is ``None``.
|
|
453
|
+
* ``--agent <name>`` — introduced in #1704, **now backed by a real
|
|
454
|
+
committed agent file (#1705).** Confirmed: ``--agent`` selects a
|
|
455
|
+
named agent definition whose ``prompt``/``permission`` fields are
|
|
456
|
+
a genuine system-prompt + tool-permission equivalent
|
|
457
|
+
(schema-confirmed against ``https://opencode.ai/config.json``).
|
|
458
|
+
This is why it **replaces** the ignored *system_prompt* /
|
|
459
|
+
*allowed_tools* kwargs below rather than sitting alongside them —
|
|
460
|
+
there is no opencode flag that accepts raw system-prompt text or
|
|
461
|
+
a raw tool allowlist string the way ``claude -p`` does; the unit
|
|
462
|
+
of configuration is a named agent. The name is computed by
|
|
463
|
+
:func:`_agent_name_for_type` from ``spec.type`` — see that
|
|
464
|
+
function's docstring for the naming contract. **Raises
|
|
465
|
+
:class:`OpenCodeAgentNotFoundError`** (propagates out of this
|
|
466
|
+
method — callers must not treat that as "fall back to something
|
|
467
|
+
permissive") when ``spec.type`` has no committed agent file yet;
|
|
468
|
+
as of #1705 that's every type except ``"work"``.
|
|
469
|
+
* ``--auto`` — introduced in #1704, always passed. Confirmed:
|
|
470
|
+
``--auto`` converts an agent's ``"ask"`` permission rules to
|
|
471
|
+
auto-approve without overriding an explicit ``"deny"`` rule
|
|
472
|
+
(``OPENCODE_VERIFICATION.md`` "``--auto`` semantics", scenarios
|
|
473
|
+
2 & 3). coord's workers are headless/unattended by design, so
|
|
474
|
+
this flag is unconditional — the same way ``permission_mode``
|
|
475
|
+
defaults to ``"acceptEdits"`` for :class:`~.claude.ClaudeProvider`.
|
|
476
|
+
**Safety note:** opencode's permission model is deny-*list*
|
|
477
|
+
semantics inverted from ``claude -p``'s allow-list — ``--auto``
|
|
478
|
+
only ever *widens* what an ``"ask"`` rule would otherwise block;
|
|
479
|
+
it can never widen past an explicit ``"deny"``. As of #1705,
|
|
480
|
+
with ``work.md``'s deny-baseline config actually in force
|
|
481
|
+
(``bash: {"*": "deny", "git status*"/"git commit*"/"git push*"/...:
|
|
482
|
+
"allow", "gh *": "deny", ...}``), this is exactly what keeps a
|
|
483
|
+
real ``work`` dispatch headless instead of hanging on
|
|
484
|
+
``work.md``'s narrow bash allow-list / ``external_directory: deny``
|
|
485
|
+
``ask``-adjacent rules — see :meth:`capabilities`'s
|
|
486
|
+
``enforces_deny_list`` note for the tests that prove the explicit
|
|
487
|
+
``deny`` entries still hold under ``--auto``.
|
|
488
|
+
|
|
489
|
+
Ignored kwargs
|
|
490
|
+
~~~~~~~~~~~~~~
|
|
491
|
+
*system_prompt* and *allowed_tools* are accepted (matching the
|
|
492
|
+
Provider ABC signature) but **silently ignored** — replaced by
|
|
493
|
+
``--agent`` as described above. *permission_mode* is accepted but
|
|
494
|
+
also ignored: it is a ``claude -p``-specific vocabulary
|
|
495
|
+
(``"acceptEdits"``/``"bypassPermissions"``/...) with no opencode
|
|
496
|
+
equivalent; ``--auto`` is unconditional instead of varying by value.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
spec: The assignment spec being dispatched.
|
|
500
|
+
resolved_model: The resolved model identifier to pass. When
|
|
501
|
+
provided, takes precedence over ``spec.model``. ``None``
|
|
502
|
+
falls back to ``spec.model``, then to the provider
|
|
503
|
+
definition's ``model``; if all three are ``None``, the
|
|
504
|
+
``--model`` flag is omitted (OpenCode picks its configured
|
|
505
|
+
default).
|
|
506
|
+
system_prompt: Accepted but **ignored** — see "Ignored kwargs".
|
|
507
|
+
allowed_tools: Accepted but **ignored** — see "Ignored kwargs".
|
|
508
|
+
permission_mode: Accepted but **ignored** — see "Ignored kwargs".
|
|
509
|
+
"""
|
|
510
|
+
binary = self._binary if self._binary is not None else DEFAULT_OPENCODE_BINARY
|
|
511
|
+
|
|
512
|
+
# Precedence: explicit resolved_model > spec.model > provider-
|
|
513
|
+
# definition model (ProviderDef.model, threaded in via __init__).
|
|
514
|
+
if resolved_model is not None:
|
|
515
|
+
effective_model = resolved_model
|
|
516
|
+
elif spec.model is not None:
|
|
517
|
+
effective_model = spec.model
|
|
518
|
+
else:
|
|
519
|
+
effective_model = self._model
|
|
520
|
+
|
|
521
|
+
argv: list[str] = [binary, "run"]
|
|
522
|
+
|
|
523
|
+
# Confirmed (#1703): only --format json emits a parseable NDJSON
|
|
524
|
+
# stream; --format default is a human transcript.
|
|
525
|
+
argv.extend(["--format", "json"])
|
|
526
|
+
|
|
527
|
+
# When attach_url is set, connect to a running OpenCode server instead
|
|
528
|
+
# of starting a new session. Confirmed end-to-end against a real
|
|
529
|
+
# `opencode serve`.
|
|
530
|
+
if self._attach_url:
|
|
531
|
+
argv.extend(["--attach", self._attach_url])
|
|
532
|
+
|
|
533
|
+
# Confirmed: --model selects the AI model by 'provider/model' string.
|
|
534
|
+
if effective_model:
|
|
535
|
+
argv.extend(["--model", effective_model])
|
|
536
|
+
|
|
537
|
+
# Confirmed: --session resumes a prior session by ID.
|
|
538
|
+
if spec.resume_session_id:
|
|
539
|
+
argv.extend(["--session", spec.resume_session_id])
|
|
540
|
+
|
|
541
|
+
# New in #1704: --agent replaces system_prompt/allowed_tools (see
|
|
542
|
+
# docstring above and _agent_name_for_type).
|
|
543
|
+
argv.extend(["--agent", _agent_name_for_type(spec.type)])
|
|
544
|
+
|
|
545
|
+
# New in #1704: unconditional, headless-safety flag (see docstring).
|
|
546
|
+
argv.append("--auto")
|
|
547
|
+
|
|
548
|
+
# #1706: provider-definition extra_args go after this method's own
|
|
549
|
+
# flags but BEFORE the trailing positional briefing — OpenCode's
|
|
550
|
+
# argv parsing assumes the briefing is the last argument (and, per
|
|
551
|
+
# #1703, array-typed flags like --file must come AFTER it — see the
|
|
552
|
+
# __init__ docstring's caveat about extra_args).
|
|
553
|
+
if self._extra_args:
|
|
554
|
+
argv.extend(self._extra_args)
|
|
555
|
+
|
|
556
|
+
# Briefing is the final positional argument — passed on argv, NOT stdin.
|
|
557
|
+
# Multi-line briefings are safe here because subprocess.Popen passes
|
|
558
|
+
# the list directly to execv() (no shell interpolation).
|
|
559
|
+
argv.append(spec.briefing)
|
|
560
|
+
return argv
|
|
561
|
+
|
|
562
|
+
def oneshot_command(
|
|
563
|
+
self,
|
|
564
|
+
*,
|
|
565
|
+
system_prompt: str,
|
|
566
|
+
output_format: str | None = "json",
|
|
567
|
+
) -> list[str]:
|
|
568
|
+
"""Best-effort one-shot argv for the OpenCode backend.
|
|
569
|
+
|
|
570
|
+
LIMITATION, unchanged from the first pass and still real: OpenCode
|
|
571
|
+
takes its briefing as a positional argv argument, not via stdin
|
|
572
|
+
(confirmed, #1703), and this method's signature — inherited from the
|
|
573
|
+
:class:`~.base.Provider` ABC — does not receive the user message, so
|
|
574
|
+
it cannot be appended here either. Callers (brain planning, the
|
|
575
|
+
dashboard assistant) pipe the user message via stdin the way they do
|
|
576
|
+
for :class:`~.claude.ClaudeProvider`; OpenCode will not see it. Real
|
|
577
|
+
one-shot semantics (a single prompt/response round with the message
|
|
578
|
+
actually delivered) are not achievable through this ABC method for
|
|
579
|
+
this backend — callers that need that should configure a
|
|
580
|
+
:class:`~.claude.ClaudeProvider` backend instead, exactly as the
|
|
581
|
+
first-pass docstring already said.
|
|
582
|
+
|
|
583
|
+
What *is* corrected here (#1704): the returned argv now includes
|
|
584
|
+
``--format json`` when *output_format* is ``"json"`` (or whatever
|
|
585
|
+
string is passed — forwarded verbatim, mirroring
|
|
586
|
+
:meth:`~.claude.ClaudeProvider.oneshot_command`'s behaviour), so at
|
|
587
|
+
least the output stream is the structured NDJSON shape
|
|
588
|
+
:meth:`parse_log` understands rather than the unparseable human
|
|
589
|
+
transcript. This does **not** produce the ``{"result": ...}``
|
|
590
|
+
top-level shape the brain's JSON-extraction path expects — opencode
|
|
591
|
+
has no such wrapper (confirmed, #1703) — so callers still fall back
|
|
592
|
+
to raw-stdout handling for this backend, as the first-pass docstring
|
|
593
|
+
already documented. ``--auto`` is always appended for the same
|
|
594
|
+
headless-safety reason as :meth:`build_command`.
|
|
595
|
+
|
|
596
|
+
*system_prompt* is still silently ignored: there is no CLI flag to
|
|
597
|
+
inject ad hoc system-prompt text, only ``--agent <name>`` selecting
|
|
598
|
+
a *pre-configured* agent (see :meth:`build_command`), and this
|
|
599
|
+
method's signature has no assignment-spec context to compute an
|
|
600
|
+
agent name from.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
system_prompt: Accepted but **ignored** — see above.
|
|
604
|
+
output_format: ``"json"`` (or any other string) is forwarded as
|
|
605
|
+
``--format <value>``. ``None`` omits the flag entirely,
|
|
606
|
+
falling back to opencode's human-transcript default —
|
|
607
|
+
matching the dashboard-assistant streaming use case the
|
|
608
|
+
same way ``output_format=None`` does for
|
|
609
|
+
:class:`~.claude.ClaudeProvider`.
|
|
610
|
+
"""
|
|
611
|
+
binary = self._binary if self._binary is not None else DEFAULT_OPENCODE_BINARY
|
|
612
|
+
argv = [binary, "run"]
|
|
613
|
+
if output_format is not None:
|
|
614
|
+
argv.extend(["--format", output_format])
|
|
615
|
+
argv.append("--auto")
|
|
616
|
+
return argv
|
|
617
|
+
|
|
618
|
+
def initial_input(self, spec: "AssignmentSpec") -> bytes:
|
|
619
|
+
"""Return an empty bytes object — the briefing travels on argv.
|
|
620
|
+
|
|
621
|
+
Confirmed (#1703): ``run [message..]`` is a genuine positional argv
|
|
622
|
+
message; no stdin-delivery protocol was observed. Unlike
|
|
623
|
+
:class:`~.claude.ClaudeProvider`, OpenCode receives its briefing as
|
|
624
|
+
the final positional argument in :meth:`build_command`. Returning
|
|
625
|
+
``b""`` (falsy) signals to the spawn path that nothing should be
|
|
626
|
+
written to the worker's stdin pipe.
|
|
627
|
+
"""
|
|
628
|
+
# Briefing is already embedded in the argv by build_command.
|
|
629
|
+
# Return empty bytes so the spawn path's ``if initial_input:`` guard
|
|
630
|
+
# skips the stdin write.
|
|
631
|
+
return b""
|
|
632
|
+
|
|
633
|
+
def result_marker(self) -> str:
|
|
634
|
+
"""Return the completion sentinel for OpenCode NDJSON logs.
|
|
635
|
+
|
|
636
|
+
CORRECTED (#1704): see :data:`RESULT_MARKER`'s docstring for the
|
|
637
|
+
full citation and the documented substring-fragility risk.
|
|
638
|
+
"""
|
|
639
|
+
return RESULT_MARKER
|
|
640
|
+
|
|
641
|
+
def env(self) -> dict[str, str]:
|
|
642
|
+
"""Extra environment variables for the worker subprocess.
|
|
643
|
+
|
|
644
|
+
Two parts:
|
|
645
|
+
|
|
646
|
+
1. ``ProviderDef.env`` (already ``${VAR}``-expanded by config
|
|
647
|
+
parsing, #1706) — how an operator points a named ``opencode``
|
|
648
|
+
provider definition at a specific set of credentials without
|
|
649
|
+
baking them into the machine's agent unit.
|
|
650
|
+
2. **#1705, always set, not operator-overridable:**
|
|
651
|
+
``OPENCODE_CONFIG_DIR`` (:data:`AGENTS_ROOT` — where the
|
|
652
|
+
committed per-spec-type agent files live, see the module
|
|
653
|
+
docstring for why an env var rather than a worktree file copy)
|
|
654
|
+
and ``OPENCODE_CONFIG`` (:data:`ROUTING_PIN_PATH` — the
|
|
655
|
+
OpenRouter routing pin, see ``coord/agents/opencode/routing.jsonc``).
|
|
656
|
+
These are applied **after** ``self._env`` so a
|
|
657
|
+
``ProviderDef.env`` entry cannot accidentally (or maliciously,
|
|
658
|
+
via a compromised ``coordinator.yml`` edit that isn't the
|
|
659
|
+
security-reviewed agent-file path) shadow the deny-list
|
|
660
|
+
discovery mechanism — see :class:`OpenCodeAgentNotFoundError`'s
|
|
661
|
+
docstring for why silently losing this would be bad. Verified
|
|
662
|
+
empirically that ``OPENCODE_CONFIG_DIR`` is inert for a provider
|
|
663
|
+
the operator hasn't configured credentials for (real opencode
|
|
664
|
+
1.18.11 run against a non-OpenRouter model with both variables
|
|
665
|
+
set completed normally) — see
|
|
666
|
+
``test_opencode_routing_pin_inert_without_openrouter_credential_end_to_end``.
|
|
667
|
+
|
|
668
|
+
Returns a fresh dict each call.
|
|
669
|
+
"""
|
|
670
|
+
merged = dict(self._env)
|
|
671
|
+
merged["OPENCODE_CONFIG_DIR"] = str(AGENTS_ROOT)
|
|
672
|
+
merged["OPENCODE_CONFIG"] = str(ROUTING_PIN_PATH)
|
|
673
|
+
return merged
|
|
674
|
+
|
|
675
|
+
def parse_log(
|
|
676
|
+
self, log_path: str | Path, tail_bytes: int = 65536
|
|
677
|
+
) -> WorkerSummary:
|
|
678
|
+
"""Parse an OpenCode NDJSON log file into a :class:`WorkerSummary`.
|
|
679
|
+
|
|
680
|
+
CORRECTED (#1704) against the real event schema captured in #1703
|
|
681
|
+
(``docs/OPENCODE_VERIFICATION.md`` "The real event schema" through
|
|
682
|
+
"Session id / resume"). ``opencode run --format json`` emits one
|
|
683
|
+
JSON object per line to stdout; the agent writes this stream
|
|
684
|
+
verbatim to the log file.
|
|
685
|
+
|
|
686
|
+
This method is **deliberately permissive** — it silently skips any
|
|
687
|
+
line that is blank, not valid JSON, or an unrecognised event shape,
|
|
688
|
+
and it NEVER raises regardless of log content. This is unchanged
|
|
689
|
+
from the first pass and still required: a truncated tail read
|
|
690
|
+
(``tail_bytes > 0``) can produce a leading incomplete JSON line,
|
|
691
|
+
which must be skipped, not raised.
|
|
692
|
+
|
|
693
|
+
Real event shapes handled (see
|
|
694
|
+
``tests/fixtures/opencode_run_sample.jsonl`` and
|
|
695
|
+
``tests/fixtures/opencode_run_failure_sample.jsonl``):
|
|
696
|
+
|
|
697
|
+
* Every event (any ``type``) carries a top-level ``sessionID``
|
|
698
|
+
string from the first line onward — confirmed, there is no
|
|
699
|
+
separate ``session.start``/``session.init`` event. The first
|
|
700
|
+
``sessionID`` seen sets :attr:`WorkerSummary.session_id`.
|
|
701
|
+
* ``{"type":"tool_use","part":{"tool":"...","state":{"status":...,
|
|
702
|
+
"input":{...}}}}`` — the tool name updates
|
|
703
|
+
:attr:`WorkerSummary.tools_used` /
|
|
704
|
+
:attr:`WorkerSummary.last_tool`; a ``bash`` call's
|
|
705
|
+
``input.command`` is recorded in
|
|
706
|
+
:attr:`WorkerSummary.bash_commands`; an ``edit``/``write`` call's
|
|
707
|
+
``input.filePath`` is recorded in
|
|
708
|
+
:attr:`WorkerSummary.files_edited` (``edit`` confirmed against the
|
|
709
|
+
real fixture; ``write`` extrapolated from the same ``filePath``
|
|
710
|
+
convention ``read``/``edit`` both use — not independently
|
|
711
|
+
confirmed, but the extraction is a no-op, never a crash, if that
|
|
712
|
+
extrapolation is wrong). A ``state.status == "error"`` whose
|
|
713
|
+
``state.error`` message mentions "permission" (the confirmed
|
|
714
|
+
permission-denial error shape quotes the matching rules verbatim)
|
|
715
|
+
is recorded in :attr:`WorkerSummary.permission_denials`.
|
|
716
|
+
* ``{"type":"text","part":{"text":"..."}}`` — the assistant's final
|
|
717
|
+
answer; confirmed to be the last ``text`` event before the
|
|
718
|
+
terminal ``step_finish``, so each one overwrites
|
|
719
|
+
:attr:`WorkerSummary.result_text` and the last write wins.
|
|
720
|
+
* ``{"type":"step_finish","part":{"reason":...,"tokens":{...},
|
|
721
|
+
"cost":...}}`` — one per completed turn/step. Each occurrence
|
|
722
|
+
increments :attr:`WorkerSummary.num_turns` by one and *sums*
|
|
723
|
+
(never overwrites) ``part.cost`` into
|
|
724
|
+
:attr:`WorkerSummary.total_cost_usd` and
|
|
725
|
+
``part.tokens.{input,output}`` /
|
|
726
|
+
``part.tokens.cache.{write,read}`` into
|
|
727
|
+
:attr:`WorkerSummary.input_tokens` /
|
|
728
|
+
:attr:`WorkerSummary.output_tokens` /
|
|
729
|
+
:attr:`WorkerSummary.cache_creation_tokens` /
|
|
730
|
+
:attr:`WorkerSummary.cache_read_tokens` — confirmed there is
|
|
731
|
+
**no cumulative session-total field anywhere in the stream**, so
|
|
732
|
+
summing per-step is the only correct total. ``part.reason``
|
|
733
|
+
overwrites :attr:`WorkerSummary.stop_reason` every time, so it
|
|
734
|
+
ends at whatever the *last* ``step_finish`` reported — ``"stop"``
|
|
735
|
+
for a normal completion.
|
|
736
|
+
* ``{"type":"error","error":{"name":...,"data":{"message":...}}}``
|
|
737
|
+
— a run-level failure (confirmed: the whole request/stream failed,
|
|
738
|
+
no more events follow). Sets
|
|
739
|
+
:attr:`WorkerSummary.is_error` ``= True``,
|
|
740
|
+
:attr:`WorkerSummary.stop_reason` ``= "error"``,
|
|
741
|
+
:attr:`WorkerSummary.terminal_reason` from ``error.name``, and
|
|
742
|
+
:attr:`WorkerSummary.result_text` from ``error.data.message``
|
|
743
|
+
(the real captured message is itself a JSON string containing
|
|
744
|
+
literal escaped quotes — preserved verbatim, not stripped).
|
|
745
|
+
|
|
746
|
+
Known, named gap: **no field in any observed event carries a model
|
|
747
|
+
identifier** (confirmed absent across every event type in both
|
|
748
|
+
fixtures and every capture in #1703's pass).
|
|
749
|
+
:attr:`WorkerSummary.model_used` is therefore never set by this
|
|
750
|
+
method and stays ``None`` — this is evidenced absence, not an
|
|
751
|
+
unexamined assumption.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
log_path: Path to the worker's log file.
|
|
755
|
+
tail_bytes: When > 0, only the last *tail_bytes* of the file are
|
|
756
|
+
read (cheap live-polling). Pass ``0`` for a full parse.
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
A :class:`WorkerSummary` with whatever fields could be extracted.
|
|
760
|
+
Returns a blank summary for a missing, empty, or unreadable file.
|
|
761
|
+
"""
|
|
762
|
+
summary = WorkerSummary()
|
|
763
|
+
p = Path(log_path)
|
|
764
|
+
if not p.exists():
|
|
765
|
+
return summary
|
|
766
|
+
try:
|
|
767
|
+
size = p.stat().st_size
|
|
768
|
+
with open(p, "r", encoding="utf-8", errors="replace") as f:
|
|
769
|
+
if tail_bytes and size > tail_bytes:
|
|
770
|
+
f.seek(size - tail_bytes)
|
|
771
|
+
f.readline() # discard the leading partial line
|
|
772
|
+
text = f.read()
|
|
773
|
+
except OSError:
|
|
774
|
+
return summary
|
|
775
|
+
|
|
776
|
+
for line in text.splitlines():
|
|
777
|
+
if not line or not line.strip():
|
|
778
|
+
continue
|
|
779
|
+
try:
|
|
780
|
+
data = json.loads(line)
|
|
781
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
782
|
+
# Non-JSON lines (e.g. "# argv=..." header comment written by the
|
|
783
|
+
# agent, or plain-text error output) are silently skipped.
|
|
784
|
+
continue
|
|
785
|
+
if not isinstance(data, dict):
|
|
786
|
+
continue
|
|
787
|
+
_update_opencode_summary(summary, data)
|
|
788
|
+
|
|
789
|
+
return summary
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# ── Internal summary helper ────────────────────────────────────────────────────
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _update_opencode_summary(summary: WorkerSummary, data: dict) -> None:
|
|
796
|
+
"""Fold a single parsed NDJSON object into *summary* in-place.
|
|
797
|
+
|
|
798
|
+
All field accesses use ``.get()`` with defensive type checks so that
|
|
799
|
+
unexpected shapes produce at most a no-op, never a ``KeyError`` /
|
|
800
|
+
``AttributeError``. Event shapes are the real ones captured in #1703 —
|
|
801
|
+
see :meth:`OpenCodeProvider.parse_log`'s docstring for the full mapping
|
|
802
|
+
and citations.
|
|
803
|
+
"""
|
|
804
|
+
# Confirmed: sessionID is present on every event from line 1 onward —
|
|
805
|
+
# capture the first one seen regardless of event type.
|
|
806
|
+
if summary.session_id is None:
|
|
807
|
+
sid = data.get("sessionID")
|
|
808
|
+
if isinstance(sid, str) and sid:
|
|
809
|
+
summary.session_id = sid
|
|
810
|
+
|
|
811
|
+
event_type = data.get("type")
|
|
812
|
+
if not isinstance(event_type, str):
|
|
813
|
+
return
|
|
814
|
+
|
|
815
|
+
part = data.get("part")
|
|
816
|
+
part = part if isinstance(part, dict) else {}
|
|
817
|
+
|
|
818
|
+
if event_type == "tool_use":
|
|
819
|
+
tool = part.get("tool")
|
|
820
|
+
if isinstance(tool, str) and tool:
|
|
821
|
+
summary.tools_used.append(tool)
|
|
822
|
+
summary.last_tool = tool
|
|
823
|
+
|
|
824
|
+
state = part.get("state")
|
|
825
|
+
state = state if isinstance(state, dict) else {}
|
|
826
|
+
tool_input = state.get("input")
|
|
827
|
+
tool_input = tool_input if isinstance(tool_input, dict) else {}
|
|
828
|
+
|
|
829
|
+
if tool == "bash":
|
|
830
|
+
cmd = tool_input.get("command")
|
|
831
|
+
if isinstance(cmd, str) and cmd:
|
|
832
|
+
summary.bash_commands.append(cmd)
|
|
833
|
+
elif tool in ("edit", "write"):
|
|
834
|
+
fp = tool_input.get("filePath")
|
|
835
|
+
if isinstance(fp, str) and fp:
|
|
836
|
+
summary.files_edited.append(fp)
|
|
837
|
+
|
|
838
|
+
if state.get("status") == "error":
|
|
839
|
+
err = state.get("error")
|
|
840
|
+
if isinstance(err, str) and err and "permission" in err.lower():
|
|
841
|
+
summary.permission_denials.append(err)
|
|
842
|
+
return
|
|
843
|
+
|
|
844
|
+
if event_type == "text":
|
|
845
|
+
text = part.get("text")
|
|
846
|
+
if isinstance(text, str):
|
|
847
|
+
summary.result_text = text
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
if event_type == "step_finish":
|
|
851
|
+
reason = part.get("reason")
|
|
852
|
+
if isinstance(reason, str) and reason:
|
|
853
|
+
summary.stop_reason = reason
|
|
854
|
+
|
|
855
|
+
summary.num_turns += 1
|
|
856
|
+
|
|
857
|
+
tokens = part.get("tokens")
|
|
858
|
+
tokens = tokens if isinstance(tokens, dict) else {}
|
|
859
|
+
in_tok = tokens.get("input")
|
|
860
|
+
if isinstance(in_tok, int):
|
|
861
|
+
summary.input_tokens += in_tok
|
|
862
|
+
out_tok = tokens.get("output")
|
|
863
|
+
if isinstance(out_tok, int):
|
|
864
|
+
summary.output_tokens += out_tok
|
|
865
|
+
|
|
866
|
+
cache = tokens.get("cache")
|
|
867
|
+
cache = cache if isinstance(cache, dict) else {}
|
|
868
|
+
cache_write = cache.get("write")
|
|
869
|
+
if isinstance(cache_write, int):
|
|
870
|
+
summary.cache_creation_tokens += cache_write
|
|
871
|
+
cache_read = cache.get("read")
|
|
872
|
+
if isinstance(cache_read, int):
|
|
873
|
+
summary.cache_read_tokens += cache_read
|
|
874
|
+
|
|
875
|
+
cost = part.get("cost")
|
|
876
|
+
if isinstance(cost, (int, float)):
|
|
877
|
+
summary.total_cost_usd += float(cost)
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
if event_type == "error":
|
|
881
|
+
summary.is_error = True
|
|
882
|
+
summary.stop_reason = "error"
|
|
883
|
+
error_obj = data.get("error")
|
|
884
|
+
error_obj = error_obj if isinstance(error_obj, dict) else {}
|
|
885
|
+
name = error_obj.get("name")
|
|
886
|
+
if isinstance(name, str) and name:
|
|
887
|
+
summary.terminal_reason = name
|
|
888
|
+
err_data = error_obj.get("data")
|
|
889
|
+
err_data = err_data if isinstance(err_data, dict) else {}
|
|
890
|
+
message = err_data.get("message")
|
|
891
|
+
if isinstance(message, str):
|
|
892
|
+
summary.result_text = message
|
|
893
|
+
return
|
|
894
|
+
|
|
895
|
+
# step_start and any other/future event type carry nothing else this
|
|
896
|
+
# method extracts — the sessionID capture above already ran.
|