conduct-cli 0.4.42__tar.gz → 0.4.44__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.42
3
+ Version: 0.4.44
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.4.42"
7
+ version = "0.4.44"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1458,6 +1458,412 @@ def cmd_whoami(args):
1458
1458
  print()
1459
1459
 
1460
1460
 
1461
+ _CLAUDE_SESSIONS = Path.home() / ".claude" / "sessions"
1462
+ _CLAUDE_PROJECTS = Path.home() / ".claude" / "projects"
1463
+ _CODEX_SESSIONS = Path.home() / ".codex" / "sessions"
1464
+
1465
+ # Context window limits by model prefix (tokens)
1466
+ _CTX_LIMITS = {
1467
+ "claude-opus-4": 200_000,
1468
+ "claude-sonnet-4": 200_000,
1469
+ "claude-haiku-4": 200_000,
1470
+ "claude-opus-3": 200_000,
1471
+ "claude-sonnet-3": 200_000,
1472
+ "claude-haiku-3": 200_000,
1473
+ }
1474
+
1475
+ def _ctx_limit(model: str) -> int:
1476
+ for prefix, limit in _CTX_LIMITS.items():
1477
+ if model.startswith(prefix):
1478
+ return limit
1479
+ return 200_000
1480
+
1481
+
1482
+ def _is_alive(pid: int) -> bool:
1483
+ try:
1484
+ os.kill(pid, 0)
1485
+ return True
1486
+ except OSError:
1487
+ return False
1488
+
1489
+
1490
+ def _session_stats(session_id: str, project_dir: Path) -> dict:
1491
+ """Parse the tail of a session JSONL for model, token counts, and turn count."""
1492
+ jsonl = project_dir / f"{session_id}.jsonl"
1493
+ if not jsonl.exists():
1494
+ return {}
1495
+
1496
+ model = ""
1497
+ total_input = 0
1498
+ total_output = 0
1499
+ turns = 0
1500
+ last_usage: dict = {}
1501
+
1502
+ try:
1503
+ lines = jsonl.read_bytes().splitlines()
1504
+ # Scan last 300 lines for efficiency
1505
+ for raw in lines[-300:]:
1506
+ try:
1507
+ entry = json.loads(raw)
1508
+ except Exception:
1509
+ continue
1510
+ msg = entry.get("message", {})
1511
+ if not isinstance(msg, dict):
1512
+ continue
1513
+ if msg.get("role") == "assistant":
1514
+ turns += 1
1515
+ if msg.get("model"):
1516
+ model = msg["model"]
1517
+ usage = msg.get("usage", {})
1518
+ if usage:
1519
+ last_usage = usage
1520
+ if msg.get("role") == "assistant" and "usage" in msg:
1521
+ u = msg["usage"]
1522
+ total_input += u.get("input_tokens", 0) + u.get("cache_read_input_tokens", 0)
1523
+ total_output += u.get("output_tokens", 0)
1524
+ except Exception:
1525
+ pass
1526
+
1527
+ cache_read = last_usage.get("cache_read_input_tokens", 0)
1528
+ fresh_in = last_usage.get("input_tokens", 0)
1529
+ ctx_tokens = cache_read + fresh_in
1530
+ limit = _ctx_limit(model)
1531
+ ctx_pct = round(ctx_tokens / limit * 100) if limit else 0
1532
+
1533
+ return {
1534
+ "model": model,
1535
+ "turns": turns,
1536
+ "ctx_tokens": ctx_tokens,
1537
+ "ctx_pct": ctx_pct,
1538
+ "total_in": total_input,
1539
+ "total_out": total_output,
1540
+ }
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
+
1653
+ def _load_sessions() -> list[dict]:
1654
+ """Read ~/.claude/sessions/*.json and join with JSONL stats."""
1655
+ if not _CLAUDE_SESSIONS.exists():
1656
+ return []
1657
+
1658
+ guard_cfg = Path.home() / ".conductguard" / "config.json"
1659
+ guard_on = guard_cfg.exists()
1660
+
1661
+ rows = []
1662
+ seen_sessions: set[str] = set()
1663
+ for f in sorted(_CLAUDE_SESSIONS.iterdir()):
1664
+ if not f.suffix == ".json":
1665
+ continue
1666
+ try:
1667
+ s = json.loads(f.read_text())
1668
+ except Exception:
1669
+ continue
1670
+
1671
+ pid = s.get("pid", 0)
1672
+ session_id = s.get("sessionId", "")
1673
+ cwd = s.get("cwd", "")
1674
+ started_at = s.get("startedAt", 0)
1675
+ kind = s.get("kind", "")
1676
+
1677
+ if session_id in seen_sessions:
1678
+ continue
1679
+ seen_sessions.add(session_id)
1680
+
1681
+ alive = _is_alive(pid)
1682
+
1683
+ # Claude names project dirs by replacing / with - (keeping leading -)
1684
+ project_key = cwd.replace("/", "-").replace("\\", "-")
1685
+ project_dir = _CLAUDE_PROJECTS / project_key
1686
+
1687
+ stats = _session_stats(session_id, project_dir) if project_dir.exists() else {}
1688
+
1689
+ rows.append({
1690
+ "pid": pid,
1691
+ "session_id": session_id[:8],
1692
+ "project": Path(cwd).name if cwd else "—",
1693
+ "cwd": cwd,
1694
+ "model": stats.get("model", "—"),
1695
+ "turns": stats.get("turns", 0),
1696
+ "ctx_pct": stats.get("ctx_pct", 0),
1697
+ "ctx_tokens": stats.get("ctx_tokens", 0),
1698
+ "total_in": stats.get("total_in", 0),
1699
+ "total_out": stats.get("total_out", 0),
1700
+ "guard": guard_on,
1701
+ "alive": alive,
1702
+ "kind": kind,
1703
+ "started_at": started_at,
1704
+ "ai": "CC",
1705
+ })
1706
+
1707
+ # Merge Codex sessions
1708
+ active_cwds = _codex_running_cwds()
1709
+ rows += _load_codex_sessions(active_cwds)
1710
+
1711
+ return sorted(rows, key=lambda r: r["started_at"], reverse=True)
1712
+
1713
+
1714
+ def _fmt_tokens(n: int) -> str:
1715
+ if n >= 1_000_000:
1716
+ return f"{n/1_000_000:.1f}M"
1717
+ if n >= 1_000:
1718
+ return f"{n/1_000:.1f}k"
1719
+ return str(n)
1720
+
1721
+
1722
+ def _ctx_bar(pct: int, width: int = 10) -> str:
1723
+ filled = round(pct / 100 * width)
1724
+ bar = "█" * filled + "░" * (width - filled)
1725
+ color = RED if pct >= 80 else YELLOW if pct >= 60 else GREEN
1726
+ return f"{color}{bar}{RESET} {pct}%"
1727
+
1728
+
1729
+ def _render_table(rows: list[dict]) -> str:
1730
+ if not rows:
1731
+ return f"\n{GRAY} No Claude Code sessions found.{RESET}\n"
1732
+
1733
+ lines = []
1734
+ header = (
1735
+ f" {BOLD}{'AI':<4} {'PROJECT':<18} {'SESSION':<10} {'MODEL':<18} "
1736
+ f"{'CTX':>14} {'TOKENS IN':>10} {'TURNS':>6} {'GUARD':>6} {'STATUS':>8}{RESET}"
1737
+ )
1738
+ lines.append(header)
1739
+ lines.append(" " + "─" * 100)
1740
+
1741
+ for r in rows:
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 "—"
1745
+ ctx_display = _ctx_bar(r["ctx_pct"]) if r["ctx_pct"] else f"{GRAY}{'—':>14}{RESET}"
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
1749
+
1750
+ lines.append(
1751
+ f" {ai_color}{ai_label:<4}{RESET} {r['project']:<18} {r['session_id']:<10} {model_short:<18} "
1752
+ f"{ctx_display} {tokens_str:>10} {r['turns']:>6} {guard_str:>6} {status_str}"
1753
+ )
1754
+
1755
+ lines.append("")
1756
+ return "\n".join(lines)
1757
+
1758
+
1759
+ def _render_tui(rows: list[dict]) -> None:
1760
+ """Full-screen TUI with live refresh using ANSI escape codes."""
1761
+ try:
1762
+ import shutil
1763
+ import signal
1764
+
1765
+ stop = False
1766
+ def _sigint(sig, frame):
1767
+ nonlocal stop
1768
+ stop = True
1769
+ signal.signal(signal.SIGINT, _sigint)
1770
+
1771
+ def _clear():
1772
+ sys.stdout.write("\033[2J\033[H")
1773
+ sys.stdout.flush()
1774
+
1775
+ def _render_frame(rows):
1776
+ cols, _ = shutil.get_terminal_size((120, 40))
1777
+ out = []
1778
+
1779
+ # Header bar
1780
+ title = " conduct sessions (TUI — q or Ctrl+C to quit) "
1781
+ pad = max(0, cols - len(title))
1782
+ out.append(f"{BOLD}\033[44m{title}{' ' * pad}\033[0m")
1783
+ out.append("")
1784
+
1785
+ # Summary line
1786
+ active = sum(1 for r in rows if r["alive"])
1787
+ guard_on = sum(1 for r in rows if r["guard"])
1788
+ out.append(f" {BOLD}{active}{RESET} active session(s) · {BOLD}{guard_on}{RESET} with Guard · refreshing every 5s")
1789
+ out.append("")
1790
+
1791
+ # Table
1792
+ out.append(_render_table(rows))
1793
+
1794
+ # Context pressure panel — sessions above 60%
1795
+ high = [r for r in rows if r["ctx_pct"] >= 60 and r["alive"]]
1796
+ if high:
1797
+ out.append(f" {YELLOW}{BOLD}⚠ Context pressure{RESET}")
1798
+ for r in high:
1799
+ color = RED if r["ctx_pct"] >= 80 else YELLOW
1800
+ out.append(f" {color}{r['project']}{RESET} ({r['session_id']}) {r['ctx_pct']}% — consider /compact")
1801
+ out.append("")
1802
+
1803
+ sys.stdout.write("\n".join(out))
1804
+ sys.stdout.flush()
1805
+
1806
+ # Use raw terminal input for 'q' detection (non-blocking)
1807
+ import select, tty, termios
1808
+ old = termios.tcgetattr(sys.stdin)
1809
+ try:
1810
+ tty.setcbreak(sys.stdin.fileno())
1811
+ while not stop:
1812
+ _clear()
1813
+ rows = _load_sessions()
1814
+ _render_frame(rows)
1815
+ # Wait up to 5s, exit on 'q'
1816
+ for _ in range(50):
1817
+ if select.select([sys.stdin], [], [], 0.1)[0]:
1818
+ ch = sys.stdin.read(1)
1819
+ if ch in ("q", "Q"):
1820
+ stop = True
1821
+ break
1822
+ if stop:
1823
+ break
1824
+ finally:
1825
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old)
1826
+ _clear()
1827
+ print("conduct sessions exited.")
1828
+
1829
+ except Exception as e:
1830
+ # Graceful fallback to table if TUI setup fails
1831
+ print(f"{YELLOW}TUI unavailable ({e}), showing table:{RESET}")
1832
+ print(_render_table(rows))
1833
+
1834
+
1835
+ def cmd_sessions(args):
1836
+ tui = getattr(args, "tui", False)
1837
+ watch = getattr(args, "watch", False)
1838
+
1839
+ if tui:
1840
+ _render_tui(_load_sessions())
1841
+ return
1842
+
1843
+ if watch:
1844
+ try:
1845
+ import signal
1846
+ stop = False
1847
+ def _sig(s, f): nonlocal stop; stop = True
1848
+ signal.signal(signal.SIGINT, _sig)
1849
+ while not stop:
1850
+ sys.stdout.write("\033[2J\033[H")
1851
+ sys.stdout.flush()
1852
+ rows = _load_sessions()
1853
+ print(f"\n{BOLD}conduct sessions{RESET} {GRAY}(Ctrl+C to stop · refreshing every 5s){RESET}")
1854
+ print(_render_table(rows))
1855
+ for _ in range(50):
1856
+ time.sleep(0.1)
1857
+ if stop: break
1858
+ except KeyboardInterrupt:
1859
+ pass
1860
+ return
1861
+
1862
+ rows = _load_sessions()
1863
+ print(f"\n{BOLD}conduct sessions{RESET}")
1864
+ print(_render_table(rows))
1865
+
1866
+
1461
1867
  def cmd_run(args):
