sessiontools 0.3.2__tar.gz → 0.3.3__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.
- {sessiontools-0.3.2 → sessiontools-0.3.3}/PKG-INFO +13 -6
- {sessiontools-0.3.2 → sessiontools-0.3.3}/README.md +10 -5
- sessiontools-0.3.3/pyproject.toml +42 -0
- sessiontools-0.3.2/pyproject.toml → sessiontools-0.3.3/pyproject.toml.orig +11 -1
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/busy.py +283 -22
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/cli.py +13 -2
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/operator.py +66 -13
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/park.py +16 -7
- sessiontools-0.3.3/src/sessiontools/paths.py +322 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/settings.py +193 -10
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/agents.py +35 -9
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/args.py +1 -1
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/policy.py +0 -2
- sessiontools-0.3.3/src/sessiontools/web/__init__.py +23 -0
- sessiontools-0.3.3/src/sessiontools/web/auth.py +756 -0
- sessiontools-0.3.3/src/sessiontools/web/bind.py +69 -0
- sessiontools-0.3.3/src/sessiontools/web/cli.py +196 -0
- sessiontools-0.3.3/src/sessiontools/web/passkey.js +94 -0
- sessiontools-0.3.3/src/sessiontools/web/passkey.py +187 -0
- sessiontools-0.3.3/src/sessiontools/web/proxy.py +189 -0
- sessiontools-0.3.3/src/sessiontools/web/rooms.py +256 -0
- sessiontools-0.3.3/src/sessiontools/web/server.py +600 -0
- sessiontools-0.3.3/src/sessiontools/web/settings_page.py +351 -0
- sessiontools-0.3.3/src/sessiontools/web/supervisor.py +216 -0
- sessiontools-0.3.3/src/sessiontools/web/unixhttp.py +197 -0
- sessiontools-0.3.2/src/sessiontools/paths.py +0 -165
- {sessiontools-0.3.2 → sessiontools-0.3.3}/LICENSE +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/__init__.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/__init__.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/cli.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/keychain.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/leakscan.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/limits.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/movehome.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/new.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/profile.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/registry.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/setuptoken.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/status.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/tokenfile.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/tokenstore.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/use.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/verify.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/band.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/claude_code.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/clockline.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/completion.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/__init__.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/boot.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/breakdown.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/hooks.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/nnls.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/render.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/slash_context.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/tools.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/transcript.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/default_model.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/default_model_cli.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/default_model_watch.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/get.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/identity_block.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/__init__.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/_tiktoken_child.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/count.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/render.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/transcript.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/listing.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/migrate.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/pane_state.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/pane_state_agent.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/procs.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/py.typed +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/reap.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/send.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/session_info.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/settle.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/snapshot.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/sound/__init__.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/sound/engine.swift +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/sound/event.swift +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/sound/service.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/__init__.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/agentfile.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/boot_identity.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/claude_settings.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/context.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/default_seat.md +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/default_seat.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/environment.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/errors.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/holders.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/homefolder.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/identity.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/placement.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/plan.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/record.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/resume.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/run.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/secrets.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/vendor.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/statusline/__init__.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/statusline/sample.json +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/statusline/statusline.sh +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/tmux.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/turn.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/verbs.py +0 -0
- {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/window.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sessiontools
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: The harness layer: sessions, status and idle, one driver per harness - a library first
|
|
5
5
|
Author: Mike Bailey
|
|
6
6
|
Author-email: Mike Bailey <mike@sessionkit.ai>
|
|
@@ -9,9 +9,11 @@ License-File: LICENSE
|
|
|
9
9
|
Classifier: Development Status :: 3 - Alpha
|
|
10
10
|
Requires-Dist: pyyaml>=6
|
|
11
11
|
Requires-Dist: jsonschema>=4
|
|
12
|
+
Requires-Dist: cryptography>=42 ; extra == 'passkey'
|
|
12
13
|
Requires-Python: >=3.11, <3.15
|
|
13
14
|
Project-URL: Homepage, https://github.com/sessionkit/sessiontools
|
|
14
15
|
Project-URL: Repository, https://github.com/sessionkit/sessiontools
|
|
16
|
+
Provides-Extra: passkey
|
|
15
17
|
Description-Content-Type: text/markdown
|
|
16
18
|
|
|
17
19
|
# sessiontools
|
|
@@ -134,8 +136,8 @@ it. Each line says what puts it there and what takes it away.
|
|
|
134
136
|
- `~/.sessionkit/tools/state/list/`: the list cache, `snapshot.json`
|
|
135
137
|
and `list.lock`, at the first `session list`. The state folders here
|
|
136
138
|
and below are on a fresh host; a host that still has
|
|
137
|
-
`~/.session
|
|
138
|
-
- `~/.
|
|
139
|
+
`~/.session/` keeps using its folders there until they are moved.
|
|
140
|
+
- `~/.sessionkit/tools/state/sessions/<session-id>/`: one for each `session start`,
|
|
139
141
|
holding `launch/`, `log/settings.jsonl` and `session-id`. It also
|
|
140
142
|
gets `settings/<tool>.d/50-env.json`, one for each tool whose
|
|
141
143
|
settings name a variable that is set when the session starts. Each
|
|
@@ -296,8 +298,9 @@ be set, and where each one lives ([configuration][configuration]):
|
|
|
296
298
|
which layers it is read from: [settings][settings].
|
|
297
299
|
- **Folders of its own:** `~/.sessionkit/tools/accounts/` (`session
|
|
298
300
|
account`), and its state, each leaf with an old and a new folder. A
|
|
299
|
-
fresh host starts
|
|
300
|
-
one stays put until it is moved, and a moved one
|
|
301
|
+
fresh host, one with no `~/.session/`, starts every leaf in the new
|
|
302
|
+
folder; an existing one stays put until it is moved, and a moved one
|
|
303
|
+
is followed
|
|
301
304
|
([where tools keeps its state][state-paths]):
|
|
302
305
|
- `state`: `~/.session/state/`, new `~/.sessionkit/tools/state/`.
|
|
303
306
|
- `sessions`, `seats`, `agent` and `backups`: `~/.session/<leaf>/`, new
|
|
@@ -306,7 +309,8 @@ be set, and where each one lives ([configuration][configuration]):
|
|
|
306
309
|
`~/.sessionkit/tools/state/sound/`.
|
|
307
310
|
- **The rule, per leaf:** its variable when set; else the old folder
|
|
308
311
|
while it is real; else the new one when it exists; else the new one
|
|
309
|
-
for `state` and `sound`, the
|
|
312
|
+
for `state` and `sound`, and for the rest the new one on a fresh host
|
|
313
|
+
and the old one on a host that has `~/.session/`.
|
|
310
314
|
- **To see what is used:** `session path LEAF`, or `session path --json`.
|
|
311
315
|
- **To move `state` and `sound`:** `session path migrate --dry-run`,
|
|
312
316
|
then a real run with that host's go ([moving tools' state][state-migrate]).
|
|
@@ -333,6 +337,9 @@ be set, and where each one lives ([configuration][configuration]):
|
|
|
333
337
|
the launchd agent `session default-model unit launchd` prints.
|
|
334
338
|
- **`session reap --yes`:** `~/.sessionkit/tools/log/reap.log`, one line
|
|
335
339
|
for each seat it closed or failed to close.
|
|
340
|
+
- **Busy detection:** `~/.sessionkit/tools/log/busy.log`, one line each
|
|
341
|
+
time voice could not be asked where its mouth is, naming why and the
|
|
342
|
+
folder used instead ([the busy sound][busy]).
|
|
336
343
|
- **Each session's settings at birth:**
|
|
337
344
|
`~/.session/sessions/<session-id>/log/settings.jsonl`, what `session
|
|
338
345
|
start` read into that session's settings: each environment variable
|
|
@@ -118,8 +118,8 @@ it. Each line says what puts it there and what takes it away.
|
|
|
118
118
|
- `~/.sessionkit/tools/state/list/`: the list cache, `snapshot.json`
|
|
119
119
|
and `list.lock`, at the first `session list`. The state folders here
|
|
120
120
|
and below are on a fresh host; a host that still has
|
|
121
|
-
`~/.session
|
|
122
|
-
- `~/.
|
|
121
|
+
`~/.session/` keeps using its folders there until they are moved.
|
|
122
|
+
- `~/.sessionkit/tools/state/sessions/<session-id>/`: one for each `session start`,
|
|
123
123
|
holding `launch/`, `log/settings.jsonl` and `session-id`. It also
|
|
124
124
|
gets `settings/<tool>.d/50-env.json`, one for each tool whose
|
|
125
125
|
settings name a variable that is set when the session starts. Each
|
|
@@ -280,8 +280,9 @@ be set, and where each one lives ([configuration][configuration]):
|
|
|
280
280
|
which layers it is read from: [settings][settings].
|
|
281
281
|
- **Folders of its own:** `~/.sessionkit/tools/accounts/` (`session
|
|
282
282
|
account`), and its state, each leaf with an old and a new folder. A
|
|
283
|
-
fresh host starts
|
|
284
|
-
one stays put until it is moved, and a moved one
|
|
283
|
+
fresh host, one with no `~/.session/`, starts every leaf in the new
|
|
284
|
+
folder; an existing one stays put until it is moved, and a moved one
|
|
285
|
+
is followed
|
|
285
286
|
([where tools keeps its state][state-paths]):
|
|
286
287
|
- `state`: `~/.session/state/`, new `~/.sessionkit/tools/state/`.
|
|
287
288
|
- `sessions`, `seats`, `agent` and `backups`: `~/.session/<leaf>/`, new
|
|
@@ -290,7 +291,8 @@ be set, and where each one lives ([configuration][configuration]):
|
|
|
290
291
|
`~/.sessionkit/tools/state/sound/`.
|
|
291
292
|
- **The rule, per leaf:** its variable when set; else the old folder
|
|
292
293
|
while it is real; else the new one when it exists; else the new one
|
|
293
|
-
for `state` and `sound`, the
|
|
294
|
+
for `state` and `sound`, and for the rest the new one on a fresh host
|
|
295
|
+
and the old one on a host that has `~/.session/`.
|
|
294
296
|
- **To see what is used:** `session path LEAF`, or `session path --json`.
|
|
295
297
|
- **To move `state` and `sound`:** `session path migrate --dry-run`,
|
|
296
298
|
then a real run with that host's go ([moving tools' state][state-migrate]).
|
|
@@ -317,6 +319,9 @@ be set, and where each one lives ([configuration][configuration]):
|
|
|
317
319
|
the launchd agent `session default-model unit launchd` prints.
|
|
318
320
|
- **`session reap --yes`:** `~/.sessionkit/tools/log/reap.log`, one line
|
|
319
321
|
for each seat it closed or failed to close.
|
|
322
|
+
- **Busy detection:** `~/.sessionkit/tools/log/busy.log`, one line each
|
|
323
|
+
time voice could not be asked where its mouth is, naming why and the
|
|
324
|
+
folder used instead ([the busy sound][busy]).
|
|
320
325
|
- **Each session's settings at birth:**
|
|
321
326
|
`~/.session/sessions/<session-id>/log/settings.jsonl`, what `session
|
|
322
327
|
start` read into that session's settings: each environment variable
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sessiontools"
|
|
3
|
+
version = "0.3.3"
|
|
4
|
+
description = "The harness layer: sessions, status and idle, one driver per harness - a library first"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "AGPL-3.0-or-later"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
classifiers = ["Development Status :: 3 - Alpha"]
|
|
9
|
+
requires-python = ">=3.11,<3.15"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"PyYAML>=6",
|
|
12
|
+
"jsonschema>=4",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[[project.authors]]
|
|
16
|
+
name = "Mike Bailey"
|
|
17
|
+
email = "mike@sessionkit.ai"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
passkey = ["cryptography>=42"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/sessionkit/sessiontools"
|
|
24
|
+
Repository = "https://github.com/sessionkit/sessiontools"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
session = "sessiontools.cli:main"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.11.9,<0.12.0"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8",
|
|
36
|
+
"ruff>=0.6",
|
|
37
|
+
"cryptography>=42",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
addopts = "--strict-markers"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sessiontools"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.3"
|
|
4
4
|
description = "The harness layer: sessions, status and idle, one driver per harness - a library first"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "AGPL-3.0-or-later"
|
|
@@ -34,6 +34,14 @@ dependencies = ["PyYAML>=6", "jsonschema>=4"]
|
|
|
34
34
|
# aside: sessiontools provides it, same verbs, ported as package code.
|
|
35
35
|
# Exactly one package owns `session` on the PATH; from here it is this one.
|
|
36
36
|
# argparse and the library; PyYAML for `session start` alone (above).
|
|
37
|
+
# The front door's passkey (`session web serve`, PROPOSED door-passkey):
|
|
38
|
+
# WebAuthn verifies ECDSA and RSA signatures, which the standard library
|
|
39
|
+
# cannot, so `cryptography` comes only with this extra and only
|
|
40
|
+
# `sessiontools.web.passkey` imports it. Without it the front door is the
|
|
41
|
+
# pairing code alone. Rule 3: an extra is the maintainer's call too.
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
passkey = ["cryptography>=42"]
|
|
44
|
+
|
|
37
45
|
[project.urls]
|
|
38
46
|
Homepage = "https://github.com/sessionkit/sessiontools"
|
|
39
47
|
Repository = "https://github.com/sessionkit/sessiontools"
|
|
@@ -49,6 +57,8 @@ build-backend = "uv_build"
|
|
|
49
57
|
dev = [
|
|
50
58
|
"pytest>=8",
|
|
51
59
|
"ruff>=0.6",
|
|
60
|
+
# the passkey extra's tests run its ceremonies with a software authenticator
|
|
61
|
+
"cryptography>=42",
|
|
52
62
|
]
|
|
53
63
|
|
|
54
64
|
[tool.pytest.ini_options]
|
|
@@ -560,10 +560,15 @@ def stop(slot: str | Path) -> bool:
|
|
|
560
560
|
# asked to bless it or to name the surface they would rather we read).
|
|
561
561
|
# Every read of the mouth's state goes through MOUTH_PLAYING, MOUTH_PID and
|
|
562
562
|
# `mouth_speaking()` below, so a different surface is a one-line change there.
|
|
563
|
-
# WHERE the mouth lives is sessionvoice's
|
|
564
|
-
#
|
|
565
|
-
#
|
|
566
|
-
# dir
|
|
563
|
+
# WHERE the mouth lives is sessionvoice's to say, and tools ASKS it:
|
|
564
|
+
# `$MOUTH_DIR` and the home variables first, as before; then voice's own
|
|
565
|
+
# answer, `ears home state-dir --json` (its stdlib twin `python -m ears.home
|
|
566
|
+
# state-dir --json`), asked once and cached in tools' state (`voice_root()`),
|
|
567
|
+
# so the mouth is `<root>/mouth`. Only when no voice can be asked does
|
|
568
|
+
# `mouth_dir()` fall back to voice's D1 order, copied (`fallback_root()`), and
|
|
569
|
+
# say so in a debug line (BUSY_LOG). We only stat and read under voice's
|
|
570
|
+
# homes; we never write, lock or create anything in the mouth dir or under any
|
|
571
|
+
# home it is looked for in. The cache and the debug line are tools' own files.
|
|
567
572
|
|
|
568
573
|
#: The mouth's "speaking now" file: written (tmp + rename) when its player
|
|
569
574
|
#: takes a line, before synthesis; unlinked when the line ends. UNBLESSED.
|
|
@@ -580,8 +585,27 @@ STEP = 0.1
|
|
|
580
585
|
MOUTH_HOME_ENV = ("SESSIONVOICE_HOME", "VOICEMODE_BASE_DIR")
|
|
581
586
|
#: The mouth dir's own variable, then its old name (presence, not truthiness).
|
|
582
587
|
MOUTH_DIR_ENV = ("MOUTH_DIR", "VOICEMODE_MOUTH_DIR")
|
|
583
|
-
#: sessionvoice's
|
|
588
|
+
#: sessionvoice's two OLD roots under $HOME (its `OLD_ROOTS`), in its D1
|
|
589
|
+
#: order: `~/.sessionvoice` is read before the new root, `~/.voicemode` after
|
|
590
|
+
#: it. Read only by the fallback, when voice cannot be asked.
|
|
584
591
|
MOUTH_HOMES = (".sessionvoice", ".voicemode")
|
|
592
|
+
#: voice's NEW root, under the kit's home: `~/.sessionkit/voice/state`.
|
|
593
|
+
VOICE_STATE_DIR = "state"
|
|
594
|
+
#: voice's command and the words that ask it for its state root (voice's
|
|
595
|
+
#: documented CLI): `{"version", "root": {"path", "source"}, "items": {...}}`,
|
|
596
|
+
#: each entry in the words of `session path --json`.
|
|
597
|
+
EARS = "ears"
|
|
598
|
+
EARS_ASK = ("home", "state-dir", "--json")
|
|
599
|
+
#: Its stdlib twin, the same answer from the package's module, asked with
|
|
600
|
+
#: this interpreter when `ears` is not on PATH but the package imports here.
|
|
601
|
+
EARS_MODULE = "ears.home"
|
|
602
|
+
EARS_TWIN_ASK = ("state-dir", "--json")
|
|
603
|
+
#: One ask's budget, in seconds. Past it, voice could not be asked.
|
|
604
|
+
ASK_TIMEOUT = 3.0
|
|
605
|
+
#: voice's answer, cached across processes in tools' own state leaf.
|
|
606
|
+
VOICE_CACHE = "voice-state-dir.json"
|
|
607
|
+
#: The debug line's file, in tools' log dir: a hook says nothing on stderr.
|
|
608
|
+
BUSY_LOG = "busy.log"
|
|
585
609
|
|
|
586
610
|
#: The standard library's own pattern: an unclosed `${` takes the rest of the
|
|
587
611
|
#: value with it, so nothing after it expands.
|
|
@@ -603,31 +627,270 @@ def _expandvars(raw: str, e: Mapping[str, str]) -> str:
|
|
|
603
627
|
return _VAR.sub(one, raw) if "$" in raw else raw
|
|
604
628
|
|
|
605
629
|
|
|
630
|
+
def _real_file(path: Path) -> bool:
|
|
631
|
+
"""PATH is a regular file of its own, not a link. An lstat; OSError is
|
|
632
|
+
False."""
|
|
633
|
+
try:
|
|
634
|
+
return stat.S_ISREG(os.lstat(path).st_mode)
|
|
635
|
+
except OSError:
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
|
|
606
639
|
def _populated(root: Path) -> bool:
|
|
607
|
-
"""sessionvoice's POPULATED: `<root>/logs/conversations`
|
|
608
|
-
`heard.lock` or a `heard_*.jsonl
|
|
609
|
-
|
|
640
|
+
"""sessionvoice's POPULATED rule: `<root>/logs/conversations`
|
|
641
|
+
holds a `heard.lock` or a `heard_*.jsonl` that is a REAL file, so a link
|
|
642
|
+
into another root is not this root's own log. A dir that exists but
|
|
643
|
+
holds no heard log never decides. Stats, never a write; an OSError is
|
|
644
|
+
False."""
|
|
610
645
|
d = root / "logs" / "conversations"
|
|
611
646
|
try:
|
|
612
|
-
return (d / "heard.lock")
|
|
647
|
+
return _real_file(d / "heard.lock") or any(_real_file(f) for f in d.glob("heard_*.jsonl"))
|
|
613
648
|
except OSError:
|
|
614
649
|
return False
|
|
615
650
|
|
|
616
651
|
|
|
652
|
+
def _same_dir(a: Path, b: Path) -> bool:
|
|
653
|
+
"""A and B are one directory (links followed), as voice's "not the new
|
|
654
|
+
root itself" and tools' settings._same_dir. Either missing: False."""
|
|
655
|
+
try:
|
|
656
|
+
return os.path.samefile(a, b)
|
|
657
|
+
except (OSError, ValueError):
|
|
658
|
+
return False
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def voice_new_root(env: Mapping[str, str] | None = None) -> Path:
|
|
662
|
+
"""voice's NEW state root, `~/.sessionkit/voice/state`: the kit's home,
|
|
663
|
+
voice's package (settings.PACKAGES), its `state/`."""
|
|
664
|
+
return settings.kit_root(env) / settings.PACKAGES["mouth"] / VOICE_STATE_DIR
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def fallback_root(env: Mapping[str, str] | None = None) -> Path:
|
|
668
|
+
"""voice's state root by its D1 order, COPIED, for when voice cannot be
|
|
669
|
+
asked (its home variables are read before this, by mouth_dir):
|
|
670
|
+
|
|
671
|
+
3. `~/.sessionvoice`, POPULATED and not the new root itself;
|
|
672
|
+
4. `~/.sessionkit/voice/state`, POPULATED;
|
|
673
|
+
5. `~/.voicemode`, POPULATED and not the new root itself;
|
|
674
|
+
6. else `~/.sessionkit/voice/state`, where a fresh host's voice writes.
|
|
675
|
+
|
|
676
|
+
So a live host whose `~/.sessionvoice` (or, on a host not yet moved,
|
|
677
|
+
`~/.voicemode`) holds real heard files keeps it. Stats only."""
|
|
678
|
+
home = settings.home(env)
|
|
679
|
+
new = voice_new_root(env)
|
|
680
|
+
sv, vm = (home / h for h in MOUTH_HOMES)
|
|
681
|
+
if _populated(sv) and not _same_dir(sv, new):
|
|
682
|
+
return sv
|
|
683
|
+
if _populated(new):
|
|
684
|
+
return new
|
|
685
|
+
if _populated(vm) and not _same_dir(vm, new):
|
|
686
|
+
return vm
|
|
687
|
+
return new
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def read_state_dir_answer(raw: str | bytes) -> dict[str, Any] | None:
|
|
691
|
+
"""voice's `state-dir --json` answer, as RAW text, read by tools' ONE
|
|
692
|
+
entry reader (settings.read_leaf_entry: the shape and `source` words of
|
|
693
|
+
`session path --json`). `{"version": str, "root": {"path", "source"}}`
|
|
694
|
+
when RAW is `{"version": <non-empty str>, "root": <entry>, "items":
|
|
695
|
+
{...}}`, else None: an unknown shape is "voice could not be asked",
|
|
696
|
+
never a path. Never raises."""
|
|
697
|
+
try:
|
|
698
|
+
obj = json.loads(raw)
|
|
699
|
+
except (ValueError, TypeError):
|
|
700
|
+
return None
|
|
701
|
+
return _answer_of(obj)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _answer_of(obj: object) -> dict[str, Any] | None:
|
|
705
|
+
"""read_state_dir_answer's check, on the parsed OBJ."""
|
|
706
|
+
if not isinstance(obj, dict) or not isinstance(obj.get("items"), dict):
|
|
707
|
+
return None
|
|
708
|
+
version = obj.get("version")
|
|
709
|
+
root = settings.read_leaf_entry(obj.get("root"))
|
|
710
|
+
if not isinstance(version, str) or not version or root is None:
|
|
711
|
+
return None
|
|
712
|
+
return {"version": version, "root": {"path": str(root[0]), "source": root[1]}}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def voice_cache_path(env: Mapping[str, str] | None = None) -> Path:
|
|
716
|
+
"""Where voice's answer is cached: VOICE_CACHE in tools' state leaf
|
|
717
|
+
(settings.state_root), never under a voice home or the mouth dir."""
|
|
718
|
+
return settings.state_root(env) / VOICE_CACHE
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _sig(path: str | Path | None, follow: bool = True) -> list[int] | None:
|
|
722
|
+
"""A file's identity for the staleness probe: kind, device, inode, size
|
|
723
|
+
and mtime (ns). None when it is not there."""
|
|
724
|
+
if path is None:
|
|
725
|
+
return None
|
|
726
|
+
try:
|
|
727
|
+
st = os.stat(path) if follow else os.lstat(path)
|
|
728
|
+
except OSError:
|
|
729
|
+
return None
|
|
730
|
+
return [stat.S_IFMT(st.st_mode), st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _ears_twin() -> str | None:
|
|
734
|
+
"""`ears/home.py` as this interpreter would import it, found WITHOUT
|
|
735
|
+
importing it; None when there is none."""
|
|
736
|
+
import importlib.util
|
|
737
|
+
package, _, module = EARS_MODULE.partition(".")
|
|
738
|
+
try:
|
|
739
|
+
spec = importlib.util.find_spec(package)
|
|
740
|
+
except (ImportError, ValueError):
|
|
741
|
+
return None
|
|
742
|
+
if spec is None or not spec.submodule_search_locations:
|
|
743
|
+
return None
|
|
744
|
+
for loc in spec.submodule_search_locations:
|
|
745
|
+
home_py = os.path.join(loc, f"{module}.py")
|
|
746
|
+
if os.path.isfile(home_py):
|
|
747
|
+
return home_py
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _probe(env: Mapping[str, str] | None, e: Mapping[str, str]) -> dict[str, Any]:
|
|
752
|
+
"""What voice's answer depends on, read with stats and no process: the
|
|
753
|
+
HOME; the `ears` on PATH and the twin's `home.py`, each with its file's
|
|
754
|
+
identity (an install or upgrade replaces it); and each root D1 weighs,
|
|
755
|
+
by its own lstat (a migrate makes `~/.sessionvoice` a link) and its
|
|
756
|
+
conversations dir (a heard log appearing). A cached answer is used
|
|
757
|
+
only while this is equal, so a new voice or a moved root asks again."""
|
|
758
|
+
exe = shutil.which(EARS, path=e.get("PATH", ""))
|
|
759
|
+
twin = _ears_twin()
|
|
760
|
+
home = settings.home(env)
|
|
761
|
+
roots = (home / MOUTH_HOMES[0], voice_new_root(env), home / MOUTH_HOMES[1])
|
|
762
|
+
return {"home": str(home),
|
|
763
|
+
"ears": [exe, _sig(exe)] if exe else None,
|
|
764
|
+
"twin": [twin, _sig(twin)] if twin else None,
|
|
765
|
+
"roots": [[str(r), _sig(r, follow=False), _sig(r / "logs" / "conversations")]
|
|
766
|
+
for r in roots]}
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _ask(probe: Mapping[str, Any], e: Mapping[str, str]) -> tuple[dict[str, Any] | None, str]:
|
|
770
|
+
"""Ask voice: `ears home state-dir --json`, else the twin. (answer, "")
|
|
771
|
+
from the first that exits 0 with the documented shape, else (None, why).
|
|
772
|
+
A non-zero exit, a timeout or an unknown shape is "could not be asked"."""
|
|
773
|
+
tries = []
|
|
774
|
+
if probe["ears"]:
|
|
775
|
+
tries.append([probe["ears"][0], *EARS_ASK])
|
|
776
|
+
if probe["twin"]:
|
|
777
|
+
tries.append([sys.executable, "-m", EARS_MODULE, *EARS_TWIN_ASK])
|
|
778
|
+
if not tries:
|
|
779
|
+
return None, f"no `{EARS}` on PATH and no {EARS_MODULE} to import"
|
|
780
|
+
why = []
|
|
781
|
+
for argv in tries:
|
|
782
|
+
said = " ".join([os.path.basename(argv[0]), *argv[1:]])
|
|
783
|
+
try:
|
|
784
|
+
done = subprocess.run(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
785
|
+
stderr=subprocess.DEVNULL, env=dict(e),
|
|
786
|
+
timeout=ASK_TIMEOUT, check=False)
|
|
787
|
+
except subprocess.TimeoutExpired:
|
|
788
|
+
why.append(f"`{said}` took over {ASK_TIMEOUT:g} s")
|
|
789
|
+
continue
|
|
790
|
+
except OSError as err:
|
|
791
|
+
why.append(f"`{said}` could not run ({type(err).__name__})")
|
|
792
|
+
continue
|
|
793
|
+
if done.returncode != 0:
|
|
794
|
+
why.append(f"`{said}` exited {done.returncode}")
|
|
795
|
+
continue
|
|
796
|
+
answer = read_state_dir_answer(done.stdout)
|
|
797
|
+
if answer is None:
|
|
798
|
+
why.append(f"`{said}` answered a shape tools does not know")
|
|
799
|
+
continue
|
|
800
|
+
return answer, ""
|
|
801
|
+
return None, "; ".join(why)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _read_cache(path: Path) -> dict[str, Any] | None:
|
|
805
|
+
"""The cache at PATH, when it is whole: `{"v": 1, "probe": {...},
|
|
806
|
+
"answer": <an answer> | null, "why": str}`. Missing, unreadable,
|
|
807
|
+
corrupt or of another shape: None, a miss."""
|
|
808
|
+
try:
|
|
809
|
+
held = json.loads(path.read_text(encoding="utf-8"))
|
|
810
|
+
except (OSError, ValueError, UnicodeError):
|
|
811
|
+
return None
|
|
812
|
+
if not isinstance(held, dict) or held.get("v") != 1 or not isinstance(held.get("probe"), dict):
|
|
813
|
+
return None
|
|
814
|
+
answer = held.get("answer")
|
|
815
|
+
if answer is not None and (not isinstance(answer, dict)
|
|
816
|
+
or _answer_of({**answer, "items": {}}) != answer):
|
|
817
|
+
return None
|
|
818
|
+
return held
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _write_cache(path: Path, body: Mapping[str, Any]) -> None:
|
|
822
|
+
"""Write the cache: 0600, atomic (temp, then rename). Best effort."""
|
|
823
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}")
|
|
824
|
+
try:
|
|
825
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
826
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
827
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
828
|
+
fh.write(json.dumps(body) + "\n")
|
|
829
|
+
os.replace(tmp, path)
|
|
830
|
+
except Exception: # noqa: BLE001 - a cache that cannot be written is a miss next time
|
|
831
|
+
_unlink(tmp)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def log_path(env: Mapping[str, str] | None = None) -> Path:
|
|
835
|
+
"""BUSY_LOG in tools' log dir: `~/.sessionkit/tools/log/busy.log`."""
|
|
836
|
+
return settings.log_dir(settings.PACKAGES["session"], env) / BUSY_LOG
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _debug(env: Mapping[str, str] | None, line: str) -> None:
|
|
840
|
+
"""One debug line in log_path(): time, a tab, `mouth_dir`, a tab, LINE.
|
|
841
|
+
A hook says nothing on stderr, so this is where it goes. Best effort."""
|
|
842
|
+
with contextlib.suppress(Exception):
|
|
843
|
+
log = log_path(env)
|
|
844
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
845
|
+
when = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
846
|
+
with open(log, "a", encoding="utf-8") as fh:
|
|
847
|
+
fh.write(f"{when}\tmouth_dir\t{line}\n")
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def voice_root(env: Mapping[str, str] | None = None) -> Path | None:
|
|
851
|
+
"""voice's state root, AS VOICE ANSWERS IT, or None when no voice can be
|
|
852
|
+
asked. Asked ONCE and cached across processes (VOICE_CACHE), keyed on
|
|
853
|
+
the answer's `version` and the probe (`_probe`): the warm path is stats
|
|
854
|
+
and one small read, no process. A probe that differs asks again. "Could
|
|
855
|
+
not be asked" is cached too, so a host with no voice (or an old voice
|
|
856
|
+
without the verb) spawns nothing on every call; its miss writes one
|
|
857
|
+
debug line. A corrupt cache is a miss. Never raises."""
|
|
858
|
+
e = os.environ if env is None else env
|
|
859
|
+
try:
|
|
860
|
+
probe = _probe(env, e)
|
|
861
|
+
cache = voice_cache_path(env)
|
|
862
|
+
held = _read_cache(cache)
|
|
863
|
+
if held is not None and held["probe"] == probe:
|
|
864
|
+
answer = held.get("answer")
|
|
865
|
+
else:
|
|
866
|
+
answer, why = _ask(probe, e)
|
|
867
|
+
_write_cache(cache, {"v": 1, "probe": probe, "answer": answer, "why": why})
|
|
868
|
+
if answer is None:
|
|
869
|
+
_debug(env, f"voice could not be asked ({why}); the mouth is found by "
|
|
870
|
+
f"voice's D1 order, copied: {fallback_root(env) / 'mouth'}")
|
|
871
|
+
return None if answer is None else Path(answer["root"]["path"])
|
|
872
|
+
except Exception: # noqa: BLE001 - busy detection never raises
|
|
873
|
+
return None
|
|
874
|
+
|
|
875
|
+
|
|
617
876
|
def mouth_dir(env: Mapping[str, str] | None = None) -> Path:
|
|
618
|
-
"""The mouth dir,
|
|
619
|
-
`ears/home.py`), copied here so that nothing imports sessionvoice:
|
|
877
|
+
"""The mouth dir, as sessionvoice says it:
|
|
620
878
|
|
|
621
879
|
1. `$MOUTH_DIR` if it is set, else the old `$VOICEMODE_MOUTH_DIR` if that
|
|
622
880
|
is set (presence: a set `MOUTH_DIR` hides the old name even when
|
|
623
881
|
empty). A non-empty value is the dir; an empty one falls through.
|
|
624
|
-
2.
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
882
|
+
2. `$SESSIONVOICE_HOME`, then `$VOICEMODE_BASE_DIR` (each only when
|
|
883
|
+
non-empty, with `$VAR` and `~` expanded), `/mouth`.
|
|
884
|
+
3. ASK voice: `<root>/mouth`, where root is `ears home state-dir --json`'s
|
|
885
|
+
answer (voice_root: asked once, cached on its version).
|
|
886
|
+
4. Only when no voice can be asked: `<fallback_root>/mouth`, voice's D1
|
|
887
|
+
order copied (populated `~/.sessionvoice`, populated
|
|
888
|
+
`~/.sessionkit/voice/state`, populated `~/.voicemode`, else
|
|
889
|
+
`~/.sessionkit/voice/state`), and a debug line says so.
|
|
890
|
+
|
|
891
|
+
`~` and `$VAR` expand against ENV and its HOME. Under voice's homes it
|
|
892
|
+
only stats: nothing is written, locked or created there. The cache and
|
|
893
|
+
the debug line are tools' own (voice_cache_path, BUSY_LOG)."""
|
|
631
894
|
e = os.environ if env is None else env
|
|
632
895
|
home = settings.home(env)
|
|
633
896
|
|
|
@@ -654,10 +917,8 @@ def mouth_dir(env: Mapping[str, str] | None = None) -> Path:
|
|
|
654
917
|
raw = e.get(n)
|
|
655
918
|
if raw:
|
|
656
919
|
return Path(expanduser(_expandvars(raw, e))) / "mouth"
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
return root / "mouth"
|
|
660
|
-
return home / MOUTH_HOMES[0] / "mouth"
|
|
920
|
+
asked = voice_root(env)
|
|
921
|
+
return (asked if asked is not None else fallback_root(env)) / "mouth"
|
|
661
922
|
|
|
662
923
|
|
|
663
924
|
def _pid_live(path: Path) -> bool:
|
|
@@ -27,6 +27,8 @@ package code, well structured, in the right place - not copied.
|
|
|
27
27
|
session park --check [SEAT] [--json] read it back: parked, clean, and why not
|
|
28
28
|
session path [LEAF [NAME]] [--json] where tools keeps each leaf of its state
|
|
29
29
|
session completion bash|zsh a shell completion script, on stdout
|
|
30
|
+
session web pair | logout [ID... | --all] | rescan | serve [--host H] [--port N]
|
|
31
|
+
the kit web front door (PROPOSED)
|
|
30
32
|
session mail ... (retired: says where each verb went)
|
|
31
33
|
|
|
32
34
|
Two runtime dependencies, each imported lazily by the verb that needs it:
|
|
@@ -66,6 +68,7 @@ VERBS = (
|
|
|
66
68
|
("park", "say this seat has settled, and what the checks found (--check reads it back)"),
|
|
67
69
|
("path", "where tools keeps each leaf of its state (state, sessions, ...), for peers"),
|
|
68
70
|
("completion", "a completion script for bash or zsh, on stdout"),
|
|
71
|
+
("web", "the kit's web front door (serve | pair | logout | rescan); PROPOSED"),
|
|
69
72
|
)
|
|
70
73
|
|
|
71
74
|
|
|
@@ -828,13 +831,20 @@ def _path(argv: list[str]) -> int:
|
|
|
828
831
|
return paths.main(argv)
|
|
829
832
|
|
|
830
833
|
|
|
834
|
+
def _web(argv: list[str]) -> int:
|
|
835
|
+
"""`session web pair|logout|rescan`: the kit web front door (PROPOSED,
|
|
836
|
+
kit-web-shell). Imported here and nowhere else; stdlib only."""
|
|
837
|
+
from sessiontools.web import cli as web_cli
|
|
838
|
+
return web_cli.main(argv)
|
|
839
|
+
|
|
840
|
+
|
|
831
841
|
HANDLERS = {"list": _list, "get": _get, "context": _context, "start": _start, "agent": _agent,
|
|
832
842
|
"identity": _identity,
|
|
833
843
|
"settings": _settings, "sound": _sound, "operator": _operator,
|
|
834
844
|
"statusline": _statusline, "pane-state": _pane_state,
|
|
835
845
|
"default-model": _default_model, "ledger": _ledger,
|
|
836
846
|
"account": _account, "reap": _reap, "park": _park,
|
|
837
|
-
"path": _path, "completion": _completion}
|
|
847
|
+
"path": _path, "completion": _completion, "web": _web}
|
|
838
848
|
|
|
839
849
|
#: Each verb's parser, as a `sessiontools.verbs` reference: a builder, or a
|
|
840
850
|
#: `Group` for a verb whose first word is dispatched by hand. Each handler
|
|
@@ -858,7 +868,8 @@ PARSERS = {"list": "sessiontools.cli:_list_parser",
|
|
|
858
868
|
"reap": "sessiontools.reap:build_parser",
|
|
859
869
|
"park": "sessiontools.park:build_parser",
|
|
860
870
|
"path": "sessiontools.paths:GROUP",
|
|
861
|
-
"completion": "sessiontools.cli:_completion_parser"
|
|
871
|
+
"completion": "sessiontools.cli:_completion_parser",
|
|
872
|
+
"web": "sessiontools.web.cli:build_parser"}
|
|
862
873
|
|
|
863
874
|
|
|
864
875
|
def main(argv: list[str] | None = None) -> int:
|