conduct-cli 0.4.43__tar.gz → 0.4.45__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.43
3
+ Version: 0.4.45
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -21,6 +21,7 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
21
21
  Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: rich>=13.0
24
25
 
25
26
  # conduct-cli
26
27
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.4.43"
7
+ version = "0.4.45"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Programming Language :: Python :: 3.12",
24
24
  "Topic :: Software Development :: Libraries :: Application Frameworks",
25
25
  ]
26
- dependencies = ["pyyaml>=6.0"]
26
+ dependencies = ["pyyaml>=6.0", "rich>=13.0"]
27
27
 
28
28
  [project.urls]
29
29
  Homepage = "https://conductai.ai"
@@ -1460,6 +1460,7 @@ def cmd_whoami(args):
1460
1460
 
1461
1461
  _CLAUDE_SESSIONS = Path.home() / ".claude" / "sessions"
1462
1462
  _CLAUDE_PROJECTS = Path.home() / ".claude" / "projects"
1463
+ _CODEX_SESSIONS = Path.home() / ".codex" / "sessions"
1463
1464
 
1464
1465
  # Context window limits by model prefix (tokens)
1465
1466
  _CTX_LIMITS = {
@@ -1539,6 +1540,116 @@ def _session_stats(session_id: str, project_dir: Path) -> dict:
1539
1540
  }
1540
1541
 
1541
1542
 
1543
+ def _codex_running_cwds() -> set[str]:
1544
+ """Return cwds of any running codex processes via /proc or ps."""
1545
+ cwds: set[str] = set()
1546
+ try:
1547
+ import subprocess as _sp
1548
+ out = _sp.run(["ps", "aux"], capture_output=True, text=True).stdout
1549
+ for line in out.splitlines():
1550
+ if "codex" in line and "grep" not in line:
1551
+ # Extract cwd from lsof for each codex PID
1552
+ parts = line.split()
1553
+ if parts:
1554
+ pid = parts[1]
1555
+ try:
1556
+ r = _sp.run(["lsof", "-p", pid, "-a", "-d", "cwd", "-Fn"],
1557
+ capture_output=True, text=True, timeout=1)
1558
+ for l in r.stdout.splitlines():
1559
+ if l.startswith("n"):
1560
+ cwds.add(l[1:])
1561
+ except Exception:
1562
+ pass
1563
+ except Exception:
1564
+ pass
1565
+ return cwds
1566
+
1567
+
1568
+ def _codex_session_stats(jsonl_path: Path) -> dict:
1569
+ """Parse a Codex JSONL session file for model, ctx window, and turn count."""
1570
+ model = ""
1571
+ ctx_limit = 0
1572
+ turns = 0
1573
+ cwd = ""
1574
+ try:
1575
+ for raw in jsonl_path.read_bytes().splitlines():
1576
+ try:
1577
+ entry = json.loads(raw)
1578
+ except Exception:
1579
+ continue
1580
+ t = entry.get("type", "")
1581
+ p = entry.get("payload", {})
1582
+ if t == "session_meta":
1583
+ cwd = p.get("cwd", "")
1584
+ if t == "turn_context":
1585
+ model = p.get("model", model)
1586
+ turns += 1
1587
+ if t == "event_msg" and not ctx_limit:
1588
+ ctx_limit = p.get("model_context_window", 0)
1589
+ except Exception:
1590
+ pass
1591
+
1592
+ return {"model": model, "ctx_limit": ctx_limit, "turns": turns, "cwd": cwd}
1593
+
1594
+
1595
+ def _load_codex_sessions(active_cwds: set[str]) -> list[dict]:
1596
+ """Find recent Codex sessions from ~/.codex/sessions/YYYY/MM/DD/."""
1597
+ if not _CODEX_SESSIONS.exists():
1598
+ return []
1599
+
1600
+ guard_cfg = Path.home() / ".conductguard" / "config.json"
1601
+ guard_on = guard_cfg.exists()
1602
+
1603
+ rows = []
1604
+ # Walk the last 2 days of session dirs
1605
+ from datetime import datetime, timedelta
1606
+ today = datetime.now()
1607
+ date_dirs = []
1608
+ for delta in (0, 1):
1609
+ d = today - timedelta(days=delta)
1610
+ date_dirs.append(_CODEX_SESSIONS / str(d.year) / f"{d.month:02d}" / f"{d.day:02d}")
1611
+
1612
+ seen: set[str] = set()
1613
+ for date_dir in date_dirs:
1614
+ if not date_dir.exists():
1615
+ continue
1616
+ for f in sorted(date_dir.iterdir(), reverse=True):
1617
+ if not f.suffix == ".jsonl":
1618
+ continue
1619
+ session_id = f.stem.split("-", 1)[-1] if "-" in f.stem else f.stem
1620
+ if session_id in seen:
1621
+ continue
1622
+ seen.add(session_id)
1623
+
1624
+ stats = _codex_session_stats(f)
1625
+ cwd = stats.get("cwd", "")
1626
+ alive = cwd in active_cwds
1627
+
1628
+ ctx_limit = stats.get("ctx_limit", 0) or 200_000
1629
+ # Codex doesn't expose per-turn token counts in JSONL — show turns only
1630
+ ctx_pct = 0
1631
+
1632
+ rows.append({
1633
+ "pid": "—",
1634
+ "session_id": session_id[:8],
1635
+ "project": Path(cwd).name if cwd else "—",
1636
+ "cwd": cwd,
1637
+ "model": stats.get("model", "—"),
1638
+ "turns": stats.get("turns", 0),
1639
+ "ctx_pct": ctx_pct,
1640
+ "ctx_tokens": 0,
1641
+ "total_in": 0,
1642
+ "total_out": 0,
1643
+ "guard": guard_on,
1644
+ "alive": alive,
1645
+ "kind": "codex",
1646
+ "started_at": int(f.stat().st_mtime * 1000),
1647
+ "ai": "CD",
1648
+ })
1649
+
1650
+ return rows
1651
+
1652
+
1542
1653
  def _load_sessions() -> list[dict]:
