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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.4.2"
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
- click.echo(f"Onako server stopped (pid {pid}).")
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
- click.echo("Onako server: running")
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
- click.echo(f"Onako server already running (pid {PID_FILE.read_text().strip()})")
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 task_id == "onako-main":
92
- raise HTTPException(status_code=400, detail="Cannot kill the main window")
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
- white-space: pre-wrap;
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
- <!-- List View -->
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">&larr; Back</button>
233
373
  <span id="detail-task-id"></span>
234
- <button id="interrupt-btn">Interrupt</button>
235
- <button id="kill-btn">Kill</button>
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
- document.getElementById('skip-perms-input').checked = skipPermissionsDefault;
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' : '&#10003;'}</span>
450
+ ${t.started_at ? ' &middot; started ' + timeAgo(t.started_at) : ''}
451
+ ${t.last_activity ? ' &middot; 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 tasks = (await res.json()).filter(t => t.status === 'running');
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
- if (tasks.length === 0) {
294
- list.innerHTML = '<div class="empty-state">No tasks running</div>';
486
+ let html = '';
487
+ if (running.length === 0 && done.length === 0) {
488
+ html = '<div class="empty-state">No tasks</div>';
295
489
  } else {
296
- list.innerHTML = tasks.map(t => `
297
- <div class="task-item" onclick="showTask('${t.id}')">
298
- <div class="task-id">${t.id}${t.origin === 'external' ? '<span class="origin-badge">external</span>' : ''}</div>
299
- <div class="task-prompt">${escapeHtml(t.prompt)}</div>
300
- <div class="task-meta">
301
- <span class="status-${t.status}">${t.status}</span>
302
- ${t.started_at ? ' &middot; ' + timeAgo(t.started_at) : ''}
303
- </div>
304
- </div>
305
- `).join('');
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">&#9660;</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 (id === 'onako-main') {
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
- pollInterval = setInterval(refreshOutput, 3000);
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
- el.textContent = data.output || '(no output yet)';
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
- document.getElementById('detail-view').classList.remove('active');
363
- document.getElementById('list-view').classList.remove('hidden');
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
- document.getElementById('skip-perms-input').checked = skipPermissionsDefault;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>
@@ -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 the origin column
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
- command, "Enter",
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": datetime.now(timezone.utc).isoformat(),
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
- cleaned = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", raw)
136
- return self._strip_claude_chrome(cleaned)
172
+ return self._strip_claude_chrome(raw)
137
173
 
138
- @staticmethod
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
- elif self.tasks[window_name]["status"] == "done":
226
- self.tasks[window_name]["status"] = "running"
227
- self._save_task(self.tasks[window_name])
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])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: onako
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Dispatch and monitor Claude Code tasks from your phone
5
5
  Author: Amir
6
6
  License-Expression: MIT
@@ -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,,
@@ -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