onako 0.2.0__tar.gz → 0.3.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.2.0 → onako-0.3.0}/PKG-INFO +11 -17
- onako-0.3.0/README.md +35 -0
- {onako-0.2.0 → onako-0.3.0}/pyproject.toml +1 -1
- onako-0.3.0/src/onako/__init__.py +1 -0
- onako-0.3.0/src/onako/cli.py +338 -0
- {onako-0.2.0 → onako-0.3.0}/src/onako/server.py +4 -1
- {onako-0.2.0 → onako-0.3.0}/src/onako/static/index.html +16 -2
- {onako-0.2.0 → onako-0.3.0}/src/onako/tmux_orchestrator.py +61 -17
- {onako-0.2.0 → onako-0.3.0}/src/onako.egg-info/PKG-INFO +11 -17
- {onako-0.2.0 → onako-0.3.0}/tests/test_api.py +19 -2
- {onako-0.2.0 → onako-0.3.0}/tests/test_cli.py +11 -1
- {onako-0.2.0 → onako-0.3.0}/tests/test_cli_service.py +7 -0
- {onako-0.2.0 → onako-0.3.0}/tests/test_tmux_orchestrator.py +62 -0
- onako-0.2.0/README.md +0 -41
- onako-0.2.0/src/onako/__init__.py +0 -1
- onako-0.2.0/src/onako/cli.py +0 -208
- {onako-0.2.0 → onako-0.3.0}/setup.cfg +0 -0
- {onako-0.2.0 → onako-0.3.0}/src/onako/templates/com.onako.server.plist.tpl +0 -0
- {onako-0.2.0 → onako-0.3.0}/src/onako/templates/onako.service.tpl +0 -0
- {onako-0.2.0 → onako-0.3.0}/src/onako.egg-info/SOURCES.txt +0 -0
- {onako-0.2.0 → onako-0.3.0}/src/onako.egg-info/dependency_links.txt +0 -0
- {onako-0.2.0 → onako-0.3.0}/src/onako.egg-info/entry_points.txt +0 -0
- {onako-0.2.0 → onako-0.3.0}/src/onako.egg-info/requires.txt +0 -0
- {onako-0.2.0 → onako-0.3.0}/src/onako.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: onako
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Dispatch and monitor Claude Code tasks from your phone
|
|
5
5
|
Author: Amir
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,27 +32,21 @@ Requires [tmux](https://github.com/tmux/tmux) and [Claude Code](https://docs.ant
|
|
|
32
32
|
## Usage
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
|
|
36
|
-
onako
|
|
35
|
+
onako # starts server, drops you into tmux
|
|
36
|
+
onako --session my-project # custom session name
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Open http://localhost:8787 on your phone (same network) or set up [Tailscale](https://tailscale.com) for access from anywhere.
|
|
40
|
-
|
|
41
|
-
### Run in the background
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
onako serve --background
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
This installs a system service (launchd on macOS, systemd on Linux) that starts on boot. Tasks run in the directory where you ran the command.
|
|
39
|
+
If you're already inside tmux, onako auto-detects your session and skips the attach. Open http://localhost:8787 on your phone (same network) or set up [Tailscale](https://tailscale.com) for access from anywhere.
|
|
48
40
|
|
|
49
41
|
```bash
|
|
50
|
-
onako
|
|
51
|
-
onako status
|
|
52
|
-
onako
|
|
53
|
-
onako version
|
|
42
|
+
onako stop # stop the background server
|
|
43
|
+
onako status # check if running
|
|
44
|
+
onako serve # foreground server (for development)
|
|
45
|
+
onako version # print version
|
|
54
46
|
```
|
|
55
47
|
|
|
56
48
|
## How it works
|
|
57
49
|
|
|
58
|
-
|
|
50
|
+
Onako monitors all tmux windows in the configured session. Windows it creates (via the dashboard) are "managed" tasks. Windows created by you or other tools are discovered automatically as "external" — both get full dashboard support: view output, send messages, kill.
|
|
51
|
+
|
|
52
|
+
Task state is persisted in SQLite so it survives server restarts.
|
onako-0.3.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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 # starts server, drops you into tmux
|
|
19
|
+
onako --session my-project # custom session name
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
If you're already inside tmux, onako auto-detects your session and skips the attach. Open http://localhost:8787 on your phone (same network) or set up [Tailscale](https://tailscale.com) for access from anywhere.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
onako stop # stop the background server
|
|
26
|
+
onako status # check if running
|
|
27
|
+
onako serve # foreground server (for development)
|
|
28
|
+
onako version # print version
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
Onako monitors all tmux windows in the configured session. Windows it creates (via the dashboard) are "managed" tasks. Windows created by you or other tools are discovered automatically as "external" — both get full dashboard support: view output, send messages, kill.
|
|
34
|
+
|
|
35
|
+
Task state is persisted in SQLite so it survives server restarts.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import socket
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
ONAKO_DIR = Path.home() / ".onako"
|
|
12
|
+
LOG_DIR = ONAKO_DIR / "logs"
|
|
13
|
+
PID_FILE = ONAKO_DIR / "onako.pid"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group(invoke_without_command=True)
|
|
17
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind to.")
|
|
18
|
+
@click.option("--port", default=8787, type=int, help="Port to bind to.")
|
|
19
|
+
@click.option("--session", default="onako", help="tmux session name (auto-detected if inside tmux).")
|
|
20
|
+
@click.option("--dir", "working_dir", default=None, type=click.Path(exists=True), help="Working directory for tasks (default: current directory).")
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def main(ctx, host, port, session, working_dir):
|
|
23
|
+
"""Onako — Dispatch and monitor Claude Code tasks from your phone."""
|
|
24
|
+
if ctx.invoked_subcommand is not None:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
_check_prerequisites()
|
|
28
|
+
working_dir = str(Path(working_dir).resolve()) if working_dir else os.getcwd()
|
|
29
|
+
|
|
30
|
+
# Auto-detect current tmux session if inside one
|
|
31
|
+
if os.environ.get("TMUX"):
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
["tmux", "display-message", "-p", "#S"],
|
|
35
|
+
capture_output=True, text=True,
|
|
36
|
+
)
|
|
37
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
38
|
+
detected = result.stdout.strip()
|
|
39
|
+
click.echo(f"Detected tmux session: {detected}")
|
|
40
|
+
session = detected
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
_start_server(host, port, session, working_dir)
|
|
45
|
+
|
|
46
|
+
# If not inside tmux, ensure session exists and attach
|
|
47
|
+
if not os.environ.get("TMUX"):
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["tmux", "has-session", "-t", session],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
)
|
|
52
|
+
if result.returncode != 0:
|
|
53
|
+
subprocess.run(
|
|
54
|
+
["tmux", "new-session", "-d", "-s", session],
|
|
55
|
+
check=True,
|
|
56
|
+
)
|
|
57
|
+
os.execvp("tmux", ["tmux", "attach-session", "-t", session])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@main.command()
|
|
61
|
+
def version():
|
|
62
|
+
"""Print the version."""
|
|
63
|
+
from onako import __version__
|
|
64
|
+
click.echo(f"onako {__version__}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@main.command()
|
|
68
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind to.")
|
|
69
|
+
@click.option("--port", default=8787, type=int, help="Port to bind to.")
|
|
70
|
+
@click.option("--session", default="onako", help="tmux session name.")
|
|
71
|
+
@click.option("--dir", "working_dir", default=None, type=click.Path(exists=True), help="Working directory for tasks (default: current directory).")
|
|
72
|
+
@click.option("--background", is_flag=True, help="Run as a background service (launchd/systemd).")
|
|
73
|
+
def serve(host, port, session, working_dir, background):
|
|
74
|
+
"""Start the Onako server."""
|
|
75
|
+
working_dir = str(Path(working_dir).resolve()) if working_dir else os.getcwd()
|
|
76
|
+
|
|
77
|
+
if background:
|
|
78
|
+
_start_background(host, port, working_dir)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
_check_prerequisites()
|
|
82
|
+
|
|
83
|
+
os.environ["ONAKO_WORKING_DIR"] = working_dir
|
|
84
|
+
os.environ["ONAKO_SESSION"] = session
|
|
85
|
+
|
|
86
|
+
from onako import __version__
|
|
87
|
+
click.echo(f"Onako v{__version__}")
|
|
88
|
+
click.echo(f"Starting server at http://{host}:{port}")
|
|
89
|
+
click.echo(f"Working directory: {working_dir}")
|
|
90
|
+
click.echo(f"Session: {session}")
|
|
91
|
+
click.echo()
|
|
92
|
+
|
|
93
|
+
import uvicorn
|
|
94
|
+
from onako.server import app
|
|
95
|
+
uvicorn.run(app, host=host, port=port)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@main.command()
|
|
99
|
+
def stop():
|
|
100
|
+
"""Stop the background Onako service."""
|
|
101
|
+
stopped = False
|
|
102
|
+
|
|
103
|
+
# Try pid file first (from `onako start`)
|
|
104
|
+
if PID_FILE.exists():
|
|
105
|
+
try:
|
|
106
|
+
pid = int(PID_FILE.read_text().strip())
|
|
107
|
+
os.kill(pid, 15) # SIGTERM
|
|
108
|
+
click.echo(f"Onako server stopped (pid {pid}).")
|
|
109
|
+
stopped = True
|
|
110
|
+
except (ValueError, ProcessLookupError):
|
|
111
|
+
click.echo("Stale pid file found, cleaning up.")
|
|
112
|
+
PID_FILE.unlink(missing_ok=True)
|
|
113
|
+
|
|
114
|
+
# Fall back to launchd/systemd
|
|
115
|
+
if not stopped:
|
|
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
|
+
uid = os.getuid()
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
["launchctl", "bootout", f"gui/{uid}", str(plist_path)],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
)
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
|
|
127
|
+
plist_path.unlink()
|
|
128
|
+
click.echo("Onako service stopped and removed.")
|
|
129
|
+
stopped = True
|
|
130
|
+
elif system == "Linux":
|
|
131
|
+
unit_path = Path.home() / ".config" / "systemd" / "user" / "onako.service"
|
|
132
|
+
if unit_path.exists():
|
|
133
|
+
subprocess.run(["systemctl", "--user", "disable", "--now", "onako"])
|
|
134
|
+
unit_path.unlink()
|
|
135
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"])
|
|
136
|
+
click.echo("Onako service stopped and removed.")
|
|
137
|
+
stopped = True
|
|
138
|
+
|
|
139
|
+
if not stopped:
|
|
140
|
+
click.echo("Onako service is not running.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@main.command()
|
|
144
|
+
def status():
|
|
145
|
+
"""Check if Onako is running."""
|
|
146
|
+
import urllib.request
|
|
147
|
+
try:
|
|
148
|
+
r = urllib.request.urlopen("http://127.0.0.1:8787/health", timeout=2)
|
|
149
|
+
data = r.read().decode()
|
|
150
|
+
if '"ok"' in data:
|
|
151
|
+
click.echo("Onako server: running")
|
|
152
|
+
click.echo(" URL: http://127.0.0.1:8787")
|
|
153
|
+
else:
|
|
154
|
+
click.echo("Onako server: not responding correctly")
|
|
155
|
+
except Exception:
|
|
156
|
+
click.echo("Onako server: not running")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_server_running():
|
|
160
|
+
"""Check if the onako server is already running via pid file."""
|
|
161
|
+
if not PID_FILE.exists():
|
|
162
|
+
return False
|
|
163
|
+
try:
|
|
164
|
+
pid = int(PID_FILE.read_text().strip())
|
|
165
|
+
os.kill(pid, 0) # signal 0 = check if process exists
|
|
166
|
+
return True
|
|
167
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
168
|
+
PID_FILE.unlink(missing_ok=True)
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _start_server(host, port, session, working_dir):
|
|
173
|
+
"""Start the Onako server in the background if not already running.
|
|
174
|
+
|
|
175
|
+
Returns True if the server was started or is already running.
|
|
176
|
+
"""
|
|
177
|
+
if _is_server_running():
|
|
178
|
+
click.echo(f"Onako server already running (pid {PID_FILE.read_text().strip()})")
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
onako_bin = shutil.which("onako")
|
|
183
|
+
if not onako_bin:
|
|
184
|
+
click.echo("Error: 'onako' command not found on PATH.", err=True)
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
|
|
187
|
+
log_out = LOG_DIR / "onako.log"
|
|
188
|
+
|
|
189
|
+
with open(log_out, "a") as log_fh:
|
|
190
|
+
proc = subprocess.Popen(
|
|
191
|
+
[onako_bin, "serve", "--host", host, "--port", str(port), "--session", session, "--dir", working_dir],
|
|
192
|
+
stdout=log_fh,
|
|
193
|
+
stderr=subprocess.STDOUT,
|
|
194
|
+
start_new_session=True,
|
|
195
|
+
)
|
|
196
|
+
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
PID_FILE.write_text(str(proc.pid))
|
|
198
|
+
|
|
199
|
+
local_ip = _get_local_ip()
|
|
200
|
+
banner = [
|
|
201
|
+
f"Onako server started (pid {proc.pid})",
|
|
202
|
+
f" Dashboard: http://{host}:{port}",
|
|
203
|
+
]
|
|
204
|
+
if local_ip:
|
|
205
|
+
banner.append(f" http://{local_ip}:{port}")
|
|
206
|
+
banner.append(f" Session: {session}")
|
|
207
|
+
banner.append(f" Logs: {log_out}")
|
|
208
|
+
for line in banner:
|
|
209
|
+
click.echo(line)
|
|
210
|
+
|
|
211
|
+
# Wait for server to be ready
|
|
212
|
+
import urllib.request
|
|
213
|
+
for _ in range(20):
|
|
214
|
+
try:
|
|
215
|
+
urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1)
|
|
216
|
+
break
|
|
217
|
+
except Exception:
|
|
218
|
+
import time
|
|
219
|
+
time.sleep(0.25)
|
|
220
|
+
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _start_background(host, port, working_dir):
|
|
226
|
+
"""Install and start Onako as a background service."""
|
|
227
|
+
system = platform.system()
|
|
228
|
+
onako_bin = shutil.which("onako")
|
|
229
|
+
if not onako_bin:
|
|
230
|
+
click.echo("Error: 'onako' command not found on PATH.", err=True)
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
|
|
233
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
|
|
235
|
+
# Build PATH that includes dirs for tmux and claude
|
|
236
|
+
path_dirs = set()
|
|
237
|
+
for cmd in ["tmux", "claude"]:
|
|
238
|
+
p = shutil.which(cmd)
|
|
239
|
+
if p:
|
|
240
|
+
path_dirs.add(str(Path(p).parent))
|
|
241
|
+
path_dirs.update(["/usr/local/bin", "/usr/bin", "/bin"])
|
|
242
|
+
path_value = ":".join(sorted(path_dirs))
|
|
243
|
+
|
|
244
|
+
if system == "Darwin":
|
|
245
|
+
_install_launchd(onako_bin, host, port, working_dir, path_value)
|
|
246
|
+
elif system == "Linux":
|
|
247
|
+
_install_systemd(onako_bin, host, port, working_dir, path_value)
|
|
248
|
+
else:
|
|
249
|
+
click.echo(f"Background mode is not supported on {system}. Run 'onako serve' manually.", err=True)
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _install_launchd(onako_bin, host, port, working_dir, path_value):
|
|
254
|
+
from importlib.resources import files
|
|
255
|
+
tpl = files("onako").joinpath("templates", "com.onako.server.plist.tpl").read_text()
|
|
256
|
+
plist = tpl.format(
|
|
257
|
+
onako_bin=onako_bin,
|
|
258
|
+
host=host,
|
|
259
|
+
port=port,
|
|
260
|
+
working_dir=working_dir,
|
|
261
|
+
log_dir=LOG_DIR,
|
|
262
|
+
path_value=path_value,
|
|
263
|
+
)
|
|
264
|
+
plist_path = Path.home() / "Library" / "LaunchAgents" / "com.onako.server.plist"
|
|
265
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
|
|
267
|
+
# Unload existing service if present
|
|
268
|
+
uid = os.getuid()
|
|
269
|
+
subprocess.run(
|
|
270
|
+
["launchctl", "bootout", f"gui/{uid}", str(plist_path)],
|
|
271
|
+
capture_output=True,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
plist_path.write_text(plist)
|
|
275
|
+
|
|
276
|
+
# Register and start the service
|
|
277
|
+
result = subprocess.run(
|
|
278
|
+
["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
279
|
+
capture_output=True, text=True,
|
|
280
|
+
)
|
|
281
|
+
if result.returncode != 0:
|
|
282
|
+
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
|
283
|
+
|
|
284
|
+
# kickstart forces the service to run now (bootstrap alone may not start it)
|
|
285
|
+
subprocess.run(
|
|
286
|
+
["launchctl", "kickstart", f"gui/{uid}/com.onako.server"],
|
|
287
|
+
capture_output=True,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
click.echo(f"Onako running in background at http://{host}:{port}")
|
|
291
|
+
click.echo(f" Working directory: {working_dir}")
|
|
292
|
+
click.echo(f" Logs: {LOG_DIR}")
|
|
293
|
+
click.echo()
|
|
294
|
+
click.echo("If macOS blocks this service, allow it in:")
|
|
295
|
+
click.echo(" System Settings > General > Login Items & Extensions")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _install_systemd(onako_bin, host, port, working_dir, path_value):
|
|
299
|
+
from importlib.resources import files
|
|
300
|
+
tpl = files("onako").joinpath("templates", "onako.service.tpl").read_text()
|
|
301
|
+
unit = tpl.format(
|
|
302
|
+
onako_bin=onako_bin,
|
|
303
|
+
host=host,
|
|
304
|
+
port=port,
|
|
305
|
+
working_dir=working_dir,
|
|
306
|
+
path_value=path_value,
|
|
307
|
+
)
|
|
308
|
+
unit_path = Path.home() / ".config" / "systemd" / "user" / "onako.service"
|
|
309
|
+
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
unit_path.write_text(unit)
|
|
311
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
|
312
|
+
subprocess.run(["systemctl", "--user", "enable", "--now", "onako"], check=True)
|
|
313
|
+
click.echo(f"Onako running in background at http://{host}:{port}")
|
|
314
|
+
click.echo(f" Working directory: {working_dir}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _get_local_ip():
|
|
318
|
+
"""Get the machine's local network IP address."""
|
|
319
|
+
try:
|
|
320
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
321
|
+
s.connect(("8.8.8.8", 80))
|
|
322
|
+
ip = s.getsockname()[0]
|
|
323
|
+
s.close()
|
|
324
|
+
return ip
|
|
325
|
+
except Exception:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _check_prerequisites():
|
|
330
|
+
"""Check that tmux and claude are installed."""
|
|
331
|
+
if not shutil.which("tmux"):
|
|
332
|
+
click.echo("Error: tmux is not installed.", err=True)
|
|
333
|
+
click.echo("Install it with: brew install tmux (macOS) or apt install tmux (Linux)", err=True)
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
|
|
336
|
+
if not shutil.which("claude"):
|
|
337
|
+
click.echo("Warning: claude CLI not found on PATH.", err=True)
|
|
338
|
+
click.echo("Install Claude Code from: https://docs.anthropic.com/en/docs/claude-code", err=True)
|
|
@@ -8,7 +8,8 @@ from pydantic import BaseModel
|
|
|
8
8
|
from onako.tmux_orchestrator import TmuxOrchestrator
|
|
9
9
|
|
|
10
10
|
app = FastAPI()
|
|
11
|
-
|
|
11
|
+
session_name = os.environ.get("ONAKO_SESSION", "onako")
|
|
12
|
+
orch = TmuxOrchestrator(session_name=session_name)
|
|
12
13
|
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
|
13
14
|
|
|
14
15
|
|
|
@@ -73,6 +74,8 @@ def send_message(task_id: str, req: SendMessageRequest):
|
|
|
73
74
|
def delete_task(task_id: str):
|
|
74
75
|
if task_id not in orch.tasks:
|
|
75
76
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
77
|
+
if task_id == "onako-main":
|
|
78
|
+
raise HTTPException(status_code=400, detail="Cannot kill the main window")
|
|
76
79
|
orch.kill_task(task_id)
|
|
77
80
|
return {"status": "deleted"}
|
|
78
81
|
|
|
@@ -55,6 +55,16 @@
|
|
|
55
55
|
}
|
|
56
56
|
.status-running { color: #22c55e; }
|
|
57
57
|
.status-done { color: #888; }
|
|
58
|
+
.origin-badge {
|
|
59
|
+
display: inline-block;
|
|
60
|
+
background: #444;
|
|
61
|
+
color: #aaa;
|
|
62
|
+
font-size: 10px;
|
|
63
|
+
padding: 1px 6px;
|
|
64
|
+
border-radius: 8px;
|
|
65
|
+
margin-left: 6px;
|
|
66
|
+
vertical-align: middle;
|
|
67
|
+
}
|
|
58
68
|
.empty-state {
|
|
59
69
|
text-align: center;
|
|
60
70
|
color: #666;
|
|
@@ -260,7 +270,7 @@
|
|
|
260
270
|
} else {
|
|
261
271
|
list.innerHTML = tasks.map(t => `
|
|
262
272
|
<div class="task-item" onclick="showTask('${t.id}')">
|
|
263
|
-
<div class="task-id">${t.id}</div>
|
|
273
|
+
<div class="task-id">${t.id}${t.origin === 'external' ? '<span class="origin-badge">external</span>' : ''}</div>
|
|
264
274
|
<div class="task-prompt">${escapeHtml(t.prompt)}</div>
|
|
265
275
|
<div class="task-meta">
|
|
266
276
|
<span class="status-${t.status}">${t.status}</span>
|
|
@@ -281,7 +291,11 @@
|
|
|
281
291
|
document.getElementById('list-view').classList.add('hidden');
|
|
282
292
|
document.getElementById('detail-view').classList.add('active');
|
|
283
293
|
document.getElementById('detail-task-id').textContent = id;
|
|
284
|
-
|
|
294
|
+
if (id === 'onako-main') {
|
|
295
|
+
document.getElementById('kill-btn').classList.add('hidden');
|
|
296
|
+
} else {
|
|
297
|
+
document.getElementById('kill-btn').classList.remove('hidden');
|
|
298
|
+
}
|
|
285
299
|
await refreshOutput();
|
|
286
300
|
pollInterval = setInterval(refreshOutput, 3000);
|
|
287
301
|
}
|
|
@@ -15,6 +15,7 @@ class TmuxOrchestrator:
|
|
|
15
15
|
self.session_name = session_name
|
|
16
16
|
self.db_path = db_path or DB_PATH
|
|
17
17
|
self.tasks: dict[str, dict] = {}
|
|
18
|
+
self._window_ids: dict[str, str] = {} # window_name -> @id
|
|
18
19
|
self._init_db()
|
|
19
20
|
self._load_tasks()
|
|
20
21
|
self._ensure_session()
|
|
@@ -27,7 +28,7 @@ class TmuxOrchestrator:
|
|
|
27
28
|
)
|
|
28
29
|
if result.returncode != 0:
|
|
29
30
|
subprocess.run(
|
|
30
|
-
["tmux", "new-session", "-d", "-s", self.session_name],
|
|
31
|
+
["tmux", "new-session", "-d", "-s", self.session_name, "-n", "onako-main"],
|
|
31
32
|
check=True,
|
|
32
33
|
)
|
|
33
34
|
|
|
@@ -39,15 +40,21 @@ class TmuxOrchestrator:
|
|
|
39
40
|
id TEXT PRIMARY KEY,
|
|
40
41
|
prompt TEXT,
|
|
41
42
|
status TEXT,
|
|
42
|
-
started_at TEXT
|
|
43
|
+
started_at TEXT,
|
|
44
|
+
origin TEXT DEFAULT 'managed'
|
|
43
45
|
)
|
|
44
46
|
""")
|
|
47
|
+
# Migrate existing DBs that lack the origin column
|
|
48
|
+
cursor = conn.execute("PRAGMA table_info(tasks)")
|
|
49
|
+
columns = [row[1] for row in cursor.fetchall()]
|
|
50
|
+
if "origin" not in columns:
|
|
51
|
+
conn.execute("ALTER TABLE tasks ADD COLUMN origin TEXT DEFAULT 'managed'")
|
|
45
52
|
conn.commit()
|
|
46
53
|
conn.close()
|
|
47
54
|
|
|
48
55
|
def _load_tasks(self):
|
|
49
56
|
conn = sqlite3.connect(self.db_path)
|
|
50
|
-
rows = conn.execute("SELECT id, prompt, status, started_at FROM tasks").fetchall()
|
|
57
|
+
rows = conn.execute("SELECT id, prompt, status, started_at, origin FROM tasks").fetchall()
|
|
51
58
|
conn.close()
|
|
52
59
|
for row in rows:
|
|
53
60
|
self.tasks[row[0]] = {
|
|
@@ -55,13 +62,14 @@ class TmuxOrchestrator:
|
|
|
55
62
|
"prompt": row[1],
|
|
56
63
|
"status": row[2],
|
|
57
64
|
"started_at": row[3],
|
|
65
|
+
"origin": row[4] or "managed",
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
def _save_task(self, task: dict):
|
|
61
69
|
conn = sqlite3.connect(self.db_path)
|
|
62
70
|
conn.execute(
|
|
63
|
-
"INSERT OR REPLACE INTO tasks (id, prompt, status, started_at) VALUES (?, ?, ?, ?)",
|
|
64
|
-
(task["id"], task["prompt"], task["status"], task["started_at"]),
|
|
71
|
+
"INSERT OR REPLACE INTO tasks (id, prompt, status, started_at, origin) VALUES (?, ?, ?, ?, ?)",
|
|
72
|
+
(task["id"], task["prompt"], task["status"], task["started_at"], task.get("origin", "managed")),
|
|
65
73
|
)
|
|
66
74
|
conn.commit()
|
|
67
75
|
conn.close()
|
|
@@ -69,18 +77,36 @@ class TmuxOrchestrator:
|
|
|
69
77
|
def _run_tmux(self, *args) -> subprocess.CompletedProcess:
|
|
70
78
|
return subprocess.run(["tmux", *args], capture_output=True, text=True)
|
|
71
79
|
|
|
80
|
+
def _task_target(self, task_id: str) -> str:
|
|
81
|
+
"""Return a safe tmux target for a task, using window ID when available."""
|
|
82
|
+
window_id = self._window_ids.get(task_id)
|
|
83
|
+
if window_id:
|
|
84
|
+
return window_id
|
|
85
|
+
return f"{self.session_name}:{task_id}"
|
|
86
|
+
|
|
72
87
|
def create_task(self, command: str, working_dir: str | None = None, prompt: str | None = None) -> dict:
|
|
88
|
+
self._ensure_session()
|
|
73
89
|
task_id = f"task-{secrets.token_hex(4)}"
|
|
74
90
|
self._run_tmux(
|
|
75
91
|
"new-window", "-t", self.session_name, "-n", task_id,
|
|
76
92
|
)
|
|
93
|
+
# Look up the window ID for safe targeting
|
|
94
|
+
result = self._run_tmux(
|
|
95
|
+
"list-windows", "-t", self.session_name, "-F", "#{window_name}|#{window_id}",
|
|
96
|
+
)
|
|
97
|
+
if result.stdout.strip():
|
|
98
|
+
for line in result.stdout.strip().split("\n"):
|
|
99
|
+
name, _, wid = line.partition("|")
|
|
100
|
+
if name == task_id and wid:
|
|
101
|
+
self._window_ids[task_id] = wid
|
|
102
|
+
break
|
|
77
103
|
if working_dir:
|
|
78
104
|
self._run_tmux(
|
|
79
|
-
"send-keys", "-t",
|
|
105
|
+
"send-keys", "-t", self._task_target(task_id),
|
|
80
106
|
f"cd {shlex.quote(working_dir)}", "Enter",
|
|
81
107
|
)
|
|
82
108
|
self._run_tmux(
|
|
83
|
-
"send-keys", "-t",
|
|
109
|
+
"send-keys", "-t", self._task_target(task_id),
|
|
84
110
|
command, "Enter",
|
|
85
111
|
)
|
|
86
112
|
task = {
|
|
@@ -88,12 +114,14 @@ class TmuxOrchestrator:
|
|
|
88
114
|
"prompt": prompt or command,
|
|
89
115
|
"status": "running",
|
|
90
116
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
117
|
+
"origin": "managed",
|
|
91
118
|
}
|
|
92
119
|
self.tasks[task_id] = task
|
|
93
120
|
self._save_task(task)
|
|
94
121
|
return task
|
|
95
122
|
|
|
96
123
|
def list_tasks(self) -> list[dict]:
|
|
124
|
+
self.rediscover_tasks()
|
|
97
125
|
self._sync_task_status()
|
|
98
126
|
return list(self.tasks.values())
|
|
99
127
|
|
|
@@ -125,32 +153,38 @@ class TmuxOrchestrator:
|
|
|
125
153
|
|
|
126
154
|
def get_raw_output(self, task_id: str) -> str:
|
|
127
155
|
result = self._run_tmux(
|
|
128
|
-
"capture-pane", "-t",
|
|
156
|
+
"capture-pane", "-t", self._task_target(task_id),
|
|
129
157
|
"-p", "-S", "-",
|
|
130
158
|
)
|
|
131
159
|
return result.stdout
|
|
132
160
|
|
|
133
161
|
def send_message(self, task_id: str, message: str):
|
|
134
162
|
self._run_tmux(
|
|
135
|
-
"send-keys", "-t",
|
|
163
|
+
"send-keys", "-t", self._task_target(task_id),
|
|
136
164
|
"-l", message,
|
|
137
165
|
)
|
|
138
166
|
self._run_tmux(
|
|
139
|
-
"send-keys", "-t",
|
|
167
|
+
"send-keys", "-t", self._task_target(task_id),
|
|
140
168
|
"Enter",
|
|
141
169
|
)
|
|
142
170
|
|
|
143
171
|
def kill_task(self, task_id: str):
|
|
144
|
-
self._run_tmux("kill-window", "-t",
|
|
172
|
+
self._run_tmux("kill-window", "-t", self._task_target(task_id))
|
|
145
173
|
if task_id in self.tasks:
|
|
146
174
|
self.tasks[task_id]["status"] = "done"
|
|
147
175
|
self._save_task(self.tasks[task_id])
|
|
148
176
|
|
|
149
177
|
def _sync_task_status(self):
|
|
150
178
|
result = self._run_tmux(
|
|
151
|
-
"list-windows", "-t", self.session_name, "-F", "#{window_name}",
|
|
179
|
+
"list-windows", "-t", self.session_name, "-F", "#{window_name}|#{window_id}",
|
|
152
180
|
)
|
|
153
|
-
active_windows = set(
|
|
181
|
+
active_windows = set()
|
|
182
|
+
if result.stdout.strip():
|
|
183
|
+
for line in result.stdout.strip().split("\n"):
|
|
184
|
+
parts = line.split("|", 1)
|
|
185
|
+
active_windows.add(parts[0])
|
|
186
|
+
if len(parts) > 1:
|
|
187
|
+
self._window_ids[parts[0]] = parts[1]
|
|
154
188
|
for task_id, task in self.tasks.items():
|
|
155
189
|
if task["status"] == "running" and task_id not in active_windows:
|
|
156
190
|
task["status"] = "done"
|
|
@@ -159,17 +193,27 @@ class TmuxOrchestrator:
|
|
|
159
193
|
def rediscover_tasks(self):
|
|
160
194
|
"""Rediscover tasks from existing tmux windows on server restart."""
|
|
161
195
|
result = self._run_tmux(
|
|
162
|
-
"list-windows", "-t", self.session_name, "-F", "#{window_name}",
|
|
196
|
+
"list-windows", "-t", self.session_name, "-F", "#{window_name}|#{window_id}",
|
|
163
197
|
)
|
|
164
198
|
if not result.stdout.strip():
|
|
165
199
|
return
|
|
166
|
-
for
|
|
167
|
-
|
|
200
|
+
for line in result.stdout.strip().split("\n"):
|
|
201
|
+
parts = line.split("|", 1)
|
|
202
|
+
window_name = parts[0]
|
|
203
|
+
window_id = parts[1] if len(parts) > 1 else None
|
|
204
|
+
if window_id:
|
|
205
|
+
self._window_ids[window_name] = window_id
|
|
206
|
+
if window_name not in self.tasks:
|
|
207
|
+
is_managed = window_name.startswith("task-")
|
|
168
208
|
task = {
|
|
169
209
|
"id": window_name,
|
|
170
|
-
"prompt": "(rediscovered)",
|
|
210
|
+
"prompt": "(rediscovered)" if is_managed else window_name,
|
|
171
211
|
"status": "running",
|
|
172
212
|
"started_at": None,
|
|
213
|
+
"origin": "managed" if is_managed else "external",
|
|
173
214
|
}
|
|
174
215
|
self.tasks[window_name] = task
|
|
175
216
|
self._save_task(task)
|
|
217
|
+
elif self.tasks[window_name]["status"] == "done":
|
|
218
|
+
self.tasks[window_name]["status"] = "running"
|
|
219
|
+
self._save_task(self.tasks[window_name])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: onako
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Dispatch and monitor Claude Code tasks from your phone
|
|
5
5
|
Author: Amir
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,27 +32,21 @@ Requires [tmux](https://github.com/tmux/tmux) and [Claude Code](https://docs.ant
|
|
|
32
32
|
## Usage
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
|
|
36
|
-
onako
|
|
35
|
+
onako # starts server, drops you into tmux
|
|
36
|
+
onako --session my-project # custom session name
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Open http://localhost:8787 on your phone (same network) or set up [Tailscale](https://tailscale.com) for access from anywhere.
|
|
40
|
-
|
|
41
|
-
### Run in the background
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
onako serve --background
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
This installs a system service (launchd on macOS, systemd on Linux) that starts on boot. Tasks run in the directory where you ran the command.
|
|
39
|
+
If you're already inside tmux, onako auto-detects your session and skips the attach. Open http://localhost:8787 on your phone (same network) or set up [Tailscale](https://tailscale.com) for access from anywhere.
|
|
48
40
|
|
|
49
41
|
```bash
|
|
50
|
-
onako
|
|
51
|
-
onako status
|
|
52
|
-
onako
|
|
53
|
-
onako version
|
|
42
|
+
onako stop # stop the background server
|
|
43
|
+
onako status # check if running
|
|
44
|
+
onako serve # foreground server (for development)
|
|
45
|
+
onako version # print version
|
|
54
46
|
```
|
|
55
47
|
|
|
56
48
|
## How it works
|
|
57
49
|
|
|
58
|
-
|
|
50
|
+
Onako monitors all tmux windows in the configured session. Windows it creates (via the dashboard) are "managed" tasks. Windows created by you or other tools are discovered automatically as "external" — both get full dashboard support: view output, send messages, kill.
|
|
51
|
+
|
|
52
|
+
Task state is persisted in SQLite so it survives server restarts.
|
|
@@ -1,17 +1,34 @@
|
|
|
1
|
+
import subprocess
|
|
1
2
|
import pytest
|
|
2
3
|
from fastapi.testclient import TestClient
|
|
3
4
|
|
|
5
|
+
API_SESSION = "onako-api-test"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(autouse=True)
|
|
9
|
+
def cleanup_session():
|
|
10
|
+
subprocess.run(["tmux", "kill-session", "-t", API_SESSION], capture_output=True)
|
|
11
|
+
yield
|
|
12
|
+
subprocess.run(["tmux", "kill-session", "-t", API_SESSION], capture_output=True)
|
|
13
|
+
|
|
4
14
|
|
|
5
15
|
@pytest.fixture
|
|
6
|
-
def client():
|
|
16
|
+
def client(tmp_path):
|
|
17
|
+
import os
|
|
7
18
|
import importlib
|
|
19
|
+
os.environ["ONAKO_SESSION"] = API_SESSION
|
|
20
|
+
from onako import tmux_orchestrator
|
|
21
|
+
original_db = tmux_orchestrator.DB_PATH
|
|
22
|
+
tmux_orchestrator.DB_PATH = tmp_path / "test.db"
|
|
8
23
|
from onako import server
|
|
9
24
|
importlib.reload(server)
|
|
10
25
|
client = TestClient(server.app)
|
|
11
26
|
yield client
|
|
12
27
|
response = client.get("/tasks")
|
|
13
28
|
for task in response.json():
|
|
14
|
-
|
|
29
|
+
if task["id"] != "onako-main":
|
|
30
|
+
client.delete(f"/tasks/{task['id']}")
|
|
31
|
+
tmux_orchestrator.DB_PATH = original_db
|
|
15
32
|
|
|
16
33
|
|
|
17
34
|
def test_health(client):
|
|
@@ -6,7 +6,16 @@ def test_version():
|
|
|
6
6
|
runner = CliRunner()
|
|
7
7
|
result = runner.invoke(main, ["version"])
|
|
8
8
|
assert result.exit_code == 0
|
|
9
|
-
assert "0.
|
|
9
|
+
assert "0.3.0" in result.output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_default_help():
|
|
13
|
+
runner = CliRunner()
|
|
14
|
+
result = runner.invoke(main, ["--help"])
|
|
15
|
+
assert result.exit_code == 0
|
|
16
|
+
assert "--session" in result.output
|
|
17
|
+
assert "--host" in result.output
|
|
18
|
+
assert "--port" in result.output
|
|
10
19
|
|
|
11
20
|
|
|
12
21
|
def test_serve_help():
|
|
@@ -15,5 +24,6 @@ def test_serve_help():
|
|
|
15
24
|
assert result.exit_code == 0
|
|
16
25
|
assert "--host" in result.output
|
|
17
26
|
assert "--port" in result.output
|
|
27
|
+
assert "--session" in result.output
|
|
18
28
|
assert "--background" in result.output
|
|
19
29
|
assert "--dir" in result.output
|
|
@@ -14,3 +14,10 @@ def test_stop_completes():
|
|
|
14
14
|
result = runner.invoke(main, ["stop"])
|
|
15
15
|
assert result.exit_code == 0
|
|
16
16
|
assert "not running" in result.output or "stopped" in result.output
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_stop_handles_no_service():
|
|
20
|
+
"""Stop should handle the case where neither pid file nor service exists."""
|
|
21
|
+
runner = CliRunner()
|
|
22
|
+
result = runner.invoke(main, ["stop"])
|
|
23
|
+
assert result.exit_code == 0
|
|
@@ -71,3 +71,65 @@ def test_create_multiple_tasks(orch):
|
|
|
71
71
|
assert t1["id"] in ids
|
|
72
72
|
assert t2["id"] in ids
|
|
73
73
|
assert t1["id"] != t2["id"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_created_task_has_managed_origin(orch):
|
|
77
|
+
task = orch.create_task("echo hello")
|
|
78
|
+
assert task["origin"] == "managed"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_rediscover_external_window(orch):
|
|
82
|
+
"""Windows not created by onako are discovered with origin=external."""
|
|
83
|
+
subprocess.run([
|
|
84
|
+
"tmux", "new-window", "-t", SESSION_NAME, "-n", "my-feature",
|
|
85
|
+
], check=True)
|
|
86
|
+
time.sleep(0.5)
|
|
87
|
+
orch.rediscover_tasks()
|
|
88
|
+
tasks = orch.list_tasks()
|
|
89
|
+
external = [t for t in tasks if t["id"] == "my-feature"]
|
|
90
|
+
assert len(external) == 1
|
|
91
|
+
assert external[0]["origin"] == "external"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_external_window_output(orch):
|
|
95
|
+
"""Can read output from externally created windows."""
|
|
96
|
+
subprocess.run([
|
|
97
|
+
"tmux", "new-window", "-t", SESSION_NAME, "-n", "ext-test",
|
|
98
|
+
], check=True)
|
|
99
|
+
subprocess.run([
|
|
100
|
+
"tmux", "send-keys", "-t", f"{SESSION_NAME}:ext-test",
|
|
101
|
+
"echo external-output", "Enter",
|
|
102
|
+
], check=True)
|
|
103
|
+
time.sleep(1)
|
|
104
|
+
orch.rediscover_tasks()
|
|
105
|
+
output = orch.get_output("ext-test")
|
|
106
|
+
assert "external-output" in output
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_external_window_send_message(orch):
|
|
110
|
+
"""Can send messages to externally created windows."""
|
|
111
|
+
subprocess.run([
|
|
112
|
+
"tmux", "new-window", "-t", SESSION_NAME, "-n", "ext-msg",
|
|
113
|
+
], check=True)
|
|
114
|
+
subprocess.run([
|
|
115
|
+
"tmux", "send-keys", "-t", f"{SESSION_NAME}:ext-msg",
|
|
116
|
+
"cat", "Enter",
|
|
117
|
+
], check=True)
|
|
118
|
+
time.sleep(0.5)
|
|
119
|
+
orch.rediscover_tasks()
|
|
120
|
+
orch.send_message("ext-msg", "hello-external")
|
|
121
|
+
time.sleep(1)
|
|
122
|
+
output = orch.get_output("ext-msg")
|
|
123
|
+
assert "hello-external" in output
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_external_window_kill(orch):
|
|
127
|
+
"""Can kill externally created windows."""
|
|
128
|
+
subprocess.run([
|
|
129
|
+
"tmux", "new-window", "-t", SESSION_NAME, "-n", "ext-kill",
|
|
130
|
+
], check=True)
|
|
131
|
+
time.sleep(0.5)
|
|
132
|
+
orch.rediscover_tasks()
|
|
133
|
+
orch.kill_task("ext-kill")
|
|
134
|
+
tasks = orch.list_tasks()
|
|
135
|
+
assert not any(t["id"] == "ext-kill" and t["status"] == "running" for t in tasks)
|
onako-0.2.0/README.md
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
cd ~/projects
|
|
19
|
-
onako serve
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Open http://localhost:8787 on your phone (same network) or set up [Tailscale](https://tailscale.com) for access from anywhere.
|
|
23
|
-
|
|
24
|
-
### Run in the background
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
onako serve --background
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
This installs a system service (launchd on macOS, systemd on Linux) that starts on boot. Tasks run in the directory where you ran the command.
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
onako serve --background --dir ~/projects # override working directory
|
|
34
|
-
onako status # check if running
|
|
35
|
-
onako stop # stop the background service
|
|
36
|
-
onako version # print version
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
## How it works
|
|
40
|
-
|
|
41
|
-
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.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
onako-0.2.0/src/onako/cli.py
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
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="0.0.0.0", help="Host to bind to.")
|
|
29
|
-
@click.option("--port", default=8787, type=int, help="Port to bind to.")
|
|
30
|
-
@click.option("--dir", "working_dir", default=None, type=click.Path(exists=True), help="Working directory for tasks (default: current directory).")
|
|
31
|
-
@click.option("--background", is_flag=True, help="Run as a background service (launchd/systemd).")
|
|
32
|
-
def serve(host, port, working_dir, background):
|
|
33
|
-
"""Start the Onako server."""
|
|
34
|
-
working_dir = str(Path(working_dir).resolve()) if working_dir else os.getcwd()
|
|
35
|
-
|
|
36
|
-
if background:
|
|
37
|
-
_start_background(host, port, working_dir)
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
_check_prerequisites()
|
|
41
|
-
|
|
42
|
-
os.environ["ONAKO_WORKING_DIR"] = working_dir
|
|
43
|
-
|
|
44
|
-
from onako import __version__
|
|
45
|
-
click.echo(f"Onako v{__version__}")
|
|
46
|
-
click.echo(f"Starting server at http://{host}:{port}")
|
|
47
|
-
click.echo(f"Working directory: {working_dir}")
|
|
48
|
-
click.echo()
|
|
49
|
-
|
|
50
|
-
import uvicorn
|
|
51
|
-
from onako.server import app
|
|
52
|
-
uvicorn.run(app, host=host, port=port)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@main.command()
|
|
56
|
-
def stop():
|
|
57
|
-
"""Stop the background Onako service."""
|
|
58
|
-
system = platform.system()
|
|
59
|
-
if system == "Darwin":
|
|
60
|
-
plist_path = Path.home() / "Library" / "LaunchAgents" / "com.onako.server.plist"
|
|
61
|
-
if plist_path.exists():
|
|
62
|
-
uid = os.getuid()
|
|
63
|
-
result = subprocess.run(
|
|
64
|
-
["launchctl", "bootout", f"gui/{uid}", str(plist_path)],
|
|
65
|
-
capture_output=True,
|
|
66
|
-
)
|
|
67
|
-
if result.returncode != 0:
|
|
68
|
-
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
|
|
69
|
-
plist_path.unlink()
|
|
70
|
-
click.echo("Onako service stopped and removed.")
|
|
71
|
-
else:
|
|
72
|
-
click.echo("Onako service is not running.")
|
|
73
|
-
elif system == "Linux":
|
|
74
|
-
unit_path = Path.home() / ".config" / "systemd" / "user" / "onako.service"
|
|
75
|
-
if unit_path.exists():
|
|
76
|
-
subprocess.run(["systemctl", "--user", "disable", "--now", "onako"])
|
|
77
|
-
unit_path.unlink()
|
|
78
|
-
subprocess.run(["systemctl", "--user", "daemon-reload"])
|
|
79
|
-
click.echo("Onako service stopped and removed.")
|
|
80
|
-
else:
|
|
81
|
-
click.echo("Onako service is not running.")
|
|
82
|
-
else:
|
|
83
|
-
click.echo(f"Not supported on {system}.")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
@main.command()
|
|
87
|
-
def status():
|
|
88
|
-
"""Check if Onako is running."""
|
|
89
|
-
import urllib.request
|
|
90
|
-
try:
|
|
91
|
-
r = urllib.request.urlopen("http://127.0.0.1:8787/health", timeout=2)
|
|
92
|
-
data = r.read().decode()
|
|
93
|
-
if '"ok"' in data:
|
|
94
|
-
click.echo("Onako server: running")
|
|
95
|
-
click.echo(" URL: http://127.0.0.1:8787")
|
|
96
|
-
else:
|
|
97
|
-
click.echo("Onako server: not responding correctly")
|
|
98
|
-
except Exception:
|
|
99
|
-
click.echo("Onako server: not running")
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _start_background(host, port, working_dir):
|
|
103
|
-
"""Install and start Onako as a background service."""
|
|
104
|
-
system = platform.system()
|
|
105
|
-
onako_bin = shutil.which("onako")
|
|
106
|
-
if not onako_bin:
|
|
107
|
-
click.echo("Error: 'onako' command not found on PATH.", err=True)
|
|
108
|
-
sys.exit(1)
|
|
109
|
-
|
|
110
|
-
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
111
|
-
|
|
112
|
-
# Build PATH that includes dirs for tmux and claude
|
|
113
|
-
path_dirs = set()
|
|
114
|
-
for cmd in ["tmux", "claude"]:
|
|
115
|
-
p = shutil.which(cmd)
|
|
116
|
-
if p:
|
|
117
|
-
path_dirs.add(str(Path(p).parent))
|
|
118
|
-
path_dirs.update(["/usr/local/bin", "/usr/bin", "/bin"])
|
|
119
|
-
path_value = ":".join(sorted(path_dirs))
|
|
120
|
-
|
|
121
|
-
if system == "Darwin":
|
|
122
|
-
_install_launchd(onako_bin, host, port, working_dir, path_value)
|
|
123
|
-
elif system == "Linux":
|
|
124
|
-
_install_systemd(onako_bin, host, port, working_dir, path_value)
|
|
125
|
-
else:
|
|
126
|
-
click.echo(f"Background mode is not supported on {system}. Run 'onako serve' manually.", err=True)
|
|
127
|
-
sys.exit(1)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _install_launchd(onako_bin, host, port, working_dir, path_value):
|
|
131
|
-
from importlib.resources import files
|
|
132
|
-
tpl = files("onako").joinpath("templates", "com.onako.server.plist.tpl").read_text()
|
|
133
|
-
plist = tpl.format(
|
|
134
|
-
onako_bin=onako_bin,
|
|
135
|
-
host=host,
|
|
136
|
-
port=port,
|
|
137
|
-
working_dir=working_dir,
|
|
138
|
-
log_dir=LOG_DIR,
|
|
139
|
-
path_value=path_value,
|
|
140
|
-
)
|
|
141
|
-
plist_path = Path.home() / "Library" / "LaunchAgents" / "com.onako.server.plist"
|
|
142
|
-
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
-
|
|
144
|
-
# Unload existing service if present
|
|
145
|
-
uid = os.getuid()
|
|
146
|
-
subprocess.run(
|
|
147
|
-
["launchctl", "bootout", f"gui/{uid}", str(plist_path)],
|
|
148
|
-
capture_output=True,
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
plist_path.write_text(plist)
|
|
152
|
-
|
|
153
|
-
# Register and start the service
|
|
154
|
-
result = subprocess.run(
|
|
155
|
-
["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
156
|
-
capture_output=True, text=True,
|
|
157
|
-
)
|
|
158
|
-
if result.returncode != 0:
|
|
159
|
-
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
|
160
|
-
|
|
161
|
-
# kickstart forces the service to run now (bootstrap alone may not start it)
|
|
162
|
-
subprocess.run(
|
|
163
|
-
["launchctl", "kickstart", f"gui/{uid}/com.onako.server"],
|
|
164
|
-
capture_output=True,
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
click.echo(f"Onako running in background at http://{host}:{port}")
|
|
168
|
-
click.echo(f" Working directory: {working_dir}")
|
|
169
|
-
click.echo(f" Logs: {LOG_DIR}")
|
|
170
|
-
click.echo()
|
|
171
|
-
click.echo("If macOS blocks this service, allow it in:")
|
|
172
|
-
click.echo(" System Settings > General > Login Items & Extensions")
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def _install_systemd(onako_bin, host, port, working_dir, path_value):
|
|
176
|
-
from importlib.resources import files
|
|
177
|
-
tpl = files("onako").joinpath("templates", "onako.service.tpl").read_text()
|
|
178
|
-
unit = tpl.format(
|
|
179
|
-
onako_bin=onako_bin,
|
|
180
|
-
host=host,
|
|
181
|
-
port=port,
|
|
182
|
-
working_dir=working_dir,
|
|
183
|
-
path_value=path_value,
|
|
184
|
-
)
|
|
185
|
-
unit_path = Path.home() / ".config" / "systemd" / "user" / "onako.service"
|
|
186
|
-
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
-
unit_path.write_text(unit)
|
|
188
|
-
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
|
189
|
-
subprocess.run(["systemctl", "--user", "enable", "--now", "onako"], check=True)
|
|
190
|
-
click.echo(f"Onako running in background at http://{host}:{port}")
|
|
191
|
-
click.echo(f" Working directory: {working_dir}")
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _check_prerequisites():
|
|
195
|
-
"""Check that tmux and claude are installed."""
|
|
196
|
-
tmux_path = shutil.which("tmux")
|
|
197
|
-
if not tmux_path:
|
|
198
|
-
click.echo("Error: tmux is not installed.", err=True)
|
|
199
|
-
click.echo("Install it with: brew install tmux (macOS) or apt install tmux (Linux)", err=True)
|
|
200
|
-
sys.exit(1)
|
|
201
|
-
click.echo(f" tmux: {tmux_path}")
|
|
202
|
-
|
|
203
|
-
claude_path = shutil.which("claude")
|
|
204
|
-
if not claude_path:
|
|
205
|
-
click.echo("Warning: claude CLI not found on PATH.", err=True)
|
|
206
|
-
click.echo("Install Claude Code from: https://docs.anthropic.com/en/docs/claude-code", err=True)
|
|
207
|
-
else:
|
|
208
|
-
click.echo(f" claude: {claude_path}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|