aimax 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.
aimax/__init__.py ADDED
@@ -0,0 +1,121 @@
1
+ """aimax — drive Claude Code and Codex TUIs through tmux from Python.
2
+
3
+ Quick start
4
+ ===========
5
+
6
+ .. code-block:: python
7
+
8
+ import asyncio
9
+ import aimax
10
+
11
+ async def main():
12
+ backend = aimax.get("tmux-codex")
13
+ await backend.create_new_session({
14
+ "sessionId": "my-session",
15
+ "cwd": "/tmp/work",
16
+ "initialPrompt": "List files in this directory.",
17
+ })
18
+
19
+ asyncio.run(main())
20
+
21
+ Supported backends
22
+ ------------------
23
+
24
+ * ``"tmux-claude-code"`` — wraps Anthropic's ``claude`` TUI
25
+ (:class:`aimax.tmux_claude_code.TmuxClaudeCodeBackend`).
26
+ * ``"tmux-codex"`` — wraps OpenAI's ``codex`` TUI
27
+ (:class:`aimax.tmux_codex.TmuxCodexBackend`).
28
+
29
+ Each backend exposes the same async surface:
30
+
31
+ * ``create_new_session(opts)``
32
+ * ``no_pause_current_and_queue_query_at_session(opts)``
33
+ * ``pause_current_and_resume_from_session(opts)``
34
+ * ``terminate_session(session_id)``
35
+ * ``is_alive(session_id)`` / ``is_working(session_id)`` /
36
+ ``is_job_goal_accomplished(session_id)`` / ``is_failed(session_id)``
37
+ * ``list_sessions()`` / ``get_history(session_id)`` /
38
+ ``get_agent_raw_thought_stream(session_id, listener, opts)``
39
+
40
+ Configuration
41
+ -------------
42
+
43
+ Filesystem paths and hub names are configurable through environment
44
+ variables (see :mod:`aimax.config`) or by installing a custom
45
+ :class:`~aimax.config.TmuxAgentsConfig` *before* the first
46
+ :func:`get` call.
47
+
48
+ CLI
49
+ ---
50
+
51
+ The package ships with a ``aimax`` console script — run
52
+ ``aimax --help`` for a tour, or see :mod:`aimax.cli`.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ from typing import Dict
58
+
59
+ from . import config
60
+ from ._version import __version__
61
+ from .base import AgentBackend
62
+ from .config import AimaxConfig, TmuxAgentsConfig, get_config, reset_config, set_config
63
+
64
+ # Recognised backend names. Adding a new backend = extend this tuple and
65
+ # the ``get`` factory below.
66
+ SUPPORTED_BACKENDS = ("tmux-claude-code", "tmux-codex")
67
+
68
+ _singletons: Dict[str, AgentBackend] = {}
69
+
70
+
71
+ def get(name: str) -> AgentBackend:
72
+ """Return the singleton backend named ``name``.
73
+
74
+ First call instantiates the backend (which may do a one-shot
75
+ preflight binary check and read the on-disk runtime mapping).
76
+ Subsequent calls return the same instance.
77
+
78
+ Raises
79
+ ------
80
+ ValueError
81
+ ``name`` is not in :data:`SUPPORTED_BACKENDS`.
82
+ """
83
+ backend = _singletons.get(name)
84
+ if backend is None:
85
+ if name == "tmux-claude-code":
86
+ from .tmux_claude_code import TmuxClaudeCodeBackend
87
+ backend = TmuxClaudeCodeBackend()
88
+ elif name == "tmux-codex":
89
+ from .tmux_codex import TmuxCodexBackend
90
+ backend = TmuxCodexBackend()
91
+ else:
92
+ raise ValueError(
93
+ f"unknown agent backend: {name!r}. Supported: {SUPPORTED_BACKENDS}"
94
+ )
95
+ _singletons[name] = backend
96
+ return backend
97
+
98
+
99
+ def reset() -> None:
100
+ """Drop all cached singleton backends.
101
+
102
+ Mostly useful for tests — production code rarely needs this. After
103
+ ``reset()``, the next :func:`get` call constructs a fresh backend
104
+ (which re-reads the runtime JSON and re-runs preflight).
105
+ """
106
+ _singletons.clear()
107
+
108
+
109
+ __all__ = [
110
+ "__version__",
111
+ "AgentBackend",
112
+ "AimaxConfig",
113
+ "TmuxAgentsConfig",
114
+ "SUPPORTED_BACKENDS",
115
+ "config",
116
+ "get",
117
+ "get_config",
118
+ "set_config",
119
+ "reset_config",
120
+ "reset",
121
+ ]
aimax/_version.py ADDED
@@ -0,0 +1,7 @@
1
+ """Single source of truth for the package version.
2
+
3
+ The version is exposed at ``aimax.__version__`` and is also read by
4
+ ``pyproject.toml`` via Hatch's ``regex`` dynamic-version source.
5
+ """
6
+
7
+ __version__ = "0.1.0"
aimax/base.py ADDED
@@ -0,0 +1,305 @@
1
+ """``AgentBackend`` base class — async lock + event bus + persistence.
2
+
3
+ Every concrete backend (``TmuxClaudeCodeBackend``, ``TmuxCodexBackend``)
4
+ extends :class:`AgentBackend`. The base class supplies three shared
5
+ concerns:
6
+
7
+ #. **Per-session async lock**. Mutating operations (create, send, pause,
8
+ terminate) on the same session are serialised; cross-session
9
+ operations stay concurrent.
10
+ #. **Event bus**. The backend dispatches each parsed JSONL entry into a
11
+ tiny per-session pub-sub bus, so any number of subscribers can stream
12
+ the agent's thoughts in real time.
13
+ #. **Two-tier persistence**. A *runtime* JSON file holds the mapping for
14
+ currently live sessions (cleared on terminate). An *archive* JSON
15
+ file keeps every session that has ever started so historic JSONL
16
+ paths can be resolved even after the live entry is gone.
17
+
18
+ Subclass contract
19
+ -----------------
20
+
21
+ Subclasses **must** implement:
22
+
23
+ * ``async create_new_session(opts) -> dict``
24
+ * ``async pause_current_and_resume_from_session(opts) -> None``
25
+ * ``async no_pause_current_and_queue_query_at_session(opts) -> None``
26
+ * ``async terminate_session(session_id) -> dict``
27
+ * ``is_alive(session_id) -> bool``
28
+ * ``is_working(session_id) -> bool``
29
+ * ``list_sessions() -> list[dict]``
30
+
31
+ Subclasses **may** override (defaults are sensible no-ops):
32
+
33
+ * ``get_history(session_id) -> {entries, sentinel}``
34
+ * ``get_agent_raw_thought_stream(session_id, listener, opts) -> unsubscribe()``
35
+ * ``is_job_goal_accomplished(session_id) -> bool``
36
+ * ``is_failed(session_id) -> bool``
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import asyncio
42
+ import json
43
+ import os
44
+ import threading
45
+ from collections import defaultdict
46
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Tiny lock-free event emitter (channel-based fanout)
51
+ # ---------------------------------------------------------------------------
52
+ class _EventEmitter:
53
+ """In-process pub/sub. Per-channel listener list, fanout by ``emit``.
54
+
55
+ Listeners are called *synchronously* from inside ``emit``; any
56
+ exception raised by a listener is caught and logged so a single
57
+ misbehaving subscriber cannot break the others.
58
+ """
59
+
60
+ def __init__(self) -> None:
61
+ self._listeners: Dict[str, List[Callable[..., Any]]] = defaultdict(list)
62
+ self._lock = threading.Lock()
63
+
64
+ def on(self, channel: str, listener: Callable[..., Any]) -> None:
65
+ with self._lock:
66
+ self._listeners[channel].append(listener)
67
+
68
+ def off(self, channel: str, listener: Callable[..., Any]) -> None:
69
+ with self._lock:
70
+ try:
71
+ self._listeners[channel].remove(listener)
72
+ except ValueError:
73
+ pass # already removed — fine.
74
+
75
+ def emit(self, channel: str, *args, **kwargs) -> None:
76
+ # Copy under the lock so concurrent ``on``/``off`` calls do not
77
+ # break iteration. Listeners themselves run *outside* the lock.
78
+ with self._lock:
79
+ listeners = list(self._listeners.get(channel, ()))
80
+ for ln in listeners:
81
+ try:
82
+ ln(*args, **kwargs)
83
+ except Exception as e: # pragma: no cover — defensive
84
+ print(f"[event-emitter] listener on {channel} raised: {e}")
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # AgentBackend
89
+ # ---------------------------------------------------------------------------
90
+ class AgentBackend:
91
+ """Shared infrastructure for every concrete backend.
92
+
93
+ Parameters
94
+ ----------
95
+ name:
96
+ Logical backend name (``"tmux-claude-code"``, ``"tmux-codex"``).
97
+ Used in log prefixes and as the key for admin defaults.
98
+ runtime_file:
99
+ Absolute path to the *live* mapping JSON. Removed on terminate.
100
+ archive_file:
101
+ Absolute path to the *all-time* mapping JSON. Never removed; the
102
+ live entries are mirrored here at write time so historic JSONL
103
+ paths can be looked up after admin closes a window. Pass
104
+ ``None`` to disable archiving.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ name: str,
110
+ runtime_file: str | os.PathLike[str],
111
+ archive_file: Optional[str | os.PathLike[str]] = None,
112
+ ) -> None:
113
+ self.name = name
114
+ self.runtime_file = str(runtime_file)
115
+ self.archive_file = str(archive_file) if archive_file else None
116
+
117
+ # ``locks`` is keyed by ``session_id``; created lazily by
118
+ # :meth:`_get_lock`.
119
+ self.locks: Dict[str, asyncio.Lock] = {}
120
+
121
+ self.emitter = _EventEmitter()
122
+
123
+ self.persisted: Dict[str, dict] = self._load_json(self.runtime_file)
124
+ self.archive: Dict[str, dict] = (
125
+ self._load_json(self.archive_file) if self.archive_file else {}
126
+ )
127
+
128
+ # One-shot catch-up: when the archive feature was added, any
129
+ # already-running sessions only existed in ``persisted``. Copy
130
+ # them across so historic jsonl-path lookups work for them too.
131
+ if self.archive_file:
132
+ dirty = False
133
+ for sid, p in list(self.persisted.items()):
134
+ if sid not in self.archive:
135
+ self.archive[sid] = dict(p)
136
+ dirty = True
137
+ if dirty:
138
+ self._save_archive()
139
+
140
+ # ------------------------------------------------------------------
141
+ # Per-session asyncio lock
142
+ # ------------------------------------------------------------------
143
+ def _get_lock(self, session_id: Optional[str]) -> asyncio.Lock:
144
+ # ``""`` is the catch-all key for cases where the caller did not
145
+ # supply a session id (which the concrete backends rarely do, but
146
+ # we tolerate it rather than crashing).
147
+ key = session_id or ""
148
+ lock = self.locks.get(key)
149
+ if lock is None:
150
+ lock = asyncio.Lock()
151
+ self.locks[key] = lock
152
+ return lock
153
+
154
+ async def _with_lock(
155
+ self, session_id: Optional[str], fn: Callable[[], Awaitable[Any]]
156
+ ) -> Any:
157
+ """Run ``fn()`` exclusively for the given session."""
158
+ async with self._get_lock(session_id):
159
+ return await fn()
160
+
161
+ # ------------------------------------------------------------------
162
+ # Event subscription
163
+ # ------------------------------------------------------------------
164
+ def get_agent_raw_thought_stream(
165
+ self,
166
+ session_id: str,
167
+ listener: Callable[..., Any],
168
+ opts: Optional[dict] = None,
169
+ ) -> Callable[[], None]:
170
+ """Subscribe to the live raw-event stream for ``session_id``.
171
+
172
+ ``opts`` is reserved for subclass-specific cues — e.g. the tmux
173
+ backends understand ``{"fromSentinel": <byte_offset>}`` to splice
174
+ history and live into a single duplicate-free stream.
175
+
176
+ Returns
177
+ -------
178
+ A zero-argument callable that, when invoked, unsubscribes the
179
+ listener. Idempotent.
180
+ """
181
+ ch = f"raw:{session_id}"
182
+ self.emitter.on(ch, listener)
183
+
184
+ def unsubscribe() -> None:
185
+ self.emitter.off(ch, listener)
186
+
187
+ return unsubscribe
188
+
189
+ def _emit_raw(self, session_id: str, raw: Any) -> None:
190
+ """Fan ``raw`` out to every subscriber of the given session."""
191
+ self.emitter.emit(f"raw:{session_id}", raw)
192
+
193
+ def get_history(self, session_id: str) -> dict:
194
+ """Return ``{entries: [...], sentinel: ...}`` for ``session_id``.
195
+
196
+ The default returns an empty snapshot — subclasses override using
197
+ their own JSONL files. The sentinel is whatever the subclass
198
+ uses as a "tail from here" cursor; for the tmux backends it is
199
+ the file byte size.
200
+ """
201
+ return {"entries": [], "sentinel": None}
202
+
203
+ # ------------------------------------------------------------------
204
+ # Persistence
205
+ # ------------------------------------------------------------------
206
+ def _load_json(self, file: Optional[str]) -> dict:
207
+ """Read a JSON file. Returns ``{}`` for missing/corrupt files."""
208
+ if not file:
209
+ return {}
210
+ try:
211
+ if not os.path.exists(file):
212
+ return {}
213
+ with open(file, "r", encoding="utf-8") as f:
214
+ return json.load(f) or {}
215
+ except Exception as e: # pragma: no cover — defensive
216
+ print(f"[agents/{self.name}] load {os.path.basename(file)} failed: {e}")
217
+ return {}
218
+
219
+ def _save_json(self, file: str, obj: dict) -> None:
220
+ try:
221
+ os.makedirs(os.path.dirname(file), exist_ok=True)
222
+ with open(file, "w", encoding="utf-8") as f:
223
+ json.dump(obj, f, indent=2)
224
+ except Exception as e: # pragma: no cover — defensive
225
+ print(f"[agents/{self.name}] save {os.path.basename(file)} failed: {e}")
226
+
227
+ def _save_persisted(self) -> None:
228
+ self._save_json(self.runtime_file, self.persisted)
229
+
230
+ def _save_archive(self) -> None:
231
+ if self.archive_file:
232
+ self._save_json(self.archive_file, self.archive)
233
+
234
+ def _persist_entry(self, session_id: str, partial: dict) -> None:
235
+ """Merge ``partial`` into the entry for ``session_id`` and save both files."""
236
+ merged = {**(self.persisted.get(session_id) or {}), **partial}
237
+ self.persisted[session_id] = merged
238
+ self._save_persisted()
239
+ if self.archive_file:
240
+ arch = {**(self.archive.get(session_id) or {}), **partial}
241
+ self.archive[session_id] = arch
242
+ self._save_archive()
243
+
244
+ def _forget_persisted(self, session_id: str) -> None:
245
+ """Remove ``session_id`` from ``persisted`` only — archive is preserved."""
246
+ self.persisted.pop(session_id, None)
247
+ self._save_persisted()
248
+
249
+ def _lookup_archived_jsonl_path(self, session_id: str) -> Optional[str]:
250
+ """Fall back to the archive when the live map has no JSONL path.
251
+
252
+ This is the safety net for the "admin closed the window then a
253
+ client asks for history" case.
254
+ """
255
+ entry = self.archive.get(session_id) if self.archive else None
256
+ return (entry or {}).get("jsonlPath")
257
+
258
+ # ------------------------------------------------------------------
259
+ # Misc helpers
260
+ # ------------------------------------------------------------------
261
+ def get_session_use_proxy(self, session_id: str) -> Optional[bool]:
262
+ """Return the proxy choice persisted for ``session_id``, or ``None`` if unknown.
263
+
264
+ Reads the in-memory runtime map first (a subclass attribute
265
+ named ``runtime``, if present), then falls back to the on-disk
266
+ persisted map.
267
+ """
268
+ runtime_entry = None
269
+ rt = getattr(self, "runtime", None)
270
+ if rt is not None and hasattr(rt, "get"):
271
+ runtime_entry = rt.get(session_id)
272
+ entry = runtime_entry or (self.persisted.get(session_id) if self.persisted else None)
273
+ if not entry:
274
+ return None
275
+ value = entry.get("useProxy")
276
+ if value is None:
277
+ value = entry.get("use_proxy")
278
+ if value in (True, 1, "1", "true"):
279
+ return True
280
+ if value in (False, 0, "0", "false"):
281
+ return False
282
+ return None
283
+
284
+ # ------------------------------------------------------------------
285
+ # Default state queries (subclass overrides almost always)
286
+ # ------------------------------------------------------------------
287
+ def is_working(self, session_id: str) -> bool: # noqa: ARG002
288
+ """Is the agent currently mid-turn? Default ``False`` (don't know)."""
289
+ return False
290
+
291
+ def is_job_goal_accomplished(self, session_id: str) -> bool: # noqa: ARG002
292
+ """Did the agent's overall job finish?
293
+
294
+ Convention: the backend writes ``running.flag`` on session start,
295
+ the agent removes it when its job ends. The flag's *absence* is
296
+ the signal. With no cwd context the base class returns ``False``.
297
+ """
298
+ return False
299
+
300
+ def is_failed(self, session_id: str) -> bool: # noqa: ARG002
301
+ """Did the agent's job fail? Mirror of :meth:`is_job_goal_accomplished`."""
302
+ return False
303
+
304
+
305
+ __all__ = ["AgentBackend"]