sessiontools 0.0.1__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,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sessiontools"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "The harness layer: sessions, status and idle, one driver per harness - a library first"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Cora 7", email = "ai-cora@failmode.com" }
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.13"
|
|
9
|
+
# ZERO runtime dependencies, on purpose: sessionmail (click + mcp) will
|
|
10
|
+
# depend on this, and a stranger installing sessionmail must be happy to
|
|
11
|
+
# pull it. A library first.
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
# NO [project.scripts] - by ruling. Mike, voice 12:18 Thu 2026-09-17: "if
|
|
15
|
+
# sessionmail only consumes it via the library, then we don't need to put
|
|
16
|
+
# the session command from sessiontools on our path today. We can put that
|
|
17
|
+
# on when we've migrated all the other commands across." The moment a
|
|
18
|
+
# console script is declared here, every install puts it on the PATH, so
|
|
19
|
+
# the way to keep it off is to not declare it. tests/test_library.py pins
|
|
20
|
+
# this.
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["uv_build>=0.11.9,<0.12.0"]
|
|
24
|
+
build-backend = "uv_build"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=8",
|
|
29
|
+
"ruff>=0.6",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""sessiontools - the harness layer: sessions, their status, and how long
|
|
2
|
+
they have been idle - one driver per harness, a library first.
|
|
3
|
+
|
|
4
|
+
Import it; do not look for a command. There is no console script until the
|
|
5
|
+
cutover (Mike, voice 12:18 Thu 2026-09-17), and the one that arrives then
|
|
6
|
+
is `session`, whose verbs the fleet's callers are already written to.
|
|
7
|
+
|
|
8
|
+
from sessiontools import sessions, find, idle_seconds
|
|
9
|
+
|
|
10
|
+
Every function speaks OUR words - busy, not busy, unknown - never a
|
|
11
|
+
harness's. The first driver is `claude_code`; the day a harness changes a
|
|
12
|
+
status word, that driver changes and nothing above it does.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
|
|
19
|
+
from sessiontools.claude_code import (
|
|
20
|
+
SIDECAR_DIR_ENV,
|
|
21
|
+
Session,
|
|
22
|
+
find,
|
|
23
|
+
idle_seconds,
|
|
24
|
+
sessions,
|
|
25
|
+
sidecar_dir,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
__version__ = version("sessiontools")
|
|
30
|
+
except PackageNotFoundError: # a checkout that is not installed
|
|
31
|
+
__version__ = "0+unknown"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"SIDECAR_DIR_ENV",
|
|
35
|
+
"Session",
|
|
36
|
+
"__version__",
|
|
37
|
+
"find",
|
|
38
|
+
"idle_seconds",
|
|
39
|
+
"sessions",
|
|
40
|
+
"sidecar_dir",
|
|
41
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Claude Code - the first driver.
|
|
2
|
+
|
|
3
|
+
Claude Code writes one sidecar per live session, `<dir>/<pid>.json`, and
|
|
4
|
+
keeps its `status` and `updatedAt` current as the session works. Three of
|
|
5
|
+
our tools each grew a private reader of that file (sessionmail's
|
|
6
|
+
`_seat._sidecar_idle`, taskmaster's `agents/discovery.py`, `session get
|
|
7
|
+
idle`); this is the one copy they share.
|
|
8
|
+
|
|
9
|
+
The words are the harness's, not ours. Claude Code says `busy`, `shell`,
|
|
10
|
+
`idle`, `waiting`; a driver translates: busy is busy, anything else is
|
|
11
|
+
not, and no sidecar is unknown. Nothing above this module should ever
|
|
12
|
+
see a harness's vocabulary (Cora, by mail 11:50 Thu 2026-09-17: "the API
|
|
13
|
+
must not speak Claude Code's vocabulary ... the day a status word
|
|
14
|
+
changes, one driver changes and nothing else does").
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
HARNESS = "claude-code"
|
|
26
|
+
|
|
27
|
+
#: Where the sidecars live. The same rule every reader already used:
|
|
28
|
+
#: `SESSION_SIDECAR_DIR`, else `~/.claude/sessions`.
|
|
29
|
+
SIDECAR_DIR_ENV = "SESSION_SIDECAR_DIR"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sidecar_dir(env: dict[str, str] | None = None) -> Path:
|
|
33
|
+
env = os.environ if env is None else env
|
|
34
|
+
return Path(env.get(SIDECAR_DIR_ENV) or (Path.home() / ".claude" / "sessions"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Session:
|
|
39
|
+
"""One live session as the harness reports it, in our words."""
|
|
40
|
+
|
|
41
|
+
id: str
|
|
42
|
+
harness: str
|
|
43
|
+
busy: bool
|
|
44
|
+
updated_at: float # epoch seconds, when status last changed
|
|
45
|
+
pid: int | None = None
|
|
46
|
+
cwd: str | None = None
|
|
47
|
+
name: str | None = None
|
|
48
|
+
agent: str | None = None
|
|
49
|
+
raw_status: str | None = None # the harness's own word, for the curious
|
|
50
|
+
path: Path | None = None # the sidecar it came from
|
|
51
|
+
|
|
52
|
+
def idle_seconds(self, now: float | None = None) -> float:
|
|
53
|
+
"""0 while busy; else seconds since the status last changed."""
|
|
54
|
+
if self.busy:
|
|
55
|
+
return 0.0
|
|
56
|
+
now = time.time() if now is None else now
|
|
57
|
+
return max(0.0, now - self.updated_at)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read(path: Path) -> Session | None:
|
|
61
|
+
"""One sidecar -> one Session, or None when it is not one: unreadable,
|
|
62
|
+
not JSON, not a dict, or missing the two fields a verdict needs."""
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(path.read_text())
|
|
65
|
+
except (OSError, ValueError):
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
return None
|
|
69
|
+
sid = data.get("sessionId")
|
|
70
|
+
status = data.get("status")
|
|
71
|
+
updated = data.get("updatedAt")
|
|
72
|
+
if not isinstance(sid, str) or not status or updated is None:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
updated_at = float(updated) / 1000.0
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return None
|
|
78
|
+
return Session(
|
|
79
|
+
id=sid,
|
|
80
|
+
harness=HARNESS,
|
|
81
|
+
busy=(status == "busy"),
|
|
82
|
+
updated_at=updated_at,
|
|
83
|
+
pid=data.get("pid") if isinstance(data.get("pid"), int) else None,
|
|
84
|
+
cwd=data.get("cwd") if isinstance(data.get("cwd"), str) else None,
|
|
85
|
+
name=data.get("name") if isinstance(data.get("name"), str) else None,
|
|
86
|
+
agent=data.get("agent") if isinstance(data.get("agent"), str) else None,
|
|
87
|
+
raw_status=status if isinstance(status, str) else None,
|
|
88
|
+
path=path,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def sessions(env: dict[str, str] | None = None) -> list[Session]:
|
|
93
|
+
"""Every session with a readable sidecar, newest status first. A
|
|
94
|
+
directory that is missing or unreadable is an empty list, not an
|
|
95
|
+
error: no harness here is a fact, not a fault."""
|
|
96
|
+
try:
|
|
97
|
+
paths = sorted(sidecar_dir(env).glob("*.json"))
|
|
98
|
+
except OSError:
|
|
99
|
+
return []
|
|
100
|
+
found = [s for s in (_read(p) for p in paths) if s is not None]
|
|
101
|
+
return sorted(found, key=lambda s: s.updated_at, reverse=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def find(session_id: str, env: dict[str, str] | None = None) -> Session | None:
|
|
105
|
+
"""The session with this id, or None when no sidecar names it."""
|
|
106
|
+
for s in sessions(env):
|
|
107
|
+
if s.id == session_id:
|
|
108
|
+
return s
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def idle_seconds(session_id: str, now: float | None = None,
|
|
113
|
+
env: dict[str, str] | None = None) -> float | None:
|
|
114
|
+
"""The one number the pager wants: 0 while busy, else seconds since
|
|
115
|
+
the status last changed; None when nothing can be said. Same rule as
|
|
116
|
+
every reader it replaces."""
|
|
117
|
+
s = find(session_id, env)
|
|
118
|
+
return None if s is None else s.idle_seconds(now)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The `session` command - NOT YET, by ruling.
|
|
2
|
+
|
|
3
|
+
Mike, voice 12:18 Thu 2026-09-17: *"if sessionmail only consumes it via the
|
|
4
|
+
library, then we don't need to put the session command from sessiontools on
|
|
5
|
+
our path today. We can put that on when we've migrated all the other
|
|
6
|
+
commands across."*
|
|
7
|
+
|
|
8
|
+
So this module is a placeholder on purpose: no click, no `main`, nothing
|
|
9
|
+
`pyproject.toml` points at. When the cutover comes, the command is
|
|
10
|
+
`session` - `session list`, `session get idle` - the verbs the fleet's
|
|
11
|
+
callers (sessionview, voicemode, the shell aliases) are already written to,
|
|
12
|
+
and exactly one package owns it on the PATH. See
|
|
13
|
+
`openspec/changes/the-session-command`.
|
|
14
|
+
"""
|
|
File without changes
|