kijito-tools 0.2.0 → 0.2.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kijito-tools",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Installer for kijito-tools: copies the context-tracking, session catch-up, and self-clear scripts plus the Kijito skills into ~/.claude.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",
@@ -35,7 +35,7 @@
35
35
  "kijito"
36
36
  ],
37
37
  "scripts": {
38
- "prepublishOnly": "npm pack --dry-run",
38
+ "prepublishOnly": "bash tests/vendored_monitor_test.sh && npm pack --dry-run",
39
39
  "test:n0-subject": "node providers/codex/test/n0-subject-boundary.mjs && node --test providers/codex/test/n0-subject-boundary.test.mjs",
40
40
  "test:n0": "node --test providers/codex/test/n0-harness.test.mjs && echo N0_HARNESS_BASELINE_GREEN",
41
41
  "test:n0-cli": "node --test providers/codex/test/n0-cli-refusal.test.mjs && echo N0_CLI_REFUSAL_GREEN",
@@ -54,7 +54,11 @@ kijito_stream_for_persona() {
54
54
  done
55
55
  [ -n "$km" ] && safe=$("$km" --safe-persona "$want" 2>/dev/null)
56
56
  if [ -n "$safe" ]; then
57
- for cand in "$HOME/.kijito-monitor/$safe.jsonl" "$HOME/.cache/kijito-inbox-monitor/events.$safe.ndjson"; do
57
+ # THREE layouts: the fleet's systemd units, the launchd plist, and the monitor's own shipped systemd
58
+ # template (~/.local/state, XDG state) - the last one was missing until row M313's follow-up, so a
59
+ # Linux user who installed the unit from the monitor repo had a stream nothing here would find.
60
+ for cand in "$HOME/.kijito-monitor/$safe.jsonl" "$HOME/.cache/kijito-inbox-monitor/events.$safe.ndjson" \
61
+ "$HOME/.local/state/kijito-inbox-monitor/events.$safe.ndjson"; do
58
62
  [ -e "$cand" ] && { printf '%s' "$cand"; return 0; }
59
63
  done
60
64
  fi
@@ -63,7 +67,8 @@ kijito_stream_for_persona() {
63
67
  import glob, json, os, sys
64
68
  want = os.environ["KJ_WANT"].casefold(); home = os.path.expanduser("~"); hits = []
65
69
  for pat in (os.path.join(home, ".kijito-monitor", "*.jsonl"),
66
- os.path.join(home, ".cache", "kijito-inbox-monitor", "events.*.ndjson")):
70
+ os.path.join(home, ".cache", "kijito-inbox-monitor", "events.*.ndjson"),
71
+ os.path.join(home, ".local", "state", "kijito-inbox-monitor", "events.*.ndjson")):
67
72
  for path in glob.glob(pat):
68
73
  try:
69
74
  with open(path, "rb") as fh:
@@ -100,7 +100,8 @@ want = os.environ["KJ_PERSONA"].casefold()
100
100
  home = os.path.expanduser("~")
101
101
  hits = []
102
102
  for pat in (os.path.join(home, ".kijito-monitor", "*.jsonl"),
103
- os.path.join(home, ".cache", "kijito-inbox-monitor", "events.*.ndjson")):
103
+ os.path.join(home, ".cache", "kijito-inbox-monitor", "events.*.ndjson"),
104
+ os.path.join(home, ".local", "state", "kijito-inbox-monitor", "events.*.ndjson")):
104
105
  for path in glob.glob(pat):
105
106
  try:
106
107
  with open(path, "rb") as fh:
@@ -174,6 +175,10 @@ fi
174
175
  # supervisor definition is the next-best evidence. uname is the last resort, not the first test.
175
176
  _mac_events="$HOME/.cache/kijito-inbox-monitor/events.${_safe}.ndjson"
176
177
  _lnx_events="$HOME/.kijito-monitor/${_safe}.jsonl"
178
+ # The monitor repo's OWN shipped systemd template writes here (XDG state). A third layout, and until row
179
+ # M313's follow-up this script did not know it: a user who installed the unit from the monitor README got
180
+ # "nothing is collecting your mail" while their producer was writing mail into this file.
181
+ _tpl_events="$HOME/.local/state/kijito-inbox-monitor/events.${_safe}.ndjson"
177
182
  if [ "$_rule" = by-content ] || [ "$_rule" = stale-stream ]; then
178
183
  # The producer's own output named this file. It outranks every derivation below, because it is the
179
184
  # only one of them that was written by the process we are asking about.
@@ -183,18 +188,21 @@ if [ "$_rule" = by-content ] || [ "$_rule" = stale-stream ]; then
183
188
  # an empty component that looks like a path and names nothing. The producer line says it is stale;
184
189
  # the arming block should still point at the file that will come back when it restarts.
185
190
  _events="$_found"
186
- case "$_events" in *.jsonl) _sup="systemd" ;; *) _sup="launchd" ;; esac
191
+ case "$_events" in *.jsonl|*/.local/state/*) _sup="systemd" ;; *) _sup="launchd" ;; esac
187
192
  elif [ -n "$_safe" ] && [ -e "$_lnx_events" ]; then _events="$_lnx_events"; _sup="systemd"
188
193
  elif [ -n "$_safe" ] && [ -e "$_mac_events" ]; then _events="$_mac_events"; _sup="launchd"
194
+ elif [ -n "$_safe" ] && [ -e "$_tpl_events" ]; then _events="$_tpl_events"; _sup="systemd"
189
195
  elif [ -d "$HOME/.kijito-monitor" ]; then _events="$_lnx_events"; _sup="systemd"
196
+ elif [ -d "$HOME/.local/state/kijito-inbox-monitor" ]; then _events="$_tpl_events"; _sup="systemd"
190
197
  elif [ -d "$HOME/.cache/kijito-inbox-monitor" ]; then _events="$_mac_events"; _sup="launchd"
191
198
  elif [ -f "$HOME/Library/LaunchAgents/com.kijito.inbox-monitor.plist" ]; then _events="$_mac_events"; _sup="launchd"
192
199
  elif [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then _events="$_mac_events"; _sup="launchd"
193
200
  else _events="$_lnx_events"; _sup="systemd"; fi
194
201
  # The generic (no-marker) branch cannot name a file, so it shows the directory shape instead.
195
- case "$_sup" in
196
- launchd) _events_tmpl="\$HOME/.cache/kijito-inbox-monitor/events.<persona>.ndjson" ;;
197
- *) _events_tmpl="\$HOME/.kijito-monitor/<persona>.jsonl" ;;
202
+ case "$_sup:$_events" in
203
+ launchd:*) _events_tmpl="\$HOME/.cache/kijito-inbox-monitor/events.<persona>.ndjson" ;;
204
+ *:*/.local/state/*) _events_tmpl="\$HOME/.local/state/kijito-inbox-monitor/events.<persona>.ndjson" ;;
205
+ *) _events_tmpl="\$HOME/.kijito-monitor/<persona>.jsonl" ;;
198
206
  esac
199
207
 
200
208
  # Producer health, PER PERSONA — because "a producer is running" and "YOUR mail is being collected"
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "artifacts": {
16
16
  "wakeCoreSha256": "5f7b57f71fd7ae96b303195c3671af0d410668b95f6a397c97cbec275992e412",
17
- "workflowSha256": "e5f82d7aa18d3b652466d53ef89642d4dedb3ae05b0e13cea0e3d1882cb29496",
17
+ "workflowSha256": "ec6a65c095e7011c3100c2a804e23c1dee1a63cfba0fb79f1b872aee91dd3b2d",
18
18
  "planSha256": "d9d666b86ac06414f6e6a13a3e3e2115983a1dad44fcfded7eccc51b191fb451",
19
19
  "wakeHelperSha256": "838f6b4462ba037a23a14b6fdb8dc34ee8b8f4a7df258b428832bc092fac3ea4",
20
20
  "wsUdsSha256": "4da13eef89b572fb735e756cb06846a021919dec7f91c6d07989377944cc776c",
@@ -3,6 +3,63 @@
3
3
  All notable changes to kijito-inbox-monitor are documented in this file.
4
4
  The format is based on Keep a Changelog, and this project follows Semantic Versioning.
5
5
 
6
+ ## [0.5.4] - 2026-09-24
7
+
8
+ ### Fixed
9
+ - **The systemd unit no longer decides file names.** `kijito-inbox-monitor@.service.template` spelled
10
+ systemd's `%i` into `--state-file`, `--events-file` and `--token-file`. `%i` is the ESCAPED instance
11
+ name, so for any persona that is not already lowercase ASCII (`Loom`, `name (purpose)`, `Ωmega`) the unit
12
+ named its files differently from the producer and from the launchd plist, and passed the escaped
13
+ string as the persona itself. The unit now passes `--persona %I` (unescaped) and the producer fills in
14
+ every path from `{persona}` with the same rule `--safe-persona` prints. For names that are already
15
+ lowercase ASCII the paths are byte-identical to before, so upgrading moves nothing.
16
+
17
+ ### Added
18
+ - `--state-file-template` and `--token-file-template`, the `{persona}` counterparts of `--state-file` and
19
+ `--token-file`.
20
+ - `scripts/migrate-systemd-unit.sh` migrates a deployed unit: dry run by default, `--apply` to act,
21
+ idempotent, keeps the old unit file, refuses to rewrite until the installed producer supports the new
22
+ flags, and for an instance whose files do move keeps the old event path working (hard link) so a
23
+ running `tail -F` consumer is not cut off.
24
+
25
+ ## [0.5.3] - 2026-09-24
26
+
27
+ ### Changed
28
+ - **Both shipped service templates run opaque (`--no-content`).** A producer deployed from
29
+ `com.kijito.inbox-monitor.plist.template` or `kijito-inbox-monitor@.service.template` now writes
30
+ event rows that carry a message's identity, sender and timestamps but never its body. An event file
31
+ is a plain local file that other tools and agents read; message text does not belong in it by
32
+ default. To keep a bounded content preview, remove `--no-content` from your unit (or pass
33
+ `--content-chars N`). `OpaqueOutputEnforcementTest` pins both templates and the flag's behaviour.
34
+ (Previously enforced only in the copy vendored by kijito-tools; upstreamed so the two are identical.)
35
+
36
+ ## [0.5.2] - 2026-09-24
37
+
38
+ ### Added
39
+ - **`--safe-persona PERSONA`** prints the filename component the producer derives from a persona name
40
+ (casefold, any Unicode alphanumeric kept) and exits. It is the one published persona->filename rule,
41
+ so the SessionStart hook, the inbox self-test and health tools ask the producer instead of
42
+ re-implementing it (three copies had drifted).
43
+
44
+ ### Fixed
45
+ - **The persona `reserved` flag is honoured.** `/api/personas` marks the legacy row named after the
46
+ hive-wide broadcast name (`all`) as `reserved`: nobody works as it. The watcher now treats that row
47
+ exactly like a declared-`retired` one for alarm purposes: its urgent mail no longer fires the loud
48
+ "urgent-unanswered" alert (it is named once on the quiet stderr channel instead, and stays readable
49
+ and counted), and a never-read reserved inbox is classified as clearable debris. A server that does
50
+ not send the field changes nothing.
51
+
52
+ ## [0.5.1] - 2026-09-24
53
+
54
+ ### Fixed
55
+ - **An empty first window no longer swallows message id 0.** On a first launch with nothing in the inbox
56
+ the watcher baselined its cursor to `0` (`max(ids, default=0)`), and every emission test is
57
+ `id > cursor`, so the very first hive message on a new account (the server numbers it `0`) could
58
+ never be emitted. The producer only logged a quiet `dormant inbox (1 unread)` notice and the agent
59
+ never woke. An empty first window now baselines to `-1`, the value the fail-closed corrupt-state
60
+ branch already produced for an empty window; a non-empty first window still baselines to its newest
61
+ id. Two regression tests cover both cases (`EmptyFirstWindowBaselineTest`).
62
+
6
63
  ## [0.5.0] - 2026-08-15
7
64
 
8
65
  ### Fixed
@@ -66,3 +66,33 @@ Old-source transition (kijito-inbox-monitor stays the publish/pin home until fle
66
66
  P0-C7 terminal), `_shared` doorbell + authenticated consumer lease (§4.4), and F29/A29 certify+enforce
67
67
  of opaque `--no-content` output in both service templates plus the live Mac-producer gap (§4.6) are
68
68
  tracked separately and not claimed here.
69
+
70
+ ## Re-import 2026-09-24 (row M314) — the vendored copy is now verified, not asserted
71
+
72
+ By 2026-09-24 this copy had drifted both ways from upstream: upstream had fixes it lacked (M289, the
73
+ empty-first-window baseline), and it carried two changes upstream lacked (`--safe-persona`, row M290,
74
+ and the opaque-output rule in `OPAQUE-OUTPUT-ENFORCEMENT.md`). Both local changes were first sent
75
+ upstream verbatim (kijito-inbox-monitor #4 → v0.5.2, #5 → v0.5.3), so a byte-exact re-import reverts
76
+ nothing.
77
+
78
+ | role | SHA | tree |
79
+ |---|---|---|
80
+ | imported (tag `v0.5.3`) | `803c53da623fa2d9bd97f5a9e75f01a78571ea2e` | `0c8d80201933589363305f9e2753e7d717f348d8` |
81
+
82
+ From here on **never hand-edit `providers/monitor`**:
83
+ - `providers/monitor/UPSTREAM` records the upstream sha, its git tree id and the full file listing.
84
+ - `tests/vendored_monitor_test.sh` recomputes the git tree id of this directory from the files on disk
85
+ (excluding `UPSTREAM`, this file and `OPAQUE-OUTPUT-ENFORCEMENT.md`, which belong to kijito-tools) and
86
+ fails on any difference. It runs in the test workflow, in both publish workflows before the package is
87
+ built, and in npm `prepublishOnly`, so a drifted copy cannot be published.
88
+ - To move to a new upstream release: `scripts/import-monitor.sh <sha-or-tag>`.
89
+
90
+ ## Re-import 2026-09-24 (row M313) — v0.5.4
91
+
92
+ | role | SHA | tree |
93
+ |---|---|---|
94
+ | imported (tag `v0.5.4`) | see `providers/monitor/UPSTREAM` (`scripts/import-monitor.sh v0.5.4`) | idem |
95
+
96
+ v0.5.4: the shipped systemd unit passes `--persona %I` and the producer names every file
97
+ (`--state-file-template`, `--token-file-template`, `--events-file-template`), plus
98
+ `scripts/migrate-systemd-unit.sh` for deployed units.
@@ -1,5 +1,9 @@
1
1
  # Opaque output enforcement (P0-F29/A29)
2
2
 
3
+ > **Upstreamed 2026-09-24 (row M314):** both template edits and `OpaqueOutputEnforcementTest` now live in
4
+ > `KijitoAI/kijito-inbox-monitor` itself (v0.5.3), and this directory is a byte-exact copy of that release.
5
+ > The record below is kept as the history of why the rule exists.
6
+
3
7
  **Steward:** argus · **Date:** 2026-08-17 · **Row:** `P0-F29`/`A29`, anchor `monitor-foundation-v1`
4
8
  (clause: *opaque output*). This is the **certify + enforce** half of the row — the `--no-content`
5
9
  capability already exists in shipped code, so nothing is built here; it is made non-optional in the
@@ -545,6 +545,9 @@ four sat above it. Only the newest page's count answers the question "is there u
545
545
  | `--events-file PATH` | Write NDJSON events to an owned, size-rotated file (survives rotation) instead of stdout. Consumers `tail -F` it. |
546
546
  | `--events-file-template PATH` | Per-persona owned, rotated files, e.g. `events.{persona}.ndjson`; each session consumes its own. Must contain `{persona}`. Mutually exclusive with `--events-file`. |
547
547
  | `--state-file PATH` | Persist and resume cursor/liveness; single-writer locked. Persona targets derive one file per persona. Recommended under a supervisor. |
548
+ | `--state-file-template PATH` | Like `--state-file`, with `{persona}` filled in by the producer (the component `--safe-persona` prints). Use it in a supervisor unit so the unit never spells a persona into a path. |
549
+ | `--token-file-template PATH` | Like `--token-file`, with `{persona}` filled in by the producer for the one `--persona` target. |
550
+ | `--safe-persona NAME` | Print the filename component the producer uses for NAME, then exit. Anything that needs to name a persona's files should ask this rather than re-implement the rule. |
548
551
  | `--wait N` | Long-poll hold (s) requested from the server so new mail wakes the watcher near-instantly at ~the same request rate (default 50; `0` disables). Falls back to interval polling against a server that doesn't support it, and auto-upgrades when it does. |
549
552
  | `--poll-seconds N` | Interval between polls when long-poll is off/unsupported (default 60). |
550
553
  | `--alert-after N` | Consecutive failures before an `alert` (default 3, min 1). A single transient failure is normal. |
@@ -0,0 +1,26 @@
1
+ # The upstream release providers/monitor is a byte-exact copy of (row M314).
2
+ # Written by scripts/import-monitor.sh; verified by tests/vendored_monitor_test.sh. Never hand-edit.
3
+ repo=KijitoAI/kijito-inbox-monitor
4
+ sha=1bdc0ced989bf9c10d4a62fa4140b1cf97b7fa2e
5
+ tree=732bef3a3b72279fb4d1111314a6eb7ecb266097
6
+ tag=v0.5.4
7
+ file=100644 8d88ccb0fd09b9144505f985b4f882db278fac1f .github/workflows/publish-npm.yml
8
+ file=100644 19ce3471a2cffa689be9d261049c1e74e3e7a25e .github/workflows/publish-pypi.yml
9
+ file=100644 4534290a5dd36d18d07a782b784b6b0ad5ee38fc .gitignore
10
+ file=100644 6191d5c9b75c3d5198168a6422c854cd56e0426e CHANGELOG.md
11
+ file=100644 d645695673349e3947e8e5ae42332d0ac3164cd7 LICENSE
12
+ file=100644 f6b0e5634f3fd4156c1c000efb31578bfb068cf3 NOTICE
13
+ file=100644 86f816d7ac9ef4a5a7242376d642f59be8554e2b README.md
14
+ file=100644 72f0c148f1e721ac1bb4a38e5cae690355774314 RELEASING.md
15
+ file=100755 fca1203267cb726736b0c8f239e2d74c40a7a895 arm-hive-monitor.sh
16
+ file=100755 bbe3f07de13dab8a8a7a4798481156ae4a46ab8f bin/cli.js
17
+ file=100644 23e088613c314bf86b384640639b545de60ea9d0 com.kijito.inbox-monitor.plist.template
18
+ file=100644 0ec3f23c29bb26d2890b9083d1014840a48cd2c3 docs/DESIGN.md
19
+ file=100644 4f5e53a8633e60e6c57c451e28546f8af8783fe8 kijito-inbox-monitor@.service.template
20
+ file=100644 8ad5159d6659a05c33756abc901f7cf4dcd5c9c7 kijito_inbox_monitor.py
21
+ file=100644 4f1d402533247b1453d3b670c9dc746d3941a576 package.json
22
+ file=100644 bc6f7c99e94c83fc89c5ceb1b1c2bfebf203e015 pyproject.toml
23
+ file=100755 b34a5c775385ecfaefd4b374c1fc1aa082a85d14 scripts/migrate-systemd-unit.sh
24
+ file=100755 22acef6d6875395a81c701eb535030d0a78b0df1 scripts/mutation-check.py
25
+ file=100755 acba6196213736922fc4f7135749386d85d172a6 scripts/prepublish-gate.sh
26
+ file=100644 f7272d96cd1b3b0235aa79debbb6568a67063d6e test_kijito_monitor.py
@@ -10,12 +10,15 @@
10
10
  # ~/.config/systemd/user/kijito-inbox-monitor@.service
11
11
  # systemctl --user daemon-reload
12
12
  # run: systemctl --user enable --now kijito-inbox-monitor@YOURPERSONA
13
+ # (a persona with spaces, parentheses or non-ASCII: escape it for systemd -
14
+ # systemctl --user enable --now "kijito-inbox-monitor@$(systemd-escape 'Name (dev)').service")
13
15
  # check: systemctl --user status kijito-inbox-monitor@YOURPERSONA
14
16
  # logs: journalctl --user -u kijito-inbox-monitor@YOURPERSONA -f
15
17
  #
16
- # BEFORE IT WILL START, create the token file for that persona (0600):
17
- # install -m600 /dev/null ~/.config/kijito-inbox-monitor/token.YOURPERSONA
18
- # printf '%s' "$YOUR_KIJITO_API_TOKEN" > ~/.config/kijito-inbox-monitor/token.YOURPERSONA
18
+ # BEFORE IT WILL START, create the token file for that persona (0600). Its name uses the
19
+ # producer's filename component for the persona, which you get from the producer itself:
20
+ # f=~/.config/kijito-inbox-monitor/token.$(kijito-inbox-monitor --safe-persona YOURPERSONA)
21
+ # install -m600 /dev/null "$f"; printf '%s' "$YOUR_KIJITO_API_TOKEN" > "$f"
19
22
  # A missing token is FATAL and says so - the producer will not start half-configured.
20
23
  #
21
24
  # ⚠️ THIS CAPTURES; IT DOES NOT WAKE. The unit appends one JSON event per new message
@@ -41,7 +44,7 @@
41
44
  # different claims, and so are an ENABLED unit and a unit that survives your logout.
42
45
 
43
46
  [Unit]
44
- Description=Kijito inbox monitor (persona: %i)
47
+ Description=Kijito inbox monitor (persona: %I)
45
48
  Documentation=https://github.com/KijitoAI/kijito-inbox-monitor
46
49
  # Start after the network is genuinely up, not merely configured: the producer's first
47
50
  # act is an HTTPS poll, and a DNS failure at boot is an avoidable alert.
@@ -60,13 +63,19 @@ Type=simple
60
63
  # that skip is at least ANNOUNCED as a `baseline_skipped` diagnostic instead of
61
64
  # happening in silence - but announced-and-skipped is still skipped.)
62
65
  # ⇒ state lives under ~/.local/state (XDG_STATE_HOME: persists between restarts).
66
+ # ⛔ THE UNIT NEVER SPELLS A PERSONA INTO A PATH (row M313). It passes the persona (%I, the
67
+ # UNESCAPED instance name - %i is systemd's escaped form, so 'Name (dev)' would arrive as
68
+ # 'Name\x20\x28dev\x29' and watch a persona that does not exist), and every file name is
69
+ # filled in by the producer from `{persona}` using the SAME rule `--safe-persona` prints and
70
+ # the launchd plist uses. For a name that is already a safe component (lowercase ASCII),
71
+ # the paths are byte-identical to the older %i form, so upgrading moves nothing.
63
72
  ExecStart=%h/.local/bin/kijito-inbox-monitor \
64
- --persona %i \
65
- --token-file %h/.config/kijito-inbox-monitor/token.%i \
73
+ --persona %I \
74
+ --token-file-template %h/.config/kijito-inbox-monitor/token.{persona} \
66
75
  --poll-seconds 30 \
67
76
  --wait 60 \
68
- --state-file %h/.local/state/kijito-inbox-monitor/%i.state \
69
- --events-file %h/.local/state/kijito-inbox-monitor/events.%i.ndjson \
77
+ --state-file-template %h/.local/state/kijito-inbox-monitor/{persona}.state \
78
+ --events-file-template %h/.local/state/kijito-inbox-monitor/events.{persona}.ndjson \
70
79
  --no-content \
71
80
  --heartbeat 900
72
81
 
@@ -33,7 +33,7 @@ try:
33
33
  except ImportError: # pragma: no cover - Windows
34
34
  fcntl = None
35
35
 
36
- __version__ = "0.5.0"
36
+ __version__ = "0.5.4"
37
37
  SOURCE = "kijito-inbox"
38
38
  # A named User-Agent is REQUIRED: api.kijito.ai is fronted by a WAF that 403s the default Python-urllib UA.
39
39
  USER_AGENT = "kijito-inbox-monitor/%s" % __version__
@@ -332,6 +332,14 @@ _PERSONA_RETIRED = {}
332
332
  # so the producer can ship before the API populates the field with zero behaviour change. A boolean.
333
333
  _PERSONA_WRITE_ONLY = {}
334
334
 
335
+ # Declared `reserved` flag per persona from /api/personas (server row M315; consumed here for row M332).
336
+ # TRUE = a legacy row named after the hive-wide BROADCAST name ('all'): it can no longer be created, but
337
+ # an account that minted one before the guard existed still holds it, and it is an ordinary inbox that
338
+ # NOBODY works as. The server calls it debris and names the remedy (retire it). For alarm purposes it is
339
+ # therefore treated exactly like a declared-`retired` row: see _is_debris. Its mail is still READ and
340
+ # still counted - only the alarms change. Same tri-state discipline: absent/non-bool => undeclared.
341
+ _PERSONA_RESERVED = {}
342
+
335
343
 
336
344
  def _row_memory_count(row):
337
345
  """Memories owned by this persona, or None if the server did not say.
@@ -387,6 +395,25 @@ def _row_write_only(row):
387
395
  return w if isinstance(w, bool) else None
388
396
 
389
397
 
398
+ def _row_reserved(row):
399
+ """The persona's declared `reserved` flag as a strict bool, or None if the server did not report it.
400
+
401
+ Same tri-state discipline as _row_retired: only a genuine bool is a declaration. A server that
402
+ predates the field reports nothing, and the producer then behaves exactly as it did before (row M332
403
+ ships ahead of, or behind, the server with zero behaviour change).
404
+ """
405
+ r = row.get("reserved")
406
+ return r if isinstance(r, bool) else None
407
+
408
+
409
+ def _is_debris(persona):
410
+ """True when the directory DECLARES that nobody works as this persona: `retired`, or `reserved`
411
+ (the legacy broadcast-name row, row M332). A positive declaration only (`is True`); an undeclared
412
+ flag is never debris. Debris is classified as clearable in the stranded tier and never fires the
413
+ urgent-unanswered alarm - there is no member there to be unresponsive."""
414
+ return _PERSONA_RETIRED.get(persona) is True or _PERSONA_RESERVED.get(persona) is True
415
+
416
+
390
417
  def fetch_personas(opener, headers):
391
418
  """Fetch the account persona directory for default/explicit all-persona mode."""
392
419
  req = urllib.request.Request(PERSONAS_URL, headers=headers, method="GET")
@@ -410,6 +437,7 @@ def fetch_personas(opener, headers):
410
437
  _PERSONA_READ_COUNTS[row["persona"]] = _row_read_count(row)
411
438
  _PERSONA_RETIRED[row["persona"]] = _row_retired(row)
412
439
  _PERSONA_WRITE_ONLY[row["persona"]] = _row_write_only(row)
440
+ _PERSONA_RESERVED[row["persona"]] = _row_reserved(row)
413
441
  if not personas:
414
442
  raise FatalConfig("/api/personas returned no personas")
415
443
  return personas
@@ -1813,6 +1841,17 @@ def make_opener_for(url):
1813
1841
  return build_opener(pinned)
1814
1842
 
1815
1843
 
1844
+ def _persona_path(template, persona):
1845
+ """Fill a `{persona}` path template with the producer's OWN filename component for PERSONA (row M313).
1846
+
1847
+ Every per-persona path a supervisor needs - events, state, token - goes through here, so the unit file
1848
+ (systemd `%i`, a plist, a shell script) never spells a filename itself. `%i` is systemd's ESCAPED
1849
+ instance name and passes a persona's raw case through, so a unit that interpolated it disagreed with
1850
+ the producer for any name that is not already a safe component ('Loom', 'name (purpose)', 'Ωmega').
1851
+ """
1852
+ return template.replace("{persona}", _state_safe_persona(persona))
1853
+
1854
+
1816
1855
  def _state_path_for_persona(base_path, persona):
1817
1856
  if not base_path or not persona:
1818
1857
  return base_path
@@ -1963,7 +2002,8 @@ class WatchTarget:
1963
2002
  cp = urllib.parse.urlsplit(url)
1964
2003
  self.unread_persona = dict(urllib.parse.parse_qsl(cp.query)).get("persona") or persona
1965
2004
 
1966
- state_path = _state_path_for_persona(args.state_file, persona)
2005
+ state_path = (_persona_path(args.state_file_template, persona) if args.state_file_template
2006
+ else _state_path_for_persona(args.state_file, persona))
1967
2007
  if state_path:
1968
2008
  self.state_file = StateFile(state_path, self.identity)
1969
2009
  if not args.self_test:
@@ -2341,7 +2381,14 @@ class WatchTarget:
2341
2381
  "reason": "state file present but unusable; re-emitting the "
2342
2382
  "visible window instead of baselining over it"})
2343
2383
  elif self.cursor is None:
2344
- self.cursor = max((m["id"] for m in items), default=0)
2384
+ # An EMPTY first window baselines BELOW id 0, not AT it. Message ids start at 0
2385
+ # on a new account, and every emission test is `id > cursor`, so a cursor of 0
2386
+ # on an empty inbox swallowed the account's very first message forever - the
2387
+ # producer only ever logged it as a quiet "dormant inbox (1 unread)". -1 is the
2388
+ # value the fail-closed branch above already produces for an empty window, so it
2389
+ # is a value every later comparison already tolerates. A NON-empty first window
2390
+ # still baselines to its newest id (never flood a new agent with history).
2391
+ self.cursor = max((m["id"] for m in items), default=-1)
2345
2392
  # ⛔ AN ABSENT STATE FILE MEANS TWO THINGS THAT DEMAND OPPOSITE BEHAVIOUR, AND
2346
2393
  # NOTHING HERE CAN TELL THEM APART. A genuine first launch must baseline - never
2347
2394
  # flood a new agent with inbox history. A LOST state file must not: everything
@@ -3043,6 +3090,12 @@ _REPORTED_URGENT_QUIET = set()
3043
3090
  # Same once-per-member, EXACT-keyed discipline as _REPORTED_STRANDED / _REPORTED_DORMANT.
3044
3091
  _REPORTED_URGENT_WO = set()
3045
3092
 
3093
+ # DEBRIS rows (declared retired, or the reserved broadcast-name row - row M332) holding urgent unread.
3094
+ # Surfaced QUIETLY (a stderr NOTICE) once per member, never the loud alarm: "nobody is answering
3095
+ # escalated mail" presumes a member who could answer, and debris by declaration has none. Its own set so
3096
+ # no tier can gag another; re-armed by intersection_update like the others.
3097
+ _REPORTED_URGENT_DEBRIS = set()
3098
+
3046
3099
 
3047
3100
  def urgent_unanswered(directory):
3048
3101
  """Directory personas holding SENDER-ESCALATED mail while showing no observed activity (§5.5).
@@ -3106,10 +3159,25 @@ def report_urgent_unanswered(directory, targets, emitter):
3106
3159
  # Partition by the DECLARED write_only fact, exactly as _partition_stranded does. `is True` is strict:
3107
3160
  # an undeclared or False flag leaves the member in the LOUD tier unchanged (graceful degradation, the
3108
3161
  # same tri-state _row_write_only guarantees).
3162
+ # DEBRIS FIRST (row M332): a declared-retired row or the reserved broadcast-name row has no member to
3163
+ # be unresponsive, so it leaves the loud tier whatever its other flags say; named quietly below.
3164
+ debris_quiet = [(p, n) for p, n in current if _is_debris(p)]
3165
+ current = [(p, n) for p, n in current if not _is_debris(p)]
3109
3166
  wo_quiet = [(p, n) for p, n in current if _PERSONA_WRITE_ONLY.get(p) is True]
3110
3167
  alerting = [(p, n) for p, n in current if _PERSONA_WRITE_ONLY.get(p) is not True]
3111
3168
  _REPORTED_URGENT_QUIET.intersection_update({p for p, _ in alerting}) # release: leaving re-arms alarm
3112
3169
  _REPORTED_URGENT_WO.intersection_update({p for p, _ in wo_quiet})
3170
+ _REPORTED_URGENT_DEBRIS.intersection_update({p for p, _ in debris_quiet})
3171
+ for persona, n in debris_quiet:
3172
+ if persona in _REPORTED_URGENT_DEBRIS:
3173
+ continue
3174
+ _REPORTED_URGENT_DEBRIS.add(persona)
3175
+ sys.stderr.write(
3176
+ "kijito-inbox-monitor: NOTICE urgent-unanswered on debris (quiet, not alarmed) - %s (%d urgent "
3177
+ "unread; declared %s, so nobody works as it - its mail is still readable; retire or clear it) "
3178
+ "(further notices for %r suppressed)\n"
3179
+ % (persona, n, "reserved (the broadcast name)" if _PERSONA_RESERVED.get(persona) is True
3180
+ else "retired", persona))
3113
3181
  fresh = [(p, n) for p, n in alerting if p not in _REPORTED_URGENT_QUIET]
3114
3182
  fresh_wo = [(p, n) for p, n in wo_quiet if p not in _REPORTED_URGENT_WO]
3115
3183
  # QUIET-BUT-NAMED tier: a stderr NOTICE is a non-waking channel (the event-stream grep filters
@@ -3242,8 +3310,8 @@ def _partition_stranded(directory, counts):
3242
3310
  if read > 0:
3243
3311
  continue # someone is consuming it - not stranded at all
3244
3312
  # read == 0: this inbox has never been consumed. Partition by the DECLARED retired flag.
3245
- if _PERSONA_RETIRED.get(p) is True:
3246
- loud.append(p) # declared clearable debris -> loud, exactly like today
3313
+ if _is_debris(p):
3314
+ loud.append(p) # declared clearable debris (retired or reserved) -> loud
3247
3315
  else:
3248
3316
  dormant.append(p) # real-but-dormant -> quiet, must NOT ride the loud alarm
3249
3317
  return loud, dormant
@@ -3272,9 +3340,11 @@ def _stranded_detail(persona, directory, counts):
3272
3340
  if twin is not None:
3273
3341
  return "%s (%s unread; case-variant of known persona %r)" % (persona, counts.get(persona), twin)
3274
3342
  in_dir = persona in set(directory)
3275
- if in_dir and _PERSONA_READ_COUNTS.get(persona) == 0 and _PERSONA_RETIRED.get(persona) is True:
3276
- return ("%s (%s unread; never consumed (read 0) and declared retired, so it is clearable debris)"
3277
- % (persona, counts.get(persona)))
3343
+ if in_dir and _PERSONA_READ_COUNTS.get(persona) == 0 and _is_debris(persona):
3344
+ return ("%s (%s unread; never consumed (read 0) and declared %s, so it is clearable debris)"
3345
+ % (persona, counts.get(persona),
3346
+ "reserved (the broadcast name, not an identity)" if _PERSONA_RESERVED.get(persona) is True
3347
+ else "retired"))
3278
3348
  if in_dir and _PERSONA_MEMORY_COUNTS.get(persona) == 0:
3279
3349
  return "%s (%s unread; registered as a recipient but owns no memories, so nobody works as it)" % (
3280
3350
  persona, counts.get(persona))
@@ -3548,6 +3618,11 @@ def build_parser():
3548
3618
  p.add_argument("--state-file",
3549
3619
  help="Persist+resume cursor/FSM; single-writer locked. Kijito persona targets derive one "
3550
3620
  "file per persona from this base path. Recommended w/ a supervisor.")
3621
+ p.add_argument("--state-file-template",
3622
+ help="Per-persona state file named by the PRODUCER, e.g. ~/.local/state/kijito-inbox-monitor/"
3623
+ "{persona}.state; '{persona}' is replaced by the same filename component --safe-persona "
3624
+ "prints. Use it in a supervisor unit instead of spelling the persona into --state-file "
3625
+ "(row M313). Mutually exclusive with --state-file.")
3551
3626
  p.add_argument("--heartbeat", type=int, help="Emit a heartbeat event every N seconds (external dead-man's-switch).")
3552
3627
  p.add_argument("--activity-file",
3553
3628
  help="Publish who AUTHORED mail most recently, as JSON, refreshed each tick. Lets a "
@@ -3574,6 +3649,10 @@ def build_parser():
3574
3649
  "(reported verbatim, so a reader can judge magnitude). Default 1.")
3575
3650
  p.add_argument("--auth-header", help="Header NAME for the token (default Authorization: Bearer).")
3576
3651
  p.add_argument("--token-file", help="File holding the auth token (wins over $KIJITOMON_TOKEN).")
3652
+ p.add_argument("--token-file-template",
3653
+ help="Like --token-file, with '{persona}' replaced by the producer's filename component for the "
3654
+ "ONE --persona target (row M313), e.g. ~/.config/kijito-inbox-monitor/token.{persona}. "
3655
+ "Mutually exclusive with --token-file.")
3577
3656
  p.add_argument("--no-fast-path", action="store_true",
3578
3657
  help="Disable the /api/notify/pending unread pre-check; always full-poll the inbox list.")
3579
3658
  p.add_argument("--resync-every", type=int, default=10,
@@ -3615,6 +3694,19 @@ def validate_args(args):
3615
3694
  raise FatalConfig("--events-file-template must contain the '{persona}' placeholder")
3616
3695
  if (args.events_file or args.events_file_template) and args.emit != "stdout-jsonl":
3617
3696
  sys.stderr.write("kijito-inbox-monitor: WARNING --events-file/-template ignored (emit mode is %s)\n" % args.emit)
3697
+ # Row M313: the persona-named paths come from the producer's rule, never from the supervisor.
3698
+ if args.state_file and args.state_file_template:
3699
+ raise FatalConfig("--state-file and --state-file-template are mutually exclusive")
3700
+ if args.state_file_template and "{persona}" not in args.state_file_template:
3701
+ raise FatalConfig("--state-file-template must contain the '{persona}' placeholder")
3702
+ if args.token_file and args.token_file_template:
3703
+ raise FatalConfig("--token-file and --token-file-template are mutually exclusive")
3704
+ if args.token_file_template:
3705
+ if "{persona}" not in args.token_file_template:
3706
+ raise FatalConfig("--token-file-template must contain the '{persona}' placeholder")
3707
+ if len(args.persona or []) != 1 or args.personas or args.all_personas:
3708
+ raise FatalConfig("--token-file-template needs exactly one --persona target (one token file per unit)")
3709
+ args.token_file = _persona_path(args.token_file_template, args.persona[0])
3618
3710
  if args.seed_at is not None:
3619
3711
  single = len(args.persona or []) == 1 and not args.personas and not args.all_personas
3620
3712
  if not single:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kijito-inbox-monitor",
3
- "version": "0.5.0",
3
+ "version": "0.5.4",
4
4
  "description": "Pointer to the Python package kijito-inbox-monitor. A zero-dependency watcher that wakes a running agent when new Kijito inbox mail arrives. Install the real tool with pipx/uv/pip.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "kijito-inbox-monitor"
10
- version = "0.5.0"
10
+ version = "0.5.4"
11
11
  description = "Zero-dependency watcher that wakes a running agent when new Kijito inbox mail arrives."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.9"
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # Migrate a DEPLOYED kijito-inbox-monitor systemd user unit to producer-derived file names (row M313).
3
+ #
4
+ # scripts/migrate-systemd-unit.sh # DRY RUN: says what it would change, changes nothing
5
+ # scripts/migrate-systemd-unit.sh --apply # do it
6
+ # options: --unit NAME (default kijito-inbox-monitor) --bin PATH (default ~/.local/bin/kijito-inbox-monitor)
7
+ # --unit-dir DIR (default ~/.config/systemd/user)
8
+ #
9
+ # WHAT CHANGES. Older units spelled the persona into paths with systemd's `%i` - the ESCAPED instance name -
10
+ # so the unit, not the producer, decided file names, and a persona like 'Loom' or 'name (purpose)' got a
11
+ # different file than the producer (and the launchd plist) would give it. The rewritten unit passes
12
+ # `--persona %I` and turns every `--state-file/--events-file/--token-file ...%i...` into the matching
13
+ # `-template ...{persona}...` flag, so the producer names every file with the rule `--safe-persona` prints.
14
+ #
15
+ # WHAT MOVES. For a persona that is already a safe component (lowercase ASCII - every persona on a normal
16
+ # fleet) the new paths are BYTE-IDENTICAL to the old ones: nothing moves and no consumer has to re-arm.
17
+ # Only an instance whose old and new paths differ is touched, and then (instance stopped first):
18
+ # state copied to the new name (the old file is kept) - the cursor survives
19
+ # token copied to the new name, mode kept (the old file is kept)
20
+ # events HARD-LINKED under the new name - the old name is never removed, so a consumer running
21
+ # `tail -F <old path>` notices nothing and keeps receiving every event the producer now appends
22
+ # under the new name (same file). It stays that way until the producer next ROTATES the stream
23
+ # (default 5 MB), after which only the new name moves on: re-arm consumers on the new path.
24
+ # (Measured, GNU tail 9.4: a SYMLINK at the old path is refused as "untailable", and a rename
25
+ # followed by a relink makes tail re-read the whole file - a replay of every old event.)
26
+ #
27
+ # SAFE TO RE-RUN. A unit with no `%i` left in ExecStart is reported as already migrated; files are only
28
+ # copied/moved when the new one does not exist yet. The old unit file is kept as <unit>.pre-m313.<time>.
29
+ # REFUSES to rewrite the unit while the installed producer lacks --state-file-template (it would not start).
30
+ # ANNOUNCE TO YOUR FLEET BEFORE --apply on a shared seat: instances restart, for a few seconds each.
31
+ set -eu
32
+ UNIT=kijito-inbox-monitor
33
+ BIN="$HOME/.local/bin/kijito-inbox-monitor"
34
+ UNIT_DIR="$HOME/.config/systemd/user"
35
+ APPLY=0
36
+ while [ $# -gt 0 ]; do
37
+ case "$1" in
38
+ --apply) APPLY=1; shift ;;
39
+ --unit) UNIT=$2; shift 2 ;;
40
+ --bin) BIN=$2; shift 2 ;;
41
+ --unit-dir) UNIT_DIR=$2; shift 2 ;;
42
+ -h|--help) sed -n '2,30p' "$0"; exit 0 ;;
43
+ *) echo "unknown option: $1" >&2; exit 2 ;;
44
+ esac
45
+ done
46
+ FILE="$UNIT_DIR/$UNIT@.service"
47
+ [ -f "$FILE" ] || { echo "no unit file at $FILE - nothing to migrate"; exit 0; }
48
+ say() { printf '%s\n' "$*"; }
49
+ [ "$APPLY" = 1 ] && say "== APPLY: $FILE ==" || say "== DRY RUN (pass --apply to act): $FILE =="
50
+
51
+ # The rewrite, as a pure text transform of the unit file (also used to compute the new paths).
52
+ rewrite() {
53
+ sed -E \
54
+ -e '/^ExecStart=/,/[^\\]$/ s/--persona %i/--persona %I/' \
55
+ -e '/^ExecStart=/,/[^\\]$/ s/--(state-file|events-file|token-file)([ =])([^ ]*)%i([^ ]*)/--\1-template\2\3{persona}\4/g' \
56
+ -e 's/^(Description=.*)%i/\1%I/' "$1"
57
+ }
58
+ exec_line() { tr -d '\r' < "$1" | sed -e ':a' -e '/\\$/N; s/\\\n//; ta' | grep '^ExecStart='; }
59
+ flag_value() { printf '%s\n' "$1" | tr ' ' '\n' | awk -v f="$2" 'p{print; exit} $0==f{p=1}'; }
60
+
61
+ old_exec=$(exec_line "$FILE")
62
+ case "$old_exec" in
63
+ *%i*) ;;
64
+ *) say "already migrated: ExecStart spells no %i (the producer names every file)"; exit 0 ;;
65
+ esac
66
+ tmp_unit=$(mktemp); trap 'rm -f "$tmp_unit"' EXIT
67
+ rewrite "$FILE" > "$tmp_unit"
68
+ new_exec=$(exec_line "$tmp_unit")
69
+ case "$new_exec" in *%i*) say "REFUSING: could not rewrite every %i in ExecStart:"; say " $new_exec"; exit 1 ;; esac
70
+ say "ExecStart now: ${old_exec#ExecStart=}"
71
+ say "ExecStart after: ${new_exec#ExecStart=}"
72
+
73
+ have_flags=0
74
+ "$BIN" --help 2>/dev/null | grep -q -- '--state-file-template' && have_flags=1
75
+ if [ "$have_flags" = 0 ]; then
76
+ say "REFUSING to rewrite: $BIN does not support --state-file-template (needs kijito-inbox-monitor >= 0.5.4)."
77
+ say "Upgrade the producer first, then re-run. (For persona names that are already lowercase ASCII the"
78
+ say "old unit is correct as it is: this migration changes no file for them.)"
79
+ [ "$APPLY" = 1 ] && exit 1
80
+ fi
81
+
82
+ H=$HOME
83
+ changed=(); to_start=()
84
+ # Every instance: loaded ones, plus enabled ones that are not loaded right now (a stopped template
85
+ # instance drops out of list-units, and its files must migrate too).
86
+ instances=$( { systemctl --user list-units --all --plain --no-legend "$UNIT@*.service" 2>/dev/null | awk '{print $1}'
87
+ for w in "$UNIT_DIR"/*.wants/"$UNIT"@*.service; do [ -e "$w" ] || [ -L "$w" ] && basename "$w"; done
88
+ } | sort -u)
89
+ while IFS= read -r inst; do
90
+ [ -n "$inst" ] || continue
91
+ esc=${inst#"$UNIT@"}; esc=${esc%.service}
92
+ raw=$(systemd-escape --unescape -- "$esc")
93
+ if [ "$have_flags" = 1 ]; then
94
+ safe=$("$BIN" --safe-persona "$raw")
95
+ else
96
+ # Without an upgraded producer there is no --safe-persona to ask, and this script must not guess the
97
+ # rule (row M290). A name that is already lowercase [a-z0-9._-] is its own component; say so for it,
98
+ # and say plainly that anything else cannot be judged yet.
99
+ case "$raw" in
100
+ *[!a-z0-9._-]*) say " $raw: cannot tell yet which files would move - needs the upgraded producer's --safe-persona"; continue ;;
101
+ esac
102
+ safe=$raw
103
+ fi
104
+ moves=""
105
+ for f in --state-file --events-file --token-file; do
106
+ o=$(flag_value "${old_exec#ExecStart=}" "$f"); [ -n "$o" ] || continue
107
+ case "$o" in *%i*) ;; *) continue ;; esac
108
+ # Parameter expansion, NOT sed: an escaped instance name carries backslashes ('name\x20\x28dev\x29')
109
+ # and GNU sed would decode them in a replacement - the old path would then name a file that never
110
+ # existed. (Caught by this script's own test against real systemd.)
111
+ op=${o//%h/$H}; op=${op//%i/$esc}
112
+ np=${o//%h/$H}; np=${np//%i/$safe}
113
+ [ "$op" = "$np" ] || moves="$moves$f|$op|$np"$'\n'
114
+ done
115
+ if [ -z "$moves" ]; then
116
+ say " $raw: paths unchanged (already a safe component) - nothing moves, consumers need not re-arm"
117
+ continue
118
+ fi
119
+ changed+=("$inst")
120
+ while IFS='|' read -r f op np; do
121
+ [ -n "$f" ] && say " $raw: ${f#--} $op -> $np"
122
+ done <<EOF
123
+ $moves
124
+ EOF
125
+ [ "$APPLY" = 1 ] && [ "$have_flags" = 1 ] || continue
126
+ was_active=0; systemctl --user is-active --quiet "$inst" && was_active=1
127
+ systemctl --user stop "$inst"
128
+ while IFS='|' read -r f op np; do
129
+ [ -n "$f" ] && [ -e "$op" ] || continue
130
+ [ -e "$np" ] && { say " ${f#--}: $np already exists - left as is"; continue; }
131
+ mkdir -p "$(dirname "$np")"
132
+ case "$f" in
133
+ --events-file) ln "$op" "$np" && say " events: $np hard-linked to the live stream; $op still works until the next rotation" ;;
134
+ *) cp -p "$op" "$np" && say " ${f#--} copied (old file kept)" ;;
135
+ esac
136
+ done <<EOF
137
+ $moves
138
+ EOF
139
+ [ "$was_active" = 1 ] && to_start+=("$inst")
140
+ done <<EOF
141
+ $instances
142
+ EOF
143
+
144
+ [ "$APPLY" = 1 ] || { say "(dry run: nothing changed)"; exit 0; }
145
+ cp -p "$FILE" "$FILE.pre-m313.$(date +%Y%m%dT%H%M%S)"
146
+ cp "$tmp_unit" "$FILE"
147
+ systemctl --user daemon-reload
148
+ # The instances this script stopped are started again BY NAME (they are no longer listed once stopped);
149
+ # every other running instance is restarted so it picks up the rewritten ExecStart (same paths).
150
+ for inst in "${to_start[@]+"${to_start[@]}"}"; do systemctl --user start "$inst"; done
151
+ while IFS= read -r inst; do
152
+ [ -n "$inst" ] || continue
153
+ case " ${to_start[*]+"${to_start[*]}"} " in *" $inst "*) continue ;; esac
154
+ systemctl --user is-active --quiet "$inst" && systemctl --user restart "$inst"
155
+ done <<EOF
156
+ $instances
157
+ EOF
158
+ say "migrated: unit rewritten (old copy kept next to it); ${#changed[@]} instance(s) had files moved"
@@ -1,6 +1,7 @@
1
1
  import io
2
2
  import json
3
3
  import os
4
+ import re
4
5
  import subprocess
5
6
  import sys
6
7
  import tempfile
@@ -757,7 +758,10 @@ class UrgentUnansweredAlarmTest(unittest.TestCase):
757
758
  km._INBOX_FLOORS.clear()
758
759
  km._REPORTED_URGENT_QUIET.clear()
759
760
  km._REPORTED_URGENT_WO.clear()
761
+ km._REPORTED_URGENT_DEBRIS.clear()
760
762
  km._PERSONA_WRITE_ONLY.clear()
763
+ km._PERSONA_RETIRED.clear()
764
+ km._PERSONA_RESERVED.clear()
761
765
  km._OBSERVED_SINCE = "2026-07-25T07:00:00+00:00"
762
766
 
763
767
  def tearDown(self):
@@ -920,6 +924,55 @@ class UrgentUnansweredAlarmTest(unittest.TestCase):
920
924
  self.assertNotIn("jason", alerts[0]["urgent_unanswered"])
921
925
  self.assertIn("jason", err) # also on the quiet channel
922
926
 
927
+ # ── row M332: DEBRIS (the reserved broadcast-name row, or a retired row) never fires the loud alarm ──
928
+ # "Nobody is answering escalated mail" presumes a member who could answer. A reserved row (the legacy
929
+ # 'all' inbox the server marks `reserved`) or a retired one has none by declaration, so its urgent mail
930
+ # is NAMED on the quiet channel - still readable, still counted - and never wakes anyone.
931
+ def test_reserved_row_holding_urgent_mail_does_NOT_fire_the_loud_alarm(self):
932
+ km._URGENT_UNREAD.update({"all": 2})
933
+ km._PERSONA_RESERVED.update({"all": True})
934
+ self._observe([{"id": 100, "from": "river", "created": "t"}])
935
+ fresh, alerts, err = self._run_cap(directory=("argus", "all"))
936
+ self.assertEqual(fresh, [])
937
+ self.assertEqual(alerts, [])
938
+ self.assertIn("all", err) # quiet, not invisible
939
+ self.assertIn("reserved", err)
940
+ self.assertIn("still readable", err)
941
+ self.assertEqual(km._URGENT_UNREAD["all"], 2) # its mail is still counted
942
+
943
+ def test_reserved_is_treated_like_retired(self):
944
+ # The DONE-WHEN's own comparison, asserted directly: the two declarations behave the same.
945
+ for flag in (km._PERSONA_RESERVED, km._PERSONA_RETIRED):
946
+ with self.subTest(flag="reserved" if flag is km._PERSONA_RESERVED else "retired"):
947
+ self.setUp()
948
+ km._URGENT_UNREAD.update({"ghost": 1, "loom": 1})
949
+ flag.update({"ghost": True})
950
+ self._observe([{"id": 100, "from": "river", "created": "t"}])
951
+ fresh, alerts, err = self._run_cap(directory=("argus", "ghost", "loom"))
952
+ self.assertEqual(fresh, ["loom"]) # the real member still alarms
953
+ self.assertEqual(alerts[0]["urgent_unanswered"], ["loom"])
954
+ self.assertIn("ghost", err)
955
+
956
+ def test_undeclared_or_false_reserved_still_fires_loud(self):
957
+ # Only a POSITIVE declaration quiets: a flag explicitly False, or absent (an older server), leaves
958
+ # the member on the loud alarm exactly as before.
959
+ km._URGENT_UNREAD.update({"loom": 1, "quill": 3})
960
+ km._PERSONA_RESERVED.update({"loom": False}) # quill: no entry at all
961
+ self._observe([{"id": 100, "from": "river", "created": "t"}])
962
+ fresh, _alerts, _err = self._run_cap(directory=("argus", "loom", "quill"))
963
+ self.assertEqual(sorted(fresh), ["loom", "quill"])
964
+
965
+ def test_debris_quiet_notice_is_once_then_re_arms(self):
966
+ km._URGENT_UNREAD.update({"all": 1})
967
+ km._PERSONA_RESERVED.update({"all": True})
968
+ self._observe([{"id": 100, "from": "river", "created": "t"}])
969
+ self.assertIn("all", self._run_cap(directory=("argus", "all"))[2])
970
+ self.assertEqual(self._run_cap(directory=("argus", "all"))[2], "") # suppressed while it holds
971
+ km._URGENT_UNREAD.update({"all": 0})
972
+ self._run_cap(directory=("argus", "all")) # condition clears
973
+ km._URGENT_UNREAD.update({"all": 1})
974
+ self.assertIn("all", self._run_cap(directory=("argus", "all"))[2]) # recurrence announced again
975
+
923
976
  def test_undeclared_or_false_write_only_still_fires_loud(self):
924
977
  # GRACEFUL DEGRADATION + the mutation discriminator: only `is True` quiets. A flag explicitly
925
978
  # False, or absent entirely, still rides the loud alarm exactly as before - and the informational
@@ -1663,6 +1716,7 @@ class BoundedWindowEndToEndTest(unittest.TestCase):
1663
1716
  class FullArgs:
1664
1717
  persona = personas = None
1665
1718
  all_personas = False
1719
+ state_file_template = None
1666
1720
  alert_after = 3
1667
1721
  poll_seconds = 60
1668
1722
  heartbeat = 0
@@ -2289,9 +2343,11 @@ class M167ReadCountPartitionTest(unittest.TestCase):
2289
2343
  self._read = dict(km._PERSONA_READ_COUNTS)
2290
2344
  self._ret = dict(km._PERSONA_RETIRED)
2291
2345
  self._wo = dict(km._PERSONA_WRITE_ONLY)
2346
+ self._rsv = dict(km._PERSONA_RESERVED)
2292
2347
  self._rs = set(km._REPORTED_STRANDED)
2293
2348
  self._rd = set(km._REPORTED_DORMANT)
2294
- for d in (km._PERSONA_MEMORY_COUNTS, km._PERSONA_READ_COUNTS, km._PERSONA_RETIRED, km._PERSONA_WRITE_ONLY):
2349
+ for d in (km._PERSONA_MEMORY_COUNTS, km._PERSONA_READ_COUNTS, km._PERSONA_RETIRED, km._PERSONA_WRITE_ONLY,
2350
+ km._PERSONA_RESERVED):
2295
2351
  d.clear()
2296
2352
  km._REPORTED_STRANDED.clear()
2297
2353
  km._REPORTED_DORMANT.clear()
@@ -2300,7 +2356,8 @@ class M167ReadCountPartitionTest(unittest.TestCase):
2300
2356
  for d, saved in ((km._PERSONA_MEMORY_COUNTS, self._mem),
2301
2357
  (km._PERSONA_READ_COUNTS, self._read),
2302
2358
  (km._PERSONA_RETIRED, self._ret),
2303
- (km._PERSONA_WRITE_ONLY, self._wo)):
2359
+ (km._PERSONA_WRITE_ONLY, self._wo),
2360
+ (km._PERSONA_RESERVED, self._rsv)):
2304
2361
  d.clear()
2305
2362
  d.update(saved)
2306
2363
  km._REPORTED_STRANDED.clear()
@@ -2415,6 +2472,27 @@ class M167ReadCountPartitionTest(unittest.TestCase):
2415
2472
  self.assertNotIn("dormant_inboxes", events[0]) # no dormant this tick
2416
2473
  self.assertIn("clearable debris", err)
2417
2474
 
2475
+ # --- row M332: the reserved broadcast-name row is classified exactly like a retired one ------------
2476
+ def test_reserved_row_read0_is_classified_like_retired_debris(self):
2477
+ km._PERSONA_MEMORY_COUNTS.update({"all": 0, "argus": 40})
2478
+ km._PERSONA_READ_COUNTS.update({"all": 0, "argus": 3})
2479
+ km._PERSONA_RESERVED.update({"all": True}) # NOT retired - reserved alone
2480
+ directory = ["all", "argus"]
2481
+ self.assertEqual(km.stranded_inboxes(directory, {"all": 1}), ["all"])
2482
+ self.assertEqual(km.dormant_inboxes(directory, {"all": 1}), [])
2483
+ fresh, events, err = self._report(directory, {"all": 1}, watchers=("argus",))
2484
+ self.assertEqual(fresh, ["all"])
2485
+ self.assertIn("reserved (the broadcast name, not an identity)", err)
2486
+ self.assertIn("clearable debris", err)
2487
+
2488
+ def test_a_reserved_False_row_is_not_debris(self):
2489
+ km._PERSONA_MEMORY_COUNTS.update({"quietone": 3, "argus": 40})
2490
+ km._PERSONA_READ_COUNTS.update({"quietone": 0, "argus": 3})
2491
+ km._PERSONA_RESERVED.update({"quietone": False})
2492
+ directory = ["quietone", "argus"]
2493
+ self.assertEqual(km.stranded_inboxes(directory, {"quietone": 1}), [])
2494
+ self.assertEqual(km.dormant_inboxes(directory, {"quietone": 1}), ["quietone"])
2495
+
2418
2496
  # --- case 2: omniview reads nothing but is NOT retired -> DORMANT/quiet, never loud -----------------
2419
2497
  def test_omniview_read0_not_retired_is_QUIET_dormant_not_loud(self):
2420
2498
  km._PERSONA_MEMORY_COUNTS.update({"omniview": 149, "argus": 40})
@@ -4435,6 +4513,40 @@ class Loom7CorruptionPinReleaseTest(unittest.TestCase):
4435
4513
  self.assertEqual(t.cursor, 200)
4436
4514
 
4437
4515
 
4516
+ class EmptyFirstWindowBaselineTest(unittest.TestCase):
4517
+ """A brand-new account's first hive message carries id 0. An EMPTY first window used to baseline the
4518
+ cursor to 0 (`max(..., default=0)`), and every emission test is `id > cursor`, so that message could
4519
+ never be emitted: the producer logged a quiet "dormant inbox (1 unread)" and the agent never woke.
4520
+ Measured 2026-09-18 on a fresh account with one persona and one launch of the producer."""
4521
+
4522
+ E2E = BoundedWindowEndToEndTest
4523
+
4524
+ def _fresh(self, em):
4525
+ t = self.E2E()._target(cursor=None, emitter=em)
4526
+ t.armed = False # a first launch: nothing persisted, not yet armed
4527
+ return t
4528
+
4529
+ def test_an_EMPTY_first_window_baselines_BELOW_id_zero_so_message_0_is_emitted(self):
4530
+ em = self.E2E.RecordingEmitter()
4531
+ t = self._fresh(em)
4532
+ self.E2E()._run(t, self.E2E()._fetch([], 0))
4533
+ self.assertEqual(t.cursor, -1, "an empty inbox has delivered nothing, so the watermark sits below 0")
4534
+ self.assertEqual(em.new_ids, [])
4535
+ self.E2E()._run(t, self.E2E()._fetch([{"id": 0}], 0))
4536
+ self.assertEqual(em.new_ids, [0], "the account's first message must wake the agent")
4537
+ self.assertEqual(t.cursor, 0)
4538
+
4539
+ def test_a_NON_EMPTY_first_window_still_baselines_to_its_newest_id(self):
4540
+ # The control: a genuine first launch onto an inbox WITH history must not flood the agent.
4541
+ em = self.E2E.RecordingEmitter()
4542
+ t = self._fresh(em)
4543
+ self.E2E()._run(t, self.E2E()._fetch([{"id": 0}, {"id": 5}], 0))
4544
+ self.assertEqual(t.cursor, 5)
4545
+ self.assertEqual(em.new_ids, [])
4546
+ self.E2E()._run(t, self.E2E()._fetch([{"id": 0}, {"id": 5}, {"id": 6}], 0))
4547
+ self.assertEqual(em.new_ids, [6])
4548
+
4549
+
4438
4550
  class Loom7StateFileHygieneTest(unittest.TestCase):
4439
4551
  """Loom re-audit 7, item 7. The lock fd was never closed - two ResourceWarnings, and a real leak."""
4440
4552
 
@@ -5579,6 +5691,139 @@ class WakeClassPhase1Test(unittest.TestCase):
5579
5691
  self.assertEqual(bool(old_lenient.search(line)), should_match)
5580
5692
  # and the field itself is the bare kind, not decorated
5581
5693
  self.assertEqual(self._emit("armed")["event"], "armed")
5694
+ class SharedPersonaFilenameRuleTest(unittest.TestCase):
5695
+ """`--safe-persona` is the ONE place the persona->filename rule is published (row M290).
5696
+
5697
+ Three programs had each re-implemented it and drifted: this producer (correct), kijito-tools'
5698
+ SessionStart hook (`sed 's/[^A-Za-z0-9._-]/_/g'` - no casefold, ASCII-only) and producer-health.sh
5699
+ (no sanitising at all). A persona whose name merely contains a CAPITAL LETTER therefore got a path
5700
+ from the hook that the producer never writes, the hook reported "your mail is not being collected",
5701
+ and a Monitor armed on that path waited forever in silence.
5702
+
5703
+ ⚠️ THE REASON IT SURVIVED REVIEW, AND WHY THESE ASSERTIONS ARE SHAPED AS THEY ARE: on macOS the
5704
+ filesystem is case-INSENSITIVE, so the hook's `[ -e ... ]` probe SUCCEEDS on the producer's
5705
+ differently-cased file and everything looks fine. The defect is only observable on Linux. So the
5706
+ tests below pin the RULE ITSELF (casefold; Unicode alnum survives) rather than "the file was
5707
+ found", because the find-the-file question answers YES on the very platform most likely to be
5708
+ running the test.
5709
+ """
5710
+
5711
+ # spaces, parentheses, slashes, unicode and case - the set row M290's DONE-WHEN names, plus the
5712
+ # ordinary name as a control (a fixture of only exotic names cannot tell "correct" from "mangles
5713
+ # everything").
5714
+ NAMES = [
5715
+ "argus", "x", "dots.and_dash-ok",
5716
+ "spaced name", "name (purpose)", "a/b", "tab\tname",
5717
+ "Loom", "UPPER", "Claude-Chat",
5718
+ "café", "Ωmega",
5719
+ ]
5720
+
5721
+ def _run(self, argv):
5722
+ out, err = io.StringIO(), io.StringIO()
5723
+ real_out, real_err = sys.stdout, sys.stderr
5724
+ sys.stdout, sys.stderr = out, err
5725
+ try:
5726
+ rc = km.main(argv)
5727
+ finally:
5728
+ sys.stdout, sys.stderr = real_out, real_err
5729
+ return rc, out.getvalue(), err.getvalue()
5730
+
5731
+ def test_cli_answer_is_the_function_answer_for_every_name(self):
5732
+ # The CLI is not a second implementation to be kept in step by hand - it must BE the function.
5733
+ for name in self.NAMES:
5734
+ with self.subTest(name=name):
5735
+ rc, out, _ = self._run(["--safe-persona", name])
5736
+ self.assertEqual(rc, 0)
5737
+ self.assertEqual(out, km._state_safe_persona(name) + "\n")
5738
+
5739
+ def test_answer_is_one_bare_line_a_shell_can_capture(self):
5740
+ # `_safe=$(kijito-inbox-monitor --safe-persona "$p")` is the calling convention, so anything
5741
+ # else on stdout silently becomes part of a filename.
5742
+ rc, out, _ = self._run(["--safe-persona", "name (purpose)"])
5743
+ self.assertEqual(rc, 0)
5744
+ self.assertEqual(out.count("\n"), 1)
5745
+ self.assertEqual(out.strip(), "name__purpose_")
5746
+
5747
+ def test_needs_no_token_no_network_no_state_file(self):
5748
+ # The hook runs at session start with none of the producer's configuration in hand. If asking
5749
+ # for the rule required a token or a state file, every caller would go back to guessing - which
5750
+ # is the defect. Proven by clearing the token env rather than by reading the code.
5751
+ saved = os.environ.pop("KIJITOMON_TOKEN", None)
5752
+ try:
5753
+ rc, out, _ = self._run(["--safe-persona", "Loom"])
5754
+ finally:
5755
+ if saved is not None:
5756
+ os.environ["KIJITOMON_TOKEN"] = saved
5757
+ self.assertEqual(rc, 0)
5758
+ self.assertEqual(out.strip(), "loom")
5759
+
5760
+ def test_empty_persona_refuses_and_prints_no_component(self):
5761
+ # A blank answer would be interpolated into a path as an empty component, producing a plausible
5762
+ # file that nothing writes. Refuse by name instead (the tool's could-not-do-it code).
5763
+ rc, out, err = self._run(["--safe-persona", ""])
5764
+ self.assertEqual(rc, 2)
5765
+ self.assertEqual(out, "")
5766
+ self.assertIn("--safe-persona", err)
5767
+
5768
+ def test_rule_casefolds_and_keeps_unicode_alphanumerics(self):
5769
+ # ⛔ THE REGRESSION GUARD, pinned as the two PROPERTIES that actually diverged rather than as a
5770
+ # list of observed strings: replace the rule with a hand-written ASCII filter (the hook's old
5771
+ # `[^A-Za-z0-9._-]`) and both of these fail - the first because it would keep the capital, the
5772
+ # second because it would blank a perfectly good Unicode letter.
5773
+ self.assertEqual(km._state_safe_persona("Loom"), "loom")
5774
+ self.assertEqual(km._state_safe_persona("Ωmega"), "ωmega")
5775
+ # and the control: it still replaces what genuinely cannot be in a filename component.
5776
+ self.assertEqual(km._state_safe_persona("a/b"), "a_b")
5777
+
5778
+ def test_the_rule_is_idempotent(self):
5779
+ # Callers chain: the hook sanitises, a script re-sanitises the result. A rule that is not
5780
+ # idempotent turns that into a third distinct filename.
5781
+ for name in self.NAMES:
5782
+ with self.subTest(name=name):
5783
+ once = km._state_safe_persona(name)
5784
+ self.assertEqual(km._state_safe_persona(once), once)
5785
+
5786
+
5787
+ class ReservedFlagParseTest(unittest.TestCase):
5788
+ """Row M332: `reserved` is read from /api/personas with the same tri-state discipline as `retired`."""
5789
+
5790
+ def test_only_a_genuine_bool_is_a_declaration(self):
5791
+ self.assertIs(km._row_reserved({"reserved": True}), True)
5792
+ self.assertIs(km._row_reserved({"reserved": False}), False)
5793
+ for junk in ({}, {"reserved": None}, {"reserved": "true"}, {"reserved": 1}):
5794
+ with self.subTest(row=junk):
5795
+ self.assertIsNone(km._row_reserved(junk))
5796
+
5797
+ def test_fetch_personas_records_it(self):
5798
+ dicts = (km._PERSONA_RESERVED, km._PERSONA_RETIRED, km._PERSONA_WRITE_ONLY,
5799
+ km._PERSONA_MEMORY_COUNTS, km._PERSONA_READ_COUNTS)
5800
+ saved = [dict(d) for d in dicts]
5801
+ try:
5802
+ body = json.dumps({"result": [{"persona": "all", "reserved": True, "retired": False},
5803
+ {"persona": "argus", "reserved": False}]}).encode()
5804
+
5805
+ class Resp:
5806
+ status = 200
5807
+ def read(self):
5808
+ return body
5809
+ def __enter__(self):
5810
+ return self
5811
+ def __exit__(self, *a):
5812
+ return False
5813
+
5814
+ class Opener:
5815
+ def open(self, req, timeout=None):
5816
+ return Resp()
5817
+
5818
+ self.assertEqual(km.fetch_personas(Opener(), {}), ["all", "argus"])
5819
+ self.assertIs(km._PERSONA_RESERVED["all"], True)
5820
+ self.assertIs(km._PERSONA_RESERVED["argus"], False)
5821
+ self.assertTrue(km._is_debris("all"))
5822
+ self.assertFalse(km._is_debris("argus"))
5823
+ finally:
5824
+ for d, keep in zip(dicts, saved):
5825
+ d.clear()
5826
+ d.update(keep)
5582
5827
 
5583
5828
 
5584
5829
  class OpaqueOutputEnforcementTest(unittest.TestCase):
@@ -5607,3 +5852,146 @@ class OpaqueOutputEnforcementTest(unittest.TestCase):
5607
5852
  def test_systemd_template_enforces_no_content(self):
5608
5853
  with open(os.path.join(self._HERE, "kijito-inbox-monitor@.service.template"), encoding="utf-8") as fh:
5609
5854
  self.assertIn("--no-content", fh.read())
5855
+
5856
+
5857
+ class SupervisorPersonaPathParityTest(unittest.TestCase):
5858
+ """Row M313. The systemd unit used to interpolate `%i` - systemd's ESCAPED instance name - into
5859
+ --state-file / --events-file / --token-file, so the UNIT decided the file names and disagreed with the
5860
+ producer (and with the launchd plist) for any persona that is not already a safe component. Now the
5861
+ unit passes the persona (%I, unescaped) and the producer fills every `{persona}` path from its own
5862
+ rule. These tests expand BOTH shipped templates the way their supervisors do and require every
5863
+ persona-named file to carry exactly the component `--safe-persona` prints."""
5864
+
5865
+ NAMES = ["argus", "Loom", "name (purpose)", "\u03a9mega", "two words", "Claude-chat"]
5866
+ HOME = "/HOMEDIR"
5867
+ _HERE = os.path.dirname(os.path.abspath(__file__))
5868
+
5869
+ def _systemd_argv_home(self, persona, home):
5870
+ saved, self.HOME = self.HOME, home
5871
+ try:
5872
+ return self._systemd_argv(persona)
5873
+ finally:
5874
+ self.HOME = saved
5875
+
5876
+ def _systemd_argv(self, persona):
5877
+ with open(os.path.join(self._HERE, "kijito-inbox-monitor@.service.template"), encoding="utf-8") as fh:
5878
+ text = fh.read().replace("\\\n", " ")
5879
+ line = next(l for l in text.splitlines() if l.startswith("ExecStart="))
5880
+ self.assertNotIn("%i", line, "the unit must not spell the escaped instance name into ExecStart")
5881
+ # systemd's own expansion: %h = home, %I = the UNESCAPED instance name (measured on systemd 255:
5882
+ # an instance escaped by systemd-escape expands %i to 'Name\\x20...' and %I to 'Name ...').
5883
+ # It is ONE argv word even with spaces in it - systemd splits the line before substituting.
5884
+ words = line[len("ExecStart="):].split()
5885
+ return [w.replace("%h", self.HOME).replace("%I", persona) for w in words][1:]
5886
+
5887
+ def _launchd_events_template(self):
5888
+ with open(os.path.join(self._HERE, "com.kijito.inbox-monitor.plist.template"), encoding="utf-8") as fh:
5889
+ strings = re.findall(r"<string>([^<]*)</string>", fh.read())
5890
+ return strings[strings.index("--events-file-template") + 1].replace("__HOME__", self.HOME)
5891
+
5892
+ def test_every_persona_named_file_uses_the_producers_component_on_both_supervisors(self):
5893
+ for name in self.NAMES:
5894
+ with self.subTest(persona=name):
5895
+ safe = km._state_safe_persona(name)
5896
+ args = km.build_parser().parse_args(self._systemd_argv(name))
5897
+ km.validate_args(args)
5898
+ self.assertEqual(args.persona, [name], "the unit must hand the producer the REAL persona")
5899
+ events = km._persona_path(args.events_file_template, name)
5900
+ state = km._persona_path(args.state_file_template, name)
5901
+ self.assertEqual(os.path.basename(events), "events.%s.ndjson" % safe)
5902
+ self.assertEqual(os.path.basename(state), "%s.state" % safe)
5903
+ self.assertEqual(os.path.basename(args.token_file), "token.%s" % safe)
5904
+ launchd = km._persona_path(self._launchd_events_template(), name)
5905
+ self.assertEqual(os.path.basename(events), os.path.basename(launchd),
5906
+ "launchd and systemd must name a persona's event stream identically")
5907
+
5908
+ def test_upgrading_a_safe_named_unit_moves_nothing(self):
5909
+ # Every persona on a real fleet today is lowercase ASCII; for those the new unit resolves to the
5910
+ # SAME paths the %i form did, so an upgrade needs no migration and no consumer re-arm.
5911
+ args = km.build_parser().parse_args(self._systemd_argv("argus"))
5912
+ km.validate_args(args)
5913
+ base = self.HOME + "/.local/state/kijito-inbox-monitor/"
5914
+ self.assertEqual(km._persona_path(args.events_file_template, "argus"), base + "events.argus.ndjson")
5915
+ self.assertEqual(km._persona_path(args.state_file_template, "argus"), base + "argus.state")
5916
+ self.assertEqual(args.token_file, self.HOME + "/.config/kijito-inbox-monitor/token.argus")
5917
+
5918
+ def test_the_state_file_template_reaches_the_watch_target(self):
5919
+ d = tempfile.mkdtemp()
5920
+
5921
+ class A(BoundedWindowEndToEndTest.FullArgs):
5922
+ state_file = None
5923
+ state_file_template = os.path.join(d, "{persona}.state")
5924
+ seed_at = None
5925
+ t = km.WatchTarget("Loom", "http://x/api/inbox?persona=Loom", None, {}, A(),
5926
+ BoundedWindowEndToEndTest.RecordingEmitter())
5927
+ self.addCleanup(t.state_file.unlock)
5928
+ self.assertEqual(t.state_file.path, os.path.join(d, "loom.state"))
5929
+
5930
+ def test_template_flags_are_validated(self):
5931
+ def err(argv):
5932
+ try:
5933
+ km.validate_args(km.build_parser().parse_args(argv))
5934
+ except km.FatalConfig as e:
5935
+ return str(e)
5936
+ return None
5937
+ self.assertIn("mutually exclusive", err(["--persona", "a", "--state-file", "x", "--state-file-template", "{persona}"]))
5938
+ self.assertIn("placeholder", err(["--persona", "a", "--state-file-template", "x.state"]))
5939
+ self.assertIn("mutually exclusive", err(["--persona", "a", "--token-file", "x", "--token-file-template", "t.{persona}"]))
5940
+ self.assertIn("placeholder", err(["--persona", "a", "--token-file-template", "tok"]))
5941
+ self.assertIn("exactly one --persona", err(["--persona", "a", "--persona", "b", "--token-file-template", "t.{persona}"]))
5942
+ self.assertIsNone(err(["--persona", "A b", "--token-file-template", "/t.{persona}"]))
5943
+
5944
+
5945
+ class SystemdUnitMigrationRewriteTest(unittest.TestCase):
5946
+ """Row M313: scripts/migrate-systemd-unit.sh rewrites a DEPLOYED pre-M313 unit into the producer-named
5947
+ form. Its end-to-end behaviour (files moved, stopped instances restarted, an old-path `tail -F` kept
5948
+ alive) was proven against real systemd when it was written; this pins the REWRITE, which is the part
5949
+ that must stay right on every machine, by running the script's dry run on the pre-M313 unit text."""
5950
+
5951
+ OLD = ("[Unit]\nDescription=Kijito inbox monitor (persona: %i)\n[Service]\n"
5952
+ "ExecStart=%h/.local/bin/kijito-inbox-monitor \\\n --persona %i \\\n"
5953
+ " --token-file %h/.config/kijito-inbox-monitor/token.%i \\\n --poll-seconds 30 \\\n"
5954
+ " --state-file %h/.local/state/kijito-inbox-monitor/%i.state \\\n"
5955
+ " --events-file %h/.local/state/kijito-inbox-monitor/events.%i.ndjson \\\n --heartbeat 900\n")
5956
+
5957
+ def _run(self, unit_text):
5958
+ d = tempfile.mkdtemp()
5959
+ with open(os.path.join(d, "kjt-m313-rewrite-test@.service"), "w") as fh:
5960
+ fh.write(unit_text)
5961
+ here = os.path.dirname(os.path.abspath(__file__))
5962
+ return subprocess.run(["bash", os.path.join(here, "scripts", "migrate-systemd-unit.sh"),
5963
+ "--unit", "kjt-m313-rewrite-test", "--unit-dir", d, "--bin", "/nonexistent"],
5964
+ capture_output=True, text=True)
5965
+
5966
+ def test_every_percent_i_path_becomes_a_producer_template(self):
5967
+ r = self._run(self.OLD)
5968
+ after = next(l for l in r.stdout.splitlines() if l.startswith("ExecStart after:"))
5969
+ self.assertNotIn("%i", after)
5970
+ self.assertIn("--persona %I", after)
5971
+ for flag, path in (("--token-file-template", "token.{persona}"),
5972
+ ("--state-file-template", "{persona}.state"),
5973
+ ("--events-file-template", "events.{persona}.ndjson")):
5974
+ self.assertRegex(after, re.escape(flag) + r" \S*" + re.escape(path))
5975
+ self.assertIn("--poll-seconds 30", after) # everything else untouched
5976
+ self.assertIn("REFUSING to rewrite", r.stdout) # /nonexistent lacks the new flags
5977
+ self.assertEqual(r.returncode, 0, "a dry run never fails")
5978
+
5979
+ def test_the_rewritten_unit_resolves_to_the_same_paths_the_shipped_template_does(self):
5980
+ r = self._run(self.OLD)
5981
+ after = next(l for l in r.stdout.splitlines() if l.startswith("ExecStart after:"))
5982
+ argv = [w.replace("%h", "/h").replace("%I", "Loom") for w in after.split()[3:]]
5983
+ args = km.build_parser().parse_args(argv)
5984
+ km.validate_args(args)
5985
+ shipped = km.build_parser().parse_args(SupervisorPersonaPathParityTest()._systemd_argv_home("Loom", "/h"))
5986
+ km.validate_args(shipped)
5987
+ self.assertEqual(args.token_file, shipped.token_file)
5988
+ self.assertEqual(args.state_file_template, shipped.state_file_template)
5989
+ self.assertEqual(args.events_file_template, shipped.events_file_template)
5990
+
5991
+ def test_an_already_migrated_unit_is_left_alone(self):
5992
+ # The shipped template IS the migrated form, so running the script on it must be a no-op report.
5993
+ here = os.path.dirname(os.path.abspath(__file__))
5994
+ with open(os.path.join(here, "kijito-inbox-monitor@.service.template"), encoding="utf-8") as fh:
5995
+ r = self._run(fh.read())
5996
+ self.assertIn("already migrated", r.stdout)
5997
+ self.assertNotIn("ExecStart after:", r.stdout)