@onlooker-community/ecosystem 0.3.3 → 0.4.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.
@@ -112,7 +112,6 @@ jobs:
112
112
  cache: npm
113
113
 
114
114
  - run: npm ci
115
- - run: npm run build
116
115
 
117
116
  - name: Publish
118
117
  run: |
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.3.3"
2
+ ".": "0.4.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/onlooker-community/ecosystem/compare/v0.3.3...v0.4.0) (2026-05-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * **hooks:** add SessionStart and SessionEnd session trackers ([#10](https://github.com/onlooker-community/ecosystem/issues/10)) ([a48d680](https://github.com/onlooker-community/ecosystem/commit/a48d680dd24c98e79ef1c0401b07483ecebf9e8b))
9
+
3
10
  ## [0.3.3](https://github.com/onlooker-community/ecosystem/compare/v0.3.2...v0.3.3) (2026-05-22)
4
11
 
5
12
 
package/hooks/hooks.json CHANGED
@@ -61,6 +61,28 @@
61
61
  }
62
62
  ]
63
63
  }
64
+ ],
65
+ "SessionStart": [
66
+ {
67
+ "matcher": "*",
68
+ "hooks": [
69
+ {
70
+ "type": "command",
71
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/session-start-tracker.sh"
72
+ }
73
+ ]
74
+ }
75
+ ],
76
+ "SessionEnd": [
77
+ {
78
+ "matcher": "*",
79
+ "hooks": [
80
+ {
81
+ "type": "command",
82
+ "command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/session-end-tracker.sh"
83
+ }
84
+ ]
85
+ }
64
86
  ]
65
87
  }
