onako 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
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/__init__.py +1 -1
- onako/cli.py +167 -37
- onako/server.py +4 -1
- onako/static/index.html +16 -2
- onako/tmux_orchestrator.py +61 -17
- {onako-0.2.0.dist-info → onako-0.3.0.dist-info}/METADATA +11 -17
- onako-0.3.0.dist-info/RECORD +12 -0
- onako-0.2.0.dist-info/RECORD +0 -12
- {onako-0.2.0.dist-info → onako-0.3.0.dist-info}/WHEEL +0 -0
- {onako-0.2.0.dist-info → onako-0.3.0.dist-info}/entry_points.txt +0 -0
- {onako-0.2.0.dist-info → onako-0.3.0.dist-info}/top_level.txt +0 -0
onako/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.3.0"
|
onako/cli.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import platform
|
|
3
|
+
import socket
|
|
3
4
|
import shutil
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
@@ -9,12 +10,51 @@ import click
|
|
|
9
10
|
|
|
10
11
|
ONAKO_DIR = Path.home() / ".onako"
|
|
11
12
|
LOG_DIR = ONAKO_DIR / "logs"
|
|
13
|
+
PID_FILE = ONAKO_DIR / "onako.pid"
|
|
12
14
|
|
|
13
15
|
|
|
14
|
-
@click.group()
|
|
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):
|
|
16
23
|
"""Onako — Dispatch and monitor Claude Code tasks from your phone."""
|
|
17
|
-
|
|
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])
|
|
18
58
|
|
|
19
59
|
|
|
20
60
|
@main.command()
|
|
@@ -27,9 +67,10 @@ def version():
|
|
|
27
67
|
@main.command()
|
|
28
68
|
@click.option("--host", default="0.0.0.0", help="Host to bind to.")
|
|
29
69
|
@click.option("--port", default=8787, type=int, help="Port to bind to.")
|
|
70
|
+
@click.option("--session", default="onako", help="tmux session name.")
|
|
30
71
|
@click.option("--dir", "working_dir", default=None, type=click.Path(exists=True), help="Working directory for tasks (default: current directory).")
|
|
31
72
|
@click.option("--background", is_flag=True, help="Run as a background service (launchd/systemd).")
|
|
32
|
-
def serve(host, port, working_dir, background):
|
|
73
|
+
def serve(host, port, session, working_dir, background):
|
|
33
74
|
"""Start the Onako server."""
|
|
34
75
|
working_dir = str(Path(working_dir).resolve()) if working_dir else os.getcwd()
|
|
35
76
|
|
|
@@ -40,11 +81,13 @@ def serve(host, port, working_dir, background):
|
|
|
40
81
|
_check_prerequisites()
|
|
41
82
|
|
|
42
83
|
os.environ["ONAKO_WORKING_DIR"] = working_dir
|
|
84
|
+
os.environ["ONAKO_SESSION"] = session
|
|
43
85
|
|
|
44
86
|
from onako import __version__
|
|
45
87
|
click.echo(f"Onako v{__version__}")
|
|
46
88
|
click.echo(f"Starting server at http://{host}:{port}")
|
|
47
89
|
click.echo(f"Working directory: {working_dir}")
|
|
90
|
+
click.echo(f"Session: {session}")
|
|
48
91
|
click.echo()
|
|
49
92
|
|
|
50
93
|
import uvicorn
|
|
@@ -55,32 +98,46 @@ def serve(host, port, working_dir, background):
|
|
|
55
98
|
@main.command()
|
|
56
99
|
def stop():
|
|
57
100
|
"""Stop the background Onako service."""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.")
|
|
84
141
|
|
|
85
142
|
|
|
86
143
|
@main.command()
|
|
@@ -99,6 +156,72 @@ def status():
|
|
|
99
156
|
click.echo("Onako server: not running")
|
|
100
157
|
|
|
101
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
|
+
|
|
102
225
|
def _start_background(host, port, working_dir):
|
|
103
226
|
"""Install and start Onako as a background service."""
|
|
104
227
|
system = platform.system()
|
|
@@ -191,18 +314,25 @@ def _install_systemd(onako_bin, host, port, working_dir, path_value):
|
|
|
191
314
|
click.echo(f" Working directory: {working_dir}")
|
|
192
315
|
|
|
193
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
|
+
|
|
194
329
|
def _check_prerequisites():
|
|
195
330
|
"""Check that tmux and claude are installed."""
|
|
196
|
-
|
|
197
|
-
if not tmux_path:
|
|
331
|
+
if not shutil.which("tmux"):
|
|
198
332
|
click.echo("Error: tmux is not installed.", err=True)
|
|
199
333
|
click.echo("Install it with: brew install tmux (macOS) or apt install tmux (Linux)", err=True)
|
|
200
334
|
sys.exit(1)
|
|
201
|
-
click.echo(f" tmux: {tmux_path}")
|
|
202
335
|
|
|
203
|
-
|
|
204
|
-
if not claude_path:
|
|
336
|
+
if not shutil.which("claude"):
|
|
205
337
|
click.echo("Warning: claude CLI not found on PATH.", err=True)
|
|
206
338
|
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}")
|
onako/server.py
CHANGED
|
@@ -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
|
|
onako/static/index.html
CHANGED
|
@@ -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
|
}
|
onako/tmux_orchestrator.py
CHANGED
|
@@ -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.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
onako/__init__.py,sha256=VrXpHDu3erkzwl_WXrqINBm9xWkcyUy53IQOj042dOs,22
|
|
2
|
+
onako/cli.py,sha256=S6Haa3X16s5EaLzbLw6W2W3bO_26jt3utgMB8sohM-k,11786
|
|
3
|
+
onako/server.py,sha256=cff5cKRgeItRNLfvPHQ5NMX8eBl5Sesn26G3rac7KyY,2261
|
|
4
|
+
onako/tmux_orchestrator.py,sha256=D83eY7iSSgKoZwhd-sjk-JM2bfNE30AyoPAbNwrTZIg,8152
|
|
5
|
+
onako/static/index.html,sha256=_l_ELWjtmLBk-5p3b3tjJ0fj83IT4GzJBrWbcaTFKQo,14917
|
|
6
|
+
onako/templates/com.onako.server.plist.tpl,sha256=XvjvRz_AnjcREVjDPFu2qGMVCxojp6hhTwMfvrFcEbY,994
|
|
7
|
+
onako/templates/onako.service.tpl,sha256=EOVOaLtxC1FrcLdy7DtEbtf9ImgN6PmnMb57ZT81nTM,280
|
|
8
|
+
onako-0.3.0.dist-info/METADATA,sha256=_6meanq6QmLs-0ddqo8lCYR4HNXsw66fQXikk23cBoc,1956
|
|
9
|
+
onako-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
10
|
+
onako-0.3.0.dist-info/entry_points.txt,sha256=51KRJzuVpr69iT_k4JO0Lj3DQv_HbgtGjTBTev13JAQ,41
|
|
11
|
+
onako-0.3.0.dist-info/top_level.txt,sha256=EZsc5qq2paM9GTbaFE9Xar4B5wFdfIqK9l_bDQVcmZ4,6
|
|
12
|
+
onako-0.3.0.dist-info/RECORD,,
|
onako-0.2.0.dist-info/RECORD
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
onako/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
|
|
2
|
-
onako/cli.py,sha256=scmBfnyRAaT_yJziVQyumV6SyureGx6FXumV_r6KGxE,7217
|
|
3
|
-
onako/server.py,sha256=lBNaT8Xq5Jw8EpN1CZNWzKQ2TudAMpSE4L0PY4ucW10,2065
|
|
4
|
-
onako/tmux_orchestrator.py,sha256=rLNVrCcam2iF412WKX_5hAiMCBHmi8sNhDPD4KvEiU8,6080
|
|
5
|
-
onako/static/index.html,sha256=7TjxfF38Spnuerd6u0vbwGpJApstsxzVS3F-YUGCp7I,14403
|
|
6
|
-
onako/templates/com.onako.server.plist.tpl,sha256=XvjvRz_AnjcREVjDPFu2qGMVCxojp6hhTwMfvrFcEbY,994
|
|
7
|
-
onako/templates/onako.service.tpl,sha256=EOVOaLtxC1FrcLdy7DtEbtf9ImgN6PmnMb57ZT81nTM,280
|
|
8
|
-
onako-0.2.0.dist-info/METADATA,sha256=q4U1SxLom_kJjvbZ4SMAqE5zRrVvTBGiZBBa1pI8kf4,1891
|
|
9
|
-
onako-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
10
|
-
onako-0.2.0.dist-info/entry_points.txt,sha256=51KRJzuVpr69iT_k4JO0Lj3DQv_HbgtGjTBTev13JAQ,41
|
|
11
|
-
onako-0.2.0.dist-info/top_level.txt,sha256=EZsc5qq2paM9GTbaFE9Xar4B5wFdfIqK9l_bDQVcmZ4,6
|
|
12
|
-
onako-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|