onako 0.4.3__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.3"
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,11 +117,13 @@ 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.")
@@ -146,10 +150,12 @@ def purge(session):
146
150
  try:
147
151
  pid = int(PID_FILE.read_text().strip())
148
152
  os.kill(pid, 15)
149
- click.echo(f"Stopped onako server (pid {pid}).")
153
+ dev = " (dev)" if DEV_MARKER.exists() else ""
154
+ click.echo(f"Stopped onako server{dev} (pid {pid}).")
150
155
  except (ValueError, ProcessLookupError):
151
156
  pass
152
157
  PID_FILE.unlink(missing_ok=True)
158
+ DEV_MARKER.unlink(missing_ok=True)
153
159
 
154
160
  # Kill the tmux session
155
161
  result = subprocess.run(
@@ -182,7 +188,8 @@ def status():
182
188
  r = urllib.request.urlopen("http://127.0.0.1:8787/health", timeout=2)
183
189
  data = r.read().decode()
184
190
  if '"ok"' in data:
185
- click.echo("Onako server: running")
191
+ mode = " (dev)" if DEV_MARKER.exists() else ""
192
+ click.echo(f"Onako server: running{mode}")
186
193
  click.echo(" URL: http://127.0.0.1:8787")
187
194
  else:
188
195
  click.echo("Onako server: not responding correctly")
@@ -209,7 +216,8 @@ def _start_server(host, port, session, working_dir, skip_permissions=False):
209
216
  Returns True if the server was started or is already running.
210
217
  """
211
218
  if _is_server_running():
212
- 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()})")
213
221
  return True
214
222
 
215
223
  LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -234,9 +242,15 @@ def _start_server(host, port, session, working_dir, skip_permissions=False):
234
242
  PID_FILE.parent.mkdir(parents=True, exist_ok=True)
235
243
  PID_FILE.write_text(str(proc.pid))
236
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 ""
237
251
  local_ip = _get_local_ip()
238
252
  banner = [
239
- f"Onako server started (pid {proc.pid})",
253
+ f"Onako server started{dev} (pid {proc.pid})",
240
254
  f" Dashboard: http://{host}:{port}",
241
255
  ]
242
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;
@@ -72,6 +99,40 @@
72
99
  padding: 48px 16px;
73
100
  font-size: 14px;
74
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
+ }
75
136
  #connection-banner {
76
137
  display: none;
77
138
  background: #ef4444;
@@ -127,16 +188,38 @@
127
188
  font-size: 12px;
128
189
  }
129
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; }
130
201
  #output {
131
202
  padding: 12px;
132
203
  font-family: "SF Mono", "Menlo", "Monaco", monospace;
133
204
  font-size: 12px;
134
- white-space: pre-wrap;
135
- word-break: break-all;
136
- line-height: 1.4;
205
+ line-height: 1.5;
137
206
  overflow-y: auto;
138
207
  flex: 1;
139
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
+ }
140
223
  #message-bar {
141
224
  padding: 8px;
142
225
  padding-bottom: max(8px, env(safe-area-inset-bottom));
@@ -271,6 +354,15 @@
271
354
  <h1>Onako</h1>
272
355
  <button id="new-task-btn">+ New Task</button>
273
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>
274
366
  <div class="task-list" id="task-list"></div>
275
367
  </div>
276
368
 
@@ -280,6 +372,7 @@
280
372
  <button id="back-btn">&larr; Back</button>
281
373
  <span id="detail-task-id"></span>
282
374
  <div style="display:flex;gap:8px">
375
+ <button id="resume-detail-btn" class="hidden">Resume</button>
283
376
  <button id="interrupt-btn">Interrupt</button>
284
377
  <button id="kill-btn">Kill</button>
285
378
  </div>
@@ -337,35 +430,90 @@
337
430
  const res = await fetch(`${API}/health`);
338
431
  const data = await res.json();
339
432
  skipPermissionsDefault = data.skip_permissions || false;
340
- 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;
341
436
  } catch (e) {}
342
437
  }
343
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
+
344
456
  async function loadTasks() {
345
457
  try {
346
458
  const res = await fetch(`${API}/tasks`);
347
- 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);
348
485
  const list = document.getElementById('task-list');
349
- if (tasks.length === 0) {
350
- 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>';
351
489
  } else {
352
- list.innerHTML = tasks.map(t => `
353
- <div class="task-item${currentTaskId === t.id ? ' selected' : ''}" onclick="showTask('${t.id}')">
354
- <div class="task-id">${t.id}${t.origin === 'external' ? '<span class="origin-badge">external</span>' : ''}</div>
355
- <div class="task-prompt">${escapeHtml(t.prompt)}</div>
356
- <div class="task-meta">
357
- <span class="status-${t.status}">${t.status}</span>
358
- ${t.started_at ? ' &middot; ' + timeAgo(t.started_at) : ''}
359
- </div>
360
- </div>
361
- `).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
+ }
362
501
  }
502
+ list.innerHTML = html;
363
503
  showConnectionError(false);
364
504
  } catch (e) {
365
505
  showConnectionError(true);
366
506
  }
367
507
  }
368
508
 
509
+ function toggleRecent() {
510
+ recentCollapsed = !recentCollapsed;
511
+ localStorage.setItem('recentCollapsed', recentCollapsed ? '1' : '0');
512
+ loadTasks();
513
+ }
514
+
515
+ let allTasksCache = [];
516
+
369
517
  async function showTask(id) {
370
518
  currentTaskId = id;
371
519
  currentTaskStatus = null;
@@ -382,16 +530,23 @@
382
530
  document.getElementById('list-view').classList.add('hidden');
383
531
  document.getElementById('detail-view').classList.add('active');
384
532
  }
385
- if (id === 'onako-main') {
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';
386
537
  document.getElementById('interrupt-btn').classList.add('hidden');
387
538
  document.getElementById('kill-btn').classList.add('hidden');
539
+ document.getElementById('resume-detail-btn').classList.toggle('hidden', !taskData.claude_session_id);
388
540
  } else {
389
541
  document.getElementById('interrupt-btn').classList.remove('hidden');
390
542
  document.getElementById('kill-btn').classList.remove('hidden');
543
+ document.getElementById('resume-detail-btn').classList.add('hidden');
391
544
  }
392
545
  if (pollInterval) clearInterval(pollInterval);
393
546
  await refreshOutput();
394
- pollInterval = setInterval(refreshOutput, 3000);
547
+ if (currentTaskStatus !== 'done') {
548
+ pollInterval = setInterval(refreshOutput, 3000);
549
+ }
395
550
  }
396
551
 
397
552
  async function refreshOutput() {
@@ -402,7 +557,11 @@
402
557
  const data = await res.json();
403
558
  const el = document.getElementById('output');
404
559
  const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
405
- 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
+ }
406
565
  if (wasAtBottom) el.scrollTop = el.scrollHeight;
407
566
  showConnectionError(false);
408
567
 
@@ -411,10 +570,12 @@
411
570
  currentTaskStatus = 'done';
412
571
  document.getElementById('interrupt-btn').classList.add('hidden');
413
572
  document.getElementById('kill-btn').classList.add('hidden');
573
+ document.getElementById('resume-detail-btn').classList.toggle('hidden', !data.claude_session_id);
414
574
  if (pollInterval) {
415
575
  clearInterval(pollInterval);
416
576
  pollInterval = null;
417
577
  }
578
+ loadTasks();
418
579
  }
419
580
  } catch (e) {
420
581
  showConnectionError(true);
@@ -465,17 +626,39 @@
465
626
  if (!currentTaskId) return;
466
627
  if (!confirm('Kill this task?')) return;
467
628
  try {
468
- 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
+ }
469
635
  showList();
470
636
  } catch (e) {
471
637
  showConnectionError(true);
472
638
  }
473
639
  }
474
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
+
475
657
  function showModal() {
476
658
  document.getElementById('modal').style.display = 'block';
477
659
  document.getElementById('modal-overlay').style.display = 'block';
478
- 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;
479
662
  document.getElementById('prompt-input').focus();
480
663
  }
481
664
 
@@ -489,6 +672,7 @@
489
672
  if (!prompt) return;
490
673
  const workdir = document.getElementById('workdir-input').value.trim() || null;
491
674
  const skipPerms = document.getElementById('skip-perms-input').checked;
675
+ localStorage.setItem('skipPermissions', skipPerms ? '1' : '0');
492
676
  const body = {prompt, skip_permissions: skipPerms};
493
677
  if (workdir) body.working_dir = workdir;
494
678
  try {
@@ -514,6 +698,110 @@
514
698
  return d.innerHTML;
515
699
  }
516
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
+
517
805
  // Event listeners
518
806
  document.getElementById('new-task-btn').addEventListener('click', showModal);
519
807
  document.getElementById('modal-overlay').addEventListener('click', hideModal);
@@ -521,6 +809,9 @@
521
809
  document.getElementById('back-btn').addEventListener('click', showList);
522
810
  document.getElementById('interrupt-btn').addEventListener('click', interruptTask);
523
811
  document.getElementById('kill-btn').addEventListener('click', killTask);
812
+ document.getElementById('resume-detail-btn').addEventListener('click', () => {
813
+ if (currentTaskId) resumeTask(currentTaskId);
814
+ });
524
815
  document.getElementById('send-btn').addEventListener('click', sendMessage);
525
816
  document.getElementById('message-input').addEventListener('keydown', e => {
526
817
  if (e.key === 'Enter') sendMessage();
@@ -529,7 +820,15 @@
529
820
  if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submitTask();
530
821
  });
531
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
+
532
829
  // Init
830
+ const savedSort = localStorage.getItem('onakoSort');
831
+ if (savedSort) document.getElementById('sort-select').value = savedSort;
533
832
  loadConfig();
534
833
  loadTasks();
535
834
  setInterval(() => { if (!currentTaskId || isDesktop()) loadTasks(); }, 10000);
@@ -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(
@@ -118,34 +128,56 @@ class TmuxOrchestrator:
118
128
  "send-keys", "-t", self._task_target(task_id),
119
129
  "Enter",
120
130
  )
131
+ now = datetime.now(timezone.utc).isoformat()
121
132
  task = {
122
133
  "id": task_id,
123
134
  "prompt": prompt or command,
124
135
  "status": "running",
125
- "started_at": datetime.now(timezone.utc).isoformat(),
136
+ "started_at": now,
126
137
  "origin": "managed",
138
+ "last_activity": now,
139
+ "claude_session_id": claude_session_id,
127
140
  }
128
141
  self.tasks[task_id] = task
129
142
  self._save_task(task)
130
143
  return task
131
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
+
132
163
  def list_tasks(self) -> list[dict]:
133
164
  self.rediscover_tasks()
134
165
  self._sync_task_status()
135
166
  return list(self.tasks.values())
136
167
 
168
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
169
+
137
170
  def get_output(self, task_id: str) -> str:
138
171
  raw = self.get_raw_output(task_id)
139
- cleaned = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", raw)
140
- return self._strip_claude_chrome(cleaned)
172
+ return self._strip_claude_chrome(raw)
141
173
 
142
- @staticmethod
143
- def _strip_claude_chrome(text: str) -> str:
174
+ @classmethod
175
+ def _strip_claude_chrome(cls, text: str) -> str:
144
176
  lines = text.split("\n")
145
177
  # Strip from the bottom: Claude Code's TUI draws box-drawing chars,
146
178
  # the › prompt, and status lines like "accept edits on..."
147
179
  while lines:
148
- line = lines[-1].strip()
180
+ line = cls._ANSI_RE.sub("", lines[-1]).strip()
149
181
  if (
150
182
  not line
151
183
  or all(c in "─━╭╮╰╯│┃┌┐└┘├┤┬┴┼╋═║ ›❯▸▶" for c in line)
@@ -163,7 +195,7 @@ class TmuxOrchestrator:
163
195
  def get_raw_output(self, task_id: str) -> str:
164
196
  result = self._run_tmux(
165
197
  "capture-pane", "-t", self._task_target(task_id),
166
- "-p", "-S", "-",
198
+ "-p", "-S", "-", "-e",
167
199
  )
168
200
  return result.stdout
169
201
 
@@ -180,6 +212,14 @@ class TmuxOrchestrator:
180
212
  def send_interrupt(self, task_id: str):
181
213
  self._run_tmux("send-keys", "-t", self._task_target(task_id), "Escape")
182
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
+
183
223
  def kill_task(self, task_id: str):
184
224
  self._run_tmux("kill-window", "-t", self._task_target(task_id))
185
225
  if task_id in self.tasks:
@@ -201,6 +241,16 @@ class TmuxOrchestrator:
201
241
  if task["status"] == "running" and task_id not in active_windows:
202
242
  task["status"] = "done"
203
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)
204
254
 
205
255
  def rediscover_tasks(self):
206
256
  """Rediscover tasks from existing tmux windows on server restart."""
@@ -216,16 +266,28 @@ class TmuxOrchestrator:
216
266
  if window_id:
217
267
  self._window_ids[window_name] = window_id
218
268
  if window_name not in self.tasks:
219
- 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()
220
271
  task = {
221
272
  "id": window_name,
222
273
  "prompt": "(rediscovered)" if is_managed else window_name,
223
274
  "status": "running",
224
275
  "started_at": None,
225
276
  "origin": "managed" if is_managed else "external",
277
+ "last_activity": now,
226
278
  }
227
279
  self.tasks[window_name] = task
228
280
  self._save_task(task)
229
- elif self.tasks[window_name]["status"] == "done":
230
- self.tasks[window_name]["status"] = "running"
231
- 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.3
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=Nyg0pmk5ea9-SLCAFEIF96ByFx4-TJFtrqYPN-Zn6g4,22
2
- onako/cli.py,sha256=wcrpaTA1GZNlLghBmrgEnuYzQIDyBte0NqpLhY0mX6w,9541
3
- onako/server.py,sha256=NfP2CaecxAzuq0tSIaatfYX3d98EuLfhIwK00oHNjro,2883
4
- onako/tmux_orchestrator.py,sha256=5RPtZ3B0dBeKMEJjYLI54AIJaOVncSp6los2CJ65szA,8572
5
- onako/static/index.html,sha256=6fXfObv2Gtj3A7yBWYtiee-fdfDXtTNl092i7PiRebY,19383
6
- onako-0.4.3.dist-info/METADATA,sha256=mfgLx5hEXot1M83Xe93J8LY0srayEwQ4z41raSTfhMY,2175
7
- onako-0.4.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
- onako-0.4.3.dist-info/entry_points.txt,sha256=51KRJzuVpr69iT_k4JO0Lj3DQv_HbgtGjTBTev13JAQ,41
9
- onako-0.4.3.dist-info/top_level.txt,sha256=EZsc5qq2paM9GTbaFE9Xar4B5wFdfIqK9l_bDQVcmZ4,6
10
- onako-0.4.3.dist-info/RECORD,,
File without changes