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.
- {sessiontools-0.0.3 → sessiontools-0.0.4}/PKG-INFO +3 -1
- sessiontools-0.0.4/pyproject.toml +54 -0
- {sessiontools-0.0.3 → sessiontools-0.0.4}/src/sessiontools/__init__.py +7 -4
- {sessiontools-0.0.3 → sessiontools-0.0.4}/src/sessiontools/claude_code.py +25 -0
- sessiontools-0.0.4/src/sessiontools/cli.py +311 -0
- sessiontools-0.0.4/src/sessiontools/get.py +89 -0
- sessiontools-0.0.4/src/sessiontools/listing.py +169 -0
- sessiontools-0.0.4/src/sessiontools/procs.py +78 -0
- sessiontools-0.0.4/src/sessiontools/send.py +72 -0
- sessiontools-0.0.4/src/sessiontools/settings.py +437 -0
- sessiontools-0.0.4/src/sessiontools/settle.py +150 -0
- sessiontools-0.0.4/src/sessiontools/start/__init__.py +155 -0
- sessiontools-0.0.4/src/sessiontools/start/agentfile.py +297 -0
- sessiontools-0.0.4/src/sessiontools/start/agents.py +138 -0
- sessiontools-0.0.4/src/sessiontools/start/args.py +184 -0
- sessiontools-0.0.4/src/sessiontools/start/context.py +410 -0
- sessiontools-0.0.4/src/sessiontools/start/environment.py +269 -0
- sessiontools-0.0.4/src/sessiontools/start/errors.py +44 -0
- sessiontools-0.0.4/src/sessiontools/start/holders.py +193 -0
- sessiontools-0.0.4/src/sessiontools/start/identity.py +310 -0
- sessiontools-0.0.4/src/sessiontools/start/placement.py +204 -0
- sessiontools-0.0.4/src/sessiontools/start/plan.py +757 -0
- sessiontools-0.0.4/src/sessiontools/start/policy.py +244 -0
- sessiontools-0.0.4/src/sessiontools/start/record.py +401 -0
- sessiontools-0.0.4/src/sessiontools/start/resume.py +133 -0
- sessiontools-0.0.4/src/sessiontools/start/run.py +274 -0
- sessiontools-0.0.4/src/sessiontools/start/secrets.py +189 -0
- sessiontools-0.0.4/src/sessiontools/start/vendor.py +238 -0
- sessiontools-0.0.4/src/sessiontools/tmux.py +90 -0
- sessiontools-0.0.4/src/sessiontools/window.py +292 -0
- sessiontools-0.0.3/pyproject.toml +0 -38
- sessiontools-0.0.3/src/sessiontools/cli.py +0 -14
- {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
|
+
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
|
|
5
|
-
|
|
6
|
-
|
|
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)
|