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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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">&larr; 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 ? ' &middot; ' + 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,11 @@
1
+ [Unit]
2
+ Description=Onako - Claude Code Task Orchestrator
3
+ After=network.target
4
+
5
+ [Service]
6
+ ExecStart={onako_bin} serve --host {host} --port {port}
7
+ Restart=on-failure
8
+ Environment=PATH={path_value}
9
+
10
+ [Install]
11
+ WantedBy=default.target
@@ -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,2 @@
1
+ [console_scripts]
2
+ onako = onako.cli:main
@@ -0,0 +1,7 @@
1
+ fastapi>=0.100.0
2
+ uvicorn>=0.20.0
3
+ click>=8.0.0
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+ httpx>=0.24.0
@@ -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"]