1462
1868
  server, workspace_id, api_key, token = _require_auth(args)
1463
1869
  json_h = api.headers(workspace_id, token, "application/json", api_key)
@@ -1624,6 +2030,10 @@ def main():
1624
2030
  guard_p, _guard_sub = _guard.register_guard_parser(sub)
1625
2031
 
1626
2032
  # conduct mcp
2033
+ sessions_p = sub.add_parser("sessions", help="Show active Claude Code / Codex sessions")
2034
+ sessions_p.add_argument("--watch", action="store_true", help="Refresh every 5s (table view)")
2035
+ sessions_p.add_argument("--tui", action="store_true", help="Full-screen TUI with live panels")
2036
+
1627
2037
  mcp_p = sub.add_parser("mcp", help="Manage the Conduct MCP server")
1628
2038
  mcp_sub = mcp_p.add_subparsers(dest="mcp_command")
1629
2039
  mcp_sub.add_parser("install", help="Register conduct-mcp in Claude Code and Codex")
@@ -1676,6 +2086,8 @@ def main():
1676
2086
  cmd_switch(args)
1677
2087
  elif args.command == "whoami":
1678
2088
  cmd_whoami(args)
2089
+ elif args.command == "sessions":
2090
+ cmd_sessions(args)
1679
2091
  elif args.command == "guard":
1680
2092
  _guard.dispatch_guard(args, guard_p)
1681
2093
  elif args.command == "mcp":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.42
3
+ Version: 0.4.44
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
File without changes
File without changes
File without changes