siesa-agents 2.1.76 → 2.1.78

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.
@@ -39,13 +39,12 @@
39
39
  "ask": []
40
40
  },
41
41
  "hooks": {
42
- "SessionEnd": [
42
+ "SessionStart": [
43
43
  {
44
- "matcher": ".*",
45
44
  "hooks": [
46
45
  {
47
46
  "type": "command",
48
- "command": "python .claude/hooks/cleanup-agent.py"
47
+ "command": "node \"${CLAUDE_PROJECT_DIR}/_siesa-agents/observability/scripts/sa-session.js\""
49
48
  }
50
49
  ]
51
50
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siesa-agents",
3
- "version": "2.1.76",
3
+ "version": "2.1.78",
4
4
  "description": "Paquete para instalar y configurar agentes SIESA en tu proyecto",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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
- // Environment:
17
- // SA_OTLP_ENDPOINT OTel collector HTTP endpoint (default: http://localhost:4318)
18
- // SA_OTLP_HEADERS Additional headers, comma-separated: "Authorization=Bearer xxx,X-Other=yyy"
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
- const otlpEndpoint = process.env.SA_OTLP_ENDPOINT || 'http://localhost:4318'
106
- const stateDir = path.join(os.homedir(), '.claude', 'observability')
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 durationMs = null
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
- const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'))
148
- durationMs = nowMs - state.start_ms
149
- fs.unlinkSync(stateFile)
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({ start_ms: nowMs, fix_option: fixOption }))
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
- const fixState = JSON.parse(fs.readFileSync(fixStateFile, 'utf8'))
163
- durationMs = nowMs - fixState.start_ms
164
- if (!fixOption) fixOption = fixState.fix_option
165
- fs.unlinkSync(fixStateFile)
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 (from) attributes.push({ key: 'from', value: { stringValue: from } })
177
- if (to) attributes.push({ key: 'to', value: { stringValue: to } })
178
- if (fixOption) attributes.push({ key: 'fix_option', value: { stringValue: fixOption } })
179
- if (durationMs !== null) attributes.push({ key: 'duration_ms', value: { intValue: durationMs } })
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(process.env.SA_OTLP_HEADERS),
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 (durationMs !== null) msg += ` duration=${durationMs}ms`
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 bufferDir = path.join(stateDir, 'buffer')
242
- fs.mkdirSync(bufferDir, { recursive: true })
243
- fs.appendFileSync(path.join(bufferDir, 'events.jsonl'), JSON.stringify(otlpBody) + '\n')
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
+ })()