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.
Files changed (107) hide show
  1. {sessiontools-0.3.2 → sessiontools-0.3.3}/PKG-INFO +13 -6
  2. {sessiontools-0.3.2 → sessiontools-0.3.3}/README.md +10 -5
  3. sessiontools-0.3.3/pyproject.toml +42 -0
  4. sessiontools-0.3.2/pyproject.toml → sessiontools-0.3.3/pyproject.toml.orig +11 -1
  5. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/busy.py +283 -22
  6. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/cli.py +13 -2
  7. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/operator.py +66 -13
  8. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/park.py +16 -7
  9. sessiontools-0.3.3/src/sessiontools/paths.py +322 -0
  10. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/settings.py +193 -10
  11. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/agents.py +35 -9
  12. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/args.py +1 -1
  13. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/policy.py +0 -2
  14. sessiontools-0.3.3/src/sessiontools/web/__init__.py +23 -0
  15. sessiontools-0.3.3/src/sessiontools/web/auth.py +756 -0
  16. sessiontools-0.3.3/src/sessiontools/web/bind.py +69 -0
  17. sessiontools-0.3.3/src/sessiontools/web/cli.py +196 -0
  18. sessiontools-0.3.3/src/sessiontools/web/passkey.js +94 -0
  19. sessiontools-0.3.3/src/sessiontools/web/passkey.py +187 -0
  20. sessiontools-0.3.3/src/sessiontools/web/proxy.py +189 -0
  21. sessiontools-0.3.3/src/sessiontools/web/rooms.py +256 -0
  22. sessiontools-0.3.3/src/sessiontools/web/server.py +600 -0
  23. sessiontools-0.3.3/src/sessiontools/web/settings_page.py +351 -0
  24. sessiontools-0.3.3/src/sessiontools/web/supervisor.py +216 -0
  25. sessiontools-0.3.3/src/sessiontools/web/unixhttp.py +197 -0
  26. sessiontools-0.3.2/src/sessiontools/paths.py +0 -165
  27. {sessiontools-0.3.2 → sessiontools-0.3.3}/LICENSE +0 -0
  28. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/__init__.py +0 -0
  29. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/__init__.py +0 -0
  30. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/cli.py +0 -0
  31. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/keychain.py +0 -0
  32. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/leakscan.py +0 -0
  33. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/limits.py +0 -0
  34. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/movehome.py +0 -0
  35. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/new.py +0 -0
  36. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/profile.py +0 -0
  37. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/registry.py +0 -0
  38. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/setuptoken.py +0 -0
  39. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/status.py +0 -0
  40. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/tokenfile.py +0 -0
  41. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/tokenstore.py +0 -0
  42. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/use.py +0 -0
  43. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/account/verify.py +0 -0
  44. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/band.py +0 -0
  45. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/claude_code.py +0 -0
  46. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/clockline.py +0 -0
  47. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/completion.py +0 -0
  48. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/__init__.py +0 -0
  49. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/boot.py +0 -0
  50. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/breakdown.py +0 -0
  51. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/hooks.py +0 -0
  52. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/nnls.py +0 -0
  53. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/render.py +0 -0
  54. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/slash_context.py +0 -0
  55. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/tools.py +0 -0
  56. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/context_parts/transcript.py +0 -0
  57. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/default_model.py +0 -0
  58. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/default_model_cli.py +0 -0
  59. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/default_model_watch.py +0 -0
  60. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/get.py +0 -0
  61. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/identity_block.py +0 -0
  62. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/__init__.py +0 -0
  63. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/_tiktoken_child.py +0 -0
  64. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/count.py +0 -0
  65. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/render.py +0 -0
  66. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/ledger/transcript.py +0 -0
  67. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/listing.py +0 -0
  68. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/migrate.py +0 -0
  69. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/pane_state.py +0 -0
  70. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/pane_state_agent.py +0 -0
  71. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/procs.py +0 -0
  72. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/py.typed +0 -0
  73. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/reap.py +0 -0
  74. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/send.py +0 -0
  75. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/session_info.py +0 -0
  76. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/settle.py +0 -0
  77. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/snapshot.py +0 -0
  78. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/sound/__init__.py +0 -0
  79. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/sound/engine.swift +0 -0
  80. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/sound/event.swift +0 -0
  81. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/sound/service.py +0 -0
  82. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/__init__.py +0 -0
  83. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/agentfile.py +0 -0
  84. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/boot_identity.py +0 -0
  85. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/claude_settings.py +0 -0
  86. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/context.py +0 -0
  87. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/default_seat.md +0 -0
  88. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/default_seat.py +0 -0
  89. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/environment.py +0 -0
  90. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/errors.py +0 -0
  91. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/holders.py +0 -0
  92. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/homefolder.py +0 -0
  93. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/identity.py +0 -0
  94. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/placement.py +0 -0
  95. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/plan.py +0 -0
  96. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/record.py +0 -0
  97. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/resume.py +0 -0
  98. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/run.py +0 -0
  99. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/secrets.py +0 -0
  100. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/start/vendor.py +0 -0
  101. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/statusline/__init__.py +0 -0
  102. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/statusline/sample.json +0 -0
  103. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/statusline/statusline.sh +0 -0
  104. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/tmux.py +0 -0
  105. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/turn.py +0 -0
  106. {sessiontools-0.3.2 → sessiontools-0.3.3}/src/sessiontools/verbs.py +0 -0
  107. {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.2
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/state/` keeps using it until it is moved.
138
- - `~/.session/sessions/<session-id>/`: one for each `session start`,
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 `state` and `sound` in the new folder; an existing
300
- one stays put until it is moved, and a moved one is followed
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 old one for the rest.
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/state/` keeps using it until it is moved.
122
- - `~/.session/sessions/<session-id>/`: one for each `session start`,
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 `state` and `sound` in the new folder; an existing
284
- one stays put until it is moved, and a moved one is followed
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 old one for the rest.
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.2"
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 rule, copied (not imported) into
564
- # `mouth_dir()`: `$MOUTH_DIR`, else `<sessionvoice home>/mouth`.
565
- # We only stat and read; we never write, lock or create anything in the mouth
566
- # dir or under any home it is looked for in.
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 default home, then the old one, under $HOME.
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` holds a
608
- `heard.lock` or a `heard_*.jsonl`. A dir that exists but holds no heard
609
- log never decides. A stat, never a write; an OSError is False."""
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").exists() or any(d.glob("heard_*.jsonl"))
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, by sessionvoice's own rule (its `mouth/paths.py` and
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. Else `<home>/mouth`, where home is the first of: `$SESSIONVOICE_HOME`,
625
- `$VOICEMODE_BASE_DIR` (each only when non-empty, with `$VAR` and `~`
626
- expanded); `~/.sessionvoice` if it holds a heard log; `~/.voicemode`
627
- if it holds a heard log; else `~/.sessionvoice`.
628
-
629
- `~` and `$VAR` expand against ENV and its HOME. Only stats: nothing is
630
- written, locked or created under any home."""
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
- for root in (home / h for h in MOUTH_HOMES):
658
- if _populated(root):
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: