onako 0.2.1__py3-none-any.whl → 0.4.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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.1"
1
+ __version__ = "0.4.0"
onako/cli.py CHANGED
@@ -1,5 +1,5 @@
1
1
  import os
2
- import platform
2
+ import socket
3
3
  import shutil
4
4
  import subprocess
5
5
  import sys
@@ -9,12 +9,52 @@ import click
9
9
 
10
10
  ONAKO_DIR = Path.home() / ".onako"
11
11
  LOG_DIR = ONAKO_DIR / "logs"
12
+ PID_FILE = ONAKO_DIR / "onako.pid"
12
13
 
13
14
 
14
- @click.group()
15
- def main():
15
+ @click.group(invoke_without_command=True)
16
+ @click.option("--host", default="0.0.0.0", help="Host to bind to.")
17
+ @click.option("--port", default=8787, type=int, help="Port to bind to.")
18
+ @click.option("--session", default="onako", help="tmux session name (auto-detected if inside tmux).")
19
+ @click.option("--dir", "working_dir", default=None, type=click.Path(exists=True), help="Working directory for tasks (default: current directory).")
20
+ @click.option("--dangerously-skip-permissions", "skip_permissions", is_flag=True, default=False, help="Pass --dangerously-skip-permissions to all Claude Code tasks.")
21
+ @click.pass_context
22
+ def main(ctx, host, port, session, working_dir, skip_permissions):
16
23
  """Onako — Dispatch and monitor Claude Code tasks from your phone."""
17
- pass
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, skip_permissions)
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,24 +67,25 @@ 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
- @click.option("--background", is_flag=True, help="Run as a background service (launchd/systemd).")
32
- def serve(host, port, working_dir, background):
72
+ @click.option("--dangerously-skip-permissions", "skip_permissions", is_flag=True, default=False, help="Pass --dangerously-skip-permissions to all Claude Code tasks.")
73
+ def serve(host, port, session, working_dir, skip_permissions):
33
74
  """Start the Onako server."""
34
75
  working_dir = str(Path(working_dir).resolve()) if working_dir else os.getcwd()
35
76
 
36
- if background:
37
- _start_background(host, port, working_dir)
38
- return
39
-
40
77
  _check_prerequisites()
41
78
 
42
79
  os.environ["ONAKO_WORKING_DIR"] = working_dir
80
+ os.environ["ONAKO_SESSION"] = session
81
+ if skip_permissions:
82
+ os.environ["ONAKO_SKIP_PERMISSIONS"] = "1"
43
83
 
44
84
  from onako import __version__
45
85
  click.echo(f"Onako v{__version__}")
46
86
  click.echo(f"Starting server at http://{host}:{port}")
47
87
  click.echo(f"Working directory: {working_dir}")
88
+ click.echo(f"Session: {session}")
48
89
  click.echo()
49
90
 
50
91
  import uvicorn
@@ -55,32 +96,21 @@ def serve(host, port, working_dir, background):
55
96
  @main.command()
56
97
  def stop():
