inTandem 0.1.0__tar.gz

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 (39) hide show
  1. intandem-0.1.0/.gitignore +8 -0
  2. intandem-0.1.0/LICENSE +21 -0
  3. intandem-0.1.0/PKG-INFO +108 -0
  4. intandem-0.1.0/README.md +80 -0
  5. intandem-0.1.0/pyproject.toml +101 -0
  6. intandem-0.1.0/src/intandem/__init__.py +3 -0
  7. intandem-0.1.0/src/intandem/__main__.py +3 -0
  8. intandem-0.1.0/src/intandem/bridge/__init__.py +6 -0
  9. intandem-0.1.0/src/intandem/bridge/app.py +318 -0
  10. intandem-0.1.0/src/intandem/bridge/dashboard.html +480 -0
  11. intandem-0.1.0/src/intandem/bridge/hub.html +121 -0
  12. intandem-0.1.0/src/intandem/bridge/manifest.py +75 -0
  13. intandem-0.1.0/src/intandem/bridge/schemas.py +159 -0
  14. intandem-0.1.0/src/intandem/cli/__init__.py +5 -0
  15. intandem-0.1.0/src/intandem/cli/client.py +151 -0
  16. intandem-0.1.0/src/intandem/cli/main.py +543 -0
  17. intandem-0.1.0/src/intandem/cli/render.py +91 -0
  18. intandem-0.1.0/src/intandem/config.py +148 -0
  19. intandem-0.1.0/src/intandem/hub.py +356 -0
  20. intandem-0.1.0/src/intandem/mcp_server.py +800 -0
  21. intandem-0.1.0/src/intandem/notify.py +190 -0
  22. intandem-0.1.0/src/intandem/opencode/__init__.py +20 -0
  23. intandem-0.1.0/src/intandem/opencode/client.py +268 -0
  24. intandem-0.1.0/src/intandem/opencode/events.py +171 -0
  25. intandem-0.1.0/src/intandem/opencode/host.py +199 -0
  26. intandem-0.1.0/src/intandem/opencode/jsonpath.py +29 -0
  27. intandem-0.1.0/src/intandem/opencode/models.py +145 -0
  28. intandem-0.1.0/src/intandem/opencode/server.py +73 -0
  29. intandem-0.1.0/src/intandem/py.typed +0 -0
  30. intandem-0.1.0/src/intandem/runtime.py +217 -0
  31. intandem-0.1.0/src/intandem/supervisor/__init__.py +36 -0
  32. intandem-0.1.0/src/intandem/supervisor/activity.py +257 -0
  33. intandem-0.1.0/src/intandem/supervisor/journal.py +84 -0
  34. intandem-0.1.0/src/intandem/supervisor/prompts.py +84 -0
  35. intandem-0.1.0/src/intandem/supervisor/protected.py +98 -0
  36. intandem-0.1.0/src/intandem/supervisor/results.py +39 -0
  37. intandem-0.1.0/src/intandem/supervisor/state.py +179 -0
  38. intandem-0.1.0/src/intandem/supervisor/supervisor.py +852 -0
  39. intandem-0.1.0/src/intandem/worktrees.py +182 -0
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
intandem-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack Mumo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: inTandem
3
+ Version: 0.1.0
4
+ Summary: Let a cheaper model write the code while your best model plans and reviews it
5
+ Project-URL: Homepage, https://github.com/mumoj/inTandem
6
+ Project-URL: Documentation, https://github.com/mumoj/inTandem/blob/main/docs/usage.md
7
+ Project-URL: Issues, https://github.com/mumoj/inTandem/issues
8
+ Author: Jack Mumo
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,claude-code,codex,mcp,opencode
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: fastapi>=0.115
20
+ Requires-Dist: httpx-sse>=0.4
21
+ Requires-Dist: httpx>=0.28
22
+ Requires-Dist: mcp>=1.20
23
+ Requires-Dist: pydantic-settings>=2.6
24
+ Requires-Dist: pydantic>=2.9
25
+ Requires-Dist: typer>=0.15
26
+ Requires-Dist: uvicorn>=0.32
27
+ Description-Content-Type: text/markdown
28
+
29
+ # inTandem
30
+
31
+ **Let a cheaper model write the code while your best model plans and reviews it.**
32
+
33
+ Your planner (Claude Code or Codex) writes a spec. inTandem hands it to a cheaper model running
34
+ in [OpenCode](https://opencode.ai), watches the work, and tells the planner exactly why it stopped.
35
+ It checks what the implementer did instead of taking its word, and you can step in from a browser
36
+ or your phone.
37
+
38
+ <p align="center">
39
+ <img src="https://raw.githubusercontent.com/mumoj/inTandem/main/docs/overview.svg" width="900"
40
+ alt="A planner sends a spec to inTandem, which prompts a cheaper model in OpenCode. It edits your repo, and inTandem returns a verified result. You can allow or deny requests from a dashboard or phone.">
41
+ </p>
42
+
43
+ ## Why use it
44
+
45
+ - **Spend less.** Your most capable model only plans and reviews, and any model OpenCode supports does the typing.
46
+ - **Trust the result.** A result only counts if it's fresh, and you review the real diff.
47
+ - **Protect what matters.** Mark files as off limits. inTandem blocks edits and catches any that slip through.
48
+ - **Never get stuck.** You get an alert when the implementer needs you, stalls or goes quiet.
49
+ - **Work in parallel.** Run several implementers at once, each on its own branch.
50
+
51
+ ## Install
52
+
53
+ You need Python 3.12+, [uv](https://docs.astral.sh/uv/) and OpenCode 1.x.
54
+
55
+ ```bash
56
+ npm i -g opencode-ai@1 # OpenCode 2 isn't supported yet
57
+ opencode auth login # connect a model provider
58
+ uv tool install intandem # puts `inTandem` on your PATH
59
+ ```
60
+
61
+ ## Connect your planner
62
+
63
+ Register inTandem once:
64
+
65
+ ```bash
66
+ claude mcp add inTandem --scope user -- inTandem mcp # Claude Code
67
+ codex mcp add inTandem -- inTandem mcp # Codex
68
+ ```
69
+
70
+ There's nothing else to start. inTandem runs when your planner connects and stops when it exits.
71
+
72
+ ## Run your first task
73
+
74
+ Ask your planner to delegate something:
75
+
76
+ > Have a cheaper model write the tests for citations.py through inTandem.
77
+
78
+ It writes the spec, waits for the work and reviews the result. Its first reply includes a
79
+ **dashboard link**, where you can watch live and approve or deny requests.
80
+
81
+ To set a default model or protect files for every task, add them when you register:
82
+
83
+ ```bash
84
+ claude mcp add inTandem --scope user \
85
+ -e INTANDEM_MODEL=openrouter/qwen/qwen3-coder \
86
+ -e 'INTANDEM_PROTECTED=["instruction.md"]' \
87
+ -- inTandem mcp
88
+ ```
89
+
90
+ ## Step in from a terminal
91
+
92
+ The same controls work from the command line, including over SSH from your phone:
93
+
94
+ ```bash
95
+ inTandem status # what it's doing right now
96
+ inTandem pending # what it's waiting on
97
+ inTandem allow # or: inTandem deny --message "leave that file alone"
98
+ inTandem cost # what it has spent
99
+ ```
100
+
101
+ ## Learn more
102
+
103
+ | | |
104
+ | --- | --- |
105
+ | [Usage guide](https://github.com/mumoj/inTandem/blob/main/docs/usage.md) | Parallel implementers, the CLI, exit codes, alerts and configuration |
106
+ | [How it works](https://github.com/mumoj/inTandem/blob/main/docs/architecture.md) | Architecture, what it enforces and design notes |
107
+ | [Development](https://github.com/mumoj/inTandem/blob/main/docs/development.md) | Running the tests and releasing |
108
+ | [Planner skill](https://github.com/mumoj/inTandem/blob/main/skills/intandem-planner/SKILL.md) | The planner loop as an Agent Skill |
@@ -0,0 +1,80 @@
1
+ # inTandem
2
+
3
+ **Let a cheaper model write the code while your best model plans and reviews it.**
4
+
5
+ Your planner (Claude Code or Codex) writes a spec. inTandem hands it to a cheaper model running
6
+ in [OpenCode](https://opencode.ai), watches the work, and tells the planner exactly why it stopped.
7
+ It checks what the implementer did instead of taking its word, and you can step in from a browser
8
+ or your phone.
9
+
10
+ <p align="center">
11
+ <img src="https://raw.githubusercontent.com/mumoj/inTandem/main/docs/overview.svg" width="900"
12
+ alt="A planner sends a spec to inTandem, which prompts a cheaper model in OpenCode. It edits your repo, and inTandem returns a verified result. You can allow or deny requests from a dashboard or phone.">
13
+ </p>
14
+
15
+ ## Why use it
16
+
17
+ - **Spend less.** Your most capable model only plans and reviews, and any model OpenCode supports does the typing.
18
+ - **Trust the result.** A result only counts if it's fresh, and you review the real diff.
19
+ - **Protect what matters.** Mark files as off limits. inTandem blocks edits and catches any that slip through.
20
+ - **Never get stuck.** You get an alert when the implementer needs you, stalls or goes quiet.
21
+ - **Work in parallel.** Run several implementers at once, each on its own branch.
22
+
23
+ ## Install
24
+
25
+ You need Python 3.12+, [uv](https://docs.astral.sh/uv/) and OpenCode 1.x.
26
+
27
+ ```bash
28
+ npm i -g opencode-ai@1 # OpenCode 2 isn't supported yet
29
+ opencode auth login # connect a model provider
30
+ uv tool install intandem # puts `inTandem` on your PATH
31
+ ```
32
+
33
+ ## Connect your planner
34
+
35
+ Register inTandem once:
36
+
37
+ ```bash
38
+ claude mcp add inTandem --scope user -- inTandem mcp # Claude Code
39
+ codex mcp add inTandem -- inTandem mcp # Codex
40
+ ```
41
+
42
+ There's nothing else to start. inTandem runs when your planner connects and stops when it exits.
43
+
44
+ ## Run your first task
45
+
46
+ Ask your planner to delegate something:
47
+
48
+ > Have a cheaper model write the tests for citations.py through inTandem.
49
+
50
+ It writes the spec, waits for the work and reviews the result. Its first reply includes a
51
+ **dashboard link**, where you can watch live and approve or deny requests.
52
+
53
+ To set a default model or protect files for every task, add them when you register:
54
+
55
+ ```bash
56
+ claude mcp add inTandem --scope user \
57
+ -e INTANDEM_MODEL=openrouter/qwen/qwen3-coder \
58
+ -e 'INTANDEM_PROTECTED=["instruction.md"]' \
59
+ -- inTandem mcp
60
+ ```
61
+
62
+ ## Step in from a terminal
63
+
64
+ The same controls work from the command line, including over SSH from your phone:
65
+
66
+ ```bash
67
+ inTandem status # what it's doing right now
68
+ inTandem pending # what it's waiting on
69
+ inTandem allow # or: inTandem deny --message "leave that file alone"
70
+ inTandem cost # what it has spent
71
+ ```
72
+
73
+ ## Learn more
74
+
75
+ | | |
76
+ | --- | --- |
77
+ | [Usage guide](https://github.com/mumoj/inTandem/blob/main/docs/usage.md) | Parallel implementers, the CLI, exit codes, alerts and configuration |
78
+ | [How it works](https://github.com/mumoj/inTandem/blob/main/docs/architecture.md) | Architecture, what it enforces and design notes |
79
+ | [Development](https://github.com/mumoj/inTandem/blob/main/docs/development.md) | Running the tests and releasing |
80
+ | [Planner skill](https://github.com/mumoj/inTandem/blob/main/skills/intandem-planner/SKILL.md) | The planner loop as an Agent Skill |
@@ -0,0 +1,101 @@
1
+ [project]
2
+ name = "inTandem"
3
+ version = "0.1.0"
4
+ description = "Let a cheaper model write the code while your best model plans and reviews it"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ authors = [{ name = "Jack Mumo" }]
10
+ keywords = ["ai-agents", "claude-code", "codex", "mcp", "opencode"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Software Development",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "fastapi>=0.115",
21
+ "httpx>=0.28",
22
+ "httpx-sse>=0.4",
23
+ "mcp>=1.20",
24
+ "pydantic>=2.9",
25
+ "pydantic-settings>=2.6",
26
+ "typer>=0.15",
27
+ "uvicorn>=0.32",
28
+ ]
29
+
30
+ [project.scripts]
31
+ inTandem = "intandem.cli:app"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/mumoj/inTandem"
35
+ Documentation = "https://github.com/mumoj/inTandem/blob/main/docs/usage.md"
36
+ Issues = "https://github.com/mumoj/inTandem/issues"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pyright>=1.1.390",
41
+ "pytest>=8.3",
42
+ "pytest-asyncio>=0.25",
43
+ "respx>=0.22",
44
+ "ruff>=0.8",
45
+ ]
46
+
47
+ [build-system]
48
+ requires = ["hatchling"]
49
+ build-backend = "hatchling.build"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/intandem"]
53
+
54
+ # Only what a source build needs: stray files in the checkout must never ship.
55
+ [tool.hatch.build.targets.sdist]
56
+ only-include = ["src", "LICENSE", "README.md", "pyproject.toml"]
57
+
58
+ [tool.ruff]
59
+ line-length = 100
60
+ target-version = "py312"
61
+ src = ["src", "tests"]
62
+
63
+ [tool.ruff.lint]
64
+ select = [
65
+ "E", "W", "F", # pycodestyle, pyflakes
66
+ "I", # import order
67
+ "UP", # modern syntax
68
+ "B", # likely bugs
69
+ "SIM", # simpler forms
70
+ "C4", # comprehensions
71
+ "RUF", # ruff's own checks
72
+ "ASYNC", # blocking calls inside async code
73
+ "PTH", # pathlib over os.path
74
+ "PT", # pytest style
75
+ "N", # naming
76
+ ]
77
+
78
+ [tool.ruff.lint.per-file-ignores]
79
+ # Typer reads option metadata from default values, which is exactly what B008 flags.
80
+ "src/intandem/cli/*.py" = ["B008"]
81
+
82
+ [tool.pyright]
83
+ include = ["src", "tests"]
84
+ pythonVersion = "3.12"
85
+ typeCheckingMode = "strict"
86
+ venvPath = "."
87
+ venv = ".venv"
88
+ # Tests poke at private state on purpose and lean on fixtures pyright cannot see through.
89
+ executionEnvironments = [
90
+ { root = "tests", extraPaths = [".", "src"], reportPrivateUsage = false, reportUnknownMemberType = false, reportUnknownArgumentType = false, reportUnknownVariableType = false, reportMissingParameterType = false, reportUnknownParameterType = false, reportUnknownLambdaType = false },
91
+ { root = "src" },
92
+ ]
93
+
94
+ [tool.pytest.ini_options]
95
+ testpaths = ["tests"]
96
+ asyncio_mode = "auto"
97
+ asyncio_default_fixture_loop_scope = "function"
98
+ markers = [
99
+ "e2e: drives a real `opencode serve` with a scripted model (needs OpenCode 1.x)",
100
+ ]
101
+ addopts = "-ra"
@@ -0,0 +1,3 @@
1
+ """Supervise a headless OpenCode implementer for a frontier-model planner."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from intandem.cli import app
2
+
3
+ app(prog_name="inTandem")
@@ -0,0 +1,6 @@
1
+ """The authenticated control API over the supervisor."""
2
+
3
+ from intandem.bridge.app import create_app
4
+ from intandem.bridge.manifest import Manifest
5
+
6
+ __all__ = ["Manifest", "create_app"]
@@ -0,0 +1,318 @@
1
+ """The control bridge: a narrow, authenticated HTTP API over the supervisor.
2
+
3
+ The planner and the human on a phone use the same endpoints, through the same
4
+ ``inTandem`` client. Anything the planner can do, the human can do too, and every
5
+ call from either lands in the same journal.
6
+
7
+ It binds to loopback and deliberately does not proxy OpenCode. A dozen named
8
+ operations keep the trial's networking private and make every intervention an
9
+ explicit, auditable call.
10
+
11
+ ``/ui`` is the one unauthenticated route: a static page holding no data. It
12
+ reads the token from its URL fragment, which browsers never send to a server,
13
+ and calls the same authenticated endpoints as everyone else.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hmac
19
+ from collections.abc import Awaitable, Callable
20
+ from dataclasses import dataclass
21
+ from importlib.resources import files
22
+ from typing import Annotated
23
+
24
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response, status
25
+ from fastapi.responses import HTMLResponse, JSONResponse
26
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
27
+ from starlette.datastructures import Headers
28
+ from starlette.types import ASGIApp, Receive, Scope, Send
29
+
30
+ from intandem import __version__
31
+ from intandem.bridge.schemas import (
32
+ AnswerIn,
33
+ DiffOut,
34
+ DirectiveIn,
35
+ DirectiveOut,
36
+ JournalOut,
37
+ MessageOut,
38
+ MessagesOut,
39
+ Ok,
40
+ OpenCodeOut,
41
+ OverviewOut,
42
+ PermissionIn,
43
+ PromptIn,
44
+ ResultOut,
45
+ RetractIn,
46
+ SpecIn,
47
+ SpecOut,
48
+ WaitOut,
49
+ )
50
+ from intandem.opencode.client import NotFoundError, OpenCodeError
51
+ from intandem.supervisor.state import SpecRecord
52
+ from intandem.supervisor.supervisor import (
53
+ CostReport,
54
+ Pending,
55
+ StatusReport,
56
+ Supervisor,
57
+ SupervisorError,
58
+ )
59
+
60
+ MAX_BODY_BYTES = 64 * 1024
61
+
62
+ _bearer = HTTPBearer(auto_error=False, description="The token from the manifest.")
63
+
64
+
65
+ def get_supervisor(request: Request) -> Supervisor:
66
+ supervisor: Supervisor | None = getattr(request.app.state, "supervisor", None)
67
+ if supervisor is None:
68
+ raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "supervisor is starting")
69
+ return supervisor
70
+
71
+
72
+ def require_token(
73
+ request: Request,
74
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
75
+ ) -> None:
76
+ expected: str = request.app.state.token
77
+ given = credentials.credentials if credentials else ""
78
+ # Constant-time comparison: the token is the only thing guarding the bridge.
79
+ if not hmac.compare_digest(given.encode(), expected.encode()):
80
+ raise HTTPException(
81
+ status.HTTP_401_UNAUTHORIZED,
82
+ "missing or wrong bearer token",
83
+ headers={"WWW-Authenticate": "Bearer"},
84
+ )
85
+
86
+
87
+ SupervisorDep = Annotated[Supervisor, Depends(get_supervisor)]
88
+
89
+
90
+ class BodyLimit:
91
+ """Refuse oversized bodies before anything parses them.
92
+
93
+ Plain ASGI rather than ``BaseHTTPMiddleware``, which wraps every request in
94
+ an extra task and buffers what passes through it.
95
+ """
96
+
97
+ def __init__(self, app: ASGIApp) -> None:
98
+ self.app = app
99
+
100
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
101
+ if scope["type"] == "http":
102
+ length = Headers(scope=scope).get("content-length")
103
+ if length is not None and (not length.isdigit() or int(length) > MAX_BODY_BYTES):
104
+ response = JSONResponse(
105
+ {"detail": f"request body over {MAX_BODY_BYTES} bytes"},
106
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
107
+ )
108
+ await response(scope, receive, send)
109
+ return
110
+ await self.app(scope, receive, send)
111
+
112
+
113
+ def _spec_out(supervisor: Supervisor, record: SpecRecord) -> SpecOut:
114
+ return SpecOut(
115
+ spec_id=record.spec_id,
116
+ session_id=record.session_id,
117
+ status=record.status,
118
+ result_path=str(supervisor.result_path(record)),
119
+ )
120
+
121
+
122
+ router = APIRouter(dependencies=[Depends(require_token)])
123
+
124
+
125
+ @router.get("/status")
126
+ async def read_status(supervisor: SupervisorDep) -> StatusReport:
127
+ return supervisor.status()
128
+
129
+
130
+ @router.get("/overview")
131
+ async def read_overview(supervisor: SupervisorDep) -> OverviewOut:
132
+ attention = supervisor.attention()
133
+ return OverviewOut(
134
+ status=supervisor.status(),
135
+ attention=attention.reason if attention else None,
136
+ attention_detail=attention.detail if attention else "",
137
+ pending=await supervisor.pending(),
138
+ )
139
+
140
+
141
+ @router.get("/journal")
142
+ def read_journal( # a plain def: FastAPI runs it in its threadpool, off the event loop
143
+ supervisor: SupervisorDep,
144
+ after: Annotated[int | None, Query(ge=0)] = None,
145
+ limit: Annotated[int, Query(gt=0, le=1000)] = 200,
146
+ ) -> JournalOut:
147
+ entries, offset = supervisor.journal.read_from(after, limit)
148
+ return JournalOut(entries=entries, next=offset)
149
+
150
+
151
+ @router.get("/opencode")
152
+ async def read_opencode(request: Request) -> OpenCodeOut:
153
+ """OpenCode's own web UI for this task, for watching the session live."""
154
+ login: OpenCodeLogin | None = request.app.state.opencode
155
+ return OpenCodeOut(url=login.url if login else None, password=login.password if login else None)
156
+
157
+
158
+ @router.get("/wait")
159
+ async def wait(
160
+ supervisor: SupervisorDep,
161
+ seconds: Annotated[float, Query(alias="timeout", gt=0, le=3600)] = 600,
162
+ ) -> WaitOut:
163
+ """Block until the implementer needs the planner, or ``timeout`` seconds pass."""
164
+ attention = await supervisor.wait(seconds)
165
+ return WaitOut(reason=attention.reason, detail=attention.detail, status=supervisor.status())
166
+
167
+
168
+ @router.get("/messages")
169
+ async def read_messages(
170
+ supervisor: SupervisorDep, limit: Annotated[int, Query(gt=0, le=500)] = 20
171
+ ) -> MessagesOut:
172
+ messages = await supervisor.messages(limit)
173
+ record = supervisor.state.current
174
+ assert record is not None # messages() already required one
175
+ return MessagesOut(session_id=record.session_id, messages=[MessageOut.of(m) for m in messages])
176
+
177
+
178
+ @router.get("/pending")
179
+ async def read_pending(supervisor: SupervisorDep) -> Pending:
180
+ return await supervisor.pending()
181
+
182
+
183
+ @router.get("/result")
184
+ async def read_result(supervisor: SupervisorDep) -> ResultOut:
185
+ record = supervisor.state.current
186
+ path = str(supervisor.result_path(record)) if record else None
187
+ return ResultOut(path=path, result=supervisor.result())
188
+
189
+
190
+ @router.get("/diff")
191
+ async def read_diff(supervisor: SupervisorDep) -> DiffOut:
192
+ return DiffOut(files=await supervisor.diff())
193
+
194
+
195
+ @router.get("/cost")
196
+ async def read_cost(supervisor: SupervisorDep) -> CostReport:
197
+ return await supervisor.cost()
198
+
199
+
200
+ @router.post("/spec")
201
+ async def submit_spec(body: SpecIn, supervisor: SupervisorDep) -> SpecOut:
202
+ """Start a spec in a fresh session. Idempotent for the same id and text."""
203
+ record = await supervisor.submit_spec(
204
+ body.spec_id, body.text, model=body.model, agent=body.agent
205
+ )
206
+ return _spec_out(supervisor, record)
207
+
208
+
209
+ @router.post("/directive")
210
+ async def send_directive(body: DirectiveIn, supervisor: SupervisorDep) -> DirectiveOut:
211
+ directive = await supervisor.directive(body.spec_id, body.directive_id, body.action, body.text)
212
+ return DirectiveOut(
213
+ spec_id=body.spec_id,
214
+ directive_id=directive.directive_id,
215
+ action=directive.action,
216
+ retracted=directive.retracted,
217
+ )
218
+
219
+
220
+ @router.post("/retract")
221
+ async def retract(body: RetractIn, supervisor: SupervisorDep) -> DirectiveOut:
222
+ directive = await supervisor.retract(body.spec_id, body.directive_id)
223
+ return DirectiveOut(
224
+ spec_id=body.spec_id,
225
+ directive_id=directive.directive_id,
226
+ action=directive.action,
227
+ retracted=directive.retracted,
228
+ )
229
+
230
+
231
+ @router.post("/prompt", status_code=status.HTTP_202_ACCEPTED)
232
+ async def prompt(body: PromptIn, supervisor: SupervisorDep) -> SpecOut:
233
+ record = await supervisor.say(body.text)
234
+ return _spec_out(supervisor, record)
235
+
236
+
237
+ @router.post("/permission")
238
+ async def reply_permission(body: PermissionIn, supervisor: SupervisorDep) -> Ok:
239
+ await supervisor.reply_permission(body.id, body.reply, body.message)
240
+ return Ok(detail=f"{body.id} {body.reply}")
241
+
242
+
243
+ @router.post("/answer")
244
+ async def answer(body: AnswerIn, supervisor: SupervisorDep) -> Ok:
245
+ await supervisor.answer(body.id, None if body.reject else body.answers)
246
+ return Ok(detail=f"{body.id} {'rejected' if body.reject else 'answered'}")
247
+
248
+
249
+ @router.post("/abort")
250
+ async def abort(supervisor: SupervisorDep) -> Ok:
251
+ record = await supervisor.abort()
252
+ return Ok(detail=f"aborted the turn on {record.spec_id}; the session stays open")
253
+
254
+
255
+ @router.post("/finish")
256
+ async def finish(request: Request) -> Ok:
257
+ """Stop the supervisor. Evidence is captured on the way out."""
258
+ on_finish: Callable[[], None] = request.app.state.on_finish
259
+ on_finish()
260
+ return Ok(detail="stopping")
261
+
262
+
263
+ @dataclass(frozen=True, slots=True)
264
+ class OpenCodeLogin:
265
+ url: str
266
+ password: str
267
+
268
+
269
+ def dashboard_headers() -> dict[str, str]:
270
+ """For a page that holds no data and talks only to the server that sent it."""
271
+ return {
272
+ "Cache-Control": "no-store",
273
+ "Referrer-Policy": "no-referrer",
274
+ "X-Frame-Options": "DENY",
275
+ "Content-Security-Policy": (
276
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
277
+ "connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'"
278
+ ),
279
+ }
280
+
281
+
282
+ def _dashboard() -> HTMLResponse:
283
+ page = files("intandem.bridge").joinpath("dashboard.html").read_text(encoding="utf-8")
284
+ return HTMLResponse(page, headers=dashboard_headers())
285
+
286
+
287
+ def _error(status_code: int) -> Callable[[Request, Exception], Awaitable[Response]]:
288
+ async def handler(_request: Request, exc: Exception) -> Response:
289
+ return JSONResponse({"detail": str(exc)}, status_code=status_code)
290
+
291
+ return handler
292
+
293
+
294
+ def create_app(
295
+ *,
296
+ supervisor: Supervisor | None = None,
297
+ token: str,
298
+ on_finish: Callable[[], None] = lambda: None,
299
+ opencode: OpenCodeLogin | None = None,
300
+ ) -> FastAPI:
301
+ app = FastAPI(
302
+ title="inTandem bridge",
303
+ version=__version__,
304
+ description="Drive a supervised OpenCode implementer. Every call needs the bearer token.",
305
+ )
306
+ app.state.supervisor = supervisor
307
+ app.state.token = token
308
+ app.state.on_finish = on_finish
309
+ app.state.opencode = opencode
310
+ app.add_middleware(BodyLimit)
311
+ app.add_exception_handler(SupervisorError, _error(status.HTTP_409_CONFLICT))
312
+ # Starlette walks the exception's MRO, so NotFoundError gets its own status even
313
+ # though it is also an OpenCodeError.
314
+ app.add_exception_handler(OpenCodeError, _error(status.HTTP_502_BAD_GATEWAY))
315
+ app.add_exception_handler(NotFoundError, _error(status.HTTP_404_NOT_FOUND))
316
+ app.include_router(router)
317
+ app.add_api_route("/ui", _dashboard, methods=["GET"], include_in_schema=False)
318
+ return app