sessiontools 0.0.3__tar.gz → 0.0.4__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.

Potentially problematic release.


This version of sessiontools might be problematic. Click here for more details.

Files changed (33) hide show
  1. {sessiontools-0.0.3 → sessiontools-0.0.4}/PKG-INFO +3 -1
  2. sessiontools-0.0.4/pyproject.toml +54 -0
  3. {sessiontools-0.0.3 → sessiontools-0.0.4}/src/sessiontools/__init__.py +7 -4
  4. {sessiontools-0.0.3 → sessiontools-0.0.4}/src/sessiontools/claude_code.py +25 -0
  5. sessiontools-0.0.4/src/sessiontools/cli.py +311 -0
  6. sessiontools-0.0.4/src/sessiontools/get.py +89 -0
  7. sessiontools-0.0.4/src/sessiontools/listing.py +169 -0
  8. sessiontools-0.0.4/src/sessiontools/procs.py +78 -0
  9. sessiontools-0.0.4/src/sessiontools/send.py +72 -0
  10. sessiontools-0.0.4/src/sessiontools/settings.py +437 -0
  11. sessiontools-0.0.4/src/sessiontools/settle.py +150 -0
  12. sessiontools-0.0.4/src/sessiontools/start/__init__.py +155 -0
  13. sessiontools-0.0.4/src/sessiontools/start/agentfile.py +297 -0
  14. sessiontools-0.0.4/src/sessiontools/start/agents.py +138 -0
  15. sessiontools-0.0.4/src/sessiontools/start/args.py +184 -0
  16. sessiontools-0.0.4/src/sessiontools/start/context.py +410 -0
  17. sessiontools-0.0.4/src/sessiontools/start/environment.py +269 -0
  18. sessiontools-0.0.4/src/sessiontools/start/errors.py +44 -0
  19. sessiontools-0.0.4/src/sessiontools/start/holders.py +193 -0
  20. sessiontools-0.0.4/src/sessiontools/start/identity.py +310 -0
  21. sessiontools-0.0.4/src/sessiontools/start/placement.py +204 -0
  22. sessiontools-0.0.4/src/sessiontools/start/plan.py +757 -0
  23. sessiontools-0.0.4/src/sessiontools/start/policy.py +244 -0
  24. sessiontools-0.0.4/src/sessiontools/start/record.py +401 -0
  25. sessiontools-0.0.4/src/sessiontools/start/resume.py +133 -0
  26. sessiontools-0.0.4/src/sessiontools/start/run.py +274 -0
  27. sessiontools-0.0.4/src/sessiontools/start/secrets.py +189 -0
  28. sessiontools-0.0.4/src/sessiontools/start/vendor.py +238 -0
  29. sessiontools-0.0.4/src/sessiontools/tmux.py +90 -0
  30. sessiontools-0.0.4/src/sessiontools/window.py +292 -0
  31. sessiontools-0.0.3/pyproject.toml +0 -38
  32. sessiontools-0.0.3/src/sessiontools/cli.py +0 -14
  33. {sessiontools-0.0.3 → sessiontools-0.0.4}/src/sessiontools/py.typed +0 -0
@@ -1,7 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sessiontools
3
- Version: 0.0.3
3
+ Version: 0.0.4
4
4
  Summary: The harness layer: sessions, status and idle, one driver per harness - a library first
5
5
  Author: Cora 7
6
6
  Author-email: Cora 7 <ai-cora@failmode.com>