57
98
  """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}.")
99
+ stopped = False
100
+
101
+ # Try pid file first (from `onako start`)
102
+ if PID_FILE.exists():
103
+ try:
104
+ pid = int(PID_FILE.read_text().strip())
105
+ os.kill(pid, 15) # SIGTERM
106
+ click.echo(f"Onako server stopped (pid {pid}).")
107
+ stopped = True
108
+ except (ValueError, ProcessLookupError):
109
+ click.echo("Stale pid file found, cleaning up.")
110
+ PID_FILE.unlink(missing_ok=True)
111
+
112
+ if not stopped:
113
+ click.echo("Onako service is not running.")
84
114
 
85
115
 
86
116
  @main.command()
@@ -99,110 +129,95 @@ def status():
99
129
  click.echo("Onako server: not running")
100
130
 
101
131
 
102
- def _start_background(host, port, working_dir):
103
- """Install and start Onako as a background service."""
104
- system = platform.system()
132
+ def _is_server_running():
133
+ """Check if the onako server is already running via pid file."""
134
+ if not PID_FILE.exists():
135
+ return False
136
+ try:
137
+ pid = int(PID_FILE.read_text().strip())
138
+ os.kill(pid, 0) # signal 0 = check if process exists
139
+ return True
140
+ except (ValueError, ProcessLookupError, PermissionError):
141
+ PID_FILE.unlink(missing_ok=True)
142
+ return False
143
+
144
+
145
+ def _start_server(host, port, session, working_dir, skip_permissions=False):
146
+ """Start the Onako server in the background if not already running.
147
+
148
+ Returns True if the server was started or is already running.
149
+ """
150
+ if _is_server_running():
151
+ click.echo(f"Onako server already running (pid {PID_FILE.read_text().strip()})")
152
+ return True
153
+
154
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
105
155
  onako_bin = shutil.which("onako")
106
156
  if not onako_bin:
107
157
  click.echo("Error: 'onako' command not found on PATH.", err=True)
108
158
  sys.exit(1)
109
159
 
110
- LOG_DIR.mkdir(parents=True, exist_ok=True)
160
+ log_out = LOG_DIR / "onako.log"
161
+
162
+ cmd = [onako_bin, "serve", "--host", host, "--port", str(port), "--session", session, "--dir", working_dir]
163
+ if skip_permissions:
164
+ cmd.append("--dangerously-skip-permissions")
165
+
166
+ with open(log_out, "a") as log_fh:
167
+ proc = subprocess.Popen(
168
+ cmd,
169
+ stdout=log_fh,
170
+ stderr=subprocess.STDOUT,
171
+ start_new_session=True,
172
+ )
173
+ PID_FILE.parent.mkdir(parents=True, exist_ok=True)
174
+ PID_FILE.write_text(str(proc.pid))
175
+
176
+ local_ip = _get_local_ip()
177
+ banner = [
178
+ f"Onako server started (pid {proc.pid})",
179
+ f" Dashboard: http://{host}:{port}",
180
+ ]
181
+ if local_ip:
182
+ banner.append(f" http://{local_ip}:{port}")
183
+ banner.append(f" Session: {session}")
184
+ banner.append(f" Logs: {log_out}")
185
+ for line in banner:
186
+ click.echo(line)
187
+
188
+ # Wait for server to be ready
189
+ import urllib.request
190
+ for _ in range(20):
191
+ try:
192
+ urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1)
193
+ break
194
+ except Exception:
195
+ import time
196
+ time.sleep(0.25)
111
197
 
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)
198
+ return True
128
199
 
129
200
 
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}")
201
+
202
+ def _get_local_ip():
203
+ """Get the machine's local network IP address."""
204
+ try:
205
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
206
+ s.connect(("8.8.8.8", 80))
207
+ ip = s.getsockname()[0]
208
+ s.close()
209
+ return ip
210
+ except Exception:
211
+ return None
192
212
 
193
213
 
194
214
  def _check_prerequisites():
195
215
  """Check that tmux and claude are installed."""
196
- tmux_path = shutil.which("tmux")
197
- if not tmux_path:
216
+ if not shutil.which("tmux"):
198
217
  click.echo("Error: tmux is not installed.", err=True)
199
218
  click.echo("Install it with: brew install tmux (macOS) or apt install tmux (Linux)", err=True)
200
219
  sys.exit(1)
201
- click.echo(f" tmux: {tmux_path}")
202
220
 
203
- claude_path = shutil.which("claude")
204
- if not claude_path:
221
+ if not shutil.which("claude"):
205
222
  click.echo("Warning: claude CLI not found on PATH.", err=True)
206
223
  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,13 +8,16 @@ 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