1543
1654
  """Read ~/.claude/sessions/*.json and join with JSONL stats."""
1544
1655
  if not _CLAUDE_SESSIONS.exists():
@@ -1590,8 +1701,13 @@ def _load_sessions() -> list[dict]:
1590
1701
  "alive": alive,
1591
1702
  "kind": kind,
1592
1703
  "started_at": started_at,
1704
+ "ai": "CC",
1593
1705
  })
1594
1706
 
1707
+ # Merge Codex sessions
1708
+ active_cwds = _codex_running_cwds()
1709
+ rows += _load_codex_sessions(active_cwds)
1710
+
1595
1711
  return sorted(rows, key=lambda r: r["started_at"], reverse=True)
1596
1712
 
1597
1713
 
@@ -1616,21 +1732,23 @@ def _render_table(rows: list[dict]) -> str:
1616
1732
 
1617
1733
  lines = []
1618
1734
  header = (
1619
- f" {BOLD}{'PROJECT':<18} {'SESSION':<10} {'MODEL':<18} "
1735
+ f" {BOLD}{'AI':<4} {'PROJECT':<18} {'SESSION':<10} {'MODEL':<18} "
1620
1736
  f"{'CTX':>14} {'TOKENS IN':>10} {'TURNS':>6} {'GUARD':>6} {'STATUS':>8}{RESET}"
1621
1737
  )
1622
1738
  lines.append(header)
1623
- lines.append(" " + "─" * 95)
1739
+ lines.append(" " + "─" * 100)
1624
1740
 
1625
1741
  for r in rows:
