Files
vitya 8205f5d758 fix(session-inbox-monitor): UTF-8 OutputEncoding forward-guard in SessionStart hook (v0.2.2)
Closes session-inbox-monitor-encoding-guard-followup (finding from -review
structural audit item E, CONCERN).

inbox-monitor.ps1 emitted ConvertTo-Json (incl. the interpolated inbox path)
to a redirected pipe under WinPS 5.1 without setting [Console]::OutputEncoding
— the same context that mojibaked stop-dispatcher. ASCII-safe today, but the
inbox path is user-data, so a non-ASCII path/content would mangle the inject.

- Add [Console]::OutputEncoding + $OutputEncoding = UTF8 after the $ProjectDir
  gate (mirror of stop-dispatcher.ps1). Comment text kept pure ASCII.
- Regression under WinPS 5.1: parse 0 errors; ran hook against a Cyrillic-path
  project, read raw stdout bytes as no-BOM UTF-8 -> JSON valid, Cyrillic path
  round-trips intact.
- Re-deployed to ~/.claude/hooks/inbox-monitor.ps1, SHA256 byte-identical.
- SKILL.md Mojibake failure-mode extended; version 0.2.1 -> 0.2.2 (PATCH).

install/hermes/dist not rebuilt — PATCH needs only reload-plugins; the runtime
hook is deployed.

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

95 lines
4.9 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 }
# UTF-8 stdout guard. This hook emits JSON (additionalContext) to a redirected
# pipe under WinPS 5.1 - the same context that mojibaked stop-dispatcher output
# (see session-inbox-monitor-stophook-utf8-fix). $ctx is ASCII today, but the
# inbox path ($inboxFwd) is user-data interpolated into stdout, so set UTF-8 as a
# forward-guard: a non-ASCII path or content never mangles the inject. Idempotent.
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$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