sessionmail 0.3.1__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 (92) hide show
  1. {sessionmail-0.3.1 → sessionmail-0.3.3}/.claude-plugin/plugin.json +1 -1
  2. {sessionmail-0.3.1 → sessionmail-0.3.3}/PKG-INFO +41 -17
  3. {sessionmail-0.3.1 → sessionmail-0.3.3}/README.md +39 -15
  4. {sessionmail-0.3.1 → sessionmail-0.3.3}/hooks/mail-guard/README.md +64 -8
  5. {sessionmail-0.3.1 → sessionmail-0.3.3}/pyproject.toml +3 -3
  6. {sessionmail-0.3.1 → sessionmail-0.3.3}/scripts/seat-agent/README.md +26 -8
  7. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_archive.py +16 -11
  8. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_boxes.py +197 -3
  9. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_detect.py +36 -7
  10. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_drop.py +6 -13
  11. sessionmail-0.3.3/src/sessionmail/_fresh.py +158 -0
  12. sessionmail-0.3.3/src/sessionmail/_frozen.py +899 -0
  13. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_index.py +74 -9
  14. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_launchd.py +61 -20
  15. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_layer.py +17 -3
  16. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_maildir.py +183 -22
  17. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_mhs.py +19 -1
  18. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_migrate.py +17 -13
  19. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_pull_agent.py +9 -3
  20. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_pull_imap.py +19 -2
  21. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_puller.py +121 -26
  22. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_reach.py +32 -10
  23. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_root.py +15 -3
  24. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_seat.py +231 -21
  25. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_send.py +8 -6
  26. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_sender.py +48 -16
  27. sessionmail-0.3.3/src/sessionmail/_settings.py +1905 -0
  28. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_setup.py +295 -39
  29. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_shorten.py +9 -2
  30. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_subscribe.py +17 -5
  31. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_subscription.py +21 -8
  32. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_views.py +14 -1
  33. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/hook_lines.toml +30 -0
  34. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/humans +1 -1
  35. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mail.schema.json +45 -4
  36. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/memory.py +38 -10
  37. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/message.py +294 -47
  38. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mhs/mail-box.lib.sh +48 -5
  39. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mhs/mhs-thread +25 -5
  40. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mhs/session-mail-wake +331 -17
  41. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/notmuch/notmuch-config.sessionkit.template +5 -3
  42. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/notmuch/post-new +101 -12
  43. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/notmuch/store-index +41 -6
  44. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/pager.py +893 -104
  45. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/postfix/delivery-check +76 -11
  46. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/postfix/nm-deliver +29 -7
  47. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/postfix/vmailbox-gen +185 -32
  48. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/rules.py +172 -30
  49. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/seat/seat-agent +31 -8
  50. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/seat/seat-agent.plist.template +8 -2
  51. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/seat_init.py +17 -5
  52. sessionmail-0.3.1/src/sessionmail/_settings.py +0 -825
  53. {sessionmail-0.3.1 → sessionmail-0.3.3}/.gitignore +0 -0
  54. {sessionmail-0.3.1 → sessionmail-0.3.3}/LICENSE +0 -0
  55. {sessionmail-0.3.1 → sessionmail-0.3.3}/plugins/pager-monitor/README.md +0 -0
  56. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/__init__.py +0 -0
  57. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_arm.py +0 -0
  58. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_birth.py +0 -0
  59. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_detect_imap.py +0 -0
  60. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_generator.py +0 -0
  61. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_headers.py +0 -0
  62. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_help.py +0 -0
  63. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_host.py +0 -0
  64. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_init.py +0 -0
  65. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_injectlog.py +0 -0
  66. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_magicdns.py +0 -0
  67. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_mcp.py +0 -0
  68. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_metrics.py +0 -0
  69. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_notmuch.py +0 -0
  70. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_operator.py +0 -0
  71. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_parsers.py +0 -0
  72. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_record.py +0 -0
  73. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_render.py +0 -0
  74. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_say.py +0 -0
  75. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_sig.py +0 -0
  76. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_stub.py +0 -0
  77. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_tokens.py +0 -0
  78. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_voice.py +0 -0
  79. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/_why.py +0 -0
  80. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/arm.txt +0 -0
  81. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mhs/mail-ext.lib.sh +0 -0
  82. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mhs/session-mail-cat +0 -0
  83. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mhs/session-mail-ls +0 -0
  84. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mhs/session-mail-run +0 -0
  85. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mhs/session-mail-tree +0 -0
  86. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/mhs/session-mail-verify +0 -0
  87. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/notmuch/notmuch-config.store.template +0 -0
  88. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/postfix/magicdns-suffix +0 -0
  89. {sessionmail-0.3.1 → sessionmail-0.3.3}/src/sessionmail/settings.template.json +0 -0
  90. {sessionmail-0.3.1 → sessionmail-0.3.3}/tests/fixtures/sig_v2/README.md +0 -0
  91. {sessionmail-0.3.1 → sessionmail-0.3.3}/tests/fixtures/sm827_hint_root/README.md +0 -0
  92. {sessionmail-0.3.1 → sessionmail-0.3.3}/tests/fixtures/sm827_honest/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessionmail",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Mail for coding agents: message moves messages, pager alerts, memory is the filesystem view - three commands over one maildir.",
