kijito-tools 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +6 -0
  3. package/README.md +220 -0
  4. package/bin/cli.js +34 -0
  5. package/install.sh +161 -0
  6. package/package.json +50 -0
  7. package/providers/_shared/wake-core.mjs +358 -0
  8. package/providers/claude/CLAUDE.md.snippet +31 -0
  9. package/providers/claude/install.sh +186 -0
  10. package/providers/claude/scripts/arm-session.sh +66 -0
  11. package/providers/claude/scripts/claude-armed.sh +101 -0
  12. package/providers/claude/scripts/heartbeat-watchdog.sh +209 -0
  13. package/providers/claude/scripts/inbox-selftest.sh +277 -0
  14. package/providers/claude/scripts/kijito-persona-lib.sh +94 -0
  15. package/providers/claude/scripts/kijito-qa-pass.sh +12 -0
  16. package/providers/claude/scripts/lifecycle-lib.sh +167 -0
  17. package/providers/claude/scripts/myctx.sh +46 -0
  18. package/providers/claude/scripts/self-clear.sh +134 -0
  19. package/providers/claude/scripts/session-autosend.sh +85 -0
  20. package/providers/claude/scripts/session-catchup-hint.sh +330 -0
  21. package/providers/claude/scripts/statusline-context.sh +67 -0
  22. package/providers/claude/skills/kijito-qa-memory/SKILL.md +110 -0
  23. package/providers/claude/skills/kijito-recall/SKILL.md +53 -0
  24. package/providers/claude/skills/kijito-start/SKILL.md +122 -0
  25. package/providers/claude/wiring/README.md +93 -0
  26. package/providers/claude/wiring/kijito-heartbeat@.service +21 -0
  27. package/providers/codex/README.md +75 -0
  28. package/providers/codex/codex-kijito-parity-plan.md +265 -0
  29. package/providers/codex/docs-codex-setup.md +64 -0
  30. package/providers/codex/install.mjs +184 -0
  31. package/providers/codex/n0-capability-probe-protocol.md +472 -0
  32. package/providers/codex/n0-harness/README.md +57 -0
  33. package/providers/codex/n0-harness/cli.mjs +58 -0
  34. package/providers/codex/n0-harness/evidence-manifest.mjs +77 -0
  35. package/providers/codex/n0-harness/fixture.mjs +279 -0
  36. package/providers/codex/n0-harness/lib.mjs +141 -0
  37. package/providers/codex/n0-harness/manifest.mjs +36 -0
  38. package/providers/codex/n0-harness/oracle.mjs +464 -0
  39. package/providers/codex/n0-harness/parser.mjs +200 -0
  40. package/providers/codex/n0-harness/prompt.mjs +43 -0
  41. package/providers/codex/n0-harness/snapshot.mjs +120 -0
  42. package/providers/codex/n0-harness/specimen.mjs +37 -0
  43. package/providers/codex/notify/kijito-notify-count.mjs +117 -0
  44. package/providers/codex/notify/kijito-notify-count.test.mjs +76 -0
  45. package/providers/codex/plans/gate4-battery-log.md +184 -0
  46. package/providers/codex/plans/gate4-battery-protocol.md +43 -0
  47. package/providers/codex/plans/hive-user-first-plan-QA.md +34 -0
  48. package/providers/codex/plans/hive-user-first-plan.md +247 -0
  49. package/providers/codex/release-manifest.json +53 -0
  50. package/providers/codex/same-chat-continuation-plan-gate.md +235 -0
  51. package/providers/codex/same-chat-continuation-plan.md +458 -0
  52. package/providers/codex/skills/kijito-qa-memory/SKILL.md +193 -0
  53. package/providers/codex/skills/kijito-qa-memory/agents/openai.yaml +4 -0
  54. package/providers/codex/skills/kijito-recall/SKILL.md +53 -0
  55. package/providers/codex/skills/kijito-start/SKILL.md +173 -0
  56. package/providers/codex/skills/kijito-start/agents/openai.yaml +4 -0
  57. package/providers/codex/test/n0-cli-refusal.test.mjs +31 -0
  58. package/providers/codex/test/n0-counterexample-matrix.test.mjs +118 -0
  59. package/providers/codex/test/n0-guard-census-core.mjs +1372 -0
  60. package/providers/codex/test/n0-guard-census.json +35479 -0
  61. package/providers/codex/test/n0-guard-census.mjs +31 -0
  62. package/providers/codex/test/n0-guard-census.test.mjs +374 -0
  63. package/providers/codex/test/n0-guard-counterexamples.mjs +1068 -0
  64. package/providers/codex/test/n0-guard-independent-projection.mjs +70 -0
  65. package/providers/codex/test/n0-guard-independent-projection.test.mjs +53 -0
  66. package/providers/codex/test/n0-guard-manifest-author.mjs +424 -0
  67. package/providers/codex/test/n0-guard-mutation-operators.test.mjs +244 -0
  68. package/providers/codex/test/n0-guard-mutation-runner.mjs +599 -0
  69. package/providers/codex/test/n0-guard-remainder-classes.test.mjs +314 -0
  70. package/providers/codex/test/n0-guard-sanitizer-pairs.test.mjs +68 -0
  71. package/providers/codex/test/n0-guard-shared-context-predicates.test.mjs +84 -0
  72. package/providers/codex/test/n0-harness.test.mjs +553 -0
  73. package/providers/codex/test/n0-notable-regressions.test.mjs +70 -0
  74. package/providers/codex/test/n0-subject-boundary.mjs +97 -0
  75. package/providers/codex/test/n0-subject-boundary.test.mjs +72 -0
  76. package/providers/codex/test/same-chat-plan-preflight.sh +167 -0
  77. package/providers/codex/tools/refresh-manifest.mjs +81 -0
  78. package/providers/codex/wake-helper/TRANSPORT-NOTES.md +25 -0
  79. package/providers/codex/wake-helper/integration.test.mjs +285 -0
  80. package/providers/codex/wake-helper/kijito-wake-helper.mjs +488 -0
  81. package/providers/codex/wake-helper/kijito-wake-helper.test.mjs +128 -0
  82. package/providers/codex/wake-helper/mock-daemon.mjs +64 -0
  83. package/providers/codex/wake-helper/status-probe.mjs +45 -0
  84. package/providers/codex/wake-helper/ws-uds.mjs +153 -0
  85. package/providers/monitor/.github/workflows/publish-npm.yml +38 -0
  86. package/providers/monitor/.github/workflows/publish-pypi.yml +30 -0
  87. package/providers/monitor/CHANGELOG.md +465 -0
  88. package/providers/monitor/IMPORT-PROVENANCE.md +68 -0
  89. package/providers/monitor/LICENSE +202 -0
  90. package/providers/monitor/NOTICE +7 -0
  91. package/providers/monitor/OPAQUE-OUTPUT-ENFORCEMENT.md +45 -0
  92. package/providers/monitor/README.md +571 -0
  93. package/providers/monitor/RELEASING.md +125 -0
  94. package/providers/monitor/arm-hive-monitor.sh +13 -0
  95. package/providers/monitor/bin/cli.js +43 -0
  96. package/providers/monitor/com.kijito.inbox-monitor.plist.template +59 -0
  97. package/providers/monitor/docs/DESIGN.md +984 -0
  98. package/providers/monitor/kijito-inbox-monitor@.service.template +95 -0
  99. package/providers/monitor/kijito_inbox_monitor.py +3666 -0
  100. package/providers/monitor/package.json +35 -0
  101. package/providers/monitor/pyproject.toml +42 -0
  102. package/providers/monitor/scripts/mutation-check.py +399 -0
  103. package/providers/monitor/scripts/prepublish-gate.sh +284 -0
  104. package/providers/monitor/test_kijito_monitor.py +5609 -0
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # Turn THIS session's autonomy on/off via INTERACTION. The agent runs this when the user says
3
+ # "enable self-clear" / "go autonomous" (on), or "I'll manage this one" (off). It arms the current
4
+ # tmux PANE: self-clear becomes permitted, and post-/clear SessionStarts auto-catch-up + resume.
5
+ # Same marker claude-armed.sh uses, so launch-time and interaction-time arming are identical.
6
+ # Resolve the shared lib NEXT TO THIS SCRIPT so the repo copy is runnable/testable in place, and
7
+ # fall back to the installed location for a stray single-file copy. KIJITO_LC_LIB overrides both.
8
+ _kjt_lib="${KIJITO_LC_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lifecycle-lib.sh}"
9
+ [ -f "$_kjt_lib" ] || _kjt_lib="$HOME/.claude/lifecycle-lib.sh"
10
+ . "$_kjt_lib"
11
+ action="${1:-on}"
12
+ if [ -z "${TMUX_PANE:-}" ]; then echo "not in tmux — autonomy needs a tmux pane; nothing armed."; exit 1; fi
13
+ marker="$KIJITO_LC_DIR/arm.$TMUX_PANE"
14
+ case "$action" in
15
+ # The marker carries PROVENANCE (session name + #{session_created}) and is validated against the
16
+ # live tmux server on every read — see lifecycle-lib.sh. A zero-byte touch would arm nothing.
17
+ on) if ! lc_marker_write "$TMUX_PANE"; then
18
+ echo "could not arm: pane $TMUX_PANE is not a live tmux pane (or tmux did not answer). Nothing armed." >&2
19
+ lc_log ARM "on FAILED pane=$TMUX_PANE — no live pane fingerprint"; exit 1
20
+ fi
21
+ lc_log ARM "on pane=$TMUX_PANE"
22
+ echo "AUTONOMY ON (pane $TMUX_PANE): self-clear permitted; after any /clear this pane auto-catches-up + resumes. Turn off: ~/.claude/arm-session.sh off" ;;
23
+ # ⛔ `off` MUST NOT REPORT SUCCESS IT CANNOT DELIVER. Removing the marker disarms NOTHING while the
24
+ # seat-wide KIJITO_AUTOCATCHUP=1 is in force, and a process cannot unset an env var for itself.
25
+ # So: still remove the marker (that part is real and durable), then REFUSE LOUDLY and name the one
26
+ # brake that works. Non-zero exit, so a caller that checks can tell it did not get what it asked.
27
+ off) rm -f "$marker"; lc_log ARM "off pane=$TMUX_PANE marker_removed"
28
+ if lc_env_armed; then
29
+ lc_log ARM "off REFUSED pane=$TMUX_PANE env=KIJITO_AUTOCATCHUP=1"
30
+ cat >&2 <<EOF
31
+ ⛔ STILL ARMED — 'off' COULD NOT DISARM THIS PANE.
32
+ The marker $marker was removed, but arming is also granted seat-wide by
33
+ KIJITO_AUTOCATCHUP=1 (set in the environment, typically ~/.claude/settings.json), and a
34
+ running session cannot unset that for itself. self-clear is STILL PERMITTED here.
35
+ The only brake that works: touch $KIJITO_LC_STOP
36
+ (undo with: rm -f $KIJITO_LC_STOP )
37
+ EOF
38
+ exit 3
39
+ fi
40
+ echo "AUTONOMY OFF (pane $TMUX_PANE): human-managed; self-clear refused." ;;
41
+ # status prints BOTH inputs — a status that reports only the marker gives the wrong answer in
42
+ # both directions on a seat where the env var is set.
43
+ status) m=no; e=no
44
+ lc_marker_armed "$TMUX_PANE" && m=yes
45
+ lc_env_armed && e=yes
46
+ # ⚠️ "no" has three different causes and only one of them means "you never armed this".
47
+ # Saying which is the difference between a status and a riddle.
48
+ if [ "$m" = no ]; then
49
+ if lc_marker_legacy "$TMUX_PANE"; then
50
+ m="no (LEGACY marker: written before markers carried provenance, so it cannot prove it
51
+ belongs to THIS session — pane ids recycle. Re-arm with 'arm-session.sh on'.)"
52
+ elif [ -f "$marker" ]; then
53
+ m="no (marker exists but its session fingerprint does NOT match this pane's live tmux
54
+ session — it was left by a DIFFERENT session that had this pane id. Re-arm to claim it.)"
55
+ fi
56
+ fi
57
+ echo "pane=$TMUX_PANE marker=$m (arm.$TMUX_PANE) env=$e (KIJITO_AUTOCATCHUP=${KIJITO_AUTOCATCHUP:-unset})"
58
+ if lc_is_armed "$TMUX_PANE"; then
59
+ echo "armed (autonomous) — armed by: $( [ "$m" = yes ] && printf 'marker '; [ "$e" = yes ] && printf 'env')"
60
+ lc_stopped && echo "…but the kill switch is SET ($KIJITO_LC_STOP) — self-clear will refuse."
61
+ else
62
+ echo "not armed (human-managed)"
63
+ fi
64
+ exit 0 ;; # status always exits 0 (it REPORTS; it does not assert). Read the text, not $?.
65
+ *) echo "usage: arm-session.sh [on|off|status]"; exit 2 ;;
66
+ esac
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ # Launch an ARMED Claude Code session: when in tmux it auto-sends the catch-up prompt (instigating
3
+ # its own first turn) and continues preloaded work. Use for orchestrator/unattended panes + the
4
+ # self-clear loop. Plain `claude` is NOT armed → never collides with you typing.
5
+ #
6
+ # Arming is a per-pane MARKER file (robust; doesn't depend on env reaching the hook). The marker is
7
+ # removed when claude exits (trap). KIJITO_AUTOCATCHUP=1 is also exported as a belt-and-suspenders.
8
+ # Resolve the shared lib NEXT TO THIS SCRIPT so the repo copy is runnable/testable in place, and
9
+ # fall back to the installed location for a stray single-file copy. KIJITO_LC_LIB overrides both.
10
+ _kjt_lib="${KIJITO_LC_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lifecycle-lib.sh}"
11
+ [ -f "$_kjt_lib" ] || _kjt_lib="$HOME/.claude/lifecycle-lib.sh"
12
+ . "$_kjt_lib" 2>/dev/null
13
+ marker="${KIJITO_LC_DIR:-$HOME/.claude/.lifecycle}/arm.${TMUX_PANE:-nopane}"
14
+ mkdir -p "$(dirname "$marker")" 2>/dev/null
15
+ # ⛔ NOT `touch`. A zero-byte marker cannot prove it belongs to THIS session, and tmux pane ids
16
+ # restart at %0 and recycle — so a bare touch is how a future session inherits an arming it never
17
+ # performed, on the gate that authorises an irreversible /clear. lc_marker_write stamps the live
18
+ # session fingerprint into the file and lc_marker_armed re-validates it on every read.
19
+ if command -v lc_marker_write >/dev/null 2>&1 && [ -n "${TMUX_PANE:-}" ]; then
20
+ lc_marker_write "$TMUX_PANE" || echo "claude-armed: could not stamp an arm marker for $TMUX_PANE (not a live tmux pane?) — relying on KIJITO_AUTOCATCHUP" >&2
21
+ fi
22
+ # ── THE BACKUP HEARTBEAT STARTS WITH THE SESSION (row M291) ─────────────────────────────────────
23
+ # It used to be a separate, manual step (wiring/README.md), so most armed panes ran without one — and
24
+ # the heartbeat is the only thing that recovers a session whose inbox loop a usage-limit outage ended
25
+ # silently. Idempotent: if a watchdog already watches this pane (a systemd/launchd unit, or an earlier
26
+ # launch), it is left alone and NOT stopped on exit — only the one started here is ours to stop.
27
+ # Opt out with KIJITO_HEARTBEAT=0.
28
+ hb_pid=""
29
+ if [ "${KIJITO_HEARTBEAT:-1}" != "0" ] && [ -n "${TMUX_PANE:-}" ]; then
30
+ hb_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/heartbeat-watchdog.sh"
31
+ [ -f "$hb_script" ] || hb_script="$HOME/.claude/heartbeat-watchdog.sh"
32
+ if [ ! -f "$hb_script" ]; then
33
+ echo "claude-armed: no heartbeat-watchdog.sh next to this script or in ~/.claude — running without the backup heartbeat" >&2
34
+ elif command -v lc_heartbeat_running >/dev/null 2>&1 && lc_heartbeat_running "$TMUX_PANE"; then
35
+ : # already watched — by a supervisor unit or a previous launch
36
+ else
37
+ nohup bash "$hb_script" "$TMUX_PANE" >/dev/null 2>&1 &
38
+ hb_pid=$!
39
+ fi
40
+ fi
41
+ trap 'rm -f "$marker"; [ -n "$hb_pid" ] && kill "$hb_pid" 2>/dev/null' EXIT INT TERM
42
+
43
+ # Hosted Kijito MCP bearer token for .mcp.json's ${KIJITO_API_TOKEN} — read from a file so it is
44
+ # reliable inside the armed loop regardless of which shell profile did (or didn't) load. Only set it
45
+ # if the file exists AND the var isn't already provided by the environment.
46
+ if [ -z "${KIJITO_API_TOKEN:-}" ] && [ -r "${KIJITO_API_TOKEN_FILE:-$HOME/.claude/.kijito_api_token}" ]; then
47
+ export KIJITO_API_TOKEN="$(cat "${KIJITO_API_TOKEN_FILE:-$HOME/.claude/.kijito_api_token}" 2>/dev/null)"
48
+ fi
49
+
50
+ # Remote Control for armed/autonomous panes: it lets you check an unattended session from a phone,
51
+ # which is the normal reason to arm one. RC is a per-PROCESS feature — it survives /clear but NOT a
52
+ # fresh launch, and an agent cannot self-invoke a slash command, so it has to be set here at start.
53
+ #
54
+ # Both knobs are opt-OUT-able because this ships to other people's machines:
55
+ # KIJITO_REMOTE_CONTROL=0 → don't enable remote control at all
56
+ # KIJITO_RC_PREFIX=<name> → session-name prefix; makes the pane easy to spot in the session list.
57
+ # The prefix defaults to this project's persona (the `.kijito_persona` marker, same file the
58
+ # SessionStart hook reads), so an armed river pane is named "river-*" and an argus one "argus-*"
59
+ # without anyone hardcoding a name. No marker → no prefix, rather than someone else's persona.
60
+ rc_args=()
61
+ if [ "${KIJITO_REMOTE_CONTROL:-1}" != "0" ]; then
62
+ rc_args+=(--remote-control)
63
+ rc_prefix="${KIJITO_RC_PREFIX:-}"
64
+ if [ -z "$rc_prefix" ]; then
65
+ for d in "${CLAUDE_PROJECT_DIR:-}" "$PWD"; do
66
+ if [ -n "$d" ] && [ -f "$d/.kijito_persona" ]; then
67
+ rc_prefix=$(head -n1 "$d/.kijito_persona" | tr -d '[:space:]')
68
+ [ -n "$rc_prefix" ] && break
69
+ fi
70
+ done
71
+ fi
72
+ [ -n "$rc_prefix" ] && rc_args+=(--remote-control-session-name-prefix "$rc_prefix")
73
+ fi
74
+ # Session provenance ([18500]; Kijito #624/#627). The Claude Code MCP client forwards ONLY the
75
+ # Authorization header from .mcp.json `headers` (measured on 2.1.265 — every other header, literal or
76
+ # not, is dropped) and expands `${VAR}` in a server URL from the LAUNCHING environment only; it never
77
+ # injects its own session id there. So the id is minted HERE, exported for the URL
78
+ # (`https://api.kijito.ai/mcp/?session=${CLAUDE_CODE_SESSION_ID}`) and passed as --session-id, so the
79
+ # transcript, the hooks and the server all name the same session. Per LAUNCH: /clear rotates the
80
+ # harness's own id (children still see the rotated one — the harness overrides an inherited value),
81
+ # but the MCP URL was resolved at launch, so writes after a /clear carry the launch id. An explicit
82
+ # --session-id from the caller is honoured; an INHERITED value is ignored (a nested launch would
83
+ # otherwise reuse a live id, which `claude` refuses as "already in use").
84
+ kjt_sid=""
85
+ kjt_prev=""
86
+ for kjt_arg in "$@"; do # an explicit --session-id X / --session-id=X wins
87
+ case "$kjt_prev" in --session-id) kjt_sid="$kjt_arg";; esac
88
+ case "$kjt_arg" in --session-id=*) kjt_sid="${kjt_arg#--session-id=}";; esac
89
+ kjt_prev="$kjt_arg"
90
+ done
91
+ if [ -z "$kjt_sid" ]; then
92
+ kjt_sid=$( (uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null \
93
+ || python3 -c 'import uuid;print(uuid.uuid4())' 2>/dev/null) | tr 'A-Z' 'a-z' | tr -d '[:space:]')
94
+ if [ -n "$kjt_sid" ]; then
95
+ set -- --session-id "$kjt_sid" "$@"
96
+ else
97
+ echo "claude-armed: no uuid source (uuidgen / /proc / python3) — launching without a minted session id; MCP writes will carry no Session:" >&2
98
+ fi
99
+ fi
100
+ [ -n "$kjt_sid" ] && export CLAUDE_CODE_SESSION_ID="$kjt_sid" # the URL's ${CLAUDE_CODE_SESSION_ID}
101
+ KIJITO_AUTOCATCHUP=1 claude "${rc_args[@]+"${rc_args[@]}"}" "$@"
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env bash
2
+ # BACKUP heartbeat for an armed autonomous pane. (Jason, 2026-08-01: "please also setup a backup
3
+ # heartbeat.")
4
+ #
5
+ # ~/.claude/heartbeat-watchdog.sh %3 & # or run under systemd/launchd; see WIRING below
6
+ #
7
+ # WHAT THE PRIMARY IS, so this stays a BACKUP and never competes with it: after a /clear the
8
+ # SessionStart hook runs `session-autosend.sh`, which injects the catch-up prompt and restarts the
9
+ # loop. That path owns the normal case. This covers only what it cannot — the pane going QUIET
10
+ # WITHOUT a /clear: a turn that ended without queueing the next one, a crashed send, an agent that
11
+ # reported and then simply stopped.
12
+ #
13
+ # ⛔ THE DANGER IS FIRING WHILE THE AGENT IS ALIVE, so every guard is biased toward NOT sending. A
14
+ # spurious nudge injects a prompt into a working session; a missed nudge costs idle time. Those are
15
+ # not symmetric.
16
+ # * ARMED-ONLY, FAIL CLOSED. An unarmed pane is human-managed and must never be poked; if
17
+ # armed-ness cannot be established, do nothing.
18
+ # * KILL SWITCH honoured every cycle, not just at startup.
19
+ # * IDLE = the pane's visible output byte-identical for QUIET_CHECKS consecutive polls (default
20
+ # 4 x 300s = 20 min). A working session repaints constantly — spinner, tool output, streaming
21
+ # text — so 20 minutes unchanged is a strong signal, deliberately far above any normal gap.
22
+ # * ONE NUDGE PER QUIET WINDOW — not one per episode, and the distinction is deliberate. After a
23
+ # nudge the counter resets and the baseline is retaken, so a pane that STAYS idle is nudged
24
+ # again one full window later (20 min at defaults), not on every poll. That is the right
25
+ # behaviour for a heartbeat: if the first nudge did not restart anything, the pane is still
26
+ # stuck and still needs help. Measured at test speeds (POLL=1, QUIET=2): 3 nudges in 11s, i.e.
27
+ # one per window, which is exactly one per 20 min at production settings.
28
+ #
29
+ # ⚠️ THE PROMPT IT SENDS IS SAFE TO RECEIVE AT ANY MOMENT — it asks the agent to continue from its
30
+ # own pointer, which is nearly a no-op for a session already doing that. That property is what makes
31
+ # a false positive cheap; do not replace it with a directive that assumes idleness.
32
+ #
33
+ # WIRING (either host; the script itself is host-agnostic):
34
+ # Linux/systemd : systemd --user unit with ExecStart=%h/.claude/heartbeat-watchdog.sh <pane>
35
+ # macOS/launchd : a LaunchAgent with the same ExecStart, or simply `nohup ... &` from the pane.
36
+ set -u
37
+
38
+ PANE="${1:-}"
39
+ POLL="${HEARTBEAT_POLL:-300}"
40
+ QUIET_CHECKS="${HEARTBEAT_QUIET:-4}"
41
+ # M291: how long a persona's event stream may carry unread wake events with NO consumer before this
42
+ # says so. Default 10 min: longer than a Monitor re-arm gap, far shorter than a usage-limit outage.
43
+ UNCONSUMED_SECS="${HEARTBEAT_UNCONSUMED_SECS:-600}"
44
+
45
+ _kjt_lib="${KIJITO_LC_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lifecycle-lib.sh}"
46
+ [ -f "$_kjt_lib" ] || _kjt_lib="$HOME/.claude/lifecycle-lib.sh"
47
+ # shellcheck disable=SC1090
48
+ . "$_kjt_lib" 2>/dev/null || { echo "heartbeat-watchdog: cannot source lifecycle-lib" >&2; exit 2; }
49
+
50
+ _kjt_plib="$(dirname "$_kjt_lib")/kijito-persona-lib.sh"
51
+ [ -f "$_kjt_plib" ] || _kjt_plib="$HOME/.claude/kijito-persona-lib.sh"
52
+ # shellcheck disable=SC1090
53
+ . "$_kjt_plib" 2>/dev/null || true # optional: without it the stream check is skipped, not faked
54
+
55
+ command -v tmux >/dev/null 2>&1 || { lc_log HEARTBEAT_SKIP "no tmux"; exit 0; }
56
+ [ -n "$PANE" ] || { echo "usage: heartbeat-watchdog.sh <tmux-pane-id> (e.g. %3)" >&2; exit 2; }
57
+
58
+ # Change detection only — `cksum` is POSIX and present on both BSD and GNU userland, unlike md5sum
59
+ # (absent on macOS, where it is `md5`). We need "did this differ", not a cryptographic digest.
60
+ _pane_hash() { tmux capture-pane -p -t "$1" 2>/dev/null | tail -40 | cksum | awk '{print $1"-"$2}'; }
61
+
62
+ # ── M291: THE UNCONSUMED-STREAM CHECK ────────────────────────────────────────────────────────────
63
+ # WHY: a Claude usage-limit hit ends the agent's turn loop, and the wake-capable consumer (a Monitor
64
+ # tail) dies or expires with it. When the limit clears NOTHING re-arms it: the producer keeps writing
65
+ # events, nobody reads them, and the session is permanently deaf while every health signal reads
66
+ # green (producer up, heartbeat fresh, mail landing). That is DIFFERENT from the producer's "dormant
67
+ # inbox" notice, which is about mail nobody has READ on the server; this is about a local stream
68
+ # nobody is CONSUMING — and it has a different fix (re-arm the consumer), so it gets its own name.
69
+ #
70
+ # RAISED WHEN: this pane's persona has an event stream, no `tail` consumer has been attached for
71
+ # UNCONSUMED_SECS, AND at least one wake-worthy event was appended since the consumer went missing
72
+ # (no events = nothing missed = nothing to alarm about; the heartbeat rows the producer writes every
73
+ # minute are excluded by the same filter a consumer uses).
74
+ # SURFACED AS: an `HEARTBEAT_UNCONSUMED_STREAM` lifecycle-log line, a flag file the status line shows
75
+ # (`unconsumed.<pane>`), and a nudge prompt that says to re-arm the consumer FIRST. Cleared (with a
76
+ # `HEARTBEAT_STREAM_CONSUMED` line) the moment a consumer is attached again.
77
+ WAKE_EVENTS='"event": ?"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)"'
78
+ UNCONSUMED_FLAG="$KIJITO_LC_DIR/unconsumed.$PANE"
79
+ st_missing_since=""; st_offset=""; st_alerted=0; st_path=""
80
+
81
+ _stream_check() {
82
+ command -v kijito_stream_for_persona >/dev/null 2>&1 || return 0
83
+ local dir persona path now size n
84
+ dir=$(tmux display-message -p -t "$PANE" '#{pane_current_path}' 2>/dev/null)
85
+ persona=$(kijito_persona_from_marker "$dir" 2>/dev/null) || persona=""
86
+ [ -n "$persona" ] || return 0
87
+ path=$(kijito_stream_for_persona "$persona" 2>/dev/null) || path=""
88
+ [ -n "$path" ] && [ -f "$path" ] || return 0
89
+ if kijito_stream_consumed "$path"; then
90
+ if [ "$st_alerted" = 1 ]; then lc_log HEARTBEAT_STREAM_CONSUMED "target_pane=$PANE persona=$persona stream=$path"; fi
91
+ rm -f "$UNCONSUMED_FLAG" 2>/dev/null
92
+ st_missing_since=""; st_offset=""; st_alerted=0; st_path=""
93
+ return 0
94
+ fi
95
+ now=$(lc_now); size=$(wc -c < "$path" 2>/dev/null | tr -d ' ')
96
+ if [ -z "$st_missing_since" ] || [ "$path" != "$st_path" ]; then
97
+ st_missing_since=$now; st_offset=$size; st_path=$path; return 0
98
+ fi
99
+ # the producer self-rotates its stream; a shrunken file means everything in it is new
100
+ [ "${size:-0}" -lt "${st_offset:-0}" ] && st_offset=0
101
+ [ "$st_alerted" = 0 ] || return 0
102
+ [ $((now - st_missing_since)) -ge "$UNCONSUMED_SECS" ] || return 0
103
+ n=$(tail -c +"$((st_offset + 1))" "$path" 2>/dev/null | grep -cE -- "$WAKE_EVENTS")
104
+ [ "${n:-0}" -gt 0 ] || return 0
105
+ st_alerted=1
106
+ lc_log HEARTBEAT_UNCONSUMED_STREAM "target_pane=$PANE persona=$persona stream=$path events=$n no_consumer_for=$((now - st_missing_since))s"
107
+ printf 'persona=%s\nstream=%s\nevents=%s\nsince=%s\n' "$persona" "$path" "$n" "$st_missing_since" > "$UNCONSUMED_FLAG" 2>/dev/null
108
+ }
109
+
110
+ lc_log HEARTBEAT_START "pane=$PANE poll=${POLL}s quiet=$QUIET_CHECKS"
111
+ last=""; unchanged=0
112
+
113
+ while true; do
114
+ sleep "$POLL"
115
+
116
+ lc_stopped && { lc_log HEARTBEAT_SKIP "kill switch"; unchanged=0; continue; }
117
+
118
+ # Uses the FIXED lc_pane_alive (it enumerates real pane ids). Before 0.1.4 this returned true for
119
+ # any string, so this loop would have run forever against a pane that no longer existed.
120
+ lc_pane_alive "$PANE" || { lc_log HEARTBEAT_EXIT "pane $PANE gone"; exit 0; }
121
+
122
+ lc_is_armed "$PANE" || { lc_log HEARTBEAT_SKIP "pane $PANE not armed"; unchanged=0; continue; }
123
+
124
+ _stream_check
125
+
126
+ cur="$(_pane_hash "$PANE")"
127
+ [ -n "$cur" ] || { unchanged=0; continue; }
128
+ if [ "$cur" = "$last" ]; then unchanged=$((unchanged+1)); else unchanged=0; last="$cur"; fi
129
+ [ "$unchanged" -ge "$QUIET_CHECKS" ] || continue
130
+
131
+ # ⛔ M291: NEVER TYPE INTO A MENU. The folder-trust dialog's second option is "No, exit"; an Enter
132
+ # there does not deliver the nudge, it can END the session. Checked AFTER the idle window so a
133
+ # quiet pane sitting on a dialog is logged once per window, not on every poll — and nothing is sent.
134
+ if lc_pane_at_menu "$PANE"; then
135
+ lc_log HEARTBEAT_SKIP "target_pane=$PANE shows an interactive menu (e.g. folder trust, where Enter = No, exit); not typing into it"
136
+ unchanged=0; last="$(_pane_hash "$PANE")"
137
+ continue
138
+ fi
139
+
140
+ # ── WAKE NONCE ────────────────────────────────────────────────────────────
141
+ # Every nudge carries an identity. Without one, EVERY heartbeat nudge is
142
+ # BYTE-IDENTICAL to every other heartbeat nudge, which breaks three things
143
+ # that were each reported separately as if they were different bugs:
144
+ # 1. D1 cannot tell nudge #1 from nudge #47, so nudges are invisible to
145
+ # the wake population by construction.
146
+ # 2. Content-identity attribution collapses them onto one another -- the
147
+ # same duplicate-payload hazard found in the D1 batch keys (L3-F2).
148
+ # 3. Byte-identical NUDGE log lines at the same timestamp cannot be
149
+ # resolved into "two panes coinciding" vs "one pane double-firing",
150
+ # so a double-fire is a real bug the log is structurally unable to
151
+ # reveal (argus).
152
+ #
153
+ # RANDOM, not derived -- and this is the ONE place in the design where that
154
+ # is correct. The derived-nonce ruling covers producer events, which have an
155
+ # `event_id` to derive FROM and an equivalence class where a re-delivery is
156
+ # the SAME wake. A nudge has no event and no dedupe key: one EMISSION is one
157
+ # wake (signal-class), so a per-emission identity is the right semantics.
158
+ #
159
+ # 11 base62 chars matches the producer's wake nonce exactly: 10 chars is
160
+ # 59.5 bits (under the >=64-bit floor), 12 breaks the <=11 ceiling.
161
+ _nonce="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 11)"
162
+ if [ ${#_nonce} -ne 11 ]; then
163
+ # DEGRADED GENERATOR -- and it must ANNOUNCE itself. The fallback is
164
+ # `date+cksum`, ~36 bits, BELOW the >=64-bit floor the plan sets. A weak
165
+ # nonce is byte-indistinguishable from a strong one at the point of use,
166
+ # so a silent downgrade is the false-calm shape this whole file exists to
167
+ # remove: collisions would surface far downstream as two wakes that look
168
+ # like one, and nothing would point back here. (assay, review of 225cc0e.)
169
+ _nonce="$(date +%s%N | cksum | tr -dc '0-9' | head -c 11)"
170
+ lc_log HEARTBEAT_NONCE_DEGRADED "urandom unavailable; ~36-bit fallback nonce=$_nonce"
171
+ fi
172
+
173
+ # The pane goes in the MESSAGE BODY, not just the lc_log prefix: that prefix
174
+ # is built from $TMUX_PANE/$CLAUDE_CODE_SESSION_ID, which a systemd unit does
175
+ # not have, so it renders `pane=? sid=?` in production -- 147 such lines on
176
+ # this seat. The one context where the prefix IS populated is a hand-run,
177
+ # which is the one context that never runs in production.
178
+ #
179
+ # ⚠️ IT IS `target_pane=`, NOT `pane=`, AND THAT IS NOT COSMETIC (cadence,
180
+ # caught pre-install). lc_log ALWAYS emits `pane=` in its prefix, so a body
181
+ # field of the same name puts TWO `pane=` on one line:
182
+ # … sid=? pane=? HEARTBEAT_NUDGE pane=%4 nonce=…
183
+ # and `grep -o 'pane=[^ ]*'` returns the PREFIX one -- `pane=?` -- because
184
+ # it comes first. A consumer parsing naively would read "pane unknown" on
185
+ # precisely the lines this change exists to make attributable. That is one
186
+ # label with two meanings on a single line, which is the defect class this
187
+ # fleet has hit repeatedly; a distinct name avoids it without touching the
188
+ # shared lc_log prefix, whose blast radius is every log line we emit.
189
+ lc_log HEARTBEAT_NUDGE "target_pane=$PANE nonce=$_nonce idle ~$((unchanged*POLL))s"
190
+ prompt="Backup heartbeat [wake-nonce: $_nonce]: this pane has been idle. Make sure your wake-capable inbox consumer is armed (a usage-limit outage ends it silently). Re-read your current-state pointer by ID (never by recall) and CONTINUE the active work autonomously to its DONE-WHEN. If your measured context is at or past the self-clear target, run the kijito-qa-memory skill and then self-clear. If there is genuinely no active work left, say so and stop."
191
+ if [ "$st_alerted" = 1 ]; then
192
+ prompt="Backup heartbeat [wake-nonce: $_nonce]: YOUR INBOX IS DEAF - the event stream $st_path has unread wake events and NO consumer is reading it. FIRST re-arm your wake-capable inbox consumer on that file (a usage-limit outage ends it silently), then read your inbox. Then re-read your current-state pointer by ID (never by recall) and CONTINUE the active work to its DONE-WHEN. If your measured context is at or past the self-clear target, run the kijito-qa-memory skill and then self-clear."
193
+ fi
194
+
195
+ # Same paste-buffer discipline as session-autosend: a gap before the Enter, then verify, because
196
+ # an Enter inside the TUI's ingest burst is absorbed as a newline and the nudge would sit unsent —
197
+ # a backup heartbeat that silently fails to fire is worse than none, since it is trusted.
198
+ tmux send-keys -t "$PANE" -l -- "$prompt" 2>/dev/null
199
+ sleep "${KIJITO_SEND_SETTLE:-1.2}"
200
+ probe=$(printf '%s' "$prompt" | tail -c 40)
201
+ for _try in 1 2 3; do
202
+ tmux send-keys -t "$PANE" Enter 2>/dev/null
203
+ sleep 1.5
204
+ tmux capture-pane -p -t "$PANE" 2>/dev/null | tail -6 | grep -qF -- "$probe" || break
205
+ done
206
+
207
+ unchanged=0
208
+ last="$(_pane_hash "$PANE")"
209
+ done