Files
claude-skills/skills/session-inbox-monitor/hooks/inbox-monitor.ps1
vitya c3e1ce7b40 feat(session-inbox-monitor): SessionStart hook + fill SKILL body v0.2.0
Core content task of the session-inbox-monitor line. Two deliverables:

1. SessionStart hook `skills/session-inbox-monitor/hooks/inbox-monitor.ps1`
   (versioned for multi-machine rollout; deployed to ~/.claude/hooks/ and
   registered in ~/.claude/settings.json SessionStart):
   - sweep: Get-CimInstance | Stop-Process orphaned monitors of THIS inbox,
     matched by sentinel CLAUDE_INBOX_MONITOR + inbox path (a /clear leaves
     the poll process alive -> re-raise without sweep stacks duplicates);
   - inject: hookSpecificOutput.additionalContext with the exact persistent
     Monitor command (Monitor tool, not background Bash);
   - opt-in gate: fires only on .claude-inbox/ dir or CLAUDE.md trigger line.
   ASCII-only (em-dash -> mojibake under WinPS 5.1 without BOM, fixed).

2. SKILL.md body filled (When to use / Inputs / Steps / Deployment /
   Failure modes / Side effects / What NOT to do); bump 0.1.0 -> 0.2.0 MINOR.

Headless: no hook-level signal exists (verified via claude-code-guide) ->
agent-side best-effort skip, default errs toward raising (false-skip in
interactive loses the feature; false-raise in headless is a harmless no-op).

Live-verified: inject -> valid JSON; sweep -> killed a planted orphan (PASS);
real Monitor tool spawns a bash process carrying the sentinel (sweep will
find real orphans); settings.json stays valid. Sweep over-match edge and
multi-session-per-project limit documented honestly in Failure modes.

Closes [session-inbox-monitor-sessionstart-hook].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:48:50 +03:00

87 lines
4.4 KiB
PowerShell

# SessionStart inbox-monitor injector hook (session-inbox-monitor skill).
#
# Two jobs, run on every SessionStart (startup / resume / clear / compact):
# (a) SWEEP - kill orphaned inbox-monitor OS processes of THIS project.
# A `/clear` does NOT fire SessionEnd, so a Monitor's underlying
# poll process can outlive the session it belonged to. Without a
# sweep, re-raising would stack duplicates. Match is by a sentinel
# string (CLAUDE_INBOX_MONITOR) baked into the poll command PLUS
# this project's inbox path - so we never touch unrelated processes.
# (b) INJECT - additionalContext telling the agent to raise a persistent
# Monitor (Monitor TOOL, not background Bash) on <project>/.claude-inbox.
#
# Opt-in per project: fires only when the project has a `.claude-inbox/` dir OR a
# CLAUDE.md line `inbox monitor: raise on start`.
#
# Headless (`claude -p`): there is NO reliable hook-level signal to detect it
# (verified 2026-06-17 - `source` and CLAUDE_* env vars don't distinguish it).
# So the hook injects unconditionally and the SKILL instructs the agent to skip
# when headless. A Monitor raised in headless is harmless (killed ~5s after the
# run ends); a false-skip in an interactive session would silently lose the
# feature - so the default errs toward raising.
#
# Twin pattern: poller-interactive-lock-writer (interactive-lock.ps1).
# Machine-local deploy target: ~/.claude/hooks/inbox-monitor.ps1 (registered in
# ~/.claude/settings.json SessionStart). Versioned here for multi-machine rollout.
param(
[string]$ProjectDir = $env:CLAUDE_PROJECT_DIR
)
if (-not $ProjectDir) { exit 0 }
$inbox = Join-Path $ProjectDir '.claude-inbox'
$claudeMd = Join-Path $ProjectDir 'CLAUDE.md'
# --- opt-in gate -----------------------------------------------------------
$optedIn = $false
if (Test-Path $inbox) {
$optedIn = $true
} elseif (Test-Path $claudeMd) {
if (Select-String -Path $claudeMd -SimpleMatch 'inbox monitor: raise on start' -Quiet -ErrorAction SilentlyContinue) {
$optedIn = $true
}
}
if (-not $optedIn) { exit 0 }
# Forward-slash inbox path: the Monitor poll command (Git Bash) uses this form,
# so both the sweep match and the injected command share one literal.
$inboxFwd = ($inbox -replace '\\', '/')
# --- (a) sweep orphaned monitors of THIS inbox -----------------------------
# Match = sentinel AND this inbox's path in the same process command line.
try {
Get-CimInstance Win32_Process -ErrorAction Stop |
Where-Object {
$_.CommandLine -and
$_.CommandLine -match 'CLAUDE_INBOX_MONITOR' -and
$_.CommandLine -like "*$inboxFwd*"
} |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
} catch { }
# --- (b) build the canonical Monitor poll command --------------------------
# `: CLAUDE_INBOX_MONITOR` is a bash no-op carrying the sweep sentinel in the
# process command line without polluting the event stream. De-dups by filename
# so a sitting message pages once, not every 15s (a noisy monitor is auto-stopped).
$cmd = @'
: CLAUDE_INBOX_MONITOR; d='__INBOX__'; s=' '; while true; do for f in "$d"/*.md; do [ -e "$f" ] || continue; n=$(basename "$f"); case "$s" in *" $n "*) continue;; esac; s="$s$n "; echo "New inter-session message in inbox: $n - read .claude-inbox/ and handle it now"; done; sleep 15; done
'@
$cmd = $cmd.Trim().Replace('__INBOX__', $inboxFwd)
# --- (c) inject the raise-instruction --------------------------------------
$ctx = @"
[session-inbox-monitor] This project participates in inter-session messaging. As your FIRST action, raise a persistent inbox watcher so messages from other sessions page you in real time.
Use the Monitor tool with persistent: true, description "inbox watcher", and this EXACT command:
$cmd
Do NOT use a background Bash for this - it leaks across /clear. The Monitor tool is session-bound and tears down on its own at session end. The paired SessionStart hook already swept any orphaned watcher before this, so raise exactly one.
If you are running headless (claude -p / non-interactive), SKIP this - the Stop-hook inbox pickup plus Notify cover delivery there. See the session-inbox-monitor skill for the full contract.
"@
@{ hookSpecificOutput = @{ hookEventName = 'SessionStart'; additionalContext = $ctx } } | ConvertTo-Json -Compress -Depth 5
exit 0