5
5
  "author": {
6
6
  "name": "Mike Bailey",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sessionmail
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: sessionmail - mail for coding agents: message, pager and memory over one maildir.
5
5
  Project-URL: Homepage, https://github.com/sessionkit/sessionmail
6
6
  Project-URL: Repository, https://github.com/sessionkit/sessionmail
@@ -14,7 +14,7 @@ Requires-Dist: cryptography>=42
14
14
  Requires-Dist: markdown>=3.6
15
15
  Requires-Dist: mcp>=2.2.0
16
16
  Requires-Dist: pymdown-extensions>=10.0
17
- Requires-Dist: sessiontools>=0.3.0
17
+ Requires-Dist: sessiontools>=0.3.3
18
18
  Description-Content-Type: text/markdown
19
19
 
20
20
  # sessionmail
@@ -91,8 +91,7 @@ hook says so once per session rather than leaving you to guess.
91
91
  name.
92
92
  - Reading needs no server either: `message list`, `tree` and `read`, and
93
93
  `pager`, read the maildir directly. `message mark` and `memory` read the
94
- [notmuch][] index, which is only as current as its last `notmuch new`
95
- (below).
94
+ [notmuch][] index, and refresh it themselves before they read (below).
96
95
  - **Mail across machines** goes through postfix, which is opt-in:
97
96
  [postfix configuration][postfix-configuration].
98
97
 
@@ -112,9 +111,12 @@ message setup --box NAME # the mail root, the boxes, the notmuch profile
112
111
  - It never touches your own notmuch config, never edits postfix, and
113
112
  writes no settings key.
114
113
  - **Setup runs `notmuch new --no-hooks` once**, on its own empty profile,
115
- to build the index the hook needs. Nothing keeps it fresh: `message mark`
116
- and `memory` read what was indexed until something runs `notmuch new`
117
- again: launchd, cron, or by hand.
114
+ to build the index the hook needs. It schedules nothing and installs no
115
+ job.
116
+ - **The readers keep the index fresh.** `memory` runs a bounded
117
+ `notmuch new --no-hooks` before it reads, and `message mark` runs one
118
+ when an id is missing. The bound is `notmuch.refresh`: 2 seconds by
119
+ default, and 0 turns it off: [notmuch][].
118
120
 
119
121
  `message`, `pager` and `memory` all run; the specs, in the git repository
120
122
  (the release archive leaves them out), say what each verb is meant to do
@@ -125,11 +127,25 @@ and `make progress` counts how much of it is built.
125
127
  ```sh
126
128
  claude plugin uninstall sessionmail # skills, hooks, MCP entries
127
129
  uv tool uninstall sessionmail # the three commands
128
- rm ~/.mail/.notmuch/hooks/post-new ~/.mail/.notmuch/hooks/post-insert # the linked hook (in notmuch's database.hook_dir)
129
- notmuch --config="$HOME/.config/notmuch/sessionkit/config" config set sessionmail.post_new_command # the optional command, off
130
- rm ~/.config/notmuch/sessionkit/config # sessionmail's notmuch profile (its index stays in ~/.mail/.notmuch)
130
+ R="$(message path --root)"; rm "$R/.notmuch/hooks/post-new" "$R/.notmuch/hooks/post-insert" # the linked hook (in notmuch's database.hook_dir)
131
131
  ```
132
132
 
133
+ - sessionmail's notmuch profile (its index stays in the mail root's
134
+ `.notmuch`). A fresh host:
135
+
136
+ ```sh
137
+ notmuch --config="$HOME/.sessionkit/mail/settings/notmuch/sessionkit/config" config set sessionmail.post_new_command # the optional command, off
138
+ rm ~/.sessionkit/mail/settings/notmuch/sessionkit/config
139
+ ```
140
+
141
+ - A host that kept its older profile (or `~/.notmuch-config.sessionkit`,
142
+ if that is the file it has):
143
+
144
+ ```sh
145
+ notmuch --config="$HOME/.config/notmuch/sessionkit/config" config set sessionmail.post_new_command # the optional command, off
146
+ rm ~/.config/notmuch/sessionkit/config
147
+ ```
148
+
133
149
  ## Configuration
134
150
 
135
151
  Which files, which environment variables, and where each one lives, one
@@ -141,8 +157,10 @@ component at a time: [configuration][configuration].
141
157
  and the two maps the generator writes as you, in
142
158
  `/usr/local/etc/postfix-agents/`.
143
159
  - **notmuch**, the index: sessionmail's own profile,
