onako 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.
- onako-0.1.0/PKG-INFO +56 -0
- onako-0.1.0/README.md +39 -0
- onako-0.1.0/pyproject.toml +44 -0
- onako-0.1.0/setup.cfg +4 -0
- onako-0.1.0/src/onako/__init__.py +1 -0
- onako-0.1.0/src/onako/cli.py +168 -0
- onako-0.1.0/src/onako/server.py +80 -0
- onako-0.1.0/src/onako/static/index.html +410 -0
- onako-0.1.0/src/onako/templates/com.onako.server.plist.tpl +30 -0
- onako-0.1.0/src/onako/templates/onako.service.tpl +11 -0
- onako-0.1.0/src/onako/tmux_orchestrator.py +175 -0
- onako-0.1.0/src/onako.egg-info/PKG-INFO +56 -0
- onako-0.1.0/src/onako.egg-info/SOURCES.txt +19 -0
- onako-0.1.0/src/onako.egg-info/dependency_links.txt +1 -0
- onako-0.1.0/src/onako.egg-info/entry_points.txt +2 -0
- onako-0.1.0/src/onako.egg-info/requires.txt +7 -0
- onako-0.1.0/src/onako.egg-info/top_level.txt +1 -0
- onako-0.1.0/tests/test_api.py +74 -0
- onako-0.1.0/tests/test_cli.py +17 -0
- onako-0.1.0/tests/test_cli_service.py +16 -0
- onako-0.1.0/tests/test_tmux_orchestrator.py +73 -0
onako-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: onako
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dispatch and monitor Claude Code tasks from your phone
|
|
5
|
+
Author: Amir
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/AzRu/onako
|
|
8
|
+
Keywords: claude,claude-code,tmux,orchestrator,ai
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: fastapi>=0.100.0
|
|
12
|
+
Requires-Dist: uvicorn>=0.20.0
|
|
13
|
+
Requires-Dist: click>=8.0.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
16
|
+
Requires-Dist: httpx>=0.24.0; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# Onako
|
|
19
|
+
|
|
20
|
+
Dispatch and monitor Claude Code tasks from your phone.
|
|
21
|
+
|
|
22
|
+
Onako is a lightweight server that runs on your machine. It spawns Claude Code sessions in tmux, and you monitor them through a mobile-friendly web dashboard. Fire off tasks from an iOS Shortcut or the dashboard, check in from anywhere.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pipx install onako
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires [tmux](https://github.com/tmux/tmux) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
onako serve
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Open http://localhost:8000 on your phone (same network) or set up [Tailscale](https://tailscale.com) for access from anywhere.
|
|
39
|
+
|
|
40
|
+
### Auto-start on boot
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
onako install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Other commands
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
onako status # Check if server is running
|
|
50
|
+
onako uninstall # Remove auto-start service
|
|
51
|
+
onako version # Print version
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## How it works
|
|
55
|
+
|
|
56
|
+
Each task is a tmux window running an interactive Claude Code session. The web dashboard reads tmux output and lets you send messages to running sessions. Task state is persisted in SQLite so it survives server restarts.
|
onako-0.1.0/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Onako
|
|
2
|
+
|
|
3
|
+
Dispatch and monitor Claude Code tasks from your phone.
|
|
4
|
+
|
|
5
|
+
Onako is a lightweight server that runs on your machine. It spawns Claude Code sessions in tmux, and you monitor them through a mobile-friendly web dashboard. Fire off tasks from an iOS Shortcut or the dashboard, check in from anywhere.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install onako
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires [tmux](https://github.com/tmux/tmux) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
onako serve
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Open http://localhost:8000 on your phone (same network) or set up [Tailscale](https://tailscale.com) for access from anywhere.
|
|
22
|
+
|
|
23
|
+
### Auto-start on boot
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
onako install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Other commands
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
onako status # Check if server is running
|
|
33
|
+
onako uninstall # Remove auto-start service
|
|
34
|
+
onako version # Print version
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
Each task is a tmux window running an interactive Claude Code session. The web dashboard reads tmux output and lets you send messages to running sessions. Task state is persisted in SQLite so it survives server restarts.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "onako"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Dispatch and monitor Claude Code tasks from your phone"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Amir"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["claude", "claude-code", "tmux", "orchestrator", "ai"]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"fastapi>=0.100.0",
|
|
18
|
+
"uvicorn>=0.20.0",
|
|
19
|
+
"click>=8.0.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=7.0",
|
|
25
|
+
"httpx>=0.24.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
onako = "onako.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/AzRu/onako"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
onako = [
|
|
39
|
+
"static/**/*",
|
|
40
|
+
"templates/*",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
onako-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
ONAKO_DIR = Path.home() / ".onako"
|
|
11
|
+
LOG_DIR = ONAKO_DIR / "logs"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
def main():
|
|
16
|
+
"""Onako — Dispatch and monitor Claude Code tasks from your phone."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@main.command()
|
|
21
|
+
def version():
|
|
22
|
+
"""Print the version."""
|
|
23
|
+
from onako import __version__
|
|
24
|
+
click.echo(f"onako {__version__}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@main.command()
|
|
28
|
+
@click.option("--host", default="127.0.0.1", help="Host to bind to.")
|
|
29
|
+
@click.option("--port", default=8000, type=int, help="Port to bind to.")
|
|
30
|
+
def serve(host, port):
|
|
31
|
+
"""Start the Onako server."""
|
|
32
|
+
_check_prerequisites()
|
|
33
|
+
|
|
34
|
+
from onako import __version__
|
|
35
|
+
click.echo(f"Onako v{__version__}")
|
|
36
|
+
click.echo(f"Starting server at http://{host}:{port}")
|
|
37
|
+
click.echo(f"Dashboard: http://{host}:{port}")
|
|
38
|
+
click.echo()
|
|
39
|
+
|
|
40
|
+
import uvicorn
|
|
41
|
+
from onako.server import app
|
|
42
|
+
uvicorn.run(app, host=host, port=port)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@main.command()
|
|
46
|
+
@click.option("--host", default="127.0.0.1", help="Host to bind to.")
|
|
47
|
+
@click.option("--port", default=8000, type=int, help="Port to bind to.")
|
|
48
|
+
def install(host, port):
|
|
49
|
+
"""Install Onako as a background service (launchd on macOS, systemd on Linux)."""
|
|
50
|
+
system = platform.system()
|
|
51
|
+
onako_bin = shutil.which("onako")
|
|
52
|
+
if not onako_bin:
|
|
53
|
+
click.echo("Error: 'onako' command not found on PATH.", err=True)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
# Build PATH that includes dirs for tmux and claude
|
|
59
|
+
path_dirs = set()
|
|
60
|
+
for cmd in ["tmux", "claude"]:
|
|
61
|
+
p = shutil.which(cmd)
|
|
62
|
+
if p:
|
|
63
|
+
path_dirs.add(str(Path(p).parent))
|
|
64
|
+
path_dirs.update(["/usr/local/bin", "/usr/bin", "/bin"])
|
|
65
|
+
path_value = ":".join(sorted(path_dirs))
|
|
66
|
+
|
|
67
|
+
if system == "Darwin":
|
|
68
|
+
_install_launchd(onako_bin, host, port, path_value)
|
|
69
|
+
elif system == "Linux":
|
|
70
|
+
_install_systemd(onako_bin, host, port, path_value)
|
|
71
|
+
else:
|
|
72
|
+
click.echo(f"Auto-start is not supported on {system}. Run 'onako serve' manually.", err=True)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _install_launchd(onako_bin, host, port, path_value):
|
|
77
|
+
from importlib.resources import files
|
|
78
|
+
tpl = files("onako").joinpath("templates", "com.onako.server.plist.tpl").read_text()
|
|
79
|
+
plist = tpl.format(
|
|
80
|
+
onako_bin=onako_bin,
|
|
81
|
+
host=host,
|
|
82
|
+
port=port,
|
|
83
|
+
log_dir=LOG_DIR,
|
|
84
|
+
path_value=path_value,
|
|
85
|
+
)
|
|
86
|
+
plist_path = Path.home() / "Library" / "LaunchAgents" / "com.onako.server.plist"
|
|
87
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
plist_path.write_text(plist)
|
|
89
|
+
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
|
90
|
+
click.echo(f"Installed launchd service: {plist_path}")
|
|
91
|
+
click.echo(f"Logs: {LOG_DIR}")
|
|
92
|
+
click.echo(f"Onako is running at http://{host}:{port}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _install_systemd(onako_bin, host, port, path_value):
|
|
96
|
+
from importlib.resources import files
|
|
97
|
+
tpl = files("onako").joinpath("templates", "onako.service.tpl").read_text()
|
|
98
|
+
unit = tpl.format(
|
|
99
|
+
onako_bin=onako_bin,
|
|
100
|
+
host=host,
|
|
101
|
+
port=port,
|
|
102
|
+
path_value=path_value,
|
|
103
|
+
)
|
|
104
|
+
unit_path = Path.home() / ".config" / "systemd" / "user" / "onako.service"
|
|
105
|
+
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
unit_path.write_text(unit)
|
|
107
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
|
108
|
+
subprocess.run(["systemctl", "--user", "enable", "--now", "onako"], check=True)
|
|
109
|
+
click.echo(f"Installed systemd service: {unit_path}")
|
|
110
|
+
click.echo(f"Onako is running at http://{host}:{port}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@main.command()
|
|
114
|
+
def uninstall():
|
|
115
|
+
"""Remove the Onako background service."""
|
|
116
|
+
system = platform.system()
|
|
117
|
+
if system == "Darwin":
|
|
118
|
+
plist_path = Path.home() / "Library" / "LaunchAgents" / "com.onako.server.plist"
|
|
119
|
+
if plist_path.exists():
|
|
120
|
+
subprocess.run(["launchctl", "unload", str(plist_path)])
|
|
121
|
+
plist_path.unlink()
|
|
122
|
+
click.echo("Onako service removed.")
|
|
123
|
+
else:
|
|
124
|
+
click.echo("Onako service is not installed.")
|
|
125
|
+
elif system == "Linux":
|
|
126
|
+
unit_path = Path.home() / ".config" / "systemd" / "user" / "onako.service"
|
|
127
|
+
if unit_path.exists():
|
|
128
|
+
subprocess.run(["systemctl", "--user", "disable", "--now", "onako"])
|
|
129
|
+
unit_path.unlink()
|
|
130
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"])
|
|
131
|
+
click.echo("Onako service removed.")
|
|
132
|
+
else:
|
|
133
|
+
click.echo("Onako service is not installed.")
|
|
134
|
+
else:
|
|
135
|
+
click.echo(f"Not supported on {system}.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@main.command()
|
|
139
|
+
def status():
|
|
140
|
+
"""Check if Onako is running."""
|
|
141
|
+
import urllib.request
|
|
142
|
+
try:
|
|
143
|
+
r = urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=2)
|
|
144
|
+
data = r.read().decode()
|
|
145
|
+
if '"ok"' in data:
|
|
146
|
+
click.echo("Onako server: running")
|
|
147
|
+
click.echo(" URL: http://127.0.0.1:8000")
|
|
148
|
+
else:
|
|
149
|
+
click.echo("Onako server: not responding correctly")
|
|
150
|
+
except Exception:
|
|
151
|
+
click.echo("Onako server: not running")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _check_prerequisites():
|
|
155
|
+
"""Check that tmux and claude are installed."""
|
|
156
|
+
tmux_path = shutil.which("tmux")
|
|
157
|
+
if not tmux_path:
|
|
158
|
+
click.echo("Error: tmux is not installed.", err=True)
|
|
159
|
+
click.echo("Install it with: brew install tmux (macOS) or apt install tmux (Linux)", err=True)
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
click.echo(f" tmux: {tmux_path}")
|
|
162
|
+
|
|
163
|
+
claude_path = shutil.which("claude")
|
|
164
|
+
if not claude_path:
|
|
165
|
+
click.echo("Warning: claude CLI not found on PATH.", err=True)
|
|
166
|
+
click.echo("Install Claude Code from: https://docs.anthropic.com/en/docs/claude-code", err=True)
|
|
167
|
+
else:
|
|
168
|
+
click.echo(f" claude: {claude_path}")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shlex
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI, HTTPException
|
|
5
|
+
from fastapi.responses import FileResponse
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from onako.tmux_orchestrator import TmuxOrchestrator
|
|
9
|
+
|
|
10
|
+
app = FastAPI()
|
|
11
|
+
orch = TmuxOrchestrator()
|
|
12
|
+
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CreateTaskRequest(BaseModel):
|
|
16
|
+
prompt: str
|
|
17
|
+
working_dir: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SendMessageRequest(BaseModel):
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.get("/")
|
|
25
|
+
def dashboard():
|
|
26
|
+
return FileResponse(os.path.join(static_dir, "index.html"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.get("/health")
|
|
30
|
+
def health():
|
|
31
|
+
return {"status": "ok"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.post("/tasks")
|
|
35
|
+
def create_task(req: CreateTaskRequest):
|
|
36
|
+
command = f"claude {shlex.quote(req.prompt)}"
|
|
37
|
+
task = orch.create_task(command, working_dir=req.working_dir, prompt=req.prompt)
|
|
38
|
+
return task
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.get("/tasks")
|
|
42
|
+
def list_tasks():
|
|
43
|
+
return orch.list_tasks()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.get("/tasks/{task_id}")
|
|
47
|
+
def get_task(task_id: str):
|
|
48
|
+
if task_id not in orch.tasks:
|
|
49
|
+
raise HTTPException(status_code=404, detail="Task not found")
|
|
50
|
+
task = orch.tasks[task_id].copy()
|
|
51
|
+
task["output"] = orch.get_output(task_id)
|
|
52
|
+
return task
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.get("/tasks/{task_id}/raw")
|
|
56
|
+
def get_task_raw(task_id: str):
|
|
57
|
+
if task_id not in orch.tasks:
|
|
58
|
+
raise HTTPException(status_code=404, detail="Task not found")
|
|
59
|
+
task = orch.tasks[task_id].copy()
|
|
60
|
+
task["output"] = orch.get_raw_output(task_id)
|
|
61
|
+
return task
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.post("/tasks/{task_id}/message")
|
|
65
|
+
def send_message(task_id: str, req: SendMessageRequest):
|
|
66
|
+
if task_id not in orch.tasks:
|
|
67
|
+
raise HTTPException(status_code=404, detail="Task not found")
|
|
68
|
+
orch.send_message(task_id, req.message)
|
|
69
|
+
return {"status": "sent"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.delete("/tasks/{task_id}")
|
|
73
|
+
def delete_task(task_id: str):
|
|
74
|
+
if task_id not in orch.tasks:
|
|
75
|
+
raise HTTPException(status_code=404, detail="Task not found")
|
|
76
|
+
orch.kill_task(task_id)
|
|
77
|
+
return {"status": "deleted"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
6
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
7
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
8
|
+
<meta name="theme-color" content="#1a1a1a">
|
|
9
|
+
<title>Onako</title>
|
|
10
|
+
<style>
|
|
11
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
12
|
+
body {
|
|
13
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
14
|
+
background: #1a1a1a;
|
|
15
|
+
color: #e0e0e0;
|
|
16
|
+
min-height: 100dvh;
|
|
17
|
+
}
|
|
18
|
+
header {
|
|
19
|
+
padding: 16px;
|
|
20
|
+
padding-top: max(16px, env(safe-area-inset-top));
|
|
21
|
+
border-bottom: 1px solid #333;
|
|
22
|
+
display: flex;
|
|
23
|
+
justify-content: space-between;
|
|
24
|
+
align-items: center;
|
|
25
|
+
}
|
|
26
|
+
header h1 { font-size: 18px; font-weight: 600; }
|
|
27
|
+
#new-task-btn {
|
|
28
|
+
background: #3b82f6;
|
|
29
|
+
color: white;
|
|
30
|
+
border: none;
|
|
31
|
+
padding: 8px 16px;
|
|
32
|
+
border-radius: 6px;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
font-size: 14px;
|
|
35
|
+
}
|
|
36
|
+
.task-list { padding: 8px; }
|
|
37
|
+
.task-item {
|
|
38
|
+
padding: 12px;
|
|
39
|
+
border-bottom: 1px solid #2a2a2a;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
}
|
|
42
|
+
.task-item:hover { background: #222; }
|
|
43
|
+
.task-item .task-id { font-size: 12px; color: #888; }
|
|
44
|
+
.task-item .task-prompt {
|
|
45
|
+
font-size: 14px;
|
|
46
|
+
margin-top: 4px;
|
|
47
|
+
white-space: nowrap;
|
|
48
|
+
overflow: hidden;
|
|
49
|
+
text-overflow: ellipsis;
|
|
50
|
+
}
|
|
51
|
+
.task-item .task-meta {
|
|
52
|
+
font-size: 12px;
|
|
53
|
+
color: #888;
|
|
54
|
+
margin-top: 4px;
|
|
55
|
+
}
|
|
56
|
+
.status-running { color: #22c55e; }
|
|
57
|
+
.status-done { color: #888; }
|
|
58
|
+
.empty-state {
|
|
59
|
+
text-align: center;
|
|
60
|
+
color: #666;
|
|
61
|
+
padding: 48px 16px;
|
|
62
|
+
font-size: 14px;
|
|
63
|
+
}
|
|
64
|
+
#connection-banner {
|
|
65
|
+
display: none;
|
|
66
|
+
background: #ef4444;
|
|
67
|
+
color: white;
|
|
68
|
+
text-align: center;
|
|
69
|
+
padding: 6px;
|
|
70
|
+
font-size: 12px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#detail-view { display: none; flex-direction: column; height: 100dvh; }
|
|
74
|
+
#detail-view.active { display: flex; }
|
|
75
|
+
#list-view.hidden { display: none; }
|
|
76
|
+
#detail-header {
|
|
77
|
+
padding: 12px 16px;
|
|
78
|
+
padding-top: max(12px, env(safe-area-inset-top));
|
|
79
|
+
border-bottom: 1px solid #333;
|
|
80
|
+
display: flex;
|
|
81
|
+
justify-content: space-between;
|
|
82
|
+
align-items: center;
|
|
83
|
+
}
|
|
84
|
+
#back-btn {
|
|
85
|
+
background: none;
|
|
86
|
+
border: none;
|
|
87
|
+
color: #3b82f6;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
font-size: 14px;
|
|
90
|
+
}
|
|
91
|
+
#kill-btn {
|
|
92
|
+
background: #ef4444;
|
|
93
|
+
color: white;
|
|
94
|
+
border: none;
|
|
95
|
+
padding: 6px 12px;
|
|
96
|
+
border-radius: 6px;
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
font-size: 12px;
|
|
99
|
+
}
|
|
100
|
+
#kill-btn.hidden { display: none; }
|
|
101
|
+
#output {
|
|
102
|
+
padding: 12px;
|
|
103
|
+
font-family: "SF Mono", "Menlo", "Monaco", monospace;
|
|
104
|
+
font-size: 12px;
|
|
105
|
+
white-space: pre-wrap;
|
|
106
|
+
word-break: break-all;
|
|
107
|
+
line-height: 1.4;
|
|
108
|
+
overflow-y: auto;
|
|
109
|
+
flex: 1;
|
|
110
|
+
}
|
|
111
|
+
#message-bar {
|
|
112
|
+
padding: 8px;
|
|
113
|
+
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
|
114
|
+
border-top: 1px solid #333;
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-shrink: 0;
|
|
117
|
+
gap: 8px;
|
|
118
|
+
align-items: center;
|
|
119
|
+
}
|
|
120
|
+
#message-input {
|
|
121
|
+
flex: 1;
|
|
122
|
+
min-width: 0;
|
|
123
|
+
background: #2a2a2a;
|
|
124
|
+
border: 1px solid #444;
|
|
125
|
+
color: #e0e0e0;
|
|
126
|
+
padding: 8px 12px;
|
|
127
|
+
border-radius: 6px;
|
|
128
|
+
font-size: 16px;
|
|
129
|
+
-webkit-appearance: none;
|
|
130
|
+
}
|
|
131
|
+
#send-btn {
|
|
132
|
+
background: #3b82f6;
|
|
133
|
+
color: white;
|
|
134
|
+
border: none;
|
|
135
|
+
padding: 8px 12px;
|
|
136
|
+
border-radius: 6px;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
font-size: 14px;
|
|
139
|
+
flex-shrink: 0;
|
|
140
|
+
white-space: nowrap;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#modal-overlay {
|
|
144
|
+
display: none;
|
|
145
|
+
position: fixed;
|
|
146
|
+
inset: 0;
|
|
147
|
+
background: rgba(0,0,0,0.7);
|
|
148
|
+
z-index: 10;
|
|
149
|
+
}
|
|
150
|
+
#modal {
|
|
151
|
+
position: fixed;
|
|
152
|
+
bottom: 0;
|
|
153
|
+
left: 0;
|
|
154
|
+
right: 0;
|
|
155
|
+
background: #222;
|
|
156
|
+
padding: 16px;
|
|
157
|
+
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
|
158
|
+
border-radius: 12px 12px 0 0;
|
|
159
|
+
z-index: 11;
|
|
160
|
+
display: none;
|
|
161
|
+
}
|
|
162
|
+
#modal textarea {
|
|
163
|
+
width: 100%;
|
|
164
|
+
background: #2a2a2a;
|
|
165
|
+
border: 1px solid #444;
|
|
166
|
+
color: #e0e0e0;
|
|
167
|
+
padding: 12px;
|
|
168
|
+
border-radius: 6px;
|
|
169
|
+
font-size: 14px;
|
|
170
|
+
resize: vertical;
|
|
171
|
+
min-height: 80px;
|
|
172
|
+
font-family: inherit;
|
|
173
|
+
}
|
|
174
|
+
#modal input {
|
|
175
|
+
width: 100%;
|
|
176
|
+
background: #2a2a2a;
|
|
177
|
+
border: 1px solid #444;
|
|
178
|
+
color: #e0e0e0;
|
|
179
|
+
padding: 10px 12px;
|
|
180
|
+
border-radius: 6px;
|
|
181
|
+
font-size: 14px;
|
|
182
|
+
margin-top: 8px;
|
|
183
|
+
}
|
|
184
|
+
#modal button {
|
|
185
|
+
width: 100%;
|
|
186
|
+
background: #3b82f6;
|
|
187
|
+
color: white;
|
|
188
|
+
border: none;
|
|
189
|
+
padding: 12px;
|
|
190
|
+
border-radius: 6px;
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
font-size: 16px;
|
|
193
|
+
margin-top: 12px;
|
|
194
|
+
}
|
|
195
|
+
</style>
|
|
196
|
+
</head>
|
|
197
|
+
<body>
|
|
198
|
+
<div id="connection-banner">Connection lost. Retrying...</div>
|
|
199
|
+
|
|
200
|
+
<!-- List View -->
|
|
201
|
+
<div id="list-view">
|
|
202
|
+
<header>
|
|
203
|
+
<h1>Onako</h1>
|
|
204
|
+
<button id="new-task-btn">+ New Task</button>
|
|
205
|
+
</header>
|
|
206
|
+
<div class="task-list" id="task-list"></div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<!-- Detail View -->
|
|
210
|
+
<div id="detail-view">
|
|
211
|
+
<div id="detail-header">
|
|
212
|
+
<button id="back-btn">← Back</button>
|
|
213
|
+
<span id="detail-task-id"></span>
|
|
214
|
+
<button id="kill-btn">Kill</button>
|
|
215
|
+
</div>
|
|
216
|
+
<div id="output"></div>
|
|
217
|
+
<div id="message-bar">
|
|
218
|
+
<input id="message-input" type="text" placeholder="Send a message...">
|
|
219
|
+
<button id="send-btn">Send</button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<!-- New Task Modal -->
|
|
224
|
+
<div id="modal-overlay"></div>
|
|
225
|
+
<div id="modal">
|
|
226
|
+
<textarea id="prompt-input" placeholder="What do you want done?"></textarea>
|
|
227
|
+
<input id="workdir-input" type="text" placeholder="Working directory (optional)">
|
|
228
|
+
<button id="submit-task-btn">Start Task</button>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<script>
|
|
232
|
+
const API = '';
|
|
233
|
+
let currentTaskId = null;
|
|
234
|
+
let currentTaskStatus = null;
|
|
235
|
+
let pollInterval = null;
|
|
236
|
+
|
|
237
|
+
function timeAgo(dateStr) {
|
|
238
|
+
if (!dateStr) return '';
|
|
239
|
+
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
240
|
+
if (seconds < 60) return 'just now';
|
|
241
|
+
const minutes = Math.floor(seconds / 60);
|
|
242
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
243
|
+
const hours = Math.floor(minutes / 60);
|
|
244
|
+
if (hours < 24) return `${hours}h ago`;
|
|
245
|
+
const days = Math.floor(hours / 24);
|
|
246
|
+
return `${days}d ago`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function showConnectionError(show) {
|
|
250
|
+
document.getElementById('connection-banner').style.display = show ? 'block' : 'none';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function loadTasks() {
|
|
254
|
+
try {
|
|
255
|
+
const res = await fetch(`${API}/tasks`);
|
|
256
|
+
const tasks = (await res.json()).filter(t => t.status === 'running');
|
|
257
|
+
const list = document.getElementById('task-list');
|
|
258
|
+
if (tasks.length === 0) {
|
|
259
|
+
list.innerHTML = '<div class="empty-state">No tasks running</div>';
|
|
260
|
+
} else {
|
|
261
|
+
list.innerHTML = tasks.map(t => `
|
|
262
|
+
<div class="task-item" onclick="showTask('${t.id}')">
|
|
263
|
+
<div class="task-id">${t.id}</div>
|
|
264
|
+
<div class="task-prompt">${escapeHtml(t.prompt)}</div>
|
|
265
|
+
<div class="task-meta">
|
|
266
|
+
<span class="status-${t.status}">${t.status}</span>
|
|
267
|
+
${t.started_at ? ' · ' + timeAgo(t.started_at) : ''}
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
`).join('');
|
|
271
|
+
}
|
|
272
|
+
showConnectionError(false);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
showConnectionError(true);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function showTask(id) {
|
|
279
|
+
currentTaskId = id;
|
|
280
|
+
currentTaskStatus = null;
|
|
281
|
+
document.getElementById('list-view').classList.add('hidden');
|
|
282
|
+
document.getElementById('detail-view').classList.add('active');
|
|
283
|
+
document.getElementById('detail-task-id').textContent = id;
|
|
284
|
+
document.getElementById('kill-btn').classList.remove('hidden');
|
|
285
|
+
await refreshOutput();
|
|
286
|
+
pollInterval = setInterval(refreshOutput, 3000);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function refreshOutput() {
|
|
290
|
+
if (!currentTaskId) return;
|
|
291
|
+
try {
|
|
292
|
+
const res = await fetch(`${API}/tasks/${currentTaskId}`);
|
|
293
|
+
if (!res.ok) return;
|
|
294
|
+
const data = await res.json();
|
|
295
|
+
const el = document.getElementById('output');
|
|
296
|
+
const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
|
297
|
+
el.textContent = data.output || '(no output yet)';
|
|
298
|
+
if (wasAtBottom) el.scrollTop = el.scrollHeight;
|
|
299
|
+
showConnectionError(false);
|
|
300
|
+
|
|
301
|
+
// Stop polling and hide kill button when task is done
|
|
302
|
+
if (data.status === 'done' && currentTaskStatus !== 'done') {
|
|
303
|
+
currentTaskStatus = 'done';
|
|
304
|
+
document.getElementById('kill-btn').classList.add('hidden');
|
|
305
|
+
if (pollInterval) {
|
|
306
|
+
clearInterval(pollInterval);
|
|
307
|
+
pollInterval = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch (e) {
|
|
311
|
+
showConnectionError(true);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function showList() {
|
|
316
|
+
currentTaskId = null;
|
|
317
|
+
currentTaskStatus = null;
|
|
318
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
319
|
+
pollInterval = null;
|
|
320
|
+
document.getElementById('detail-view').classList.remove('active');
|
|
321
|
+
document.getElementById('list-view').classList.remove('hidden');
|
|
322
|
+
loadTasks();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function sendMessage() {
|
|
326
|
+
const input = document.getElementById('message-input');
|
|
327
|
+
const msg = input.value.trim();
|
|
328
|
+
if (!msg || !currentTaskId) return;
|
|
329
|
+
try {
|
|
330
|
+
await fetch(`${API}/tasks/${currentTaskId}/message`, {
|
|
331
|
+
method: 'POST',
|
|
332
|
+
headers: {'Content-Type': 'application/json'},
|
|
333
|
+
body: JSON.stringify({message: msg}),
|
|
334
|
+
});
|
|
335
|
+
input.value = '';
|
|
336
|
+
} catch (e) {
|
|
337
|
+
showConnectionError(true);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function killTask() {
|
|
342
|
+
if (!currentTaskId) return;
|
|
343
|
+
if (!confirm('Kill this task?')) return;
|
|
344
|
+
try {
|
|
345
|
+
await fetch(`${API}/tasks/${currentTaskId}`, {method: 'DELETE'});
|
|
346
|
+
showList();
|
|
347
|
+
} catch (e) {
|
|
348
|
+
showConnectionError(true);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function showModal() {
|
|
353
|
+
document.getElementById('modal').style.display = 'block';
|
|
354
|
+
document.getElementById('modal-overlay').style.display = 'block';
|
|
355
|
+
document.getElementById('prompt-input').focus();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function hideModal() {
|
|
359
|
+
document.getElementById('modal').style.display = 'none';
|
|
360
|
+
document.getElementById('modal-overlay').style.display = 'none';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function submitTask() {
|
|
364
|
+
const prompt = document.getElementById('prompt-input').value.trim();
|
|
365
|
+
if (!prompt) return;
|
|
366
|
+
const workdir = document.getElementById('workdir-input').value.trim() || null;
|
|
367
|
+
const body = {prompt};
|
|
368
|
+
if (workdir) body.working_dir = workdir;
|
|
369
|
+
try {
|
|
370
|
+
const res = await fetch(`${API}/tasks`, {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: {'Content-Type': 'application/json'},
|
|
373
|
+
body: JSON.stringify(body),
|
|
374
|
+
});
|
|
375
|
+
const task = await res.json();
|
|
376
|
+
document.getElementById('prompt-input').value = '';
|
|
377
|
+
document.getElementById('workdir-input').value = '';
|
|
378
|
+
hideModal();
|
|
379
|
+
showTask(task.id);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
showConnectionError(true);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function escapeHtml(s) {
|
|
386
|
+
const d = document.createElement('div');
|
|
387
|
+
d.textContent = s;
|
|
388
|
+
return d.innerHTML;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Event listeners
|
|
392
|
+
document.getElementById('new-task-btn').addEventListener('click', showModal);
|
|
393
|
+
document.getElementById('modal-overlay').addEventListener('click', hideModal);
|
|
394
|
+
document.getElementById('submit-task-btn').addEventListener('click', submitTask);
|
|
395
|
+
document.getElementById('back-btn').addEventListener('click', showList);
|
|
396
|
+
document.getElementById('kill-btn').addEventListener('click', killTask);
|
|
397
|
+
document.getElementById('send-btn').addEventListener('click', sendMessage);
|
|
398
|
+
document.getElementById('message-input').addEventListener('keydown', e => {
|
|
399
|
+
if (e.key === 'Enter') sendMessage();
|
|
400
|
+
});
|
|
401
|
+
document.getElementById('prompt-input').addEventListener('keydown', e => {
|
|
402
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submitTask();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Init
|
|
406
|
+
loadTasks();
|
|
407
|
+
setInterval(() => { if (!currentTaskId) loadTasks(); }, 10000);
|
|
408
|
+
</script>
|
|
409
|
+
</body>
|
|
410
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key>
|
|
6
|
+
<string>com.onako.server</string>
|
|
7
|
+
<key>ProgramArguments</key>
|
|
8
|
+
<array>
|
|
9
|
+
<string>{onako_bin}</string>
|
|
10
|
+
<string>serve</string>
|
|
11
|
+
<string>--host</string>
|
|
12
|
+
<string>{host}</string>
|
|
13
|
+
<string>--port</string>
|
|
14
|
+
<string>{port}</string>
|
|
15
|
+
</array>
|
|
16
|
+
<key>RunAtLoad</key>
|
|
17
|
+
<true/>
|
|
18
|
+
<key>KeepAlive</key>
|
|
19
|
+
<true/>
|
|
20
|
+
<key>StandardOutPath</key>
|
|
21
|
+
<string>{log_dir}/server.stdout.log</string>
|
|
22
|
+
<key>StandardErrorPath</key>
|
|
23
|
+
<string>{log_dir}/server.stderr.log</string>
|
|
24
|
+
<key>EnvironmentVariables</key>
|
|
25
|
+
<dict>
|
|
26
|
+
<key>PATH</key>
|
|
27
|
+
<string>{path_value}</string>
|
|
28
|
+
</dict>
|
|
29
|
+
</dict>
|
|
30
|
+
</plist>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import secrets
|
|
3
|
+
import shlex
|
|
4
|
+
import sqlite3
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DB_PATH = Path.home() / ".onako" / "onako.db"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TmuxOrchestrator:
|
|
14
|
+
def __init__(self, session_name: str = "onako", db_path: Path | None = None):
|
|
15
|
+
self.session_name = session_name
|
|
16
|
+
self.db_path = db_path or DB_PATH
|
|
17
|
+
self.tasks: dict[str, dict] = {}
|
|
18
|
+
self._init_db()
|
|
19
|
+
self._load_tasks()
|
|
20
|
+
self._ensure_session()
|
|
21
|
+
self.rediscover_tasks()
|
|
22
|
+
|
|
23
|
+
def _ensure_session(self):
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["tmux", "has-session", "-t", self.session_name],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
)
|
|
28
|
+
if result.returncode != 0:
|
|
29
|
+
subprocess.run(
|
|
30
|
+
["tmux", "new-session", "-d", "-s", self.session_name],
|
|
31
|
+
check=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def _init_db(self):
|
|
35
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
conn = sqlite3.connect(self.db_path)
|
|
37
|
+
conn.execute("""
|
|
38
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
prompt TEXT,
|
|
41
|
+
status TEXT,
|
|
42
|
+
started_at TEXT
|
|
43
|
+
)
|
|
44
|
+
""")
|
|
45
|
+
conn.commit()
|
|
46
|
+
conn.close()
|
|
47
|
+
|
|
48
|
+
def _load_tasks(self):
|
|
49
|
+
conn = sqlite3.connect(self.db_path)
|
|
50
|
+
rows = conn.execute("SELECT id, prompt, status, started_at FROM tasks").fetchall()
|
|
51
|
+
conn.close()
|
|
52
|
+
for row in rows:
|
|
53
|
+
self.tasks[row[0]] = {
|
|
54
|
+
"id": row[0],
|
|
55
|
+
"prompt": row[1],
|
|
56
|
+
"status": row[2],
|
|
57
|
+
"started_at": row[3],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def _save_task(self, task: dict):
|
|
61
|
+
conn = sqlite3.connect(self.db_path)
|
|
62
|
+
conn.execute(
|
|
63
|
+
"INSERT OR REPLACE INTO tasks (id, prompt, status, started_at) VALUES (?, ?, ?, ?)",
|
|
64
|
+
(task["id"], task["prompt"], task["status"], task["started_at"]),
|
|
65
|
+
)
|
|
66
|
+
conn.commit()
|
|
67
|
+
conn.close()
|
|
68
|
+
|
|
69
|
+
def _run_tmux(self, *args) -> subprocess.CompletedProcess:
|
|
70
|
+
return subprocess.run(["tmux", *args], capture_output=True, text=True)
|
|
71
|
+
|
|
72
|
+
def create_task(self, command: str, working_dir: str | None = None, prompt: str | None = None) -> dict:
|
|
73
|
+
task_id = f"task-{secrets.token_hex(4)}"
|
|
74
|
+
self._run_tmux(
|
|
75
|
+
"new-window", "-t", self.session_name, "-n", task_id,
|
|
76
|
+
)
|
|
77
|
+
if working_dir:
|
|
78
|
+
self._run_tmux(
|
|
79
|
+
"send-keys", "-t", f"{self.session_name}:{task_id}",
|
|
80
|
+
f"cd {shlex.quote(working_dir)}", "Enter",
|
|
81
|
+
)
|
|
82
|
+
self._run_tmux(
|
|
83
|
+
"send-keys", "-t", f"{self.session_name}:{task_id}",
|
|
84
|
+
command, "Enter",
|
|
85
|
+
)
|
|
86
|
+
task = {
|
|
87
|
+
"id": task_id,
|
|
88
|
+
"prompt": prompt or command,
|
|
89
|
+
"status": "running",
|
|
90
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
91
|
+
}
|
|
92
|
+
self.tasks[task_id] = task
|
|
93
|
+
self._save_task(task)
|
|
94
|
+
return task
|
|
95
|
+
|
|
96
|
+
def list_tasks(self) -> list[dict]:
|
|
97
|
+
self._sync_task_status()
|
|
98
|
+
return list(self.tasks.values())
|
|
99
|
+
|
|
100
|
+
def get_output(self, task_id: str) -> str:
|
|
101
|
+
raw = self.get_raw_output(task_id)
|
|
102
|
+
cleaned = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", raw)
|
|
103
|
+
return self._strip_claude_chrome(cleaned)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _strip_claude_chrome(text: str) -> str:
|
|
107
|
+
lines = text.split("\n")
|
|
108
|
+
# Strip from the bottom: Claude Code's TUI draws box-drawing chars,
|
|
109
|
+
# the › prompt, and status lines like "accept edits on..."
|
|
110
|
+
while lines:
|
|
111
|
+
line = lines[-1].strip()
|
|
112
|
+
if (
|
|
113
|
+
not line
|
|
114
|
+
or all(c in "─━╭╮╰╯│┃┌┐└┘├┤┬┴┼╋═║ ›❯▸▶" for c in line)
|
|
115
|
+
or "accept edits" in line
|
|
116
|
+
or "esc to interrupt" in line
|
|
117
|
+
or "shift+tab to cycle" in line
|
|
118
|
+
or "ctrl+" in line.lower()
|
|
119
|
+
or line == "›"
|
|
120
|
+
):
|
|
121
|
+
lines.pop()
|
|
122
|
+
else:
|
|
123
|
+
break
|
|
124
|
+
return "\n".join(lines)
|
|
125
|
+
|
|
126
|
+
def get_raw_output(self, task_id: str) -> str:
|
|
127
|
+
result = self._run_tmux(
|
|
128
|
+
"capture-pane", "-t", f"{self.session_name}:{task_id}",
|
|
129
|
+
"-p", "-S", "-",
|
|
130
|
+
)
|
|
131
|
+
return result.stdout
|
|
132
|
+
|
|
133
|
+
def send_message(self, task_id: str, message: str):
|
|
134
|
+
self._run_tmux(
|
|
135
|
+
"send-keys", "-t", f"{self.session_name}:{task_id}",
|
|
136
|
+
"-l", message,
|
|
137
|
+
)
|
|
138
|
+
self._run_tmux(
|
|
139
|
+
"send-keys", "-t", f"{self.session_name}:{task_id}",
|
|
140
|
+
"Enter",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def kill_task(self, task_id: str):
|
|
144
|
+
self._run_tmux("kill-window", "-t", f"{self.session_name}:{task_id}")
|
|
145
|
+
if task_id in self.tasks:
|
|
146
|
+
self.tasks[task_id]["status"] = "done"
|
|
147
|
+
self._save_task(self.tasks[task_id])
|
|
148
|
+
|
|
149
|
+
def _sync_task_status(self):
|
|
150
|
+
result = self._run_tmux(
|
|
151
|
+
"list-windows", "-t", self.session_name, "-F", "#{window_name}",
|
|
152
|
+
)
|
|
153
|
+
active_windows = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
|
|
154
|
+
for task_id, task in self.tasks.items():
|
|
155
|
+
if task["status"] == "running" and task_id not in active_windows:
|
|
156
|
+
task["status"] = "done"
|
|
157
|
+
self._save_task(task)
|
|
158
|
+
|
|
159
|
+
def rediscover_tasks(self):
|
|
160
|
+
"""Rediscover tasks from existing tmux windows on server restart."""
|
|
161
|
+
result = self._run_tmux(
|
|
162
|
+
"list-windows", "-t", self.session_name, "-F", "#{window_name}",
|
|
163
|
+
)
|
|
164
|
+
if not result.stdout.strip():
|
|
165
|
+
return
|
|
166
|
+
for window_name in result.stdout.strip().split("\n"):
|
|
167
|
+
if window_name.startswith("task-") and window_name not in self.tasks:
|
|
168
|
+
task = {
|
|
169
|
+
"id": window_name,
|
|
170
|
+
"prompt": "(rediscovered)",
|
|
171
|
+
"status": "running",
|
|
172
|
+
"started_at": None,
|
|
173
|
+
}
|
|
174
|
+
self.tasks[window_name] = task
|
|
175
|
+
self._save_task(task)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: onako
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dispatch and monitor Claude Code tasks from your phone
|
|
5
|
+
Author: Amir
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/AzRu/onako
|
|
8
|
+
Keywords: claude,claude-code,tmux,orchestrator,ai
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: fastapi>=0.100.0
|
|
12
|
+
Requires-Dist: uvicorn>=0.20.0
|
|
13
|
+
Requires-Dist: click>=8.0.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
16
|
+
Requires-Dist: httpx>=0.24.0; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# Onako
|
|
19
|
+
|
|
20
|
+
Dispatch and monitor Claude Code tasks from your phone.
|
|
21
|
+
|
|
22
|
+
Onako is a lightweight server that runs on your machine. It spawns Claude Code sessions in tmux, and you monitor them through a mobile-friendly web dashboard. Fire off tasks from an iOS Shortcut or the dashboard, check in from anywhere.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pipx install onako
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires [tmux](https://github.com/tmux/tmux) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
onako serve
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Open http://localhost:8000 on your phone (same network) or set up [Tailscale](https://tailscale.com) for access from anywhere.
|
|
39
|
+
|
|
40
|
+
### Auto-start on boot
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
onako install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Other commands
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
onako status # Check if server is running
|
|
50
|
+
onako uninstall # Remove auto-start service
|
|
51
|
+
onako version # Print version
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## How it works
|
|
55
|
+
|
|
56
|
+
Each task is a tmux window running an interactive Claude Code session. The web dashboard reads tmux output and lets you send messages to running sessions. Task state is persisted in SQLite so it survives server restarts.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/onako/__init__.py
|
|
4
|
+
src/onako/cli.py
|
|
5
|
+
src/onako/server.py
|
|
6
|
+
src/onako/tmux_orchestrator.py
|
|
7
|
+
src/onako.egg-info/PKG-INFO
|
|
8
|
+
src/onako.egg-info/SOURCES.txt
|
|
9
|
+
src/onako.egg-info/dependency_links.txt
|
|
10
|
+
src/onako.egg-info/entry_points.txt
|
|
11
|
+
src/onako.egg-info/requires.txt
|
|
12
|
+
src/onako.egg-info/top_level.txt
|
|
13
|
+
src/onako/static/index.html
|
|
14
|
+
src/onako/templates/com.onako.server.plist.tpl
|
|
15
|
+
src/onako/templates/onako.service.tpl
|
|
16
|
+
tests/test_api.py
|
|
17
|
+
tests/test_cli.py
|
|
18
|
+
tests/test_cli_service.py
|
|
19
|
+
tests/test_tmux_orchestrator.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
onako
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi.testclient import TestClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@pytest.fixture
|
|
6
|
+
def client():
|
|
7
|
+
import importlib
|
|
8
|
+
from onako import server
|
|
9
|
+
importlib.reload(server)
|
|
10
|
+
client = TestClient(server.app)
|
|
11
|
+
yield client
|
|
12
|
+
response = client.get("/tasks")
|
|
13
|
+
for task in response.json():
|
|
14
|
+
client.delete(f"/tasks/{task['id']}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_health(client):
|
|
18
|
+
r = client.get("/health")
|
|
19
|
+
assert r.status_code == 200
|
|
20
|
+
assert r.json()["status"] == "ok"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_create_task(client):
|
|
24
|
+
r = client.post("/tasks", json={"prompt": "echo api-test"})
|
|
25
|
+
assert r.status_code == 200
|
|
26
|
+
assert r.json()["id"].startswith("task-")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_list_tasks(client):
|
|
30
|
+
client.post("/tasks", json={"prompt": "echo one"})
|
|
31
|
+
client.post("/tasks", json={"prompt": "echo two"})
|
|
32
|
+
r = client.get("/tasks")
|
|
33
|
+
assert r.status_code == 200
|
|
34
|
+
assert len(r.json()) >= 2
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_get_task(client):
|
|
38
|
+
create = client.post("/tasks", json={"prompt": "echo detail-test"})
|
|
39
|
+
task_id = create.json()["id"]
|
|
40
|
+
import time
|
|
41
|
+
time.sleep(1)
|
|
42
|
+
r = client.get(f"/tasks/{task_id}")
|
|
43
|
+
assert r.status_code == 200
|
|
44
|
+
assert r.json()["id"] == task_id
|
|
45
|
+
assert "output" in r.json()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_get_task_raw(client):
|
|
49
|
+
create = client.post("/tasks", json={"prompt": "echo raw-api-test"})
|
|
50
|
+
task_id = create.json()["id"]
|
|
51
|
+
import time
|
|
52
|
+
time.sleep(1)
|
|
53
|
+
r = client.get(f"/tasks/{task_id}/raw")
|
|
54
|
+
assert r.status_code == 200
|
|
55
|
+
assert "output" in r.json()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_send_message(client):
|
|
59
|
+
create = client.post("/tasks", json={"prompt": "cat"})
|
|
60
|
+
task_id = create.json()["id"]
|
|
61
|
+
r = client.post(f"/tasks/{task_id}/message", json={"message": "hello"})
|
|
62
|
+
assert r.status_code == 200
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_delete_task(client):
|
|
66
|
+
create = client.post("/tasks", json={"prompt": "sleep 999"})
|
|
67
|
+
task_id = create.json()["id"]
|
|
68
|
+
r = client.delete(f"/tasks/{task_id}")
|
|
69
|
+
assert r.status_code == 200
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_get_nonexistent_task(client):
|
|
73
|
+
r = client.get("/tasks/task-nonexistent")
|
|
74
|
+
assert r.status_code == 404
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from click.testing import CliRunner
|
|
2
|
+
from onako.cli import main
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_version():
|
|
6
|
+
runner = CliRunner()
|
|
7
|
+
result = runner.invoke(main, ["version"])
|
|
8
|
+
assert result.exit_code == 0
|
|
9
|
+
assert "0.1.0" in result.output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_serve_help():
|
|
13
|
+
runner = CliRunner()
|
|
14
|
+
result = runner.invoke(main, ["serve", "--help"])
|
|
15
|
+
assert result.exit_code == 0
|
|
16
|
+
assert "--host" in result.output
|
|
17
|
+
assert "--port" in result.output
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from click.testing import CliRunner
|
|
2
|
+
from onako.cli import main
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_status_reports_something():
|
|
6
|
+
runner = CliRunner()
|
|
7
|
+
result = runner.invoke(main, ["status"])
|
|
8
|
+
assert result.exit_code == 0
|
|
9
|
+
assert "Onako server:" in result.output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_uninstall_when_not_installed():
|
|
13
|
+
runner = CliRunner()
|
|
14
|
+
result = runner.invoke(main, ["uninstall"])
|
|
15
|
+
assert result.exit_code == 0
|
|
16
|
+
assert "not installed" in result.output
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import pytest
|
|
5
|
+
from onako.tmux_orchestrator import TmuxOrchestrator
|
|
6
|
+
|
|
7
|
+
SESSION_NAME = "onako-test"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture(autouse=True)
|
|
11
|
+
def cleanup(tmp_path):
|
|
12
|
+
"""Kill test tmux session before and after each test."""
|
|
13
|
+
subprocess.run(["tmux", "kill-session", "-t", SESSION_NAME], capture_output=True)
|
|
14
|
+
yield
|
|
15
|
+
subprocess.run(["tmux", "kill-session", "-t", SESSION_NAME], capture_output=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def orch(tmp_path):
|
|
20
|
+
return TmuxOrchestrator(session_name=SESSION_NAME, db_path=tmp_path / "test.db")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_create_task_returns_id(orch):
|
|
24
|
+
task = orch.create_task("echo hello")
|
|
25
|
+
assert task["id"].startswith("task-")
|
|
26
|
+
assert task["status"] == "running"
|
|
27
|
+
assert task["prompt"] == "echo hello"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_list_tasks_shows_created_task(orch):
|
|
31
|
+
task = orch.create_task("echo hello")
|
|
32
|
+
tasks = orch.list_tasks()
|
|
33
|
+
assert any(t["id"] == task["id"] for t in tasks)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_get_output_captures_pane(orch):
|
|
37
|
+
task = orch.create_task("echo hello-from-test")
|
|
38
|
+
time.sleep(1)
|
|
39
|
+
output = orch.get_output(task["id"])
|
|
40
|
+
assert "hello-from-test" in output
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_get_raw_output(orch):
|
|
44
|
+
task = orch.create_task("echo raw-test")
|
|
45
|
+
time.sleep(1)
|
|
46
|
+
raw = orch.get_raw_output(task["id"])
|
|
47
|
+
assert "raw-test" in raw
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_send_message(orch):
|
|
51
|
+
task = orch.create_task("cat") # cat waits for stdin
|
|
52
|
+
time.sleep(0.5)
|
|
53
|
+
orch.send_message(task["id"], "hello-input")
|
|
54
|
+
time.sleep(1)
|
|
55
|
+
output = orch.get_output(task["id"])
|
|
56
|
+
assert "hello-input" in output
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_kill_task(orch):
|
|
60
|
+
task = orch.create_task("sleep 999")
|
|
61
|
+
orch.kill_task(task["id"])
|
|
62
|
+
tasks = orch.list_tasks()
|
|
63
|
+
assert not any(t["id"] == task["id"] and t["status"] == "running" for t in tasks)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_create_multiple_tasks(orch):
|
|
67
|
+
t1 = orch.create_task("echo one")
|
|
68
|
+
t2 = orch.create_task("echo two")
|
|
69
|
+
tasks = orch.list_tasks()
|
|
70
|
+
ids = [t["id"] for t in tasks]
|
|
71
|
+
assert t1["id"] in ids
|
|
72
|
+
assert t2["id"] in ids
|
|
73
|
+
assert t1["id"] != t2["id"]
|