7
+ Requires-Dist: pyyaml>=6
8
+ Requires-Dist: jsonschema>=4
7
9
  Requires-Python: >=3.11
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "sessiontools"
3
+ version = "0.0.4"
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
+ # >=3.11, not the recipe's >=3.13: sessionmail (the first consumer) declares
9
+ # >=3.11, and uv resolves for EVERY version a project allows, so a stricter
10
+ # floor here made sessionmail's `uv sync` unsatisfiable (Pip, 13:02 Thu
11
+ # 2026-09-17). Measured: the suite passes on 3.11, 3.12 and 3.13; nothing in
12
+ # the tree needs 3.13.
13
+ requires-python = ">=3.11"
14
+ # ONE runtime dependency, and only for `session start`: agent front matter
15
+ # is YAML (the same YAML Claude Code reads itself). Mike, voice 00:12 Tue
16
+ # 2026-09-29, asked "Can start pull in the YAML library, just for itself?":
17
+ # "Um, yes." So PyYAML is declared here and imported by exactly one module,
18
+ # `sessiontools.start.agentfile`; `import sessiontools` (the library
19
+ # sessionmail depends on) still imports nothing outside the standard library.
20
+ # tests/test_cli.py pins both halves.
21
+ # The SECOND: jsonschema, for `session settings` alone - each tool ships a
22
+ # JSON Schema and the reader checks the merged result against it. No
23
+ # configuration library for the layering (Mike, voice 19:15 Sat 2026-10-03,
24
+ # Cora /RULINGS 4600); imported inside `sessiontools.settings.check_schema`,
25
+ # so `import sessiontools` still reaches nothing outside the standard library.
26
+ dependencies = ["PyYAML>=6", "jsonschema>=4"]
27
+
28
+ # THE `session` COMMAND - ruled at the cutover. Mike, voice 23:25-23:28 Mon
29
+ # 2026-09-28, the session repo just moved aside: sessiontools provides it
30
+ # ("Ah, yes, it should"), same verbs, ported as package code ("port it
31
+ # nicely"), superseding his 12:18 Thu 2026-09-17 "not on our path today".
32
+ # Exactly one package owns `session` on the PATH; from here it is this one.
33
+ # argparse and the library; PyYAML for `session start` alone (above).
34
+ [project.scripts]
35
+ session = "sessiontools.cli:main"
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.11.9,<0.12.0"]
39
+ build-backend = "uv_build"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=8",
44
+ "ruff>=0.6",
45
+ ]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ # The oracle tests run the old launcher (tests/oracle/session-start) beside
50
+ # the port. They are manual: `make oracle` (Mike, voice 11:34 Tue 2026-09-29).
51
+ addopts = "--strict-markers -m 'not oracle'"
52
+ markers = [
53
+ "oracle: runs the old launcher as the byte-equal oracle; manual, `make oracle`",
54
+ ]
@@ -1,11 +1,12 @@
1
1
  """sessiontools - the harness layer: sessions, their status, and how long
2
2
  they have been idle - one driver per harness, a library first.
3
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.
4
+ Import it, or use the command it ships: `session` (Mike, voice 23:25-23:28
5
+ Mon 2026-09-28, when the session repo was retired). `session list` and
6
+ `session get` are built on the functions below; `session start` is its own
7
+ package, `sessiontools.start`; `session send` is disabled (2026-09-29).
7
8
 
8
- from sessiontools import sessions, find, idle_seconds
9
+ from sessiontools import sessions, live, find, idle_seconds
9
10
  from sessiontools import session_id, current, SESSION_ID_VARS
10
11
 
11
12
  Every function speaks OUR words - busy, not busy, unknown - never a
@@ -24,6 +25,7 @@ from sessiontools.claude_code import (
24
25
  current,
25
26
  find,
26
27
  idle_seconds,
28
+ live,
27
29
  session_id,
28
30
  sessions,
29
31
  sidecar_dir,
@@ -42,6 +44,7 @@ __all__ = [
42
44
  "current",
43
45
  "find",
44
46
  "idle_seconds",
47
+ "live",
45
48
  "session_id",
46
49
  "sessions",
47
50
  "sidecar_dir",
@@ -48,6 +48,10 @@ class Session:
48
48
  agent: str | None = None
49
49
  raw_status: str | None = None # the harness's own word, for the curious
50
50
  path: Path | None = None # the sidecar it came from
51
+ proc_start: str | None = None # the process's start, UTC ctime - liveness
52
+ started_at: float | None = None # epoch seconds, when the session began
53
+ version: str | None = None # the harness's version
54
+ tmux: str | None = None # where the harness says it runs: `sock:@win.%pane`
51
55
 
52
56
  def idle_seconds(self, now: float | None = None) -> float:
53
57
  """0 while busy; else seconds since the status last changed."""
@@ -86,9 +90,20 @@ def _read(path: Path) -> Session | None:
86
90
  agent=data.get("agent") if isinstance(data.get("agent"), str) else None,
87
91
  raw_status=status if isinstance(status, str) else None,
88
92
  path=path,
93
+ proc_start=data.get("procStart") if isinstance(data.get("procStart"), str) else None,
94
+ started_at=_ms(data.get("startedAt")),
95
+ version=data.get("version") if isinstance(data.get("version"), str) else None,
96
+ tmux=data.get("tmux") if isinstance(data.get("tmux"), str) else None,
89
97
  )
90
98
 
91
99
 
100
+ def _ms(value: object) -> float | None:
101
+ """Epoch milliseconds -> seconds; None when it is not a number."""
102
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
103
+ return None
104
+ return float(value) / 1000.0
105
+
106
+
92
107
  def sessions(env: dict[str, str] | None = None) -> list[Session]:
93
108
  """Every session with a readable sidecar, newest status first. A
