televibe 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.
televibe/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Run headless coding agents one turn at a time, as resumable sessions with a typed event stream."""
2
+
3
+ import sys
4
+
5
+ if sys.platform == "win32":
6
+ raise ImportError(
7
+ "televibe supports Linux and macOS only: it kills an agent by its process group, "
8
+ "which Windows does not have"
9
+ )
10
+
11
+ from televibe.access import Access
12
+ from televibe.account import Account
13
+ from televibe.engine import Engine, Stranded
14
+ from televibe.errors import TelevibeError
15
+ from televibe.events import Done, Failed, FailReason, Message, Queued, Started, ToolUse, Warning
16
+ from televibe.providers import ClaudeCode, Codex
17
+ from televibe.session import Session
18
+ from televibe.turn import Turn
19
+
20
+ __all__ = [
21
+ "Access", "Account", "ClaudeCode", "Codex", "Done", "Engine", "FailReason", "Failed", "Message",
22
+ "Queued", "Session", "Started", "Stranded", "TelevibeError", "ToolUse", "Turn", "Warning",
23
+ ]
televibe/access.py ADDED
@@ -0,0 +1,11 @@
1
+ """How much an agent may change (section 9)."""
2
+
3
+ import enum
4
+
5
+
6
+ class Access(enum.Enum):
7
+ """The same name does not give the same guarantee across providers (see SPEC.md section 9)."""
8
+
9
+ READ_ONLY = "read_only"
10
+ WORKSPACE_WRITE = "workspace_write"
11
+ FULL = "full"
televibe/account.py ADDED
@@ -0,0 +1,26 @@
1
+ """An agent account: one directory with its login, settings and transcripts (section 3)."""
2
+
3
+ import os
4
+ from collections.abc import Mapping
5
+ from pathlib import Path
6
+ from types import MappingProxyType
7
+
8
+ from televibe.env import check_credentials
9
+ from televibe.errors import TelevibeError
10
+
11
+
12
+ class Account:
13
+ __slots__ = ("_credentials", "home")
14
+
15
+ def __init__(self, home: str | os.PathLike[str], credentials: Mapping[str, str] | None = None) -> None:
16
+ if home is None:
17
+ raise TelevibeError("Account needs a home directory; televibe never falls back to ~/.claude or ~/.codex")
18
+ self.home = Path(home).expanduser().resolve()
19
+ self._credentials = check_credentials(credentials or {})
20
+
21
+ @property
22
+ def credentials(self) -> Mapping[str, str]:
23
+ return MappingProxyType(self._credentials)
24
+
25
+ def __repr__(self) -> str:
26
+ return f"Account(home={str(self.home)!r}, credentials=<{len(self._credentials)} hidden>)"
televibe/engine.py ADDED
@@ -0,0 +1,177 @@
1
+ """The Engine: ordering, recovery, and the factory for turns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import fcntl
7
+ import json
8
+ import logging
9
+ import math
10
+ import os
11
+ import re
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from types import MappingProxyType
17
+ from typing import Any
18
+
19
+ from televibe.access import Access
20
+ from televibe.env import check_turn_env
21
+ from televibe.errors import TelevibeError
22
+ from televibe.markers import Markers
23
+ from televibe.procinfo import kill_group, start_time
24
+ from televibe.providers.base import TurnOptions
25
+ from televibe.scheduler import Scheduler
26
+ from televibe.session import Session, unbound
27
+ from televibe.turn import Runner, Turn, TurnSpec
28
+
29
+ log = logging.getLogger("televibe")
30
+
31
+ _EMPTY: Mapping[str, Any] = MappingProxyType({})
32
+ _TURN_ID = re.compile(r"[0-9a-f]{32}")
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class Stranded:
37
+ """A turn an earlier process left in flight (REQ-STATE-3).
38
+
39
+ `session` is not bound to a provider; run it again with
40
+ `provider.load_session(stranded.session.dump())`.
41
+ """
42
+
43
+ turn_id: str
44
+ tag: dict[str, Any]
45
+ session: Session
46
+ started_at: datetime
47
+ killed: bool
48
+
49
+
50
+ def _kill_if_same(pgid: object, recorded: object) -> bool:
51
+ """Kill the group only if its leader exists and started when recorded (REQ-STATE-4)."""
52
+ if not isinstance(pgid, int) or not isinstance(recorded, str):
53
+ return False
54
+ if start_time(pgid) != recorded:
55
+ return False
56
+ kill_group(pgid)
57
+ return True
58
+
59
+
60
+ class Engine:
61
+ def __init__(self, state_dir: str | os.PathLike[str], max_concurrent: int = 1) -> None:
62
+ if isinstance(max_concurrent, bool) or not isinstance(max_concurrent, int) or max_concurrent < 1:
63
+ raise TelevibeError("max_concurrent must be a positive int")
64
+ self._markers = Markers(Path(state_dir).expanduser())
65
+ self._scheduler = Scheduler(max_concurrent)
66
+ self._runners: dict[str, Runner] = {}
67
+ self._state = "new"
68
+ self._lock_fd: int | None = None
69
+
70
+ async def __aenter__(self) -> Engine:
71
+ if self._state != "new":
72
+ raise TelevibeError("an Engine is entered once")
73
+ self._markers.open()
74
+ self._lock()
75
+ self._state = "open"
76
+ return self
77
+
78
+ def _lock(self) -> None:
79
+ """One engine per state_dir, so stranded() never sees another engine's live turn (REQ-STATE-3)."""
80
+ state_dir = self._markers.dir.parent
81
+ fd = os.open(state_dir / "lock", os.O_RDWR | os.O_CREAT, 0o600)
82
+ try:
83
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
84
+ except BlockingIOError:
85
+ os.close(fd)
86
+ raise TelevibeError(f"state_dir {state_dir} is in use by another Engine") from None
87
+ self._lock_fd = fd
88
+
89
+ async def __aexit__(self, *exc_info: object) -> None:
90
+ self._state = "closed"
91
+ runners = list(self._runners.values())
92
+ for runner in runners:
93
+ runner.cancel("engine closed") # REQ-CANCEL-7
94
+ await asyncio.gather(*(runner.wait_ended() for runner in runners), return_exceptions=True)
95
+ if self._lock_fd is not None:
96
+ os.close(self._lock_fd) # closing the descriptor releases the lock
97
+ self._lock_fd = None
98
+
99
+ def _require_open(self) -> None:
100
+ if self._state != "open":
101
+ raise TelevibeError("the Engine is not open: use it inside `async with Engine(...)`")
102
+
103
+ def turn(
104
+ self,
105
+ prompt: str,
106
+ *,
107
+ session: Session,
108
+ access: Access = Access.READ_ONLY,
109
+ lane: str | None = None,
110
+ model: str | None = None,
111
+ instructions: str | None = None,
112
+ env: Mapping[str, str] = _EMPTY,
113
+ add_dirs: Sequence[str | os.PathLike[str]] = (),
114
+ timeout_s: float = 900,
115
+ tag: Mapping[str, Any] = _EMPTY,
116
+ ) -> Turn:
117
+ self._require_open()
118
+ if not isinstance(prompt, str) or not prompt:
119
+ raise TelevibeError("prompt must be a non-empty str")
120
+ try:
121
+ prompt.encode()
122
+ except UnicodeEncodeError:
123
+ raise TelevibeError("prompt must be text that encodes to UTF-8 (it holds a lone surrogate)") from None
124
+ if not isinstance(session, Session):
125
+ raise TelevibeError(f"session must be a Session, got {type(session).__name__}")
126
+ provider = session._bound
127
+ if provider is None:
128
+ raise TelevibeError("this session is not bound to a provider; use provider.load_session(session.dump())")
129
+ if not isinstance(access, Access):
130
+ raise TelevibeError(f"access must be an Access, got {access!r}")
131
+ provider.check_access(access)
132
+ for name, value in (("lane", lane), ("model", model), ("instructions", instructions)):
133
+ if value is not None and not isinstance(value, str):
134
+ raise TelevibeError(f"{name} must be a str or None")
135
+ if value is not None and "\0" in value:
136
+ raise TelevibeError(f"{name} must not contain a NUL character")
137
+ if isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)) or not math.isfinite(timeout_s) or timeout_s <= 0:
138
+ raise TelevibeError("timeout_s must be a positive, finite number of seconds")
139
+ if isinstance(add_dirs, (str, bytes, os.PathLike)):
140
+ raise TelevibeError("add_dirs must be a sequence of paths, not one path")
141
+ if any("\0" in os.fspath(d) for d in add_dirs if isinstance(d, (str, os.PathLike))):
142
+ raise TelevibeError("add_dirs must not contain a NUL character")
143
+ dirs = tuple(Path(d).expanduser().resolve() for d in add_dirs)
144
+ turn_env = check_turn_env(env)
145
+ try:
146
+ tag_copy = json.loads(json.dumps(dict(tag)))
147
+ except (TypeError, ValueError) as exc:
148
+ raise TelevibeError(f"tag must be a JSON-serializable mapping: {exc}") from None
149
+ spec = TurnSpec(prompt, TurnOptions(access, model, instructions, dirs), lane, turn_env, float(timeout_s), tag_copy)
150
+ return Turn(Runner(self, provider, provider.prepare(session), spec))
151
+
152
+ async def stranded(self) -> list[Stranded]:
153
+ """Every marker an earlier process left; kills its agent if still alive (REQ-STATE-3, REQ-STATE-4)."""
154
+ self._require_open()
155
+ found = []
156
+ for marker in self._markers.all():
157
+ turn_id = marker.get("turn_id")
158
+ if not isinstance(turn_id, str) or turn_id in self._runners:
159
+ continue
160
+ try:
161
+ session = unbound(marker["session"])
162
+ started_at = datetime.fromisoformat(marker["started_at"])
163
+ except (KeyError, TypeError, ValueError, TelevibeError):
164
+ log.warning("televibe: marker %s is malformed; skipping it", turn_id)
165
+ continue
166
+ killed = await asyncio.to_thread(_kill_if_same, marker.get("pgid"), marker.get("proc_start"))
167
+ found.append(Stranded(turn_id, dict(marker.get("tag") or {}), session, started_at, killed))
168
+ return found
169
+
170
+ def forget(self, turn_id: str) -> None:
171
+ """Remove a stranded marker (REQ-STATE-5)."""
172
+ self._require_open()
173
+ if not isinstance(turn_id, str) or not _TURN_ID.fullmatch(turn_id):
174
+ raise TelevibeError(f"not a turn id: {turn_id!r}")
175
+ if turn_id in self._runners:
176
+ raise TelevibeError(f"turn {turn_id} is live in this engine, not stranded")
177
+ self._markers.remove(turn_id)
televibe/env.py ADDED
@@ -0,0 +1,69 @@
1
+ """The child process environment (REQ-ENV-1..3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterable, Mapping
7
+ from typing import TYPE_CHECKING
8
+
9
+ from televibe.errors import TelevibeError
10
+
11
+ if TYPE_CHECKING:
12
+ from televibe.account import Account
13
+
14
+ INHERITED = frozenset({"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "TERM", "TMPDIR", "TZ"})
15
+ API_KEYS = frozenset({"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY"})
16
+ ACCOUNT_VARS = frozenset({"CLAUDE_CONFIG_DIR", "CODEX_HOME"})
17
+
18
+
19
+ def _check_str_map(env: Mapping[str, str], where: str) -> dict[str, str]:
20
+ if not isinstance(env, Mapping):
21
+ raise TelevibeError(f"{where} must be a mapping of str to str")
22
+ for key, value in env.items():
23
+ if not isinstance(key, str) or not isinstance(value, str):
24
+ raise TelevibeError(f"{where} must map str to str; {key!r} does not")
25
+ if not key or "=" in key or "\0" in key or "\0" in value:
26
+ raise TelevibeError(f"{where}: {key!r} is not a name and value a process environment can hold")
27
+ return dict(env)
28
+
29
+
30
+ def refuse_account_vars(names: Iterable[str], where: str) -> None:
31
+ found = sorted(ACCOUNT_VARS.intersection(names))
32
+ if found:
33
+ raise TelevibeError(f"{', '.join(found)} refused in {where}: choose the account through Account")
34
+
35
+
36
+ def refuse_api_keys(names: Iterable[str], where: str) -> None:
37
+ found = sorted(API_KEYS.intersection(names))
38
+ if found:
39
+ raise TelevibeError(
40
+ f"{', '.join(found)} refused in {where}: televibe v1 runs agents on subscriptions only, "
41
+ "and an API key would bill the API"
42
+ )
43
+
44
+
45
+ def check_credentials(credentials: Mapping[str, str]) -> dict[str, str]:
46
+ checked = _check_str_map(credentials, "credentials")
47
+ refuse_api_keys(checked, "credentials")
48
+ refuse_account_vars(checked, "credentials")
49
+ return checked
50
+
51
+
52
+ def check_turn_env(env: Mapping[str, str]) -> dict[str, str]:
53
+ checked = _check_str_map(env, "a turn's env")
54
+ refuse_api_keys(checked, "a turn's env")
55
+ refuse_account_vars(checked, "a turn's env")
56
+ return checked
57
+
58
+
59
+ def child_env(
60
+ account_var: str,
61
+ account: Account,
62
+ turn_env: Mapping[str, str],
63
+ parent: Mapping[str, str] = os.environ,
64
+ ) -> dict[str, str]:
65
+ env = {name: value for name, value in parent.items() if name in INHERITED or name.startswith("LC_")}
66
+ env[account_var] = str(account.home)
67
+ env.update(account.credentials)
68
+ env.update(turn_env)
69
+ return env
televibe/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """The one exception televibe raises at a call site."""
2
+
3
+
4
+ class TelevibeError(Exception):
5
+ """A misuse caught before any agent process starts (REQ-API-2)."""
televibe/events.py ADDED
@@ -0,0 +1,76 @@
1
+ """The events a turn yields (section 5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from televibe.session import Session
11
+
12
+
13
+ class FailReason(enum.StrEnum):
14
+ TIMEOUT = "timeout"
15
+ CANCELLED = "cancelled"
16
+ SESSION_LOST = "session_lost"
17
+ PROVIDER_ERROR = "provider_error"
18
+ SPAWN_ERROR = "spawn_error"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Queued:
23
+ """The turn is waiting for a free slot, lane or session."""
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Started:
28
+ """The agent process is running and reported its session id."""
29
+
30
+ session: Session
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class Message:
35
+ """One complete assistant message."""
36
+
37
+ text: str
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ToolUse:
42
+ """The agent started a tool (a command, an edit, a search)."""
43
+
44
+ name: str
45
+ detail: str
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Warning: # noqa: A001 - the spec's name; it shadows the builtin only inside this module
50
+ """The agent reported a problem that did not end the turn."""
51
+
52
+ text: str
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class Done:
57
+ """Terminal: the turn finished. `text` is the final answer."""
58
+
59
+ session: Session
60
+ text: str
61
+ usage: dict[str, Any]
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class Failed:
66
+ """Terminal: the turn did not finish."""
67
+
68
+ reason: FailReason
69
+ session: Session | None
70
+ detail: str
71
+ partial: str
72
+
73
+
74
+ type Event = Queued | Started | Message | ToolUse | Warning | Done | Failed
75
+
76
+ TERMINAL = (Done, Failed)
televibe/lines.py ADDED
@@ -0,0 +1,44 @@
1
+ """JSON records from an agent's stdout, one per line (REQ-RUN-3)."""
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+ LINE_LIMIT = 8 * 1024 * 1024
9
+ _CHUNK = 64 * 1024
10
+
11
+
12
+ def _decode(line: bytes) -> dict[str, Any] | None:
13
+ line = line.strip()
14
+ if not line:
15
+ return None
16
+ try:
17
+ value = json.loads(line)
18
+ except ValueError: # includes UnicodeDecodeError
19
+ return None
20
+ return value if isinstance(value, dict) else None
21
+
22
+
23
+ async def json_records(stream: asyncio.StreamReader, limit: int = LINE_LIMIT) -> AsyncIterator[dict[str, Any]]:
24
+ """Yield each line that is a JSON object; skip everything else, including lines over `limit` bytes."""
25
+ buf = bytearray()
26
+ skipping = False # inside a line that already went over the limit
27
+ while chunk := await stream.read(_CHUNK):
28
+ start = 0
29
+ while (newline := chunk.find(b"\n", start)) >= 0:
30
+ if skipping:
31
+ skipping = False
32
+ else:
33
+ buf += chunk[start:newline]
34
+ if len(buf) <= limit and (record := _decode(bytes(buf))) is not None:
35
+ yield record
36
+ buf.clear()
37
+ start = newline + 1
38
+ if not skipping:
39
+ buf += chunk[start:]
40
+ if len(buf) > limit:
41
+ buf.clear()
42
+ skipping = True
43
+ if buf and not skipping and (record := _decode(bytes(buf))) is not None:
44
+ yield record
televibe/markers.py ADDED
@@ -0,0 +1,41 @@
1
+ """One marker file per in-flight turn (section 11)."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ log = logging.getLogger("televibe")
10
+
11
+
12
+ class Markers:
13
+ def __init__(self, state_dir: Path) -> None:
14
+ self.dir = state_dir / "turns"
15
+
16
+ def open(self) -> None:
17
+ self.dir.mkdir(parents=True, exist_ok=True)
18
+
19
+ def write(self, turn_id: str, data: dict[str, Any]) -> None:
20
+ path = self.dir / f"{turn_id}.json"
21
+ tmp = self.dir / f".{turn_id}.json.tmp"
22
+ with open(tmp, "w") as f:
23
+ json.dump(data, f)
24
+ f.flush()
25
+ os.fsync(f.fileno())
26
+ os.replace(tmp, path)
27
+
28
+ def remove(self, turn_id: str) -> None:
29
+ (self.dir / f"{turn_id}.json").unlink(missing_ok=True)
30
+
31
+ def all(self) -> list[dict[str, Any]]:
32
+ found = []
33
+ for path in sorted(self.dir.glob("*.json")):
34
+ try:
35
+ data = json.loads(path.read_text())
36
+ except (OSError, ValueError):
37
+ log.warning("televibe: skipping unreadable marker %s", path)
38
+ continue
39
+ if isinstance(data, dict):
40
+ found.append(data)
41
+ return found
televibe/procinfo.py ADDED
@@ -0,0 +1,50 @@
1
+ """Process facts televibe needs: a start time to recognize a pid, and a group kill."""
2
+
3
+ import os
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def start_time(pid: int) -> str | None:
11
+ """An opaque start time for `pid`, or None if there is no such process.
12
+
13
+ Comparing it with a recorded value tells a live agent from a recycled pid (REQ-STATE-4).
14
+ """
15
+ if sys.platform.startswith("linux"):
16
+ try:
17
+ stat = Path(f"/proc/{pid}/stat").read_text()
18
+ except OSError:
19
+ return None
20
+ return stat.rsplit(")", 1)[1].split()[19] # field 22, starttime
21
+ try:
22
+ result = subprocess.run(
23
+ ["ps", "-o", "lstart=", "-p", str(pid)],
24
+ capture_output=True,
25
+ text=True,
26
+ env={"PATH": "/bin:/usr/bin", "LC_ALL": "C"},
27
+ check=False,
28
+ )
29
+ except OSError:
30
+ return None
31
+ value = result.stdout.strip()
32
+ return value if result.returncode == 0 and value else None
33
+
34
+
35
+ def kill_group(pgid: int) -> None:
36
+ """SIGKILL the whole process group; silent if it is already gone (REQ-CANCEL-1)."""
37
+ try:
38
+ os.killpg(pgid, signal.SIGKILL)
39
+ except (ProcessLookupError, PermissionError):
40
+ pass
41
+
42
+
43
+ def group_alive(pgid: int) -> bool:
44
+ try:
45
+ os.killpg(pgid, 0)
46
+ except ProcessLookupError:
47
+ return False
48
+ except PermissionError:
49
+ return True
50
+ return True
@@ -0,0 +1,6 @@
1
+ """Agent providers."""
2
+
3
+ from televibe.providers.claude import ClaudeCode
4
+ from televibe.providers.codex import Codex
5
+
6
+ __all__ = ["ClaudeCode", "Codex"]
@@ -0,0 +1,101 @@
1
+ """What every provider supplies: sessions, a command line, and a stream parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, ClassVar
10
+
11
+ from televibe.access import Access
12
+ from televibe.account import Account
13
+ from televibe.errors import TelevibeError
14
+ from televibe.events import Done, Event, FailReason
15
+ from televibe.session import Session, parse_dump
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class TurnOptions:
20
+ access: Access
21
+ model: str | None
22
+ instructions: str | None
23
+ add_dirs: tuple[Path, ...]
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Failure:
28
+ """How a provider says a turn failed; the runner turns it into `Failed`."""
29
+
30
+ reason: FailReason
31
+ detail: str
32
+
33
+
34
+ class Parser(ABC):
35
+ """Reads one turn's records. `session` gains its id once the agent reports it."""
36
+
37
+ def __init__(self, session: Session) -> None:
38
+ self.session = session
39
+ self.terminal: Done | Failure | None = None
40
+
41
+ @abstractmethod
42
+ def feed(self, record: dict[str, Any]) -> list[Event]:
43
+ """Non-terminal events for one record; sets `terminal` on a terminal record."""
44
+
45
+ @abstractmethod
46
+ def finish(self, returncode: int, stderr_tail: str) -> Done | Failure:
47
+ """The turn's outcome once the process has exited."""
48
+
49
+
50
+ class Provider(ABC):
51
+ kind: ClassVar[str]
52
+ binary: ClassVar[str]
53
+ account_var: ClassVar[str]
54
+ access_levels: ClassVar[frozenset[Access]]
55
+ fixed_ids: ClassVar[bool]
56
+
57
+ def __init__(self, account: Account) -> None:
58
+ if not isinstance(account, Account):
59
+ raise TelevibeError(f"{type(self).__name__} needs an Account, got {type(account).__name__}")
60
+ self.account = account
61
+
62
+ def __repr__(self) -> str:
63
+ return f"{type(self).__name__}(account={self.account!r})"
64
+
65
+ def new_session(self, cwd: str | os.PathLike[str], id: str | None = None) -> Session:
66
+ if id is not None and not self.fixed_ids:
67
+ raise TelevibeError(f"{self.kind} names its own sessions; new_session takes no id")
68
+ if id is not None and (not isinstance(id, str) or not id):
69
+ raise TelevibeError("a fixed session id must be a non-empty str")
70
+ return self._session(cwd, id, started=False)
71
+
72
+ def resume_session(self, cwd: str | os.PathLike[str], id: str) -> Session:
73
+ if not isinstance(id, str) or not id:
74
+ raise TelevibeError("resume_session needs the session's non-empty id")
75
+ return self._session(cwd, id, started=True)
76
+
77
+ def load_session(self, text: str) -> Session:
78
+ data = parse_dump(text)
79
+ if data["provider"] != self.kind:
80
+ raise TelevibeError(f"this session belongs to {data['provider']!r}, not {self.kind!r}")
81
+ if Path(data["home"]) != self.account.home:
82
+ raise TelevibeError(f"this session belongs to account home {data['home']}, not {self.account.home}")
83
+ return Session(self.kind, self.account.home, Path(data["cwd"]), data["id"], data["started"], _bound=self)
84
+
85
+ def _session(self, cwd: str | os.PathLike[str], id: str | None, *, started: bool) -> Session:
86
+ return Session(self.kind, self.account.home, Path(cwd).expanduser().resolve(), id, started, _bound=self)
87
+
88
+ def check_access(self, access: Access) -> None:
89
+ if access not in self.access_levels:
90
+ supported = ", ".join(sorted(level.name for level in self.access_levels))
91
+ raise TelevibeError(f"{self.kind} does not support Access.{access.name}; it supports {supported}")
92
+
93
+ def prepare(self, session: Session) -> Session:
94
+ """The session as the turn will run it."""
95
+ return session
96
+
97
+ @abstractmethod
98
+ def argv(self, session: Session, options: TurnOptions) -> list[str]: ...
99
+
100
+ @abstractmethod
101
+ def parser(self, session: Session) -> Parser: ...