144
- `~/.config/notmuch/sessionkit/config`. Your own `~/.notmuch-config` is
145
- never read.
160
+ `~/.sessionkit/mail/settings/notmuch/sessionkit/config` on a fresh host.
161
+ An older `~/.config/notmuch/sessionkit/config` (or
162
+ `~/.notmuch-config.sessionkit`) is still read while it exists, and is
163
+ never moved. Your own `~/.notmuch-config` is never read.
146
164
  - **Environment variables**: each settings key has one (the `sendmail`
147
165
  key's is `SESSIONMAIL_SENDMAIL`). When it is set and not empty, it wins
148
166
  over the file.
@@ -160,13 +178,14 @@ to record:
160
178
  under `~/.session/seats/<seat>/`: each settings change for that session
161
179
  or seat, such as a subscription or its address, with the command that
162
180
  made it.
163
- - `~/.mail/.state/regen-failures.log`: each time the map generator
181
+ - `<mail root>/.state/regen-failures.log` (`message path --root`): each time the map generator
164
182
  refused to rewrite the alias map, and why.
165
- - `~/.sessionmail/post-new/log`: the [post-new command][post-new-command]'s
183
+ - `~/.sessionkit/mail/state/post-new/log` (`~/.sessionmail/post-new/log` on
184
+ an existing host where that folder is still real): the [post-new command][post-new-command]'s
166
185
  runs and output, only once you set one. Past 1 MiB it becomes `log.1`.
167
186
 
168
187
  Follow one as it grows with `tail -F`, for example
169
- `tail -F ~/.mail/.state/regen-failures.log`. `pager status` ends its
188
+ `tail -F "$(message path --root)/.state/regen-failures.log"`. `pager status` ends its
170
189
  `today:` line with the path of this session's `pager.jsonl`.
171
190
 
172
191
  - **Health:** `pager status`, run by the agent. `monitor: armed (pid N)`,
@@ -181,12 +200,15 @@ Follow one as it grows with `tail -F`, for example
181
200
  Everything else is in `docs/`, one subject per file:
182
201
 
183
202
  - [The pager][pager], the model in one page; [how `pager listen`
184
- works][pager-listen-internals]; [the pager monitor][pager-monitor]; [not
185
- running under Claude Code][pager-outside-claude]; [what the pager
203
+ works][pager-listen-internals]; [the pager monitor][pager-monitor] and
204
+ its [frozen-turn watch][frozen-watch], off by default; [not running
205
+ under Claude Code][pager-outside-claude]; [what the pager
186
206
  injects][pager-inject-log].
187
207
  - Three hidden verbs: [`message seat`][message-seat], a seat with no Claude
188
208
  session; [`message receipt`][message-receipt], what another session
189
209
  recorded; and [`message setup`][message-setup], making a host ready.
210
+ - [Your first memory root, by hand][memory-first-root]: `memory` has no
211
+ automated first step yet.
190
212
  - [Gating on `message verify --json`][verify-json-verdict]: the verdict as
191
213
  one JSON object.
192
214
  - [The writing model][session-model]: the signed `Session-Model:` header,
@@ -207,12 +229,14 @@ Everything else is in `docs/`, one subject per file:
207
229
  [pager]: docs/pager.md
208
230
  [pager-listen-internals]: docs/pager-listen-internals.md
209
231
  [pager-monitor]: docs/pager-monitor.md
232
+ [frozen-watch]: docs/frozen-watch.md
210
233
  [pager-outside-claude]: docs/pager-outside-claude.md
211
234
  [pager-inject-log]: docs/pager-inject-log.md
212
235
  [message-seat]: docs/message-seat.md
213
236
  [session-model]: docs/session-model.md
214
237
  [message-receipt]: docs/message-receipt.md
215
238
  [message-setup]: docs/message-setup.md
239
+ [memory-first-root]: docs/memory-first-root.md
216
240
  [verify-json-verdict]: docs/verify-json-verdict.md
217
241
  [mail-delivery-paths]: docs/mail-delivery-paths.md
218
242
  [postfix-configuration]: docs/postfix-configuration.md
@@ -72,8 +72,7 @@ hook says so once per session rather than leaving you to guess.
72
72
  name.
73
73
  - Reading needs no server either: `message list`, `tree` and `read`, and
74
74
  `pager`, read the maildir directly. `message mark` and `memory` read the
75
- [notmuch][] index, which is only as current as its last `notmuch new`
76
- (below).
75
+ [notmuch][] index, and refresh it themselves before they read (below).
77
76
  - **Mail across machines** goes through postfix, which is opt-in:
78
77
  [postfix configuration][postfix-configuration].
79
78
 
@@ -93,9 +92,12 @@ message setup --box NAME # the mail root, the boxes, the notmuch profile
93
92
  - It never touches your own notmuch config, never edits postfix, and
94
93
  writes no settings key.
95
94
  - **Setup runs `notmuch new --no-hooks` once**, on its own empty profile,
96
- to build the index the hook needs. Nothing keeps it fresh: `message mark`
97
- and `memory` read what was indexed until something runs `notmuch new`
98
- again: launchd, cron, or by hand.
95
+ to build the index the hook needs. It schedules nothing and installs no
96
+ job.
97
+ - **The readers keep the index fresh.** `memory` runs a bounded
98
+ `notmuch new --no-hooks` before it reads, and `message mark` runs one
99
+ when an id is missing. The bound is `notmuch.refresh`: 2 seconds by
100
+ default, and 0 turns it off: [notmuch][].
99
101
 
100
102
  `message`, `pager` and `memory` all run; the specs, in the git repository
101
103
  (the release archive leaves them out), say what each verb is meant to do
@@ -106,11 +108,25 @@ and `make progress` counts how much of it is built.
106
108
  ```sh
107
109
  claude plugin uninstall sessionmail # skills, hooks, MCP entries
108
110
  uv tool uninstall sessionmail # the three commands
109
- rm ~/.mail/.notmuch/hooks/post-new ~/.mail/.notmuch/hooks/post-insert # the linked hook (in notmuch's database.hook_dir)
110
- notmuch --config="$HOME/.config/notmuch/sessionkit/config" config set sessionmail.post_new_command # the optional command, off
111
- rm ~/.config/notmuch/sessionkit/config # sessionmail's notmuch profile (its index stays in ~/.mail/.notmuch)
111
+ R="$(message path --root)"; rm "$R/.notmuch/hooks/post-new" "$R/.notmuch/hooks/post-insert" # the linked hook (in notmuch's database.hook_dir)
112
112
  ```
113
113
 
114
+ - sessionmail's notmuch profile (its index stays in the mail root's
115
+ `.notmuch`). A fresh host:
116
+
117
+ ```sh
118
+ notmuch --config="$HOME/.sessionkit/mail/settings/notmuch/sessionkit/config" config set sessionmail.post_new_command # the optional command, off
119
+ rm ~/.sessionkit/mail/settings/notmuch/sessionkit/config
120
+ ```
121
+
122
+ - A host that kept its older profile (or `~/.notmuch-config.sessionkit`,
123
+ if that is the file it has):
124
+
125
+ ```sh
126
+ notmuch --config="$HOME/.config/notmuch/sessionkit/config" config set sessionmail.post_new_command # the optional command, off
127
+ rm ~/.config/notmuch/sessionkit/config
128
+ ```
129
+
114
130
  ## Configuration
