diff --git a/bin/kei b/bin/kei index 1169f30..2d6c6bc 100755 --- a/bin/kei +++ b/bin/kei @@ -8,6 +8,7 @@ # kei # splash → claude (interactive REPL) # kei --no-splash # skip splash → exec claude # kei --status # status only, don't launch claude +# kei message ... # inter-session mailbox (send/inbox/list) — see kei-message.sh # kei [args...] # splash → claude args... (forwarded verbatim) # # The splash shows: substrate version, agent count, last sleep run, @@ -17,6 +18,15 @@ set -e +# --- subcommand dispatch (before splash) --------------------------------- +# `kei message ...` → the mailbox CLI; everything else falls through to launch. +case "${1:-}" in + message|msg|m) + shift + exec "$HOME/.claude/scripts/kei-message.sh" "$@" + ;; +esac + # --- args ---------------------------------------------------------------- SPLASH=1 STATUS_ONLY=0 diff --git a/hooks/mailbox-inject.sh b/hooks/mailbox-inject.sh new file mode 100755 index 0000000..b5ca36d --- /dev/null +++ b/hooks/mailbox-inject.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# mailbox-inject — pull-inbox for kei-message. On every UserPromptSubmit, inject +# any messages addressed to THIS session (by cwd-basename or the broadcast +# channel "all") that arrived since last turn, into the session context, so +# Claude sees what other sessions sent. Per-session read cursor dedups; first +# turn starts fresh (no history dump). Never blocks (always exit 0). +# Event: UserPromptSubmit. Bypass: KEI_MAILBOX_BYPASS=1. + +[ "${KEI_MAILBOX_BYPASS:-}" = "1" ] && exit 0 +command -v jq >/dev/null 2>&1 || exit 0 + +INPUT=$(cat) +SID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) +CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) +[ -n "$CWD" ] || CWD="$PWD" +me="$(basename "$CWD")" + +MBOX="$HOME/.claude/mailbox" +LOG="$MBOX/messages.jsonl" +mkdir -p "$MBOX" +CUR="$MBOX/.cursor-${SID:-$me}" + +# Highest id currently in the bus (0 if the log doesn't exist yet / is empty). +if [ -f "$LOG" ]; then + maxid=$(jq -s 'map(.id) | max // 0' "$LOG" 2>/dev/null || echo 0) +else + maxid=0 +fi +[ -n "$maxid" ] || maxid=0 + +# First fire for this session: record baseline cursor, show nothing. Done even +# when the bus is still empty — so messages that arrive AFTER this point (but +# before the session's next turn) are not missed. +if [ ! -f "$CUR" ]; then + echo "$maxid" > "$CUR" + exit 0 +fi + +# Nothing to read yet. +[ -f "$LOG" ] || { echo "$maxid" > "$CUR"; exit 0; } + +last=$(cat "$CUR" 2>/dev/null || echo 0) +case "$last" in ''|*[!0-9]*) last=0 ;; esac + +new=$(jq -r --argjson last "$last" --arg me "$me" ' + select(.id > $last) + | select(.to == $me or .to == "all") + | select(.from != $me) + | " • \(.from) -> \(.to): \(.body)"' "$LOG" 2>/dev/null) + +# Advance cursor past everything seen this turn. +echo "$maxid" > "$CUR" + +if [ -n "$new" ]; then + printf '[kei mailbox] new message(s) for this session (%s):\n%s\n (reply: kei message send --to "...")\n' "$me" "$new" +fi +exit 0 diff --git a/install/lib-scaffold.sh b/install/lib-scaffold.sh index 0903c86..272d97d 100644 --- a/install/lib-scaffold.sh +++ b/install/lib-scaffold.sh @@ -66,20 +66,19 @@ copy_sleep_scripts() { fi } -# KeiSei tamagotchi statusline — copy the renderer + state updater into -# ~/.claude/scripts/. Zero binary deps (pure bash, state under ~/.claude/pet/), -# always available regardless of profile. The statusLine + pet-update hooks -# are wired into settings.json by the settings-snippet merge (lib-hooks.sh). +# Pure-bash scripts → ~/.claude/scripts/ (tamagotchi renderer + state updater, +# kei-message mailbox CLI, any future scripts). Zero binary deps, always +# available regardless of profile. statusLine + pet-update + mailbox-inject +# hooks are wired into settings.json by the settings-snippet merge (lib-hooks.sh). copy_pet_scripts() { - local pet_sh src dst="$HOME_DIR/.claude/scripts" + local src dst="$HOME_DIR/.claude/scripts" name [ -d "$KIT_DIR/scripts" ] || return 0 mkdir -p "$dst" - for pet_sh in keisei-pet.sh keisei-pet-update.sh; do - src="$KIT_DIR/scripts/$pet_sh" - if [ -f "$src" ]; then - cp -f "$src" "$dst/$pet_sh" - chmod +x "$dst/$pet_sh" - fi + for src in "$KIT_DIR/scripts/"*.sh; do + [ -f "$src" ] || continue + name="$(basename "$src")" + cp -f "$src" "$dst/$name" + chmod +x "$dst/$name" done } diff --git a/scripts/kei-message.sh b/scripts/kei-message.sh new file mode 100755 index 0000000..4e56c5a --- /dev/null +++ b/scripts/kei-message.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# kei-message — minimal persistent mailbox so ANY Claude Code session can message +# ANY other (not just Agent-Teams teammates). Append-only jsonl bus; the +# mailbox-inject.sh UserPromptSubmit hook pulls unread into each session's +# context per turn. Identity = basename of the session's cwd (or --from/--to a +# name), plus the broadcast channel "all". +# +# kei message send [--to ] [--from ] +# kei message inbox # messages addressed to me (cwd) or all +# kei message list # whole bus (recent) +# kei message channels # known recipient names +# +# Store: ~/.claude/mailbox/messages.jsonl (one JSON object per line) + +set -eu +command -v jq >/dev/null 2>&1 || { echo "kei message: jq required" >&2; exit 1; } + +MBOX="$HOME/.claude/mailbox" +LOG="$MBOX/messages.jsonl" +mkdir -p "$MBOX" +[ -f "$LOG" ] || : > "$LOG" + +me="$(basename "$PWD")" +cmd="${1:-inbox}" +[ $# -gt 0 ] && shift || true + +case "$cmd" in + send) + to="all"; body="" + while [ $# -gt 0 ]; do + case "$1" in + --to) to="$2"; shift; shift ;; + --from) me="$2"; shift; shift ;; + --) shift; body="$body $*"; break ;; + *) body="$body $1"; shift ;; + esac + done + body="${body# }" + [ -n "$body" ] || { echo "usage: kei message send [--to ] " >&2; exit 1; } + id="$(date +%s)$(date +%N 2>/dev/null | cut -c1-6 || printf '000000')" + jq -cn --argjson id "$id" --arg ts "$(date -u +%FT%TZ)" \ + --arg from "$me" --arg to "$to" --arg body "$body" \ + '{id:$id, ts:$ts, from:$from, to:$to, body:$body}' >> "$LOG" + echo "-> sent to '$to' (from '$me')" + ;; + inbox|read) + while [ $# -gt 0 ]; do case "$1" in --me) me="$2"; shift; shift ;; *) shift ;; esac; done + jq -r --arg me "$me" ' + select(.to==$me or .to=="all") + | "[\(.ts|sub("T";" ")|sub("Z";""))] \(.from) -> \(.to): \(.body)"' "$LOG" | tail -20 + ;; + list|all) + jq -r '"[\(.ts|sub("T";" ")|sub("Z";""))] \(.from) -> \(.to): \(.body)"' "$LOG" | tail -40 + ;; + channels|names|who) + jq -r '.to, .from' "$LOG" 2>/dev/null | sort -u | grep -v '^$' || true + ;; + *) + echo "kei message: send [--to ] | inbox | list | channels" >&2 + exit 1 + ;; +esac diff --git a/settings-snippet.json b/settings-snippet.json index 16d55ab..457632e 100644 --- a/settings-snippet.json +++ b/settings-snippet.json @@ -241,6 +241,11 @@ { "type": "command", "command": "~/.claude/scripts/keisei-pet-update.sh prompt" + }, + { + "type": "command", + "command": "~/.claude/hooks/mailbox-inject.sh", + "statusMessage": "kei mailbox pull-inbox..." } ] }