94
109
  directory that is missing or unreadable is an empty list, not an
@@ -101,6 +116,16 @@ def sessions(env: dict[str, str] | None = None) -> list[Session]:
101
116
  return sorted(found, key=lambda s: s.updated_at, reverse=True)
102
117
 
103
118
 
119
+ def live(env: dict[str, str] | None = None,
120
+ procs: dict | None = None) -> list[Session]:
121
+ """The sessions whose process is running NOW - `sessions()` minus every
122
+ sidecar a crash left behind or whose pid was reused (`procs.same_process`,
123
+ the rule `session list` always used). Newest status first."""
124
+ from sessiontools import procs as _procs
125
+ table = _procs.table() if procs is None else procs
126
+ return [s for s in sessions(env) if _procs.same_process(s.pid, s.proc_start, table)]
127
+
128
+
104
129
  def find(session_id: str, env: dict[str, str] | None = None) -> Session | None:
105
130
  """The session with this id, or None when no sidecar names it."""
106
131
  for s in sessions(env):
@@ -0,0 +1,311 @@
1
+ """`session`: the fleet's session command, owned by sessiontools.
2
+
3
+ Mike ruled the cutover at 23:25-23:28 Mon 2026-09-28, the session repo just
4
+ moved aside as `session-20260928`: sessiontools provides `session` ("Ah,
5
+ yes, it should"), same verbs, and the verbs are PORTED - package code, "well
6
+ structured, in the right place" - not copied. See
7
+ `openspec/changes/the-session-command`.
8
+
9
+ session list [--json] [--no-recap] [--no-context] [--pane NAME]
10
+ session get [PROP [SEAT]] [--json]
11
+ session context [SEAT] [--peak] [--max N] [--json]
12
+ session start ... launch a session from an agent file
13
+ session mail ... (retired: says where each verb went)
14
+
15
+ One runtime dependency, PyYAML, imported by `session start` alone (Mike,
16
+ 00:12 Tue 2026-09-29); the rest is argparse and the library underneath.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+
25
+ MAIL_MOVED = """session mail: retired with the session repo (2026-09-28). Its verbs live in sessionmail:
26
+ session mail ls|tree|cat|run PATH -> memory ls|tree|cat|run PATH
27
+ session mail send ... -> message send ...
28
+ session mail arm / wait -> pager listen"""
29
+
30
+ VERBS = (
31
+ ("list", "the live sessions and their tmux panes"),
32
+ ("get", "read one property of a live seat"),
33
+ ("context", "how full a seat's context window is"),
34
+ ("start", "launch a session from an agent file"),
35
+ ("settings", "a session's settings, each key with the file that set it"),
36
+ )
37
+
38
+
39
+ def usage() -> str:
40
+ rows = "\n".join(f" {v:14} {d}" for v, d in VERBS)
41
+ return (f"Usage: session <verb> [args...]\n\nVerbs:\n{rows}\n\n"
42
+ "Run `session <verb> --help` for that verb's options.\n")
43
+
44
+
45
+ def _list(argv: list[str]) -> int:
46
+ from sessiontools import listing
47
+ p = argparse.ArgumentParser(prog="session list",
48
+ description="The live sessions and their tmux panes.")
49
+ p.add_argument("--json", action="store_true", help="machine-readable")
50
+ p.add_argument("--no-recap", action="store_true", help="accepted; recaps are not ported yet")
51
+ p.add_argument("--no-context", action="store_true",
52
+ help="skip CONTEXT (a transcript read per seat): '-' cells, null keys")
53
+ p.add_argument("--pane", metavar="NAME", help="print the pane id of the session matching NAME")
54
+ a = p.parse_args(argv)
55
+ found = listing.rows()
56
+ if a.pane is not None:
57
+ row = listing.match(found, a.pane)
58
+ if row is None or not row.tmux_pane:
59
+ print(f"session list: no live session with a pane matches {a.pane!r}", file=sys.stderr)
60
+ return 1
61
+ print(row.tmux_pane)
62
+ return 0
63
+ if not a.no_context:
64
+ found = listing.with_context(found)
65
+ print(listing.render_json(found) if a.json else listing.render_table(found))
66
+ return 0
67
+
68
+
69
+ def _get(argv: list[str]) -> int:
70
+ from sessiontools import get as _g
71
+ from sessiontools import listing
72
+ p = argparse.ArgumentParser(prog="session get", description="Read one property of a live seat.")
73
+ p.add_argument("prop", nargs="?")
74
+ p.add_argument("seat", nargs="?")
75
+ p.add_argument("--json", action="store_true")
76
+ a = p.parse_args(argv)
77
+ if a.prop is None:
78
+ print(_g.table())
79
+ return 0
80
+ try:
81
+ print(_g.get(listing.rows(), a.prop, a.seat, a.json))
82
+ except _g.GetError as e:
83
+ print(e, file=sys.stderr)
84
+ return e.code
85
+ return 0
86
+
87
+
88
+ def _send(argv: list[str]) -> int:
89
+ from sessiontools import listing
90
+ from sessiontools import send as _s
91
+ # Parsed by hand, not argparse: everything after NAME is TEXT, verbatim,
92
+ # dashes included - except a lone `-v`, the voice-mode trigger
93
+ # (sessionview's `speak` runs `session send "$name" -v`).
94
+ if argv[:1] == ["--list"]:
95
+ return _list([])
96
+ if not argv or argv[0] in ("-h", "--help"):
97
+ print(SEND_USAGE)
98
+ return 0 if argv else 2
99
+ name, rest = argv[0], argv[1:]
100
+ text = "v" if rest == ["-v"] else " ".join(rest)
101
+ if not text:
102
+ print(SEND_USAGE, file=sys.stderr)
103
+ return 2
104
+ found = listing.rows()
105
+ row = listing.match(found, name)
106
+ if row is None:
107
+ print(f"No session matching '{name}'", file=sys.stderr)
108
+ if found:
109
+ print("Available sessions:", file=sys.stderr)
110
+ for r in found:
111
+ print(f" {r.name:30} ({r.repo})", file=sys.stderr)
112
+ return 1
113
+ if not row.tmux_pane or not row.tmux_socket:
114
+ print(f"Session '{row.name}' found but has no tmux pane", file=sys.stderr)
115
+ return 1
116
+ if not _s.still_belongs(row.tmux_socket, row.tmux_pane, row.session.pid):
117
+ print(f"Pane {row.tmux_pane} no longer belongs to session '{row.name}' "
118
+ "(stale/recycled pane) - refusing to send", file=sys.stderr)
119
+ return 1
120
+ if _s.send_keys(row.tmux_socket, row.tmux_pane, text):
121
+ print(f"Sent to {row.name} (pane {row.tmux_pane}): {text}")
122
+ return 0
123
+ print(f"Failed to send to pane {row.tmux_pane}", file=sys.stderr)
124
+ return 1
125
+
126
+
127
+ CONTEXT_EPILOG = """\
128
+ Unlike the old session-context (retired 2026-09-28):
129
+ - the window is the model's; unknown or exceeded prints '?' and the JSON
130
+ carries max/pct/free as null (the old CLI assumed 1M, the confidently
131
+ wrong number). $CONTEXT_MAX still tightens a known window; it is no
132
+ longer --max's default.
133
+ - a transcript with no usage rows is exit 1, not '0 / 1.00M (0%)'.
134
+ - a <synthetic> zero reading on a seat that reached its peak is '?', as in
135
+ session list's CONTEXT; --peak shows the peak.
136
+ - --dir is gone: with no SEAT and no session in the environment, the newest
137
+ transcript of the current directory's project is read.
138
+ - SEAT is a live seat's id prefix, then the `session get` name rule, then
139
+ any session id prefix among the transcripts."""
140
+
141
+
142
+ def _context(argv: list[str]) -> int:
143
+ """The old `session-context` report, on sessiontools.window: the window
144
+ is the MODEL's (never a guessed 1M), or --max N when you assert one."""
145
+ from pathlib import Path
146
+
147
+ from sessiontools import listing, window
148
+ from sessiontools.claude_code import session_id
149
+ p = argparse.ArgumentParser(prog="session context", epilog=CONTEXT_EPILOG,
150
+ formatter_class=argparse.RawDescriptionHelpFormatter,
151
+ description="How full a seat's context window is: the "
152
+ "tokens in it now, of the model's window.")
153
+ p.add_argument("seat", nargs="?", metavar="SEAT",
154
+ help="a live seat or a session id / prefix; default: this session")
155
+ p.add_argument("--peak", action="store_true", help="the session's high-water mark, not now")
156
+ p.add_argument("--max", type=int, metavar="N", help="assert the window size (tokens, > 0)")
157
+ p.add_argument("--json", action="store_true", help="machine-readable")
158
+ a = p.parse_args(argv)
159
+ if a.max is not None and a.max <= 0:
160
+ p.error(f"--max must be a positive number of tokens, not {a.max}")
161
+ ident = a.seat or session_id()
162
+ live = listing.rows() if ident else []
163
+ row = listing.match_id_first(live, ident) if ident else None
164
+ if row is not None:
165
+ path = window.transcript_path(row.session.cwd, row.session.id)
166
+ elif ident:
167
+ path = window.transcript_path(None, ident)
168
+ else:
169
+ path = window.newest_transcript(str(Path.cwd()))
170
+ if path is None:
171
+ print(f"session context: no transcript for {ident or 'this directory'!r} "
172
+ f"(under {window.projects_dir()})", file=sys.stderr)
173
+ return 1
174
+ u = window.scan(path)
175
+ if u.usage_rows == 0:
176
+ print(f"session context: {path.name} has no usage rows yet", file=sys.stderr)
177
+ return 1
178
+ used = u.peak if a.peak else u.used
179
+ size = a.max if a.max else window.resolve_window(u.model, used)
180
+ zero_tail = not a.peak and u.used == 0 and u.peak > 0
181
+ pct = used * 100 // size if size and not zero_tail else None
182
+ free = size - used if size and not zero_tail else None
183
+ if a.json:
184
+ print(json.dumps({"used": used, "peak": u.peak, "max": size, "pct": pct,
185
+ "free": free, "session": path.stem, "model": u.model}))
186
+ return 0
187
+ tag = " [peak]" if a.peak else ""
188
+ fmt = window.fmt_tokens
189
+ if zero_tail:
190
+ print(f"Context: ? - the last reading is a <synthetic> zero; the peak was "
191
+ f"{fmt(u.peak)} (--peak)")
192
+ elif size is None:
193
+ print(f"Context: {fmt(used)} / ? - {window.why_unknown(u.model, used)} "
194
+ f"(--max N asserts one){tag}")
195
+ else:
196
+ print(f"Context: {fmt(used)} / {fmt(size)} ({pct}%) · {fmt(free)} free{tag}")
197
+ return 0
198
+
199
+
200
+ def _start(argv: list[str]) -> int:
201
+ try:
202
+ from sessiontools.start import main as start_main # Cora's port
203
+ except ImportError as e:
204
+ # The port has landed, so a failed import here is a broken install,
205
+ # never an unported verb. PyYAML is start's one runtime dependency
206
+ # and the likeliest thing missing. Exit 1: the launch failed (2 is
207
+ # usage or a refusal, and this is neither).
208
+ missing = "PyYAML" if e.name == "yaml" else (e.name or "a module")
209
+ print(f"session start: broken install - {missing} will not import ({e}); "
210
+ "reinstall sessiontools with its dependencies: "
211
+ "uv tool install --editable --reinstall CHECKOUT", file=sys.stderr)
212
+ return 1
213
+ return start_main(argv)
214
+
215
+
216
+ SEND_USAGE = """Usage:
217
+ session send NAME TEXT... type TEXT into the session matching NAME, then Enter
218
+ session send NAME -v send 'v' + Enter (the voice-mode trigger)
219
+ session send --list the live sessions (session list)
220
+
221
+ NAME matches part of a session's name or repo, or a prefix of its id; the
222
+ first match wins. TEXT is joined with spaces and typed literally."""
223
+
224
+
225
+ #: `session send` is DISABLED - Mike, voice 00:32 Tue 2026-09-29: "I
226
+ #: accidentally ran session send, and then it wrote something into your pane
227
+ #: ... we should disable it ... keep it somewhere in case I want it back ...
228
+ #: so it's not in the help and it's not available as a command ... it's too
229
+ #: tempting. If it's there, it's too inviting." So it is in neither the help
230
+ #: nor HANDLERS; `_send` and send.py stay, unwired. Bringing it back: delete
231
+ #: its DISABLED entry (checked FIRST in main, so it wins), add "send": _send
232
+ #: to HANDLERS, and restore its VERBS row.
233
+ DISABLED = {
234
+ "send": ("session send: disabled (Mike, 2026-09-29: it typed into an agent's pane "
235
+ "by accident). To reach an agent, mail it: message send ADDRESS -s SUBJECT --no-body"),
236
+ }
237
+
238
+ def _settings(argv: list[str]) -> int:
239
+ """`session settings <sid> [tool]`: the cascade computed, never on disk
240
+ (openspec/changes/session-settings). New paths only: a tool that has not
241
+ moved reads "not moved"."""
242
+ from sessiontools import settings
243
+ p = argparse.ArgumentParser(prog="session settings",
244
+ description="A session's settings, merged down the cascade "
245
+ "(template, host, agent, seat, session), each key "
246
+ "with the file that set it.")
247
+ p.add_argument("sid", nargs="?", metavar="SID", help="a session id; default: this session")
248
+ p.add_argument("tool", nargs="?", metavar="TOOL", help="one tool (mail, session, ears, mouth); default: every tool found")
249
+ p.add_argument("--agent", metavar="NAME", help="the agent layer to read; default: the session's own record")
250
+ p.add_argument("--seat", metavar="NAME", help="the seat layer to read; default: the session's own record")
251
+ p.add_argument("--json", action="store_true", help="machine-readable: values, origins, locked, problems per tool")
252
+ a = p.parse_args(argv)
253
+ sid = a.sid
254
+ if not sid:
255
+ from sessiontools.claude_code import session_id
256
+ sid = session_id()
257
+ if not sid:
258
+ p.error("no session id: name one, or run inside a session")
259
+ agent, seat = settings.identity(sid)
260
+ agent = a.agent or agent
261
+ seat = a.seat or seat
262
+ names = [a.tool] if a.tool else settings.tools(sid, agent, seat)
263
+ rc = 0
264
+ out: dict[str, dict] = {}
265
+ for name in names:
266
+ try:
267
+ res = settings.resolve(name, sid, agent, seat)
268
+ except settings.SettingsError as e:
269
+ print(f"session settings: refused: {e}", file=sys.stderr)
270
+ return 2
271
+ if res.problems:
272
+ rc = 2
273
+ if a.json:
274
+ out[name] = {"moved": res.moved, "values": res.values, "origins": res.origins,
275
+ "locked": res.locked, "problems": res.problems,
276
+ "sources": [{"layer": s.layer, "path": str(s.path)} for s in res.sources]}
277
+ else:
278
+ sys.stdout.write(settings.report(res))
279
+ if a.json:
280
+ print(json.dumps({"session": sid, "agent": agent, "seat": seat, "tools": out}, indent=1))
281
+ elif not names:
282
+ print(f"no settings at any new path for session {sid}")
283
+ return rc
284
+
285
+
286
+ HANDLERS = {"list": _list, "get": _get, "context": _context, "start": _start,
287
+ "settings": _settings}
288
+
289
+
290
+ def main(argv: list[str] | None = None) -> int:
291
+ args = list(sys.argv[1:] if argv is None else argv)
292
+ verb = args.pop(0) if args else ""
293
+ if verb in ("", "-h", "--help"):
294
+ sys.stdout.write(usage())
295
+ return 0
296
+ if verb == "mail":
297
+ print(MAIL_MOVED, file=sys.stderr)
298
+ return 2
299
+ if verb in DISABLED:
300
+ print(DISABLED[verb], file=sys.stderr)
301
+ return 2
302
+ handler = HANDLERS.get(verb)
303
+ if handler is None:
304
+ print(f"session: unknown verb {verb!r}\n", file=sys.stderr)
305
+ sys.stderr.write(usage())
306
+ return 2
307
+ return handler(args)
308
+
309
+
310
+ if __name__ == "__main__": # pragma: no cover
311
+ sys.exit(main())
@@ -0,0 +1,89 @@
1
+ """`session get`: read one property of a live seat.
2
+
3
+ The properties and output are the session repo's `session-get` (retired
4
+ 2026-09-28). `model` reads the transcript (`sessiontools.window`: the last
5
+ real model id, never `<synthetic>`); the rest come from `session list`.
6
+ Exit codes: 0 found; 1 unknown PROP; 2 no such seat, or no value for PROP.
7
+ (argparse usage errors are 2.) A SEAT is matched by the `session send`
8
+ rule; no SEAT means this session, from the environment.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+
16
+ from sessiontools import window
17
+ from sessiontools.claude_code import session_id
18
+ from sessiontools.listing import Row, match
19
+
20
+ #: (prop, source) in the order `session get` lists them.
21
+ PROPS = (
22
+ ("id", "session list"),
23
+ ("short", "session list"),
24
+ ("pid", "session list"),
25
+ ("name", "session list"),
26
+ ("agent", "session list"),
27
+ ("cwd", "session list"),
28
+ ("status", "session list"),
29
+ ("idle", "session list: 0 while status is busy, else seconds since it changed"),
30
+ ("version", "session list"),
31
+ ("pane", "session list"),
32
+ ("model", "the transcript: the last real model id"),
33
+ )
34
+
35
+
36
+ class GetError(Exception):
37
+ def __init__(self, message: str, code: int):
38
+ super().__init__(message)
39
+ self.code = code
40
+
41
+
42
+ def table() -> str:
43
+ lines = [f"{'PROP':8} {'SETTABLE':9} SOURCE"]
44
+ lines += [f"{p:8} {'no':9} {src}" for p, src in PROPS]
45
+ return "\n".join(lines)
46
+
47
+
48
+ def value(row: Row, prop: str, now: float | None = None) -> object:
49
+ s = row.session
50
+ if prop == "model": # a transcript read: only when asked for
51
+ return window.of(s.id, s.cwd).model
52
+ now = time.time() if now is None else now
53
+ return {
54
+ "id": s.id, "short": row.short, "pid": s.pid, "name": row.name,
55
+ "agent": s.agent, "cwd": s.cwd, "status": s.raw_status,
56
+ "idle": int(s.idle_seconds(now)), "version": s.version, "pane": row.tmux_pane,
57
+ }[prop]
58
+
59
+
60
+ def resolve(found: list[Row], seat: str | None, env: dict[str, str] | None = None) -> Row:
61
+ if seat:
62
+ row = match(found, seat)
63
+ if row is None:
64
+ raise GetError(f"session get: no live seat matches {seat!r} "
65
+ f"({len(found)} live; see `session list`)", 2)
66
+ return row
67
+ sid = session_id(env)
68
+ for row in found:
69
+ if sid and row.session.id == sid:
70
+ return row
71
+ raise GetError("session get: no SEAT given and this session is not a live one "
72
+ "(no CLAUDE_CODE_SESSION_ID, or no sidecar for it)", 2)
73
+
74
+
75
+ def get(found: list[Row], prop: str, seat: str | None, as_json: bool,
76
+ env: dict[str, str] | None = None) -> str:
77
+ if prop not in dict(PROPS):
78
+ raise GetError(f"session get: unknown PROP {prop!r}; one of: "
79
+ + ", ".join(p for p, _ in PROPS), 1)
80
+ row = resolve(found, seat, env)
81
+ v = value(row, prop)
82
+ if v is None:
83
+ raise GetError(f"session get: no {prop} known for {row.name}", 2)
84
+ if not as_json:
85
+ return str(v)
86
+ out = {"session_id": row.session.id, "name": row.name, prop: v}
87
+ if prop == "idle":
88
+ out["status"] = row.session.raw_status
89
+ return json.dumps(out)