mainai 0.1.0__py3-none-any.whl

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.
mainai/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
mainai/cli.py ADDED
@@ -0,0 +1,148 @@
1
+ """mainai -- pick which coding agent to continue a project with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from mainai import __version__, llm
10
+ from mainai.llm_picker import llm_pick
11
+ from mainai.picker import Pick, heuristic_pick
12
+ from mainai.readers import all_sessions_for
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="mainai",
18
+ description=(
19
+ "Find which coding agent (Claude Code, Codex, ...) last did real "
20
+ "work in a project folder, and show which one to continue with. "
21
+ "Run with no arguments to process the current directory."
22
+ ),
23
+ )
24
+ parser.add_argument(
25
+ "folder",
26
+ nargs="?",
27
+ default=".",
28
+ help="project folder to inspect (default: current directory)",
29
+ )
30
+ parser.add_argument(
31
+ "--list",
32
+ action="store_true",
33
+ help=(
34
+ "show the sessions found and a quick file-evidence guess, nothing "
35
+ "else (no file written, no LLM call)"
36
+ ),
37
+ )
38
+ parser.add_argument(
39
+ "--agent",
40
+ metavar="NAME",
41
+ help="override the pick, e.g. --agent codex (no LLM call)",
42
+ )
43
+ parser.add_argument(
44
+ "--handoff",
45
+ action="store_true",
46
+ help="also summarize the other agents' sessions into HANDOFF.md (uses an LLM)",
47
+ )
48
+ parser.add_argument(
49
+ "-y",
50
+ "--yes",
51
+ action="store_true",
52
+ help=(
53
+ "don't ask for confirmation before an LLM call that would bill "
54
+ "ANTHROPIC_API_KEY directly (no claude.ai subscription login found)"
55
+ ),
56
+ )
57
+ parser.add_argument(
58
+ "--version",
59
+ action="version",
60
+ version=f"%(prog)s {__version__}",
61
+ )
62
+ return parser
63
+
64
+
65
+ def _confirm_api_key_billing(yes: bool) -> bool:
66
+ print(
67
+ "mainai: no claude.ai subscription login found; the LLM pick would bill "
68
+ "ANTHROPIC_API_KEY directly (pay-per-token), not a subscription.",
69
+ file=sys.stderr,
70
+ )
71
+ if yes:
72
+ return True
73
+ if not sys.stdin.isatty():
74
+ print(
75
+ "mainai: not an interactive terminal; re-run with --yes to proceed anyway, "
76
+ "or use --list / --agent to avoid the LLM call.",
77
+ file=sys.stderr,
78
+ )
79
+ return False
80
+ reply = input("Proceed and bill ANTHROPIC_API_KEY for this call? [y/N] ").strip().lower()
81
+ return reply in ("y", "yes")
82
+
83
+
84
+ def main(argv: list[str] | None = None) -> int:
85
+ parser = build_parser()
86
+ args = parser.parse_args(argv)
87
+
88
+ folder = Path(args.folder).expanduser()
89
+ if not folder.is_dir():
90
+ print(f"mainai: {folder} is not a directory", file=sys.stderr)
91
+ return 1
92
+
93
+ sessions = all_sessions_for(folder)
94
+
95
+ if not sessions:
96
+ print(f"No sessions found for {folder.resolve()}")
97
+ return 0
98
+
99
+ if args.agent:
100
+ chosen = next((s for s in sessions if s.agent == args.agent), None)
101
+ if chosen is None:
102
+ print(f"mainai: no session found for agent '{args.agent}'", file=sys.stderr)
103
+ return 1
104
+ result = Pick(session=chosen, reasons=[f"overridden with --agent {args.agent}"])
105
+ elif args.list:
106
+ result = heuristic_pick(sessions, folder)
107
+ else:
108
+ auth_mode = llm.determine_auth_mode()
109
+ if auth_mode == llm.AuthMode.UNAVAILABLE:
110
+ print(
111
+ "mainai: no usable `claude` auth found (no claude.ai login, no "
112
+ "ANTHROPIC_API_KEY). Run `claude login`, or use --list / --agent "
113
+ "to avoid the LLM call.",
114
+ file=sys.stderr,
115
+ )
116
+ return 1
117
+ if auth_mode == llm.AuthMode.API_KEY and not _confirm_api_key_billing(args.yes):
118
+ print("mainai: aborted.", file=sys.stderr)
119
+ return 1
120
+ result = llm_pick(sessions, folder)
121
+
122
+ chosen = result.session
123
+
124
+ print(f"Found {len(sessions)} session(s) in {folder.resolve()}")
125
+ for session in sessions:
126
+ marker = " <- latest work" if session is chosen else ""
127
+ print(f' {session.agent:<8} {session.end.date()} "{session.summary}"{marker}')
128
+
129
+ if args.list:
130
+ return 0
131
+
132
+ if chosen is None:
133
+ return 0
134
+
135
+ print()
136
+ print(f"Continue with: {chosen.agent} ({'; '.join(result.reasons)})")
137
+ print(f" Advice: cd {chosen.cwd} && {chosen.agent}")
138
+
139
+ if args.handoff:
140
+ print()
141
+ print("mainai: --handoff (LLM summarizer) is not implemented yet.", file=sys.stderr)
142
+ return 1
143
+
144
+ return 0
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())
mainai/llm.py ADDED
@@ -0,0 +1,112 @@
1
+ """Thin wrapper around calling an LLM in headless/non-interactive mode.
2
+
3
+ Default backend is `claude -p` (Claude Code headless mode): no separate API
4
+ key needed when the user has a claude.ai login, since it draws on that
5
+ login's included usage. Kept behind this small interface so `codex exec` or
6
+ `grok -p` can be swapped in later (see mainai.md).
7
+
8
+ Many developers keep ANTHROPIC_API_KEY set for their own API projects, and
9
+ `claude -p` prefers that key over a claude.ai login when both are present.
10
+ mainai must not silently bill someone's personal API key for a background
11
+ utility call, so every call here strips ANTHROPIC_API_KEY from the
12
+ subprocess environment whenever a claude.ai login is available
13
+ (`determine_auth_mode` -> AuthMode.SUBSCRIPTION). When no claude.ai login
14
+ exists, the caller (mainai.cli) must get explicit confirmation before a call
15
+ that will bill the API key.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ from enum import Enum
25
+
26
+
27
+ class LLMError(RuntimeError):
28
+ pass
29
+
30
+
31
+ class AuthMode(Enum):
32
+ SUBSCRIPTION = "subscription" # claude.ai login available; API key (if any) is stripped
33
+ API_KEY = "api_key" # no claude.ai login; a call would bill ANTHROPIC_API_KEY
34
+ UNAVAILABLE = "unavailable" # claude CLI missing, or no usable auth at all
35
+
36
+
37
+ def determine_auth_mode() -> AuthMode:
38
+ if shutil.which("claude") is None:
39
+ return AuthMode.UNAVAILABLE
40
+
41
+ env = dict(os.environ)
42
+ env.pop("ANTHROPIC_API_KEY", None)
43
+ status: dict = {}
44
+ try:
45
+ result = subprocess.run(
46
+ ["claude", "auth", "status"],
47
+ capture_output=True,
48
+ text=True,
49
+ timeout=15,
50
+ env=env,
51
+ )
52
+ status = json.loads(result.stdout)
53
+ except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
54
+ status = {}
55
+
56
+ if status.get("loggedIn") and status.get("authMethod") == "claude.ai":
57
+ return AuthMode.SUBSCRIPTION
58
+ if os.environ.get("ANTHROPIC_API_KEY"):
59
+ return AuthMode.API_KEY
60
+ return AuthMode.UNAVAILABLE
61
+
62
+
63
+ def call(prompt: str, *, model: str = "sonnet", timeout: int = 180) -> str:
64
+ """Run `prompt` through `claude -p` and return the text result.
65
+
66
+ Raises LLMError on anything that keeps the caller from getting a usable
67
+ answer: missing CLI, process failure, timeout, or unparseable output.
68
+ """
69
+ if shutil.which("claude") is None:
70
+ raise LLMError("the `claude` CLI is not on PATH")
71
+
72
+ env = dict(os.environ)
73
+ if determine_auth_mode() == AuthMode.SUBSCRIPTION:
74
+ env.pop("ANTHROPIC_API_KEY", None)
75
+
76
+ try:
77
+ result = subprocess.run(
78
+ [
79
+ "claude",
80
+ "-p",
81
+ prompt,
82
+ "--model",
83
+ model,
84
+ "--output-format",
85
+ "json",
86
+ "--no-session-persistence",
87
+ "--tools",
88
+ "",
89
+ ],
90
+ capture_output=True,
91
+ text=True,
92
+ timeout=timeout,
93
+ env=env,
94
+ )
95
+ except subprocess.SubprocessError as exc:
96
+ raise LLMError(f"failed to run claude -p: {exc}") from exc
97
+
98
+ if result.returncode != 0:
99
+ raise LLMError(f"claude -p exited {result.returncode}: {result.stderr.strip()}")
100
+
101
+ try:
102
+ payload = json.loads(result.stdout)
103
+ except json.JSONDecodeError as exc:
104
+ raise LLMError(f"could not parse claude -p output as JSON: {exc}") from exc
105
+
106
+ if payload.get("is_error"):
107
+ raise LLMError(f"claude -p reported an error: {payload.get('result')}")
108
+
109
+ text = payload.get("result")
110
+ if not text:
111
+ raise LLMError("claude -p returned no text result")
112
+ return text
mainai/llm_picker.py ADDED
@@ -0,0 +1,92 @@
1
+ """LLM-based picker: this is mainai's default decision maker.
2
+
3
+ Instead of only the file-evidence heuristic in `picker.py`, this asks an LLM
4
+ to actually read each session's content -- the summary/title, a capped
5
+ excerpt of turns, the files it touched that still exist on disk, and the
6
+ commands it ran -- and recommend which agent to continue with, in its own
7
+ words. Falls back to `picker.heuristic_pick` when the LLM call fails (CLI
8
+ missing, timeout, bad output) or there's only one session to begin with.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ from mainai import llm
17
+ from mainai.models import Session
18
+ from mainai.picker import Pick, heuristic_pick
19
+
20
+ MAX_TURN_CHARS = 400
21
+ MAX_TURNS_PER_SESSION = 6
22
+
23
+
24
+ def llm_pick(sessions: list[Session], folder: Path) -> Pick:
25
+ if not sessions:
26
+ return Pick(session=None, reasons=["no sessions found for this folder"])
27
+ if len(sessions) == 1:
28
+ return Pick(session=sessions[0], reasons=["only one session found"])
29
+
30
+ try:
31
+ raw = llm.call(_build_prompt(sessions, folder))
32
+ data = json.loads(raw)
33
+ agent = data["agent"]
34
+ reason = str(data.get("reason", "")).strip()
35
+ except (llm.LLMError, json.JSONDecodeError, KeyError, TypeError) as exc:
36
+ fallback = heuristic_pick(sessions, folder)
37
+ fallback.reasons.insert(
38
+ 0, f"LLM pick unavailable ({exc}); used file-evidence heuristic instead"
39
+ )
40
+ return fallback
41
+
42
+ matches = [s for s in sessions if s.agent == agent]
43
+ if not matches:
44
+ fallback = heuristic_pick(sessions, folder)
45
+ fallback.reasons.insert(
46
+ 0,
47
+ f"LLM named unrecognized agent '{agent}'; used file-evidence heuristic instead",
48
+ )
49
+ return fallback
50
+
51
+ chosen = max(matches, key=lambda s: s.end)
52
+ return Pick(session=chosen, reasons=[reason] if reason else ["LLM pick"])
53
+
54
+
55
+ def _session_excerpt(session: Session) -> dict:
56
+ existing_files = sorted(f for f in session.files_touched if Path(f).exists())
57
+ turns = [
58
+ f"{turn.role}: {turn.text[:MAX_TURN_CHARS]}"
59
+ for turn in session.turns[:MAX_TURNS_PER_SESSION]
60
+ if turn.text.strip()
61
+ ]
62
+ return {
63
+ "agent": session.agent,
64
+ "start": session.start.isoformat(),
65
+ "end": session.end.isoformat(),
66
+ "title": session.title,
67
+ "files_touched_still_on_disk": existing_files,
68
+ "commands_run": session.commands_run[:10],
69
+ "turns_excerpt": turns,
70
+ }
71
+
72
+
73
+ def _build_prompt(sessions: list[Session], folder: Path) -> str:
74
+ data = [_session_excerpt(s) for s in sessions]
75
+ agents = sorted({s.agent for s in sessions})
76
+ return (
77
+ "A developer worked on the same project folder using several different "
78
+ "coding agents (Claude Code, Codex, Grok, ...), at different times. Below "
79
+ "are summaries of each agent's most recent session in this folder. Decide "
80
+ "which session represents the most recent REAL work worth continuing from. "
81
+ "A session's newest message timestamp alone can be misleading -- e.g. a "
82
+ "trivial side question can be the newest message in an otherwise stale "
83
+ "session. Weigh the actual conversation content, the files it touched that "
84
+ "still exist on disk, and the commands it ran.\n\n"
85
+ f"Project folder: {folder}\n"
86
+ f"Candidate agents: {', '.join(agents)}\n\n"
87
+ "Sessions (JSON):\n"
88
+ f"{json.dumps(data, indent=2)}\n\n"
89
+ "Reply with ONLY a JSON object and nothing else, in this exact shape:\n"
90
+ '{"agent": "<one of the candidate agents above>", '
91
+ '"reason": "<one or two sentence explanation a developer would find useful>"}'
92
+ )
mainai/models.py ADDED
@@ -0,0 +1,35 @@
1
+ """Common record types every per-agent reader converts its log format into."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+
8
+
9
+ @dataclass
10
+ class Turn:
11
+ role: str # "user" | "assistant"
12
+ text: str
13
+ timestamp: datetime | None = None
14
+
15
+
16
+ @dataclass
17
+ class Session:
18
+ agent: str # "claude", "codex", "grok", ...
19
+ session_id: str
20
+ cwd: str
21
+ start: datetime
22
+ end: datetime
23
+ title: str | None = None
24
+ turns: list[Turn] = field(default_factory=list)
25
+ files_touched: set[str] = field(default_factory=set)
26
+ commands_run: list[str] = field(default_factory=list)
27
+
28
+ @property
29
+ def summary(self) -> str:
30
+ if self.title:
31
+ return self.title
32
+ for turn in self.turns:
33
+ if turn.role == "user" and turn.text.strip():
34
+ return turn.text.strip().splitlines()[0][:80]
35
+ return "(no prompt text found)"
mainai/picker.py ADDED
@@ -0,0 +1,80 @@
1
+ """Heuristic, LLM-free picker: file evidence + git history only.
2
+
3
+ This is no longer mainai's default decision maker -- see `llm_picker` for
4
+ that, which asks an LLM to read the actual session content. This module is
5
+ kept as the free, instant path for `--list`, and as the fallback when the
6
+ LLM call fails or isn't available.
7
+
8
+ Raw "latest message" can mislead -- a trivial side question can be the
9
+ newest turn. The stronger signal is file evidence: does a file the session
10
+ touched still exist on disk, matching what the session wrote. Git history is
11
+ used as a secondary cross-check when the folder is a repo.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from dataclasses import dataclass
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ from mainai.models import Session
22
+
23
+
24
+ @dataclass
25
+ class Pick:
26
+ session: Session | None
27
+ reasons: list[str]
28
+
29
+
30
+ def heuristic_pick(sessions: list[Session], folder: Path) -> Pick:
31
+ if not sessions:
32
+ return Pick(session=None, reasons=["no sessions found for this folder"])
33
+
34
+ folder = folder.resolve()
35
+ latest_commit_ts = _latest_git_commit_timestamp(folder)
36
+
37
+ scored = []
38
+ for session in sessions:
39
+ existing = {f for f in session.files_touched if Path(f).exists()}
40
+ scored.append((session, bool(existing), len(existing)))
41
+
42
+ scored.sort(key=lambda item: (item[1], item[2], item[0].end), reverse=True)
43
+ best_session, has_evidence, existing_count = scored[0]
44
+
45
+ reasons = []
46
+ if has_evidence:
47
+ reasons.append(f"{existing_count} file(s) it touched still exist on disk")
48
+ else:
49
+ reasons.append("no session has file evidence on disk; picked by latest activity")
50
+ reasons.append(f"most recent turn {best_session.end.isoformat()}")
51
+
52
+ if latest_commit_ts is not None:
53
+ if best_session.start <= latest_commit_ts <= best_session.end:
54
+ reasons.append("its time range contains the latest git commit")
55
+ else:
56
+ reasons.append(f"latest git commit was at {latest_commit_ts.isoformat()}")
57
+
58
+ return Pick(session=best_session, reasons=reasons)
59
+
60
+
61
+ def _latest_git_commit_timestamp(folder: Path) -> datetime | None:
62
+ if not (folder / ".git").exists():
63
+ return None
64
+ try:
65
+ result = subprocess.run(
66
+ ["git", "-C", str(folder), "log", "-1", "--format=%cI"],
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=5,
70
+ check=True,
71
+ )
72
+ except (subprocess.SubprocessError, OSError):
73
+ return None
74
+ value = result.stdout.strip()
75
+ if not value:
76
+ return None
77
+ try:
78
+ return datetime.fromisoformat(value)
79
+ except ValueError:
80
+ return None
@@ -0,0 +1,25 @@
1
+ """One reader per coding agent. Each exposes `sessions_for(folder)` and returns
2
+ a list of `mainai.models.Session`, filtered to sessions whose cwd is the given
3
+ folder or a subfolder of it. Readers must never crash on a malformed or
4
+ unrecognized log line -- skip it and move on.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from mainai.models import Session
12
+ from mainai.readers import claude_code, codex
13
+
14
+ READERS = {
15
+ "claude": claude_code.sessions_for,
16
+ "codex": codex.sessions_for,
17
+ }
18
+
19
+
20
+ def all_sessions_for(folder: Path) -> list[Session]:
21
+ sessions: list[Session] = []
22
+ for reader in READERS.values():
23
+ sessions.extend(reader(folder))
24
+ sessions.sort(key=lambda s: s.end, reverse=True)
25
+ return sessions
@@ -0,0 +1,141 @@
1
+ """Reader for Claude Code's own transcripts.
2
+
3
+ Format: JSONL under ~/.claude/projects/<cwd-slug>/<session-id>.jsonl, one
4
+ event per line. `cwd`, `timestamp`, `sessionId` appear on most event types.
5
+ The format is undocumented and has grown new event types across versions
6
+ (mode, permission-mode, file-history-snapshot, attachment, ai-title, ...);
7
+ this reader only looks at the few types it understands and skips the rest.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ from mainai.models import Session, Turn
17
+
18
+ PROJECTS_DIR = Path.home() / ".claude" / "projects"
19
+
20
+ EDIT_TOOLS = {"Edit", "Write", "NotebookEdit", "MultiEdit"}
21
+
22
+
23
+ def sessions_for(folder: Path) -> list[Session]:
24
+ folder = folder.resolve()
25
+ if not PROJECTS_DIR.is_dir():
26
+ return []
27
+
28
+ sessions = []
29
+ for project_dir in PROJECTS_DIR.iterdir():
30
+ if not project_dir.is_dir():
31
+ continue
32
+ for jsonl_path in project_dir.glob("*.jsonl"):
33
+ session = _read_session(jsonl_path, folder)
34
+ if session is not None:
35
+ sessions.append(session)
36
+ return sessions
37
+
38
+
39
+ def _read_session(path: Path, folder: Path) -> Session | None:
40
+ session_id = path.stem
41
+ cwd: str | None = None
42
+ title: str | None = None
43
+ start: datetime | None = None
44
+ end: datetime | None = None
45
+ turns: list[Turn] = []
46
+ files_touched: set[str] = set()
47
+ commands_run: list[str] = []
48
+
49
+ try:
50
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
51
+ except OSError:
52
+ return None
53
+
54
+ for line in lines:
55
+ line = line.strip()
56
+ if not line:
57
+ continue
58
+ try:
59
+ event = json.loads(line)
60
+ except json.JSONDecodeError:
61
+ continue
62
+ if not isinstance(event, dict):
63
+ continue
64
+
65
+ event_cwd = event.get("cwd")
66
+ if event_cwd:
67
+ cwd = event_cwd
68
+
69
+ ts = _parse_timestamp(event.get("timestamp"))
70
+ if ts is not None:
71
+ if start is None or ts < start:
72
+ start = ts
73
+ if end is None or ts > end:
74
+ end = ts
75
+
76
+ event_type = event.get("type")
77
+
78
+ if event_type == "ai-title":
79
+ title = event.get("aiTitle") or title
80
+ continue
81
+
82
+ if event_type not in ("user", "assistant"):
83
+ continue
84
+
85
+ message = event.get("message")
86
+ if not isinstance(message, dict):
87
+ continue
88
+ role = message.get("role", event_type)
89
+ content = message.get("content")
90
+
91
+ if isinstance(content, str):
92
+ if not event.get("isMeta") and content.strip():
93
+ turns.append(Turn(role=role, text=content, timestamp=ts))
94
+ elif isinstance(content, list):
95
+ for block in content:
96
+ if not isinstance(block, dict):
97
+ continue
98
+ block_type = block.get("type")
99
+ if block_type == "text" and not event.get("isMeta"):
100
+ text = block.get("text", "")
101
+ if text.strip():
102
+ turns.append(Turn(role=role, text=text, timestamp=ts))
103
+ elif block_type == "tool_use":
104
+ name = block.get("name")
105
+ tool_input = block.get("input") or {}
106
+ if name in EDIT_TOOLS:
107
+ file_path = tool_input.get("file_path")
108
+ if file_path:
109
+ files_touched.add(file_path)
110
+ elif name == "Bash":
111
+ command = tool_input.get("command")
112
+ if command:
113
+ commands_run.append(command)
114
+
115
+ if cwd is None or start is None or end is None:
116
+ return None
117
+
118
+ cwd_path = Path(cwd).resolve()
119
+ if cwd_path != folder and folder not in cwd_path.parents:
120
+ return None
121
+
122
+ return Session(
123
+ agent="claude",
124
+ session_id=session_id,
125
+ cwd=cwd,
126
+ start=start,
127
+ end=end,
128
+ title=title,
129
+ turns=turns,
130
+ files_touched=files_touched,
131
+ commands_run=commands_run,
132
+ )
133
+
134
+
135
+ def _parse_timestamp(value: object) -> datetime | None:
136
+ if not isinstance(value, str) or not value:
137
+ return None
138
+ try:
139
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
140
+ except ValueError:
141
+ return None
@@ -0,0 +1,144 @@
1
+ """Reader for Codex CLI session logs.
2
+
3
+ Format: JSONL under ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl (or, when
4
+ Codex is installed via snap, ~/snap/codex/<rev>/sessions/YYYY/MM/DD/). First
5
+ line is `type: session_meta` with `payload.cwd`, `payload.id`,
6
+ `payload.timestamp`. Then `response_item` (message, function_call,
7
+ function_call_output, reasoning), `event_msg`, `turn_context`. Undocumented
8
+ and version-dependent; unrecognized line types and payload shapes are
9
+ skipped.
10
+
11
+ Codex has no dedicated "edit file" tool call in the sessions seen so far --
12
+ file edits happen via shell commands run through `exec_command` -- so
13
+ `files_touched` is left empty here; only `commands_run` is populated.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+
22
+ from mainai.models import Session, Turn
23
+
24
+
25
+ def _session_bases() -> list[Path]:
26
+ bases = [Path.home() / ".codex" / "sessions"]
27
+ snap_root = Path.home() / "snap" / "codex"
28
+ if snap_root.is_dir():
29
+ for rev_dir in snap_root.iterdir():
30
+ candidate = rev_dir / "sessions"
31
+ if candidate.is_dir():
32
+ bases.append(candidate)
33
+ return bases
34
+
35
+
36
+ def sessions_for(folder: Path) -> list[Session]:
37
+ folder = folder.resolve()
38
+ sessions = []
39
+ for base in _session_bases():
40
+ if not base.is_dir():
41
+ continue
42
+ for jsonl_path in base.glob("*/*/*/rollout-*.jsonl"):
43
+ session = _read_session(jsonl_path, folder)
44
+ if session is not None:
45
+ sessions.append(session)
46
+ return sessions
47
+
48
+
49
+ def _read_session(path: Path, folder: Path) -> Session | None:
50
+ session_id: str | None = None
51
+ cwd: str | None = None
52
+ start: datetime | None = None
53
+ end: datetime | None = None
54
+ turns: list[Turn] = []
55
+ commands_run: list[str] = []
56
+
57
+ try:
58
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
59
+ except OSError:
60
+ return None
61
+
62
+ for line in lines:
63
+ line = line.strip()
64
+ if not line:
65
+ continue
66
+ try:
67
+ event = json.loads(line)
68
+ except json.JSONDecodeError:
69
+ continue
70
+ if not isinstance(event, dict):
71
+ continue
72
+
73
+ ts = _parse_timestamp(event.get("timestamp"))
74
+ if ts is not None:
75
+ if start is None or ts < start:
76
+ start = ts
77
+ if end is None or ts > end:
78
+ end = ts
79
+
80
+ event_type = event.get("type")
81
+ payload = event.get("payload")
82
+ if not isinstance(payload, dict):
83
+ continue
84
+
85
+ if event_type == "session_meta":
86
+ session_id = payload.get("id") or payload.get("session_id") or session_id
87
+ cwd = payload.get("cwd") or cwd
88
+ continue
89
+
90
+ if event_type != "response_item":
91
+ continue
92
+
93
+ item_type = payload.get("type")
94
+
95
+ if item_type == "message":
96
+ role = payload.get("role")
97
+ if role not in ("user", "assistant"):
98
+ continue
99
+ content = payload.get("content")
100
+ if not isinstance(content, list):
101
+ continue
102
+ text = "".join(
103
+ block.get("text", "")
104
+ for block in content
105
+ if isinstance(block, dict)
106
+ and block.get("type") in ("input_text", "output_text", "text")
107
+ ).strip()
108
+ if not text or text.startswith("<environment_context>"):
109
+ continue
110
+ turns.append(Turn(role=role, text=text, timestamp=ts))
111
+ elif item_type == "function_call" and payload.get("name") == "exec_command":
112
+ try:
113
+ arguments = json.loads(payload.get("arguments") or "{}")
114
+ except json.JSONDecodeError:
115
+ arguments = {}
116
+ command = arguments.get("cmd")
117
+ if command:
118
+ commands_run.append(command)
119
+
120
+ if session_id is None or cwd is None or start is None or end is None:
121
+ return None
122
+
123
+ cwd_path = Path(cwd).resolve()
124
+ if cwd_path != folder and folder not in cwd_path.parents:
125
+ return None
126
+
127
+ return Session(
128
+ agent="codex",
129
+ session_id=session_id,
130
+ cwd=cwd,
131
+ start=start,
132
+ end=end,
133
+ turns=turns,
134
+ commands_run=commands_run,
135
+ )
136
+
137
+
138
+ def _parse_timestamp(value: object) -> datetime | None:
139
+ if not isinstance(value, str) or not value:
140
+ return None
141
+ try:
142
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
143
+ except ValueError:
144
+ return None
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: mainai
3
+ Version: 0.1.0
4
+ Summary: Pick the right coding agent to continue a project with, across Claude Code, Codex, and Grok sessions.
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+
8
+ # mainai
9
+
10
+ A CLI that figures out which coding agent (Claude Code, Codex, Grok, ...)
11
+ last did real work in a project folder, and hands off cleanly to the one you
12
+ pick next.
13
+
14
+ ## The problem
15
+
16
+ Working the same project folders with several coding agents means that
17
+ coming back months later, it's unclear which agent did the most recent real
18
+ work, and what the other agents did in the meantime.
19
+
20
+ ## What it does
21
+
22
+ ```
23
+ $ mainai .
24
+ Found 6 sessions in ~/Dropbox/inflammatix/projects/endotypes
25
+ codex 2026-09-25 "fix the endotype clustering..." <- latest work
26
+ claude 2026-09-18 "add the GSE cohort loader..."
27
+ ...
28
+ Continue with: Codex (its edits match the current files)
29
+ Wrote HANDOFF.md (summaries of the 4 Claude sessions)
30
+ Launching: codex "Read HANDOFF.md, then continue where we left off."
31
+ ```
32
+
33
+ 1. Reads every agent's stored conversations, keeps the ones for the current
34
+ folder.
35
+ 2. Decides which agent to continue with, based on which session's edits
36
+ match the files actually on disk (not just the newest timestamp).
37
+ 3. Summarizes the relevant parts of the *other* agents' sessions into
38
+ `HANDOFF.md`.
39
+ 4. Starts the chosen agent, pointed at `HANDOFF.md`.
40
+
41
+ `mainai` with no arguments prints help and does nothing, so a stray bare
42
+ invocation never spends LLM usage. Flags: `--list` (sessions + pick only, no
43
+ LLM, no write), `--agent NAME` (override the pick), `--no-launch` (write
44
+ `HANDOFF.md` but don't start the agent).
45
+
46
+ See `mainai.md` for the full design notes (reader architecture, per-agent log
47
+ formats, picker logic, summarizer, scope/build order).
48
+
49
+ ## Status
50
+
51
+ Not yet built. Design only.
52
+
53
+ ## Development
54
+
55
+ uv-managed project.
56
+
57
+ ```bash
58
+ uv sync
59
+ uv run mainai --list .
60
+ ```
@@ -0,0 +1,13 @@
1
+ mainai/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ mainai/cli.py,sha256=JbzrtdVqIdT9T58B_Gket3xe2aW5CBKy0Qe5VmJXxsA,4622
3
+ mainai/llm.py,sha256=nm1eiLJ0SjygS8qxviJ5jm1dXKAASVlLFHI6w3sJNTo,3715
4
+ mainai/llm_picker.py,sha256=k0KQLpA4DD8ljApW6QOnXJHPu8gUSQnutQMgONLVYok,3713
5
+ mainai/models.py,sha256=yhCSaG49IvSUTnRFSce5sI9fYZAhzYZ4IdEunxxIc9o,936
6
+ mainai/picker.py,sha256=Wbb0qkFqMezQdpCS9SERGS0psafkfhJnihp91E7gN_E,2678
7
+ mainai/readers/__init__.py,sha256=FYpN7QN8XXoZlSV8PaFhI2yNpkriWs8gAgoIxhk7TX4,749
8
+ mainai/readers/claude_code.py,sha256=hUKHen3jQI0fbriUMKOar0cjglYR23hduWOzWdq5N4A,4408
9
+ mainai/readers/codex.py,sha256=w0_GbbcSCiwzs6HXym1bR2eGBC5F_iJnfuT2ttzpFY4,4571
10
+ mainai-0.1.0.dist-info/METADATA,sha256=xqwzhDQ_MaFCE1RGZ3Z5WlIyPYS1DitKTgWk1k8RRb4,1913
11
+ mainai-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ mainai-0.1.0.dist-info/entry_points.txt,sha256=ly6pPJfBEU9FBvlWgKHBfvfbJPH0fgures8s7JaweD4,43
13
+ mainai-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mainai = mainai.cli:main