66
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Agents, skills, hooks, commands, rules, and MCP configurations that power [Onlooker](https://onlooker.dev)",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # Onlooker Session End Tracker
3
+ # Invoked by SessionEnd (matcher: *) when a session ends.
4
+ #
5
+ # Emits session.end with duration and turn count, then cleans up hook bus dirs.
6
+ # Default SessionEnd budget is 1.5s — keep this hook fast.
7
+ #
8
+ # Usage:
9
+ # echo "$INPUT" | session-end-tracker.sh
10
+
11
+ set -uo pipefail # No -e: never block session termination
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "$SCRIPT_DIR/../lib/validate-path.sh"
15
+ source "$SCRIPT_DIR/../lib/onlooker-schema.sh"
16
+ source "$SCRIPT_DIR/../lib/tool-history.sh"
17
+ source "$SCRIPT_DIR/../lib/session-tracker.sh"
18
+
19
+ hook_register "session-end-tracker" "Session End Tracker" "Records session.end and cleans up session resources"
20
+
21
+ INPUT=$(cat)
22
+ hook_set_context "$INPUT" "SessionEnd"
23
+
24
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
25
+
26
+ PAYLOAD=$(session_tracker_build_end_payload "$SESSION_ID" "$INPUT")
27
+ if [[ -n "$PAYLOAD" ]]; then
28
+ session_tracker_emit "$SESSION_ID" "session.end" "$PAYLOAD" \
29
+ || hook_failure "Failed to emit session.end"
30
+ fi
31
+
32
+ hook_bus_cleanup
33
+
34
+ hook_success
35
+ exit 0
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # Onlooker Session Start Tracker
3
+ # Invoked by SessionStart (matcher: *) when a session starts or resumes.
4
+ #
5
+ # Initializes per-session tracker state and emits session.start for
6
+ # startup, resume, and clear sources (compact is metadata-only).
7
+ #
8
+ # Usage:
9
+ # echo "$INPUT" | session-start-tracker.sh
10
+
11
+ set -uo pipefail # No -e: never block session startup
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "$SCRIPT_DIR/../lib/validate-path.sh"
15
+ source "$SCRIPT_DIR/../lib/onlooker-schema.sh"
16
+ source "$SCRIPT_DIR/../lib/tool-history.sh"
17
+ source "$SCRIPT_DIR/../lib/session-tracker.sh"
18
+
19
+ hook_register "session-start-tracker" "Session Start Tracker" "Records session.start and initializes session tracker"
20
+
21
+ INPUT=$(cat)
22
+ hook_set_context "$INPUT" "SessionStart"
23
+
24
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
25
+ SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"')
26
+
27
+ session_tracker_record_start "$SESSION_ID" "$INPUT" || hook_failure "Failed to record session start metadata"
28
+
29
+ # Compaction reuses the session; do not emit another session.start.
30
+ if [[ "$SOURCE" != "compact" ]]; then
31
+ PAYLOAD=$(session_tracker_build_start_payload "$INPUT")
32
+ if [[ -n "$PAYLOAD" ]]; then
33
+ session_tracker_emit "$SESSION_ID" "session.start" "$PAYLOAD" \
34
+ || hook_failure "Failed to emit session.start"
35
+ fi
36
+ fi
37
+
38
+ hook_success
39
+ exit 0
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bash
2
+ # Session lifecycle helpers — session.start / session.end canonical events.
3
+ #
4
+ # Source after validate-path.sh, onlooker-schema.sh, and tool-history.sh:
5
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/session-tracker.sh"
6
+
7
+ # Milliseconds since epoch (macOS-compatible).
8
+ session_tracker_now_ms() {
9
+ if [[ "$(uname)" == "Darwin" ]]; then
10
+ python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s000
11
+ else
12
+ date +%s%3N 2>/dev/null || date +%s000
13
+ fi
14
+ }
15
+
16
+ # Optional git_branch and git_commit for a working directory (empty when not a repo).
17
+ session_tracker_git_context() {
18
+ local cwd="${1:-}"
19
+ local branch="" commit=""
20
+ [[ -z "$cwd" ]] && return 0
21
+
22
+ if git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
23
+ branch=$(git -C "$cwd" branch --show-current 2>/dev/null) || branch=""
24
+ commit=$(git -C "$cwd" rev-parse --short HEAD 2>/dev/null) || commit=""
25
+ fi
26
+
27
+ printf '%s\n%s' "$branch" "$commit"
28
+ }
29
+
30
+ # Map Claude Code SessionEnd reason to schema end_reason.
31
+ session_tracker_map_end_reason() {
32
+ local reason="${1:-other}"
33
+ case "$reason" in
34
+ clear | logout | prompt_input_exit) echo "user_exit" ;;
35
+ timeout) echo "timeout" ;;
36
+ error) echo "error" ;;
37
+ task_complete) echo "task_complete" ;;
38
+ *) echo "unknown" ;;
39
+ esac
40
+ }
41
+
42
+ # Merge session start metadata into the per-session tracker file.
43
+ # Usage: session_tracker_record_start "$SESSION_ID" "$INPUT_JSON"
44
+ session_tracker_record_start() {
45
+ local session_id="${1:-}"
46
+ local input_json="${2:-}"
47
+ [[ -z "$session_id" || "$session_id" == "null" || -z "$input_json" ]] && return 0
48
+
49
+ turn_state_ensure_session "$session_id" || return 1
50
+
51
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
52
+ local now_ms source model cwd transcript_path agent_type
53
+ now_ms=$(session_tracker_now_ms)
54
+ source=$(echo "$input_json" | jq -r '.source // ""' 2>/dev/null) || source=""
55
+ model=$(echo "$input_json" | jq -r '.model // ""' 2>/dev/null) || model=""
56
+ cwd=$(echo "$input_json" | jq -r '.cwd // ""' 2>/dev/null) || cwd=""
57
+ transcript_path=$(echo "$input_json" | jq -r '.transcript_path // ""' 2>/dev/null) || transcript_path=""
58
+ agent_type=$(echo "$input_json" | jq -r '.agent_type // ""' 2>/dev/null) || agent_type=""
59
+
60
+ local temp_file
61
+ temp_file=$(mktemp)
62
+ if ! jq \
63
+ --argjson start_ms "$now_ms" \
64
+ --arg source "$source" \
65
+ --arg model "$model" \
66
+ --arg cwd "$cwd" \
67
+ --arg transcript "$transcript_path" \
68
+ --arg agent_type "$agent_type" \
69
+ '.start_time_ms = $start_ms
70
+ | .start_source = (if $source != "" then $source else .start_source end)
71
+ | .model = (if $model != "" then $model else .model end)
72
+ | .cwd = (if $cwd != "" then $cwd else .cwd end)
73
+ | .transcript_path = (if $transcript != "" then $transcript else .transcript_path end)
74
+ | .agent_type = (if $agent_type != "" then $agent_type else .agent_type end)' \
75
+ "$tracker_file" >"$temp_file" 2>/dev/null; then
76
+ rm -f "$temp_file"
77
+ return 1
78
+ fi
79
+ mv "$temp_file" "$tracker_file"
80
+ }
81
+
82
+ # Build session.start payload JSON from hook input and tracker state.
83
+ # Usage: payload=$(session_tracker_build_start_payload "$INPUT_JSON")
84
+ session_tracker_build_start_payload() {
85
+ local input_json="${1:-}"
86
+ local cwd
87
+ cwd=$(echo "$input_json" | jq -r '.cwd // ""' 2>/dev/null) || cwd=""
88
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
89
+
90
+ local git_lines branch commit
91
+ git_lines=$(session_tracker_git_context "$cwd")
92
+ branch=$(echo "$git_lines" | sed -n '1p')
93
+ commit=$(echo "$git_lines" | sed -n '2p')
94
+
95
+ jq -n \
96
+ --arg wd "$cwd" \
97
+ --arg branch "$branch" \
98
+ --arg commit "$commit" \
99
+ '{
100
+ working_directory: $wd
101
+ }
102
+ + (if $branch != "" then {git_branch: $branch} else {} end)
103
+ + (if $commit != "" then {git_commit: $commit} else {} end)'
104
+ }
105
+
106
+ # Build session.end payload JSON from hook input and tracker file.
107
+ # Usage: payload=$(session_tracker_build_end_payload "$SESSION_ID" "$INPUT_JSON")
108
+ session_tracker_build_end_payload() {
109
+ local session_id="${1:-}"
110
+ local input_json="${2:-}"
111
+ [[ -z "$session_id" || "$session_id" == "null" ]] && return 1
112
+
113
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
114
+ local now_ms start_ms turn_count reason end_reason duration_ms
115
+ now_ms=$(session_tracker_now_ms)
116
+ reason=$(echo "$input_json" | jq -r '.reason // "other"' 2>/dev/null) || reason="other"
117
+ end_reason=$(session_tracker_map_end_reason "$reason")
118
+
119
+ if [[ -f "$tracker_file" ]]; then
120
+ start_ms=$(jq -r '.start_time_ms // 0' "$tracker_file" 2>/dev/null) || start_ms=0
121
+ turn_count=$(jq -r '.turn_number // 1' "$tracker_file" 2>/dev/null) || turn_count=1
122
+ else
123
+ start_ms=0
124
+ turn_count=1
125
+ fi
126
+
127
+ if [[ "$start_ms" =~ ^[0-9]+$ ]] && (( start_ms > 0 )); then
128
+ duration_ms=$((now_ms - start_ms))
129
+ else
130
+ duration_ms=0
131
+ fi
132
+ (( duration_ms < 0 )) && duration_ms=0
133
+
134
+ jq -n \
135
+ --argjson duration_ms "$duration_ms" \
136
+ --argjson turn_count "$turn_count" \
137
+ --arg end_reason "$end_reason" \
138
+ '{
139
+ duration_ms: $duration_ms,
140
+ turn_count: $turn_count,
141
+ end_reason: $end_reason
142
+ }'
143
+ }
144
+
145
+ # Emit a validated canonical session event and append to logs.
146
+ # Usage: session_tracker_emit "$SESSION_ID" "session.start" "$payload_json"
147
+ session_tracker_emit() {
148
+ local session_id="${1:-}"
149
+ local event_type="${2:-}"
150
+ local payload_json="${3:-}"
151
+ [[ -z "$session_id" || -z "$event_type" || -z "$payload_json" ]] && return 0
152
+
153
+ local params event
154
+ params=$(jq -n \
155
+ --arg plugin "${ONLOOKER_PLUGIN_NAME:-onlooker}" \
156
+ --arg sid "$session_id" \
157
+ --arg type "$event_type" \
158
+ --argjson payload "$payload_json" \
159
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}')
160
+
161
+ event=$(printf '%s' "$params" | ONLOOKER_DIR="$ONLOOKER_DIR" ONLOOKER_PLUGIN_NAME="$ONLOOKER_PLUGIN_NAME" \
162
+ node "${_ONLOOKER_EVENT_JS:-${CLAUDE_PLUGIN_ROOT:-}/scripts/lib/onlooker-event.mjs}" emit 2>/dev/null) || return 1
163
+
164
+ tool_history_append "$session_id" "$event" || return 1
165
+ onlooker_append_event "$event" || return 1
166
+ }
@@ -84,6 +84,34 @@ setup_file() {
84
84
  [[ "$hook_cmd" == *tool-history-tracker.sh ]]
85
85
  }
