siesa-agents 2.1.76 → 2.1.77
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.
- package/claude/settings.local.json +10 -0
- package/claude/skills/clean-observability/SKILL.md +51 -0
- package/package.json +1 -1
- package/siesa-agents/observability/scripts/sa-clean.js +133 -0
- package/siesa-agents/observability/scripts/sa-emit.js +153 -31
- package/siesa-agents/observability/scripts/sa-pid.js +106 -0
- package/siesa-agents/observability/scripts/sa-session.js +66 -0
|
@@ -39,6 +39,16 @@
|
|
|
39
39
|
"ask": []
|
|
40
40
|
},
|
|
41
41
|
"hooks": {
|
|
42
|
+
"SessionStart": [
|
|
43
|
+
{
|
|
44
|
+
"hooks": [
|
|
45
|
+
{
|
|
46
|
+
"type": "command",
|
|
47
|
+
"command": "node \"${CLAUDE_PROJECT_DIR}/siesa-agents/observability/scripts/sa-session.js\""
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
42
52
|
"SessionEnd": [
|
|
43
53
|
{
|
|
44
54
|
"matcher": ".*",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clean-observability
|
|
3
|
+
description: Safely clean up stale state files in ~/.claude/observability/ left by abandoned BMAD workflows. Triggered by /clean-observability. Files belonging to live Claude Code sessions are never touched. Run this weekly or whenever the observability dir feels cluttered.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Clean Observability State
|
|
7
|
+
|
|
8
|
+
Manual cleanup for `~/.claude/observability/`. Removes only files whose owning `claude.exe` PID is no longer alive. Leaves files of currently-running sessions intact.
|
|
9
|
+
|
|
10
|
+
## What to do when invoked
|
|
11
|
+
|
|
12
|
+
### Step 1 — Preview (dry-run)
|
|
13
|
+
|
|
14
|
+
Run the cleanup script in dry-run mode and show its output verbatim to the user. The script lives in this project at `siesa-agents/observability/scripts/sa-clean.js`. The Bash tool's working directory is the project root, so a relative path is sufficient:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
node siesa-agents/observability/scripts/sa-clean.js --dry-run
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The script prints:
|
|
21
|
+
- The set of currently-live Claude Code PIDs.
|
|
22
|
+
- How many files will be kept.
|
|
23
|
+
- The exact list of files that would be deleted.
|
|
24
|
+
|
|
25
|
+
**Important:** do NOT use `${CLAUDE_PROJECT_DIR}` — that variable is not exported to the Bash tool's subshell and would resolve to empty, causing a "file not found" error. Always use the relative path above.
|
|
26
|
+
|
|
27
|
+
### Step 2 — Ask the user
|
|
28
|
+
|
|
29
|
+
Show the preview and ask:
|
|
30
|
+
|
|
31
|
+
> ¿Procedo con el borrado de los N archivos listados? [s/N]
|
|
32
|
+
|
|
33
|
+
Wait for explicit confirmation. Do **not** auto-proceed.
|
|
34
|
+
|
|
35
|
+
### Step 3 — Apply (only if user confirms)
|
|
36
|
+
|
|
37
|
+
If the user replies affirmatively (e.g., "sí", "s", "yes", "y"), run:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
node siesa-agents/observability/scripts/sa-clean.js --apply --rotate-buffer
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Report the script's output verbatim.
|
|
44
|
+
|
|
45
|
+
If the user declines, end the turn with: `Ok, no borré nada.`
|
|
46
|
+
|
|
47
|
+
## Notes
|
|
48
|
+
|
|
49
|
+
- Never run `--apply` without explicit user confirmation in this turn.
|
|
50
|
+
- Do not edit or delete files manually — always use `sa-clean.js`.
|
|
51
|
+
- The `--rotate-buffer` flag rotates `buffer/events.jsonl` if it exceeds 5 MB. Safe for parallel sessions.
|
package/package.json
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sa-clean.js — Manual cleanup of ~/.claude/observability/ state files.
|
|
3
|
+
//
|
|
4
|
+
// Safe for parallel CC sessions: deletes only files that don't belong to a
|
|
5
|
+
// currently-alive claude.exe process. Files of live sessions are never touched.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// node sa-clean.js --dry-run # list what would be deleted
|
|
9
|
+
// node sa-clean.js --apply # actually delete
|
|
10
|
+
// node sa-clean.js --apply --rotate-buffer # also rotate buffer/events.jsonl if > 5 MB
|
|
11
|
+
//
|
|
12
|
+
// Rules:
|
|
13
|
+
// - session-<pid>.json → delete unless <pid> is alive
|
|
14
|
+
// - session-id-<uuid>.json → delete unless its recorded claude_pid is alive
|
|
15
|
+
// - wf-*.json / fix-*.json → delete unless its recorded claude_pid is alive
|
|
16
|
+
// - any other file (logs, etc.) → never touched
|
|
17
|
+
|
|
18
|
+
'use strict'
|
|
19
|
+
|
|
20
|
+
const fs = require('fs')
|
|
21
|
+
const path = require('path')
|
|
22
|
+
const os = require('os')
|
|
23
|
+
const { getAliveClaudePids } = require('./sa-pid')
|
|
24
|
+
|
|
25
|
+
const STATE_DIR = path.join(os.homedir(), '.claude', 'observability')
|
|
26
|
+
const BUFFER_FILE = path.join(STATE_DIR, 'buffer', 'events.jsonl')
|
|
27
|
+
const BUFFER_MAX_BYTES = 5 * 1024 * 1024
|
|
28
|
+
const BUFFER_KEEP_ROTATIONS = 3
|
|
29
|
+
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
const flags = new Set()
|
|
32
|
+
for (const a of argv) if (a.startsWith('--')) flags.add(a.slice(2))
|
|
33
|
+
return flags
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readPidFromFile(p) {
|
|
37
|
+
try {
|
|
38
|
+
const data = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
39
|
+
const v = data.claude_pid
|
|
40
|
+
return v == null ? null : parseInt(v, 10)
|
|
41
|
+
} catch (_) { return null }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function classify(alivePids) {
|
|
45
|
+
const toDelete = []
|
|
46
|
+
const toKeep = []
|
|
47
|
+
if (!fs.existsSync(STATE_DIR)) return { toDelete, toKeep }
|
|
48
|
+
|
|
49
|
+
for (const name of fs.readdirSync(STATE_DIR)) {
|
|
50
|
+
const full = path.join(STATE_DIR, name)
|
|
51
|
+
let stat
|
|
52
|
+
try { stat = fs.statSync(full) } catch (_) { continue }
|
|
53
|
+
if (!stat.isFile()) continue
|
|
54
|
+
if (name === 'hook-errors.log') continue
|
|
55
|
+
|
|
56
|
+
if (name.startsWith('session-') && name.endsWith('.json')) {
|
|
57
|
+
if (name.startsWith('session-id-')) {
|
|
58
|
+
const pid = readPidFromFile(full)
|
|
59
|
+
if (pid != null && alivePids.has(pid)) toKeep.push(full)
|
|
60
|
+
else toDelete.push(full)
|
|
61
|
+
} else {
|
|
62
|
+
const m = name.match(/^session-(\d+)\.json$/)
|
|
63
|
+
if (!m) { toDelete.push(full); continue }
|
|
64
|
+
const pid = parseInt(m[1], 10)
|
|
65
|
+
if (alivePids.has(pid)) toKeep.push(full)
|
|
66
|
+
else toDelete.push(full)
|
|
67
|
+
}
|
|
68
|
+
} else if ((name.startsWith('wf-') || name.startsWith('fix-')) && name.endsWith('.json')) {
|
|
69
|
+
const pid = readPidFromFile(full)
|
|
70
|
+
if (pid != null && alivePids.has(pid)) toKeep.push(full)
|
|
71
|
+
else toDelete.push(full)
|
|
72
|
+
} else {
|
|
73
|
+
toKeep.push(full)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { toDelete, toKeep }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function rotateBufferIfNeeded() {
|
|
80
|
+
if (!fs.existsSync(BUFFER_FILE)) return null
|
|
81
|
+
let size
|
|
82
|
+
try { size = fs.statSync(BUFFER_FILE).size } catch (_) { return null }
|
|
83
|
+
if (size < BUFFER_MAX_BYTES) return null
|
|
84
|
+
try {
|
|
85
|
+
for (let i = BUFFER_KEEP_ROTATIONS; i >= 1; i--) {
|
|
86
|
+
const src = `${BUFFER_FILE}.${i}`
|
|
87
|
+
const dst = `${BUFFER_FILE}.${i + 1}`
|
|
88
|
+
if (!fs.existsSync(src)) continue
|
|
89
|
+
if (i === BUFFER_KEEP_ROTATIONS) fs.unlinkSync(src)
|
|
90
|
+
else fs.renameSync(src, dst)
|
|
91
|
+
}
|
|
92
|
+
fs.renameSync(BUFFER_FILE, `${BUFFER_FILE}.1`)
|
|
93
|
+
return `rotated ${path.basename(BUFFER_FILE)} (${size.toLocaleString()} bytes)`
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return `rotation failed: ${e && e.message}`
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
;(function main() {
|
|
100
|
+
const flags = parseArgs(process.argv.slice(2))
|
|
101
|
+
const dryRun = flags.has('dry-run')
|
|
102
|
+
const apply = flags.has('apply')
|
|
103
|
+
const rotate = flags.has('rotate-buffer')
|
|
104
|
+
|
|
105
|
+
if (dryRun === apply) {
|
|
106
|
+
console.error('Usage: node sa-clean.js (--dry-run | --apply) [--rotate-buffer]')
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const alivePids = getAliveClaudePids()
|
|
111
|
+
const { toDelete, toKeep } = classify(alivePids)
|
|
112
|
+
|
|
113
|
+
console.log(`Live claude.exe PIDs: ${alivePids.size ? '[' + [...alivePids].sort((a,b) => a-b).join(', ') + ']' : '(none detected)'}`)
|
|
114
|
+
console.log(`Files to keep: ${toKeep.length}`)
|
|
115
|
+
console.log(`Files to delete: ${toDelete.length}`)
|
|
116
|
+
for (const f of toDelete) console.log(` - ${path.basename(f)}`)
|
|
117
|
+
|
|
118
|
+
if (apply) {
|
|
119
|
+
let deleted = 0
|
|
120
|
+
for (const f of toDelete) {
|
|
121
|
+
try { fs.unlinkSync(f); deleted++ }
|
|
122
|
+
catch (e) { console.log(` ! failed to delete ${path.basename(f)}: ${e && e.message}`) }
|
|
123
|
+
}
|
|
124
|
+
console.log(`Deleted: ${deleted}`)
|
|
125
|
+
if (rotate) {
|
|
126
|
+
const r = rotateBufferIfNeeded()
|
|
127
|
+
console.log(r || `Buffer below ${BUFFER_MAX_BYTES / (1024 * 1024)} MB — no rotation needed.`)
|
|
128
|
+
}
|
|
129
|
+
} else if (rotate) {
|
|
130
|
+
const r = rotateBufferIfNeeded()
|
|
131
|
+
if (r) console.log(`(dry-run, but --rotate-buffer was applied) ${r}`)
|
|
132
|
+
}
|
|
133
|
+
})()
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// sa-emit.js — Emit BMAD workflow lifecycle events via OTLP to the OTel collector
|
|
2
|
+
// sa-emit.js — Emit BMAD workflow lifecycle events via OTLP to the OTel collector.
|
|
3
|
+
//
|
|
4
|
+
// Resolves session_id automatically by walking up the process tree to find claude.exe
|
|
5
|
+
// (its PID was recorded by the SessionStart hook in ~/.claude/observability/session-<pid>.json).
|
|
6
|
+
// On *.finished events it reads the session's transcript JSONL and sums token usage
|
|
7
|
+
// since the matching *.started event.
|
|
3
8
|
//
|
|
4
9
|
// Usage:
|
|
5
10
|
// node sa-emit.js --event workflow.started --story "1-1-user-auth" --phase "create-story"
|
|
@@ -13,9 +18,13 @@
|
|
|
13
18
|
// --project-id "<name>" Override the git-remote-derived project id
|
|
14
19
|
// --timestamp-offset-ms <ms> Subtract ms from the event timestamp (backdate the log record)
|
|
15
20
|
//
|
|
16
|
-
//
|
|
17
|
-
// SA_OTLP_ENDPOINT
|
|
18
|
-
//
|
|
21
|
+
// Endpoint resolution (in priority order):
|
|
22
|
+
// 1. SA_OTLP_ENDPOINT — sa-emit-specific base URL ('/v1/logs' is appended)
|
|
23
|
+
// 2. OTEL_EXPORTER_OTLP_LOGS_ENDPOINT — CC native logs endpoint (full URL, used as-is)
|
|
24
|
+
// 3. OTEL_EXPORTER_OTLP_ENDPOINT — CC native generic endpoint ('/v1/logs' is appended)
|
|
25
|
+
// None ⇒ event is buffered to disk and a clear ERROR is logged. No localhost fallback.
|
|
26
|
+
//
|
|
27
|
+
// Headers resolution: SA_OTLP_HEADERS || OTEL_EXPORTER_OTLP_HEADERS || (empty)
|
|
19
28
|
|
|
20
29
|
'use strict'
|
|
21
30
|
|
|
@@ -23,8 +32,10 @@ const fs = require('fs')
|
|
|
23
32
|
const path = require('path')
|
|
24
33
|
const os = require('os')
|
|
25
34
|
const { execSync } = require('child_process')
|
|
35
|
+
const { findClaudePid } = require('./sa-pid')
|
|
26
36
|
|
|
27
37
|
function loadDotEnv() {
|
|
38
|
+
// Single source of truth: project-local .env in the current working directory.
|
|
28
39
|
const envPath = path.join(process.cwd(), '.env')
|
|
29
40
|
if (!fs.existsSync(envPath)) return
|
|
30
41
|
try {
|
|
@@ -102,8 +113,23 @@ if (fixOption && !VALID_FIX_OPTIONS.includes(fixOption)) {
|
|
|
102
113
|
process.exit(1)
|
|
103
114
|
}
|
|
104
115
|
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
function resolveOtlpLogsUrl() {
|
|
117
|
+
if (process.env.SA_OTLP_ENDPOINT) {
|
|
118
|
+
return process.env.SA_OTLP_ENDPOINT.replace(/\/+$/, '') + '/v1/logs'
|
|
119
|
+
}
|
|
120
|
+
if (process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT) {
|
|
121
|
+
return process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT
|
|
122
|
+
}
|
|
123
|
+
if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
124
|
+
return process.env.OTEL_EXPORTER_OTLP_ENDPOINT.replace(/\/+$/, '') + '/v1/logs'
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
function resolveOtlpHeaders() {
|
|
129
|
+
return process.env.SA_OTLP_HEADERS || process.env.OTEL_EXPORTER_OTLP_HEADERS || ''
|
|
130
|
+
}
|
|
131
|
+
const otlpUrl = resolveOtlpLogsUrl()
|
|
132
|
+
const stateDir = path.join(os.homedir(), '.claude', 'observability')
|
|
107
133
|
|
|
108
134
|
const epicIdMatch = story.match(/^(\d+)-/)
|
|
109
135
|
const epicId = epicIdMatch ? epicIdMatch[1] : 'unknown'
|
|
@@ -129,43 +155,123 @@ if (engineerOverride) {
|
|
|
129
155
|
} catch (_) {}
|
|
130
156
|
}
|
|
131
157
|
|
|
158
|
+
// ---------- Session resolution via PID walk (helper in ./sa-pid) ----------
|
|
159
|
+
|
|
160
|
+
function loadSessionByClaudePid(claudePid) {
|
|
161
|
+
if (claudePid == null) return null
|
|
162
|
+
const f = path.join(stateDir, `session-${claudePid}.json`)
|
|
163
|
+
if (!fs.existsSync(f)) return null
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(fs.readFileSync(f, 'utf8'))
|
|
166
|
+
} catch (_) { return null }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const claudePid = findClaudePid()
|
|
170
|
+
const sessionInfo = loadSessionByClaudePid(claudePid)
|
|
171
|
+
const sessionId = sessionInfo && sessionInfo.session_id ? sessionInfo.session_id : null
|
|
172
|
+
|
|
173
|
+
// ---------- State files (per workflow / fix) — now also include claude_pid for cleanup ----------
|
|
174
|
+
|
|
132
175
|
const nowMs = Date.now()
|
|
133
176
|
const eventTimeMs = nowMs - (Number.isFinite(timestampOffsetMs) ? timestampOffsetMs : 0)
|
|
134
177
|
const tsNano = `${eventTimeMs}000000`
|
|
135
178
|
|
|
136
179
|
const safeKey = story.replace(/[^a-zA-Z0-9-]/g, '')
|
|
137
180
|
const stateFile = path.join(stateDir, `wf-${phase}-${safeKey}.json`)
|
|
138
|
-
let
|
|
181
|
+
let durationMs = null
|
|
182
|
+
let wfStartMs = null
|
|
139
183
|
|
|
140
184
|
if (event === 'workflow.started') {
|
|
141
185
|
fs.mkdirSync(stateDir, { recursive: true })
|
|
142
|
-
fs.writeFileSync(stateFile, JSON.stringify({ start_ms: nowMs }))
|
|
186
|
+
fs.writeFileSync(stateFile, JSON.stringify({ start_ms: nowMs, claude_pid: claudePid, session_id: sessionId }))
|
|
143
187
|
}
|
|
144
188
|
|
|
145
189
|
if (event === 'workflow.finished') {
|
|
146
190
|
if (fs.existsSync(stateFile)) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
191
|
+
try {
|
|
192
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'))
|
|
193
|
+
wfStartMs = state.start_ms
|
|
194
|
+
durationMs = nowMs - state.start_ms
|
|
195
|
+
fs.unlinkSync(stateFile)
|
|
196
|
+
} catch (_) {}
|
|
150
197
|
}
|
|
151
198
|
}
|
|
152
199
|
|
|
153
200
|
const fixStateFile = path.join(stateDir, `fix-${phase}-${safeKey}.json`)
|
|
201
|
+
let fixStartMs = null
|
|
154
202
|
|
|
155
203
|
if (event === 'fix.started') {
|
|
156
204
|
fs.mkdirSync(stateDir, { recursive: true })
|
|
157
|
-
fs.writeFileSync(fixStateFile, JSON.stringify({
|
|
205
|
+
fs.writeFileSync(fixStateFile, JSON.stringify({
|
|
206
|
+
start_ms: nowMs, fix_option: fixOption, claude_pid: claudePid, session_id: sessionId,
|
|
207
|
+
}))
|
|
158
208
|
}
|
|
159
209
|
|
|
160
210
|
if (event === 'fix.finished') {
|
|
161
211
|
if (fs.existsSync(fixStateFile)) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
212
|
+
try {
|
|
213
|
+
const fixState = JSON.parse(fs.readFileSync(fixStateFile, 'utf8'))
|
|
214
|
+
fixStartMs = fixState.start_ms
|
|
215
|
+
durationMs = nowMs - fixState.start_ms
|
|
216
|
+
if (!fixOption) fixOption = fixState.fix_option
|
|
217
|
+
fs.unlinkSync(fixStateFile)
|
|
218
|
+
} catch (_) {}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------- Token aggregation from the session JSONL (only on *.finished) ----------
|
|
223
|
+
|
|
224
|
+
function parseIsoToMs(s) {
|
|
225
|
+
if (!s) return null
|
|
226
|
+
const t = Date.parse(s)
|
|
227
|
+
return Number.isNaN(t) ? null : t
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sumTokensFromTranscript(transcriptPath, sinceMs) {
|
|
231
|
+
const totals = { input: 0, output: 0, cache_read: 0, cache_creation: 0 }
|
|
232
|
+
const models = new Set()
|
|
233
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return { totals, models }
|
|
234
|
+
try {
|
|
235
|
+
const content = fs.readFileSync(transcriptPath, 'utf8')
|
|
236
|
+
for (const line of content.split(/\r?\n/)) {
|
|
237
|
+
const trimmed = line.trim()
|
|
238
|
+
if (!trimmed) continue
|
|
239
|
+
let rec
|
|
240
|
+
try { rec = JSON.parse(trimmed) } catch (_) { continue }
|
|
241
|
+
const tsMs = parseIsoToMs(rec.timestamp || rec.createdAt)
|
|
242
|
+
if (sinceMs != null && tsMs != null && tsMs < sinceMs) continue
|
|
243
|
+
const msg = rec.message || {}
|
|
244
|
+
const usage = msg.usage || rec.usage
|
|
245
|
+
if (!usage) continue
|
|
246
|
+
totals.input += parseInt(usage.input_tokens || 0, 10) || 0
|
|
247
|
+
totals.output += parseInt(usage.output_tokens || 0, 10) || 0
|
|
248
|
+
totals.cache_read += parseInt(usage.cache_read_input_tokens || 0, 10) || 0
|
|
249
|
+
totals.cache_creation += parseInt(usage.cache_creation_input_tokens || 0, 10) || 0
|
|
250
|
+
const model = msg.model || rec.model
|
|
251
|
+
if (model) models.add(String(model))
|
|
252
|
+
}
|
|
253
|
+
} catch (_) {}
|
|
254
|
+
return { totals, models }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let tokenAttrs = []
|
|
258
|
+
if ((event === 'workflow.finished' || event === 'fix.finished') && sessionInfo && sessionInfo.transcript_path) {
|
|
259
|
+
const sinceMs = (event === 'fix.finished') ? fixStartMs : wfStartMs
|
|
260
|
+
if (sinceMs != null) {
|
|
261
|
+
const { totals, models } = sumTokensFromTranscript(sessionInfo.transcript_path, sinceMs)
|
|
262
|
+
tokenAttrs = [
|
|
263
|
+
{ key: 'tokens_input', value: { intValue: totals.input } },
|
|
264
|
+
{ key: 'tokens_output', value: { intValue: totals.output } },
|
|
265
|
+
{ key: 'tokens_cache_read', value: { intValue: totals.cache_read } },
|
|
266
|
+
{ key: 'tokens_cache_creation', value: { intValue: totals.cache_creation } },
|
|
267
|
+
{ key: 'tokens_total', value: { intValue: totals.input + totals.output + totals.cache_read + totals.cache_creation } },
|
|
268
|
+
]
|
|
269
|
+
if (models.size) tokenAttrs.push({ key: 'models_used', value: { stringValue: [...models].sort().join(',') } })
|
|
166
270
|
}
|
|
167
271
|
}
|
|
168
272
|
|
|
273
|
+
// ---------- Build OTLP log ----------
|
|
274
|
+
|
|
169
275
|
const attributes = [
|
|
170
276
|
{ key: 'event', value: { stringValue: event } },
|
|
171
277
|
{ key: 'story_id', value: { stringValue: story } },
|
|
@@ -173,10 +279,12 @@ const attributes = [
|
|
|
173
279
|
{ key: 'phase', value: { stringValue: phase } },
|
|
174
280
|
]
|
|
175
281
|
|
|
176
|
-
if (
|
|
177
|
-
if (
|
|
178
|
-
if (
|
|
179
|
-
if (
|
|
282
|
+
if (sessionId) attributes.push({ key: 'session_id', value: { stringValue: sessionId } })
|
|
283
|
+
if (from) attributes.push({ key: 'from', value: { stringValue: from } })
|
|
284
|
+
if (to) attributes.push({ key: 'to', value: { stringValue: to } })
|
|
285
|
+
if (fixOption) attributes.push({ key: 'fix_option', value: { stringValue: fixOption } })
|
|
286
|
+
if (durationMs !== null) attributes.push({ key: 'duration_ms', value: { intValue: durationMs } })
|
|
287
|
+
for (const a of tokenAttrs) attributes.push(a)
|
|
180
288
|
|
|
181
289
|
const otlpBody = {
|
|
182
290
|
resourceLogs: [
|
|
@@ -197,7 +305,7 @@ const otlpBody = {
|
|
|
197
305
|
observedTimeUnixNano: tsNano,
|
|
198
306
|
severityNumber: 9,
|
|
199
307
|
severityText: 'INFO',
|
|
200
|
-
body: { stringValue: `${event} | story=${story} phase=${phase} project=${projectId} engineer=${engineer}` },
|
|
308
|
+
body: { stringValue: `${event} | story=${story} phase=${phase} project=${projectId} engineer=${engineer}${sessionId ? ` session=${sessionId}` : ''}` },
|
|
201
309
|
attributes,
|
|
202
310
|
},
|
|
203
311
|
],
|
|
@@ -220,14 +328,24 @@ function parseHeaders(raw) {
|
|
|
220
328
|
return out
|
|
221
329
|
}
|
|
222
330
|
|
|
331
|
+
function bufferEvent(reason) {
|
|
332
|
+
const bufferDir = path.join(stateDir, 'buffer')
|
|
333
|
+
fs.mkdirSync(bufferDir, { recursive: true })
|
|
334
|
+
fs.appendFileSync(path.join(bufferDir, 'events.jsonl'), JSON.stringify({ reason, otlpBody }) + '\n')
|
|
335
|
+
}
|
|
336
|
+
|
|
223
337
|
;(async () => {
|
|
338
|
+
if (!otlpUrl) {
|
|
339
|
+
bufferEvent('no-endpoint-configured')
|
|
340
|
+
console.error(`[sa-emit] ERROR no OTLP endpoint configured (set SA_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_LOGS_ENDPOINT). Event buffered.`)
|
|
341
|
+
process.exit(0)
|
|
342
|
+
}
|
|
224
343
|
try {
|
|
225
344
|
const headers = {
|
|
226
345
|
'Content-Type': 'application/json',
|
|
227
|
-
...parseHeaders(
|
|
346
|
+
...parseHeaders(resolveOtlpHeaders()),
|
|
228
347
|
}
|
|
229
|
-
|
|
230
|
-
const response = await fetch(`${otlpEndpoint}/v1/logs`, {
|
|
348
|
+
const response = await fetch(otlpUrl, {
|
|
231
349
|
method: 'POST',
|
|
232
350
|
headers,
|
|
233
351
|
body: JSON.stringify(otlpBody),
|
|
@@ -235,12 +353,16 @@ function parseHeaders(raw) {
|
|
|
235
353
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
236
354
|
|
|
237
355
|
let msg = `[sa-emit] ${event} | story=${story} phase=${phase} project=${projectId} engineer=${engineer}`
|
|
238
|
-
if (
|
|
356
|
+
if (sessionId) msg += ` session=${sessionId}`
|
|
357
|
+
if (durationMs !== null) msg += ` duration=${durationMs}ms`
|
|
358
|
+
if (tokenAttrs.length) {
|
|
359
|
+
const totalAttr = tokenAttrs.find(a => a.key === 'tokens_total')
|
|
360
|
+
if (totalAttr) msg += ` tokens=${totalAttr.value.intValue}`
|
|
361
|
+
}
|
|
239
362
|
console.log(msg)
|
|
240
|
-
} catch (
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
console.log(`[sa-emit] BUFFERED (collector unavailable) | ${event} story=${story}`)
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const errMsg = (err && err.message) ? err.message : String(err)
|
|
365
|
+
bufferEvent(`transport-error: ${errMsg}`)
|
|
366
|
+
console.log(`[sa-emit] BUFFERED (gateway unreachable: ${errMsg}) | ${event} story=${story}`)
|
|
245
367
|
}
|
|
246
|
-
})()
|
|
368
|
+
})()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// sa-pid.js — Shared helpers for finding the owning claude.exe PID and listing
|
|
2
|
+
// currently-alive claude.exe PIDs on the host. Cross-platform (Windows + POSIX).
|
|
3
|
+
|
|
4
|
+
'use strict'
|
|
5
|
+
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const { execSync } = require('child_process')
|
|
8
|
+
|
|
9
|
+
function snapshotProcessesWindows() {
|
|
10
|
+
// Returns Map<pid, { ppid, name }>. Empty Map on failure.
|
|
11
|
+
const map = new Map()
|
|
12
|
+
try {
|
|
13
|
+
const out = execSync(
|
|
14
|
+
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation"',
|
|
15
|
+
{ encoding: 'utf8', timeout: 10000, windowsHide: true },
|
|
16
|
+
)
|
|
17
|
+
for (const raw of out.split(/\r?\n/).slice(1)) {
|
|
18
|
+
const line = raw.trim()
|
|
19
|
+
if (!line) continue
|
|
20
|
+
const m = line.match(/^"(\d+)","(\d+)","([^"]+)"/)
|
|
21
|
+
if (!m) continue
|
|
22
|
+
map.set(parseInt(m[1], 10), { ppid: parseInt(m[2], 10), name: m[3] })
|
|
23
|
+
}
|
|
24
|
+
} catch (_) {}
|
|
25
|
+
return map
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function findClaudePidWindows(startPid) {
|
|
29
|
+
const map = snapshotProcessesWindows()
|
|
30
|
+
let cur = startPid
|
|
31
|
+
for (let i = 0; i < 30; i++) {
|
|
32
|
+
const info = map.get(cur)
|
|
33
|
+
if (!info) return null
|
|
34
|
+
if (info.name && /^claude/i.test(info.name) && /\.exe$/i.test(info.name)) return cur
|
|
35
|
+
if (info.ppid === 0 || info.ppid === cur) return null
|
|
36
|
+
cur = info.ppid
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findClaudePidPosix(startPid) {
|
|
42
|
+
try {
|
|
43
|
+
let cur = startPid
|
|
44
|
+
for (let i = 0; i < 30; i++) {
|
|
45
|
+
const status = `/proc/${cur}/status`
|
|
46
|
+
const comm = `/proc/${cur}/comm`
|
|
47
|
+
if (!fs.existsSync(status)) return null
|
|
48
|
+
const lines = fs.readFileSync(status, 'utf8').split('\n')
|
|
49
|
+
let ppid = null
|
|
50
|
+
for (const ln of lines) {
|
|
51
|
+
if (ln.startsWith('PPid:')) { ppid = parseInt(ln.split(/\s+/)[1], 10); break }
|
|
52
|
+
}
|
|
53
|
+
const name = fs.existsSync(comm) ? fs.readFileSync(comm, 'utf8').trim() : ''
|
|
54
|
+
if (/^claude/i.test(name)) return cur
|
|
55
|
+
if (ppid === null || ppid === 0 || ppid === cur) return null
|
|
56
|
+
cur = ppid
|
|
57
|
+
}
|
|
58
|
+
} catch (_) {}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function findClaudePid(startPid = process.pid) {
|
|
63
|
+
return process.platform === 'win32'
|
|
64
|
+
? findClaudePidWindows(startPid)
|
|
65
|
+
: findClaudePidPosix(startPid)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getAliveClaudePidsWindows() {
|
|
69
|
+
const alive = new Set()
|
|
70
|
+
try {
|
|
71
|
+
const out = execSync(
|
|
72
|
+
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -like \\"claude*.exe\\" } | Select-Object -ExpandProperty ProcessId"',
|
|
73
|
+
{ encoding: 'utf8', timeout: 10000, windowsHide: true },
|
|
74
|
+
)
|
|
75
|
+
for (const line of out.split(/\r?\n/)) {
|
|
76
|
+
const t = line.trim()
|
|
77
|
+
if (/^\d+$/.test(t)) alive.add(parseInt(t, 10))
|
|
78
|
+
}
|
|
79
|
+
} catch (_) {}
|
|
80
|
+
return alive
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getAliveClaudePidsPosix() {
|
|
84
|
+
const alive = new Set()
|
|
85
|
+
try {
|
|
86
|
+
if (!fs.existsSync('/proc')) return alive
|
|
87
|
+
for (const name of fs.readdirSync('/proc')) {
|
|
88
|
+
if (!/^\d+$/.test(name)) continue
|
|
89
|
+
const comm = `/proc/${name}/comm`
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.existsSync(comm)) continue
|
|
92
|
+
const cn = fs.readFileSync(comm, 'utf8').trim()
|
|
93
|
+
if (/^claude/i.test(cn)) alive.add(parseInt(name, 10))
|
|
94
|
+
} catch (_) {}
|
|
95
|
+
}
|
|
96
|
+
} catch (_) {}
|
|
97
|
+
return alive
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getAliveClaudePids() {
|
|
101
|
+
return process.platform === 'win32'
|
|
102
|
+
? getAliveClaudePidsWindows()
|
|
103
|
+
: getAliveClaudePidsPosix()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { findClaudePid, getAliveClaudePids }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sa-session.js — SessionStart hook handler.
|
|
3
|
+
//
|
|
4
|
+
// Persists session info keyed by the claude.exe PID, so any sa-emit.js
|
|
5
|
+
// subprocess (running from inside a workflow's Bash) can later resolve its
|
|
6
|
+
// own session_id by walking up the process tree.
|
|
7
|
+
|
|
8
|
+
'use strict'
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const os = require('os')
|
|
13
|
+
const { findClaudePid } = require('./sa-pid')
|
|
14
|
+
|
|
15
|
+
const STATE_DIR = path.join(os.homedir(), '.claude', 'observability')
|
|
16
|
+
|
|
17
|
+
function logError(msg) {
|
|
18
|
+
try {
|
|
19
|
+
fs.mkdirSync(STATE_DIR, { recursive: true })
|
|
20
|
+
fs.appendFileSync(path.join(STATE_DIR, 'hook-errors.log'), `sa-session: ${msg}\n`)
|
|
21
|
+
} catch (_) {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
;(async () => {
|
|
25
|
+
let raw = ''
|
|
26
|
+
try {
|
|
27
|
+
for await (const chunk of process.stdin) raw += chunk
|
|
28
|
+
} catch (e) {
|
|
29
|
+
logError(`stdin read failed: ${e && e.message}`)
|
|
30
|
+
process.exit(0)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let data = {}
|
|
34
|
+
if (raw.trim()) {
|
|
35
|
+
try { data = JSON.parse(raw) }
|
|
36
|
+
catch (e) {
|
|
37
|
+
logError(`stdin parse failed: ${e && e.message}`)
|
|
38
|
+
process.exit(0)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sessionId = data.session_id
|
|
43
|
+
if (!sessionId) process.exit(0)
|
|
44
|
+
|
|
45
|
+
const claudePid = findClaudePid()
|
|
46
|
+
|
|
47
|
+
const state = {
|
|
48
|
+
session_id: sessionId,
|
|
49
|
+
transcript_path: data.transcript_path || null,
|
|
50
|
+
cwd: data.cwd || process.cwd(),
|
|
51
|
+
source: data.source || null,
|
|
52
|
+
model: data.model || null,
|
|
53
|
+
claude_pid: claudePid,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
fs.mkdirSync(STATE_DIR, { recursive: true })
|
|
58
|
+
if (claudePid != null) {
|
|
59
|
+
fs.writeFileSync(path.join(STATE_DIR, `session-${claudePid}.json`), JSON.stringify(state))
|
|
60
|
+
}
|
|
61
|
+
fs.writeFileSync(path.join(STATE_DIR, `session-id-${sessionId}.json`), JSON.stringify(state))
|
|
62
|
+
} catch (e) {
|
|
63
|
+
logError(`persist failed: ${e && e.message}`)
|
|
64
|
+
}
|
|
65
|
+
process.exit(0)
|
|
66
|
+
})()
|