115
131
 
116
132
  Which files, which environment variables, and where each one lives, one
@@ -122,8 +138,10 @@ component at a time: [configuration][configuration].
122
138
  and the two maps the generator writes as you, in
123
139
  `/usr/local/etc/postfix-agents/`.
124
140
  - **notmuch**, the index: sessionmail's own profile,
125
- `~/.config/notmuch/sessionkit/config`. Your own `~/.notmuch-config` is
126
- never read.
141
+ `~/.sessionkit/mail/settings/notmuch/sessionkit/config` on a fresh host.
142
+ An older `~/.config/notmuch/sessionkit/config` (or
143
+ `~/.notmuch-config.sessionkit`) is still read while it exists, and is
144
+ never moved. Your own `~/.notmuch-config` is never read.
127
145
  - **Environment variables**: each settings key has one (the `sendmail`
128
146
  key's is `SESSIONMAIL_SENDMAIL`). When it is set and not empty, it wins
129
147
  over the file.
@@ -141,13 +159,14 @@ to record:
141
159
  under `~/.session/seats/<seat>/`: each settings change for that session
142
160
  or seat, such as a subscription or its address, with the command that
143
161
  made it.
144
- - `~/.mail/.state/regen-failures.log`: each time the map generator
162
+ - `<mail root>/.state/regen-failures.log` (`message path --root`): each time the map generator
145
163
  refused to rewrite the alias map, and why.
146
- - `~/.sessionmail/post-new/log`: the [post-new command][post-new-command]'s
164
+ - `~/.sessionkit/mail/state/post-new/log` (`~/.sessionmail/post-new/log` on
165
+ an existing host where that folder is still real): the [post-new command][post-new-command]'s
147
166
  runs and output, only once you set one. Past 1 MiB it becomes `log.1`.
148
167
 
149
168
  Follow one as it grows with `tail -F`, for example
150
- `tail -F ~/.mail/.state/regen-failures.log`. `pager status` ends its
169
+ `tail -F "$(message path --root)/.state/regen-failures.log"`. `pager status` ends its
151
170
  `today:` line with the path of this session's `pager.jsonl`.
152
171
 
153
172
  - **Health:** `pager status`, run by the agent. `monitor: armed (pid N)`,
@@ -162,12 +181,15 @@ Follow one as it grows with `tail -F`, for example
162
181
  Everything else is in `docs/`, one subject per file:
163
182
 
164
183
  - [The pager][pager], the model in one page; [how `pager listen`
165
- works][pager-listen-internals]; [the pager monitor][pager-monitor]; [not
166
- running under Claude Code][pager-outside-claude]; [what the pager
184
+ works][pager-listen-internals]; [the pager monitor][pager-monitor] and
185
+ its [frozen-turn watch][frozen-watch], off by default; [not running
186
+ under Claude Code][pager-outside-claude]; [what the pager
167
187
  injects][pager-inject-log].
168
188
  - Three hidden verbs: [`message seat`][message-seat], a seat with no Claude
169
189
  session; [`message receipt`][message-receipt], what another session
170
190
  recorded; and [`message setup`][message-setup], making a host ready.
191
+ - [Your first memory root, by hand][memory-first-root]: `memory` has no
192
+ automated first step yet.
171
193
  - [Gating on `message verify --json`][verify-json-verdict]: the verdict as
172
194
  one JSON object.
173
195
  - [The writing model][session-model]: the signed `Session-Model:` header,
@@ -188,12 +210,14 @@ Everything else is in `docs/`, one subject per file:
188
210
  [pager]: docs/pager.md
189
211
  [pager-listen-internals]: docs/pager-listen-internals.md
190
212
  [pager-monitor]: docs/pager-monitor.md
213
+ [frozen-watch]: docs/frozen-watch.md
191
214
  [pager-outside-claude]: docs/pager-outside-claude.md
192
215
  [pager-inject-log]: docs/pager-inject-log.md
193
216
  [message-seat]: docs/message-seat.md
194
217
  [session-model]: docs/session-model.md
195
218
  [message-receipt]: docs/message-receipt.md
196
219
  [message-setup]: docs/message-setup.md
220
+ [memory-first-root]: docs/memory-first-root.md
197
221
  [verify-json-verdict]: docs/verify-json-verdict.md
198
222
  [mail-delivery-paths]: docs/mail-delivery-paths.md
199
223
  [postfix-configuration]: docs/postfix-configuration.md
@@ -131,6 +131,41 @@ backtick or `$(` somewhere). One false positive was found and fixed before
131
131
  this landed — arithmetic `$(( … ))` in a subject. ~18µs per command; the hook