1626
- status_str = f"{GREEN}active{RESET}" if r["alive"] else f"{GRAY}idle{RESET}"
1627
- guard_str = f"{GREEN}✓{RESET}" if r["guard"] else f"{GRAY}—{RESET}"
1628
- model_short = r["model"].replace("claude-", "").replace("-20", " 20") if r["model"] != "—" else "—"
1742
+ status_str = f"{GREEN}active{RESET}" if r["alive"] else f"{GRAY}idle{RESET}"
1743
+ guard_str = f"{GREEN}✓{RESET}" if r["guard"] else f"{GRAY}—{RESET}"
1744
+ model_short = r["model"].replace("claude-", "").replace("-20", " 20") if r["model"] not in ("—", "") else "—"
1629
1745
  ctx_display = _ctx_bar(r["ctx_pct"]) if r["ctx_pct"] else f"{GRAY}{'—':>14}{RESET}"
1630
1746
  tokens_str = _fmt_tokens(r["ctx_tokens"]) if r["ctx_tokens"] else "—"
1747
+ ai_label = r.get("ai", "CC")
1748
+ ai_color = CYAN if ai_label == "CD" else BLUE
1631
1749
 
1632
1750
  lines.append(
1633
- f" {r['project']:<18} {r['session_id']:<10} {model_short:<18} "
1751
+ f" {ai_color}{ai_label:<4}{RESET} {r['project']:<18} {r['session_id']:<10} {model_short:<18} "
1634
1752
  f"{ctx_display} {tokens_str:>10} {r['turns']:>6} {guard_str:>6} {status_str}"
1635
1753
  )
1636
1754
 
@@ -1639,79 +1757,137 @@ def _render_table(rows: list[dict]) -> str:
1639
1757
 
1640
1758
 
1641
1759
  def _render_tui(rows: list[dict]) -> None:
1642
- """Full-screen TUI with live refresh using ANSI escape codes."""
1760
+ """Full-screen TUI with live refresh. Uses rich.live if available, else ANSI loop."""
1761
+
1762
+ def _build_rich_display(rows):
1763
+ from rich.table import Table
1764
+ from rich.panel import Panel
1765
+ from rich.columns import Columns
1766
+ from rich import box
1767
+ from rich.text import Text
1768
+
1769
+ active = sum(1 for r in rows if r["alive"])
1770
+ guard_on = sum(1 for r in rows if r["guard"])
1771
+ high_ctx = [r for r in rows if r["ctx_pct"] >= 60 and r["alive"]]
1772
+
1773
+ # Main sessions table
1774
+ tbl = Table(box=box.ROUNDED, expand=True, show_header=True, header_style="bold white")
1775
+ tbl.add_column("AI", width=4, style="bold")
1776
+ tbl.add_column("Project", min_width=14)
1777
+ tbl.add_column("Session", width=10, style="dim")
1778
+ tbl.add_column("Model", min_width=14)
1779
+ tbl.add_column("CTX", width=16)
1780
+ tbl.add_column("Tokens", width=10, justify="right")
1781
+ tbl.add_column("Turns", width=6, justify="right")
1782
+ tbl.add_column("Guard", width=6, justify="center")
1783
+ tbl.add_column("Status", width=8)
1784
+
1785
+ for r in rows:
1786
+ ai_text = Text(r.get("ai", "CC"), style="bold cyan" if r.get("ai") == "CD" else "bold blue")
1787
+ status_text = Text("active", style="green") if r["alive"] else Text("idle", style="dim")
1788
+ guard_text = Text("✓", style="green") if r["guard"] else Text("—", style="dim")
1789
+ model_short = r["model"].replace("claude-", "").replace("-20", " 20") if r["model"] not in ("—", "") else "—"
1790
+
1791
+ pct = r["ctx_pct"]
1792
+ if pct:
1793
+ filled = round(pct / 100 * 10)
1794
+ bar = "█" * filled + "░" * (10 - filled)
1795
+ color = "red" if pct >= 80 else "yellow" if pct >= 60 else "green"
1796
+ ctx_text = Text(f"{bar} {pct}%", style=color)
1797
+ else:
1798
+ ctx_text = Text("—", style="dim")
1799
+
1800
+ tbl.add_row(
1801
+ ai_text,
1802
+ r["project"],
1803
+ r["session_id"],
1804
+ model_short,
1805
+ ctx_text,
1806
+ _fmt_tokens(r["ctx_tokens"]) if r["ctx_tokens"] else "—",
1807
+ str(r["turns"]),
1808
+ guard_text,
1809
+ status_text,
1810
+ )
1811
+
1812
+ # Summary header
1813
+ summary = f"[bold]{active}[/] active · [bold]{guard_on}[/] with Guard · [dim]refreshing every 5s — Ctrl+C to quit[/]"
1814
+
1815
+ panels = [Panel(tbl, title="[bold blue]conduct sessions[/]", subtitle=summary, expand=True)]
1816
+
1817
+ # Context pressure panel
1818
+ if high_ctx:
1819
+ warnings = "\n".join(
1820
+ f"[{'red' if r['ctx_pct'] >= 80 else 'yellow'}]{r['project']}[/] "
1821
+ f"[dim]{r['session_id']}[/] {r['ctx_pct']}% — consider /compact"
1822
+ for r in high_ctx
1823
+ )
1824
+ panels.append(Panel(warnings, title="[yellow bold]⚠ Context pressure[/]", expand=True))
1825
+
1826
+ from rich.console import Group
1827
+ return Group(*panels)
1828
+
1829
+ # Try rich.live first (works without raw TTY)
1643
1830
  try:
