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.
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +220 -0
- package/bin/cli.js +34 -0
- package/install.sh +161 -0
- package/package.json +50 -0
- package/providers/_shared/wake-core.mjs +358 -0
- package/providers/claude/CLAUDE.md.snippet +31 -0
- package/providers/claude/install.sh +186 -0
- package/providers/claude/scripts/arm-session.sh +66 -0
- package/providers/claude/scripts/claude-armed.sh +101 -0
- package/providers/claude/scripts/heartbeat-watchdog.sh +209 -0
- package/providers/claude/scripts/inbox-selftest.sh +277 -0
- package/providers/claude/scripts/kijito-persona-lib.sh +94 -0
- package/providers/claude/scripts/kijito-qa-pass.sh +12 -0
- package/providers/claude/scripts/lifecycle-lib.sh +167 -0
- package/providers/claude/scripts/myctx.sh +46 -0
- package/providers/claude/scripts/self-clear.sh +134 -0
- package/providers/claude/scripts/session-autosend.sh +85 -0
- package/providers/claude/scripts/session-catchup-hint.sh +330 -0
- package/providers/claude/scripts/statusline-context.sh +67 -0
- package/providers/claude/skills/kijito-qa-memory/SKILL.md +110 -0
- package/providers/claude/skills/kijito-recall/SKILL.md +53 -0
- package/providers/claude/skills/kijito-start/SKILL.md +122 -0
- package/providers/claude/wiring/README.md +93 -0
- package/providers/claude/wiring/kijito-heartbeat@.service +21 -0
- package/providers/codex/README.md +75 -0
- package/providers/codex/codex-kijito-parity-plan.md +265 -0
- package/providers/codex/docs-codex-setup.md +64 -0
- package/providers/codex/install.mjs +184 -0
- package/providers/codex/n0-capability-probe-protocol.md +472 -0
- package/providers/codex/n0-harness/README.md +57 -0
- package/providers/codex/n0-harness/cli.mjs +58 -0
- package/providers/codex/n0-harness/evidence-manifest.mjs +77 -0
- package/providers/codex/n0-harness/fixture.mjs +279 -0
- package/providers/codex/n0-harness/lib.mjs +141 -0
- package/providers/codex/n0-harness/manifest.mjs +36 -0
- package/providers/codex/n0-harness/oracle.mjs +464 -0
- package/providers/codex/n0-harness/parser.mjs +200 -0
- package/providers/codex/n0-harness/prompt.mjs +43 -0
- package/providers/codex/n0-harness/snapshot.mjs +120 -0
- package/providers/codex/n0-harness/specimen.mjs +37 -0
- package/providers/codex/notify/kijito-notify-count.mjs +117 -0
- package/providers/codex/notify/kijito-notify-count.test.mjs +76 -0
- package/providers/codex/plans/gate4-battery-log.md +184 -0
- package/providers/codex/plans/gate4-battery-protocol.md +43 -0
- package/providers/codex/plans/hive-user-first-plan-QA.md +34 -0
- package/providers/codex/plans/hive-user-first-plan.md +247 -0
- package/providers/codex/release-manifest.json +53 -0
- package/providers/codex/same-chat-continuation-plan-gate.md +235 -0
- package/providers/codex/same-chat-continuation-plan.md +458 -0
- package/providers/codex/skills/kijito-qa-memory/SKILL.md +193 -0
- package/providers/codex/skills/kijito-qa-memory/agents/openai.yaml +4 -0
- package/providers/codex/skills/kijito-recall/SKILL.md +53 -0
- package/providers/codex/skills/kijito-start/SKILL.md +173 -0
- package/providers/codex/skills/kijito-start/agents/openai.yaml +4 -0
- package/providers/codex/test/n0-cli-refusal.test.mjs +31 -0
- package/providers/codex/test/n0-counterexample-matrix.test.mjs +118 -0
- package/providers/codex/test/n0-guard-census-core.mjs +1372 -0
- package/providers/codex/test/n0-guard-census.json +35479 -0
- package/providers/codex/test/n0-guard-census.mjs +31 -0
- package/providers/codex/test/n0-guard-census.test.mjs +374 -0
- package/providers/codex/test/n0-guard-counterexamples.mjs +1068 -0
- package/providers/codex/test/n0-guard-independent-projection.mjs +70 -0
- package/providers/codex/test/n0-guard-independent-projection.test.mjs +53 -0
- package/providers/codex/test/n0-guard-manifest-author.mjs +424 -0
- package/providers/codex/test/n0-guard-mutation-operators.test.mjs +244 -0
- package/providers/codex/test/n0-guard-mutation-runner.mjs +599 -0
- package/providers/codex/test/n0-guard-remainder-classes.test.mjs +314 -0
- package/providers/codex/test/n0-guard-sanitizer-pairs.test.mjs +68 -0
- package/providers/codex/test/n0-guard-shared-context-predicates.test.mjs +84 -0
- package/providers/codex/test/n0-harness.test.mjs +553 -0
- package/providers/codex/test/n0-notable-regressions.test.mjs +70 -0
- package/providers/codex/test/n0-subject-boundary.mjs +97 -0
- package/providers/codex/test/n0-subject-boundary.test.mjs +72 -0
- package/providers/codex/test/same-chat-plan-preflight.sh +167 -0
- package/providers/codex/tools/refresh-manifest.mjs +81 -0
- package/providers/codex/wake-helper/TRANSPORT-NOTES.md +25 -0
- package/providers/codex/wake-helper/integration.test.mjs +285 -0
- package/providers/codex/wake-helper/kijito-wake-helper.mjs +488 -0
- package/providers/codex/wake-helper/kijito-wake-helper.test.mjs +128 -0
- package/providers/codex/wake-helper/mock-daemon.mjs +64 -0
- package/providers/codex/wake-helper/status-probe.mjs +45 -0
- package/providers/codex/wake-helper/ws-uds.mjs +153 -0
- package/providers/monitor/.github/workflows/publish-npm.yml +38 -0
- package/providers/monitor/.github/workflows/publish-pypi.yml +30 -0
- package/providers/monitor/CHANGELOG.md +465 -0
- package/providers/monitor/IMPORT-PROVENANCE.md +68 -0
- package/providers/monitor/LICENSE +202 -0
- package/providers/monitor/NOTICE +7 -0
- package/providers/monitor/OPAQUE-OUTPUT-ENFORCEMENT.md +45 -0
- package/providers/monitor/README.md +571 -0
- package/providers/monitor/RELEASING.md +125 -0
- package/providers/monitor/arm-hive-monitor.sh +13 -0
- package/providers/monitor/bin/cli.js +43 -0
- package/providers/monitor/com.kijito.inbox-monitor.plist.template +59 -0
- package/providers/monitor/docs/DESIGN.md +984 -0
- package/providers/monitor/kijito-inbox-monitor@.service.template +95 -0
- package/providers/monitor/kijito_inbox_monitor.py +3666 -0
- package/providers/monitor/package.json +35 -0
- package/providers/monitor/pyproject.toml +42 -0
- package/providers/monitor/scripts/mutation-check.py +399 -0
- package/providers/monitor/scripts/prepublish-gate.sh +284 -0
- 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
|