86
86
 
87
+ @test "hooks.json SessionStart references session-start-tracker" {
88
+ run jq -e '.hooks.SessionStart[0].matcher == "*"' "${REPO_ROOT}/hooks/hooks.json"
89
+ [ "$status" -eq 0 ]
90
+
91
+ local hook_cmd
92
+ hook_cmd=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
93
+ [[ "$hook_cmd" == *session-start-tracker.sh ]]
94
+
95
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
96
+ script_path="${script_path//\"/}"
97
+ run test -x "$script_path"
98
+ [ "$status" -eq 0 ]
99
+ }
100
+
101
+ @test "hooks.json SessionEnd references session-end-tracker" {
102
+ run jq -e '.hooks.SessionEnd[0].matcher == "*"' "${REPO_ROOT}/hooks/hooks.json"
103
+ [ "$status" -eq 0 ]
104
+
105
+ local hook_cmd
106
+ hook_cmd=$(jq -r '.hooks.SessionEnd[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
107
+ [[ "$hook_cmd" == *session-end-tracker.sh ]]
108
+
109
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
110
+ script_path="${script_path//\"/}"
111
+ run test -x "$script_path"
112
+ [ "$status" -eq 0 ]
113
+ }
114
+
87
115
  @test "plugin.json is valid JSON" {
88
116
  run jq -e '.name and .version' "${REPO_ROOT}/.claude-plugin/plugin.json"
89
117
  [ "$status" -eq 0 ]
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ load_validate_path
7
+ export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
8
+ # shellcheck source=../../scripts/lib/session-tracker.sh
9
+ source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
10
+ source "${REPO_ROOT}/scripts/lib/tool-history.sh"
11
+ source "${REPO_ROOT}/scripts/lib/session-tracker.sh"
12
+ }
13
+
14
+ @test "session-start-tracker emits session.start for startup source" {
15
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/session-start-startup.json"
16
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/session-start-001.jsonl"
17
+ rm -f "$history_file"
18
+
19
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/session-start-tracker.sh' 2>/dev/null"
20
+ [ "$status" -eq 0 ]
21
+
22
+ [ -f "$history_file" ]
23
+ jq -e '.event_type == "session.start"
24
+ and .session_id == "session-start-001"
25
+ and .payload.working_directory == "/project/repo"' \
26
+ "$history_file" >/dev/null
27
+
28
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/session-start-001"
29
+ jq -e '.start_source == "startup" and (.start_time_ms | type) == "number"' "$tracker" >/dev/null
30
+ }
31
+
32
+ @test "session-start-tracker does not emit session.start for compact source" {
33
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/session-start-compact.json"
34
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/session-start-002.jsonl"
35
+ rm -f "$history_file"
36
+
37
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/session-start-tracker.sh' 2>/dev/null"
38
+ [ "$status" -eq 0 ]
39
+ [ ! -f "$history_file" ]
40
+
41
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/session-start-002"
42
+ jq -e '.start_source == "compact"' "$tracker" >/dev/null
43
+ }
44
+
45
+ @test "session-end-tracker emits session.end with duration and turn count" {
46
+ local start_fixture="${REPO_ROOT}/test/fixtures/hook-inputs/session-start-startup.json"
47
+ local end_fixture="${REPO_ROOT}/test/fixtures/hook-inputs/session-end-other.json"
48
+ local session_id="session-end-001"
49
+ local tracker="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
50
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/${session_id}.jsonl"
51
+
52
+ rm -f "$history_file" "$tracker"
53
+
54
+ # Seed tracker as if session had been running
55
+ turn_state_ensure_session "$session_id"
56
+ local past_ms
57
+ past_ms=$(python3 -c 'import time; print(int((time.time() - 2) * 1000))' 2>/dev/null || echo 0)
58
+ jq --argjson start_ms "$past_ms" '.start_time_ms = $start_ms | .turn_number = 3' "$tracker" >"${tracker}.tmp"
59
+ mv "${tracker}.tmp" "$tracker"
60
+
61
+ run bash -c "cat '${end_fixture}' | '${REPO_ROOT}/scripts/hooks/session-end-tracker.sh' 2>/dev/null"
62
+ [ "$status" -eq 0 ]
63
+
64
+ jq -e '.event_type == "session.end"
65
+ and .session_id == "session-end-001"
66
+ and .payload.turn_count == 3
67
+ and .payload.end_reason == "unknown"
68
+ and (.payload.duration_ms | type) == "number"
69
+ and .payload.duration_ms >= 0' \
70
+ "$history_file" >/dev/null
71
+ }
72
+
73
+ @test "session_tracker_map_end_reason maps logout to user_exit" {
74
+ [ "$(session_tracker_map_end_reason logout)" = "user_exit" ]
75
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "session_id": "session-end-001",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "hook_event_name": "SessionEnd",
6
+ "reason": "other"
7
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "session_id": "session-start-002",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "hook_event_name": "SessionStart",
6
+ "source": "compact",
7
+ "model": "claude-sonnet-4-6"
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "session_id": "session-start-001",
3
+ "transcript_path": "/tmp/transcript.jsonl",
4
+ "cwd": "/project/repo",
5
+ "hook_event_name": "SessionStart",
6
+ "source": "startup",
7
+ "model": "claude-sonnet-4-6"
8
+ }