1644
- import shutil
1645
- import signal
1646
-
1647
- stop = False
1648
- def _sigint(sig, frame):
1649
- nonlocal stop
1650
- stop = True
1651
- signal.signal(signal.SIGINT, _sigint)
1652
-
1653
- def _clear():
1654
- sys.stdout.write("\033[2J\033[H")
1655
- sys.stdout.flush()
1656
-
1657
- def _render_frame(rows):
1658
- cols, _ = shutil.get_terminal_size((120, 40))
1659
- out = []
1660
-
1661
- # Header bar
1662
- title = " conduct sessions (TUI — q or Ctrl+C to quit) "
1663
- pad = max(0, cols - len(title))
1664
- out.append(f"{BOLD}\033[44m{title}{' ' * pad}\033[0m")
1665
- out.append("")
1666
-
1667
- # Summary line
1668
- active = sum(1 for r in rows if r["alive"])
1669
- guard_on = sum(1 for r in rows if r["guard"])
1670
- out.append(f" {BOLD}{active}{RESET} active session(s) · {BOLD}{guard_on}{RESET} with Guard · refreshing every 5s")
1671
- out.append("")
1672
-
1673
- # Table
1674
- out.append(_render_table(rows))
1675
-
1676
- # Context pressure panel — sessions above 60%
1677
- high = [r for r in rows if r["ctx_pct"] >= 60 and r["alive"]]
1678
- if high:
1679
- out.append(f" {YELLOW}{BOLD}⚠ Context pressure{RESET}")
1680
- for r in high:
1681
- color = RED if r["ctx_pct"] >= 80 else YELLOW
1682
- out.append(f" {color}{r['project']}{RESET} ({r['session_id']}) {r['ctx_pct']}% — consider /compact")
1683
- out.append("")
1684
-
1685
- sys.stdout.write("\n".join(out))
1686
- sys.stdout.flush()
1687
-
1688
- # Use raw terminal input for 'q' detection (non-blocking)
1689
- import select, tty, termios
1690
- old = termios.tcgetattr(sys.stdin)
1691
- try:
1692
- tty.setcbreak(sys.stdin.fileno())
1693
- while not stop:
1694
- _clear()
1695
- rows = _load_sessions()
1696
- _render_frame(rows)
1697
- # Wait up to 5s, exit on 'q'
1698
- for _ in range(50):
1699
- if select.select([sys.stdin], [], [], 0.1)[0]:
1700
- ch = sys.stdin.read(1)
1701
- if ch in ("q", "Q"):
1702
- stop = True
1703
- break
1704
- if stop:
1705
- break
1706
- finally:
1707
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old)
1708
- _clear()
1709
- print("conduct sessions exited.")
1831
+ from rich.live import Live
1832
+ from rich.console import Console
1833
+ console = Console()
1834
+ with Live(console=console, refresh_per_second=0.2, screen=True) as live:
1835
+ while True:
1836
+ live.update(_build_rich_display(_load_sessions()))
1837
+ time.sleep(5)
1838
+ return
1839
+ except KeyboardInterrupt:
1840
+ return
1841
+ except ImportError:
1842
+ pass # fall through to ANSI loop
1710
1843
 
