kijito-tools 0.2.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kijito-tools",
3
- "version": "0.2.4",
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,17 @@
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
+
6
17
  ## [0.5.6] - 2026-09-25
7
18
 
8
19
  ### Fixed
@@ -1,13 +1,13 @@
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=04bb12ae11fc882d14c30ac36a9220591b5fab65
5
- tree=101419def29ffc023991a6f5aace5933d042768a
6
- tag=v0.5.6
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 8d92a7f3d76e6802faed79328e349b6b7786b87e CHANGELOG.md
10
+ file=100644 483f8aaa29ab3500ff421315bca2d894b3c40fc6 CHANGELOG.md
11
11
  file=100644 d645695673349e3947e8e5ae42332d0ac3164cd7 LICENSE
12
12
  file=100644 f6b0e5634f3fd4156c1c000efb31578bfb068cf3 NOTICE
13
13
  file=100644 73d2bf5a5d5d6cb622db014285cd9ada4c705724 README.md
@@ -15,12 +15,12 @@ file=100644 72f0c148f1e721ac1bb4a38e5cae690355774314 RELEASING.md
15
15
  file=100755 fca1203267cb726736b0c8f239e2d74c40a7a895 arm-hive-monitor.sh
16
16
  file=100755 bbe3f07de13dab8a8a7a4798481156ae4a46ab8f bin/cli.js
17
17
  file=100644 fffa8bd23749a321285bd942a3f4f4a98936129c com.kijito.inbox-monitor.plist.template
18
- file=100644 30519b1ad6098541083648392f344da68e688246 docs/DESIGN.md
18
+ file=100644 fc953e43bcd3069a1dc426ddefc751d8f01cf599 docs/DESIGN.md
19
19
  file=100644 4f5e53a8633e60e6c57c451e28546f8af8783fe8 kijito-inbox-monitor@.service.template
20
- file=100644 5038ff752d4b936e5b9bfab5642f38e7622fd4a4 kijito_inbox_monitor.py
21
- file=100644 5e682088a3dafbe9526761c87f9a16573c3bbdc9 package.json
22
- file=100644 6f90a103962a6ee2fa985f8356ff8c95cb93a6a7 pyproject.toml
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 ea22077c22b25d53d6719af4b0e7d9e719aeea7d test_kijito_monitor.py
26
+ file=100644 8d881e5f8562d4ce6b1b22fc97bf9f1ade1d52fb test_kijito_monitor.py
@@ -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.6"
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__
@@ -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 ->
@@ -1998,6 +2011,10 @@ class WatchTarget:
1998
2011
  self.armed = False
1999
2012
  self.fast_path = False
2000
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
2001
2018
  self.skips = 0
2002
2019
  self.first_poll = True
2003
2020
  self.last_heartbeat = _monotonic()
@@ -2408,6 +2425,11 @@ class WatchTarget:
2408
2425
  def poll_once(self, counts_available=False, unread_counts=None):
2409
2426
  args = self.args
2410
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)
2411
2433
 
2412
2434
  skip_full = False
2413
2435
  if self.armed and self.fast_path and not args.no_fast_path and self.unread_persona:
@@ -2832,7 +2854,8 @@ class WatchTarget:
2832
2854
  pin_evidence_intact=self.pin_evidence_intact,
2833
2855
  state_corrupt=self.state_corrupt,
2834
2856
  pin_release_at=self.pin_release_at,
2835
- unread_hidden=self.unread_hidden)
2857
+ unread_hidden=self.unread_hidden,
2858
+ unread=self.observed_unread)
2836
2859
  # ★ CONSUME THE ANSWER (Loom re-audit 9, MEDIUM). Round 8 taught me to RETURN a durability
2837
2860
  # status; this is the same defect one layer out - I produced an answer and then discarded it
2838
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.6",
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.6"
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"
@@ -6193,3 +6193,84 @@ class NegativeFailureCountStateTest(unittest.TestCase):
6193
6193
 
6194
6194
  def test_zero_still_loads(self):
6195
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)