kijito-tools 0.2.3 → 0.2.5
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 +1 -1
- package/providers/claude/scripts/kijito-persona-lib.sh +26 -0
- package/providers/claude/scripts/statusline-context.sh +8 -0
- package/providers/monitor/CHANGELOG.md +24 -0
- package/providers/monitor/README.md +1 -1
- package/providers/monitor/UPSTREAM +11 -11
- package/providers/monitor/com.kijito.inbox-monitor.plist.template +2 -2
- package/providers/monitor/docs/DESIGN.md +7 -1
- package/providers/monitor/kijito_inbox_monitor.py +38 -7
- package/providers/monitor/package.json +1 -1
- package/providers/monitor/pyproject.toml +1 -1
- package/providers/monitor/test_kijito_monitor.py +160 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kijito-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
|
@@ -85,6 +85,32 @@ PYSCAN
|
|
|
85
85
|
return 1
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
# kijito_unread_for_persona <persona> -> prints the persona's unread count, or nothing (row M309).
|
|
89
|
+
# The producer (kijito-inbox-monitor >= 0.5.7) writes `unread` into the persona's STATE file on every poll
|
|
90
|
+
# that had a count, and omits it when it had none. Nothing is printed unless a FRESH state file for this
|
|
91
|
+
# persona holds one: a stale or missing figure is a confident wrong number on a pane, which is worse than none.
|
|
92
|
+
# ⛔ THE FILE IS FOUND BY WHAT IT SAYS, NOT BY ITS NAME. Re-deriving the persona->filename rule here would be a
|
|
93
|
+
# third copy of it (the M290 lesson); every state file records its own watched persona in `identity`, so one
|
|
94
|
+
# jq pass over the known layouts asks the files instead. Newest first, and the NEWEST match decides: if it
|
|
95
|
+
# holds no count (unknown), an older file's figure must not stand in for it.
|
|
96
|
+
kijito_unread_for_persona() {
|
|
97
|
+
local want=${1:-} f n files=()
|
|
98
|
+
[ -n "$want" ] || return 1
|
|
99
|
+
command -v jq >/dev/null 2>&1 || return 1
|
|
100
|
+
# rewritten every poll, so anything older than 10 min is a producer that has stopped writing it
|
|
101
|
+
while IFS= read -r f; do [ -n "$f" ] && files+=("$f"); done < <(
|
|
102
|
+
find "$HOME/.kijito-monitor" "$HOME/.cache/kijito-inbox-monitor" "$HOME/.local/state/kijito-inbox-monitor" \
|
|
103
|
+
-maxdepth 1 -type f \( -name '*.state' -o -name 'hive.*.json' -o -name 'state.*.json' \) -mmin -10 \
|
|
104
|
+
2>/dev/null)
|
|
105
|
+
[ "${#files[@]}" -gt 0 ] || return 1
|
|
106
|
+
# shellcheck disable=SC2012 # the paths come from find above; ls is only ordering them by mtime
|
|
107
|
+
n=$(ls -t "${files[@]}" 2>/dev/null | tr '\n' '\0' | xargs -0 jq -rn --arg w "$(printf '%s' "$want" | tr '[:upper:]' '[:lower:]')" '
|
|
108
|
+
first(inputs | select((try (.identity[4] | map(select(.[0] == "persona")) | .[0][1] | ascii_downcase)
|
|
109
|
+
catch null) == $w)) | .unread // empty' 2>/dev/null)
|
|
110
|
+
case "$n" in ''|*[!0-9]*) return 1 ;; esac
|
|
111
|
+
printf '%s' "$n"
|
|
112
|
+
}
|
|
113
|
+
|
|
88
114
|
# kijito_stream_consumed <stream-path> -> 0 if a wake-capable consumer (`tail -n 0 -F …`) reads it.
|
|
89
115
|
# ⚠️ ANCHOR ON WHAT THE PROCESS *IS*. An unanchored pgrep on the events path SELF-MATCHES the producer
|
|
90
116
|
# (its own argv contains that path), and the harness's `bash -c … eval` wrappers carry the same argv —
|
|
@@ -58,6 +58,14 @@ col=$(awk -v p="$pct" 'BEGIN { if (p>=80) printf "\033[31m"; else if (p>=60) pri
|
|
|
58
58
|
if [ -n "$persona" ]; then
|
|
59
59
|
printf '\033[36m%s\033[0m · ' "$(kijito_truncate "$persona" 18)"
|
|
60
60
|
fi
|
|
61
|
+
# ROW M309, second half: the persona's unread count, from the producer's own state file (fresh only).
|
|
62
|
+
# Shown only when non-zero, so a quiet pane keeps the line it had.
|
|
63
|
+
if [ -n "$persona" ]; then
|
|
64
|
+
unread=$(kijito_unread_for_persona "$persona" || true)
|
|
65
|
+
if [ -n "$unread" ] && [ "$unread" -gt 0 ]; then
|
|
66
|
+
printf '\033[33m✉ %s\033[0m · ' "$unread"
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
61
69
|
# ROW M291: the heartbeat watchdog raises this flag when this pane's persona has unread wake events
|
|
62
70
|
# in its stream and NO consumer reading it (typically after a usage-limit outage ended the loop). It
|
|
63
71
|
# is the one place a human watching the pane will see it; the watchdog removes it on re-arm.
|
|
@@ -3,6 +3,30 @@
|
|
|
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.7] - 2026-09-25
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **The state file carries the watched persona's unread count (row M309).** The producer read every persona's
|
|
10
|
+
unread count from `/api/notify/pending` each poll and kept it only in memory, so nothing local - a status
|
|
11
|
+
line, a health check - could show one. Each poll that had a count now writes it as `unread` (a
|
|
12
|
+
non-negative integer); a poll that had no count writes no `unread`, so its absence means unknown, never
|
|
13
|
+
zero. It is written with the fast path on or off. Nothing in the producer reads it back to decide what to
|
|
14
|
+
emit. It is read as strictly as the other persisted fields: anything but a non-negative integer makes the
|
|
15
|
+
file CORRUPT. Files written by older versions, which lack the field, load as before.
|
|
16
|
+
|
|
17
|
+
## [0.5.6] - 2026-09-25
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **The launchd template names the state file the producer actually writes (row M289).** It passed a base
|
|
21
|
+
(`--state-file <dir>/hive.json`) that the producer turned into `hive.<persona>.json`, so anyone reading the
|
|
22
|
+
template looked for a file that never existed. It now passes `--state-file-template <dir>/hive.{persona}.json`,
|
|
23
|
+
which resolves to the SAME file as before, so upgrading moves nothing. The README's multi-persona example
|
|
24
|
+
likewise names `state.{persona}.json`. A new `_state_path_from_args()` is the one place the producer
|
|
25
|
+
decides a persona's state file, and `StateFileNameAgreementTest` derives the name from the template's own
|
|
26
|
+
argv through it.
|
|
27
|
+
- **A negative persisted `consecutive_failures` makes the state file CORRUPT.** A count below zero was
|
|
28
|
+
resumed as-is and silently postponed the dead-man alert by that many polls.
|
|
29
|
+
|
|
6
30
|
## [0.5.5] - 2026-09-24
|
|
7
31
|
|
|
8
32
|
### Added
|
|
@@ -269,7 +269,7 @@ owned, rotated event file, and each agent session consumes only its own:
|
|
|
269
269
|
```sh
|
|
270
270
|
kijito-inbox-monitor --all-personas \
|
|
271
271
|
--events-file-template ~/.local/state/kijito-inbox-monitor/events.{persona}.ndjson \
|
|
272
|
-
--state-file ~/.local/state/kijito-inbox-monitor/state.json
|
|
272
|
+
--state-file-template ~/.local/state/kijito-inbox-monitor/state.{persona}.json
|
|
273
273
|
```
|
|
274
274
|
|
|
275
275
|
Each session then wakes on its own `events.<persona>.ndjson` using the recipe in
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
# The upstream release providers/monitor is a byte-exact copy of (row M314).
|
|
2
2
|
# Written by scripts/import-monitor.sh; verified by tests/vendored_monitor_test.sh. Never hand-edit.
|
|
3
3
|
repo=KijitoAI/kijito-inbox-monitor
|
|
4
|
-
sha=
|
|
5
|
-
tree=
|
|
6
|
-
tag=v0.5.
|
|
4
|
+
sha=9ac160aa98d8205a9eabe05378d9fd7ac25d8273
|
|
5
|
+
tree=65ee5cf22640992369c09d6d6f1707116093908b
|
|
6
|
+
tag=v0.5.7
|
|
7
7
|
file=100644 8d88ccb0fd09b9144505f985b4f882db278fac1f .github/workflows/publish-npm.yml
|
|
8
8
|
file=100644 19ce3471a2cffa689be9d261049c1e74e3e7a25e .github/workflows/publish-pypi.yml
|
|
9
9
|
file=100644 4534290a5dd36d18d07a782b784b6b0ad5ee38fc .gitignore
|
|
10
|
-
file=100644
|
|
10
|
+
file=100644 483f8aaa29ab3500ff421315bca2d894b3c40fc6 CHANGELOG.md
|
|
11
11
|
file=100644 d645695673349e3947e8e5ae42332d0ac3164cd7 LICENSE
|
|
12
12
|
file=100644 f6b0e5634f3fd4156c1c000efb31578bfb068cf3 NOTICE
|
|
13
|
-
file=100644
|
|
13
|
+
file=100644 73d2bf5a5d5d6cb622db014285cd9ada4c705724 README.md
|
|
14
14
|
file=100644 72f0c148f1e721ac1bb4a38e5cae690355774314 RELEASING.md
|
|
15
15
|
file=100755 fca1203267cb726736b0c8f239e2d74c40a7a895 arm-hive-monitor.sh
|
|
16
16
|
file=100755 bbe3f07de13dab8a8a7a4798481156ae4a46ab8f bin/cli.js
|
|
17
|
-
file=100644
|
|
18
|
-
file=100644
|
|
17
|
+
file=100644 fffa8bd23749a321285bd942a3f4f4a98936129c com.kijito.inbox-monitor.plist.template
|
|
18
|
+
file=100644 fc953e43bcd3069a1dc426ddefc751d8f01cf599 docs/DESIGN.md
|
|
19
19
|
file=100644 4f5e53a8633e60e6c57c451e28546f8af8783fe8 kijito-inbox-monitor@.service.template
|
|
20
|
-
file=100644
|
|
21
|
-
file=100644
|
|
22
|
-
file=100644
|
|
20
|
+
file=100644 db09ae46bf223a01ea0c2903cc886e6e09b21d43 kijito_inbox_monitor.py
|
|
21
|
+
file=100644 9cea8637ea9c61323ac2298da3d521454eabd85a package.json
|
|
22
|
+
file=100644 d0af8757fa4d3c3f4591f64a0e3d67d1637a9210 pyproject.toml
|
|
23
23
|
file=100755 b34a5c775385ecfaefd4b374c1fc1aa082a85d14 scripts/migrate-systemd-unit.sh
|
|
24
24
|
file=100755 22acef6d6875395a81c701eb535030d0a78b0df1 scripts/mutation-check.py
|
|
25
25
|
file=100755 acba6196213736922fc4f7135749386d85d172a6 scripts/prepublish-gate.sh
|
|
26
|
-
file=100644
|
|
26
|
+
file=100644 8d881e5f8562d4ce6b1b22fc97bf9f1ade1d52fb test_kijito_monitor.py
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
<string>__PROGRAM__</string>
|
|
38
38
|
<string>--token-file</string>
|
|
39
39
|
<string>__HOME__/.config/kijito-inbox-monitor/token</string>
|
|
40
|
-
<string>--state-file</string>
|
|
41
|
-
<string>__HOME__/.cache/kijito-inbox-monitor/hive.json</string>
|
|
40
|
+
<string>--state-file-template</string>
|
|
41
|
+
<string>__HOME__/.cache/kijito-inbox-monitor/hive.{persona}.json</string>
|
|
42
42
|
<string>--events-file-template</string>
|
|
43
43
|
<string>__HOME__/.cache/kijito-inbox-monitor/events.{persona}.ndjson</string>
|
|
44
44
|
<string>--heartbeat</string>
|
|
@@ -592,7 +592,13 @@ read-state-neutral (DONE-WHEN #5 holds after self-test).
|
|
|
592
592
|
- **Content (JSON):** `{"identity":<canonical-id>, "cursor":<int|null>, "state":"UP|DOWN",
|
|
593
593
|
"consecutive_failures":<int>}`, plus the optional pin fields written only when they are in force:
|
|
594
594
|
`emitted_above` (list of int), `gap_alerted` (int), `pin_forced` (true), `pin_evidence_intact` (false),
|
|
595
|
-
`state_corrupt` (true), `pin_release_at` (int).
|
|
595
|
+
`state_corrupt` (true), `pin_release_at` (int). Also `unread_hidden` (true) while the unread-not-shown alarm
|
|
596
|
+
is announced, and `unread` (non-negative int, row M309): the watched persona's unread count as of the poll that
|
|
597
|
+
wrote the file. `unread` is INFORMATIONAL - it is written for local readers (a status line, a health check) and
|
|
598
|
+
nothing in the producer reads it back to decide what to emit. It is written only by a poll whose
|
|
599
|
+
`/api/notify/pending` read succeeded (a persona missing from a good response is a real 0 - the server omits
|
|
600
|
+
personas with nothing pending); a poll without that read writes no `unread`, so its absence means UNKNOWN,
|
|
601
|
+
never zero. A reader should also judge freshness from the file's mtime: the file is rewritten every poll.
|
|
596
602
|
- **Every persisted field is read STRICTLY, and anything unrecognised fails CLOSED** (Loom re-audit 7, HIGH 2).
|
|
597
603
|
Booleans must be JSON booleans and integers must be real integers - a JSON `1` for `pin_forced` used to
|
|
598
604
|
normalise to `false` and silently UNPIN the watermark, letting the replay cap cross the very span the pin was
|
|
@@ -33,7 +33,7 @@ try:
|
|
|
33
33
|
except ImportError: # pragma: no cover - Windows
|
|
34
34
|
fcntl = None
|
|
35
35
|
|
|
36
|
-
__version__ = "0.5.
|
|
36
|
+
__version__ = "0.5.7"
|
|
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__
|
|
@@ -1543,7 +1543,7 @@ class StateFile:
|
|
|
1543
1543
|
"%s\n" % self.path)
|
|
1544
1544
|
return CORRUPT_STATE
|
|
1545
1545
|
if not ((cursor is None or _is_int(cursor)) and state in ("UP", "DOWN")
|
|
1546
|
-
and _is_int(failures)):
|
|
1546
|
+
and _is_int(failures) and failures >= 0): # a failure COUNT is never negative
|
|
1547
1547
|
sys.stderr.write("kijito-inbox-monitor: WARNING state-file has a valid envelope but invalid "
|
|
1548
1548
|
"fields; refusing to baseline over it: %s\n" % self.path)
|
|
1549
1549
|
return CORRUPT_STATE
|
|
@@ -1644,10 +1644,18 @@ class StateFile:
|
|
|
1644
1644
|
# a re-announce after an upgrade costs one event and is honest about the current condition,
|
|
1645
1645
|
# whereas defaulting to True would silence a live condition for the rest of the run.
|
|
1646
1646
|
hidden = d.get("unread_hidden") is True
|
|
1647
|
+
# `unread` (row M309) is read as strictly as every other field: a count that is not a non-negative
|
|
1648
|
+
# integer is evidence the file was not written by us, and the answer to that is the same fail-closed
|
|
1649
|
+
# one. Absent (an older file, or a poll with no count) is fine and means unknown.
|
|
1650
|
+
unread = d.get("unread")
|
|
1651
|
+
if unread is not None and not (_is_int(unread) and unread >= 0):
|
|
1652
|
+
sys.stderr.write("kijito-inbox-monitor: WARNING state-file 'unread' is not a non-negative integer "
|
|
1653
|
+
"(%r); treating the whole file as CORRUPT (fail closed): %s\n" % (unread, self.path))
|
|
1654
|
+
return CORRUPT_STATE
|
|
1647
1655
|
return {"cursor": cursor, "state": state, "failures": failures, "emitted_above": emitted,
|
|
1648
1656
|
"gap_alerted": alerted, "pin_evidence_intact": intact,
|
|
1649
1657
|
"pin_forced": pin_forced, "pin_release_at": release_at,
|
|
1650
|
-
"state_corrupt": state_corrupt, "unread_hidden": hidden}
|
|
1658
|
+
"state_corrupt": state_corrupt, "unread_hidden": hidden, "unread": unread}
|
|
1651
1659
|
|
|
1652
1660
|
def unlock(self):
|
|
1653
1661
|
"""Release the single-writer flock and close the sidecar fd.
|
|
@@ -1664,7 +1672,7 @@ class StateFile:
|
|
|
1664
1672
|
|
|
1665
1673
|
def save(self, cursor, state, failures, emitted_above=None, gap_alerted=None,
|
|
1666
1674
|
pin_forced=False, pin_evidence_intact=True, state_corrupt=False, pin_release_at=None,
|
|
1667
|
-
unread_hidden=False):
|
|
1675
|
+
unread_hidden=False, unread=None):
|
|
1668
1676
|
"""Persist the cursor. Returns True IFF the write is DURABLE (Loom re-audit 8, HIGH 3).
|
|
1669
1677
|
|
|
1670
1678
|
The directory fsync used to be called and its answer thrown away, so a failure returned success
|
|
@@ -1700,6 +1708,11 @@ class StateFile:
|
|
|
1700
1708
|
# act on any faster for being told twice.
|
|
1701
1709
|
if unread_hidden:
|
|
1702
1710
|
d["unread_hidden"] = True
|
|
1711
|
+
# The persona's unread count as of this poll (row M309) - the one LOCAL place a status line or a
|
|
1712
|
+
# health check can read it. INFORMATIONAL: nothing here reads it back to decide what to emit.
|
|
1713
|
+
# Omitted when this poll had no count, so its absence means "unknown", never "zero".
|
|
1714
|
+
if unread is not None:
|
|
1715
|
+
d["unread"] = unread
|
|
1703
1716
|
dirn = os.path.dirname(os.path.abspath(self.path)) or "."
|
|
1704
1717
|
# BOTH OF THESE ARE INSIDE THE GUARD, and they did not used to be (drill, 2026-08-05).
|
|
1705
1718
|
# This function builds a careful "written but not provably durable" path - _fsync_dir fails ->
|
|
@@ -1858,6 +1871,15 @@ def _persona_path(template, persona):
|
|
|
1858
1871
|
return template.replace("{persona}", _state_safe_persona(persona))
|
|
1859
1872
|
|
|
1860
1873
|
|
|
1874
|
+
def _state_path_from_args(args, persona):
|
|
1875
|
+
"""THE state file a persona's watch target opens (row M289): the `{persona}` template when given, else the
|
|
1876
|
+
per-persona derivation of the `--state-file` base. One function, so a supervisor template, the docs and a
|
|
1877
|
+
test can ask the producer which file it will write instead of re-deriving the name and drifting."""
|
|
1878
|
+
if getattr(args, "state_file_template", None):
|
|
1879
|
+
return _persona_path(args.state_file_template, persona)
|
|
1880
|
+
return _state_path_for_persona(getattr(args, "state_file", None), persona)
|
|
1881
|
+
|
|
1882
|
+
|
|
1861
1883
|
def _created_epoch(created):
|
|
1862
1884
|
"""A message's `created` as epoch seconds, or None if it cannot be read. The server writes naive UTC
|
|
1863
1885
|
('2026-09-24 21:34:24.943580'); an explicit offset or 'Z' is honoured when present."""
|
|
@@ -1989,6 +2011,10 @@ class WatchTarget:
|
|
|
1989
2011
|
self.armed = False
|
|
1990
2012
|
self.fast_path = False
|
|
1991
2013
|
self.last_unread = None
|
|
2014
|
+
# What THIS poll learned about the persona's unread count, for the state file (row M309). Kept apart
|
|
2015
|
+
# from `last_unread`, which is the fast-path's wake TRIGGER and only exists while the fast path is on:
|
|
2016
|
+
# reusing it would publish nothing under --no-fast-path, and a status line would read that as zero.
|
|
2017
|
+
self.observed_unread = None
|
|
1992
2018
|
self.skips = 0
|
|
1993
2019
|
self.first_poll = True
|
|
1994
2020
|
self.last_heartbeat = _monotonic()
|
|
@@ -2022,8 +2048,7 @@ class WatchTarget:
|
|
|
2022
2048
|
cp = urllib.parse.urlsplit(url)
|
|
2023
2049
|
self.unread_persona = dict(urllib.parse.parse_qsl(cp.query)).get("persona") or persona
|
|
2024
2050
|
|
|
2025
|
-
state_path = (
|
|
2026
|
-
else _state_path_for_persona(args.state_file, persona))
|
|
2051
|
+
state_path = _state_path_from_args(args, persona)
|
|
2027
2052
|
if state_path:
|
|
2028
2053
|
self.state_file = StateFile(state_path, self.identity)
|
|
2029
2054
|
if not args.self_test:
|
|
@@ -2400,6 +2425,11 @@ class WatchTarget:
|
|
|
2400
2425
|
def poll_once(self, counts_available=False, unread_counts=None):
|
|
2401
2426
|
args = self.args
|
|
2402
2427
|
unread_counts = unread_counts or {}
|
|
2428
|
+
# A count the server did NOT give us this tick is UNKNOWN, never zero: the state file then carries no
|
|
2429
|
+
# `unread` at all, so a reader shows nothing rather than a stale or invented figure. A persona missing
|
|
2430
|
+
# from a GOOD response is a real zero - /api/notify/pending omits personas with nothing pending.
|
|
2431
|
+
self.observed_unread = (unread_counts.get(self.unread_persona, 0)
|
|
2432
|
+
if counts_available and self.unread_persona else None)
|
|
2403
2433
|
|
|
2404
2434
|
skip_full = False
|
|
2405
2435
|
if self.armed and self.fast_path and not args.no_fast_path and self.unread_persona:
|
|
@@ -2824,7 +2854,8 @@ class WatchTarget:
|
|
|
2824
2854
|
pin_evidence_intact=self.pin_evidence_intact,
|
|
2825
2855
|
state_corrupt=self.state_corrupt,
|
|
2826
2856
|
pin_release_at=self.pin_release_at,
|
|
2827
|
-
unread_hidden=self.unread_hidden
|
|
2857
|
+
unread_hidden=self.unread_hidden,
|
|
2858
|
+
unread=self.observed_unread)
|
|
2828
2859
|
# ★ CONSUME THE ANSWER (Loom re-audit 9, MEDIUM). Round 8 taught me to RETURN a durability
|
|
2829
2860
|
# status; this is the same defect one layer out - I produced an answer and then discarded it
|
|
2830
2861
|
# at the call site, which is the exact thing the previous round was about. A cursor whose
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kijito-inbox-monitor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
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.
|
|
10
|
+
version = "0.5.7"
|
|
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"
|
|
@@ -6114,3 +6114,163 @@ class StillUnreadBackstopTest(unittest.TestCase):
|
|
|
6114
6114
|
self.assertIn(">= 0", err(["--persona", "a", "--still-unread-after", "-1"]))
|
|
6115
6115
|
self.assertIn(">= 1", err(["--persona", "a", "--still-unread-max", "0"]))
|
|
6116
6116
|
self.assertIsNone(err(["--persona", "a", "--still-unread-after", "0"]))
|
|
6117
|
+
|
|
6118
|
+
|
|
6119
|
+
class StateFileNameAgreementTest(unittest.TestCase):
|
|
6120
|
+
"""Row M289 (second half): the launchd template, the docs and the producer must agree on the state file's
|
|
6121
|
+
NAME. The template used to pass a base (`--state-file <dir>/hive.json`) that the producer silently turned
|
|
6122
|
+
into `hive.<persona>.json`, so a reader of the template looked for a file that never existed. Now the
|
|
6123
|
+
template names the file with a `{persona}` template and this test derives the name from the template's
|
|
6124
|
+
OWN argv, through the producer's OWN parser and `_state_path_from_args` - one constant, no second copy."""
|
|
6125
|
+
|
|
6126
|
+
NAMES = ["argus", "Loom", "name (purpose)", "Ωmega", "two words", "Claude-chat"]
|
|
6127
|
+
HOME = "/HOMEDIR"
|
|
6128
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
6129
|
+
|
|
6130
|
+
def _launchd_argv(self):
|
|
6131
|
+
with open(os.path.join(self._HERE, "com.kijito.inbox-monitor.plist.template"), encoding="utf-8") as fh:
|
|
6132
|
+
text = fh.read()
|
|
6133
|
+
block = re.search(r"<key>ProgramArguments</key>\s*<array>(.*?)</array>", text, re.S).group(1)
|
|
6134
|
+
words = [w.replace("__HOME__", self.HOME) for w in re.findall(r"<string>([^<]*)</string>", block)]
|
|
6135
|
+
return words[words.index("__PROGRAM__") + 1:]
|
|
6136
|
+
|
|
6137
|
+
def _args(self):
|
|
6138
|
+
args = km.build_parser().parse_args(self._launchd_argv())
|
|
6139
|
+
km.validate_args(args)
|
|
6140
|
+
return args
|
|
6141
|
+
|
|
6142
|
+
def test_the_launchd_template_names_exactly_the_state_file_the_producer_writes(self):
|
|
6143
|
+
args = self._args()
|
|
6144
|
+
self.assertIsNotNone(args.state_file_template, "the template must NAME the per-persona file, not a base")
|
|
6145
|
+
for name in self.NAMES:
|
|
6146
|
+
with self.subTest(persona=name):
|
|
6147
|
+
path = km._state_path_from_args(args, name)
|
|
6148
|
+
self.assertEqual(os.path.basename(path), "hive.%s.json" % km._state_safe_persona(name))
|
|
6149
|
+
|
|
6150
|
+
def test_existing_launchd_installs_keep_their_state_file(self):
|
|
6151
|
+
# The old template's base `hive.json` was derived to `hive.<safe>.json`; the new template must land on
|
|
6152
|
+
# the SAME file, or an upgrade would baseline over every persona's cursor (skipping its backlog).
|
|
6153
|
+
args = self._args()
|
|
6154
|
+
legacy = self.HOME + "/.cache/kijito-inbox-monitor/hive.json"
|
|
6155
|
+
for name in self.NAMES:
|
|
6156
|
+
with self.subTest(persona=name):
|
|
6157
|
+
self.assertEqual(km._state_path_from_args(args, name), km._state_path_for_persona(legacy, name))
|
|
6158
|
+
|
|
6159
|
+
def test_the_producer_opens_the_path_state_path_from_args_returns(self):
|
|
6160
|
+
# Pins that the watch target uses THIS function, so the test above speaks for the running producer.
|
|
6161
|
+
with open(os.path.join(self._HERE, "kijito_inbox_monitor.py"), encoding="utf-8") as fh:
|
|
6162
|
+
src = fh.read()
|
|
6163
|
+
self.assertIn("state_path = _state_path_from_args(args, persona)", src)
|
|
6164
|
+
|
|
6165
|
+
def test_the_readme_multi_persona_example_writes_the_file_it_documents(self):
|
|
6166
|
+
with open(os.path.join(self._HERE, "README.md"), encoding="utf-8") as fh:
|
|
6167
|
+
readme = fh.read()
|
|
6168
|
+
m = re.search(r"--all-personas \\\n(?:.*\\\n)*?\s*--state-file-template (\S+)", readme)
|
|
6169
|
+
self.assertIsNotNone(m, "the multi-persona example must name its state file with a {persona} template")
|
|
6170
|
+
args = km.build_parser().parse_args(["--all-personas", "--state-file-template", m.group(1)])
|
|
6171
|
+
self.assertEqual(os.path.basename(km._state_path_from_args(args, "argus")), "state.argus.json")
|
|
6172
|
+
self.assertIn("state.<persona>.json", readme)
|
|
6173
|
+
|
|
6174
|
+
|
|
6175
|
+
class NegativeFailureCountStateTest(unittest.TestCase):
|
|
6176
|
+
"""A persisted `consecutive_failures` below zero is not a count: resumed as-is it would postpone the
|
|
6177
|
+
dead-man edge by that many polls, silently. `_is_int` alone accepted it (a negative is a real integer)."""
|
|
6178
|
+
|
|
6179
|
+
def _load(self, failures):
|
|
6180
|
+
with tempfile.TemporaryDirectory() as d:
|
|
6181
|
+
p = os.path.join(d, "s.json")
|
|
6182
|
+
with open(p, "w") as f:
|
|
6183
|
+
json.dump({"identity": "idx", "cursor": 100, "state": "UP", "consecutive_failures": failures}, f)
|
|
6184
|
+
err, sys.stderr = sys.stderr, io.StringIO()
|
|
6185
|
+
try:
|
|
6186
|
+
return km.StateFile(p, "idx").load()
|
|
6187
|
+
finally:
|
|
6188
|
+
sys.stderr = err
|
|
6189
|
+
|
|
6190
|
+
def test_a_negative_count_is_CORRUPT(self):
|
|
6191
|
+
for bad in (-1, -1000):
|
|
6192
|
+
self.assertIs(self._load(bad), km.CORRUPT_STATE, "consecutive_failures=%r must not be resumed" % bad)
|
|
6193
|
+
|
|
6194
|
+
def test_zero_still_loads(self):
|
|
6195
|
+
self.assertIsNot(self._load(0), km.CORRUPT_STATE)
|
|
6196
|
+
|
|
6197
|
+
|
|
6198
|
+
class PersistedUnreadCountTest(unittest.TestCase):
|
|
6199
|
+
"""Row M309: the producer knew every persona's unread count and threw it away, so nothing local - a
|
|
6200
|
+
status line, a health check - could show one. The count now rides in the state file, written by every
|
|
6201
|
+
poll that HAD a count, absent from every poll that did not (absent = unknown, never zero), and read as
|
|
6202
|
+
strictly as every other persisted field."""
|
|
6203
|
+
|
|
6204
|
+
RecordingEmitter = BoundedWindowEndToEndTest.RecordingEmitter
|
|
6205
|
+
FullArgs = BoundedWindowEndToEndTest.FullArgs
|
|
6206
|
+
_target = BoundedWindowEndToEndTest._target
|
|
6207
|
+
_fetch = BoundedWindowEndToEndTest._fetch
|
|
6208
|
+
|
|
6209
|
+
def setUp(self):
|
|
6210
|
+
self._dir = tempfile.TemporaryDirectory()
|
|
6211
|
+
self.addCleanup(self._dir.cleanup)
|
|
6212
|
+
self.path = os.path.join(self._dir.name, "argus.state")
|
|
6213
|
+
|
|
6214
|
+
def _poll(self, counts_available, unread_counts, no_fast_path=True):
|
|
6215
|
+
t = self._target(100, self.RecordingEmitter())
|
|
6216
|
+
t.args.no_fast_path = no_fast_path
|
|
6217
|
+
t.state_file = km.StateFile(self.path, "idx")
|
|
6218
|
+
orig, km.fetch = km.fetch, self._fetch([], 0)
|
|
6219
|
+
try:
|
|
6220
|
+
t.poll_once(counts_available=counts_available, unread_counts=unread_counts)
|
|
6221
|
+
finally:
|
|
6222
|
+
km.fetch = orig
|
|
6223
|
+
with open(self.path) as f:
|
|
6224
|
+
return json.load(f)
|
|
6225
|
+
|
|
6226
|
+
def _load(self, extra):
|
|
6227
|
+
d = {"identity": "idx", "cursor": 100, "state": "UP", "consecutive_failures": 0}
|
|
6228
|
+
d.update(extra)
|
|
6229
|
+
with open(self.path, "w") as f:
|
|
6230
|
+
json.dump(d, f)
|
|
6231
|
+
err, sys.stderr = sys.stderr, io.StringIO()
|
|
6232
|
+
try:
|
|
6233
|
+
return km.StateFile(self.path, "idx").load()
|
|
6234
|
+
finally:
|
|
6235
|
+
sys.stderr = err
|
|
6236
|
+
|
|
6237
|
+
def test_a_poll_WITH_a_count_persists_it(self):
|
|
6238
|
+
self.assertEqual(self._poll(True, {"argus": 7, "river": 2})["unread"], 7)
|
|
6239
|
+
|
|
6240
|
+
def test_it_is_persisted_with_the_fast_path_OFF(self):
|
|
6241
|
+
# The fast path's `last_unread` only exists while the fast path is on; the fleet's systemd units
|
|
6242
|
+
# and every --no-fast-path user would otherwise publish nothing at all.
|
|
6243
|
+
self.assertEqual(self._poll(True, {"argus": 3}, no_fast_path=True)["unread"], 3)
|
|
6244
|
+
self.assertEqual(self._poll(True, {"argus": 4}, no_fast_path=False)["unread"], 4)
|
|
6245
|
+
|
|
6246
|
+
def test_a_persona_missing_from_a_GOOD_response_is_zero(self):
|
|
6247
|
+
# /api/notify/pending omits a persona with nothing pending (measured 2026-09-25: river absent).
|
|
6248
|
+
self.assertEqual(self._poll(True, {"river": 2})["unread"], 0)
|
|
6249
|
+
|
|
6250
|
+
def test_a_poll_WITHOUT_a_count_writes_NO_count(self):
|
|
6251
|
+
# Unknown must not be published as zero, and must not leave the PREVIOUS figure standing either.
|
|
6252
|
+
self._poll(True, {"argus": 9})
|
|
6253
|
+
self.assertNotIn("unread", self._poll(False, {}))
|
|
6254
|
+
|
|
6255
|
+
def test_the_count_round_trips_through_load(self):
|
|
6256
|
+
self._poll(True, {"argus": 5})
|
|
6257
|
+
err, sys.stderr = sys.stderr, io.StringIO()
|
|
6258
|
+
try:
|
|
6259
|
+
st = km.StateFile(self.path, "idx").load()
|
|
6260
|
+
finally:
|
|
6261
|
+
sys.stderr = err
|
|
6262
|
+
self.assertIsNot(st, km.CORRUPT_STATE)
|
|
6263
|
+
self.assertEqual(st["unread"], 5)
|
|
6264
|
+
self.assertEqual(st["cursor"], 100)
|
|
6265
|
+
|
|
6266
|
+
def test_an_OLDER_file_without_the_field_still_loads(self):
|
|
6267
|
+
st = self._load({})
|
|
6268
|
+
self.assertIsNot(st, km.CORRUPT_STATE)
|
|
6269
|
+
self.assertIsNone(st["unread"])
|
|
6270
|
+
|
|
6271
|
+
def test_a_malformed_count_is_CORRUPT(self):
|
|
6272
|
+
for bad in (-1, 1.5, "3", True, [], {}):
|
|
6273
|
+
self.assertIs(self._load({"unread": bad}), km.CORRUPT_STATE, "unread=%r must fail closed" % (bad,))
|
|
6274
|
+
|
|
6275
|
+
def test_zero_loads(self):
|
|
6276
|
+
self.assertEqual(self._load({"unread": 0})["unread"], 0)
|