1711
- except Exception as e:
1712
- # Graceful fallback to table if TUI setup fails
1713
- print(f"{YELLOW}TUI unavailable ({e}), showing table:{RESET}")
1844
+ # ANSI fallback — requires real TTY
1845
+ if not sys.stdin.isatty():
1846
+ print(f"{YELLOW}TUI requires a real terminal. Run directly in your shell, not via a subprocess.{RESET}")
1714
1847
  print(_render_table(rows))
1848
+ return
1849
+
1850
+ import shutil, signal, select, tty, termios
1851
+ stop = False
1852
+ def _sig(s, f): nonlocal stop; stop = True
1853
+ signal.signal(signal.SIGINT, _sig)
1854
+
1855
+ def _frame(rows):
1856
+ cols, _ = shutil.get_terminal_size((120, 40))
1857
+ title = " conduct sessions (Ctrl+C or q to quit) "
1858
+ pad = max(0, cols - len(title))
1859
+ active = sum(1 for r in rows if r["alive"])
1860
+ guard_on = sum(1 for r in rows if r["guard"])
1861
+ out = [
1862
+ f"{BOLD}\033[44m{title}{' ' * pad}\033[0m",
1863
+ f"\n {BOLD}{active}{RESET} active · {BOLD}{guard_on}{RESET} with Guard · refreshing every 5s\n",
1864
+ _render_table(rows),
1865
+ ]
1866
+ high = [r for r in rows if r["ctx_pct"] >= 60 and r["alive"]]
1867
+ if high:
1868
+ out.append(f" {YELLOW}{BOLD}⚠ Context pressure{RESET}")
1869
+ for r in high:
1870
+ c = RED if r["ctx_pct"] >= 80 else YELLOW
1871
+ out.append(f" {c}{r['project']}{RESET} ({r['session_id']}) {r['ctx_pct']}% — consider /compact")
1872
+ sys.stdout.write("\033[2J\033[H" + "\n".join(out))
1873
+ sys.stdout.flush()
1874
+
1875
+ old = termios.tcgetattr(sys.stdin)
1876
+ try:
1877
+ tty.setcbreak(sys.stdin.fileno())
1878
+ while not stop:
1879
+ _frame(_load_sessions())
1880
+ for _ in range(50):
1881
+ if select.select([sys.stdin], [], [], 0.1)[0]:
1882
+ if sys.stdin.read(1) in ("q", "Q"):
1883
+ stop = True
1884
+ break
1885
+ if stop:
1886
+ break
1887
+ finally:
1888
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old)
1889
+ sys.stdout.write("\033[2J\033[H")
1890
+ print("conduct sessions exited.")
1715
1891
 
1716
1892
 
1717
1893
  def cmd_sessions(args):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.43
3
+ Version: 0.4.45
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -21,6 +21,7 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
21
21
  Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: rich>=13.0
24
25
 
25
26
  # conduct-cli
26
27
 
File without changes
File without changes
File without changes