← Files NightshiftARCHIVED FILE
hooks/pulse.sh
17.4 KB · Oct 3, 2026 · 06:31 UTC
#!/usr/bin/env bash
# pulse.sh — shared overwrite-only writer for .nightshift/run/.shift-pulse.
#
# Host wrappers parse stdin, then call ns_pulse_emit with the bound session id.
# One line: epoch<space>session-id. Identity check, not the lease: helpers never
# write; Cursor origin IDE stops writing once .shift-worker exists.
# Inert outside an active shift (same as session-end). Silent stdout.
#
# When executed (Claude PostToolUse), parse session_id from stdin and CLAUDE_PROJECT_DIR.
ns_pulse_owner_ok() { # <ns> <sid>
local rec worker
[ -n "${2:-}" ] || return 1
worker="$(ns_cursor_worker_id "$1")"
if [ -n "$worker" ]; then
[ "$2" = "$worker" ]
return
fi
rec="$(ns_session_line "$1" 1)"
[ -n "$rec" ] && [ "$2" = "$rec" ]
}
ns_pulse_emit() { # <ns> <sid>
local ns="$1" sid="$2" punch armed ended pulse epoch open
[ -n "$ns" ] && [ -n "$sid" ] || return 0
ns_layout_set punch "$ns" punch-list
ns_layout_set armed "$ns" armed
ns_layout_set ended "$ns" ended
if [ ! -f "$armed" ] || [ ! -f "$punch" ] \
|| { [ -f "$ended" ] && [ ! -L "$ended" ]; }; then
return 0
fi
# A failed count is not zero. The session is alive either way, so the pulse stands.
open="$(ns_open_boxes "$punch")" || open=1
[ "$open" -gt 0 ] || return 0
ns_pulse_owner_ok "$ns" "$sid" || return 0
epoch="$(date +%s)"
ns_layout_set pulse "$ns" pulse
[ -L "$pulse" ] && rm -f "$pulse"
printf '%s %s\n' "$epoch" "$sid" >"$pulse"
return 0
}
# ns_pulse_usage <ns> <host> <sid> <transcript-or-payload> — take one reading, if the owner wants
# usage measured and this session owns the shift.
#
# The pulse already fires on every tool call on all three hosts, so the reading rides on something
# that was going to happen anyway: no daemon, no timer, no polling, no second session. On Claude it
# advances the transcript offset and reads only the appended bytes; on Codex it tails the rollout's
# running total; on Cursor there is no transcript and the figures arrive on the payload itself.
#
# Silent, and never fatal: a host that reports nothing leaves no snapshot, and the report says
# `unavailable` rather than zero.
ns_pulse_usage() {
local ns="$1" host="$2" sid="$3" src="$4" reading fields offset model armed
[ -n "$ns" ] && [ -n "$src" ] || return 0
ns_layout_set armed "$ns" armed
[ -f "$armed" ] || return 0
ns_pulse_owner_ok "$ns" "$sid" || return 0
ns_report_enabled "${ns%/.nightshift}" || return 0
[ "$(ns_report "${ns%/.nightshift}" usage)" != off ] || return 0
# The shift's own start, stood up before the first reading so it sits at zero. A baseline taken
# after spend had already accrued would swallow the first item's cost. The transcripts go with
# it: whatever the setting-up conversation already wrote is where reading begins, not byte zero.
case "$host" in
claude)
# shellcheck disable=SC2046 # each subagent path is its own argument
ns_usage_mark_arm "$ns" "$src" $(ns_usage_subagents "$src" 2>/dev/null) || return 0
;;
*) ns_usage_mark_arm "$ns" || return 0 ;;
esac
case "$host" in
claude)
offset="$(ns_usage_offset "$ns" "$src")"
reading="$(ns_usage_read_claude "$src" "$offset" "$(ns_usage_carry "$ns" "$src")")" || return 0
ns_usage_record "$ns" claude "$(printf '%s' "$reading" | cut -f3)" transcript-incremental \
"$src" "$(printf '%s' "$reading" | cut -f2)" "$(printf '%s' "$reading" | cut -f1)" \
"$(printf '%s' "$reading" | cut -f5)" || return 0
# A Task-spawned agent writes its own transcript beside this one, and its usage is there
# rather than in the parent. Each is its own segment, so a child that replays history it did
# not spend cannot inflate the shift.
ns_usage_subagents "$src" 2>/dev/null | while IFS= read -r agent; do
[ -n "$agent" ] || continue
reading="$(ns_usage_read_claude "$agent" "$(ns_usage_offset "$ns" "$agent")" "$(ns_usage_carry "$ns" "$agent")")" || continue
ns_usage_record "$ns" claude "$(printf '%s' "$reading" | cut -f3)" transcript-incremental \
"$agent" "$(printf '%s' "$reading" | cut -f2)" "$(printf '%s' "$reading" | cut -f1)" \
"$(printf '%s' "$reading" | cut -f5)" || continue
done
;;
codex)
reading="$(ns_usage_read_codex "$src")" || return 0
ns_usage_record "$ns" codex "$(printf '%s' "$reading" | cut -f3)" rollout \
"$src" 0 "$(printf '%s' "$reading" | cut -f1)" || return 0
;;
cursor)
reading="$(ns_usage_read_cursor "$src")" || return 0
ns_usage_record "$ns" cursor "$(printf '%s' "$reading" | cut -f3)" stop-payload \
"cursor:$sid" 0 "$(printf '%s' "$reading" | cut -f1)" || return 0
;;
*) return 0 ;;
esac
return 0
}
# ns_usage_carry <nightshift-dir> <id> — the last response identity counted for this transcript.
#
# Handed back to the reader so a response whose lines straddle two reads is counted once. Empty for
# a transcript never read, which is right: there is no half-read response to skip.
ns_usage_carry() {
local file line
file="$(ns_usage_dir "$1")/segments.tsv"
[ -f "$file" ] || return 0
while IFS= read -r line; do
case "$line" in
"$2 "*)
printf '%s' "$line" | cut -f8
return 0
;;
esac
done <"$file"
return 0
}
# ns_usage_offset <ns> <transcript> — the byte offset this transcript was last read to.
ns_usage_offset() {
local file line
file="$(ns_usage_dir "$1")/segments.tsv"
[ -f "$file" ] || { printf '0'; return 0; }
while IFS= read -r line; do
case "$line" in
"$2 "*)
printf '%s' "$line" | cut -f5
return 0
;;
esac
done <"$file"
printf '0'
}
# ns_pulse_receipts_enabled <project> — status 0 unless the owner turned receipts off.
ns_pulse_receipts_enabled() {
[ "$(ns_receipts "$1" enabled)" != false ]
}
# ns_pulse_receipts_basename <project> <label> — the file stem the notice names.
ns_pulse_receipts_basename() {
ns_receipt_base "$1" "$2"
}
# ns_pulse_receipts_sections <project> — the approach clause on an item-start notice.
ns_pulse_receipts_sections() {
local path
path="$(ns_receipts "$1" templatePath 2>/dev/null)" || path=""
if [ -n "$path" ]; then
printf 'follow the owner'\''s template at %s' "$path"
return 0
fi
printf 'sections: What was delivered · Why · Tried and rejected · Verification · Outputs · Parked decisions and snags.'
}
# ns_pulse_receipts_start_line <project> <label>
ns_pulse_receipts_start_line() {
printf 'receipts: item %s started — open %s/%s.md with one paragraph on the approach; %s' \
"$2" "$(ns_layout_name "$1/.nightshift" receipts)" "$(ns_pulse_receipts_basename "$1" "$2")" \
"$(ns_pulse_receipts_sections "$1")"
}
# ns_pulse_receipts_tick_line <project> <label>
ns_pulse_receipts_tick_line() {
printf 'receipts: item %s is ticked — write its closing paragraph in %s/%s.md now, before starting the next item.' \
"$2" "$(ns_layout_name "$1/.nightshift" receipts)" "$(ns_pulse_receipts_basename "$1" "$2")"
}
# ns_pulse_receipts_cadence_line <project> <label>
ns_pulse_receipts_cadence_line() {
printf 'receipts: progress update due for %s — refresh the progress paragraph in %s/%s.md: where it stands, what is left.' \
"$2" "$(ns_layout_name "$1/.nightshift" receipts)" "$(ns_pulse_receipts_basename "$1" "$2")"
}
# ns_pulse_ticked_labels <project> — every ticked item label, punch-list order, one per line.
ns_pulse_ticked_labels() {
local punch
ns_layout_set punch "$1/.nightshift" punch-list
[ -f "$punch" ] || return 0
ns_item_rows "$punch" ticked | cut -f1
}
# Previous-pulse facts live under usage/, never in the punch list.
ns_pulse_previous_file() { printf '%s/previous-pulse' "$(ns_usage_dir "$1")"; }
ns_pulse_previous_ticked_file() { printf '%s/previous-ticked' "$(ns_usage_dir "$1")"; }
ns_pulse_previous_get() { # <ns> <key>
local file line
file="$(ns_pulse_previous_file "$1")"
[ -f "$file" ] && [ ! -L "$file" ] || return 1
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"$2 "*)
printf '%s' "${line#*$'\t'}"
return 0
;;
esac
done <"$file"
return 1
}
ns_pulse_previous_write() { # <ns> <active> <ticked>
local dir file
dir="$(ns_usage_dir "$1")"
mkdir -p "$dir" 2>/dev/null || return 0
[ -L "$dir" ] && return 0
file="$(ns_pulse_previous_file "$1")"
[ -L "$file" ] && rm -f "$file"
printf 'active\t%s\nticked\t%s\n' "$2" "$3" >"$file" 2>/dev/null || :
}
# ns_pulse_report_due <ns> <project> — the cadence line, or nothing.
#
# The notice is written to a marker before it is emitted, and cleared when the item's receipt
# file changes or the item is ticked. A marker that names a different item than the one now
# open is dropped. A revived session still finds the notice; it stands until the receipt changes,
# so a long pause is one overdue notice rather than one per minute that passed.
ns_pulse_report_due() {
local ns="$1" project="$2" label due want armed marker
ns_layout_set armed "$ns" armed
ns_layout_set marker "$ns" receipt-due
[ -f "$armed" ] || return 1
ns_pulse_receipts_enabled "$project" || return 1
label="$(ns_pulse_active_item "$project")" || return 1
[ -n "$label" ] || return 1
want="$(ns_pulse_receipts_cadence_line "$project" "$label")"
if [ -f "$marker" ] && [ ! -L "$marker" ]; then
due="$(cat "$marker" 2>/dev/null)" || due=""
case "$due" in
*"for ${label} —"*|*"for ${label}")
# Refreshing the receipt is what answers the notice. The window notices the change and
# drops the marker, so a refreshed receipt is not reminded again on the next call.
ns_usage_window "$ns" "$label" "$(ns_receipt_path "$project" "$label")" >/dev/null || :
if [ -f "$marker" ]; then
printf '%s' "$due"
return 0
fi
;;
*)
# The marker names an item that is no longer the open one; it answers nothing now.
rm -f "$marker" 2>/dev/null || :
;;
esac
fi
ns_usage_progress_due "$project" "$label" || return 1
printf '%s' "$want" >"$marker" 2>/dev/null || return 1
printf '%s' "$want"
}
# ns_pulse_receipts_notice <ns> <project> — start, tick, and cadence lines for this pulse.
#
# Previous-pulse facts are read and rewritten here. Each line is injected once for a change;
# identical cadence text is not re-emitted after the item it names has been ticked.
ns_pulse_receipts_notice() {
local ns="$1" project="$2" prev_active prev_ticked active ticked line first=1 due
local labels_file usage now_file armed
ns_layout_set armed "$ns" armed
[ -f "$armed" ] || return 1
ns_pulse_receipts_enabled "$project" || return 1
prev_active="$(ns_pulse_previous_get "$ns" active 2>/dev/null)" || prev_active=""
prev_ticked="$(ns_pulse_previous_get "$ns" ticked 2>/dev/null)" || prev_ticked="0"
case "$prev_ticked" in '' | *[!0-9]*) prev_ticked=0 ;; esac
active="$(ns_pulse_active_item "$project" 2>/dev/null)" || active=""
ticked="$(ns_ticked_boxes "$(ns_layout_path "$ns" punch-list)" 2>/dev/null)" || ticked=0
case "$ticked" in '' | *[!0-9]*) ticked=0 ;; esac
labels_file="$(ns_pulse_previous_ticked_file "$ns")"
ns_layout_set usage "$ns" usage
now_file="$usage/.ticked-now"
mkdir -p "$usage" 2>/dev/null || :
: >"$now_file"
ns_pulse_ticked_labels "$project" >"$now_file" 2>/dev/null || :
if [ "$ticked" -gt "$prev_ticked" ]; then
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
if [ -f "$labels_file" ] && [ ! -L "$labels_file" ]; then
grep -Fqx -- "$line" "$labels_file" 2>/dev/null && continue
fi
if [ "$first" -eq 1 ]; then
printf '%s' "$(ns_pulse_receipts_tick_line "$project" "$line")"
first=0
else
printf '\n%s' "$(ns_pulse_receipts_tick_line "$project" "$line")"
fi
done <"$now_file"
fi
if [ -n "$active" ] && [ "$active" != "$prev_active" ]; then
# An item carried from an earlier shift may have been renumbered or retitled since; its receipt
# says so before the model opens it.
ns_receipt_track_label "$(ns_receipt_path "$project" "$active")" "$active" || :
if [ "$first" -eq 1 ]; then
printf '%s' "$(ns_pulse_receipts_start_line "$project" "$active")"
first=0
else
printf '\n%s' "$(ns_pulse_receipts_start_line "$project" "$active")"
fi
fi
due="$(ns_pulse_report_due "$ns" "$project" 2>/dev/null)" || due=""
if [ -n "$due" ]; then
if [ "$first" -eq 1 ]; then
printf '%s' "$due"
first=0
else
printf '\n%s' "$due"
fi
fi
ns_pulse_previous_write "$ns" "$active" "$ticked"
if [ -L "$labels_file" ]; then
rm -f "$labels_file"
fi
if [ -d "$usage" ] && [ ! -L "$usage" ]; then
mv "$now_file" "$labels_file" 2>/dev/null || :
fi
[ "$first" -eq 0 ]
}
# ns_pulse_active_item <project> — the item being worked; see ns_active_item.
ns_pulse_active_item() { ns_active_item "$1"; }
# ns_pulse_context <host> <line> — the notice in the field each host documents for model-visible
# context. Claude Code and Codex read hookSpecificOutput.additionalContext; Cursor reads
# additional_context. Silent when there is nothing to say, so an ordinary pulse stays silent.
ns_pulse_context() {
local host="$1" line="$2" escaped
[ -n "$line" ] || return 0
if command -v jq >/dev/null 2>&1; then
case "$host" in
cursor) jq -nc --arg c "$line" '{additional_context:$c}' ;;
*) jq -nc --arg c "$line" '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$c}}' ;;
esac
return 0
fi
escaped="$(printf '%s' "$line" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g')"
case "$host" in
cursor) printf '{"additional_context":"%s"}\n' "$escaped" ;;
*) printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$escaped" ;;
esac
}
# ns_pulse_marks <ns> <project> — mark every item ticked since the last mark, at this moment.
#
# The gate marks on a stop attempt, so two items ticked between stops both get the reading taken at
# the stop: the first is billed everything since the previous mark and the second nothing. The pulse
# fires on the PostToolUse of the edit that ticked the box, so a mark taken here carries the reading
# at the moment the work finished.
#
# It calls the gate's own sync rather than a parallel loop. One code path writes the marks and the
# report lines, whichever side gets there first, and the gate stays as the catch-up for a pulse that
# never fired.
ns_pulse_marks() { # <ns> <project> <sid> [transcript]
local ns="$1" project="$2" sid="$3" src="${4:-}" punch ticked core
[ -d "$ns" ] || return 0
# The same three conditions the reading itself needs: an armed shift, owned by this session, with
# the report on. Anything else is a to-do list in a folder, and it is not billed.
[ -f "$(ns_layout_path "$ns" armed)" ] || return 0
ns_pulse_owner_ok "$ns" "$sid" || return 0
ns_report_enabled "$project" || return 0
ns_layout_set punch "$ns" punch-list
[ -f "$punch" ] || return 0
# This file's own directory, never the caller's. The Codex and Cursor pulses source this file and
# set `_here` to their own folder, which has no `shared/` in it.
core="${BASH_SOURCE[0]%/*}"
[ "$core" != "${BASH_SOURCE[0]}" ] || core=.
core="$core/shared/gate-core.sh"
if ! command -v ns_gate_usage_sync >/dev/null 2>&1; then
[ -f "$core" ] || return 0
# shellcheck source=plugins/nightshift/hooks/shared/gate-core.sh
. "$core" || return 0
fi
ticked="$(ns_ticked_boxes "$punch" 2>/dev/null)" || return 0
case "$ticked" in '' | *[!0-9]*) return 0 ;; esac
ns_gate_usage_sync "$ns" "$project" "$punch" "$ticked" "$src" || :
# Then follow the item being worked, so a stretch spent on one item is not charged to another.
ns_gate_usage_switch "$ns" "$project" "$(ns_pulse_active_item "$project")"
}
# Executed as the Claude wrapper: parse stdin, emit, stay silent.
#
# This block is last on purpose. Bash defines a function when it reaches the definition, so a
# block placed above them runs with those names undefined: the calls below would write
# "command not found" to stderr and the hook would still exit 0, recording nothing. Every test
# that sources this file and calls its functions passes either way.
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -u
_here="${BASH_SOURCE[0]%/*}"; [ "$_here" != "${BASH_SOURCE[0]}" ] || _here=.
# shellcheck source=plugins/nightshift/hooks/shared/idle.sh
. "$_here/shared/idle.sh"
# shellcheck source=plugins/nightshift/lib/lib.sh
. "$_here/../lib/lib.sh"
ns_hook_idle_exit
INPUT="$(ns_read_stdin_bounded 2)"
HOST_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
PROJECT_DIR="$(ns_workspace_root "$HOST_DIR" 2>/dev/null)" || exit 0
STATE_KIND="$(ns_state_kind "$PROJECT_DIR")"
case "$STATE_KIND" in
malformed | future) exit 0 ;;
esac
NS="$PROJECT_DIR/.nightshift"
if command -v jq >/dev/null 2>&1; then
SID="$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || true)"
else
SID="$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
fi
if command -v jq >/dev/null 2>&1; then
TPATH="$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
else
TPATH="$(printf '%s' "$INPUT" | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
fi
ns_pulse_emit "$NS" "$SID"
ns_pulse_usage "$NS" claude "$SID" "$TPATH"
ns_pulse_marks "$NS" "$PROJECT_DIR" "$SID" "$TPATH"
if ns_pulse_owner_ok "$NS" "$SID"; then
ns_pulse_context claude "$(ns_pulse_receipts_notice "$NS" "$PROJECT_DIR")"
fi
exit 0
fi
SHA-256: 30ff32bc5fdfaaa0351f29eac41ad26872bb919d899f880f49f7bf02b00a127c