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.
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/PKG-INFO +1 -1
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/pyproject.toml +1 -1
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli/main.py +297 -3
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/README.md +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/setup.cfg +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/setup.py +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli/guard.py +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.4.41 → conduct_cli-0.4.43}/tests/test_switch.py +0 -0
|
@@ -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
|
-
|
|
1391
|
-
except Exception:
|
|
1392
|
-
|
|
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":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|