132
132
  process through its shim, ~27ms.
133
133
 
134
+ ## Turning it off — `"mail_guard": false`
135
+
136
+ The guard has one switch, in the host settings file
137
+ `~/.sessionkit/mail/settings/mail.json`: `"mail_guard": false` turns off its
138
+ subject and body checks for every seat on the host. It is the lever for a
139
+ guard that is broken. A person turns it off with `pager settings hooks
140
+ mail_guard off`, and back on with `pager settings hooks mail_guard on`;
141
+ `pager settings hooks mail_guard` says which.
142
+
143
+ - **Missing means on.** Only the boolean `false` turns the checks off. Any
144
+ other value (`"no"`, `0`), and a file that is missing, cannot be read, is
145
+ not JSON or is not a JSON object, leaves them on. An odd or broken
146
+ settings file never turns the guard off.
147
+ - **The subagent rules stay on.** Inside a subagent, the guard still denies
148
+ what would take or mark the seat's mail, write its settings or switch its
149
+ hooks, through both doors, whatever the switch says. The switch is read
150
+ only when the subject and body checks would run, so an MCP call and a
151
+ call those rules deny never read the file.
152
+ - **`"hooks"` does not touch it.** `"hooks": false` silences the hooks that
153
+ speak; the guard still judges.
154
+ - **Off, nothing stands before the shell.** The CLI refuses a subject that
155
+ *arrives* carrying a backtick or `$(`, but the shell has already expanded
156
+ and run a double-quoted one by then. With the guard off, that command runs.
157
+ - **It is a person's switch, by convention.** Every seat runs as the user
158
+ who owns the file, so nothing refuses a main-thread agent that writes it.
159
+ Each change the verb makes is logged, with who made it
160
+ ([the settings doc][settings-hooks]); turn it off only when a person
161
+ asked for it.
162
+
163
+ The guard reads the file itself, stdlib only (`guard_switched_off` in
164
+ `hook.py`): it cannot import sessionmail. A test keeps its reading in step
165
+ with sessionmail's own reader.
166
+
167
+ [settings-hooks]: ../../docs/settings.md#the-per-hook-switches
168
+
134
169
  ## Wiring it — the plugin registers it
135
170
 
136
171
  This hook lived in the `session` repo until that project was retired
