meanwhile-cli 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.
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Writes meanwhile's busy/idle state file. Invoked by the coding agent's hooks
3
+ # (Claude Code, Codex, Cursor).
4
+ # Pure bash on purpose: this runs on every prompt submit and must be instant.
5
+ set -euo pipefail
6
+
7
+ # The agent sends a JSON payload on stdin. We do not need it, but we must
8
+ # drain it so the writing end never blocks on a full pipe.
9
+ cat >/dev/null 2>&1 || true
10
+
11
+ # Only the two statuses meanwhile.state understands. Anything else — a typo in
12
+ # hooks.json, say — would be written verbatim and then silently read back as
13
+ # "idle", so refuse it loudly instead. stdout stays empty on purpose: a
14
+ # prompt-submit hook's stdout is injected into the agent's context.
15
+ status="${1:-}"
16
+ case "$status" in
17
+ busy | idle) ;;
18
+ *)
19
+ printf 'meanwhile-state.sh: status must be "busy" or "idle", got %s\n' \
20
+ "${status:-<missing>}" >&2
21
+ exit 1
22
+ ;;
23
+ esac
24
+
25
+ # Optional second argument: which agent is running. Validated rather than
26
+ # interpolated blind — this string ends up inside a JSON document.
27
+ agent="${2:-}"
28
+ if [ -n "$agent" ] && ! [[ "$agent" =~ ^[a-z][a-z-]{0,15}$ ]]; then
29
+ printf 'meanwhile-state.sh: agent must match ^[a-z][a-z-]{0,15}$, got %s\n' "$agent" >&2
30
+ exit 1
31
+ fi
32
+
33
+ # Normalize to match Python's Path(project_dir).resolve() (see
34
+ # meanwhile.state.project_key): resolve symlinks and collapse "." / ".." and
35
+ # relative or trailing-slash forms to one canonical absolute path. `pwd -P`
36
+ # only works on a directory that exists, and resolve() normalizes paths that
37
+ # do not, so resolve the deepest existing ancestor and fold the rest in
38
+ # lexically — exactly what resolve() does with its non-existent tail.
39
+ normalize_dir() {
40
+ local path="$1" head suffix="" part old_ifs
41
+ case "$path" in
42
+ /*) ;;
43
+ *) path="$PWD/$path" ;;
44
+ esac
45
+ while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do
46
+ path="${path%/}"
47
+ done
48
+
49
+ head="$path"
50
+ while [ "$head" != "/" ] && [ ! -d "$head" ]; do
51
+ suffix="${head##*/}${suffix:+/$suffix}"
52
+ head="${head%/*}"
53
+ [ -n "$head" ] || head="/"
54
+ done
55
+ # `cd -P`, not plain `cd`: bash's default is -L, which folds a ".." that
56
+ # follows a symlinked component lexically instead of resolving the link
57
+ # first. resolve() resolves the link first, so -L would key
58
+ # ".../real/tolink/.." on real's parent rather than on the link's target.
59
+ head="$(cd -P "$head" 2>/dev/null && pwd -P)" || head="/"
60
+
61
+ # `head` is now symlink-free and canonical, so the (possibly non-existent)
62
+ # tail can be folded in lexically, exactly as resolve() folds its own:
63
+ # "." drops, ".." pops, anything else appends.
64
+ old_ifs="$IFS"
65
+ IFS='/'
66
+ set -f
67
+ for part in $suffix; do
68
+ case "$part" in
69
+ '' | .) ;;
70
+ ..)
71
+ head="${head%/*}"
72
+ [ -n "$head" ] || head="/"
73
+ ;;
74
+ *)
75
+ if [ "$head" = "/" ]; then head="/$part"; else head="$head/$part"; fi
76
+ ;;
77
+ esac
78
+ done
79
+ set +f
80
+ IFS="$old_ifs"
81
+ printf '%s' "$head"
82
+ }
83
+
84
+ # Claude Code sets CLAUDE_PROJECT_DIR to the directory the session started in.
85
+ # Codex and Cursor set nothing, so their hooks fall back to the directory they
86
+ # were run from — which is that same directory.
87
+ dir="$(normalize_dir "${CLAUDE_PROJECT_DIR:-$PWD}")"
88
+
89
+ if command -v sha256sum >/dev/null 2>&1; then
90
+ key="$(printf '%s' "$dir" | sha256sum | cut -c1-16)"
91
+ else
92
+ key="$(printf '%s' "$dir" | shasum -a 256 | cut -c1-16)"
93
+ fi
94
+
95
+ state_dir="$HOME/.meanwhile/state"
96
+ mkdir -p "$state_dir"
97
+ tmp="$state_dir/$key.json.tmp.$$"
98
+ if [ -n "$agent" ]; then
99
+ printf '{"status":"%s","ts":%s,"agent":"%s"}' "$status" "$(date +%s)" "$agent" > "$tmp"
100
+ else
101
+ printf '{"status":"%s","ts":%s}' "$status" "$(date +%s)" > "$tmp"
102
+ fi
103
+ mv -f "$tmp" "$state_dir/$key.json"
meanwhile/pool.py ADDED
@@ -0,0 +1,101 @@
1
+ """Reading and validating .meanwhile/pool.json.
2
+
3
+ The pool is produced by an LLM at build time, so every field is treated as
4
+ untrusted input: the loader validates shape, not content.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ POOL_RELPATH = ".meanwhile/pool.json"
14
+
15
+
16
+ class PoolError(Exception):
17
+ """The pool file is missing, unreadable, or structurally invalid."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class QuizItem:
22
+ q: str
23
+ choices: list[str]
24
+ answer: int
25
+ why: str
26
+ source: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Pool:
31
+ built_at: str
32
+ head_sha: str
33
+ repo: str
34
+ items: list[QuizItem]
35
+ language: str = "en"
36
+
37
+
38
+ def _require(raw: dict, key: str, kind: type, where: str):
39
+ value = raw.get(key)
40
+ if not isinstance(value, kind):
41
+ raise PoolError(f"{where}: field {key!r} must be {kind.__name__}, got {value!r}")
42
+ return value
43
+
44
+
45
+ def _quiz(raw: dict, where: str) -> QuizItem:
46
+ choices = _require(raw, "choices", list, where)
47
+ if len(choices) < 2:
48
+ raise PoolError(f"{where}: needs at least 2 choices, got {len(choices)}")
49
+ if not all(isinstance(c, str) and c for c in choices):
50
+ raise PoolError(f"{where}: every entry in choices must be a non-empty string")
51
+ answer = _require(raw, "answer", int, where)
52
+ if not 0 <= answer < len(choices):
53
+ raise PoolError(f"{where}: answer {answer} is out of range for {len(choices)} choices")
54
+ return QuizItem(
55
+ q=_require(raw, "q", str, where),
56
+ choices=choices,
57
+ answer=answer,
58
+ why=_require(raw, "why", str, where),
59
+ source=_require(raw, "source", str, where),
60
+ )
61
+
62
+
63
+ def load_pool(path: str | Path) -> Pool:
64
+ path = Path(path)
65
+ try:
66
+ raw = json.loads(path.read_text(encoding="utf-8"))
67
+ except FileNotFoundError as exc:
68
+ raise PoolError(f"pool not found at {path}") from exc
69
+ except (OSError, json.JSONDecodeError) as exc:
70
+ raise PoolError(f"pool at {path} is unreadable: {exc}") from exc
71
+
72
+ if not isinstance(raw, dict):
73
+ raise PoolError("pool must be a JSON object")
74
+
75
+ raw_items = raw.get("items")
76
+ if not isinstance(raw_items, list) or not raw_items:
77
+ raise PoolError("pool 'items' is empty or not a list")
78
+
79
+ items = []
80
+ for index, entry in enumerate(raw_items):
81
+ where = f"item {index}"
82
+ if not isinstance(entry, dict):
83
+ raise PoolError(f"{where}: must be an object")
84
+ kind = entry.get("type")
85
+ if kind == "quiz":
86
+ items.append(_quiz(entry, where))
87
+ elif kind == "mutant":
88
+ raise PoolError(
89
+ f"{where}: mutant cards are no longer supported — "
90
+ "regenerate this pool with the build command"
91
+ )
92
+ else:
93
+ raise PoolError(f"{where}: unknown type {kind!r}")
94
+
95
+ return Pool(
96
+ built_at=str(raw.get("builtAt", "")),
97
+ head_sha=str(raw.get("headSha", "")),
98
+ repo=str(raw.get("repo", "")),
99
+ items=items,
100
+ language=str(raw.get("language", "en")) or "en",
101
+ )
meanwhile/repo.py ADDED
@@ -0,0 +1,30 @@
1
+ """How far the committed pool has drifted from the working tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ STALE_THRESHOLD = 50
9
+
10
+
11
+ def commits_behind(repo_dir: str | Path, head_sha: str) -> int | None:
12
+ """Commits added since the pool was built, or None if that cannot be known."""
13
+ if not head_sha:
14
+ return None
15
+ try:
16
+ result = subprocess.run(
17
+ ["git", "rev-list", "--count", f"{head_sha}..HEAD"],
18
+ cwd=str(repo_dir),
19
+ capture_output=True,
20
+ text=True,
21
+ timeout=5,
22
+ )
23
+ except (OSError, subprocess.SubprocessError):
24
+ return None
25
+ if result.returncode != 0:
26
+ return None
27
+ try:
28
+ return int(result.stdout.strip())
29
+ except ValueError:
30
+ return None
meanwhile/scores.py ADDED
@@ -0,0 +1,44 @@
1
+ """Per-developer running totals. Gitignored: scores are local, never shared."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from .game import Session
10
+
11
+ SCORES_RELPATH = ".meanwhile/scores.json"
12
+ _ZERO = {"answered": 0, "correct": 0, "best_streak": 0}
13
+
14
+
15
+ def _path(repo_dir: str | Path) -> Path:
16
+ return Path(repo_dir) / SCORES_RELPATH
17
+
18
+
19
+ def load_scores(repo_dir: str | Path) -> dict:
20
+ try:
21
+ data = json.loads(_path(repo_dir).read_text(encoding="utf-8"))
22
+ except (OSError, json.JSONDecodeError):
23
+ return dict(_ZERO)
24
+ if not isinstance(data, dict):
25
+ return dict(_ZERO)
26
+ out = dict(_ZERO)
27
+ for key in out:
28
+ value = data.get(key, 0)
29
+ out[key] = value if isinstance(value, int) and value >= 0 else 0
30
+ return out
31
+
32
+
33
+ def record_session(repo_dir: str | Path, session: Session) -> dict:
34
+ totals = load_scores(repo_dir)
35
+ totals["answered"] += session.answered
36
+ totals["correct"] += session.correct
37
+ totals["best_streak"] = max(totals["best_streak"], session.best_streak)
38
+
39
+ path = _path(repo_dir)
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
42
+ tmp.write_text(json.dumps(totals, indent=2), encoding="utf-8")
43
+ tmp.replace(path)
44
+ return totals
meanwhile/state.py ADDED
@@ -0,0 +1,66 @@
1
+ """Machine-local busy/idle state shared between an agent's hooks and the TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from .agents import SLUG_PATTERN
12
+
13
+ STATE_ROOT = Path.home() / ".meanwhile" / "state"
14
+
15
+ _VALID = ("busy", "idle")
16
+ _IDLE = {"status": "idle", "ts": 0.0, "agent": None}
17
+
18
+
19
+ def project_key(project_dir: str | Path) -> str:
20
+ """Short, stable id for a project directory.
21
+
22
+ Must stay byte-for-byte compatible with hooks/meanwhile-state.sh:
23
+ sha256 of the resolved path, no trailing newline, first 16 hex chars.
24
+ """
25
+ resolved = str(Path(project_dir).resolve())
26
+ return hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:16]
27
+
28
+
29
+ def state_path(project_dir: str | Path, root: Path = STATE_ROOT) -> Path:
30
+ return Path(root) / f"{project_key(project_dir)}.json"
31
+
32
+
33
+ def write_state(
34
+ project_dir: str | Path,
35
+ status: str,
36
+ root: Path = STATE_ROOT,
37
+ now: float | None = None,
38
+ agent: str | None = None,
39
+ ) -> Path:
40
+ if status not in _VALID:
41
+ raise ValueError(f"status must be one of {_VALID}, got {status!r}")
42
+ if agent is not None and not SLUG_PATTERN.match(agent):
43
+ raise ValueError(f"agent must match {SLUG_PATTERN.pattern}, got {agent!r}")
44
+ path = state_path(project_dir, root=root)
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ payload_data = {"status": status, "ts": now if now is not None else time.time()}
47
+ if agent is not None:
48
+ payload_data["agent"] = agent
49
+ payload = json.dumps(payload_data)
50
+ tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
51
+ tmp.write_text(payload, encoding="utf-8")
52
+ tmp.replace(path) # atomic: the TUI polls this file and must never see a half-write
53
+ return path
54
+
55
+
56
+ def read_state(project_dir: str | Path, root: Path = STATE_ROOT) -> dict:
57
+ try:
58
+ data = json.loads(state_path(project_dir, root=root).read_text(encoding="utf-8"))
59
+ except (OSError, json.JSONDecodeError):
60
+ return dict(_IDLE)
61
+ if not isinstance(data, dict) or data.get("status") not in _VALID:
62
+ return dict(_IDLE)
63
+ agent = data.get("agent")
64
+ if not isinstance(agent, str) or not SLUG_PATTERN.match(agent):
65
+ agent = None
66
+ return {"status": data["status"], "ts": float(data.get("ts", 0.0)), "agent": agent}
meanwhile/strings.py ADDED
@@ -0,0 +1,42 @@
1
+ """The interface's six strings, in the languages meanwhile ships.
2
+
3
+ A dictionary, deliberately, and not an i18n framework: there is no catalog to
4
+ extract, no compilation step and no locale negotiation. The cards themselves
5
+ are written in whatever language the pool was built in; this is only the
6
+ chrome around them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ DEFAULT_LANGUAGE = "en"
12
+
13
+ KEYS = ("waiting", "done", "exhausted", "stale", "correct", "wrong", "agent")
14
+
15
+ _TABLE = {
16
+ "en": {
17
+ "agent": "the agent",
18
+ "waiting": "Waiting for {agent} to start working — submit a prompt and the game begins.",
19
+ "done": "{agent} is done ^ — switch back to the diff; this pane keeps running.",
20
+ "exhausted": "Pool exhausted for this wait — the next one deals a fresh round.",
21
+ "stale": "pool is {behind} commits behind — run the build command for fresh questions",
22
+ "correct": "correct",
23
+ "wrong": "wrong",
24
+ },
25
+ "tr": {
26
+ "agent": "ajan",
27
+ "waiting": "{agent} çalışmaya başlayınca oyun başlıyor — bir istem gönder.",
28
+ "done": "{agent} bitirdi ^ — diff'e dön; bu pencere açık kalıyor.",
29
+ "exhausted": "Bu bekleme için havuz bitti — sonraki bekleme yeni bir tur dağıtıyor.",
30
+ "stale": "havuz {behind} commit geride — tazelemek için build komutunu çalıştır",
31
+ "correct": "doğru",
32
+ "wrong": "yanlış",
33
+ },
34
+ }
35
+
36
+
37
+ def text(language: str, key: str, **fields) -> str:
38
+ """One string, in `language` if it is shipped and in English otherwise."""
39
+ table = _TABLE.get(language, _TABLE[DEFAULT_LANGUAGE])
40
+ if key not in KEYS:
41
+ raise KeyError(key)
42
+ return table[key].format(**fields) if fields else table[key]
@@ -0,0 +1,265 @@
1
+ Metadata-Version: 2.5
2
+ Name: meanwhile-cli
3
+ Version: 0.1.0
4
+ Summary: Play a quiz about your own repository while your coding agent works
5
+ Project-URL: Homepage, https://github.com/ismailbayram/meanwhile
6
+ Project-URL: Repository, https://github.com/ismailbayram/meanwhile
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: textual>=0.80
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # meanwhile
17
+
18
+ Claude Code takes a while on real tasks — often ten or fifteen minutes. During
19
+ that wait, attention drifts: a browser tab, a chat, something unrelated. When
20
+ the diff finally lands, you no longer hold the task in your head, and the
21
+ review that follows is slower and shallower than it should be. meanwhile fills
22
+ that wait with a quiz built entirely from your own repository, so that
23
+ instead of drifting away you stay warmed up on the code you're about to
24
+ review. It works the same way alongside Codex, Cursor and OpenCode.
25
+
26
+ ## Install
27
+
28
+ ### Claude Code
29
+
30
+ meanwhile is a Claude Code plugin, and this repository is also its own
31
+ marketplace (`.claude-plugin/marketplace.json`). Add it as a marketplace,
32
+ then install the plugin from it:
33
+
34
+ ```
35
+ /plugin marketplace add ismailbayram/meanwhile
36
+ /plugin install meanwhile@meanwhile
37
+ ```
38
+
39
+ (While developing against a checkout of this repo, add the local path
40
+ instead — `/plugin marketplace add /path/to/meanwhile`.) Restart Claude Code
41
+ afterwards so the plugin's hooks are picked up.
42
+
43
+ Installing the plugin adds two hooks and one slash command; it does not
44
+ install the `meanwhile` command itself. That's a separate, ordinary Python
45
+ package — see "Daily use" below.
46
+
47
+ ### Codex
48
+
49
+ This repository is a Codex plugin and marketplace too (`plugin.json`,
50
+ `.agents/plugins/marketplace.json`). Add the marketplace:
51
+
52
+ ```
53
+ codex plugin marketplace add ismailbayram/meanwhile
54
+ ```
55
+
56
+ then install `meanwhile` from the Plugins Directory. Codex does not run a
57
+ plugin's hooks until you have reviewed and trusted them, so do that once
58
+ after installing — until then the pane never wakes.
59
+
60
+ ### Cursor, OpenCode, or Codex without the plugin
61
+
62
+ Cursor has no plugin system, so `meanwhile` writes the hook registration into
63
+ the agent's config file directly. The same works for Codex if you would
64
+ rather not install the plugin:
65
+
66
+ ```
67
+ uvx meanwhile-cli hooks install --agent cursor
68
+ uvx meanwhile-cli hooks install --agent codex
69
+ ```
70
+
71
+ OpenCode has no shell hooks at all, only JavaScript plugins, so there the
72
+ same command writes a small plugin file — `.opencode/plugins/meanwhile.js`,
73
+ or `~/.config/opencode/plugins/meanwhile.js` with `--user` — that calls the
74
+ hook script whenever a session goes busy or idle:
75
+
76
+ ```
77
+ uvx meanwhile-cli hooks install --agent opencode
78
+ ```
79
+
80
+ Restart OpenCode afterwards; plugins are loaded at startup. A file already
81
+ at that path that `meanwhile` did not write is refused, never overwritten.
82
+
83
+ (While developing against a checkout of this repo instead of the published
84
+ package, use `uv run meanwhile hooks install --agent codex` from inside it.)
85
+
86
+ Leaving off `--agent` (or passing `--agent auto` explicitly) installs for
87
+ every agent whose config directory already exists in this project — it never
88
+ creates one, so an agent you don't use is left alone. `meanwhile hooks status`
89
+ reports what's registered and where — only the registrations meanwhile itself
90
+ wrote, so a *plugin* install is invisible to it: Claude Code shows as `not
91
+ installed (the plugin registers its own hooks separately)`, Codex as plain
92
+ `not installed`. `meanwhile hooks
93
+ uninstall --agent <agent>` takes a registration back out.
94
+
95
+ Cursor only supports project-scope registration. `--user` is refused for it:
96
+ Cursor runs its user-level hooks from `~/.cursor/`, while the hook keys the
97
+ state file on its working directory, so a user-level registration could
98
+ never wake the right pane.
99
+
100
+ For Claude Code, a `hooks install` registration is written to
101
+ `.claude/settings.local.json`, not `settings.json` — the entry embeds an
102
+ absolute path to this machine's copy of the hook script, and `settings.json`
103
+ is the file teams commit. A Codex, Cursor or OpenCode project registration
104
+ has no such gitignored sibling to move to, so `hooks install` prints a note there — every
105
+ time, whether or not git already tracks the file — saying that the path it
106
+ just wrote is specific to this machine and that committing it would leave
107
+ teammates running a script they do not have.
108
+
109
+ If you both install the plugin *and* run `meanwhile hooks install` for the
110
+ same agent, the two hooks fire twice per prompt. That's harmless — both copies
111
+ are byte-identical (`hooks/meanwhile-state.sh` for the plugin,
112
+ `~/.meanwhile/meanwhile-state.sh` for the standalone registration) and are run
113
+ with the same arguments against the same state file, so it's one redundant
114
+ run per event — but worth knowing so you don't go looking for a bug.
115
+
116
+ `hooks` is handled before the normal argument parser is built, so it never
117
+ appears in `meanwhile --help`. `build-prompt` (below) does appear there, but
118
+ only as a bare choice with no explanation of what it does — so this README
119
+ is where to learn about both.
120
+
121
+ ## First run
122
+
123
+ In a project you want to play against, build a pool once, up front — nothing
124
+ regenerates it in the background.
125
+
126
+ **Claude Code**: run
127
+
128
+ ```
129
+ /meanwhile-build
130
+ ```
131
+
132
+ This asks Claude to read the repository — models, routes, the README,
133
+ `CHANGELOG.md`, recent commits — and write `.meanwhile/pool.json`: a set of
134
+ multiple-choice questions drawn from that project's actual code.
135
+
136
+ **Codex, Cursor, OpenCode, or any other agent**: `/meanwhile-build` is a Claude Code
137
+ slash command, so run this instead and paste the printed instructions to
138
+ your agent:
139
+
140
+ ```
141
+ uvx meanwhile-cli build-prompt --lang en --repo .
142
+ ```
143
+
144
+ It prints the exact same instructions the slash command uses, so the pool
145
+ comes out the same either way.
146
+
147
+ Commit `.meanwhile/pool.json`. It's checked into the repository on purpose, so
148
+ everyone on the team plays the same pool and nobody pays to regenerate it.
149
+
150
+ `--lang` takes a short language code — `tr` or `en` — not a language name:
151
+ `--lang Turkish` is not recognised and silently leaves the interface in
152
+ English. Those two codes are the only ones the interface itself speaks.
153
+
154
+ A pool built by the build prompt records the language it was written in, and
155
+ the playing interface follows it, with `--lang` overriding that for just your
156
+ pane. `--lang` on `build-prompt` controls what language the cards themselves
157
+ get written in — any code is allowed there, so `--lang de` writes German cards
158
+ and leaves the interface in English. `pool.json` carries the same code in a
159
+ top-level `language` field (it defaults to `en` when absent); the playing
160
+ interface's own chrome — the waiting banner, "correct"/"wrong", and so on —
161
+ follows that field, in `tr` or `en` and in English for anything else.
162
+
163
+ ## Daily use
164
+
165
+ Open a second terminal pane next to the one running your agent — a tmux
166
+ split, an iTerm pane, or just another window — sitting in the *same directory
167
+ you started it in*, and run:
168
+
169
+ ```
170
+ uvx meanwhile-cli
171
+ ```
172
+
173
+ `uvx` runs it without installing anything. To install it for good instead,
174
+ `uv tool install meanwhile-cli` (or `pipx install meanwhile-cli`) — the
175
+ command that leaves on your path is plain `meanwhile`, and it takes the same
176
+ arguments everywhere this README writes `uvx meanwhile-cli`.
177
+
178
+ (While developing against a checkout of this repo instead of the published
179
+ package, use `uv run meanwhile` from inside it.)
180
+
181
+ Open it whenever you like — before your first prompt is fine. The pane sits
182
+ waiting until your agent starts working, wakes up within about a second of
183
+ you submitting a prompt, and serves multiple-choice cards about the
184
+ codebase one at a time. Answer with the number key for the choice you want;
185
+ the footer only ever offers the keys the card on screen can actually take.
186
+ Answering shows the explanation, `space` deals the next card, and a streak
187
+ counter tracks that wait. If a wait runs long enough to reach the end of the
188
+ pool, the pane says so and sits out the rest of it rather than repeating
189
+ questions.
190
+
191
+ The moment your agent finishes, the current card freezes and the pane shows
192
+ `<Agent> is done ^` (or `the agent is done ^` if it can't tell which one),
193
+ which is your cue to switch back.
194
+
195
+ Then leave it open. It stays on that line — it does not clear itself or
196
+ return to the waiting message — until your next prompt, which deals a fresh,
197
+ re-shuffled round, so a long wait never exhausts the pool for the next one.
198
+ You only ever press `q` when you are done for the day. Each wait is scored as
199
+ its own session and added to your totals when it ends; a wait you answered
200
+ nothing in is not recorded.
201
+
202
+ The interface follows the language recorded in the pool. Override it for
203
+ just your pane with `--lang` and a short code, for example `uvx
204
+ meanwhile-cli --lang en`, regardless of what the pool says. `tr` and `en`
205
+ are the two the interface speaks; any other code leaves it in English.
206
+
207
+ ## How it works
208
+
209
+ meanwhile is three independent parts that only ever talk to each other
210
+ through two files on disk — nothing imports anything else, and each part can
211
+ be tested alone:
212
+
213
+ ```
214
+ /meanwhile-build (Claude Code slash command)
215
+ build-prompt (same instructions, any other agent)
216
+ │ writes
217
+ ▼
218
+ .meanwhile/pool.json ← committed to the repo, shared by the team
219
+ │ reads
220
+ ▼
221
+ meanwhile TUI ──watches──► ~/.meanwhile/state/<project-hash>.json
222
+ (uvx, separate pane) ▲
223
+ │ writes
224
+ hooks: plugin (Claude Code, Codex), or `meanwhile
225
+ hooks install` (those two, Cursor, OpenCode)
226
+ ```
227
+
228
+ - **`/meanwhile-build`** (or `meanwhile build-prompt` for every other agent) is
229
+ the only part that costs tokens or touches an LLM. It reads the repository
230
+ and writes `.meanwhile/pool.json`.
231
+ - **The hooks** are a few lines of pure bash (`hooks/meanwhile-state.sh` for
232
+ the plugin; the same script, packaged inside the wheel and copied to
233
+ `~/.meanwhile/meanwhile-state.sh`, for `hooks install`), fired on the moment
234
+ each agent starts and stops working — `UserPromptSubmit`/`Stop` for
235
+ Claude Code and Codex, `beforeSubmitPrompt`/`stop` for Cursor, and the
236
+ `session.status` event for OpenCode, whose plugin file does nothing but
237
+ call the same script. They write nothing but a
238
+ `{"status": "busy"|"idle", "ts": ..., "agent": ...}` file under
239
+ `~/.meanwhile/state/`, keyed by a hash of the directory the agent was
240
+ started in (that is what `CLAUDE_PROJECT_DIR` holds for Claude Code — not
241
+ necessarily the git root, which is why the TUI must be launched from the
242
+ same directory). No interpreter, no network, no measurable delay added to
243
+ your prompt.
244
+ - **The `meanwhile` TUI**, run separately as `uvx meanwhile-cli`, polls
245
+ that state file and renders cards from the pool. It finds
246
+ `.meanwhile/pool.json` at the git root above wherever you launched it. It makes no LLM calls and
247
+ parses no source code at play time — it only reads the two JSON files
248
+ above.
249
+
250
+ ## Refreshing a stale pool
251
+
252
+ `pool.json` records the commit it was built from. If the repository has
253
+ moved on more than about 50 commits since then, the TUI shows a banner —
254
+ `pool is N commits behind — run the build command for fresh questions` — but
255
+ it never regenerates the pool on its own. Silent background regeneration
256
+ would spend tokens you didn't ask for, which is exactly what this design
257
+ avoids. Run `/meanwhile-build` (or `meanwhile build-prompt`) again when you see
258
+ the banner (or whenever the pool just feels stale) and commit the updated
259
+ file.
260
+
261
+ ## Scores
262
+
263
+ Your running total and best streak are written to `.meanwhile/scores.json`.
264
+ That file is gitignored — scores are local to your machine and never shared
265
+ or synced between teammates.
@@ -0,0 +1,19 @@
1
+ meanwhile/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ meanwhile/agents.py,sha256=XIslhkfyU5YuIhZ7xuSW1O6GnUXBk7PnlKAYgeLFWFE,11833
3
+ meanwhile/app.py,sha256=gJ9ajSxEuPMhYEK_JgHqUH6Ftm3MdqPjWRTrC4zE9V4,7338
4
+ meanwhile/app.tcss,sha256=iTWrCKqVzC1rOH2EcEPipSmxqtXAhW2WMlpp_LgJWAs,232
5
+ meanwhile/build_prompt.md,sha256=cT1fGy8uWZJYKvq2G9Kor_hJGiV6uCzyCLp3WLFWB5k,3025
6
+ meanwhile/cli.py,sha256=4eN2_ieUoqcvKfha-zqL2MN5bTkM-zTCgObq5kI_T-A,8491
7
+ meanwhile/game.py,sha256=e02NsN-MaG0xMSKNPczVYC0dCpg93oYCAUuwNWnEAf4,1806
8
+ meanwhile/hooks_cli.py,sha256=WSsLiXhQGxbwCaXOy_UxIkjh4EroOKLtJLvWTEiKUXU,13043
9
+ meanwhile/meanwhile-state.sh,sha256=of96sFvI6YRsjQKxk0SppiM5ygJM_Irq7kmAiGLpT50,3678
10
+ meanwhile/pool.py,sha256=CuTeAt21o3DE912I3qsWt3MO7XtCnttTg0rOrBFYUhk,3091
11
+ meanwhile/repo.py,sha256=DppSxfJIh2Qf9I_bITLn1woI_IAc-ZHtvqL0gCoZZMs,809
12
+ meanwhile/scores.py,sha256=FV20gDC35quy394pVu52dUrGg9euQOLZneB_vDrxEDk,1311
13
+ meanwhile/state.py,sha256=w-dGFYVpA0uCYrEEQH4s8gTogQ0aRSX3QD6-ueowFzE,2322
14
+ meanwhile/strings.py,sha256=unvmjwDzhUVWydLtUH3kvz5CaXqL8y6boDEZSP9Eh8s,1743
15
+ meanwhile_cli-0.1.0.dist-info/METADATA,sha256=W98BhVrMN-wJlfwb2W_3mk-_8iqGZkZwLo0U9ZpbF1I,11961
16
+ meanwhile_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
17
+ meanwhile_cli-0.1.0.dist-info/entry_points.txt,sha256=nCcGlFSNmJouQGKky1kdjPg0k3o9r9F1jwkburnCJ98,84
18
+ meanwhile_cli-0.1.0.dist-info/licenses/LICENSE,sha256=DvC_3jCIWi5ymiMirFd_wjXj3SwV_wMU0VKF4Txl3Nc,1070
19
+ meanwhile_cli-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