conduct-cli 0.4.41__tar.gz → 0.4.43__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.41
3
+ Version: 0.4.43
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.41"
7
+ version = "0.4.43"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1387,9 +1387,9 @@ def cmd_switch(args):
1387
1387
  rule_count = len(policy.get("rules", []))
1388
1388
  print(f" {GRAY}Guard policies synced: {rule_count} rule(s){RESET}")
1389
1389
  except SystemExit:
1390
- pass # Guard not configured for this workspace — skip silently
1391
- except Exception:
1392
- pass
1390
+ print(f" {GRAY}Guard not configured for this workspace — policies not synced{RESET}")
1391
+ except Exception as e:
1392
+ print(f" {YELLOW}⚠ Guard policy sync failed: {e}{RESET}")
1393
1393
 
1394
1394
  print(f"{GREEN}✓ Switched to \"{new_name}\" ({new_id[:8]}){RESET}")
1395
1395
 
@@ -1458,6 +1458,294 @@ 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
+
1464
+ # Context window limits by model prefix (tokens)
1465
+ _CTX_LIMITS = {
1466
+ "claude-opus-4": 200_000,
1467
+ "claude-sonnet-4": 200_000,
1468
+ "claude-haiku-4": 200_000,
1469
+ "claude-opus-3": 200_000,
1470
+ "claude-sonnet-3": 200_000,
1471
+ "claude-haiku-3": 200_000,
1472
+ }
1473
+
1474
+ def _ctx_limit(model: str) -> int:
1475
+ for prefix, limit in _CTX_LIMITS.items():
1476
+ if model.startswith(prefix):
1477
+ return limit
1478
+ return 200_000
1479
+
1480
+
1481
+ def _is_alive(pid: int) -> bool:
1482
+ try:
1483
+ os.kill(pid, 0)
1484
+ return True
1485
+ except OSError:
1486
+ return False
1487
+
1488
+
1489
+ def _session_stats(session_id: str, project_dir: Path) -> dict:
1490
+ """Parse the tail of a session JSONL for model, token counts, and turn count."""
1491
+ jsonl = project_dir / f"{session_id}.jsonl"
1492
+ if not jsonl.exists():
1493
+ return {}
1494
+
1495
+ model = ""
1496
+ total_input = 0
1497
+ total_output = 0
1498
+ turns = 0
1499
+ last_usage: dict = {}
1500
+
1501
+ try:
1502
+ lines = jsonl.read_bytes().splitlines()
1503
+ # Scan last 300 lines for efficiency
1504
+ for raw in lines[-300:]:
1505
+ try:
1506
+ entry = json.loads(raw)
1507
+ except Exception:
1508
+ continue
1509
+ msg = entry.get("message", {})
1510
+ if not isinstance(msg, dict):
1511
+ continue
1512
+ if msg.get("role") == "assistant":
1513
+ turns += 1
1514
+ if msg.get("model"):
1515
+ model = msg["model"]
1516
+ usage = msg.get("usage", {})
1517
+ if usage:
1518
+ last_usage = usage
1519
+ if msg.get("role") == "assistant" and "usage" in msg:
1520
+ u = msg["usage"]
1521
+ total_input += u.get("input_tokens", 0) + u.get("cache_read_input_tokens", 0)
1522
+ total_output += u.get("output_tokens", 0)
1523
+ except Exception:
1524
+ pass
1525
+
1526
+ cache_read = last_usage.get("cache_read_input_tokens", 0)
1527
+ fresh_in = last_usage.get("input_tokens", 0)
1528
+ ctx_tokens = cache_read + fresh_in
1529
+ limit = _ctx_limit(model)
1530
+ ctx_pct = round(ctx_tokens / limit * 100) if limit else 0
1531
+
1532
+ return {
1533
+ "model": model,
1534
+ "turns": turns,
1535
+ "ctx_tokens": ctx_tokens,
1536
+ "ctx_pct": ctx_pct,
1537
+ "total_in": total_input,
1538
+ "total_out": total_output,
1539
+ }
1540
+
1541
+
1542
+ def _load_sessions() -> list[dict]:
1543
+ """Read ~/.claude/sessions/*.json and join with JSONL stats."""
1544
+ if not _CLAUDE_SESSIONS.exists():
1545
+ return []
1546
+
1547
+ guard_cfg = Path.home() / ".conductguard" / "config.json"
1548
+ guard_on = guard_cfg.exists()
1549
+
1550
+ rows = []
1551
+ seen_sessions: set[str] = set()
1552
+ for f in sorted(_CLAUDE_SESSIONS.iterdir()):
1553
+ if not f.suffix == ".json":
1554
+ continue
1555
+ try:
1556
+ s = json.loads(f.read_text())
1557
+ except Exception:
1558
+ continue
1559
+
1560
+ pid = s.get("pid", 0)
1561
+ session_id = s.get("sessionId", "")
1562
+ cwd = s.get("cwd", "")
1563
+ started_at = s.get("startedAt", 0)
1564
+ kind = s.get("kind", "")
1565
+
1566
+ if session_id in seen_sessions:
1567
+ continue
1568
+ seen_sessions.add(session_id)
1569
+
1570
+ alive = _is_alive(pid)
1571
+
1572
+ # Claude names project dirs by replacing / with - (keeping leading -)
1573
+ project_key = cwd.replace("/", "-").replace("\\", "-")
1574
+ project_dir = _CLAUDE_PROJECTS / project_key
1575
+
1576
+ stats = _session_stats(session_id, project_dir) if project_dir.exists() else {}
1577
+
1578
+ rows.append({
1579
+ "pid": pid,
1580
+ "session_id": session_id[:8],
1581
+ "project": Path(cwd).name if cwd else "—",
1582
+ "cwd": cwd,
1583
+ "model": stats.get("model", "—"),
1584
+ "turns": stats.get("turns", 0),
1585
+ "ctx_pct": stats.get("ctx_pct", 0),
1586
+ "ctx_tokens": stats.get("ctx_tokens", 0),
1587
+ "total_in": stats.get("total_in", 0),
1588
+ "total_out": stats.get("total_out", 0),
1589
+ "guard": guard_on,
1590
+ "alive": alive,
1591
+ "kind": kind,
1592
+ "started_at": started_at,
1593
+ })
1594
+
1595
+ return sorted(rows, key=lambda r: r["started_at"], reverse=True)
1596
+
1597
+
1598
+ def _fmt_tokens(n: int) -> str:
1599
+ if n >= 1_000_000:
1600
+ return f"{n/1_000_000:.1f}M"
1601
+ if n >= 1_000:
1602
+ return f"{n/1_000:.1f}k"
1603
+ return str(n)
1604
+
1605
+
1606
+ def _ctx_bar(pct: int, width: int = 10) -> str:
1607
+ filled = round(pct / 100 * width)
1608
+ bar = "█" * filled + "░" * (width - filled)
1609
+ color = RED if pct >= 80 else YELLOW if pct >= 60 else GREEN
1610
+ return f"{color}{bar}{RESET} {pct}%"
1611
+
1612
+
1613
+ def _render_table(rows: list[dict]) -> str:
1614
+ if not rows:
1615
+ return f"\n{GRAY} No Claude Code sessions found.{RESET}\n"
1616
+
1617
+ lines = []
1618
+ header = (
1619
+ f" {BOLD}{'PROJECT':<18} {'SESSION':<10} {'MODEL':<18} "
1620
+ f"{'CTX':>14} {'TOKENS IN':>10} {'TURNS':>6} {'GUARD':>6} {'STATUS':>8}{RESET}"
1621
+ )
1622
+ lines.append(header)
1623
+ lines.append(" " + "─" * 95)
1624
+
1625
+ 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 "—"
1629
+ ctx_display = _ctx_bar(r["ctx_pct"]) if r["ctx_pct"] else f"{GRAY}{'—':>14}{RESET}"
1630
+ tokens_str = _fmt_tokens(r["ctx_tokens"]) if r["ctx_tokens"] else "—"
1631
+
1632
+ lines.append(
1633
+ f" {r['project']:<18} {r['session_id']:<10} {model_short:<18} "
1634
+ f"{ctx_display} {tokens_str:>10} {r['turns']:>6} {guard_str:>6} {status_str}"
1635
+ )
1636
+
1637
+ lines.append("")
1638
+ return "\n".join(lines)
1639
+
1640
+
1641
+ def _render_tui(rows: list[dict]) -> None:
1642
+ """Full-screen TUI with live refresh using ANSI escape codes."""
1643
+ 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.")
1710
+
1711
+ except Exception as e:
1712
+ # Graceful fallback to table if TUI setup fails
1713
+ print(f"{YELLOW}TUI unavailable ({e}), showing table:{RESET}")
1714
+ print(_render_table(rows))
1715
+
1716
+
1717
+ def cmd_sessions(args):
1718
+ tui = getattr(args, "tui", False)
1719
+ watch = getattr(args, "watch", False)
1720
+
1721
+ if tui:
1722
+ _render_tui(_load_sessions())
1723
+ return
1724
+
1725
+ if watch:
1726
+ try:
1727
+ import signal
1728
+ stop = False
1729
+ def _sig(s, f): nonlocal stop; stop = True
1730
+ signal.signal(signal.SIGINT, _sig)
1731
+ while not stop:
1732
+ sys.stdout.write("\033[2J\033[H")
1733
+ sys.stdout.flush()
1734
+ rows = _load_sessions()
1735
+ print(f"\n{BOLD}conduct sessions{RESET} {GRAY}(Ctrl+C to stop · refreshing every 5s){RESET}")
1736
+ print(_render_table(rows))
1737
+ for _ in range(50):
1738
+ time.sleep(0.1)
1739
+ if stop: break
1740
+ except KeyboardInterrupt:
1741
+ pass
1742
+ return
1743
+
1744
+ rows = _load_sessions()
1745
+ print(f"\n{BOLD}conduct sessions{RESET}")
1746
+ print(_render_table(rows))
1747
+
1748
+
1461
1749
  def cmd_run(args):
