onako 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: onako
3
- Version: 0.2.1
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
- cd ~/projects
36
- onako serve
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 serve --background --dir ~/projects # override working directory
51
- onako status # check if running
52
- onako stop # stop the background service
53
- onako version # print 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
- 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.
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "onako"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Dispatch and monitor Claude Code tasks from your phone"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- orch = TmuxOrchestrator()
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
- document.getElementById('kill-btn').classList.remove('hidden');
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,19 +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:
73
88
  self._ensure_session()
74
89
  task_id = f"task-{secrets.token_hex(4)}"
75
90
  self._run_tmux(
76
91
  "new-window", "-t", self.session_name, "-n", task_id,
77
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
78
103
  if working_dir:
79
104
  self._run_tmux(
80
- "send-keys", "-t", f"{self.session_name}:{task_id}",
105
+ "send-keys", "-t", self._task_target(task_id),
81
106
  f"cd {shlex.quote(working_dir)}", "Enter",
82
107
  )
83
108
  self._run_tmux(
84
- "send-keys", "-t", f"{self.session_name}:{task_id}",
109
+ "send-keys", "-t", self._task_target(task_id),
85
110
  command, "Enter",
86
111
  )
87
112
  task = {
@@ -89,12 +114,14 @@ class TmuxOrchestrator:
89
114
  "prompt": prompt or command,
90
115
  "status": "running",
91
116
  "started_at": datetime.now(timezone.utc).isoformat(),
117
+ "origin": "managed",
92
118
  }
93
119
  self.tasks[task_id] = task
94
120
  self._save_task(task)
95
121
  return task
96
122
 
97
123
  def list_tasks(self) -> list[dict]:
124
+ self.rediscover_tasks()
98
125
  self._sync_task_status()
99
126
  return list(self.tasks.values())
100
127
 
@@ -126,32 +153,38 @@ class TmuxOrchestrator:
126
153
 
127
154
  def get_raw_output(self, task_id: str) -> str:
128
155
  result = self._run_tmux(
129
- "capture-pane", "-t", f"{self.session_name}:{task_id}",
156
+ "capture-pane", "-t", self._task_target(task_id),
130
157
  "-p", "-S", "-",
131
158
  )
132
159
  return result.stdout
133
160
 
134
161
  def send_message(self, task_id: str, message: str):
135
162
  self._run_tmux(
136
- "send-keys", "-t", f"{self.session_name}:{task_id}",
163
+ "send-keys", "-t", self._task_target(task_id),
137
164
  "-l", message,
138
165
  )
139
166
  self._run_tmux(
140
- "send-keys", "-t", f"{self.session_name}:{task_id}",
167
+ "send-keys", "-t", self._task_target(task_id),
141
168
  "Enter",
142
169
  )
143
170
 
144
171
  def kill_task(self, task_id: str):
145
- self._run_tmux("kill-window", "-t", f"{self.session_name}:{task_id}")
172
+ self._run_tmux("kill-window", "-t", self._task_target(task_id))
146
173
  if task_id in self.tasks:
147
174
  self.tasks[task_id]["status"] = "done"
148
175
  self._save_task(self.tasks[task_id])
149
176
 
150
177
  def _sync_task_status(self):
151
178
  result = self._run_tmux(
152
- "list-windows", "-t", self.session_name, "-F", "#{window_name}",
179
+ "list-windows", "-t", self.session_name, "-F", "#{window_name}|#{window_id}",
153
180
  )
154
- active_windows = set(result.stdout.strip().split("\n")) if result.stdout.strip() else 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]
155
188
  for task_id, task in self.tasks.items():
156
189
  if task["status"] == "running" and task_id not in active_windows:
157
190
  task["status"] = "done"
@@ -160,17 +193,27 @@ class TmuxOrchestrator:
160
193
  def rediscover_tasks(self):
161
194
  """Rediscover tasks from existing tmux windows on server restart."""
162
195
  result = self._run_tmux(
163
- "list-windows", "-t", self.session_name, "-F", "#{window_name}",
196
+ "list-windows", "-t", self.session_name, "-F", "#{window_name}|#{window_id}",
164
197
  )
165
198
  if not result.stdout.strip():
166
199
  return
167
- for window_name in result.stdout.strip().split("\n"):
168
- if window_name.startswith("task-") and window_name not in self.tasks:
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-")
169
208
  task = {
170
209
  "id": window_name,
171
- "prompt": "(rediscovered)",
210
+ "prompt": "(rediscovered)" if is_managed else window_name,
172
211
  "status": "running",
173
212
  "started_at": None,
213
+ "origin": "managed" if is_managed else "external",
174
214
  }
175
215
  self.tasks[window_name] = task
176
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.2.1
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
- cd ~/projects
36
- onako serve
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 serve --background --dir ~/projects # override working directory
51
- onako status # check if running
52
- onako stop # stop the background service
53
- onako version # print 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
- 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.
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
- client.delete(f"/tasks/{task['id']}")
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.2.0" in result.output
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.1/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.1"
@@ -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