onako 0.4.2__py3-none-any.whl → 0.4.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- onako/__init__.py +1 -1
- onako/cli.py +69 -6
- onako/server.py +18 -5
- onako/static/index.html +402 -30
- onako/tmux_orchestrator.py +84 -18
- {onako-0.4.2.dist-info → onako-0.4.4.dist-info}/METADATA +1 -1
- onako-0.4.4.dist-info/RECORD +10 -0
- onako-0.4.2.dist-info/RECORD +0 -10
- {onako-0.4.2.dist-info → onako-0.4.4.dist-info}/WHEEL +0 -0
- {onako-0.4.2.dist-info → onako-0.4.4.dist-info}/entry_points.txt +0 -0
- {onako-0.4.2.dist-info → onako-0.4.4.dist-info}/top_level.txt +0 -0
onako/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.4.
|
|
1
|
+
__version__ = "0.4.4"
|
onako/cli.py
CHANGED
|
@@ -10,6 +10,7 @@ import click
|
|
|
10
10
|
ONAKO_DIR = Path.home() / ".onako"
|
|
11
11
|
LOG_DIR = ONAKO_DIR / "logs"
|
|
12
12
|
PID_FILE = ONAKO_DIR / "onako.pid"
|
|
13
|
+
DEV_MARKER = ONAKO_DIR / "onako.dev"
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
@click.group(invoke_without_command=True)
|
|
@@ -18,8 +19,9 @@ PID_FILE = ONAKO_DIR / "onako.pid"
|
|
|
18
19
|
@click.option("--session", default="onako", help="tmux session name (auto-detected if inside tmux).")
|
|
19
20
|
@click.option("--dir", "working_dir", default=None, type=click.Path(exists=True), help="Working directory for tasks (default: current directory).")
|
|
20
21
|
@click.option("--dangerously-skip-permissions", "skip_permissions", is_flag=True, default=False, help="Pass --dangerously-skip-permissions to all Claude Code tasks.")
|
|
22
|
+
@click.option("--no-attach", is_flag=True, default=False, help="Start the server without attaching to tmux.")
|
|
21
23
|
@click.pass_context
|
|
22
|
-
def main(ctx, host, port, session, working_dir, skip_permissions):
|
|
24
|
+
def main(ctx, host, port, session, working_dir, skip_permissions, no_attach):
|
|
23
25
|
"""Onako — Dispatch and monitor Claude Code tasks from your phone."""
|
|
24
26
|
if ctx.invoked_subcommand is not None:
|
|
25
27
|
return
|
|
@@ -65,7 +67,7 @@ def main(ctx, host, port, session, working_dir, skip_permissions):
|
|
|
65
67
|
capture_output=True,
|
|
66
68
|
)
|
|
67
69
|
|
|
68
|
-
if not inside_tmux:
|
|
70
|
+
if not inside_tmux and not no_attach:
|
|
69
71
|
os.execvp("tmux", ["tmux", "attach-session", "-t", session])
|
|
70
72
|
|
|
71
73
|
|
|
@@ -115,16 +117,69 @@ def stop():
|
|
|
115
117
|
try:
|
|
116
118
|
pid = int(PID_FILE.read_text().strip())
|
|
117
119
|
os.kill(pid, 15) # SIGTERM
|
|
118
|
-
|
|
120
|
+
dev = " (dev)" if DEV_MARKER.exists() else ""
|
|
121
|
+
click.echo(f"Onako server stopped{dev} (pid {pid}).")
|
|
119
122
|
stopped = True
|
|
120
123
|
except (ValueError, ProcessLookupError):
|
|
121
124
|
click.echo("Stale pid file found, cleaning up.")
|
|
122
125
|
PID_FILE.unlink(missing_ok=True)
|
|
126
|
+
DEV_MARKER.unlink(missing_ok=True)
|
|
123
127
|
|
|
124
128
|
if not stopped:
|
|
125
129
|
click.echo("Onako service is not running.")
|
|
126
130
|
|
|
127
131
|
|
|
132
|
+
@main.command()
|
|
133
|
+
@click.option("--session", default="onako", help="tmux session name.")
|
|
134
|
+
def purge(session):
|
|
135
|
+
"""Kill all tmux windows from the onako session and clean up."""
|
|
136
|
+
# Auto-detect current tmux session if inside one
|
|
137
|
+
if os.environ.get("TMUX"):
|
|
138
|
+
try:
|
|
139
|
+
result = subprocess.run(
|
|
140
|
+
["tmux", "display-message", "-p", "#S"],
|
|
141
|
+
capture_output=True, text=True,
|
|
142
|
+
)
|
|
143
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
144
|
+
session = result.stdout.strip()
|
|
145
|
+
except FileNotFoundError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# Also stop the server if it's running
|
|
149
|
+
if PID_FILE.exists():
|
|
150
|
+
try:
|
|
151
|
+
pid = int(PID_FILE.read_text().strip())
|
|
152
|
+
os.kill(pid, 15)
|
|
153
|
+
dev = " (dev)" if DEV_MARKER.exists() else ""
|
|
154
|
+
click.echo(f"Stopped onako server{dev} (pid {pid}).")
|
|
155
|
+
except (ValueError, ProcessLookupError):
|
|
156
|
+
pass
|
|
157
|
+
PID_FILE.unlink(missing_ok=True)
|
|
158
|
+
DEV_MARKER.unlink(missing_ok=True)
|
|
159
|
+
|
|
160
|
+
# Kill the tmux session
|
|
161
|
+
result = subprocess.run(
|
|
162
|
+
["tmux", "kill-session", "-t", f"{session}:"],
|
|
163
|
+
capture_output=True, text=True,
|
|
164
|
+
)
|
|
165
|
+
if result.returncode == 0:
|
|
166
|
+
click.echo(f"Killed tmux session: {session}")
|
|
167
|
+
else:
|
|
168
|
+
click.echo(f"No tmux session '{session}' found.")
|
|
169
|
+
|
|
170
|
+
# Clean up the database
|
|
171
|
+
db_path = ONAKO_DIR / "onako.db"
|
|
172
|
+
if db_path.exists():
|
|
173
|
+
import sqlite3
|
|
174
|
+
conn = sqlite3.connect(db_path)
|
|
175
|
+
conn.execute("UPDATE tasks SET status = 'done' WHERE status = 'running'")
|
|
176
|
+
conn.commit()
|
|
177
|
+
conn.close()
|
|
178
|
+
click.echo("Marked all running tasks as done.")
|
|
179
|
+
|
|
180
|
+
click.echo("Purge complete.")
|
|
181
|
+
|
|
182
|
+
|
|
128
183
|
@main.command()
|
|
129
184
|
def status():
|
|
130
185
|
"""Check if Onako is running."""
|
|
@@ -133,7 +188,8 @@ def status():
|
|
|
133
188
|
r = urllib.request.urlopen("http://127.0.0.1:8787/health", timeout=2)
|
|
134
189
|
data = r.read().decode()
|
|
135
190
|
if '"ok"' in data:
|
|
136
|
-
|
|
191
|
+
mode = " (dev)" if DEV_MARKER.exists() else ""
|
|
192
|
+
click.echo(f"Onako server: running{mode}")
|
|
137
193
|
click.echo(" URL: http://127.0.0.1:8787")
|
|
138
194
|
else:
|
|
139
195
|
click.echo("Onako server: not responding correctly")
|
|
@@ -160,7 +216,8 @@ def _start_server(host, port, session, working_dir, skip_permissions=False):
|
|
|
160
216
|
Returns True if the server was started or is already running.
|
|
161
217
|
"""
|
|
162
218
|
if _is_server_running():
|
|
163
|
-
|
|
219
|
+
dev = " (dev)" if DEV_MARKER.exists() else ""
|
|
220
|
+
click.echo(f"Onako server already running{dev} (pid {PID_FILE.read_text().strip()})")
|
|
164
221
|
return True
|
|
165
222
|
|
|
166
223
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -185,9 +242,15 @@ def _start_server(host, port, session, working_dir, skip_permissions=False):
|
|
|
185
242
|
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
186
243
|
PID_FILE.write_text(str(proc.pid))
|
|
187
244
|
|
|
245
|
+
if os.environ.get("ONAKO_DEV"):
|
|
246
|
+
DEV_MARKER.write_text("")
|
|
247
|
+
else:
|
|
248
|
+
DEV_MARKER.unlink(missing_ok=True)
|
|
249
|
+
|
|
250
|
+
dev = " (dev)" if DEV_MARKER.exists() else ""
|
|
188
251
|
local_ip = _get_local_ip()
|
|
189
252
|
banner = [
|
|
190
|
-
f"Onako server started (pid {proc.pid})",
|
|
253
|
+
f"Onako server started{dev} (pid {proc.pid})",
|
|
191
254
|
f" Dashboard: http://{host}:{port}",
|
|
192
255
|
]
|
|
193
256
|
if local_ip:
|
onako/server.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import shlex
|
|
3
|
+
import uuid
|
|
3
4
|
|
|
4
5
|
from fastapi import FastAPI, HTTPException
|
|
5
6
|
from fastapi.responses import FileResponse
|
|
@@ -37,11 +38,12 @@ def health():
|
|
|
37
38
|
@app.post("/tasks")
|
|
38
39
|
def create_task(req: CreateTaskRequest):
|
|
39
40
|
skip = req.skip_permissions if req.skip_permissions is not None else skip_permissions_default
|
|
41
|
+
claude_session_id = str(uuid.uuid4())
|
|
40
42
|
if skip:
|
|
41
|
-
command = f"claude --dangerously-skip-permissions {shlex.quote(req.prompt)}"
|
|
43
|
+
command = f"claude --dangerously-skip-permissions --session-id {claude_session_id} {shlex.quote(req.prompt)}"
|
|
42
44
|
else:
|
|
43
|
-
command = f"claude {shlex.quote(req.prompt)}"
|
|
44
|
-
task = orch.create_task(command, working_dir=req.working_dir, prompt=req.prompt)
|
|
45
|
+
command = f"claude --session-id {claude_session_id} {shlex.quote(req.prompt)}"
|
|
46
|
+
task = orch.create_task(command, working_dir=req.working_dir, prompt=req.prompt, claude_session_id=claude_session_id)
|
|
45
47
|
return task
|
|
46
48
|
|
|
47
49
|
|
|
@@ -84,12 +86,23 @@ def interrupt_task(task_id: str):
|
|
|
84
86
|
return {"status": "interrupted"}
|
|
85
87
|
|
|
86
88
|
|
|
89
|
+
@app.post("/tasks/{task_id}/resume")
|
|
90
|
+
def resume_task(task_id: str):
|
|
91
|
+
if task_id not in orch.tasks:
|
|
92
|
+
raise HTTPException(status_code=404, detail="Task not found")
|
|
93
|
+
try:
|
|
94
|
+
new_task = orch.resume_task(task_id, skip_permissions=skip_permissions_default)
|
|
95
|
+
except ValueError as e:
|
|
96
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
97
|
+
return new_task
|
|
98
|
+
|
|
99
|
+
|
|
87
100
|
@app.delete("/tasks/{task_id}")
|
|
88
101
|
def delete_task(task_id: str):
|
|
89
102
|
if task_id not in orch.tasks:
|
|
90
103
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
91
|
-
if
|
|
92
|
-
raise HTTPException(status_code=400, detail="Cannot kill the
|
|
104
|
+
if orch.window_count() <= 1:
|
|
105
|
+
raise HTTPException(status_code=400, detail="Cannot kill the last window")
|
|
93
106
|
orch.kill_task(task_id)
|
|
94
107
|
return {"status": "deleted"}
|
|
95
108
|
|
onako/static/index.html
CHANGED
|
@@ -33,6 +33,33 @@
|
|
|
33
33
|
cursor: pointer;
|
|
34
34
|
font-size: 14px;
|
|
35
35
|
}
|
|
36
|
+
.task-controls {
|
|
37
|
+
padding: 8px;
|
|
38
|
+
display: flex;
|
|
39
|
+
gap: 6px;
|
|
40
|
+
border-bottom: 1px solid #2a2a2a;
|
|
41
|
+
}
|
|
42
|
+
.task-controls input {
|
|
43
|
+
flex: 1;
|
|
44
|
+
min-width: 0;
|
|
45
|
+
background: #2a2a2a;
|
|
46
|
+
border: 1px solid #333;
|
|
47
|
+
color: #e0e0e0;
|
|
48
|
+
padding: 6px 10px;
|
|
49
|
+
border-radius: 6px;
|
|
50
|
+
font-size: 13px;
|
|
51
|
+
-webkit-appearance: none;
|
|
52
|
+
}
|
|
53
|
+
.task-controls select {
|
|
54
|
+
background: #2a2a2a;
|
|
55
|
+
border: 1px solid #333;
|
|
56
|
+
color: #e0e0e0;
|
|
57
|
+
padding: 6px 8px;
|
|
58
|
+
border-radius: 6px;
|
|
59
|
+
font-size: 13px;
|
|
60
|
+
-webkit-appearance: none;
|
|
61
|
+
flex-shrink: 0;
|
|
62
|
+
}
|
|
36
63
|
.task-list { padding: 8px; }
|
|
37
64
|
.task-item {
|
|
38
65
|
padding: 12px;
|
|
@@ -40,6 +67,7 @@
|
|
|
40
67
|
cursor: pointer;
|
|
41
68
|
}
|
|
42
69
|
.task-item:hover { background: #222; }
|
|
70
|
+
.task-item.selected { background: #2a2a2a; }
|
|
43
71
|
.task-item .task-id { font-size: 12px; color: #888; }
|
|
44
72
|
.task-item .task-prompt {
|
|
45
73
|
font-size: 14px;
|
|
@@ -71,6 +99,40 @@
|
|
|
71
99
|
padding: 48px 16px;
|
|
72
100
|
font-size: 14px;
|
|
73
101
|
}
|
|
102
|
+
.section-header {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 6px;
|
|
106
|
+
padding: 10px 12px 6px;
|
|
107
|
+
font-size: 11px;
|
|
108
|
+
font-weight: 600;
|
|
109
|
+
color: #666;
|
|
110
|
+
text-transform: uppercase;
|
|
111
|
+
letter-spacing: 0.5px;
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
user-select: none;
|
|
114
|
+
}
|
|
115
|
+
.section-header .chevron {
|
|
116
|
+
transition: transform 0.15s;
|
|
117
|
+
font-size: 10px;
|
|
118
|
+
}
|
|
119
|
+
.section-header.collapsed .chevron {
|
|
120
|
+
transform: rotate(-90deg);
|
|
121
|
+
}
|
|
122
|
+
.recent-tasks.collapsed { display: none; }
|
|
123
|
+
.task-item.done { opacity: 0.6; }
|
|
124
|
+
.resume-btn {
|
|
125
|
+
display: inline-block;
|
|
126
|
+
background: #3b82f6;
|
|
127
|
+
color: white;
|
|
128
|
+
border: none;
|
|
129
|
+
padding: 3px 10px;
|
|
130
|
+
border-radius: 4px;
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
font-size: 11px;
|
|
133
|
+
margin-left: 8px;
|
|
134
|
+
vertical-align: middle;
|
|
135
|
+
}
|
|
74
136
|
#connection-banner {
|
|
75
137
|
display: none;
|
|
76
138
|
background: #ef4444;
|
|
@@ -83,6 +145,14 @@
|
|
|
83
145
|
#detail-view { display: none; flex-direction: column; height: 100dvh; }
|
|
84
146
|
#detail-view.active { display: flex; }
|
|
85
147
|
#list-view.hidden { display: none; }
|
|
148
|
+
#detail-empty {
|
|
149
|
+
display: none;
|
|
150
|
+
flex: 1;
|
|
151
|
+
align-items: center;
|
|
152
|
+
justify-content: center;
|
|
153
|
+
color: #555;
|
|
154
|
+
font-size: 14px;
|
|
155
|
+
}
|
|
86
156
|
#detail-header {
|
|
87
157
|
padding: 12px 16px;
|
|
88
158
|
padding-top: max(12px, env(safe-area-inset-top));
|
|
@@ -118,16 +188,38 @@
|
|
|
118
188
|
font-size: 12px;
|
|
119
189
|
}
|
|
120
190
|
#kill-btn.hidden { display: none; }
|
|
191
|
+
#resume-detail-btn {
|
|
192
|
+
background: #3b82f6;
|
|
193
|
+
color: white;
|
|
194
|
+
border: none;
|
|
195
|
+
padding: 6px 12px;
|
|
196
|
+
border-radius: 6px;
|
|
197
|
+
cursor: pointer;
|
|
198
|
+
font-size: 12px;
|
|
199
|
+
}
|
|
200
|
+
#resume-detail-btn.hidden { display: none; }
|
|
121
201
|
#output {
|
|
122
202
|
padding: 12px;
|
|
123
203
|
font-family: "SF Mono", "Menlo", "Monaco", monospace;
|
|
124
204
|
font-size: 12px;
|
|
125
|
-
|
|
126
|
-
word-break: break-all;
|
|
127
|
-
line-height: 1.4;
|
|
205
|
+
line-height: 1.5;
|
|
128
206
|
overflow-y: auto;
|
|
129
207
|
flex: 1;
|
|
130
208
|
}
|
|
209
|
+
.output-block {
|
|
210
|
+
white-space: pre-wrap;
|
|
211
|
+
word-break: break-word;
|
|
212
|
+
padding: 4px 0;
|
|
213
|
+
}
|
|
214
|
+
.block-user {
|
|
215
|
+
border-left: 2px solid #555;
|
|
216
|
+
padding-left: 10px;
|
|
217
|
+
margin-left: 2px;
|
|
218
|
+
color: #999;
|
|
219
|
+
}
|
|
220
|
+
.block-plain {
|
|
221
|
+
color: #888;
|
|
222
|
+
}
|
|
131
223
|
#message-bar {
|
|
132
224
|
padding: 8px;
|
|
133
225
|
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
|
@@ -212,17 +304,65 @@
|
|
|
212
304
|
font-size: 16px;
|
|
213
305
|
margin-top: 12px;
|
|
214
306
|
}
|
|
307
|
+
|
|
308
|
+
/* Desktop sidebar layout */
|
|
309
|
+
@media (min-width: 768px) {
|
|
310
|
+
#app-container {
|
|
311
|
+
display: flex;
|
|
312
|
+
height: 100dvh;
|
|
313
|
+
}
|
|
314
|
+
#list-view,
|
|
315
|
+
#list-view.hidden {
|
|
316
|
+
width: 280px;
|
|
317
|
+
flex-shrink: 0;
|
|
318
|
+
border-right: 1px solid #333;
|
|
319
|
+
display: flex;
|
|
320
|
+
flex-direction: column;
|
|
321
|
+
overflow: hidden;
|
|
322
|
+
}
|
|
323
|
+
.task-list {
|
|
324
|
+
flex: 1;
|
|
325
|
+
overflow-y: auto;
|
|
326
|
+
}
|
|
327
|
+
#detail-view {
|
|
328
|
+
flex: 1;
|
|
329
|
+
display: flex;
|
|
330
|
+
height: auto;
|
|
331
|
+
}
|
|
332
|
+
#detail-view:not(.active) #detail-header,
|
|
333
|
+
#detail-view:not(.active) #output,
|
|
334
|
+
#detail-view:not(.active) #message-bar {
|
|
335
|
+
display: none;
|
|
336
|
+
}
|
|
337
|
+
#detail-view:not(.active) #detail-empty {
|
|
338
|
+
display: flex;
|
|
339
|
+
}
|
|
340
|
+
#detail-view.active #detail-empty {
|
|
341
|
+
display: none;
|
|
342
|
+
}
|
|
343
|
+
#back-btn { display: none; }
|
|
344
|
+
}
|
|
215
345
|
</style>
|
|
216
346
|
</head>
|
|
217
347
|
<body>
|
|
218
348
|
<div id="connection-banner">Connection lost. Retrying...</div>
|
|
219
349
|
|
|
220
|
-
|
|
350
|
+
<div id="app-container">
|
|
351
|
+
<!-- List View / Sidebar -->
|
|
221
352
|
<div id="list-view">
|
|
222
353
|
<header>
|
|
223
354
|
<h1>Onako</h1>
|
|
224
355
|
<button id="new-task-btn">+ New Task</button>
|
|
225
356
|
</header>
|
|
357
|
+
<div class="task-controls">
|
|
358
|
+
<input id="search-input" type="text" placeholder="Search...">
|
|
359
|
+
<select id="sort-select">
|
|
360
|
+
<option value="active-desc">Active ↓</option>
|
|
361
|
+
<option value="active-asc">Active ↑</option>
|
|
362
|
+
<option value="started-desc">Started ↓</option>
|
|
363
|
+
<option value="started-asc">Started ↑</option>
|
|
364
|
+
</select>
|
|
365
|
+
</div>
|
|
226
366
|
<div class="task-list" id="task-list"></div>
|
|
227
367
|
</div>
|
|
228
368
|
|
|
@@ -231,14 +371,19 @@
|
|
|
231
371
|
<div id="detail-header">
|
|
232
372
|
<button id="back-btn">← Back</button>
|
|
233
373
|
<span id="detail-task-id"></span>
|
|
234
|
-
<
|
|
235
|
-
|
|
374
|
+
<div style="display:flex;gap:8px">
|
|
375
|
+
<button id="resume-detail-btn" class="hidden">Resume</button>
|
|
376
|
+
<button id="interrupt-btn">Interrupt</button>
|
|
377
|
+
<button id="kill-btn">Kill</button>
|
|
378
|
+
</div>
|
|
236
379
|
</div>
|
|
237
380
|
<div id="output"></div>
|
|
238
381
|
<div id="message-bar">
|
|
239
382
|
<input id="message-input" type="text" placeholder="Send a message...">
|
|
240
383
|
<button id="send-btn">Send</button>
|
|
241
384
|
</div>
|
|
385
|
+
<div id="detail-empty">Select a task to view</div>
|
|
386
|
+
</div>
|
|
242
387
|
</div>
|
|
243
388
|
|
|
244
389
|
<!-- New Task Modal -->
|
|
@@ -260,6 +405,10 @@
|
|
|
260
405
|
let pollInterval = null;
|
|
261
406
|
let skipPermissionsDefault = false;
|
|
262
407
|
|
|
408
|
+
function isDesktop() {
|
|
409
|
+
return window.innerWidth >= 768;
|
|
410
|
+
}
|
|
411
|
+
|
|
263
412
|
function timeAgo(dateStr) {
|
|
264
413
|
if (!dateStr) return '';
|
|
265
414
|
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
@@ -281,50 +430,123 @@
|
|
|
281
430
|
const res = await fetch(`${API}/health`);
|
|
282
431
|
const data = await res.json();
|
|
283
432
|
skipPermissionsDefault = data.skip_permissions || false;
|
|
284
|
-
|
|
433
|
+
const saved = localStorage.getItem('skipPermissions');
|
|
434
|
+
const initial = saved !== null ? saved === '1' : skipPermissionsDefault;
|
|
435
|
+
document.getElementById('skip-perms-input').checked = initial;
|
|
285
436
|
} catch (e) {}
|
|
286
437
|
}
|
|
287
438
|
|
|
439
|
+
let recentCollapsed = localStorage.getItem('recentCollapsed') === '1';
|
|
440
|
+
|
|
441
|
+
function renderTaskItem(t) {
|
|
442
|
+
const isDone = t.status === 'done';
|
|
443
|
+
const resumable = isDone && t.claude_session_id;
|
|
444
|
+
return `
|
|
445
|
+
<div class="task-item${currentTaskId === t.id ? ' selected' : ''}${isDone ? ' done' : ''}" onclick="showTask('${t.id}')">
|
|
446
|
+
<div class="task-id">${t.id}${t.origin === 'external' ? '<span class="origin-badge">external</span>' : ''}${resumable ? '<button class="resume-btn" onclick="event.stopPropagation();resumeTask(\''+t.id+'\')">Resume</button>' : ''}</div>
|
|
447
|
+
<div class="task-prompt">${escapeHtml(t.prompt)}</div>
|
|
448
|
+
<div class="task-meta">
|
|
449
|
+
<span class="status-${t.status}">${isDone ? 'done' : '✓'}</span>
|
|
450
|
+
${t.started_at ? ' · started ' + timeAgo(t.started_at) : ''}
|
|
451
|
+
${t.last_activity ? ' · active ' + timeAgo(t.last_activity) : ''}
|
|
452
|
+
</div>
|
|
453
|
+
</div>`;
|
|
454
|
+
}
|
|
455
|
+
|
|
288
456
|
async function loadTasks() {
|
|
289
457
|
try {
|
|
290
458
|
const res = await fetch(`${API}/tasks`);
|
|
291
|
-
const
|
|
459
|
+
const allTasks = await res.json();
|
|
460
|
+
allTasksCache = allTasks;
|
|
461
|
+
const query = document.getElementById('search-input').value.trim().toLowerCase();
|
|
462
|
+
let running = allTasks.filter(t => t.status === 'running');
|
|
463
|
+
let done = allTasks.filter(t => t.status === 'done' && t.claude_session_id);
|
|
464
|
+
if (query) {
|
|
465
|
+
running = running.filter(t => t.prompt.toLowerCase().includes(query));
|
|
466
|
+
done = done.filter(t => t.prompt.toLowerCase().includes(query));
|
|
467
|
+
}
|
|
468
|
+
const sort = document.getElementById('sort-select').value;
|
|
469
|
+
const [field, dir] = sort.split('-');
|
|
470
|
+
const key = field === 'active' ? 'last_activity' : 'started_at';
|
|
471
|
+
const mul = dir === 'desc' ? 1 : -1;
|
|
472
|
+
const sortFn = (a, b) => {
|
|
473
|
+
const va = a[key] || '';
|
|
474
|
+
const vb = b[key] || '';
|
|
475
|
+
return va < vb ? mul : va > vb ? -mul : 0;
|
|
476
|
+
};
|
|
477
|
+
running.sort(sortFn);
|
|
478
|
+
done.sort((a, b) => {
|
|
479
|
+
const va = a.last_activity || a.started_at || '';
|
|
480
|
+
const vb = b.last_activity || b.started_at || '';
|
|
481
|
+
return va < vb ? 1 : va > vb ? -1 : 0;
|
|
482
|
+
});
|
|
483
|
+
// Limit recent to last 20
|
|
484
|
+
done = done.slice(0, 20);
|
|
292
485
|
const list = document.getElementById('task-list');
|
|
293
|
-
|
|
294
|
-
|
|
486
|
+
let html = '';
|
|
487
|
+
if (running.length === 0 && done.length === 0) {
|
|
488
|
+
html = '<div class="empty-state">No tasks</div>';
|
|
295
489
|
} else {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
490
|
+
if (running.length > 0) {
|
|
491
|
+
html += running.map(renderTaskItem).join('');
|
|
492
|
+
} else {
|
|
493
|
+
html += '<div class="empty-state">No tasks running</div>';
|
|
494
|
+
}
|
|
495
|
+
if (done.length > 0) {
|
|
496
|
+
html += `<div class="section-header${recentCollapsed ? ' collapsed' : ''}" onclick="toggleRecent()"><span class="chevron">▼</span> Recent (${done.length})</div>`;
|
|
497
|
+
html += `<div class="recent-tasks${recentCollapsed ? ' collapsed' : ''}">`;
|
|
498
|
+
html += done.map(renderTaskItem).join('');
|
|
499
|
+
html += '</div>';
|
|
500
|
+
}
|
|
306
501
|
}
|
|
502
|
+
list.innerHTML = html;
|
|
307
503
|
showConnectionError(false);
|
|
308
504
|
} catch (e) {
|
|
309
505
|
showConnectionError(true);
|
|
310
506
|
}
|
|
311
507
|
}
|
|
312
508
|
|
|
509
|
+
function toggleRecent() {
|
|
510
|
+
recentCollapsed = !recentCollapsed;
|
|
511
|
+
localStorage.setItem('recentCollapsed', recentCollapsed ? '1' : '0');
|
|
512
|
+
loadTasks();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let allTasksCache = [];
|
|
516
|
+
|
|
313
517
|
async function showTask(id) {
|
|
314
518
|
currentTaskId = id;
|
|
315
519
|
currentTaskStatus = null;
|
|
316
|
-
document.getElementById('list-view').classList.add('hidden');
|
|
317
|
-
document.getElementById('detail-view').classList.add('active');
|
|
318
520
|
document.getElementById('detail-task-id').textContent = id;
|
|
319
|
-
if (
|
|
521
|
+
if (isDesktop()) {
|
|
522
|
+
document.getElementById('detail-view').classList.add('active');
|
|
523
|
+
document.querySelectorAll('.task-item').forEach(el => el.classList.remove('selected'));
|
|
524
|
+
document.querySelectorAll('.task-item').forEach(el => {
|
|
525
|
+
if (el.onclick && el.getAttribute('onclick')?.includes(id)) {
|
|
526
|
+
el.classList.add('selected');
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
} else {
|
|
530
|
+
document.getElementById('list-view').classList.add('hidden');
|
|
531
|
+
document.getElementById('detail-view').classList.add('active');
|
|
532
|
+
}
|
|
533
|
+
// Check if task is already done
|
|
534
|
+
const taskData = allTasksCache.find(t => t.id === id);
|
|
535
|
+
if (taskData && taskData.status === 'done') {
|
|
536
|
+
currentTaskStatus = 'done';
|
|
320
537
|
document.getElementById('interrupt-btn').classList.add('hidden');
|
|
321
538
|
document.getElementById('kill-btn').classList.add('hidden');
|
|
539
|
+
document.getElementById('resume-detail-btn').classList.toggle('hidden', !taskData.claude_session_id);
|
|
322
540
|
} else {
|
|
323
541
|
document.getElementById('interrupt-btn').classList.remove('hidden');
|
|
324
542
|
document.getElementById('kill-btn').classList.remove('hidden');
|
|
543
|
+
document.getElementById('resume-detail-btn').classList.add('hidden');
|
|
325
544
|
}
|
|
545
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
326
546
|
await refreshOutput();
|
|
327
|
-
|
|
547
|
+
if (currentTaskStatus !== 'done') {
|
|
548
|
+
pollInterval = setInterval(refreshOutput, 3000);
|
|
549
|
+
}
|
|
328
550
|
}
|
|
329
551
|
|
|
330
552
|
async function refreshOutput() {
|
|
@@ -335,7 +557,11 @@
|
|
|
335
557
|
const data = await res.json();
|
|
336
558
|
const el = document.getElementById('output');
|
|
337
559
|
const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
|
338
|
-
|
|
560
|
+
if (data.output) {
|
|
561
|
+
el.innerHTML = renderOutput(data.output);
|
|
562
|
+
} else {
|
|
563
|
+
el.textContent = '(no output yet)';
|
|
564
|
+
}
|
|
339
565
|
if (wasAtBottom) el.scrollTop = el.scrollHeight;
|
|
340
566
|
showConnectionError(false);
|
|
341
567
|
|
|
@@ -344,10 +570,12 @@
|
|
|
344
570
|
currentTaskStatus = 'done';
|
|
345
571
|
document.getElementById('interrupt-btn').classList.add('hidden');
|
|
346
572
|
document.getElementById('kill-btn').classList.add('hidden');
|
|
573
|
+
document.getElementById('resume-detail-btn').classList.toggle('hidden', !data.claude_session_id);
|
|
347
574
|
if (pollInterval) {
|
|
348
575
|
clearInterval(pollInterval);
|
|
349
576
|
pollInterval = null;
|
|
350
577
|
}
|
|
578
|
+
loadTasks();
|
|
351
579
|
}
|
|
352
580
|
} catch (e) {
|
|
353
581
|
showConnectionError(true);
|
|
@@ -359,8 +587,13 @@
|
|
|
359
587
|
currentTaskStatus = null;
|
|
360
588
|
if (pollInterval) clearInterval(pollInterval);
|
|
361
589
|
pollInterval = null;
|
|
362
|
-
|
|
363
|
-
|
|
590
|
+
if (isDesktop()) {
|
|
591
|
+
document.getElementById('detail-view').classList.remove('active');
|
|
592
|
+
document.querySelectorAll('.task-item').forEach(el => el.classList.remove('selected'));
|
|
593
|
+
} else {
|
|
594
|
+
document.getElementById('detail-view').classList.remove('active');
|
|
595
|
+
document.getElementById('list-view').classList.remove('hidden');
|
|
596
|
+
}
|
|
364
597
|
loadTasks();
|
|
365
598
|
}
|
|
366
599
|
|
|
@@ -393,17 +626,39 @@
|
|
|
393
626
|
if (!currentTaskId) return;
|
|
394
627
|
if (!confirm('Kill this task?')) return;
|
|
395
628
|
try {
|
|
396
|
-
await fetch(`${API}/tasks/${currentTaskId}`, {method: 'DELETE'});
|
|
629
|
+
const res = await fetch(`${API}/tasks/${currentTaskId}`, {method: 'DELETE'});
|
|
630
|
+
if (!res.ok) {
|
|
631
|
+
const data = await res.json().catch(() => ({}));
|
|
632
|
+
alert(data.detail || 'Failed to kill task');
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
397
635
|
showList();
|
|
398
636
|
} catch (e) {
|
|
399
637
|
showConnectionError(true);
|
|
400
638
|
}
|
|
401
639
|
}
|
|
402
640
|
|
|
641
|
+
async function resumeTask(taskId) {
|
|
642
|
+
try {
|
|
643
|
+
const res = await fetch(`${API}/tasks/${taskId}/resume`, {method: 'POST'});
|
|
644
|
+
if (!res.ok) {
|
|
645
|
+
const data = await res.json().catch(() => ({}));
|
|
646
|
+
alert(data.detail || 'Failed to resume task');
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const newTask = await res.json();
|
|
650
|
+
await loadTasks();
|
|
651
|
+
showTask(newTask.id);
|
|
652
|
+
} catch (e) {
|
|
653
|
+
showConnectionError(true);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
403
657
|
function showModal() {
|
|
404
658
|
document.getElementById('modal').style.display = 'block';
|
|
405
659
|
document.getElementById('modal-overlay').style.display = 'block';
|
|
406
|
-
|
|
660
|
+
const saved = localStorage.getItem('skipPermissions');
|
|
661
|
+
document.getElementById('skip-perms-input').checked = saved !== null ? saved === '1' : skipPermissionsDefault;
|
|
407
662
|
document.getElementById('prompt-input').focus();
|
|
408
663
|
}
|
|
409
664
|
|
|
@@ -417,6 +672,7 @@
|
|
|
417
672
|
if (!prompt) return;
|
|
418
673
|
const workdir = document.getElementById('workdir-input').value.trim() || null;
|
|
419
674
|
const skipPerms = document.getElementById('skip-perms-input').checked;
|
|
675
|
+
localStorage.setItem('skipPermissions', skipPerms ? '1' : '0');
|
|
420
676
|
const body = {prompt, skip_permissions: skipPerms};
|
|
421
677
|
if (workdir) body.working_dir = workdir;
|
|
422
678
|
try {
|
|
@@ -429,6 +685,7 @@
|
|
|
429
685
|
document.getElementById('prompt-input').value = '';
|
|
430
686
|
document.getElementById('workdir-input').value = '';
|
|
431
687
|
hideModal();
|
|
688
|
+
await loadTasks();
|
|
432
689
|
showTask(task.id);
|
|
433
690
|
} catch (e) {
|
|
434
691
|
showConnectionError(true);
|
|
@@ -441,6 +698,110 @@
|
|
|
441
698
|
return d.innerHTML;
|
|
442
699
|
}
|
|
443
700
|
|
|
701
|
+
// --- ANSI-to-HTML converter ---
|
|
702
|
+
const ANSI_FG = [
|
|
703
|
+
'#555','#ff5555','#50fa7b','#f1fa8c','#bd93f9','#ff79c6','#8be9fd','#f8f8f2',
|
|
704
|
+
'#6272a4','#ff6e6e','#69ff94','#ffffa5','#d6acff','#ff92df','#a4ffff','#ffffff',
|
|
705
|
+
];
|
|
706
|
+
|
|
707
|
+
function color256(n) {
|
|
708
|
+
if (n < 16) return ANSI_FG[n] || '#e0e0e0';
|
|
709
|
+
if (n < 232) {
|
|
710
|
+
n -= 16;
|
|
711
|
+
const r = Math.floor(n / 36) * 51;
|
|
712
|
+
const g = Math.floor((n % 36) / 6) * 51;
|
|
713
|
+
const b = (n % 6) * 51;
|
|
714
|
+
return `rgb(${r},${g},${b})`;
|
|
715
|
+
}
|
|
716
|
+
const v = (n - 232) * 10 + 8;
|
|
717
|
+
return `rgb(${v},${v},${v})`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function ansiToHtml(text) {
|
|
721
|
+
text = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
722
|
+
const parts = text.split(/(\x1b\[[\d;]*[a-zA-Z])/);
|
|
723
|
+
let result = '';
|
|
724
|
+
let state = {};
|
|
725
|
+
for (const part of parts) {
|
|
726
|
+
const m = part.match(/^\x1b\[([\d;]*)m$/);
|
|
727
|
+
if (m) {
|
|
728
|
+
const codes = m[1] ? m[1].split(';').map(Number) : [0];
|
|
729
|
+
let i = 0;
|
|
730
|
+
while (i < codes.length) {
|
|
731
|
+
const c = codes[i];
|
|
732
|
+
if (c === 0) state = {};
|
|
733
|
+
else if (c === 1) state.bold = true;
|
|
734
|
+
else if (c === 2) state.dim = true;
|
|
735
|
+
else if (c === 3) state.italic = true;
|
|
736
|
+
else if (c === 4) state.underline = true;
|
|
737
|
+
else if (c === 22) { delete state.bold; delete state.dim; }
|
|
738
|
+
else if (c === 23) delete state.italic;
|
|
739
|
+
else if (c === 24) delete state.underline;
|
|
740
|
+
else if (c >= 30 && c <= 37) state.fg = ANSI_FG[c - 30];
|
|
741
|
+
else if (c >= 90 && c <= 97) state.fg = ANSI_FG[c - 82];
|
|
742
|
+
else if (c === 39) delete state.fg;
|
|
743
|
+
else if (c >= 40 && c <= 47) state.bg = ANSI_FG[c - 40];
|
|
744
|
+
else if (c >= 100 && c <= 107) state.bg = ANSI_FG[c - 92];
|
|
745
|
+
else if (c === 49) delete state.bg;
|
|
746
|
+
else if (c === 38 && codes[i+1] === 5) { state.fg = color256(codes[i+2] || 0); i += 2; }
|
|
747
|
+
else if (c === 38 && codes[i+1] === 2) { state.fg = `rgb(${codes[i+2]||0},${codes[i+3]||0},${codes[i+4]||0})`; i += 4; }
|
|
748
|
+
else if (c === 48 && codes[i+1] === 5) { state.bg = color256(codes[i+2] || 0); i += 2; }
|
|
749
|
+
else if (c === 48 && codes[i+1] === 2) { state.bg = `rgb(${codes[i+2]||0},${codes[i+3]||0},${codes[i+4]||0})`; i += 4; }
|
|
750
|
+
i++;
|
|
751
|
+
}
|
|
752
|
+
} else if (part.match(/^\x1b\[/)) {
|
|
753
|
+
// non-SGR ANSI sequence — skip
|
|
754
|
+
} else if (part) {
|
|
755
|
+
const s = [];
|
|
756
|
+
if (state.bold) s.push('font-weight:bold');
|
|
757
|
+
if (state.dim) s.push('opacity:0.6');
|
|
758
|
+
if (state.italic) s.push('font-style:italic');
|
|
759
|
+
if (state.underline) s.push('text-decoration:underline');
|
|
760
|
+
if (state.fg) s.push('color:' + state.fg);
|
|
761
|
+
if (state.bg) s.push('background:' + state.bg);
|
|
762
|
+
result += s.length ? `<span style="${s.join(';')}">${part}</span>` : part;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// --- Block parser (user prompts vs claude responses) ---
|
|
769
|
+
const ANSI_STRIP_RE = /\x1b\[[\d;]*[a-zA-Z]/g;
|
|
770
|
+
|
|
771
|
+
function parseBlocks(text) {
|
|
772
|
+
const lines = text.split('\n');
|
|
773
|
+
const blocks = [];
|
|
774
|
+
let cur = null;
|
|
775
|
+
for (const line of lines) {
|
|
776
|
+
const stripped = line.replace(ANSI_STRIP_RE, '').trimStart();
|
|
777
|
+
if (stripped.startsWith('›') || stripped.startsWith('❯')) {
|
|
778
|
+
if (cur) blocks.push(cur);
|
|
779
|
+
cur = { type: 'user', lines: [line] };
|
|
780
|
+
} else if (stripped.startsWith('●') || stripped.startsWith('⏺')) {
|
|
781
|
+
if (cur) blocks.push(cur);
|
|
782
|
+
cur = { type: 'claude', lines: [line] };
|
|
783
|
+
} else if (cur) {
|
|
784
|
+
cur.lines.push(line);
|
|
785
|
+
} else {
|
|
786
|
+
if (!blocks.length || blocks[blocks.length - 1].type !== 'plain') {
|
|
787
|
+
blocks.push({ type: 'plain', lines: [] });
|
|
788
|
+
}
|
|
789
|
+
blocks[blocks.length - 1].lines.push(line);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (cur) blocks.push(cur);
|
|
793
|
+
return blocks;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function renderOutput(text) {
|
|
797
|
+
const blocks = parseBlocks(text);
|
|
798
|
+
return blocks.map(b => {
|
|
799
|
+
const html = ansiToHtml(b.lines.join('\n'));
|
|
800
|
+
const cls = b.type === 'user' ? 'block-user' : b.type === 'claude' ? 'block-claude' : 'block-plain';
|
|
801
|
+
return `<div class="output-block ${cls}">${html}</div>`;
|
|
802
|
+
}).join('');
|
|
803
|
+
}
|
|
804
|
+
|
|
444
805
|
// Event listeners
|
|
445
806
|
document.getElementById('new-task-btn').addEventListener('click', showModal);
|
|
446
807
|
document.getElementById('modal-overlay').addEventListener('click', hideModal);
|
|
@@ -448,6 +809,9 @@
|
|
|
448
809
|
document.getElementById('back-btn').addEventListener('click', showList);
|
|
449
810
|
document.getElementById('interrupt-btn').addEventListener('click', interruptTask);
|
|
450
811
|
document.getElementById('kill-btn').addEventListener('click', killTask);
|
|
812
|
+
document.getElementById('resume-detail-btn').addEventListener('click', () => {
|
|
813
|
+
if (currentTaskId) resumeTask(currentTaskId);
|
|
814
|
+
});
|
|
451
815
|
document.getElementById('send-btn').addEventListener('click', sendMessage);
|
|
452
816
|
document.getElementById('message-input').addEventListener('keydown', e => {
|
|
453
817
|
if (e.key === 'Enter') sendMessage();
|
|
@@ -456,10 +820,18 @@
|
|
|
456
820
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submitTask();
|
|
457
821
|
});
|
|
458
822
|
|
|
823
|
+
document.getElementById('search-input').addEventListener('input', loadTasks);
|
|
824
|
+
document.getElementById('sort-select').addEventListener('change', () => {
|
|
825
|
+
localStorage.setItem('onakoSort', document.getElementById('sort-select').value);
|
|
826
|
+
loadTasks();
|
|
827
|
+
});
|
|
828
|
+
|
|
459
829
|
// Init
|
|
830
|
+
const savedSort = localStorage.getItem('onakoSort');
|
|
831
|
+
if (savedSort) document.getElementById('sort-select').value = savedSort;
|
|
460
832
|
loadConfig();
|
|
461
833
|
loadTasks();
|
|
462
|
-
setInterval(() => { if (!currentTaskId) loadTasks(); }, 10000);
|
|
834
|
+
setInterval(() => { if (!currentTaskId || isDesktop()) loadTasks(); }, 10000);
|
|
463
835
|
</script>
|
|
464
836
|
</body>
|
|
465
837
|
</html>
|
onako/tmux_orchestrator.py
CHANGED
|
@@ -3,6 +3,7 @@ import secrets
|
|
|
3
3
|
import shlex
|
|
4
4
|
import sqlite3
|
|
5
5
|
import subprocess
|
|
6
|
+
import uuid
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -16,6 +17,7 @@ class TmuxOrchestrator:
|
|
|
16
17
|
self.db_path = db_path or DB_PATH
|
|
17
18
|
self.tasks: dict[str, dict] = {}
|
|
18
19
|
self._window_ids: dict[str, str] = {} # window_name -> @id
|
|
20
|
+
self._last_content_length: dict[str, int] = {} # window_name -> pane content length
|
|
19
21
|
self._init_db()
|
|
20
22
|
self._load_tasks()
|
|
21
23
|
self._ensure_session()
|
|
@@ -46,20 +48,26 @@ class TmuxOrchestrator:
|
|
|
46
48
|
prompt TEXT,
|
|
47
49
|
status TEXT,
|
|
48
50
|
started_at TEXT,
|
|
49
|
-
origin TEXT DEFAULT 'managed'
|
|
51
|
+
origin TEXT DEFAULT 'managed',
|
|
52
|
+
last_activity TEXT,
|
|
53
|
+
claude_session_id TEXT
|
|
50
54
|
)
|
|
51
55
|
""")
|
|
52
|
-
# Migrate existing DBs that lack
|
|
56
|
+
# Migrate existing DBs that lack newer columns
|
|
53
57
|
cursor = conn.execute("PRAGMA table_info(tasks)")
|
|
54
58
|
columns = [row[1] for row in cursor.fetchall()]
|
|
55
59
|
if "origin" not in columns:
|
|
56
60
|
conn.execute("ALTER TABLE tasks ADD COLUMN origin TEXT DEFAULT 'managed'")
|
|
61
|
+
if "last_activity" not in columns:
|
|
62
|
+
conn.execute("ALTER TABLE tasks ADD COLUMN last_activity TEXT")
|
|
63
|
+
if "claude_session_id" not in columns:
|
|
64
|
+
conn.execute("ALTER TABLE tasks ADD COLUMN claude_session_id TEXT")
|
|
57
65
|
conn.commit()
|
|
58
66
|
conn.close()
|
|
59
67
|
|
|
60
68
|
def _load_tasks(self):
|
|
61
69
|
conn = sqlite3.connect(self.db_path)
|
|
62
|
-
rows = conn.execute("SELECT id, prompt, status, started_at, origin FROM tasks").fetchall()
|
|
70
|
+
rows = conn.execute("SELECT id, prompt, status, started_at, origin, last_activity, claude_session_id FROM tasks").fetchall()
|
|
63
71
|
conn.close()
|
|
64
72
|
for row in rows:
|
|
65
73
|
self.tasks[row[0]] = {
|
|
@@ -68,13 +76,15 @@ class TmuxOrchestrator:
|
|
|
68
76
|
"status": row[2],
|
|
69
77
|
"started_at": row[3],
|
|
70
78
|
"origin": row[4] or "managed",
|
|
79
|
+
"last_activity": row[5],
|
|
80
|
+
"claude_session_id": row[6],
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
def _save_task(self, task: dict):
|
|
74
84
|
conn = sqlite3.connect(self.db_path)
|
|
75
85
|
conn.execute(
|
|
76
|
-
"INSERT OR REPLACE INTO tasks (id, prompt, status, started_at, origin) VALUES (?, ?, ?, ?, ?)",
|
|
77
|
-
(task["id"], task["prompt"], task["status"], task["started_at"], task.get("origin", "managed")),
|
|
86
|
+
"INSERT OR REPLACE INTO tasks (id, prompt, status, started_at, origin, last_activity, claude_session_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
87
|
+
(task["id"], task["prompt"], task["status"], task["started_at"], task.get("origin", "managed"), task.get("last_activity"), task.get("claude_session_id")),
|
|
78
88
|
)
|
|
79
89
|
conn.commit()
|
|
80
90
|
conn.close()
|
|
@@ -89,7 +99,7 @@ class TmuxOrchestrator:
|
|
|
89
99
|
return window_id
|
|
90
100
|
return f"{self.session_name}:{task_id}"
|
|
91
101
|
|
|
92
|
-
def create_task(self, command: str, working_dir: str | None = None, prompt: str | None = None) -> dict:
|
|
102
|
+
def create_task(self, command: str, working_dir: str | None = None, prompt: str | None = None, claude_session_id: str | None = None) -> dict:
|
|
93
103
|
self._ensure_session()
|
|
94
104
|
task_id = f"task-{secrets.token_hex(4)}"
|
|
95
105
|
self._run_tmux(
|
|
@@ -112,36 +122,62 @@ class TmuxOrchestrator:
|
|
|
112
122
|
)
|
|
113
123
|
self._run_tmux(
|
|
114
124
|
"send-keys", "-t", self._task_target(task_id),
|
|
115
|
-
|
|
125
|
+
"-l", command,
|
|
116
126
|
)
|
|
127
|
+
self._run_tmux(
|
|
128
|
+
"send-keys", "-t", self._task_target(task_id),
|
|
129
|
+
"Enter",
|
|
130
|
+
)
|
|
131
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
117
132
|
task = {
|
|
118
133
|
"id": task_id,
|
|
119
134
|
"prompt": prompt or command,
|
|
120
135
|
"status": "running",
|
|
121
|
-
"started_at":
|
|
136
|
+
"started_at": now,
|
|
122
137
|
"origin": "managed",
|
|
138
|
+
"last_activity": now,
|
|
139
|
+
"claude_session_id": claude_session_id,
|
|
123
140
|
}
|
|
124
141
|
self.tasks[task_id] = task
|
|
125
142
|
self._save_task(task)
|
|
126
143
|
return task
|
|
127
144
|
|
|
145
|
+
def resume_task(self, task_id: str, skip_permissions: bool = False) -> dict:
|
|
146
|
+
"""Resume a done task by its claude_session_id."""
|
|
147
|
+
task = self.tasks.get(task_id)
|
|
148
|
+
if not task:
|
|
149
|
+
raise ValueError("Task not found")
|
|
150
|
+
if task["status"] == "running":
|
|
151
|
+
raise ValueError("Task is still running")
|
|
152
|
+
session_id = task.get("claude_session_id")
|
|
153
|
+
if not session_id:
|
|
154
|
+
raise ValueError("Task has no claude_session_id (cannot resume)")
|
|
155
|
+
cmd = "claude"
|
|
156
|
+
if skip_permissions:
|
|
157
|
+
cmd += " --dangerously-skip-permissions"
|
|
158
|
+
cmd += f" --resume {session_id}"
|
|
159
|
+
task["status"] = "resumed"
|
|
160
|
+
self._save_task(task)
|
|
161
|
+
return self.create_task(cmd, prompt=f"(resumed) {task['prompt']}", claude_session_id=session_id)
|
|
162
|
+
|
|
128
163
|
def list_tasks(self) -> list[dict]:
|
|
129
164
|
self.rediscover_tasks()
|
|
130
165
|
self._sync_task_status()
|
|
131
166
|
return list(self.tasks.values())
|
|
132
167
|
|
|
168
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
|
169
|
+
|
|
133
170
|
def get_output(self, task_id: str) -> str:
|
|
134
171
|
raw = self.get_raw_output(task_id)
|
|
135
|
-
|
|
136
|
-
return self._strip_claude_chrome(cleaned)
|
|
172
|
+
return self._strip_claude_chrome(raw)
|
|
137
173
|
|
|
138
|
-
@
|
|
139
|
-
def _strip_claude_chrome(text: str) -> str:
|
|
174
|
+
@classmethod
|
|
175
|
+
def _strip_claude_chrome(cls, text: str) -> str:
|
|
140
176
|
lines = text.split("\n")
|
|
141
177
|
# Strip from the bottom: Claude Code's TUI draws box-drawing chars,
|
|
142
178
|
# the › prompt, and status lines like "accept edits on..."
|
|
143
179
|
while lines:
|
|
144
|
-
line = lines[-1].strip()
|
|
180
|
+
line = cls._ANSI_RE.sub("", lines[-1]).strip()
|
|
145
181
|
if (
|
|
146
182
|
not line
|
|
147
183
|
or all(c in "─━╭╮╰╯│┃┌┐└┘├┤┬┴┼╋═║ ›❯▸▶" for c in line)
|
|
@@ -159,7 +195,7 @@ class TmuxOrchestrator:
|
|
|
159
195
|
def get_raw_output(self, task_id: str) -> str:
|
|
160
196
|
result = self._run_tmux(
|
|
161
197
|
"capture-pane", "-t", self._task_target(task_id),
|
|
162
|
-
"-p", "-S", "-",
|
|
198
|
+
"-p", "-S", "-", "-e",
|
|
163
199
|
)
|
|
164
200
|
return result.stdout
|
|
165
201
|
|
|
@@ -176,6 +212,14 @@ class TmuxOrchestrator:
|
|
|
176
212
|
def send_interrupt(self, task_id: str):
|
|
177
213
|
self._run_tmux("send-keys", "-t", self._task_target(task_id), "Escape")
|
|
178
214
|
|
|
215
|
+
def window_count(self) -> int:
|
|
216
|
+
result = self._run_tmux(
|
|
217
|
+
"list-windows", "-t", self._session_target, "-F", "#{window_name}",
|
|
218
|
+
)
|
|
219
|
+
if result.stdout.strip():
|
|
220
|
+
return len(result.stdout.strip().split("\n"))
|
|
221
|
+
return 0
|
|
222
|
+
|
|
179
223
|
def kill_task(self, task_id: str):
|
|
180
224
|
self._run_tmux("kill-window", "-t", self._task_target(task_id))
|
|
181
225
|
if task_id in self.tasks:
|
|
@@ -197,6 +241,16 @@ class TmuxOrchestrator:
|
|
|
197
241
|
if task["status"] == "running" and task_id not in active_windows:
|
|
198
242
|
task["status"] = "done"
|
|
199
243
|
self._save_task(task)
|
|
244
|
+
elif task["status"] == "running" and task_id in active_windows:
|
|
245
|
+
pane = self._run_tmux(
|
|
246
|
+
"capture-pane", "-t", self._task_target(task_id), "-p",
|
|
247
|
+
)
|
|
248
|
+
content_len = len(pane.stdout)
|
|
249
|
+
prev_len = self._last_content_length.get(task_id)
|
|
250
|
+
self._last_content_length[task_id] = content_len
|
|
251
|
+
if prev_len is not None and content_len != prev_len:
|
|
252
|
+
task["last_activity"] = datetime.now(timezone.utc).isoformat()
|
|
253
|
+
self._save_task(task)
|
|
200
254
|
|
|
201
255
|
def rediscover_tasks(self):
|
|
202
256
|
"""Rediscover tasks from existing tmux windows on server restart."""
|
|
@@ -212,16 +266,28 @@ class TmuxOrchestrator:
|
|
|
212
266
|
if window_id:
|
|
213
267
|
self._window_ids[window_name] = window_id
|
|
214
268
|
if window_name not in self.tasks:
|
|
215
|
-
is_managed = window_name.startswith("task-")
|
|
269
|
+
is_managed = window_name.startswith("task-") or window_name == "onako-main"
|
|
270
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
216
271
|
task = {
|
|
217
272
|
"id": window_name,
|
|
218
273
|
"prompt": "(rediscovered)" if is_managed else window_name,
|
|
219
274
|
"status": "running",
|
|
220
275
|
"started_at": None,
|
|
221
276
|
"origin": "managed" if is_managed else "external",
|
|
277
|
+
"last_activity": now,
|
|
222
278
|
}
|
|
223
279
|
self.tasks[window_name] = task
|
|
224
280
|
self._save_task(task)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
self.
|
|
281
|
+
else:
|
|
282
|
+
changed = False
|
|
283
|
+
if self.tasks[window_name]["status"] == "done":
|
|
284
|
+
self.tasks[window_name]["status"] = "running"
|
|
285
|
+
changed = True
|
|
286
|
+
if window_name == "onako-main" and self.tasks[window_name]["origin"] == "external":
|
|
287
|
+
self.tasks[window_name]["origin"] = "managed"
|
|
288
|
+
changed = True
|
|
289
|
+
if not self.tasks[window_name].get("last_activity"):
|
|
290
|
+
self.tasks[window_name]["last_activity"] = datetime.now(timezone.utc).isoformat()
|
|
291
|
+
changed = True
|
|
292
|
+
if changed:
|
|
293
|
+
self._save_task(self.tasks[window_name])
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
onako/__init__.py,sha256=6G_giX6Ucuweo7w5OiftoXmbNLoqiU_soXJoU8aiLmY,22
|
|
2
|
+
onako/cli.py,sha256=sQgN07VmqF9yklWQrbEdc1PE7PsI94oAJIELiaJdVfI,10233
|
|
3
|
+
onako/server.py,sha256=UL51fROpjQirh8Rwi9WFRPv9xmITpDKrdD8_xsMNc-8,3420
|
|
4
|
+
onako/tmux_orchestrator.py,sha256=yIgnu8T9CmdW9B91JjWJxA-nX51TmMpaPHRgflKnvs0,11703
|
|
5
|
+
onako/static/index.html,sha256=_966Z2fdyJjF0N_TJdvex1Hyo14HBhmhQI_CVTXUKMo,32637
|
|
6
|
+
onako-0.4.4.dist-info/METADATA,sha256=Uu8bxV-TfTpj7PTdNa5G_psnI4kvIfmwz5XMhVxO30I,2175
|
|
7
|
+
onako-0.4.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
8
|
+
onako-0.4.4.dist-info/entry_points.txt,sha256=51KRJzuVpr69iT_k4JO0Lj3DQv_HbgtGjTBTev13JAQ,41
|
|
9
|
+
onako-0.4.4.dist-info/top_level.txt,sha256=EZsc5qq2paM9GTbaFE9Xar4B5wFdfIqK9l_bDQVcmZ4,6
|
|
10
|
+
onako-0.4.4.dist-info/RECORD,,
|
onako-0.4.2.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
onako/__init__.py,sha256=6hfVa12Q-nXyUEXr6SyKpqPEDJW6vlRHyPxlA27PfTs,22
|
|
2
|
-
onako/cli.py,sha256=_-dWcf7fVD3QmN6wIzbFLydEVX2pzGs2cKBwP6SZWtY,7932
|
|
3
|
-
onako/server.py,sha256=NfP2CaecxAzuq0tSIaatfYX3d98EuLfhIwK00oHNjro,2883
|
|
4
|
-
onako/tmux_orchestrator.py,sha256=QShgrLf0DtYrgXq3a4FxLIBCnq_CZ6skNNuiXcHQvCk,8461
|
|
5
|
-
onako/static/index.html,sha256=ioombLZg8uhVHL64saB50ikYBUYNDgw5yClA-QoNxEo,16802
|
|
6
|
-
onako-0.4.2.dist-info/METADATA,sha256=1v84RX4BpHeleN7txOm_R2Vy_zFZ4ccSkEhxpbAtFxo,2175
|
|
7
|
-
onako-0.4.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
8
|
-
onako-0.4.2.dist-info/entry_points.txt,sha256=51KRJzuVpr69iT_k4JO0Lj3DQv_HbgtGjTBTev13JAQ,41
|
|
9
|
-
onako-0.4.2.dist-info/top_level.txt,sha256=EZsc5qq2paM9GTbaFE9Xar4B5wFdfIqK9l_bDQVcmZ4,6
|
|
10
|
-
onako-0.4.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|