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.
- intandem-0.1.0/.gitignore +8 -0
- intandem-0.1.0/LICENSE +21 -0
- intandem-0.1.0/PKG-INFO +108 -0
- intandem-0.1.0/README.md +80 -0
- intandem-0.1.0/pyproject.toml +101 -0
- intandem-0.1.0/src/intandem/__init__.py +3 -0
- intandem-0.1.0/src/intandem/__main__.py +3 -0
- intandem-0.1.0/src/intandem/bridge/__init__.py +6 -0
- intandem-0.1.0/src/intandem/bridge/app.py +318 -0
- intandem-0.1.0/src/intandem/bridge/dashboard.html +480 -0
- intandem-0.1.0/src/intandem/bridge/hub.html +121 -0
- intandem-0.1.0/src/intandem/bridge/manifest.py +75 -0
- intandem-0.1.0/src/intandem/bridge/schemas.py +159 -0
- intandem-0.1.0/src/intandem/cli/__init__.py +5 -0
- intandem-0.1.0/src/intandem/cli/client.py +151 -0
- intandem-0.1.0/src/intandem/cli/main.py +543 -0
- intandem-0.1.0/src/intandem/cli/render.py +91 -0
- intandem-0.1.0/src/intandem/config.py +148 -0
- intandem-0.1.0/src/intandem/hub.py +356 -0
- intandem-0.1.0/src/intandem/mcp_server.py +800 -0
- intandem-0.1.0/src/intandem/notify.py +190 -0
- intandem-0.1.0/src/intandem/opencode/__init__.py +20 -0
- intandem-0.1.0/src/intandem/opencode/client.py +268 -0
- intandem-0.1.0/src/intandem/opencode/events.py +171 -0
- intandem-0.1.0/src/intandem/opencode/host.py +199 -0
- intandem-0.1.0/src/intandem/opencode/jsonpath.py +29 -0
- intandem-0.1.0/src/intandem/opencode/models.py +145 -0
- intandem-0.1.0/src/intandem/opencode/server.py +73 -0
- intandem-0.1.0/src/intandem/py.typed +0 -0
- intandem-0.1.0/src/intandem/runtime.py +217 -0
- intandem-0.1.0/src/intandem/supervisor/__init__.py +36 -0
- intandem-0.1.0/src/intandem/supervisor/activity.py +257 -0
- intandem-0.1.0/src/intandem/supervisor/journal.py +84 -0
- intandem-0.1.0/src/intandem/supervisor/prompts.py +84 -0
- intandem-0.1.0/src/intandem/supervisor/protected.py +98 -0
- intandem-0.1.0/src/intandem/supervisor/results.py +39 -0
- intandem-0.1.0/src/intandem/supervisor/state.py +179 -0
- intandem-0.1.0/src/intandem/supervisor/supervisor.py +852 -0
- intandem-0.1.0/src/intandem/worktrees.py +182 -0
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.
|
intandem-0.1.0/PKG-INFO
ADDED
|
@@ -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 |
|
intandem-0.1.0/README.md
ADDED
|
@@ -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,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
|