onako 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {onako-0.3.0 → onako-0.4.0}/PKG-INFO +2 -2
- {onako-0.3.0 → onako-0.4.0}/README.md +1 -1
- {onako-0.3.0 → onako-0.4.0}/pyproject.toml +1 -2
- onako-0.4.0/src/onako/__init__.py +1 -0
- {onako-0.3.0 → onako-0.4.0}/src/onako/cli.py +13 -128
- {onako-0.3.0 → onako-0.4.0}/src/onako/server.py +16 -2
- {onako-0.3.0 → onako-0.4.0}/src/onako/static/index.html +42 -1
- {onako-0.3.0 → onako-0.4.0}/src/onako/tmux_orchestrator.py +3 -0
- {onako-0.3.0 → onako-0.4.0}/src/onako.egg-info/PKG-INFO +2 -2
- {onako-0.3.0 → onako-0.4.0}/src/onako.egg-info/SOURCES.txt +0 -2
- onako-0.4.0/tests/test_api.py +169 -0
- {onako-0.3.0 → onako-0.4.0}/tests/test_cli.py +12 -1
- {onako-0.3.0 → onako-0.4.0}/tests/test_tmux_orchestrator.py +10 -0
- onako-0.3.0/src/onako/__init__.py +0 -1
- onako-0.3.0/src/onako/templates/com.onako.server.plist.tpl +0 -34
- onako-0.3.0/src/onako/templates/onako.service.tpl +0 -12
- onako-0.3.0/tests/test_api.py +0 -91
- {onako-0.3.0 → onako-0.4.0}/setup.cfg +0 -0
- {onako-0.3.0 → onako-0.4.0}/src/onako.egg-info/dependency_links.txt +0 -0
- {onako-0.3.0 → onako-0.4.0}/src/onako.egg-info/entry_points.txt +0 -0
- {onako-0.3.0 → onako-0.4.0}/src/onako.egg-info/requires.txt +0 -0
- {onako-0.3.0 → onako-0.4.0}/src/onako.egg-info/top_level.txt +0 -0
- {onako-0.3.0 → onako-0.4.0}/tests/test_cli_service.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: onako
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -39,7 +39,7 @@ onako --session my-project # custom session name
|
|
|
39
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.
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
onako stop # stop the
|
|
42
|
+
onako stop # stop the server
|
|
43
43
|
onako status # check if running
|
|
44
44
|
onako serve # foreground server (for development)
|
|
45
45
|
onako version # print version
|
|
@@ -22,7 +22,7 @@ onako --session my-project # custom session name
|
|
|
22
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
23
|
|
|
24
24
|
```bash
|
|
25
|
-
onako stop # stop the
|
|
25
|
+
onako stop # stop the server
|
|
26
26
|
onako status # check if running
|
|
27
27
|
onako serve # foreground server (for development)
|
|
28
28
|
onako version # print version
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "onako"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Dispatch and monitor Claude Code tasks from your phone"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -37,7 +37,6 @@ where = ["src"]
|
|
|
37
37
|
[tool.setuptools.package-data]
|
|
38
38
|
onako = [
|
|
39
39
|
"static/**/*",
|
|
40
|
-
"templates/*",
|
|
41
40
|
]
|
|
42
41
|
|
|
43
42
|
[tool.pytest.ini_options]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import platform
|
|
3
2
|
import socket
|
|
4
3
|
import shutil
|
|
5
4
|
import subprocess
|
|
@@ -18,8 +17,9 @@ PID_FILE = ONAKO_DIR / "onako.pid"
|
|
|
18
17
|
@click.option("--port", default=8787, type=int, help="Port to bind to.")
|
|
19
18
|
@click.option("--session", default="onako", help="tmux session name (auto-detected if inside tmux).")
|
|
20
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
21
|
@click.pass_context
|
|
22
|
-
def main(ctx, host, port, session, working_dir):
|
|
22
|
+
def main(ctx, host, port, session, working_dir, skip_permissions):
|
|
23
23
|
"""Onako — Dispatch and monitor Claude Code tasks from your phone."""
|
|
24
24
|
if ctx.invoked_subcommand is not None:
|
|
25
25
|
return
|
|
@@ -41,7 +41,7 @@ def main(ctx, host, port, session, working_dir):
|
|
|
41
41
|
except FileNotFoundError:
|
|
42
42
|
pass
|
|
43
43
|
|
|
44
|
-
_start_server(host, port, session, working_dir)
|
|
44
|
+
_start_server(host, port, session, working_dir, skip_permissions)
|
|
45
45
|
|
|
46
46
|
# If not inside tmux, ensure session exists and attach
|
|
47
47
|
if not os.environ.get("TMUX"):
|
|
@@ -69,19 +69,17 @@ def version():
|
|
|
69
69
|
@click.option("--port", default=8787, type=int, help="Port to bind to.")
|
|
70
70
|
@click.option("--session", default="onako", help="tmux session name.")
|
|
71
71
|
@click.option("--dir", "working_dir", default=None, type=click.Path(exists=True), help="Working directory for tasks (default: current directory).")
|
|
72
|
-
@click.option("--
|
|
73
|
-
def serve(host, port, session, working_dir,
|
|
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):
|
|
74
74
|
"""Start the Onako server."""
|
|
75
75
|
working_dir = str(Path(working_dir).resolve()) if working_dir else os.getcwd()
|
|
76
76
|
|
|
77
|
-
if background:
|
|
78
|
-
_start_background(host, port, working_dir)
|
|
79
|
-
return
|
|
80
|
-
|
|
81
77
|
_check_prerequisites()
|
|
82
78
|
|
|
83
79
|
os.environ["ONAKO_WORKING_DIR"] = working_dir
|
|
84
80
|
os.environ["ONAKO_SESSION"] = session
|
|
81
|
+
if skip_permissions:
|
|
82
|
+
os.environ["ONAKO_SKIP_PERMISSIONS"] = "1"
|
|
85
83
|
|
|
86
84
|
from onako import __version__
|
|
87
85
|
click.echo(f"Onako v{__version__}")
|
|
@@ -111,31 +109,6 @@ def stop():
|
|
|
111
109
|
click.echo("Stale pid file found, cleaning up.")
|
|
112
110
|
PID_FILE.unlink(missing_ok=True)
|
|
113
111
|
|
|
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
112
|
if not stopped:
|
|
140
113
|
click.echo("Onako service is not running.")
|
|
141
114
|
|
|
@@ -169,7 +142,7 @@ def _is_server_running():
|
|
|
169
142
|
return False
|
|
170
143
|
|
|
171
144
|
|
|
172
|
-
def _start_server(host, port, session, working_dir):
|
|
145
|
+
def _start_server(host, port, session, working_dir, skip_permissions=False):
|
|
173
146
|
"""Start the Onako server in the background if not already running.
|
|
174
147
|
|
|
175
148
|
Returns True if the server was started or is already running.
|
|
@@ -186,9 +159,13 @@ def _start_server(host, port, session, working_dir):
|
|
|
186
159
|
|
|
187
160
|
log_out = LOG_DIR / "onako.log"
|
|
188
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
|
+
|
|
189
166
|
with open(log_out, "a") as log_fh:
|
|
190
167
|
proc = subprocess.Popen(
|
|
191
|
-
|
|
168
|
+
cmd,
|
|
192
169
|
stdout=log_fh,
|
|
193
170
|
stderr=subprocess.STDOUT,
|
|
194
171
|
start_new_session=True,
|
|
@@ -222,98 +199,6 @@ def _start_server(host, port, session, working_dir):
|
|
|
222
199
|
|
|
223
200
|
|
|
224
201
|
|
|
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
202
|
def _get_local_ip():
|
|
318
203
|
"""Get the machine's local network IP address."""
|
|
319
204
|
try:
|
|
@@ -9,6 +9,7 @@ from onako.tmux_orchestrator import TmuxOrchestrator
|
|
|
9
9
|
|
|
10
10
|
app = FastAPI()
|
|
11
11
|
session_name = os.environ.get("ONAKO_SESSION", "onako")
|
|
12
|
+
skip_permissions_default = os.environ.get("ONAKO_SKIP_PERMISSIONS") == "1"
|
|
12
13
|
orch = TmuxOrchestrator(session_name=session_name)
|
|
13
14
|
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
|
14
15
|
|
|
@@ -16,6 +17,7 @@ static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
|
|
16
17
|
class CreateTaskRequest(BaseModel):
|
|
17
18
|
prompt: str
|
|
18
19
|
working_dir: str | None = None
|
|
20
|
+
skip_permissions: bool | None = None
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
class SendMessageRequest(BaseModel):
|
|
@@ -29,12 +31,16 @@ def dashboard():
|
|
|
29
31
|
|
|
30
32
|
@app.get("/health")
|
|
31
33
|
def health():
|
|
32
|
-
return {"status": "ok"}
|
|
34
|
+
return {"status": "ok", "skip_permissions": skip_permissions_default}
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
@app.post("/tasks")
|
|
36
38
|
def create_task(req: CreateTaskRequest):
|
|
37
|
-
|
|
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)}"
|
|
38
44
|
task = orch.create_task(command, working_dir=req.working_dir, prompt=req.prompt)
|
|
39
45
|
return task
|
|
40
46
|
|
|
@@ -70,6 +76,14 @@ def send_message(task_id: str, req: SendMessageRequest):
|
|
|
70
76
|
return {"status": "sent"}
|
|
71
77
|
|
|
72
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
|
+
|
|
73
87
|
@app.delete("/tasks/{task_id}")
|
|
74
88
|
def delete_task(task_id: str):
|
|
75
89
|
if task_id not in orch.tasks:
|
|
@@ -98,6 +98,16 @@
|
|
|
98
98
|
cursor: pointer;
|
|
99
99
|
font-size: 14px;
|
|
100
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; }
|
|
101
111
|
#kill-btn {
|
|
102
112
|
background: #ef4444;
|
|
103
113
|
color: white;
|
|
@@ -221,6 +231,7 @@
|
|
|
221
231
|
<div id="detail-header">
|
|
222
232
|
<button id="back-btn">← Back</button>
|
|
223
233
|
<span id="detail-task-id"></span>
|
|
234
|
+
<button id="interrupt-btn">Interrupt</button>
|
|
224
235
|
<button id="kill-btn">Kill</button>
|
|
225
236
|
</div>
|
|
226
237
|
<div id="output"></div>
|
|
@@ -235,6 +246,10 @@
|
|
|
235
246
|
<div id="modal">
|
|
236
247
|
<textarea id="prompt-input" placeholder="What do you want done?"></textarea>
|
|
237
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>
|
|
238
253
|
<button id="submit-task-btn">Start Task</button>
|
|
239
254
|
</div>
|
|
240
255
|
|
|
@@ -243,6 +258,7 @@
|
|
|
243
258
|
let currentTaskId = null;
|
|
244
259
|
let currentTaskStatus = null;
|
|
245
260
|
let pollInterval = null;
|
|
261
|
+
let skipPermissionsDefault = false;
|
|
246
262
|
|
|
247
263
|
function timeAgo(dateStr) {
|
|
248
264
|
if (!dateStr) return '';
|
|
@@ -260,6 +276,15 @@
|
|
|
260
276
|
document.getElementById('connection-banner').style.display = show ? 'block' : 'none';
|
|
261
277
|
}
|
|
262
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
|
+
|
|
263
288
|
async function loadTasks() {
|
|
264
289
|
try {
|
|
265
290
|
const res = await fetch(`${API}/tasks`);
|
|
@@ -292,8 +317,10 @@
|
|
|
292
317
|
document.getElementById('detail-view').classList.add('active');
|
|
293
318
|
document.getElementById('detail-task-id').textContent = id;
|
|
294
319
|
if (id === 'onako-main') {
|
|
320
|
+
document.getElementById('interrupt-btn').classList.add('hidden');
|
|
295
321
|
document.getElementById('kill-btn').classList.add('hidden');
|
|
296
322
|
} else {
|
|
323
|
+
document.getElementById('interrupt-btn').classList.remove('hidden');
|
|
297
324
|
document.getElementById('kill-btn').classList.remove('hidden');
|
|
298
325
|
}
|
|
299
326
|
await refreshOutput();
|
|
@@ -315,6 +342,7 @@
|
|
|
315
342
|
// Stop polling and hide kill button when task is done
|
|
316
343
|
if (data.status === 'done' && currentTaskStatus !== 'done') {
|
|
317
344
|
currentTaskStatus = 'done';
|
|
345
|
+
document.getElementById('interrupt-btn').classList.add('hidden');
|
|
318
346
|
document.getElementById('kill-btn').classList.add('hidden');
|
|
319
347
|
if (pollInterval) {
|
|
320
348
|
clearInterval(pollInterval);
|
|
@@ -352,6 +380,15 @@
|
|
|
352
380
|
}
|
|
353
381
|
}
|
|
354
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
|
+
|
|
355
392
|
async function killTask() {
|
|
356
393
|
if (!currentTaskId) return;
|
|
357
394
|
if (!confirm('Kill this task?')) return;
|
|
@@ -366,6 +403,7 @@
|
|
|
366
403
|
function showModal() {
|
|
367
404
|
document.getElementById('modal').style.display = 'block';
|
|
368
405
|
document.getElementById('modal-overlay').style.display = 'block';
|
|
406
|
+
document.getElementById('skip-perms-input').checked = skipPermissionsDefault;
|
|
369
407
|
document.getElementById('prompt-input').focus();
|
|
370
408
|
}
|
|
371
409
|
|
|
@@ -378,7 +416,8 @@
|
|
|
378
416
|
const prompt = document.getElementById('prompt-input').value.trim();
|
|
379
417
|
if (!prompt) return;
|
|
380
418
|
const workdir = document.getElementById('workdir-input').value.trim() || null;
|
|
381
|
-
const
|
|
419
|
+
const skipPerms = document.getElementById('skip-perms-input').checked;
|
|
420
|
+
const body = {prompt, skip_permissions: skipPerms};
|
|
382
421
|
if (workdir) body.working_dir = workdir;
|
|
383
422
|
try {
|
|
384
423
|
const res = await fetch(`${API}/tasks`, {
|
|
@@ -407,6 +446,7 @@
|
|
|
407
446
|
document.getElementById('modal-overlay').addEventListener('click', hideModal);
|
|
408
447
|
document.getElementById('submit-task-btn').addEventListener('click', submitTask);
|
|
409
448
|
document.getElementById('back-btn').addEventListener('click', showList);
|
|
449
|
+
document.getElementById('interrupt-btn').addEventListener('click', interruptTask);
|
|
410
450
|
document.getElementById('kill-btn').addEventListener('click', killTask);
|
|
411
451
|
document.getElementById('send-btn').addEventListener('click', sendMessage);
|
|
412
452
|
document.getElementById('message-input').addEventListener('keydown', e => {
|
|
@@ -417,6 +457,7 @@
|
|
|
417
457
|
});
|
|
418
458
|
|
|
419
459
|
// Init
|
|
460
|
+
loadConfig();
|
|
420
461
|
loadTasks();
|
|
421
462
|
setInterval(() => { if (!currentTaskId) loadTasks(); }, 10000);
|
|
422
463
|
</script>
|
|
@@ -168,6 +168,9 @@ class TmuxOrchestrator:
|
|
|
168
168
|
"Enter",
|
|
169
169
|
)
|
|
170
170
|
|
|
171
|
+
def send_interrupt(self, task_id: str):
|
|
172
|
+
self._run_tmux("send-keys", "-t", self._task_target(task_id), "Escape")
|
|
173
|
+
|
|
171
174
|
def kill_task(self, task_id: str):
|
|
172
175
|
self._run_tmux("kill-window", "-t", self._task_target(task_id))
|
|
173
176
|
if task_id in self.tasks:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: onako
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -39,7 +39,7 @@ onako --session my-project # custom session name
|
|
|
39
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.
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
onako stop # stop the
|
|
42
|
+
onako stop # stop the server
|
|
43
43
|
onako status # check if running
|
|
44
44
|
onako serve # foreground server (for development)
|
|
45
45
|
onako version # print version
|
|
@@ -11,8 +11,6 @@ src/onako.egg-info/entry_points.txt
|
|
|
11
11
|
src/onako.egg-info/requires.txt
|
|
12
12
|
src/onako.egg-info/top_level.txt
|
|
13
13
|
src/onako/static/index.html
|
|
14
|
-
src/onako/templates/com.onako.server.plist.tpl
|
|
15
|
-
src/onako/templates/onako.service.tpl
|
|
16
14
|
tests/test_api.py
|
|
17
15
|
tests/test_cli.py
|
|
18
16
|
tests/test_cli_service.py
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import pytest
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
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
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def client(tmp_path):
|
|
17
|
+
import os
|
|
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"
|
|
23
|
+
from onako import server
|
|
24
|
+
importlib.reload(server)
|
|
25
|
+
client = TestClient(server.app)
|
|
26
|
+
yield client
|
|
27
|
+
response = client.get("/tasks")
|
|
28
|
+
for task in response.json():
|
|
29
|
+
if task["id"] != "onako-main":
|
|
30
|
+
client.delete(f"/tasks/{task['id']}")
|
|
31
|
+
tmux_orchestrator.DB_PATH = original_db
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_health(client):
|
|
35
|
+
r = client.get("/health")
|
|
36
|
+
assert r.status_code == 200
|
|
37
|
+
assert r.json()["status"] == "ok"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_create_task(client):
|
|
41
|
+
r = client.post("/tasks", json={"prompt": "echo api-test"})
|
|
42
|
+
assert r.status_code == 200
|
|
43
|
+
assert r.json()["id"].startswith("task-")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_list_tasks(client):
|
|
47
|
+
client.post("/tasks", json={"prompt": "echo one"})
|
|
48
|
+
client.post("/tasks", json={"prompt": "echo two"})
|
|
49
|
+
r = client.get("/tasks")
|
|
50
|
+
assert r.status_code == 200
|
|
51
|
+
assert len(r.json()) >= 2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_get_task(client):
|
|
55
|
+
create = client.post("/tasks", json={"prompt": "echo detail-test"})
|
|
56
|
+
task_id = create.json()["id"]
|
|
57
|
+
import time
|
|
58
|
+
time.sleep(1)
|
|
59
|
+
r = client.get(f"/tasks/{task_id}")
|
|
60
|
+
assert r.status_code == 200
|
|
61
|
+
assert r.json()["id"] == task_id
|
|
62
|
+
assert "output" in r.json()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_get_task_raw(client):
|
|
66
|
+
create = client.post("/tasks", json={"prompt": "echo raw-api-test"})
|
|
67
|
+
task_id = create.json()["id"]
|
|
68
|
+
import time
|
|
69
|
+
time.sleep(1)
|
|
70
|
+
r = client.get(f"/tasks/{task_id}/raw")
|
|
71
|
+
assert r.status_code == 200
|
|
72
|
+
assert "output" in r.json()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_send_message(client):
|
|
76
|
+
create = client.post("/tasks", json={"prompt": "cat"})
|
|
77
|
+
task_id = create.json()["id"]
|
|
78
|
+
r = client.post(f"/tasks/{task_id}/message", json={"message": "hello"})
|
|
79
|
+
assert r.status_code == 200
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_delete_task(client):
|
|
83
|
+
create = client.post("/tasks", json={"prompt": "sleep 999"})
|
|
84
|
+
task_id = create.json()["id"]
|
|
85
|
+
r = client.delete(f"/tasks/{task_id}")
|
|
86
|
+
assert r.status_code == 200
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_interrupt_task(client):
|
|
90
|
+
create = client.post("/tasks", json={"prompt": "sleep 999"})
|
|
91
|
+
task_id = create.json()["id"]
|
|
92
|
+
r = client.post(f"/tasks/{task_id}/interrupt")
|
|
93
|
+
assert r.status_code == 200
|
|
94
|
+
assert r.json()["status"] == "interrupted"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_interrupt_nonexistent_task(client):
|
|
98
|
+
r = client.post("/tasks/task-nonexistent/interrupt")
|
|
99
|
+
assert r.status_code == 404
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_get_nonexistent_task(client):
|
|
103
|
+
r = client.get("/tasks/task-nonexistent")
|
|
104
|
+
assert r.status_code == 404
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_health_includes_skip_permissions(client):
|
|
108
|
+
r = client.get("/health")
|
|
109
|
+
assert r.status_code == 200
|
|
110
|
+
assert "skip_permissions" in r.json()
|
|
111
|
+
assert r.json()["skip_permissions"] is False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_create_task_default_no_skip(client):
|
|
115
|
+
"""When skip_permissions_default is False, command should not include the flag."""
|
|
116
|
+
from unittest.mock import patch, MagicMock
|
|
117
|
+
from onako import server
|
|
118
|
+
|
|
119
|
+
mock_create = MagicMock(return_value={"id": "task-fake", "status": "running"})
|
|
120
|
+
with patch.object(server, "skip_permissions_default", False), \
|
|
121
|
+
patch.object(server.orch, "create_task", mock_create):
|
|
122
|
+
r = client.post("/tasks", json={"prompt": "echo test"})
|
|
123
|
+
assert r.status_code == 200
|
|
124
|
+
command = mock_create.call_args[0][0]
|
|
125
|
+
assert command == "claude 'echo test'"
|
|
126
|
+
assert "--dangerously-skip-permissions" not in command
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_create_task_with_global_skip(client):
|
|
130
|
+
"""When skip_permissions_default is True, command should include the flag."""
|
|
131
|
+
from unittest.mock import patch, MagicMock
|
|
132
|
+
from onako import server
|
|
133
|
+
|
|
134
|
+
mock_create = MagicMock(return_value={"id": "task-fake", "status": "running"})
|
|
135
|
+
with patch.object(server, "skip_permissions_default", True), \
|
|
136
|
+
patch.object(server.orch, "create_task", mock_create):
|
|
137
|
+
r = client.post("/tasks", json={"prompt": "echo test"})
|
|
138
|
+
assert r.status_code == 200
|
|
139
|
+
command = mock_create.call_args[0][0]
|
|
140
|
+
assert command == "claude --dangerously-skip-permissions 'echo test'"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_create_task_per_task_skip_override(client):
|
|
144
|
+
"""Per-task skip_permissions=True overrides global default of False."""
|
|
145
|
+
from unittest.mock import patch, MagicMock
|
|
146
|
+
from onako import server
|
|
147
|
+
|
|
148
|
+
mock_create = MagicMock(return_value={"id": "task-fake", "status": "running"})
|
|
149
|
+
with patch.object(server, "skip_permissions_default", False), \
|
|
150
|
+
patch.object(server.orch, "create_task", mock_create):
|
|
151
|
+
r = client.post("/tasks", json={"prompt": "echo test", "skip_permissions": True})
|
|
152
|
+
assert r.status_code == 200
|
|
153
|
+
command = mock_create.call_args[0][0]
|
|
154
|
+
assert command == "claude --dangerously-skip-permissions 'echo test'"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_create_task_per_task_no_skip_override(client):
|
|
158
|
+
"""Per-task skip_permissions=False overrides global default of True."""
|
|
159
|
+
from unittest.mock import patch, MagicMock
|
|
160
|
+
from onako import server
|
|
161
|
+
|
|
162
|
+
mock_create = MagicMock(return_value={"id": "task-fake", "status": "running"})
|
|
163
|
+
with patch.object(server, "skip_permissions_default", True), \
|
|
164
|
+
patch.object(server.orch, "create_task", mock_create):
|
|
165
|
+
r = client.post("/tasks", json={"prompt": "echo test", "skip_permissions": False})
|
|
166
|
+
assert r.status_code == 200
|
|
167
|
+
command = mock_create.call_args[0][0]
|
|
168
|
+
assert command == "claude 'echo test'"
|
|
169
|
+
assert "--dangerously-skip-permissions" not in command
|
|
@@ -25,5 +25,16 @@ def test_serve_help():
|
|
|
25
25
|
assert "--host" in result.output
|
|
26
26
|
assert "--port" in result.output
|
|
27
27
|
assert "--session" in result.output
|
|
28
|
-
assert "--background" in result.output
|
|
29
28
|
assert "--dir" in result.output
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_skip_permissions_flag_in_help():
|
|
32
|
+
runner = CliRunner()
|
|
33
|
+
result = runner.invoke(main, ["--help"])
|
|
34
|
+
assert "--dangerously-skip-permissions" in result.output
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_serve_skip_permissions_flag_in_help():
|
|
38
|
+
runner = CliRunner()
|
|
39
|
+
result = runner.invoke(main, ["serve", "--help"])
|
|
40
|
+
assert "--dangerously-skip-permissions" in result.output
|
|
@@ -56,6 +56,16 @@ def test_send_message(orch):
|
|
|
56
56
|
assert "hello-input" in output
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
def test_send_interrupt(orch):
|
|
60
|
+
task = orch.create_task("sleep 999")
|
|
61
|
+
time.sleep(0.5)
|
|
62
|
+
orch.send_interrupt(task["id"])
|
|
63
|
+
time.sleep(0.5)
|
|
64
|
+
# Window should still exist after interrupt
|
|
65
|
+
tasks = orch.list_tasks()
|
|
66
|
+
assert any(t["id"] == task["id"] and t["status"] == "running" for t in tasks)
|
|
67
|
+
|
|
68
|
+
|
|
59
69
|
def test_kill_task(orch):
|
|
60
70
|
task = orch.create_task("sleep 999")
|
|
61
71
|
orch.kill_task(task["id"])
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
|
@@ -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
|
onako-0.3.0/tests/test_api.py
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
import pytest
|
|
3
|
-
from fastapi.testclient import TestClient
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
@pytest.fixture
|
|
16
|
-
def client(tmp_path):
|
|
17
|
-
import os
|
|
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"
|
|
23
|
-
from onako import server
|
|
24
|
-
importlib.reload(server)
|
|
25
|
-
client = TestClient(server.app)
|
|
26
|
-
yield client
|
|
27
|
-
response = client.get("/tasks")
|
|
28
|
-
for task in response.json():
|
|
29
|
-
if task["id"] != "onako-main":
|
|
30
|
-
client.delete(f"/tasks/{task['id']}")
|
|
31
|
-
tmux_orchestrator.DB_PATH = original_db
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_health(client):
|
|
35
|
-
r = client.get("/health")
|
|
36
|
-
assert r.status_code == 200
|
|
37
|
-
assert r.json()["status"] == "ok"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_create_task(client):
|
|
41
|
-
r = client.post("/tasks", json={"prompt": "echo api-test"})
|
|
42
|
-
assert r.status_code == 200
|
|
43
|
-
assert r.json()["id"].startswith("task-")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_list_tasks(client):
|
|
47
|
-
client.post("/tasks", json={"prompt": "echo one"})
|
|
48
|
-
client.post("/tasks", json={"prompt": "echo two"})
|
|
49
|
-
r = client.get("/tasks")
|
|
50
|
-
assert r.status_code == 200
|
|
51
|
-
assert len(r.json()) >= 2
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def test_get_task(client):
|
|
55
|
-
create = client.post("/tasks", json={"prompt": "echo detail-test"})
|
|
56
|
-
task_id = create.json()["id"]
|
|
57
|
-
import time
|
|
58
|
-
time.sleep(1)
|
|
59
|
-
r = client.get(f"/tasks/{task_id}")
|
|
60
|
-
assert r.status_code == 200
|
|
61
|
-
assert r.json()["id"] == task_id
|
|
62
|
-
assert "output" in r.json()
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_get_task_raw(client):
|
|
66
|
-
create = client.post("/tasks", json={"prompt": "echo raw-api-test"})
|
|
67
|
-
task_id = create.json()["id"]
|
|
68
|
-
import time
|
|
69
|
-
time.sleep(1)
|
|
70
|
-
r = client.get(f"/tasks/{task_id}/raw")
|
|
71
|
-
assert r.status_code == 200
|
|
72
|
-
assert "output" in r.json()
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_send_message(client):
|
|
76
|
-
create = client.post("/tasks", json={"prompt": "cat"})
|
|
77
|
-
task_id = create.json()["id"]
|
|
78
|
-
r = client.post(f"/tasks/{task_id}/message", json={"message": "hello"})
|
|
79
|
-
assert r.status_code == 200
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_delete_task(client):
|
|
83
|
-
create = client.post("/tasks", json={"prompt": "sleep 999"})
|
|
84
|
-
task_id = create.json()["id"]
|
|
85
|
-
r = client.delete(f"/tasks/{task_id}")
|
|
86
|
-
assert r.status_code == 200
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_get_nonexistent_task(client):
|
|
90
|
-
r = client.get("/tasks/task-nonexistent")
|
|
91
|
-
assert r.status_code == 404
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|