1462
1750
  server, workspace_id, api_key, token = _require_auth(args)
1463
1751
  json_h = api.headers(workspace_id, token, "application/json", api_key)
@@ -1624,6 +1912,10 @@ def main():
1624
1912
  guard_p, _guard_sub = _guard.register_guard_parser(sub)
1625
1913
 
1626
1914
  # conduct mcp
1915
+ sessions_p = sub.add_parser("sessions", help="Show active Claude Code / Codex sessions")
1916
+ sessions_p.add_argument("--watch", action="store_true", help="Refresh every 5s (table view)")
1917
+ sessions_p.add_argument("--tui", action="store_true", help="Full-screen TUI with live panels")
1918
+
1627
1919
  mcp_p = sub.add_parser("mcp", help="Manage the Conduct MCP server")
1628
1920
  mcp_sub = mcp_p.add_subparsers(dest="mcp_command")
1629
1921
  mcp_sub.add_parser("install", help="Register conduct-mcp in Claude Code and Codex")
@@ -1676,6 +1968,8 @@ def main():
1676
1968
  cmd_switch(args)
1677
1969
  elif args.command == "whoami":
1678
1970
  cmd_whoami(args)
1971
+ elif args.command == "sessions":
1972
+ cmd_sessions(args)
1679
1973
  elif args.command == "guard":
1680
1974
  _guard.dispatch_guard(args, guard_p)
1681
1975
  elif args.command == "mcp":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.41
3
+ Version: 0.4.43
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