openmax 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.
- openmax-0.1.0/.gitignore +43 -0
- openmax-0.1.0/LICENSE +21 -0
- openmax-0.1.0/PKG-INFO +28 -0
- openmax-0.1.0/README.md +2 -0
- openmax-0.1.0/pyproject.toml +42 -0
- openmax-0.1.0/src/openmax/__init__.py +3 -0
- openmax-0.1.0/src/openmax/adapters/__init__.py +18 -0
- openmax-0.1.0/src/openmax/adapters/base.py +40 -0
- openmax-0.1.0/src/openmax/adapters/claude_code.py +47 -0
- openmax-0.1.0/src/openmax/adapters/codex_adapter.py +36 -0
- openmax-0.1.0/src/openmax/adapters/opencode_adapter.py +18 -0
- openmax-0.1.0/src/openmax/adapters/subprocess_adapter.py +52 -0
- openmax-0.1.0/src/openmax/cli.py +97 -0
- openmax-0.1.0/src/openmax/kaku.py +28 -0
- openmax-0.1.0/src/openmax/lead_agent.py +359 -0
- openmax-0.1.0/src/openmax/pane_manager.py +369 -0
openmax-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
*.egg
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
.eggs/
|
|
10
|
+
*.so
|
|
11
|
+
*.pyd
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Environment / secrets
|
|
19
|
+
.env
|
|
20
|
+
.env.*
|
|
21
|
+
|
|
22
|
+
# Editors / IDEs
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
*.swp
|
|
26
|
+
*.swo
|
|
27
|
+
*~
|
|
28
|
+
.project
|
|
29
|
+
.settings/
|
|
30
|
+
|
|
31
|
+
# OS
|
|
32
|
+
.DS_Store
|
|
33
|
+
Thumbs.db
|
|
34
|
+
|
|
35
|
+
# openMax runtime state
|
|
36
|
+
.openmax_state.json
|
|
37
|
+
|
|
38
|
+
# Coverage / testing
|
|
39
|
+
.coverage
|
|
40
|
+
htmlcov/
|
|
41
|
+
.pytest_cache/
|
|
42
|
+
.mypy_cache/
|
|
43
|
+
.ruff_cache/
|
openmax-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cklxx
|
|
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.
|
openmax-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openmax
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi AI Agent orchestration hub — dispatch interactive AI agents across terminal panes
|
|
5
|
+
Project-URL: Homepage, https://github.com/anthropics/openMax
|
|
6
|
+
Project-URL: Repository, https://github.com/anthropics/openMax
|
|
7
|
+
Author: Chen Kailun
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,ai,claude,codex,multi-agent,orchestration,terminal
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: claude-agent-sdk>=0.1.0
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: rich>=13.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# openMax
|
|
28
|
+
The more, the better.
|
openmax-0.1.0/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openmax"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Multi AI Agent orchestration hub — dispatch interactive AI agents across terminal panes"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Chen Kailun" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["ai", "agent", "orchestration", "multi-agent", "claude", "codex", "terminal"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"claude-agent-sdk>=0.1.0",
|
|
30
|
+
"click>=8.0",
|
|
31
|
+
"rich>=13.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/anthropics/openMax"
|
|
36
|
+
Repository = "https://github.com/anthropics/openMax"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
openmax = "openmax.cli:main"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/openmax"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Agent adapters for openMax."""
|
|
2
|
+
|
|
3
|
+
from openmax.adapters.base import AgentAdapter, AgentCommand
|
|
4
|
+
from openmax.adapters.claude_code import ClaudeCodeAdapter, ClaudeCodePrintAdapter
|
|
5
|
+
from openmax.adapters.codex_adapter import CodexAdapter, CodexExecAdapter
|
|
6
|
+
from openmax.adapters.opencode_adapter import OpenCodeAdapter
|
|
7
|
+
from openmax.adapters.subprocess_adapter import SubprocessAdapter
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AgentAdapter",
|
|
11
|
+
"AgentCommand",
|
|
12
|
+
"ClaudeCodeAdapter",
|
|
13
|
+
"ClaudeCodePrintAdapter",
|
|
14
|
+
"CodexAdapter",
|
|
15
|
+
"CodexExecAdapter",
|
|
16
|
+
"OpenCodeAdapter",
|
|
17
|
+
"SubprocessAdapter",
|
|
18
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Base adapter for AI agents."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AgentCommand:
|
|
9
|
+
"""Describes how to launch an agent.
|
|
10
|
+
|
|
11
|
+
For interactive agents: `launch_cmd` starts the CLI,
|
|
12
|
+
then `initial_input` is sent via kaku send-text.
|
|
13
|
+
|
|
14
|
+
For non-interactive agents: `launch_cmd` includes the prompt,
|
|
15
|
+
`initial_input` is None.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
launch_cmd: list[str]
|
|
19
|
+
initial_input: str | None = None
|
|
20
|
+
interactive: bool = True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AgentAdapter(ABC):
|
|
24
|
+
"""Abstract base class for agent adapters."""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def agent_type(self) -> str:
|
|
29
|
+
"""Identifier for this agent type."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def interactive(self) -> bool:
|
|
34
|
+
"""Whether this agent runs interactively (default True)."""
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def get_command(self, prompt: str, cwd: str | None = None) -> AgentCommand:
|
|
39
|
+
"""Return the command spec to start this agent with the given prompt."""
|
|
40
|
+
...
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Claude Code agent adapter."""
|
|
2
|
+
|
|
3
|
+
from openmax.adapters.base import AgentAdapter, AgentCommand
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClaudeCodeAdapter(AgentAdapter):
|
|
7
|
+
"""Adapter for Claude Code CLI (interactive mode).
|
|
8
|
+
|
|
9
|
+
Launches `claude` interactively so the user can see plan mode,
|
|
10
|
+
tool usage, and token consumption. The initial prompt is sent
|
|
11
|
+
via kaku send-text.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def agent_type(self) -> str:
|
|
16
|
+
return "claude-code"
|
|
17
|
+
|
|
18
|
+
def get_command(self, prompt: str, cwd: str | None = None) -> AgentCommand:
|
|
19
|
+
launch = ["claude"]
|
|
20
|
+
if cwd:
|
|
21
|
+
launch.extend(["--add-dir", cwd])
|
|
22
|
+
return AgentCommand(
|
|
23
|
+
launch_cmd=launch,
|
|
24
|
+
initial_input=prompt,
|
|
25
|
+
interactive=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ClaudeCodePrintAdapter(AgentAdapter):
|
|
30
|
+
"""Adapter for Claude Code in non-interactive (print) mode.
|
|
31
|
+
|
|
32
|
+
Used for one-shot tasks where interactive control isn't needed.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def agent_type(self) -> str:
|
|
37
|
+
return "claude-code-print"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def interactive(self) -> bool:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def get_command(self, prompt: str, cwd: str | None = None) -> AgentCommand:
|
|
44
|
+
cmd = ["claude", "-p", prompt]
|
|
45
|
+
if cwd:
|
|
46
|
+
cmd.extend(["--add-dir", cwd])
|
|
47
|
+
return AgentCommand(launch_cmd=cmd, interactive=False)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Codex CLI agent adapter."""
|
|
2
|
+
|
|
3
|
+
from openmax.adapters.base import AgentAdapter, AgentCommand
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CodexAdapter(AgentAdapter):
|
|
7
|
+
"""Adapter for OpenAI Codex CLI (interactive mode)."""
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def agent_type(self) -> str:
|
|
11
|
+
return "codex"
|
|
12
|
+
|
|
13
|
+
def get_command(self, prompt: str, cwd: str | None = None) -> AgentCommand:
|
|
14
|
+
return AgentCommand(
|
|
15
|
+
launch_cmd=["codex"],
|
|
16
|
+
initial_input=prompt,
|
|
17
|
+
interactive=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CodexExecAdapter(AgentAdapter):
|
|
22
|
+
"""Adapter for Codex CLI in non-interactive (exec) mode."""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def agent_type(self) -> str:
|
|
26
|
+
return "codex-exec"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def interactive(self) -> bool:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
def get_command(self, prompt: str, cwd: str | None = None) -> AgentCommand:
|
|
33
|
+
return AgentCommand(
|
|
34
|
+
launch_cmd=["codex", "exec", prompt],
|
|
35
|
+
interactive=False,
|
|
36
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""OpenCode CLI agent adapter."""
|
|
2
|
+
|
|
3
|
+
from openmax.adapters.base import AgentAdapter, AgentCommand
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenCodeAdapter(AgentAdapter):
|
|
7
|
+
"""Adapter for OpenCode CLI (interactive mode)."""
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def agent_type(self) -> str:
|
|
11
|
+
return "opencode"
|
|
12
|
+
|
|
13
|
+
def get_command(self, prompt: str, cwd: str | None = None) -> AgentCommand:
|
|
14
|
+
return AgentCommand(
|
|
15
|
+
launch_cmd=["opencode"],
|
|
16
|
+
initial_input=prompt,
|
|
17
|
+
interactive=True,
|
|
18
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Generic subprocess agent adapter for arbitrary CLI agents."""
|
|
2
|
+
|
|
3
|
+
from openmax.adapters.base import AgentAdapter, AgentCommand
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SubprocessAdapter(AgentAdapter):
|
|
7
|
+
"""Adapter for any CLI-based agent.
|
|
8
|
+
|
|
9
|
+
Supports both interactive and non-interactive modes.
|
|
10
|
+
|
|
11
|
+
For interactive: `command_template` is the launch command (no prompt),
|
|
12
|
+
prompt is sent via send-text.
|
|
13
|
+
|
|
14
|
+
For non-interactive: `command_template` may contain `{prompt}` which
|
|
15
|
+
gets replaced with the actual prompt.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
# Interactive agent
|
|
19
|
+
SubprocessAdapter("my-agent", ["my-agent"], interactive=True)
|
|
20
|
+
|
|
21
|
+
# Non-interactive agent
|
|
22
|
+
SubprocessAdapter("my-agent", ["my-agent", "--prompt", "{prompt}"], interactive=False)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
name: str,
|
|
28
|
+
command_template: list[str],
|
|
29
|
+
is_interactive: bool = True,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._name = name
|
|
32
|
+
self._command_template = command_template
|
|
33
|
+
self._interactive = is_interactive
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def agent_type(self) -> str:
|
|
37
|
+
return self._name
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def interactive(self) -> bool:
|
|
41
|
+
return self._interactive
|
|
42
|
+
|
|
43
|
+
def get_command(self, prompt: str, cwd: str | None = None) -> AgentCommand:
|
|
44
|
+
if self._interactive:
|
|
45
|
+
return AgentCommand(
|
|
46
|
+
launch_cmd=list(self._command_template),
|
|
47
|
+
initial_input=prompt,
|
|
48
|
+
interactive=True,
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
cmd = [part.replace("{prompt}", prompt) for part in self._command_template]
|
|
52
|
+
return AgentCommand(launch_cmd=cmd, interactive=False)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""CLI entry point for openMax."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from openmax.kaku import is_kaku_available
|
|
13
|
+
from openmax.lead_agent import run_lead_agent
|
|
14
|
+
from openmax.pane_manager import PaneManager
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def main() -> None:
|
|
21
|
+
"""openMax — Multi AI Agent orchestration hub."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@main.command()
|
|
25
|
+
@click.argument("task")
|
|
26
|
+
@click.option("--cwd", default=None, help="Working directory for agents")
|
|
27
|
+
@click.option("--model", default=None, help="Model for the lead agent")
|
|
28
|
+
@click.option("--max-turns", default=50, type=int, help="Max agent loop turns")
|
|
29
|
+
def run(task: str, cwd: str | None, model: str | None, max_turns: int) -> None:
|
|
30
|
+
"""Decompose TASK and dispatch sub-agents in Kaku panes."""
|
|
31
|
+
if cwd is None:
|
|
32
|
+
cwd = os.getcwd()
|
|
33
|
+
|
|
34
|
+
if not is_kaku_available():
|
|
35
|
+
console.print("[red]Error: kaku CLI not available. Run inside Kaku terminal.[/red]")
|
|
36
|
+
raise SystemExit(1)
|
|
37
|
+
|
|
38
|
+
with PaneManager() as pane_mgr:
|
|
39
|
+
# Register signal handlers so Ctrl-C / kill also cleans up
|
|
40
|
+
def _cleanup_and_exit(signum, frame):
|
|
41
|
+
console.print("\n[yellow]Interrupted — cleaning up panes...[/yellow]")
|
|
42
|
+
pane_mgr.cleanup_all()
|
|
43
|
+
console.print("[green]All managed panes closed.[/green]")
|
|
44
|
+
sys.exit(130 if signum == signal.SIGINT else 143)
|
|
45
|
+
|
|
46
|
+
signal.signal(signal.SIGINT, _cleanup_and_exit)
|
|
47
|
+
signal.signal(signal.SIGTERM, _cleanup_and_exit)
|
|
48
|
+
|
|
49
|
+
plan = run_lead_agent(
|
|
50
|
+
task=task,
|
|
51
|
+
pane_mgr=pane_mgr,
|
|
52
|
+
cwd=cwd,
|
|
53
|
+
model=model,
|
|
54
|
+
max_turns=max_turns,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Session complete — show final summary before cleanup
|
|
58
|
+
summary = pane_mgr.summary()
|
|
59
|
+
console.print(
|
|
60
|
+
f"\n[bold green]Done.[/bold green] "
|
|
61
|
+
f"{len(plan.subtasks)} sub-tasks | "
|
|
62
|
+
f"{summary['windows']} windows | "
|
|
63
|
+
f"{summary['done']} done"
|
|
64
|
+
)
|
|
65
|
+
console.print("[dim]Closing managed panes...[/dim]")
|
|
66
|
+
|
|
67
|
+
# __exit__ runs cleanup_all here
|
|
68
|
+
console.print("[green]All managed panes closed.[/green]")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@main.command()
|
|
72
|
+
def panes() -> None:
|
|
73
|
+
"""List all kaku panes."""
|
|
74
|
+
if not is_kaku_available():
|
|
75
|
+
console.print("[red]kaku CLI not available.[/red]")
|
|
76
|
+
raise SystemExit(1)
|
|
77
|
+
|
|
78
|
+
all_panes = PaneManager.list_all_panes()
|
|
79
|
+
console.print(f"[bold]Found {len(all_panes)} panes:[/bold]")
|
|
80
|
+
for p in all_panes:
|
|
81
|
+
active = " [green]★[/green]" if p.is_active else ""
|
|
82
|
+
console.print(
|
|
83
|
+
f" Pane {p.pane_id}: {p.title or '(untitled)'} "
|
|
84
|
+
f"[dim]({p.cols}x{p.rows})[/dim]{active}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@main.command("read-pane")
|
|
89
|
+
@click.argument("pane_id", type=int)
|
|
90
|
+
def read_pane(pane_id: int) -> None:
|
|
91
|
+
"""Read the text content of a specific pane."""
|
|
92
|
+
mgr = PaneManager()
|
|
93
|
+
try:
|
|
94
|
+
text = mgr.get_text(pane_id)
|
|
95
|
+
console.print(text)
|
|
96
|
+
except RuntimeError as e:
|
|
97
|
+
console.print(f"[red]{e}[/red]")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Kaku terminal integration — thin convenience wrappers.
|
|
2
|
+
|
|
3
|
+
Most pane management goes through PaneManager.
|
|
4
|
+
This module provides standalone utility functions for quick operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_current_pane_id() -> int | None:
|
|
13
|
+
"""Get the pane ID of the current terminal (from WEZTERM_PANE env)."""
|
|
14
|
+
import os
|
|
15
|
+
pane = os.environ.get("WEZTERM_PANE")
|
|
16
|
+
return int(pane) if pane else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_kaku_available() -> bool:
|
|
20
|
+
"""Check if the kaku CLI is available."""
|
|
21
|
+
try:
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
["kaku", "cli", "list"],
|
|
24
|
+
capture_output=True, text=True, timeout=5,
|
|
25
|
+
)
|
|
26
|
+
return result.returncode == 0
|
|
27
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
28
|
+
return False
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Lead Agent — orchestration via claude-agent-sdk with custom tools.
|
|
2
|
+
|
|
3
|
+
Custom tools (dispatch_agent, read_pane_output, etc.) run in-process
|
|
4
|
+
via SDK MCP server. The lead agent uses ClaudeSDKClient for interactive
|
|
5
|
+
multi-turn orchestration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import anyio
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
|
|
21
|
+
from claude_agent_sdk import (
|
|
22
|
+
AssistantMessage,
|
|
23
|
+
ClaudeAgentOptions,
|
|
24
|
+
ClaudeSDKClient,
|
|
25
|
+
ResultMessage,
|
|
26
|
+
TextBlock,
|
|
27
|
+
ToolResultBlock,
|
|
28
|
+
ToolUseBlock,
|
|
29
|
+
create_sdk_mcp_server,
|
|
30
|
+
tool,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from openmax.pane_manager import PaneManager
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Data types ────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TaskStatus(str, Enum):
|
|
42
|
+
PENDING = "pending"
|
|
43
|
+
RUNNING = "running"
|
|
44
|
+
DONE = "done"
|
|
45
|
+
ERROR = "error"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class SubTask:
|
|
50
|
+
name: str
|
|
51
|
+
agent_type: str
|
|
52
|
+
prompt: str
|
|
53
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
54
|
+
pane_id: int | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PlanResult:
|
|
59
|
+
goal: str
|
|
60
|
+
subtasks: list[SubTask] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── System prompt ─────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
SYSTEM_PROMPT = """\
|
|
66
|
+
You are the Lead Agent of openMax, a multi-AI-agent orchestration system.
|
|
67
|
+
You operate as a project manager following a strict management lifecycle:
|
|
68
|
+
|
|
69
|
+
## Phase 1: Align Goal
|
|
70
|
+
- Clarify the user's intent. Restate the goal precisely.
|
|
71
|
+
- Identify constraints, scope boundaries, and success criteria.
|
|
72
|
+
|
|
73
|
+
## Phase 2: Plan & Decompose
|
|
74
|
+
- Break the goal into concrete, parallelizable sub-tasks.
|
|
75
|
+
- For each sub-task, decide which agent type is best suited.
|
|
76
|
+
|
|
77
|
+
## Phase 3: Dispatch
|
|
78
|
+
- Use the `dispatch_agent` tool to assign each sub-task to an agent.
|
|
79
|
+
- Each agent runs interactively in its own terminal pane.
|
|
80
|
+
|
|
81
|
+
## Phase 4: Monitor & Correct
|
|
82
|
+
- Use `read_pane_output` to check each agent's progress.
|
|
83
|
+
- If an agent is stuck or off-track, use `send_text_to_pane` to intervene.
|
|
84
|
+
- Use `mark_task_done` when a task is finished.
|
|
85
|
+
|
|
86
|
+
## Phase 5: Summarize & Report
|
|
87
|
+
- When all tasks are done, use `report_completion` to finalize.
|
|
88
|
+
- Provide a summary of what was accomplished.
|
|
89
|
+
|
|
90
|
+
## Available agent types:
|
|
91
|
+
- "claude-code": Claude Code CLI — best for most coding tasks. Supports plan mode, interactive editing, full tool access.
|
|
92
|
+
- "codex": OpenAI Codex CLI — good for code generation and review.
|
|
93
|
+
- "opencode": OpenCode CLI — alternative coding assistant.
|
|
94
|
+
- "generic": Falls back to interactive claude session.
|
|
95
|
+
|
|
96
|
+
## Important:
|
|
97
|
+
- Each agent runs interactively in a kaku terminal pane — users can click in and intervene.
|
|
98
|
+
- Be decisive. Dispatch agents quickly after planning.
|
|
99
|
+
- Monitor actively — read pane output to check progress.
|
|
100
|
+
- Correct course when agents drift from the goal.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Tool definitions ──────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
# These are module-level so they can capture the shared state via closure
|
|
107
|
+
_pane_mgr: PaneManager | None = None
|
|
108
|
+
_plan: PlanResult | None = None
|
|
109
|
+
_cwd: str = ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@tool(
|
|
113
|
+
"dispatch_agent",
|
|
114
|
+
"Dispatch a sub-task to an AI agent by opening a new interactive terminal pane. Returns the pane_id.",
|
|
115
|
+
{
|
|
116
|
+
"task_name": str,
|
|
117
|
+
"agent_type": str,
|
|
118
|
+
"prompt": str,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
async def dispatch_agent(args: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
from openmax.adapters import (
|
|
123
|
+
ClaudeCodeAdapter,
|
|
124
|
+
CodexAdapter,
|
|
125
|
+
OpenCodeAdapter,
|
|
126
|
+
SubprocessAdapter,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
adapters = {
|
|
130
|
+
"claude-code": ClaudeCodeAdapter(),
|
|
131
|
+
"codex": CodexAdapter(),
|
|
132
|
+
"opencode": OpenCodeAdapter(),
|
|
133
|
+
"generic": SubprocessAdapter("generic", ["claude"]),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
task_name = args["task_name"]
|
|
137
|
+
agent_type = args.get("agent_type", "claude-code")
|
|
138
|
+
prompt = args["prompt"]
|
|
139
|
+
|
|
140
|
+
adapter = adapters.get(agent_type, adapters["generic"])
|
|
141
|
+
cmd_spec = adapter.get_command(prompt, cwd=_cwd)
|
|
142
|
+
|
|
143
|
+
pane = _pane_mgr.spawn_window(
|
|
144
|
+
command=cmd_spec.launch_cmd,
|
|
145
|
+
purpose=f"subtask: {task_name}",
|
|
146
|
+
agent_type=agent_type,
|
|
147
|
+
cwd=_cwd,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# For interactive agents, send the initial prompt after CLI starts
|
|
151
|
+
if cmd_spec.interactive and cmd_spec.initial_input:
|
|
152
|
+
await anyio.sleep(3)
|
|
153
|
+
_pane_mgr.send_text(pane.pane_id, cmd_spec.initial_input)
|
|
154
|
+
|
|
155
|
+
subtask = SubTask(
|
|
156
|
+
name=task_name,
|
|
157
|
+
agent_type=agent_type,
|
|
158
|
+
prompt=prompt,
|
|
159
|
+
status=TaskStatus.RUNNING,
|
|
160
|
+
pane_id=pane.pane_id,
|
|
161
|
+
)
|
|
162
|
+
_plan.subtasks.append(subtask)
|
|
163
|
+
|
|
164
|
+
console.print(
|
|
165
|
+
f" [green]✓[/green] Dispatched [bold]{task_name}[/bold] "
|
|
166
|
+
f"→ pane {pane.pane_id} ({agent_type})"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"content": [
|
|
171
|
+
{
|
|
172
|
+
"type": "text",
|
|
173
|
+
"text": json.dumps({
|
|
174
|
+
"status": "dispatched",
|
|
175
|
+
"pane_id": pane.pane_id,
|
|
176
|
+
"agent_type": agent_type,
|
|
177
|
+
"task_name": task_name,
|
|
178
|
+
}),
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@tool(
|
|
185
|
+
"read_pane_output",
|
|
186
|
+
"Read the current terminal output of an agent pane to check progress.",
|
|
187
|
+
{"pane_id": int},
|
|
188
|
+
)
|
|
189
|
+
async def read_pane_output(args: dict[str, Any]) -> dict[str, Any]:
|
|
190
|
+
pane_id = args["pane_id"]
|
|
191
|
+
try:
|
|
192
|
+
text = _pane_mgr.get_text(pane_id)
|
|
193
|
+
lines = text.splitlines()
|
|
194
|
+
if len(lines) > 150:
|
|
195
|
+
text = "\n".join(lines[-150:])
|
|
196
|
+
return {"content": [{"type": "text", "text": text}]}
|
|
197
|
+
except RuntimeError as e:
|
|
198
|
+
return {"content": [{"type": "text", "text": f"Error: {e}"}]}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@tool(
|
|
202
|
+
"send_text_to_pane",
|
|
203
|
+
"Send text/instructions to an agent pane, as if typed by the user. Use to give follow-up instructions or intervene.",
|
|
204
|
+
{"pane_id": int, "text": str},
|
|
205
|
+
)
|
|
206
|
+
async def send_text_to_pane(args: dict[str, Any]) -> dict[str, Any]:
|
|
207
|
+
pane_id = args["pane_id"]
|
|
208
|
+
text = args["text"]
|
|
209
|
+
_pane_mgr.send_text(pane_id, text + "\n")
|
|
210
|
+
console.print(f" [yellow]→[/yellow] Sent to pane {pane_id}: {text[:80]}")
|
|
211
|
+
return {"content": [{"type": "text", "text": f"Sent to pane {pane_id}"}]}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@tool(
|
|
215
|
+
"list_managed_panes",
|
|
216
|
+
"List all managed panes and their current states.",
|
|
217
|
+
{},
|
|
218
|
+
)
|
|
219
|
+
async def list_managed_panes(args: dict[str, Any]) -> dict[str, Any]:
|
|
220
|
+
_pane_mgr.refresh_states()
|
|
221
|
+
summary = _pane_mgr.summary()
|
|
222
|
+
return {"content": [{"type": "text", "text": json.dumps(summary, ensure_ascii=False)}]}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@tool(
|
|
226
|
+
"mark_task_done",
|
|
227
|
+
"Mark a sub-task as completed.",
|
|
228
|
+
{"task_name": str},
|
|
229
|
+
)
|
|
230
|
+
async def mark_task_done(args: dict[str, Any]) -> dict[str, Any]:
|
|
231
|
+
task_name = args["task_name"]
|
|
232
|
+
for st in _plan.subtasks:
|
|
233
|
+
if st.name == task_name:
|
|
234
|
+
st.status = TaskStatus.DONE
|
|
235
|
+
console.print(f" [green]✓✓[/green] [bold]{task_name}[/bold] done")
|
|
236
|
+
return {"content": [{"type": "text", "text": f"Marked '{task_name}' as done"}]}
|
|
237
|
+
return {"content": [{"type": "text", "text": f"Task '{task_name}' not found"}]}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@tool(
|
|
241
|
+
"report_completion",
|
|
242
|
+
"Report overall goal completion percentage and summary. Call when all tasks are done.",
|
|
243
|
+
{"completion_pct": int, "notes": str},
|
|
244
|
+
)
|
|
245
|
+
async def report_completion(args: dict[str, Any]) -> dict[str, Any]:
|
|
246
|
+
pct = args["completion_pct"]
|
|
247
|
+
notes = args["notes"]
|
|
248
|
+
console.print(Panel(
|
|
249
|
+
f"[bold]Completion: {pct}%[/bold]\n{notes}",
|
|
250
|
+
title="Progress Report",
|
|
251
|
+
border_style="cyan",
|
|
252
|
+
))
|
|
253
|
+
return {"content": [{"type": "text", "text": f"Reported {pct}% — {notes}"}]}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── Run the lead agent ────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run_lead_agent(
|
|
260
|
+
task: str,
|
|
261
|
+
pane_mgr: PaneManager,
|
|
262
|
+
cwd: str,
|
|
263
|
+
model: str | None = None,
|
|
264
|
+
max_turns: int = 50,
|
|
265
|
+
) -> PlanResult:
|
|
266
|
+
"""Run the lead agent synchronously (wraps async)."""
|
|
267
|
+
return anyio.run(_run_lead_agent_async, task, pane_mgr, cwd, model, max_turns)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def _run_lead_agent_async(
|
|
271
|
+
task: str,
|
|
272
|
+
pane_mgr: PaneManager,
|
|
273
|
+
cwd: str,
|
|
274
|
+
model: str | None,
|
|
275
|
+
max_turns: int,
|
|
276
|
+
) -> PlanResult:
|
|
277
|
+
global _pane_mgr, _plan, _cwd
|
|
278
|
+
|
|
279
|
+
_pane_mgr = pane_mgr
|
|
280
|
+
_plan = PlanResult(goal=task)
|
|
281
|
+
_cwd = cwd
|
|
282
|
+
|
|
283
|
+
# Create SDK MCP server with our tools
|
|
284
|
+
server = create_sdk_mcp_server(
|
|
285
|
+
name="openmax",
|
|
286
|
+
version="0.1.0",
|
|
287
|
+
tools=[
|
|
288
|
+
dispatch_agent,
|
|
289
|
+
read_pane_output,
|
|
290
|
+
send_text_to_pane,
|
|
291
|
+
list_managed_panes,
|
|
292
|
+
mark_task_done,
|
|
293
|
+
report_completion,
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
tool_names = [
|
|
298
|
+
"mcp__openmax__dispatch_agent",
|
|
299
|
+
"mcp__openmax__read_pane_output",
|
|
300
|
+
"mcp__openmax__send_text_to_pane",
|
|
301
|
+
"mcp__openmax__list_managed_panes",
|
|
302
|
+
"mcp__openmax__mark_task_done",
|
|
303
|
+
"mcp__openmax__report_completion",
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
options = ClaudeAgentOptions(
|
|
307
|
+
system_prompt=SYSTEM_PROMPT,
|
|
308
|
+
mcp_servers={"openmax": server},
|
|
309
|
+
allowed_tools=tool_names,
|
|
310
|
+
disallowed_tools=[
|
|
311
|
+
"Read", "Write", "Edit", "Bash", "Glob", "Grep",
|
|
312
|
+
"Agent", "NotebookEdit", "WebFetch", "WebSearch",
|
|
313
|
+
],
|
|
314
|
+
max_turns=max_turns,
|
|
315
|
+
cwd=cwd,
|
|
316
|
+
permission_mode="bypassPermissions",
|
|
317
|
+
env={"CLAUDECODE": ""},
|
|
318
|
+
)
|
|
319
|
+
if model:
|
|
320
|
+
options.model = model
|
|
321
|
+
|
|
322
|
+
prompt = (
|
|
323
|
+
f"Goal: {task}\n\n"
|
|
324
|
+
f"Working directory: {cwd}\n\n"
|
|
325
|
+
"Proceed through the management lifecycle:\n"
|
|
326
|
+
"1. Align goal\n"
|
|
327
|
+
"2. Plan & decompose\n"
|
|
328
|
+
"3. Dispatch agents\n"
|
|
329
|
+
"4. Monitor & correct\n"
|
|
330
|
+
"5. Summarize & report"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
console.print(Panel(
|
|
334
|
+
f"[bold]Goal:[/bold] {task}",
|
|
335
|
+
title="openMax Lead Agent",
|
|
336
|
+
border_style="blue",
|
|
337
|
+
))
|
|
338
|
+
|
|
339
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
340
|
+
await client.query(prompt)
|
|
341
|
+
|
|
342
|
+
async for msg in client.receive_response():
|
|
343
|
+
if isinstance(msg, AssistantMessage):
|
|
344
|
+
for block in msg.content:
|
|
345
|
+
if isinstance(block, TextBlock) and block.text.strip():
|
|
346
|
+
console.print(block.text)
|
|
347
|
+
elif isinstance(block, ToolUseBlock):
|
|
348
|
+
console.print(f" [dim]⚙ {block.name}[/dim]")
|
|
349
|
+
elif isinstance(msg, ResultMessage):
|
|
350
|
+
cost = msg.total_cost_usd or 0
|
|
351
|
+
console.print(Panel(
|
|
352
|
+
f"Cost: ${cost:.4f}\n"
|
|
353
|
+
f"Duration: {msg.duration_ms / 1000:.1f}s\n"
|
|
354
|
+
f"Turns: {msg.num_turns}",
|
|
355
|
+
title="Lead Agent Summary",
|
|
356
|
+
border_style="green",
|
|
357
|
+
))
|
|
358
|
+
|
|
359
|
+
return _plan
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Kaku pane manager — tracks all panes created by openMax."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from urllib.parse import unquote, urlparse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _wrap_command_clean_env(command: list[str]) -> list[str]:
|
|
15
|
+
"""Wrap a command to run without CLAUDECODE env var.
|
|
16
|
+
|
|
17
|
+
This prevents 'nested session' errors when spawning
|
|
18
|
+
Claude Code in new kaku panes.
|
|
19
|
+
"""
|
|
20
|
+
return ["env", "-u", "CLAUDECODE", "-u", "CLAUDE_CODE_ENTRYPOINT"] + command
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PaneState(str, Enum):
|
|
24
|
+
IDLE = "idle"
|
|
25
|
+
RUNNING = "running"
|
|
26
|
+
DONE = "done"
|
|
27
|
+
ERROR = "error"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ManagedPane:
|
|
32
|
+
"""A pane managed by openMax."""
|
|
33
|
+
|
|
34
|
+
pane_id: int
|
|
35
|
+
window_id: int | None
|
|
36
|
+
purpose: str # e.g. "lead-agent", "subtask: Write components"
|
|
37
|
+
agent_type: str # e.g. "claude-code", "codex"
|
|
38
|
+
state: PaneState = PaneState.IDLE
|
|
39
|
+
created_at: float = field(default_factory=time.time)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class KakuPaneInfo:
|
|
44
|
+
"""Raw pane info from kaku cli list."""
|
|
45
|
+
|
|
46
|
+
window_id: int
|
|
47
|
+
tab_id: int
|
|
48
|
+
pane_id: int
|
|
49
|
+
workspace: str
|
|
50
|
+
rows: int
|
|
51
|
+
cols: int
|
|
52
|
+
title: str
|
|
53
|
+
cwd: str
|
|
54
|
+
is_active: bool
|
|
55
|
+
is_zoomed: bool
|
|
56
|
+
cursor_visibility: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PaneManager:
|
|
60
|
+
"""Manages kaku windows and panes for openMax."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self._managed: dict[int, ManagedPane] = {}
|
|
64
|
+
self._windows: set[int] = set() # window IDs we created
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def panes(self) -> dict[int, ManagedPane]:
|
|
68
|
+
return dict(self._managed)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def active_count(self) -> int:
|
|
72
|
+
return sum(1 for p in self._managed.values() if p.state == PaneState.RUNNING)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def idle_panes(self) -> list[ManagedPane]:
|
|
76
|
+
return [p for p in self._managed.values() if p.state == PaneState.IDLE]
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def managed_windows(self) -> set[int]:
|
|
80
|
+
return set(self._windows)
|
|
81
|
+
|
|
82
|
+
# ── Kaku CLI wrappers ──────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def list_all_panes() -> list[KakuPaneInfo]:
|
|
86
|
+
"""List all kaku panes (not just managed ones)."""
|
|
87
|
+
result = subprocess.run(
|
|
88
|
+
["kaku", "cli", "list", "--format", "json"],
|
|
89
|
+
capture_output=True, text=True,
|
|
90
|
+
)
|
|
91
|
+
if result.returncode != 0:
|
|
92
|
+
raise RuntimeError(f"kaku cli list failed: {result.stderr}")
|
|
93
|
+
raw = json.loads(result.stdout)
|
|
94
|
+
panes = []
|
|
95
|
+
for p in raw:
|
|
96
|
+
cwd = p.get("cwd", "")
|
|
97
|
+
if cwd.startswith("file://"):
|
|
98
|
+
cwd = unquote(urlparse(cwd).path)
|
|
99
|
+
panes.append(KakuPaneInfo(
|
|
100
|
+
window_id=p["window_id"],
|
|
101
|
+
tab_id=p["tab_id"],
|
|
102
|
+
pane_id=p["pane_id"],
|
|
103
|
+
workspace=p.get("workspace", ""),
|
|
104
|
+
rows=p["size"]["rows"],
|
|
105
|
+
cols=p["size"]["cols"],
|
|
106
|
+
title=p.get("title", ""),
|
|
107
|
+
cwd=cwd,
|
|
108
|
+
is_active=p.get("is_active", False),
|
|
109
|
+
is_zoomed=p.get("is_zoomed", False),
|
|
110
|
+
cursor_visibility=p.get("cursor_visibility", ""),
|
|
111
|
+
))
|
|
112
|
+
return panes
|
|
113
|
+
|
|
114
|
+
# ── Window management ──────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def spawn_window(
|
|
117
|
+
self,
|
|
118
|
+
command: list[str],
|
|
119
|
+
purpose: str,
|
|
120
|
+
agent_type: str,
|
|
121
|
+
cwd: str | None = None,
|
|
122
|
+
) -> ManagedPane:
|
|
123
|
+
"""Open a NEW window with a command and track it.
|
|
124
|
+
|
|
125
|
+
Returns the ManagedPane for the pane in the new window.
|
|
126
|
+
"""
|
|
127
|
+
args = ["kaku", "cli", "spawn", "--new-window"]
|
|
128
|
+
if cwd:
|
|
129
|
+
args.extend(["--cwd", cwd])
|
|
130
|
+
args.append("--")
|
|
131
|
+
args.extend(_wrap_command_clean_env(command))
|
|
132
|
+
|
|
133
|
+
result = subprocess.run(args, capture_output=True, text=True)
|
|
134
|
+
if result.returncode != 0:
|
|
135
|
+
raise RuntimeError(f"kaku spawn --new-window failed: {result.stderr}")
|
|
136
|
+
|
|
137
|
+
pane_id = int(result.stdout.strip())
|
|
138
|
+
|
|
139
|
+
# Find which window this pane belongs to
|
|
140
|
+
window_id = self._find_pane_window(pane_id)
|
|
141
|
+
if window_id is not None:
|
|
142
|
+
self._windows.add(window_id)
|
|
143
|
+
|
|
144
|
+
pane = ManagedPane(
|
|
145
|
+
pane_id=pane_id,
|
|
146
|
+
window_id=window_id,
|
|
147
|
+
purpose=purpose,
|
|
148
|
+
agent_type=agent_type,
|
|
149
|
+
state=PaneState.RUNNING,
|
|
150
|
+
)
|
|
151
|
+
self._managed[pane_id] = pane
|
|
152
|
+
return pane
|
|
153
|
+
|
|
154
|
+
def spawn_tab(
|
|
155
|
+
self,
|
|
156
|
+
command: list[str],
|
|
157
|
+
purpose: str,
|
|
158
|
+
agent_type: str,
|
|
159
|
+
cwd: str | None = None,
|
|
160
|
+
window_id: int | None = None,
|
|
161
|
+
) -> ManagedPane:
|
|
162
|
+
"""Open a new tab (in an existing window or default) and track it."""
|
|
163
|
+
args = ["kaku", "cli", "spawn"]
|
|
164
|
+
if window_id is not None:
|
|
165
|
+
args.extend(["--window-id", str(window_id)])
|
|
166
|
+
if cwd:
|
|
167
|
+
args.extend(["--cwd", cwd])
|
|
168
|
+
args.append("--")
|
|
169
|
+
args.extend(_wrap_command_clean_env(command))
|
|
170
|
+
|
|
171
|
+
result = subprocess.run(args, capture_output=True, text=True)
|
|
172
|
+
if result.returncode != 0:
|
|
173
|
+
raise RuntimeError(f"kaku spawn failed: {result.stderr}")
|
|
174
|
+
|
|
175
|
+
pane_id = int(result.stdout.strip())
|
|
176
|
+
win_id = window_id or self._find_pane_window(pane_id)
|
|
177
|
+
|
|
178
|
+
pane = ManagedPane(
|
|
179
|
+
pane_id=pane_id,
|
|
180
|
+
window_id=win_id,
|
|
181
|
+
purpose=purpose,
|
|
182
|
+
agent_type=agent_type,
|
|
183
|
+
state=PaneState.RUNNING,
|
|
184
|
+
)
|
|
185
|
+
self._managed[pane_id] = pane
|
|
186
|
+
return pane
|
|
187
|
+
|
|
188
|
+
def split_pane(
|
|
189
|
+
self,
|
|
190
|
+
command: list[str],
|
|
191
|
+
purpose: str,
|
|
192
|
+
agent_type: str,
|
|
193
|
+
direction: str = "right",
|
|
194
|
+
cwd: str | None = None,
|
|
195
|
+
target_pane_id: int | None = None,
|
|
196
|
+
) -> ManagedPane:
|
|
197
|
+
"""Split an existing pane and track the new one."""
|
|
198
|
+
args = ["kaku", "cli", "split-pane"]
|
|
199
|
+
if target_pane_id is not None:
|
|
200
|
+
args.extend(["--pane-id", str(target_pane_id)])
|
|
201
|
+
if direction == "right":
|
|
202
|
+
args.append("--right")
|
|
203
|
+
elif direction == "left":
|
|
204
|
+
args.append("--left")
|
|
205
|
+
elif direction == "bottom":
|
|
206
|
+
args.append("--bottom")
|
|
207
|
+
elif direction == "top":
|
|
208
|
+
args.append("--top")
|
|
209
|
+
if cwd:
|
|
210
|
+
args.extend(["--cwd", cwd])
|
|
211
|
+
args.append("--")
|
|
212
|
+
args.extend(_wrap_command_clean_env(command))
|
|
213
|
+
|
|
214
|
+
result = subprocess.run(args, capture_output=True, text=True)
|
|
215
|
+
if result.returncode != 0:
|
|
216
|
+
raise RuntimeError(f"kaku split-pane failed: {result.stderr}")
|
|
217
|
+
|
|
218
|
+
pane_id = int(result.stdout.strip())
|
|
219
|
+
win_id = self._find_pane_window(pane_id)
|
|
220
|
+
|
|
221
|
+
pane = ManagedPane(
|
|
222
|
+
pane_id=pane_id,
|
|
223
|
+
window_id=win_id,
|
|
224
|
+
purpose=purpose,
|
|
225
|
+
agent_type=agent_type,
|
|
226
|
+
state=PaneState.RUNNING,
|
|
227
|
+
)
|
|
228
|
+
self._managed[pane_id] = pane
|
|
229
|
+
return pane
|
|
230
|
+
|
|
231
|
+
# ── Pane I/O ───────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def send_text(self, pane_id: int, text: str, submit: bool = True) -> None:
|
|
234
|
+
"""Send text to a pane as paste, then optionally press Enter to submit.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
pane_id: Target pane.
|
|
238
|
+
text: Text to send.
|
|
239
|
+
submit: If True, send a carriage return after the text to trigger
|
|
240
|
+
submission (e.g. in interactive CLIs like Claude Code).
|
|
241
|
+
"""
|
|
242
|
+
content = text.rstrip("\n").rstrip("\r")
|
|
243
|
+
result = subprocess.run(
|
|
244
|
+
["kaku", "cli", "send-text", "--pane-id", str(pane_id), "--", content],
|
|
245
|
+
capture_output=True, text=True,
|
|
246
|
+
)
|
|
247
|
+
if result.returncode != 0:
|
|
248
|
+
raise RuntimeError(f"kaku send-text failed: {result.stderr}")
|
|
249
|
+
|
|
250
|
+
if submit:
|
|
251
|
+
# Wait for the CLI to finish processing the pasted text
|
|
252
|
+
time.sleep(0.5)
|
|
253
|
+
# Send raw carriage return byte via stdin to trigger Enter
|
|
254
|
+
result = subprocess.run(
|
|
255
|
+
["kaku", "cli", "send-text", "--pane-id", str(pane_id), "--no-paste"],
|
|
256
|
+
input="\r",
|
|
257
|
+
capture_output=True, text=True,
|
|
258
|
+
)
|
|
259
|
+
if result.returncode != 0:
|
|
260
|
+
raise RuntimeError(f"kaku send-text (enter) failed: {result.stderr}")
|
|
261
|
+
|
|
262
|
+
def get_text(self, pane_id: int, start_line: int | None = None) -> str:
|
|
263
|
+
"""Read text content from a pane."""
|
|
264
|
+
args = ["kaku", "cli", "get-text", "--pane-id", str(pane_id)]
|
|
265
|
+
if start_line is not None:
|
|
266
|
+
args.extend(["--start-line", str(start_line)])
|
|
267
|
+
result = subprocess.run(args, capture_output=True, text=True)
|
|
268
|
+
if result.returncode != 0:
|
|
269
|
+
raise RuntimeError(f"kaku get-text failed: {result.stderr}")
|
|
270
|
+
return result.stdout
|
|
271
|
+
|
|
272
|
+
def activate_pane(self, pane_id: int) -> None:
|
|
273
|
+
"""Focus a pane."""
|
|
274
|
+
subprocess.run(
|
|
275
|
+
["kaku", "cli", "activate-pane", "--pane-id", str(pane_id)],
|
|
276
|
+
capture_output=True, text=True,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def kill_pane(self, pane_id: int) -> None:
|
|
280
|
+
"""Kill a managed pane and remove it from tracking."""
|
|
281
|
+
subprocess.run(
|
|
282
|
+
["kaku", "cli", "kill-pane", "--pane-id", str(pane_id)],
|
|
283
|
+
capture_output=True, text=True,
|
|
284
|
+
)
|
|
285
|
+
self._managed.pop(pane_id, None)
|
|
286
|
+
|
|
287
|
+
def set_title(self, pane_id: int, title: str) -> None:
|
|
288
|
+
"""Set the tab title for a pane."""
|
|
289
|
+
subprocess.run(
|
|
290
|
+
["kaku", "cli", "set-tab-title", "--pane-id", str(pane_id), title],
|
|
291
|
+
capture_output=True, text=True,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def set_window_title(self, pane_id: int, title: str) -> None:
|
|
295
|
+
"""Set the window title via a pane in that window."""
|
|
296
|
+
subprocess.run(
|
|
297
|
+
["kaku", "cli", "set-window-title", "--pane-id", str(pane_id), title],
|
|
298
|
+
capture_output=True, text=True,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# ── State management ───────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
def update_state(self, pane_id: int, state: PaneState) -> None:
|
|
304
|
+
if pane_id in self._managed:
|
|
305
|
+
self._managed[pane_id].state = state
|
|
306
|
+
|
|
307
|
+
def is_pane_alive(self, pane_id: int) -> bool:
|
|
308
|
+
try:
|
|
309
|
+
all_panes = self.list_all_panes()
|
|
310
|
+
return any(p.pane_id == pane_id for p in all_panes)
|
|
311
|
+
except RuntimeError:
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
def refresh_states(self) -> None:
|
|
315
|
+
"""Check all managed panes and update states for dead ones."""
|
|
316
|
+
try:
|
|
317
|
+
alive_ids = {p.pane_id for p in self.list_all_panes()}
|
|
318
|
+
except RuntimeError:
|
|
319
|
+
return
|
|
320
|
+
for pane_id, pane in list(self._managed.items()):
|
|
321
|
+
if pane_id not in alive_ids:
|
|
322
|
+
pane.state = PaneState.DONE
|
|
323
|
+
|
|
324
|
+
def summary(self) -> dict:
|
|
325
|
+
return {
|
|
326
|
+
"total": len(self._managed),
|
|
327
|
+
"windows": len(self._windows),
|
|
328
|
+
"running": sum(1 for p in self._managed.values() if p.state == PaneState.RUNNING),
|
|
329
|
+
"idle": sum(1 for p in self._managed.values() if p.state == PaneState.IDLE),
|
|
330
|
+
"done": sum(1 for p in self._managed.values() if p.state == PaneState.DONE),
|
|
331
|
+
"error": sum(1 for p in self._managed.values() if p.state == PaneState.ERROR),
|
|
332
|
+
"panes": [
|
|
333
|
+
{
|
|
334
|
+
"pane_id": p.pane_id,
|
|
335
|
+
"window_id": p.window_id,
|
|
336
|
+
"purpose": p.purpose,
|
|
337
|
+
"agent_type": p.agent_type,
|
|
338
|
+
"state": p.state.value,
|
|
339
|
+
}
|
|
340
|
+
for p in self._managed.values()
|
|
341
|
+
],
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
def cleanup_all(self) -> None:
|
|
345
|
+
"""Kill all managed panes and close managed windows."""
|
|
346
|
+
for pane_id in list(self._managed):
|
|
347
|
+
self.kill_pane(pane_id)
|
|
348
|
+
self._windows.clear()
|
|
349
|
+
|
|
350
|
+
# ── Context manager ────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
def __enter__(self) -> "PaneManager":
|
|
353
|
+
return self
|
|
354
|
+
|
|
355
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
356
|
+
self.cleanup_all()
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
# ── Internal helpers ───────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
def _find_pane_window(self, pane_id: int) -> int | None:
|
|
362
|
+
"""Find which window a pane belongs to."""
|
|
363
|
+
try:
|
|
364
|
+
for p in self.list_all_panes():
|
|
365
|
+
if p.pane_id == pane_id:
|
|
366
|
+
return p.window_id
|
|
367
|
+
except RuntimeError:
|
|
368
|
+
pass
|
|
369
|
+
return None
|