@@ -143,6 +178,7 @@ it, like its other hooks:
143
178
  ```
144
179
  hooks/hooks.json PreToolUse, matcher Bash -> hooks/mail-guard-hook (timeout 5)
145
180
  PreToolUse, matcher mcp__plugin_sessionmail_(message|pager)__.*
181
+ |mcp__plugin_sessionmail_memory__memory_init
146
182
  -> the same shim (a subagent's MCP door)
147
183
  hooks/mail-guard-hook the shim: python3 hooks/mail-guard/hook.py, and exit 0
148
184
  whatever goes wrong
@@ -151,13 +187,28 @@ hooks/mail-guard-hook the shim: python3 hooks/mail-guard/hook.py, and exit 0
151
187
  So a seat that loaded sessionmail is behind the guard, and one that did not
152
188
  is not.
153
189
 
154
- **The MCP group** routes the message and pager servers' tools (not
155
- memory's) to the same guard. It acts only on a payload that carries
156
- `agent_id` (a subagent's): there it denies what the Bash door denies a
157
- subagent (`message_read` without `peek`, `message_mark`, `pager_status` with
158
- `new_only`, `pager_stop|off|mute|unmute|listen`), judged by the same table
159
- (`subagent_verb`). A main thread's MCP call is allowed at once, exit 0 and
160
- no output. Cost: one python3 run per such call, about 23 ms on one Mac. Hook configuration is read at **session start**: loading the plugin
190
+ **The MCP group** routes the message and pager servers' tools, and of
191
+ memory's only `memory_init`, to the same guard. It acts only on a payload
192
+ that carries `agent_id` (a subagent's): there it denies what the Bash door
193
+ denies a subagent (`message_read` without `peek`, `message_mark`,
194
+ `pager_status` with `new_only`, `pager_stop|off|mute|unmute|listen`,
195
+ `pager_focus`, `message_subscription_add|remove` and
196
+ `memory_init`), judged by the same table (`subagent_verb`). A main thread's
197
+ MCP call is allowed at once, exit 0 and no output. Cost: one python3 run per
198
+ such call, about 23 ms on one Mac. The rest of memory's tools are not
199
+ routed, so they cost nothing.
200
+
201
+ **Inside a subagent, through either door**, the table also denies
202
+ `memory init` (the whole verb), `memory run` without `--list`, `message
203
+ subscription add|remove`, `pager rule add|remove`, `pager focus` and
204
+ `pager settings hooks on|off` or `pager settings hooks KEY on|off`: each
205
+ writes the seat's memory, runs its script-mail, or changes its state, as the
206
+ seat (the last two, the HOST's switches, for every seat on it). Their reads
207
+ stay allowed (`memory ls|tree|cat|wake`, `memory run --list`, `message
208
+ subscription list`, `pager rule list`, `pager status --stopped`, a bare
209
+ `pager settings hooks`, and `pager settings hooks KEY` alone). A word that would allow (`--peek`, `--list`, `--help`) counts
210
+ only before a `--`, where click still reads options: `message read 1 --
211
+ --peek` reads, and is denied. Hook configuration is read at **session start**: loading the plugin
161
212
  does not guard agents that are already running.
162
213
 
163
214
  **It never blocks by failing.** The only way it speaks is decision JSON on
@@ -182,7 +233,12 @@ group, and the hook process contract. `tests/test_mail_guard_wiring.py` —
182
233
  the registration in `hooks/hooks.json`, and the shim allowing when python3
183
234
  is missing, `hook.py` is missing, or it crashes. Both run under `make test`.
184
235
  `tests/test_mail_guard_subagent.py` and `tests/test_mail_guard_subagent_mcp.py`
185
- — the subagent rule through the Bash door and the MCP door.
236
+ — the subagent rule through the Bash door and the MCP door;
237
+ `tests/test_mail_guard_subagent_sm837.py` — the seat-state rows and the `--` cut,
238
+ both doors, with the main thread pinned byte for byte to the master it was
239
+ built on. `tests/test_hook_switches_mail_guard.py` — the off switch, both
240
+ doors, and the guard's reader in step with sessionmail's over every file
241
+ shape and value kind.
186
242
 
187
243
  Written in python3, stdlib only, on purpose: a quoting-aware guard written in
188
244
  the language whose quoting is the hazard is how you get a guard with its own
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sessionmail"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "sessionmail - mail for coding agents: message, pager and memory over one maildir."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11,<3.15"
@@ -20,7 +20,7 @@ dependencies = [
20
20
  "markdown>=3.6",
21
21
  "mcp>=2.2.0",
22
22
  "pymdown-extensions>=10.0",
23
- "sessiontools>=0.3.0",
23
+ "sessiontools>=0.3.3",
24
24
  ]
25
25
 
26
26
  # One package, three commands (openspec/specs/package/spec.md):
@@ -31,7 +31,7 @@ dependencies = [
31
31
  #
32
32
  # `pager listen` regenerates the LIVE postfix maps on a seat birth
33
33
  # (pager.py:486-489), and it resolves which generator to run via
34
- # shutil.which("vmailbox-gen") BEFORE ~/.mail/.postfix/vmailbox-gen
34
+ # shutil.which("vmailbox-gen") BEFORE <mail root>/.postfix/vmailbox-gen
35
35
  # (_seat.py:994), with no --no-postmap. So an entry point here does not merely
36
36
  # put a command on the PATH: at the next seat birth it COMPILES AND SERVES the
37
37
  # packaged generator's map to postfix, for every agent address on this host.
@@ -24,8 +24,12 @@ seat --help` answers when typed.
24
24
 
25
25
  ## The wrapper
26
26
 