+ skip_permissions_default = os.environ.get("ONAKO_SKIP_PERMISSIONS") == "1"
13
+ orch = TmuxOrchestrator(session_name=session_name)
12
14
  static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
13
15
 
14
16
 
15
17
  class CreateTaskRequest(BaseModel):
16
18
  prompt: str
17
19
  working_dir: str | None = None
20
+ skip_permissions: bool | None = None
18
21
 
19
22
 
20
23
  class SendMessageRequest(BaseModel):
@@ -28,12 +31,16 @@ def dashboard():
28
31
 
29
32
  @app.get("/health")
30
33
  def health():
31
- return {"status": "ok"}
34
+ return {"status": "ok", "skip_permissions": skip_permissions_default}
32
35
 
33
36
 
34
37
  @app.post("/tasks")
35
38
  def create_task(req: CreateTaskRequest):
36
- command = f"claude {shlex.quote(req.prompt)}"
39
+ skip = req.skip_permissions if req.skip_permissions is not None else skip_permissions_default
40
+ if skip:
41
+ command = f"claude --dangerously-skip-permissions {shlex.quote(req.prompt)}"
42
+ else:
43
+ command = f"claude {shlex.quote(req.prompt)}"
37
44
  task = orch.create_task(command, working_dir=req.working_dir, prompt=req.prompt)
38
45
  return task
39
46
 
@@ -69,10 +76,20 @@ def send_message(task_id: str, req: SendMessageRequest):
69
76
  return {"status": "sent"}
70
77
 
71
78
 
79
+ @app.post("/tasks/{task_id}/interrupt")
80
+ def interrupt_task(task_id: str):
81
+ if task_id not in orch.tasks:
82
+ raise HTTPException(status_code=404, detail="Task not found")
83
+ orch.send_interrupt(task_id)
84
+ return {"status": "interrupted"}
85
+
86
+
72
87
  @app.delete("/tasks/{task_id}")
73
88
  def delete_task(task_id: str):
74
89
  if task_id not in orch.tasks:
75
90
  raise HTTPException(status_code=404, detail="Task not found")
91
+ if task_id == "onako-main":
92
+ raise HTTPException(status_code=400, detail="Cannot kill the main window")
76
93
  orch.kill_task(task_id)
77
94
  return {"status": "deleted"}
78
95
 
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;
@@ -88,6 +98,16 @@
88
98
  cursor: pointer;
89
99
  font-size: 14px;
90
100
  }
101
+ #interrupt-btn {
102
+ background: #f59e0b;
103
+ color: white;
104
+ border: none;
105
+ padding: 6px 12px;
106
+ border-radius: 6px;
107
+ cursor: pointer;
108
+ font-size: 12px;
109
+ }
110
+ #interrupt-btn.hidden { display: none; }
91
111
  #kill-btn {
92
112
  background: #ef4444;
93
113
  color: white;
@@ -211,6 +231,7 @@
211
231
  <div id="detail-header">
212
232
  <button id="back-btn">&larr; Back</button>
213
233
  <span id="detail-task-id"></span>
234
+ <button id="interrupt-btn">Interrupt</button>
214
235
  <button id="kill-btn">Kill</button>
215
236
  </div>
216
237
  <div id="output"></div>
@@ -225,6 +246,10 @@
225
246
  <div id="modal">
226
247
  <textarea id="prompt-input" placeholder="What do you want done?"></textarea>
227
248
  <input id="workdir-input" type="text" placeholder="Working directory (optional)">
249
+ <label id="skip-perms-label" style="display:flex;align-items:center;gap:8px;margin-top:8px;font-size:13px;color:#aaa;cursor:pointer;">
250
+ <input type="checkbox" id="skip-perms-input" style="width:auto;margin:0;">
251
+ Skip permissions
252
+ </label>
228
253
  <button id="submit-task-btn">Start Task</button>
229
254
  </div>
230
255
 
@@ -233,6 +258,7 @@
233
258
  let currentTaskId = null;
234
259
  let currentTaskStatus = null;
235
260
  let pollInterval = null;
261
+ let skipPermissionsDefault = false;
236
262
 
237
263
  function timeAgo(dateStr) {
238
264
  if (!dateStr) return '';
@@ -250,6 +276,15 @@
250
276
  document.getElementById('connection-banner').style.display = show ? 'block' : 'none';
251
277
  }
252
278
 
279
+ async function loadConfig() {
280
+ try {
281
+ const res = await fetch(`${API}/health`);
282
+ const data = await res.json();
283
+ skipPermissionsDefault = data.skip_permissions || false;
284
+ document.getElementById('skip-perms-input').checked = skipPermissionsDefault;
285
+ } catch (e) {}
286
+ }
287
+
253
288
  async function loadTasks() {
254
289
  try {
255
290
  const res = await fetch(`${API}/tasks`);
@@ -260,7 +295,7 @@
260
295
  } else {
261
296
  list.innerHTML = tasks.map(t => `
262
297
  <div class="task-item" onclick="showTask('${t.id}')">
263
- <div class="task-id">${t.id}</div>
298
+ <div class="task-id">${t.id}${t.origin === 'external' ? '<span class="origin-badge">external</span>' : ''}</div>
264
299
  <div class="task-prompt">${escapeHtml(t.prompt)}</div>
265
300
  <div class="task-meta">
266
301
  <span class="status-${t.status}">${t.status}</span>
@@ -281,7 +316,13 @@
281
316
  document.getElementById('list-view').classList.add('hidden');
282
317
  document.getElementById('detail-view').classList.add('active');
283
318
  document.getElementById('detail-task-id').textContent = id;
284
- document.getElementById('kill-btn').classList.remove('hidden');
319
+ if (id === 'onako-main') {
320
+ document.getElementById('interrupt-btn').classList.add('hidden');
321
+ document.getElementById('kill-btn').classList.add('hidden');
322
+ } else {
323
+ document.getElementById('interrupt-btn').classList.remove('hidden');
324
+ document.getElementById('kill-btn').classList.remove('hidden');
325
+ }
285
326
  await refreshOutput();
286
327
  pollInterval = setInterval(refreshOutput, 3000);
287
328
  }
@@ -301,6 +342,7 @@
301
342
  // Stop polling and hide kill button when task is done
302
343
  if (data.status === 'done' && currentTaskStatus !== 'done') {
303
344
  currentTaskStatus = 'done';
345
+ document.getElementById('interrupt-btn').classList.add('hidden');
304
346
  document.getElementById('kill-btn').classList.add('hidden');
305
347
  if (pollInterval) {
306
348
  clearInterval(pollInterval);
@@ -338,6 +380,15 @@
338
380
  }
339
381
  }
340
382
 
383
+ async function interruptTask() {
384
+ if (!currentTaskId) return;
385
+ try {
386
+ await fetch(`${API}/tasks/${currentTaskId}/interrupt`, {method: 'POST'});
387
+ } catch (e) {
388
+ showConnectionError(true);
389
+ }
390
+ }
391
+
341
392
  async function killTask() {
342
393
  if (!currentTaskId) return;
343
394
  if (!confirm('Kill this task?')) return;
@@ -352,6 +403,7 @@
352
403
  function showModal() {
353
404
  document.getElementById('modal').style.display = 'block';
354
405
  document.getElementById('modal-overlay').style.display = 'block';
406
+ document.getElementById('skip-perms-input').checked = skipPermissionsDefault;
355
407
  document.getElementById('prompt-input').focus();
356
408
  }
357
409
 
@@ -364,7 +416,8 @@
364
416
  const prompt = document.getElementById('prompt-input').value.trim();
365
417
  if (!prompt) return;
366
418
  const workdir = document.getElementById('workdir-input').value.trim() || null;
367
- const body = {prompt};
419
+ const skipPerms = document.getElementById('skip-perms-input').checked;
420
+ const body = {prompt, skip_permissions: skipPerms};
368
421
  if (workdir) body.working_dir = workdir;
369
422
  try {
370
423
  const res = await fetch(`${API}/tasks`, {
@@ -393,6 +446,7 @@
393
446
  document.getElementById('modal-overlay').addEventListener('click', hideModal);
394
447
  document.getElementById('submit-task-btn').addEventListener('click', submitTask);
395
448
  document.getElementById('back-btn').addEventListener('click', showList);
449
+ document.getElementById('interrupt-btn').addEventListener('click', interruptTask);
396
450
  document.getElementById('kill-btn').addEventListener('click', killTask);
397
451
  document.getElementById('send-btn').addEventListener('click', sendMessage);
398
452
  document.getElementById('message-input').addEventListener('keydown', e => {
@@ -403,6 +457,7 @@
403
457
  });
404
458
 
405
459
  // Init
460
+ loadConfig();
406
461
  loadTasks();
407
462
  setInterval(() => { if (!currentTaskId) loadTasks(); }, 10000);
408
463
  </script>
@@ -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,41 @@ 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
 
171
+ def send_interrupt(self, task_id: str):
172
+ self._run_tmux("send-keys", "-t", self._task_target(task_id), "Escape")
173
+
144
174
  def kill_task(self, task_id: str):
145
- self._run_tmux("kill-window", "-t", f"{self.session_name}:{task_id}")
175
+ self._run_tmux("kill-window", "-t", self._task_target(task_id))
146
176
  if task_id in self.tasks:
147
177
  self.tasks[task_id]["status"] = "done"
148
178
  self._save_task(self.tasks[task_id])
149
179
 
150
180
  def _sync_task_status(self):
151
181
  result = self._run_tmux(
152
- "list-windows", "-t", self.session_name, "-F", "#{window_name}",
182
+ "list-windows", "-t", self.session_name, "-F", "#{window_name}|#{window_id}",
153
183
  )
154
- active_windows = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
184
+ active_windows = set()
185
+ if result.stdout.strip():
186
+ for line in result.stdout.strip().split("\n"):
187
+ parts = line.split("|", 1)
188
+ active_windows.add(parts[0])
189
+ if len(parts) > 1:
190
+ self._window_ids[parts[0]] = parts[1]
155
191
  for task_id, task in self.tasks.items():
156
192
  if task["status"] == "running" and task_id not in active_windows:
157
193
  task["status"] = "done"
@@ -160,17 +196,27 @@ class TmuxOrchestrator:
160
196
  def rediscover_tasks(self):
161
197
  """Rediscover tasks from existing tmux windows on server restart."""
162
198
  result = self._run_tmux(
163
- "list-windows", "-t", self.session_name, "-F", "#{window_name}",
199
+ "list-windows", "-t", self.session_name, "-F", "#{window_name}|#{window_id}",
164
200
  )
165
201
  if not result.stdout.strip():
166
202
  return
167
- for window_name in result.stdout.strip().split("\n"):
168
- if window_name.startswith("task-") and window_name not in self.tasks:
203
+ for line in result.stdout.strip().split("\n"):
204
+ parts = line.split("|", 1)
205
+ window_name = parts[0]
206
+ window_id = parts[1] if len(parts) > 1 else None
207
+ if window_id:
208
+ self._window_ids[window_name] = window_id
209
+ if window_name not in self.tasks:
210
+ is_managed = window_name.startswith("task-")
169
211
  task = {
170
212
  "id": window_name,
171
- "prompt": "(rediscovered)",
213
+ "prompt": "(rediscovered)" if is_managed else window_name,
172
214
  "status": "running",
173
215
  "started_at": None,
216
+ "origin": "managed" if is_managed else "external",
174
217
  }
175
218
  self.tasks[window_name] = task
176
219
  self._save_task(task)
220
+ elif self.tasks[window_name]["status"] == "done":
221
+ self.tasks[window_name]["status"] = "running"
222
+ 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.4.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 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.
@@ -0,0 +1,10 @@
1
+ onako/__init__.py,sha256=42STGor_9nKYXumfeV5tiyD_M8VdcddX7CEexmibPBk,22
2
+ onako/cli.py,sha256=8nsQj0XbgRlS9DsgYfao2uc1hyTE8q9ULVyGtPOgP9I,7629
3
+ onako/server.py,sha256=NfP2CaecxAzuq0tSIaatfYX3d98EuLfhIwK00oHNjro,2883
4
+ onako/tmux_orchestrator.py,sha256=URAOdYo88SzW1ef0o9_hKxJ0SeTtuqZlex1kgTMv42w,8277
5
+ onako/static/index.html,sha256=ioombLZg8uhVHL64saB50ikYBUYNDgw5yClA-QoNxEo,16802
6
+ onako-0.4.0.dist-info/METADATA,sha256=dv588takf4ASUQzsuF7mMYPwJs1Gq4WDU741BvAwegY,1945
7
+ onako-0.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
+ onako-0.4.0.dist-info/entry_points.txt,sha256=51KRJzuVpr69iT_k4JO0Lj3DQv_HbgtGjTBTev13JAQ,41
9
+ onako-0.4.0.dist-info/top_level.txt,sha256=EZsc5qq2paM9GTbaFE9Xar4B5wFdfIqK9l_bDQVcmZ4,6
10
+ onako-0.4.0.dist-info/RECORD,,
@@ -1,34 +0,0 @@
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
- <string>--dir</string>
16
- <string>{working_dir}</string>
17
- </array>
18
- <key>WorkingDirectory</key>
19
- <string>{working_dir}</string>
20
- <key>RunAtLoad</key>
21
- <true/>
22
- <key>KeepAlive</key>
23
- <true/>
24
- <key>StandardOutPath</key>
25
- <string>{log_dir}/server.stdout.log</string>
26
- <key>StandardErrorPath</key>
27
- <string>{log_dir}/server.stderr.log</string>
28
- <key>EnvironmentVariables</key>
29
- <dict>
30
- <key>PATH</key>
31
- <string>{path_value}</string>
32
- </dict>
33
- </dict>
34
- </plist>
@@ -1,12 +0,0 @@
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} --dir {working_dir}
7
- WorkingDirectory={working_dir}
8
- Restart=on-failure
9
- Environment=PATH={path_value}
10
-
11
- [Install]
12
- WantedBy=default.target
@@ -1,12 +0,0 @@
1
- onako/__init__.py,sha256=HfjVOrpTnmZ-xVFCYSVmX50EXaBQeJteUHG-PD6iQs8,22
2
- onako/cli.py,sha256=scmBfnyRAaT_yJziVQyumV6SyureGx6FXumV_r6KGxE,7217
3
- onako/server.py,sha256=lBNaT8Xq5Jw8EpN1CZNWzKQ2TudAMpSE4L0PY4ucW10,2065
4
- onako/tmux_orchestrator.py,sha256=vnhNda6VRhPIi28EaHJn67WiRCPvMGnoG7uwCD2MDtU,6111
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.1.dist-info/METADATA,sha256=L52xdawAyV0GjqXFwbdGJfZnE6o4P8tGKu0oF8ZPWt0,1891
9
- onako-0.2.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
10
- onako-0.2.1.dist-info/entry_points.txt,sha256=51KRJzuVpr69iT_k4JO0Lj3DQv_HbgtGjTBTev13JAQ,41
11
- onako-0.2.1.dist-info/top_level.txt,sha256=EZsc5qq2paM9GTbaFE9Xar4B5wFdfIqK9l_bDQVcmZ4,6
12
- onako-0.2.1.dist-info/RECORD,,
File without changes