delimit-cli 4.1.42 → 4.1.44
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.
- package/CHANGELOG.md +27 -0
- package/README.md +46 -5
- package/bin/delimit-cli.js +1523 -208
- package/bin/delimit-setup.js +8 -2
- package/gateway/ai/agent_dispatch.py +34 -2
- package/gateway/ai/backends/deploy_bridge.py +167 -12
- package/gateway/ai/content_engine.py +1276 -2
- package/gateway/ai/github_scanner.py +1 -1
- package/gateway/ai/governance.py +58 -0
- package/gateway/ai/key_resolver.py +95 -2
- package/gateway/ai/ledger_manager.py +13 -3
- package/gateway/ai/loop_engine.py +220 -349
- package/gateway/ai/notify.py +1786 -2
- package/gateway/ai/reddit_scanner.py +45 -1
- package/gateway/ai/screen_record.py +1 -1
- package/gateway/ai/secrets_broker.py +5 -1
- package/gateway/ai/social_cache.py +341 -0
- package/gateway/ai/social_daemon.py +312 -18
- package/gateway/ai/supabase_sync.py +190 -2
- package/gateway/ai/tui.py +594 -36
- package/gateway/core/zero_spec/express_extractor.py +2 -2
- package/gateway/core/zero_spec/nestjs_extractor.py +40 -9
- package/gateway/requirements.txt +3 -6
- package/package.json +4 -3
- package/scripts/demo-v420-clean.sh +267 -0
- package/scripts/demo-v420-deliberation.sh +217 -0
- package/scripts/demo-v420.sh +55 -0
- package/scripts/postinstall.js +4 -3
- package/scripts/publish-ci-guard.sh +30 -0
- package/scripts/record-and-upload.sh +132 -0
- package/scripts/release.sh +126 -0
- package/scripts/sync-gateway.sh +100 -0
- package/scripts/youtube-upload.py +141 -0
package/gateway/ai/tui.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
"""Delimit TUI — Terminal User Interface (Phase
|
|
1
|
+
"""Delimit TUI — Terminal User Interface (Phase 5 of Delimit OS).
|
|
2
2
|
|
|
3
3
|
The proprietary terminal experience. Type 'delimit' and get an OS-like
|
|
4
|
-
environment with panels for ledger, swarm,
|
|
4
|
+
environment with panels for ledger, swarm, notifications, filesystem,
|
|
5
|
+
process manager, and live logs.
|
|
5
6
|
|
|
6
7
|
Enterprise-ready: zero JS, pure Python, works over SSH, sub-2s boot.
|
|
7
8
|
Designed for devs who hate browser-based tools.
|
|
@@ -12,29 +13,40 @@ Usage:
|
|
|
12
13
|
"""
|
|
13
14
|
|
|
14
15
|
from textual.app import App, ComposeResult
|
|
15
|
-
from textual.containers import Container, Horizontal, Vertical
|
|
16
|
+
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
|
16
17
|
from textual.widgets import (
|
|
17
18
|
Header, Footer, Static, DataTable, Log, TabbedContent, TabPane,
|
|
18
|
-
Label, ProgressBar, Button, Input,
|
|
19
|
+
Label, ProgressBar, Button, Input, Tree, RichLog,
|
|
19
20
|
)
|
|
20
21
|
from textual.timer import Timer
|
|
21
22
|
from textual import work
|
|
23
|
+
from textual.binding import Binding
|
|
22
24
|
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
23
27
|
import time
|
|
28
|
+
from datetime import datetime, timezone
|
|
24
29
|
from pathlib import Path
|
|
25
|
-
from typing import Any, Dict, List
|
|
30
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
31
|
|
|
27
32
|
|
|
28
|
-
#
|
|
33
|
+
# -- Data paths ---------------------------------------------------------------
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
DELIMIT_HOME = Path.home() / ".delimit"
|
|
36
|
+
LEDGER_DIR = DELIMIT_HOME / "ledger"
|
|
37
|
+
SWARM_DIR = DELIMIT_HOME / "swarm"
|
|
38
|
+
MEMORY_DIR = DELIMIT_HOME / "memory"
|
|
39
|
+
SESSIONS_DIR = DELIMIT_HOME / "sessions"
|
|
40
|
+
NOTIFICATIONS_FILE = DELIMIT_HOME / "notifications.jsonl"
|
|
41
|
+
DAEMON_STATE_FILE = DELIMIT_HOME / "daemon" / "state.json"
|
|
42
|
+
DAEMON_LOG_FILE = DELIMIT_HOME / "daemon" / "daemon.log.jsonl"
|
|
43
|
+
ALERTS_DIR = DELIMIT_HOME / "alerts"
|
|
34
44
|
|
|
35
45
|
|
|
46
|
+
# -- Data loaders -------------------------------------------------------------
|
|
47
|
+
|
|
36
48
|
def _load_ledger_items(status: str = "open", limit: int = 20) -> List[Dict]:
|
|
37
|
-
|
|
49
|
+
"""Load deduplicated ledger items (append-only JSONL, last entry wins)."""
|
|
38
50
|
by_id: Dict[str, Dict] = {}
|
|
39
51
|
for fname in ("operations.jsonl", "strategy.jsonl"):
|
|
40
52
|
path = LEDGER_DIR / fname
|
|
@@ -82,10 +94,185 @@ def _load_recent_sessions(limit: int = 5) -> List[Dict]:
|
|
|
82
94
|
return sessions
|
|
83
95
|
|
|
84
96
|
|
|
85
|
-
|
|
97
|
+
def _load_notifications(limit: int = 50) -> List[Dict]:
|
|
98
|
+
"""Load recent notifications from JSONL, newest first."""
|
|
99
|
+
if not NOTIFICATIONS_FILE.exists():
|
|
100
|
+
return []
|
|
101
|
+
# Read last N lines efficiently (tail)
|
|
102
|
+
lines: List[str] = []
|
|
103
|
+
try:
|
|
104
|
+
with open(NOTIFICATIONS_FILE, "rb") as f:
|
|
105
|
+
# Seek from end to find last `limit` lines
|
|
106
|
+
f.seek(0, 2)
|
|
107
|
+
fsize = f.tell()
|
|
108
|
+
# Read at most 64KB from the end — enough for 50 notifications
|
|
109
|
+
read_size = min(fsize, 65536)
|
|
110
|
+
f.seek(fsize - read_size)
|
|
111
|
+
data = f.read().decode("utf-8", errors="replace")
|
|
112
|
+
lines = data.strip().split("\n")
|
|
113
|
+
except (OSError, UnicodeDecodeError):
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
notifications = []
|
|
117
|
+
for line in reversed(lines[-limit:]):
|
|
118
|
+
try:
|
|
119
|
+
notifications.append(json.loads(line))
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
continue
|
|
122
|
+
return notifications
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _load_daemon_state() -> Dict[str, Any]:
|
|
126
|
+
"""Load inbox daemon state."""
|
|
127
|
+
if not DAEMON_STATE_FILE.exists():
|
|
128
|
+
return {"status": "unknown"}
|
|
129
|
+
try:
|
|
130
|
+
return json.loads(DAEMON_STATE_FILE.read_text())
|
|
131
|
+
except (json.JSONDecodeError, OSError):
|
|
132
|
+
return {"status": "unknown"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _load_process_list() -> List[Dict[str, Any]]:
|
|
136
|
+
"""Build a list of known daemons with status from state files and alerts."""
|
|
137
|
+
processes = []
|
|
138
|
+
|
|
139
|
+
# 1. Inbox daemon — primary daemon
|
|
140
|
+
daemon = _load_daemon_state()
|
|
141
|
+
started = daemon.get("started_at", "")
|
|
142
|
+
last_loop = daemon.get("last_loop_at", "")
|
|
143
|
+
loops = daemon.get("loops", 0)
|
|
144
|
+
items_proc = daemon.get("items_processed", 0)
|
|
145
|
+
status = daemon.get("status", "unknown")
|
|
146
|
+
|
|
147
|
+
# Check for alert overrides
|
|
148
|
+
alert_file = ALERTS_DIR / "inbox_daemon.json"
|
|
149
|
+
if alert_file.exists():
|
|
150
|
+
try:
|
|
151
|
+
alert = json.loads(alert_file.read_text())
|
|
152
|
+
if alert.get("alert") == "inbox_daemon_stopped":
|
|
153
|
+
status = "stopped (alert)"
|
|
154
|
+
except (json.JSONDecodeError, OSError):
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
uptime = ""
|
|
158
|
+
if started and status in ("running", "idle"):
|
|
159
|
+
try:
|
|
160
|
+
start_dt = datetime.fromisoformat(started)
|
|
161
|
+
delta = datetime.now(timezone.utc) - start_dt
|
|
162
|
+
hours = int(delta.total_seconds() // 3600)
|
|
163
|
+
minutes = int((delta.total_seconds() % 3600) // 60)
|
|
164
|
+
uptime = f"{hours}h {minutes}m"
|
|
165
|
+
except (ValueError, TypeError):
|
|
166
|
+
uptime = "?"
|
|
167
|
+
|
|
168
|
+
processes.append({
|
|
169
|
+
"name": "inbox_daemon",
|
|
170
|
+
"label": "Inbox Daemon",
|
|
171
|
+
"status": status,
|
|
172
|
+
"uptime": uptime,
|
|
173
|
+
"detail": f"loops={loops} processed={items_proc}",
|
|
174
|
+
"last_action": last_loop[:19] if last_loop else "",
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
# 2. Social scanner — check cron.log and social_drafts for activity
|
|
178
|
+
social_status = "inactive"
|
|
179
|
+
social_last = ""
|
|
180
|
+
social_detail = ""
|
|
181
|
+
cron_log = DELIMIT_HOME / "cron.log"
|
|
182
|
+
if cron_log.exists():
|
|
183
|
+
try:
|
|
184
|
+
# Read last 2KB to find recent social scan entries
|
|
185
|
+
with open(cron_log, "rb") as f:
|
|
186
|
+
f.seek(0, 2)
|
|
187
|
+
fsize = f.tell()
|
|
188
|
+
read_size = min(fsize, 2048)
|
|
189
|
+
f.seek(fsize - read_size)
|
|
190
|
+
tail = f.read().decode("utf-8", errors="replace")
|
|
191
|
+
# Look for social scan references
|
|
192
|
+
for line in reversed(tail.strip().split("\n")):
|
|
193
|
+
if "social" in line.lower() or "scan" in line.lower():
|
|
194
|
+
social_status = "active"
|
|
195
|
+
social_last = line[:19] if len(line) > 19 else line
|
|
196
|
+
social_detail = line.strip()[:60]
|
|
197
|
+
break
|
|
198
|
+
except (OSError, UnicodeDecodeError):
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
processes.append({
|
|
202
|
+
"name": "social_scanner",
|
|
203
|
+
"label": "Social Scanner",
|
|
204
|
+
"status": social_status,
|
|
205
|
+
"uptime": "",
|
|
206
|
+
"detail": social_detail,
|
|
207
|
+
"last_action": social_last,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
# 3. Ledger watcher — check if ledger files were recently modified
|
|
211
|
+
ledger_status = "inactive"
|
|
212
|
+
ledger_last = ""
|
|
213
|
+
for fname in ("operations.jsonl", "strategy.jsonl"):
|
|
214
|
+
lpath = LEDGER_DIR / fname
|
|
215
|
+
if lpath.exists():
|
|
216
|
+
mtime = lpath.stat().st_mtime
|
|
217
|
+
age_hours = (time.time() - mtime) / 3600
|
|
218
|
+
if age_hours < 1:
|
|
219
|
+
ledger_status = "active"
|
|
220
|
+
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
221
|
+
if not ledger_last or ts > ledger_last:
|
|
222
|
+
ledger_last = ts
|
|
223
|
+
|
|
224
|
+
processes.append({
|
|
225
|
+
"name": "ledger_watcher",
|
|
226
|
+
"label": "Ledger Watcher",
|
|
227
|
+
"status": ledger_status,
|
|
228
|
+
"uptime": "",
|
|
229
|
+
"detail": "monitors operations + strategy",
|
|
230
|
+
"last_action": ledger_last,
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
# 4. Notification router
|
|
234
|
+
notif_status = "inactive"
|
|
235
|
+
notif_last = ""
|
|
236
|
+
if NOTIFICATIONS_FILE.exists():
|
|
237
|
+
mtime = NOTIFICATIONS_FILE.stat().st_mtime
|
|
238
|
+
age_hours = (time.time() - mtime) / 3600
|
|
239
|
+
if age_hours < 1:
|
|
240
|
+
notif_status = "active"
|
|
241
|
+
notif_last = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
242
|
+
|
|
243
|
+
processes.append({
|
|
244
|
+
"name": "notify_router",
|
|
245
|
+
"label": "Notification Router",
|
|
246
|
+
"status": notif_status,
|
|
247
|
+
"uptime": "",
|
|
248
|
+
"detail": f"routing via {DELIMIT_HOME / 'notify_routing.yaml'}",
|
|
249
|
+
"last_action": notif_last,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
return processes
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_dir_tree(root: Path, max_depth: int = 3, _depth: int = 0) -> List[Tuple[str, Path, bool]]:
|
|
256
|
+
"""Build a flat list of (name, path, is_dir) for the tree, respecting depth."""
|
|
257
|
+
if _depth > max_depth or not root.is_dir():
|
|
258
|
+
return []
|
|
259
|
+
entries = []
|
|
260
|
+
try:
|
|
261
|
+
children = sorted(root.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
262
|
+
except PermissionError:
|
|
263
|
+
return []
|
|
264
|
+
for child in children:
|
|
265
|
+
# Skip very large directories and hidden internals
|
|
266
|
+
if child.name.startswith("__") or child.name == "venv":
|
|
267
|
+
continue
|
|
268
|
+
entries.append((child.name, child, child.is_dir()))
|
|
269
|
+
return entries
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# -- Widgets ------------------------------------------------------------------
|
|
86
273
|
|
|
87
274
|
class LedgerPanel(Static):
|
|
88
|
-
"""Live ledger view
|
|
275
|
+
"""Live ledger view -- shows open items sorted by priority."""
|
|
89
276
|
|
|
90
277
|
def compose(self) -> ComposeResult:
|
|
91
278
|
yield DataTable(id="ledger-table")
|
|
@@ -110,7 +297,7 @@ class LedgerPanel(Static):
|
|
|
110
297
|
|
|
111
298
|
|
|
112
299
|
class SwarmPanel(Static):
|
|
113
|
-
"""Swarm status
|
|
300
|
+
"""Swarm status -- agents, ventures, health."""
|
|
114
301
|
|
|
115
302
|
def compose(self) -> ComposeResult:
|
|
116
303
|
yield Static(id="swarm-content")
|
|
@@ -132,7 +319,7 @@ class SwarmPanel(Static):
|
|
|
132
319
|
|
|
133
320
|
|
|
134
321
|
class SessionPanel(Static):
|
|
135
|
-
"""Recent sessions
|
|
322
|
+
"""Recent sessions -- handoff history."""
|
|
136
323
|
|
|
137
324
|
def compose(self) -> ComposeResult:
|
|
138
325
|
yield Static(id="session-content")
|
|
@@ -151,14 +338,14 @@ class SessionPanel(Static):
|
|
|
151
338
|
ts = s.get("timestamp", s.get("closed_at", ""))[:16]
|
|
152
339
|
summary = s.get("summary", "")[:80]
|
|
153
340
|
completed = len(s.get("items_completed", []))
|
|
154
|
-
lines.append(f"[dim]{ts}[/]
|
|
341
|
+
lines.append(f"[dim]{ts}[/] -- {summary}")
|
|
155
342
|
if completed:
|
|
156
|
-
lines.append(f" [green]
|
|
343
|
+
lines.append(f" [green]{completed} items completed[/]")
|
|
157
344
|
content.update("\n".join(lines))
|
|
158
345
|
|
|
159
346
|
|
|
160
347
|
class VenturesPanel(Static):
|
|
161
|
-
"""Ventures as app tiles
|
|
348
|
+
"""Ventures as app tiles -- each venture is an 'app' in the OS."""
|
|
162
349
|
|
|
163
350
|
def compose(self) -> ComposeResult:
|
|
164
351
|
yield Static(id="ventures-content")
|
|
@@ -176,7 +363,6 @@ class VenturesPanel(Static):
|
|
|
176
363
|
content.update("[dim]No ventures registered. Run delimit_swarm(action='register').[/]")
|
|
177
364
|
return
|
|
178
365
|
|
|
179
|
-
# Count open items per venture
|
|
180
366
|
all_items = _load_ledger_items("open", 999)
|
|
181
367
|
venture_items = {}
|
|
182
368
|
for item in all_items:
|
|
@@ -184,11 +370,11 @@ class VenturesPanel(Static):
|
|
|
184
370
|
venture_items[v] = venture_items.get(v, 0) + 1
|
|
185
371
|
|
|
186
372
|
lines = [
|
|
187
|
-
"[bold]Ventures[/]
|
|
373
|
+
"[bold]Ventures[/] -- each venture is an app in Delimit OS\n",
|
|
188
374
|
]
|
|
189
375
|
for venture, agent_count in sorted(by_venture.items()):
|
|
190
376
|
open_count = venture_items.get(venture, venture_items.get(f"{venture}-mcp", 0))
|
|
191
|
-
status_icon = "[green]
|
|
377
|
+
status_icon = "[green]>[/]" if agent_count > 0 else "[red]o[/]"
|
|
192
378
|
lines.append(
|
|
193
379
|
f" {status_icon} [bold cyan]{venture}[/]"
|
|
194
380
|
f" | {agent_count} agents"
|
|
@@ -199,8 +385,330 @@ class VenturesPanel(Static):
|
|
|
199
385
|
content.update("\n".join(lines))
|
|
200
386
|
|
|
201
387
|
|
|
388
|
+
class NotificationPanel(Static):
|
|
389
|
+
"""Notification drawer -- recent events from notifications.jsonl."""
|
|
390
|
+
|
|
391
|
+
DEFAULT_CSS = """
|
|
392
|
+
NotificationPanel {
|
|
393
|
+
height: 1fr;
|
|
394
|
+
}
|
|
395
|
+
#notif-log {
|
|
396
|
+
height: 1fr;
|
|
397
|
+
padding: 0 1;
|
|
398
|
+
}
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def compose(self) -> ComposeResult:
|
|
402
|
+
yield Static("[bold]Notifications[/] [dim]Auto-refreshes every 30s[/]\n", id="notif-header")
|
|
403
|
+
yield RichLog(id="notif-log", highlight=True, markup=True, wrap=True)
|
|
404
|
+
|
|
405
|
+
def on_mount(self) -> None:
|
|
406
|
+
self._refresh_data()
|
|
407
|
+
self.set_interval(30, self._refresh_data)
|
|
408
|
+
|
|
409
|
+
def _refresh_data(self) -> None:
|
|
410
|
+
log = self.query_one("#notif-log", RichLog)
|
|
411
|
+
log.clear()
|
|
412
|
+
notifications = _load_notifications(50)
|
|
413
|
+
if not notifications:
|
|
414
|
+
log.write("[dim]No notifications yet.[/]")
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
for n in notifications:
|
|
418
|
+
ts = n.get("timestamp", "")[:19].replace("T", " ")
|
|
419
|
+
channel = n.get("channel", "?")
|
|
420
|
+
subject = n.get("subject", n.get("event_type", ""))
|
|
421
|
+
success = n.get("success", None)
|
|
422
|
+
reason = n.get("reason", "")
|
|
423
|
+
|
|
424
|
+
# Color-code by status
|
|
425
|
+
if success is True:
|
|
426
|
+
icon = "[green]OK[/]"
|
|
427
|
+
elif success is False:
|
|
428
|
+
icon = "[red]FAIL[/]"
|
|
429
|
+
else:
|
|
430
|
+
icon = "[yellow]--[/]"
|
|
431
|
+
|
|
432
|
+
line = f"[dim]{ts}[/] {icon} [{_channel_color(channel)}]{channel}[/]"
|
|
433
|
+
if subject:
|
|
434
|
+
line += f" {subject[:50]}"
|
|
435
|
+
if reason:
|
|
436
|
+
line += f" [dim]({reason})[/]"
|
|
437
|
+
log.write(line)
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def get_unread_count() -> int:
|
|
441
|
+
"""Count notifications from the last hour."""
|
|
442
|
+
if not NOTIFICATIONS_FILE.exists():
|
|
443
|
+
return 0
|
|
444
|
+
try:
|
|
445
|
+
mtime = NOTIFICATIONS_FILE.stat().st_mtime
|
|
446
|
+
age_hours = (time.time() - mtime) / 3600
|
|
447
|
+
if age_hours > 1:
|
|
448
|
+
return 0
|
|
449
|
+
# Count lines in last 4KB
|
|
450
|
+
with open(NOTIFICATIONS_FILE, "rb") as f:
|
|
451
|
+
f.seek(0, 2)
|
|
452
|
+
fsize = f.tell()
|
|
453
|
+
read_size = min(fsize, 4096)
|
|
454
|
+
f.seek(fsize - read_size)
|
|
455
|
+
data = f.read().decode("utf-8", errors="replace")
|
|
456
|
+
count = 0
|
|
457
|
+
cutoff = time.time() - 3600
|
|
458
|
+
for line in reversed(data.strip().split("\n")):
|
|
459
|
+
try:
|
|
460
|
+
n = json.loads(line)
|
|
461
|
+
ts = n.get("timestamp", "")
|
|
462
|
+
if ts:
|
|
463
|
+
dt = datetime.fromisoformat(ts)
|
|
464
|
+
if dt.timestamp() < cutoff:
|
|
465
|
+
break
|
|
466
|
+
count += 1
|
|
467
|
+
except (json.JSONDecodeError, ValueError):
|
|
468
|
+
continue
|
|
469
|
+
return count
|
|
470
|
+
except (OSError, UnicodeDecodeError):
|
|
471
|
+
return 0
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _channel_color(channel: str) -> str:
|
|
475
|
+
"""Return a rich color name for a notification channel."""
|
|
476
|
+
colors = {
|
|
477
|
+
"email": "cyan",
|
|
478
|
+
"social": "magenta",
|
|
479
|
+
"github": "white",
|
|
480
|
+
"deploy": "green",
|
|
481
|
+
"security": "red",
|
|
482
|
+
"test": "yellow",
|
|
483
|
+
}
|
|
484
|
+
return colors.get(channel, "white")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class FilesystemPanel(Static):
|
|
488
|
+
"""Filesystem browser -- navigate .delimit/ directory tree."""
|
|
489
|
+
|
|
490
|
+
DEFAULT_CSS = """
|
|
491
|
+
FilesystemPanel {
|
|
492
|
+
height: 1fr;
|
|
493
|
+
}
|
|
494
|
+
#fs-container {
|
|
495
|
+
height: 1fr;
|
|
496
|
+
}
|
|
497
|
+
#fs-tree {
|
|
498
|
+
width: 1fr;
|
|
499
|
+
min-width: 30;
|
|
500
|
+
height: 1fr;
|
|
501
|
+
}
|
|
502
|
+
#fs-preview {
|
|
503
|
+
width: 2fr;
|
|
504
|
+
height: 1fr;
|
|
505
|
+
padding: 0 1;
|
|
506
|
+
border-left: solid $primary;
|
|
507
|
+
}
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
def compose(self) -> ComposeResult:
|
|
511
|
+
with Horizontal(id="fs-container"):
|
|
512
|
+
yield Tree("[bold].delimit/[/]", id="fs-tree")
|
|
513
|
+
yield RichLog(id="fs-preview", highlight=True, markup=True, wrap=True)
|
|
514
|
+
|
|
515
|
+
def on_mount(self) -> None:
|
|
516
|
+
tree = self.query_one("#fs-tree", Tree)
|
|
517
|
+
tree.root.expand()
|
|
518
|
+
self._populate_tree(tree.root, DELIMIT_HOME, depth=0)
|
|
519
|
+
tree.root.expand()
|
|
520
|
+
|
|
521
|
+
def _populate_tree(self, node, path: Path, depth: int) -> None:
|
|
522
|
+
"""Populate tree nodes lazily up to depth 2."""
|
|
523
|
+
if depth > 2 or not path.is_dir():
|
|
524
|
+
return
|
|
525
|
+
entries = _build_dir_tree(path, max_depth=0)
|
|
526
|
+
for name, child_path, is_dir in entries:
|
|
527
|
+
if is_dir:
|
|
528
|
+
branch = node.add(f"[bold cyan]{name}/[/]", data=child_path)
|
|
529
|
+
# Add a placeholder so it shows as expandable
|
|
530
|
+
if depth < 2:
|
|
531
|
+
self._populate_tree(branch, child_path, depth + 1)
|
|
532
|
+
else:
|
|
533
|
+
# Show file size hint
|
|
534
|
+
try:
|
|
535
|
+
size = child_path.stat().st_size
|
|
536
|
+
size_str = _human_size(size)
|
|
537
|
+
except OSError:
|
|
538
|
+
size_str = "?"
|
|
539
|
+
node.add_leaf(f"{name} [dim]({size_str})[/]", data=child_path)
|
|
540
|
+
|
|
541
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
542
|
+
"""Preview file contents on selection."""
|
|
543
|
+
preview = self.query_one("#fs-preview", RichLog)
|
|
544
|
+
preview.clear()
|
|
545
|
+
|
|
546
|
+
path = event.node.data
|
|
547
|
+
if path is None:
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
if isinstance(path, Path) and path.is_file():
|
|
551
|
+
preview.write(f"[bold]{path.name}[/] [dim]{_human_size(path.stat().st_size)}[/]\n")
|
|
552
|
+
preview.write(f"[dim]{path}[/]\n")
|
|
553
|
+
preview.write("[dim]" + "-" * 60 + "[/]\n")
|
|
554
|
+
|
|
555
|
+
# Read file with size guard
|
|
556
|
+
try:
|
|
557
|
+
size = path.stat().st_size
|
|
558
|
+
if size > 102400: # 100KB limit
|
|
559
|
+
preview.write(f"[yellow]File too large to preview ({_human_size(size)}). Showing first 4KB.[/]\n\n")
|
|
560
|
+
content = path.read_bytes()[:4096].decode("utf-8", errors="replace")
|
|
561
|
+
elif path.suffix in (".json", ".jsonl", ".yml", ".yaml", ".txt", ".md", ".py", ".log", ".sh"):
|
|
562
|
+
content = path.read_text(errors="replace")
|
|
563
|
+
else:
|
|
564
|
+
preview.write(f"[dim]Binary file ({path.suffix}). Size: {_human_size(size)}[/]")
|
|
565
|
+
return
|
|
566
|
+
# For JSONL, show last 20 lines
|
|
567
|
+
if path.suffix == ".jsonl":
|
|
568
|
+
lines = content.strip().split("\n")
|
|
569
|
+
if len(lines) > 20:
|
|
570
|
+
preview.write(f"[dim]Showing last 20 of {len(lines)} lines[/]\n\n")
|
|
571
|
+
content = "\n".join(lines[-20:])
|
|
572
|
+
# Pretty-print JSON
|
|
573
|
+
if path.suffix == ".json":
|
|
574
|
+
try:
|
|
575
|
+
parsed = json.loads(content)
|
|
576
|
+
content = json.dumps(parsed, indent=2)
|
|
577
|
+
except json.JSONDecodeError:
|
|
578
|
+
pass
|
|
579
|
+
preview.write(content)
|
|
580
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
581
|
+
preview.write(f"[red]Error reading file: {e}[/]")
|
|
582
|
+
elif isinstance(path, Path) and path.is_dir():
|
|
583
|
+
preview.write(f"[bold]{path.name}/[/]\n")
|
|
584
|
+
try:
|
|
585
|
+
children = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
586
|
+
for c in children[:50]:
|
|
587
|
+
if c.is_dir():
|
|
588
|
+
preview.write(f" [cyan]{c.name}/[/]\n")
|
|
589
|
+
else:
|
|
590
|
+
preview.write(f" {c.name} [dim]({_human_size(c.stat().st_size)})[/]\n")
|
|
591
|
+
total = len(list(path.iterdir()))
|
|
592
|
+
if total > 50:
|
|
593
|
+
preview.write(f"\n[dim]... and {total - 50} more[/]")
|
|
594
|
+
except PermissionError:
|
|
595
|
+
preview.write("[red]Permission denied[/]")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _human_size(size: int) -> str:
|
|
599
|
+
"""Convert bytes to human-readable string."""
|
|
600
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
601
|
+
if size < 1024:
|
|
602
|
+
return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
|
|
603
|
+
size /= 1024
|
|
604
|
+
return f"{size:.1f}TB"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
class ProcessPanel(Static):
|
|
608
|
+
"""Process manager -- show running daemons with status and controls."""
|
|
609
|
+
|
|
610
|
+
DEFAULT_CSS = """
|
|
611
|
+
ProcessPanel {
|
|
612
|
+
height: 1fr;
|
|
613
|
+
}
|
|
614
|
+
#proc-table {
|
|
615
|
+
height: auto;
|
|
616
|
+
max-height: 50%;
|
|
617
|
+
}
|
|
618
|
+
#proc-detail {
|
|
619
|
+
height: 1fr;
|
|
620
|
+
padding: 1;
|
|
621
|
+
}
|
|
622
|
+
"""
|
|
623
|
+
|
|
624
|
+
def compose(self) -> ComposeResult:
|
|
625
|
+
yield DataTable(id="proc-table")
|
|
626
|
+
yield Static(id="proc-detail")
|
|
627
|
+
|
|
628
|
+
def on_mount(self) -> None:
|
|
629
|
+
table = self.query_one("#proc-table", DataTable)
|
|
630
|
+
table.add_columns("Name", "Status", "Uptime", "Last Action", "Detail")
|
|
631
|
+
table.cursor_type = "row"
|
|
632
|
+
self._refresh_data()
|
|
633
|
+
self.set_interval(15, self._refresh_data)
|
|
634
|
+
|
|
635
|
+
def _refresh_data(self) -> None:
|
|
636
|
+
table = self.query_one("#proc-table", DataTable)
|
|
637
|
+
detail = self.query_one("#proc-detail", Static)
|
|
638
|
+
table.clear()
|
|
639
|
+
|
|
640
|
+
processes = _load_process_list()
|
|
641
|
+
for proc in processes:
|
|
642
|
+
status = proc["status"]
|
|
643
|
+
if status in ("running", "active"):
|
|
644
|
+
status_display = f"[green]{status}[/]"
|
|
645
|
+
elif status in ("stopped", "stopped (alert)", "unknown"):
|
|
646
|
+
status_display = f"[red]{status}[/]"
|
|
647
|
+
else:
|
|
648
|
+
status_display = f"[yellow]{status}[/]"
|
|
649
|
+
|
|
650
|
+
table.add_row(
|
|
651
|
+
proc["label"],
|
|
652
|
+
status_display,
|
|
653
|
+
proc.get("uptime", ""),
|
|
654
|
+
proc.get("last_action", ""),
|
|
655
|
+
proc.get("detail", "")[:40],
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
# Show daemon log tail in detail area
|
|
659
|
+
lines = ["[bold]Recent Daemon Activity[/]\n"]
|
|
660
|
+
if DAEMON_LOG_FILE.exists():
|
|
661
|
+
try:
|
|
662
|
+
with open(DAEMON_LOG_FILE, "rb") as f:
|
|
663
|
+
f.seek(0, 2)
|
|
664
|
+
fsize = f.tell()
|
|
665
|
+
read_size = min(fsize, 4096)
|
|
666
|
+
f.seek(fsize - read_size)
|
|
667
|
+
tail = f.read().decode("utf-8", errors="replace")
|
|
668
|
+
for log_line in tail.strip().split("\n")[-10:]:
|
|
669
|
+
try:
|
|
670
|
+
entry = json.loads(log_line)
|
|
671
|
+
ts = entry.get("ts", "")[:19].replace("T", " ")
|
|
672
|
+
action = entry.get("action", "")
|
|
673
|
+
item_id = entry.get("item_id", "")
|
|
674
|
+
log_detail = entry.get("detail", "")[:50]
|
|
675
|
+
risk = entry.get("risk", "")
|
|
676
|
+
risk_color = "red" if risk == "high" else "yellow" if risk == "medium" else "green"
|
|
677
|
+
lines.append(
|
|
678
|
+
f" [dim]{ts}[/] {action:<15} {item_id:<10} "
|
|
679
|
+
f"[{risk_color}]{risk}[/] [dim]{log_detail}[/]"
|
|
680
|
+
)
|
|
681
|
+
except json.JSONDecodeError:
|
|
682
|
+
continue
|
|
683
|
+
except (OSError, UnicodeDecodeError):
|
|
684
|
+
lines.append(" [dim]Could not read daemon log.[/]")
|
|
685
|
+
else:
|
|
686
|
+
lines.append(" [dim]No daemon log found.[/]")
|
|
687
|
+
|
|
688
|
+
# Show alerts
|
|
689
|
+
lines.append("\n[bold]Active Alerts[/]\n")
|
|
690
|
+
alert_count = 0
|
|
691
|
+
if ALERTS_DIR.exists():
|
|
692
|
+
for alert_file in sorted(ALERTS_DIR.glob("*.json")):
|
|
693
|
+
try:
|
|
694
|
+
alert = json.loads(alert_file.read_text())
|
|
695
|
+
alert_name = alert.get("alert", alert_file.stem)
|
|
696
|
+
reason = alert.get("reason", "")[:60]
|
|
697
|
+
alert_ts = alert.get("timestamp", "")[:19].replace("T", " ")
|
|
698
|
+
lines.append(f" [red]![/] [bold]{alert_name}[/] [dim]{alert_ts}[/]")
|
|
699
|
+
if reason:
|
|
700
|
+
lines.append(f" {reason}")
|
|
701
|
+
alert_count += 1
|
|
702
|
+
except (json.JSONDecodeError, OSError):
|
|
703
|
+
continue
|
|
704
|
+
if alert_count == 0:
|
|
705
|
+
lines.append(" [green]No active alerts.[/]")
|
|
706
|
+
|
|
707
|
+
detail.update("\n".join(lines))
|
|
708
|
+
|
|
709
|
+
|
|
202
710
|
class GovernanceBar(Static):
|
|
203
|
-
"""Top status bar
|
|
711
|
+
"""Top status bar -- governance health at a glance."""
|
|
204
712
|
|
|
205
713
|
def compose(self) -> ComposeResult:
|
|
206
714
|
yield Static(id="gov-bar")
|
|
@@ -216,19 +724,24 @@ class GovernanceBar(Static):
|
|
|
216
724
|
mode_file = Path.home() / ".delimit" / "enforcement_mode"
|
|
217
725
|
mode = mode_file.read_text().strip() if mode_file.exists() else "default"
|
|
218
726
|
|
|
727
|
+
# Notification badge
|
|
728
|
+
notif_count = NotificationPanel.get_unread_count()
|
|
729
|
+
notif_badge = f" | [yellow]Notif:[/] {notif_count}" if notif_count > 0 else ""
|
|
730
|
+
|
|
219
731
|
bar.update(
|
|
220
732
|
f" [bold magenta]</>[/] [bold]Delimit OS[/] | "
|
|
221
733
|
f"[cyan]Ledger:[/] {ledger_count} open | "
|
|
222
734
|
f"[cyan]Swarm:[/] {swarm['agents']} agents / {swarm['ventures']} ventures | "
|
|
223
|
-
f"[cyan]Mode:[/] {mode}
|
|
735
|
+
f"[cyan]Mode:[/] {mode}"
|
|
736
|
+
f"{notif_badge} | "
|
|
224
737
|
f"[dim]{time.strftime('%H:%M')}[/]"
|
|
225
738
|
)
|
|
226
739
|
|
|
227
740
|
|
|
228
|
-
#
|
|
741
|
+
# -- Main App -----------------------------------------------------------------
|
|
229
742
|
|
|
230
743
|
class DelimitOS(App):
|
|
231
|
-
"""Delimit OS
|
|
744
|
+
"""Delimit OS -- the AI developer operating system."""
|
|
232
745
|
|
|
233
746
|
CSS = """
|
|
234
747
|
Screen {
|
|
@@ -246,7 +759,7 @@ class DelimitOS(App):
|
|
|
246
759
|
DataTable {
|
|
247
760
|
height: 1fr;
|
|
248
761
|
}
|
|
249
|
-
#swarm-content, #session-content {
|
|
762
|
+
#swarm-content, #session-content, #ventures-content {
|
|
250
763
|
padding: 1;
|
|
251
764
|
}
|
|
252
765
|
"""
|
|
@@ -255,14 +768,17 @@ class DelimitOS(App):
|
|
|
255
768
|
SUB_TITLE = "AI Developer Operating System"
|
|
256
769
|
|
|
257
770
|
BINDINGS = [
|
|
258
|
-
("q", "quit", "Quit"),
|
|
259
|
-
("l", "focus_ledger", "Ledger"),
|
|
260
|
-
("s", "focus_swarm", "Swarm"),
|
|
261
|
-
("
|
|
262
|
-
("
|
|
263
|
-
("
|
|
264
|
-
("
|
|
265
|
-
("
|
|
771
|
+
Binding("q", "quit", "Quit", key_display="Q"),
|
|
772
|
+
Binding("l", "focus_ledger", "Ledger", key_display="L"),
|
|
773
|
+
Binding("s", "focus_swarm", "Swarm", key_display="S"),
|
|
774
|
+
Binding("n", "focus_notifications", "Notifications", key_display="N"),
|
|
775
|
+
Binding("f", "focus_files", "Files", key_display="F"),
|
|
776
|
+
Binding("p", "focus_processes", "Processes", key_display="P"),
|
|
777
|
+
Binding("v", "focus_ventures", "Ventures", key_display="V"),
|
|
778
|
+
Binding("h", "focus_sessions", "History", key_display="H"),
|
|
779
|
+
Binding("t", "think", "Think", key_display="T"),
|
|
780
|
+
Binding("b", "build", "Build", key_display="B"),
|
|
781
|
+
Binding("r", "refresh", "Refresh", key_display="R"),
|
|
266
782
|
]
|
|
267
783
|
|
|
268
784
|
def compose(self) -> ComposeResult:
|
|
@@ -272,32 +788,59 @@ class DelimitOS(App):
|
|
|
272
788
|
yield LedgerPanel()
|
|
273
789
|
with TabPane("Swarm", id="tab-swarm"):
|
|
274
790
|
yield SwarmPanel()
|
|
791
|
+
with TabPane("Notifications", id="tab-notifications"):
|
|
792
|
+
yield NotificationPanel()
|
|
793
|
+
with TabPane("Files", id="tab-files"):
|
|
794
|
+
yield FilesystemPanel()
|
|
795
|
+
with TabPane("Processes", id="tab-processes"):
|
|
796
|
+
yield ProcessPanel()
|
|
275
797
|
with TabPane("Ventures", id="tab-ventures"):
|
|
276
798
|
yield VenturesPanel()
|
|
277
799
|
with TabPane("Sessions", id="tab-sessions"):
|
|
278
800
|
yield SessionPanel()
|
|
279
801
|
yield Footer()
|
|
280
802
|
|
|
803
|
+
# -- Tab focus actions -----------------------------------------------------
|
|
804
|
+
|
|
281
805
|
def action_focus_ledger(self) -> None:
|
|
282
806
|
self.query_one(TabbedContent).active = "tab-ledger"
|
|
283
807
|
|
|
284
808
|
def action_focus_swarm(self) -> None:
|
|
285
809
|
self.query_one(TabbedContent).active = "tab-swarm"
|
|
286
810
|
|
|
811
|
+
def action_focus_notifications(self) -> None:
|
|
812
|
+
self.query_one(TabbedContent).active = "tab-notifications"
|
|
813
|
+
|
|
814
|
+
def action_focus_files(self) -> None:
|
|
815
|
+
self.query_one(TabbedContent).active = "tab-files"
|
|
816
|
+
|
|
817
|
+
def action_focus_processes(self) -> None:
|
|
818
|
+
self.query_one(TabbedContent).active = "tab-processes"
|
|
819
|
+
|
|
287
820
|
def action_focus_ventures(self) -> None:
|
|
288
821
|
self.query_one(TabbedContent).active = "tab-ventures"
|
|
289
822
|
|
|
290
823
|
def action_focus_sessions(self) -> None:
|
|
291
824
|
self.query_one(TabbedContent).active = "tab-sessions"
|
|
292
825
|
|
|
826
|
+
# -- Global actions --------------------------------------------------------
|
|
827
|
+
|
|
293
828
|
def action_refresh(self) -> None:
|
|
829
|
+
"""Refresh all panels."""
|
|
294
830
|
for panel in self.query(LedgerPanel):
|
|
295
831
|
panel._refresh_data()
|
|
296
832
|
for panel in self.query(SwarmPanel):
|
|
297
833
|
panel._refresh_data()
|
|
298
834
|
for panel in self.query(SessionPanel):
|
|
299
835
|
panel._refresh_data()
|
|
836
|
+
for panel in self.query(NotificationPanel):
|
|
837
|
+
panel._refresh_data()
|
|
838
|
+
for panel in self.query(ProcessPanel):
|
|
839
|
+
panel._refresh_data()
|
|
840
|
+
for panel in self.query(VenturesPanel):
|
|
841
|
+
panel._refresh_data()
|
|
300
842
|
self.query_one(GovernanceBar)._refresh()
|
|
843
|
+
self.notify("All panels refreshed", title="Refresh")
|
|
301
844
|
|
|
302
845
|
@work(thread=True)
|
|
303
846
|
def action_think(self) -> None:
|
|
@@ -333,14 +876,14 @@ class DelimitOS(App):
|
|
|
333
876
|
timeout=10,
|
|
334
877
|
)
|
|
335
878
|
else:
|
|
336
|
-
self.notify("Ledger is clear
|
|
879
|
+
self.notify("Ledger is clear -- nothing to build!", title="Build")
|
|
337
880
|
|
|
338
881
|
|
|
339
882
|
def main():
|
|
340
883
|
"""Entry point for 'delimit' command."""
|
|
341
884
|
import sys
|
|
342
885
|
if "--quick" in sys.argv:
|
|
343
|
-
# Quick status mode
|
|
886
|
+
# Quick status mode -- no interactive TUI
|
|
344
887
|
from rich.console import Console
|
|
345
888
|
from rich.table import Table
|
|
346
889
|
|
|
@@ -367,6 +910,21 @@ def main():
|
|
|
367
910
|
item.get("venture", "")[:15],
|
|
368
911
|
)
|
|
369
912
|
console.print(table)
|
|
913
|
+
|
|
914
|
+
# Quick notification summary
|
|
915
|
+
notif_count = NotificationPanel.get_unread_count()
|
|
916
|
+
if notif_count > 0:
|
|
917
|
+
console.print(f"\n[yellow]Notifications:[/] {notif_count} in the last hour")
|
|
918
|
+
|
|
919
|
+
# Quick process summary
|
|
920
|
+
processes = _load_process_list()
|
|
921
|
+
running = [p for p in processes if p["status"] in ("running", "active")]
|
|
922
|
+
stopped = [p for p in processes if p["status"] not in ("running", "active", "inactive")]
|
|
923
|
+
if running:
|
|
924
|
+
console.print(f"[green]Running:[/] {', '.join(p['label'] for p in running)}")
|
|
925
|
+
if stopped:
|
|
926
|
+
console.print(f"[red]Stopped:[/] {', '.join(p['label'] for p in stopped)}")
|
|
927
|
+
|
|
370
928
|
return
|
|
371
929
|
|
|
372
930
|
app = DelimitOS()
|