27
- - **The key** is `$SESSIONMAIL_HOME/seat-keys/ADDRESS/id_ed25519`, which is
28
- `message seat init`'s layout. The pub the adapter names is `seat.pub` in the same dir.
27
+ - **The key** is `KEYS/ADDRESS/id_ed25519`, which is `message seat init`'s layout.
28
+ The pub the adapter names is `seat.pub` in the same dir. KEYS is
29
+ `$SESSIONMAIL_HOME/seat-keys` when that is set; else `~/.sessionmail/seat-keys`
30
+ while that is a real folder (an existing host's keys never move); else
31
+ `~/.sessionkit/mail/state/seat-keys`, where a fresh host mints. See "Where the
32
+ keys are" in [`docs/message-seat.md`](../../docs/message-seat.md).
29
33
  - **It refuses before starting anything (exit 2)** when:
30
34
  - the sessionmail install is broken: the packaged wrapper is missing or not
31
35
  executable (said by `message` itself);
@@ -43,7 +47,9 @@ seat --help` answers when typed.
43
47
  - **A TERM** (launchd stopping the job) stops the agent and removes the socket, exit 0.
44
48
  - **The socket cap is 103 bytes.** macOS's `sun_path` is 104 bytes including the NUL.
45
49
  Measured: ssh-agent binds a 103-byte path and refuses a 104-byte one. The default,
46
- `~/.sessionmail/seat-keys/<address>/agent.sock`, is well under the cap.
50
+ `KEYS/<address>/agent.sock`, is under the cap for a usual home and address
51
+ (`/Users/<name>/.sessionkit/mail/state/seat-keys/` is 45 bytes for a 4-letter
52
+ name); a longer one is refused before anything starts.
47
53
  - **An orphan agent.** If the wrapper is SIGKILLed outside launchd, its agent can outlive it,
48
54
  and the next start then refuses ("a live agent already answers"). Kill the orphan by
49
55
  hand. Under launchd this does not arise: a job's process group goes with the job.
@@ -57,15 +63,27 @@ answers. `--uninstall ADDRESS` boots it out and removes the plist. Their contrac
57
63
  [`docs/message-seat.md`](../../docs/message-seat.md).
58
64
 
59
65
  `message seat agent --template` prints it, and nothing else: it never renders it, loads
60
- it or runs launchctl. To render it by hand, replace each `@PLACEHOLDER@`, then lint it:
66
+ it or runs launchctl. To render it by hand, set KEYS to the seat-keys folder (above),
67
+ replace each `@PLACEHOLDER@`, then lint it. With no `SESSIONMAIL_HOME` of your own,
68
+ drop its entry, as `--install` does; the wrapper then finds KEYS under the job's HOME:
61
69
 
70
+ KEYS=$HOME/.sessionkit/mail/state/seat-keys # or $HOME/.sessionmail/seat-keys where the keys are
62
71
  message seat agent --template |
63
72
  sed -e 's|@ADDRESS@|bot.alice|g' -e "s|@MESSAGE@|$(command -v message)|g" \
64
- -e "s|@SESSIONMAIL_HOME@|$HOME/.sessionmail|g" \
65
- -e "s|@SOCKET@|$HOME/.sessionmail/seat-keys/bot.alice/agent.sock|g" \
66
- -e "s|@LOG_DIR@|$HOME/Library/Logs|g" > /some/scratch/dir/dev.sessionkit.mail.seat-agent.bot.alice.plist
73
+ -e '/<key>SESSIONMAIL_HOME<\/key>/{N;d;}' \
74
+ -e "s|@SEAT_PUB@|$KEYS/bot.alice/seat.pub|g" \
75
+ -e "s|@SOCKET@|$KEYS/bot.alice/agent.sock|g" \
76
+ -e "s|@LOG_DIR@|$HOME/.sessionkit/mail/log|g" > /some/scratch/dir/dev.sessionkit.mail.seat-agent.bot.alice.plist
67
77
  plutil -lint /some/scratch/dir/dev.sessionkit.mail.seat-agent.bot.alice.plist
68
78
 
79
+ With a `SESSIONMAIL_HOME` of your own, keep the entry and fill it instead
80
+ (`-e "s|@SESSIONMAIL_HOME@|$SESSIONMAIL_HOME|g"`, absolute), with KEYS its `seat-keys`.
81
+
82
+ `@LOG_DIR@` must be a folder that exists before launchd loads the job; `--install` makes
83
+ `~/.sessionkit/mail/log` mode 0700 when it is missing. An agent installed before 0.3.2
84
+ logs to `~/Library/Logs` and keeps doing so until it is uninstalled and installed again
85
+ (see "The log" in [`docs/message-seat.md`](../../docs/message-seat.md)).
86
+
69
87
  `@MESSAGE@` is the path `command -v message` prints, not resolved: `~/.local/bin/message`
70
88
  under uv tool or pipx, `<venv>/bin/message` in a venv. It stays the same across upgrades,
71
89
  and its launcher is absolute, so launchd's `PATH=/usr/bin:/bin` is enough.
@@ -89,7 +107,7 @@ them as a plist dict.
89
107
  |---|---|
90
108
  | `TM_AGENT_ADDRESS` | the seat, `bot.alice` |
91
109
  | `DESK_SIGNER_SOCK` | this job's `SOCKET` |
92
- | `DESK_SIGNER_PUB` | `$SESSIONMAIL_HOME/seat-keys/<address>/seat.pub`, apart from the private key |
110
+ | `DESK_SIGNER_PUB` | `KEYS/<address>/seat.pub`, apart from the private key |
93
111
  | `SESSIONMAIL_REQUIRE_SIGNATURE` | `1`: a send that cannot sign is refused |
94
112
  | `SESSION_REGISTRY` | the registry that `message seat init` registered into, for `message verify --trust` |
95
113
 
@@ -197,11 +197,13 @@ def routed_names(text: str) -> set[str]:
197
197
  return names
198
198
 
199
199
 
200
- def mailbox_keys(text: str, rel: str) -> list[str]:
200
+ def mailbox_keys(text: str, rel: str, boxes: str) -> list[str]:
201
201
  """The keys of REL's rows in a mailbox map's text: the rows whose
202
- SECOND field is exactly `agents/<rel>/` (BN1), so both host forms are
203
- found by one test and never by a substring."""
204
- want = f"agents/{rel.lower()}/"
202
+ SECOND field is exactly `<boxes>/<rel>/` (BN1), so both host forms are
203
+ found by one test and never by a substring. BOXES is the boxes folder's
204
+ name on this tree (`Ctx.root.name`: `stores`, or `agents` on an old
205
+ tree), which the generator writes into every value."""
206
+ want = f"{boxes}/{rel.lower()}/"
205
207
  keys = []
206
208
  for line in text.splitlines():
207
209
  parts = line.split()
@@ -214,7 +216,7 @@ def mailbox_keys(text: str, rel: str) -> list[str]:
214
216
  class Ctx:
215
217
  """One run: where things are, and what it may ask."""
216
218
 
217
- root: Path # the agents dir (`_maildir.root()`)
219
+ root: Path # the boxes dir (`_maildir.root()`); its name is the map values' folder
218
220
  archive: Path
219
221
  days: float = DEFAULT_DAYS
220
222
  maps: Path | None = None # --maps DIR; None: ask postconf
@@ -411,14 +413,17 @@ def release_lock(archive: Path) -> None:
411
413
 
412
414
  def notmuch_configs(env: dict[str, str]) -> list[Path]:
413
415
  """Every notmuch config file this host could use: `$NOTMUCH_CONFIG`
414
- (else `~/.notmuch-config`), each `~/.notmuch-config.<profile>`, and
415
- each `$XDG_CONFIG_HOME/notmuch/<profile>/config`."""
416
+ (else `~/.notmuch-config`), each `~/.notmuch-config.<profile>`, each
417
+ `$XDG_CONFIG_HOME/notmuch/<profile>/config`, and each kit profile,
418
+ `~/.sessionkit/mail/settings/notmuch/<profile>/config` (`_index`)."""
416
419
  home = Path(env.get("HOME") or Path.home())
417
420
  found = [Path(env["NOTMUCH_CONFIG"]).expanduser()] if env.get("NOTMUCH_CONFIG") \
418
421
  else [home / ".notmuch-config"]
419
422
  found += sorted(home.glob(".notmuch-config.*"))
420
423
  xdg = Path(env.get("XDG_CONFIG_HOME") or home / ".config")
421
424
  found += sorted(xdg.glob("notmuch/*/config"))
425
+ from . import _index # here, not at import: _index reaches _notmuch
426
+ found += sorted(_index.kit_profiles(env).glob("*/config"))
422
427
  out: list[Path] = []
423
428
  for p in found:
424
429
  if p.is_file() and p not in out:
@@ -853,7 +858,7 @@ def verify_gone(ctx: Ctx, text: str | None, m: Moved) -> tuple[str, str]:
853
858
  resolves against, so TEXT is the print (`Ctx.mailbox_text`)."""
854
859
  if text is None:
855
860
  return "UNKNOWN", "map unreadable"
856
- if mailbox_keys(text, m.rel):
861
+ if mailbox_keys(text, m.rel, ctx.root.name):
857
862
  return "PRESENT", "regenerate did not take"
858
863
  if not m.keys:
859
864
  # BN6: a `moving` line with no keys (or a box with no row) cannot ask
@@ -873,7 +878,7 @@ def routed_now(ctx: Ctx, text: str | None, rel: str) -> bool:
873
878
  """REL has its row back, in the text and (when postconf answers) the .db."""
874
879
  if text is None:
875
880
  return False
876
- keys = mailbox_keys(text, rel)
881
+ keys = mailbox_keys(text, rel, ctx.root.name)
877
882
  if not keys:
878
883
  return False
879
884
  if ctx.use_db():
@@ -893,7 +898,7 @@ def alias_db_hit(ctx: Ctx, rel: str) -> str | None:
893
898
  maps = _seat._postfix_maps("virtual_alias_maps")
894
899
  if not maps:
895
900
  raise Unknown("postconf could not say which alias .db postfix reads")
896
- for key in mailbox_keys(ctx.mailbox_text() or "", rel):
901
+ for key in mailbox_keys(ctx.mailbox_text() or "", rel, ctx.root.name):
897
902
  if _seat._postmap_lookup(key, maps):
898
903
  return key
899
904
  return None
@@ -1020,7 +1025,7 @@ def _apply_locked(ctx: Ctx, c: Census, pf: Preflight, candidates: list[Row],
1020
1025
  warn(f"refused {rel}: archive/{rel} already exists; message box unarchive {rel} --merge --apply")
1021
1026
  continue
1022
1027
  text = ctx.mailbox_text()
1023
- keys = mailbox_keys(text or "", rel)
1028
+ keys = mailbox_keys(text or "", rel, ctx.root.name)
1024
1029
  if not keys:
1025
1030
  warn(f"not moved {rel}: no mailbox row for it before the move (BN2)")
1026
1031
  continue