mainai 0.1.0__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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.12
mainai-0.1.0/PKG-INFO ADDED
@@ -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
+ ```
mainai-0.1.0/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # mainai
2
+
3
+ A CLI that figures out which coding agent (Claude Code, Codex, Grok, ...)
4
+ last did real work in a project folder, and hands off cleanly to the one you
5
+ pick next.
6
+
7
+ ## The problem
8
+
9
+ Working the same project folders with several coding agents means that
10
+ coming back months later, it's unclear which agent did the most recent real
11
+ work, and what the other agents did in the meantime.
12
+
13
+ ## What it does
14
+
15
+ ```
16
+ $ mainai .
17
+ Found 6 sessions in ~/Dropbox/inflammatix/projects/endotypes
18
+ codex 2026-09-25 "fix the endotype clustering..." <- latest work
19
+ claude 2026-09-18 "add the GSE cohort loader..."
20
+ ...
21
+ Continue with: Codex (its edits match the current files)
22
+ Wrote HANDOFF.md (summaries of the 4 Claude sessions)
23
+ Launching: codex "Read HANDOFF.md, then continue where we left off."
24
+ ```
25
+
26
+ 1. Reads every agent's stored conversations, keeps the ones for the current
27
+ folder.
28
+ 2. Decides which agent to continue with, based on which session's edits
29
+ match the files actually on disk (not just the newest timestamp).
30
+ 3. Summarizes the relevant parts of the *other* agents' sessions into
31
+ `HANDOFF.md`.
32
+ 4. Starts the chosen agent, pointed at `HANDOFF.md`.
33
+
34
+ `mainai` with no arguments prints help and does nothing, so a stray bare
35
+ invocation never spends LLM usage. Flags: `--list` (sessions + pick only, no
36
+ LLM, no write), `--agent NAME` (override the pick), `--no-launch` (write
37
+ `HANDOFF.md` but don't start the agent).
38
+
39
+ See `mainai.md` for the full design notes (reader architecture, per-agent log
40
+ formats, picker logic, summarizer, scope/build order).
41
+
42
+ ## Status
43
+
44
+ Not yet built. Design only.
45
+
46
+ ## Development
47
+
48
+ uv-managed project.
49
+
50
+ ```bash
51
+ uv sync
52
+ uv run mainai --list .
53
+ ```
mainai-0.1.0/mainai.md ADDED
@@ -0,0 +1,156 @@
1
+ # MainAI (mainai) - project brief
2
+
3
+ Handoff notes from a Claude Code session in ~/Dropbox/ai/ai (2026-10-04/05).
4
+ Nothing has been built yet; this file is the starting point.
5
+
6
+ ## The problem
7
+
8
+ LB works in the same folders with several coding agents (Claude Code, Codex,
9
+ Grok, maybe Gemini later). Coming back to a project months later, it is
10
+ unclear which agent did the most recent real work, and what the other agents
11
+ did in the meantime.
12
+
13
+ ## What mainai does
14
+
15
+ One CLI command, run on a project folder:
16
+
17
+ 1. Read every agent's stored conversations, keep the ones for that folder.
18
+ 2. Decide which agent to continue with.
19
+ 3. Summarize the relevant parts of the *other* agents' sessions into
20
+ HANDOFF.md.
21
+ 4. Start the chosen agent, already pointed at HANDOFF.md.
22
+
23
+ ## User interaction (decided)
24
+
25
+ A single command. Remembering two commands was called a showstopper.
26
+
27
+ ```
28
+ $ mainai .
29
+ Found 6 sessions in ~/Dropbox/inflammatix/projects/endotypes
30
+ codex 2026-09-25 "fix the endotype clustering..." <- latest work
31
+ claude 2026-09-18 "add the GSE cohort loader..."
32
+ ...
33
+ Continue with: Codex (its edits match the current files)
34
+ Wrote HANDOFF.md (summaries of the 4 Claude sessions)
35
+ Launching: codex "Read HANDOFF.md, then continue where we left off."
36
+ ```
37
+
38
+ - `mainai` with no arguments prints help and does nothing. The folder
39
+ argument (`.`) is the "go" signal, so a stray bare `mainai` never spends
40
+ LLM usage.
41
+ - Optional flags:
42
+ - `--list`: show sessions and the pick only; no LLM call, writes nothing
43
+ - `--agent NAME`: override the pick
44
+ - `--no-launch`: write HANDOFF.md but do not start the agent
45
+ - Launching works because `claude`, `codex` and `grok` all accept a starting
46
+ prompt on the command line.
47
+
48
+ ## Design
49
+
50
+ 1. **One reader per agent.** Each converts its own log format into a common
51
+ record: agent, session id, folder (cwd), start/end time, turns (role,
52
+ text), files touched, commands run. A new agent = one new reader.
53
+ 2. **Filter by folder.** Match the session's cwd to the target folder or a
54
+ subfolder.
55
+ 3. **Pick the agent.** Raw "latest message" can mislead (a trivial side
56
+ question can be the newest message). The stronger signal is file evidence:
57
+ compare the files each session wrote with what is on disk now. The session
58
+ whose edits match the current files did the latest real work. Use git
59
+ history too when the folder is a repo. Print the pick and the reasons.
60
+ 4. **Summarize the others.** First extract without an LLM (user prompts, final
61
+ assistant answers, files changed, decisions, errors), then have an LLM
62
+ condense that into HANDOFF.md with sections: goal, done, decisions + why,
63
+ open items, gotchas, key files. Tag each item with agent and date.
64
+ 5. **Hand off.** Launch the chosen agent with a prompt to read HANDOFF.md.
65
+
66
+ ## Summarizer: `claude -p` (recommended)
67
+
68
+ Claude Code's headless mode: give it a prompt, it prints the answer and exits.
69
+ It uses the existing Claude login and plan (no API key, no extra
70
+ dependencies). The planned call (flags exist in Claude Code 2.1.289; verify
71
+ the exact combination when building):
72
+
73
+ ```
74
+ claude -p "<summarize instructions>" --model sonnet --output-format json \
75
+ --no-session-persistence --tools ""
76
+ ```
77
+
78
+ - `--no-session-persistence` is essential: without it, each summarizing run
79
+ saves its own transcript into ~/.claude/projects, and mainai would then
80
+ read and summarize its own runs.
81
+ - `--tools ""` (or a minimal list): the summarizer only needs the text given.
82
+ - Keep the summarizer behind a small interface so the Anthropic API,
83
+ `codex exec` or `grok -p` can be swapped in with a flag.
84
+ - Privacy: the transcripts (including Codex/Grok ones) go to Anthropic.
85
+
86
+ The API alternative needs a key, bills per token separately and adds a
87
+ dependency, but gives full control (caching, batching). Not worth it for a
88
+ personal tool.
89
+
90
+ ## Where each agent stores sessions (verified on disk)
91
+
92
+ Locations differ per machine. Two machines so far:
93
+
94
+ | Agent | beograd (laptop) | ljubljana |
95
+ |---|---|---|
96
+ | Claude Code | ~/.claude/projects/<path-slug>/<session>.jsonl | same |
97
+ | Codex | ~/snap/codex/<rev>/sessions/YYYY/MM/DD/rollout-*.jsonl (snap; check data survives snap refreshes) | ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl (48 files, 2025-09 to 2026-09) |
98
+ | Grok | not installed | ~/.grok/sessions/<url-encoded cwd>/<session-id>/ (grok 1.0.44) |
99
+ | Gemini | not installed | not installed |
100
+
101
+ Format notes:
102
+
103
+ - **Claude Code**: JSONL, one event per line. `cwd`, `timestamp`,
104
+ `sessionId`, `gitBranch` on each user/assistant line. Path slug = cwd with
105
+ `/` replaced by `-`. Also a `sessions-index.json` per project dir.
106
+ - **Codex**: JSONL. First line `type: session_meta` with `payload.cwd`,
107
+ `payload.id`, `payload.timestamp`. Then `response_item` (message,
108
+ function_call, function_call_output, reasoning), `event_msg` (user_message,
109
+ agent_message, token_count), `turn_context`. Also ~/.codex/history.jsonl
110
+ and ~/.codex/session_index.jsonl on ljubljana.
111
+ - **Grok**: the folder name is the URL-encoded cwd (e.g.
112
+ `%2Fhome%2Fljubomir%2FDropbox%2Fai%2Fcvic`), which makes folder filtering
113
+ easy. Per session: `chat_history.jsonl` (messages; first line is the system
114
+ prompt), `events.jsonl` (ts, type, session_id, model_id), `summary.json`,
115
+ `rewind_points.jsonl`, `updates.jsonl`. Per folder: `prompt_history.jsonl`.
116
+ Also ~/.grok/sessions/session_search.sqlite. Not parsed in detail yet.
117
+
118
+ All formats are undocumented and change between versions; readers must be
119
+ tolerant (skip unknown line types, never crash on one bad line).
120
+
121
+ ## Retention: Claude Code deletes old transcripts
122
+
123
+ Claude Code deletes transcripts after 30 days by default
124
+ (`cleanupPeriodDays`). On beograd only 5 transcripts were left (oldest
125
+ 2026-09-15). So for 6-month-old work, Claude's side may simply be gone.
126
+
127
+ - beograd: `"cleanupPeriodDays": 36500` set in ~/.claude/settings.json on
128
+ 2026-10-04.
129
+ - ljubljana: set on 2026-10-05 (9 transcripts left, oldest 2026-09-04).
130
+
131
+ mainai should warn when a folder has no or few Claude sessions and the
132
+ setting is at its default.
133
+
134
+ ## Scope
135
+
136
+ - v1: Claude Code + Codex readers. Grok reader next (its data is on
137
+ ljubljana). Gemini when installed.
138
+ - Build order: readers + session listing (`mainai . --list`) first, no LLM,
139
+ to check it finds the right sessions; then the picker; then the summarizer
140
+ and launch.
141
+
142
+ ## GitHub release (discussed, not decided)
143
+
144
+ Plausibly worth releasing: multi-agent users are a growing group, and
145
+ cross-agent handoff is the distinguishing feature; per-agent readers invite
146
+ contributions. The cost is maintenance: undocumented log formats break
147
+ readers on agent upgrades. Plan: use it personally for a few weeks first,
148
+ search GitHub for prior art, then decide (or publish "as-is").
149
+
150
+ ## Conventions
151
+
152
+ - Python, uv-managed venv (no system pip installs). Executable entry point
153
+ with a shebang and argparse help that shows option defaults.
154
+ - Never write `__pycache__` (check the global CLAUDE.md on the machine in
155
+ use; the shebang convention differs between beograd and ljubljana).
156
+ - Markdown/text files plain ASCII (LB edits in Emacs).
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "mainai"
3
+ version = "0.1.0"
4
+ description = "Pick the right coding agent to continue a project with, across Claude Code, Codex, and Grok sessions."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = []
8
+
9
+ [project.scripts]
10
+ mainai = "mainai.cli:main"
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/mainai"]
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=9.1.1",
22
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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())
@@ -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
@@ -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
+ )
@@ -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)"
@@ -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,108 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from mainai.readers import claude_code
5
+
6
+
7
+ def _write_jsonl(path: Path, events: list[dict]) -> None:
8
+ path.write_text("\n".join(json.dumps(e) for e in events), encoding="utf-8")
9
+
10
+
11
+ def test_reads_session_matching_folder(tmp_path, monkeypatch):
12
+ project_dir = tmp_path / "projects" / "-home-user-proj"
13
+ project_dir.mkdir(parents=True)
14
+ target = tmp_path / "home" / "user" / "proj"
15
+ target.mkdir(parents=True)
16
+
17
+ events = [
18
+ {
19
+ "type": "user",
20
+ "message": {"role": "user", "content": "fix the bug"},
21
+ "timestamp": "2026-01-01T00:00:00Z",
22
+ "cwd": str(target),
23
+ },
24
+ {
25
+ "type": "assistant",
26
+ "message": {
27
+ "role": "assistant",
28
+ "content": [
29
+ {"type": "text", "text": "done"},
30
+ {
31
+ "type": "tool_use",
32
+ "name": "Edit",
33
+ "input": {"file_path": str(target / "a.py")},
34
+ },
35
+ ],
36
+ },
37
+ "timestamp": "2026-01-01T00:05:00Z",
38
+ "cwd": str(target),
39
+ },
40
+ {"type": "ai-title", "aiTitle": "Fix the bug"},
41
+ ]
42
+ _write_jsonl(project_dir / "session1.jsonl", events)
43
+
44
+ monkeypatch.setattr(claude_code, "PROJECTS_DIR", tmp_path / "projects")
45
+
46
+ sessions = claude_code.sessions_for(target)
47
+
48
+ assert len(sessions) == 1
49
+ session = sessions[0]
50
+ assert session.agent == "claude"
51
+ assert session.title == "Fix the bug"
52
+ assert str(target / "a.py") in session.files_touched
53
+ assert any(t.text == "fix the bug" for t in session.turns)
54
+
55
+
56
+ def test_skips_malformed_lines_without_crashing(tmp_path, monkeypatch):
57
+ project_dir = tmp_path / "projects" / "-home-user-proj"
58
+ project_dir.mkdir(parents=True)
59
+ target = tmp_path / "home" / "user" / "proj"
60
+ target.mkdir(parents=True)
61
+
62
+ path = project_dir / "session2.jsonl"
63
+ path.write_text(
64
+ "not json at all\n"
65
+ + json.dumps({"type": "unknown-future-type", "stuff": 1})
66
+ + "\n"
67
+ + json.dumps(
68
+ {
69
+ "type": "user",
70
+ "message": {"role": "user", "content": "hello"},
71
+ "timestamp": "2026-01-01T00:00:00Z",
72
+ "cwd": str(target),
73
+ }
74
+ ),
75
+ encoding="utf-8",
76
+ )
77
+
78
+ monkeypatch.setattr(claude_code, "PROJECTS_DIR", tmp_path / "projects")
79
+
80
+ sessions = claude_code.sessions_for(target)
81
+
82
+ assert len(sessions) == 1
83
+ assert sessions[0].turns[0].text == "hello"
84
+
85
+
86
+ def test_excludes_sessions_outside_folder(tmp_path, monkeypatch):
87
+ project_dir = tmp_path / "projects" / "-other"
88
+ project_dir.mkdir(parents=True)
89
+ other = tmp_path / "other"
90
+ other.mkdir()
91
+ target = tmp_path / "home" / "user" / "proj"
92
+ target.mkdir(parents=True)
93
+
94
+ _write_jsonl(
95
+ project_dir / "session3.jsonl",
96
+ [
97
+ {
98
+ "type": "user",
99
+ "message": {"role": "user", "content": "unrelated"},
100
+ "timestamp": "2026-01-01T00:00:00Z",
101
+ "cwd": str(other),
102
+ }
103
+ ],
104
+ )
105
+
106
+ monkeypatch.setattr(claude_code, "PROJECTS_DIR", tmp_path / "projects")
107
+
108
+ assert claude_code.sessions_for(target) == []
@@ -0,0 +1,66 @@
1
+ from datetime import datetime, timezone
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from mainai import llm, llm_picker
7
+ from mainai.models import Session
8
+
9
+
10
+ def _session(agent: str, end_hour: int) -> Session:
11
+ return Session(
12
+ agent=agent,
13
+ session_id=f"{agent}-1",
14
+ cwd="/tmp/proj",
15
+ start=datetime(2026, 1, 1, 0, tzinfo=timezone.utc),
16
+ end=datetime(2026, 1, 1, end_hour, tzinfo=timezone.utc),
17
+ title=f"{agent} session",
18
+ turns=[],
19
+ )
20
+
21
+
22
+ def test_single_session_skips_llm(monkeypatch):
23
+ def fail(*a, **k):
24
+ raise AssertionError("llm.call should not be invoked for a single session")
25
+
26
+ monkeypatch.setattr(llm, "call", fail)
27
+ sessions = [_session("claude", 1)]
28
+ result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
29
+ assert result.session is sessions[0]
30
+
31
+
32
+ def test_falls_back_to_heuristic_on_llm_error(monkeypatch):
33
+ def raise_error(*a, **k):
34
+ raise llm.LLMError("claude not found")
35
+
36
+ monkeypatch.setattr(llm, "call", raise_error)
37
+ sessions = [_session("claude", 1), _session("codex", 2)]
38
+ result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
39
+ assert result.session is not None
40
+ assert "LLM pick unavailable" in result.reasons[0]
41
+
42
+
43
+ def test_falls_back_on_unparseable_response(monkeypatch):
44
+ monkeypatch.setattr(llm, "call", lambda *a, **k: "not json")
45
+ sessions = [_session("claude", 1), _session("codex", 2)]
46
+ result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
47
+ assert result.session is not None
48
+ assert "LLM pick unavailable" in result.reasons[0]
49
+
50
+
51
+ def test_uses_llm_choice(monkeypatch):
52
+ monkeypatch.setattr(
53
+ llm, "call", lambda *a, **k: '{"agent": "codex", "reason": "it did the real work"}'
54
+ )
55
+ sessions = [_session("claude", 1), _session("codex", 2)]
56
+ result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
57
+ assert result.session.agent == "codex"
58
+ assert result.reasons == ["it did the real work"]
59
+
60
+
61
+ def test_falls_back_on_unrecognized_agent(monkeypatch):
62
+ monkeypatch.setattr(llm, "call", lambda *a, **k: '{"agent": "grok", "reason": "x"}')
63
+ sessions = [_session("claude", 1), _session("codex", 2)]
64
+ result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
65
+ assert result.session is not None
66
+ assert "unrecognized agent" in result.reasons[0]
mainai-0.1.0/uv.lock ADDED
@@ -0,0 +1,79 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "iniconfig"
16
+ version = "2.3.0"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
21
+ ]
22
+
23
+ [[package]]
24
+ name = "mainai"
25
+ version = "0.1.0"
26
+ source = { editable = "." }
27
+
28
+ [package.dev-dependencies]
29
+ dev = [
30
+ { name = "pytest" },
31
+ ]
32
+
33
+ [package.metadata]
34
+
35
+ [package.metadata.requires-dev]
36
+ dev = [{ name = "pytest", specifier = ">=9.1.1" }]
37
+
38
+ [[package]]
39
+ name = "packaging"
40
+ version = "26.3"
41
+ source = { registry = "https://pypi.org/simple" }
42
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
43
+ wheels = [
44
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
45
+ ]
46
+
47
+ [[package]]
48
+ name = "pluggy"
49
+ version = "1.6.0"
50
+ source = { registry = "https://pypi.org/simple" }
51
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
52
+ wheels = [
53
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
54
+ ]
55
+
56
+ [[package]]
57
+ name = "pygments"
58
+ version = "2.21.0"
59
+ source = { registry = "https://pypi.org/simple" }
60
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
61
+ wheels = [
62
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
63
+ ]
64
+
65
+ [[package]]
66
+ name = "pytest"
67
+ version = "9.1.1"
68
+ source = { registry = "https://pypi.org/simple" }
69
+ dependencies = [
70
+ { name = "colorama", marker = "sys_platform == 'win32'" },
71
+ { name = "iniconfig" },
72
+ { name = "packaging" },
73
+ { name = "pluggy" },
74
+ { name = "pygments" },
75
+ ]
76
+ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
77
+ wheels = [
78
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
79
+ ]