litmus-cli 1.3.9 → 1.3.11

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.
@@ -1,20 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Litmus AI prompt logger.
4
- * Universal hook script invoked by Claude Code, GitHub Copilot CLI, and OpenAI Codex CLI.
4
+ * Universal hook script invoked by Claude Code, GitHub Copilot CLI, OpenAI Codex
5
+ * CLI, and Cursor.
5
6
  *
6
7
  * Usage:
7
8
  * node hook-logger.cjs <tool>
8
9
  *
9
10
  * Each tool pipes JSON on stdin when a user submits a prompt:
10
11
  * - Claude Code (UserPromptSubmit): stdin JSON with session_id, prompt content
11
- * - Copilot CLI (userPromptSubmitted): { timestamp, cwd, prompt }
12
+ * - GitHub Copilot (UserPromptSubmit): CLI sends { sessionId, timestamp, cwd,
13
+ * prompt }; VS Code Copilot Chat sends { hook_event_name, session_id,
14
+ * transcript_path, timestamp, prompt } and notably NO cwd
12
15
  * - Codex CLI: experimental hooks support (format TBD)
16
+ * - Cursor (beforeSubmitPrompt): { conversation_id, generation_id, prompt, ... }
17
+ *
18
+ * Cursor is the only one that expects something back on stdout — see
19
+ * `emitCursorVerdictIfNeeded`. For every other tool stdout MUST stay empty:
20
+ * Claude Code injects it into the candidate's prompt as context.
13
21
  *
14
22
  * Normalizes to: { ts, type: "ai_prompt", tool, prompt, sessionId? }
15
23
  * Appends to .litmus/activity.jsonl — the same file the watcher uses,
16
24
  * so prompts flow through the existing analysis pipeline.
17
- * Also uploads the event to the server (fire-and-forget) for real-time backup.
25
+ * Also uploads the event to /cli/activity so it reaches candidate_activity_logs.
26
+ * That upload is AWAITED (bounded) — see uploadEvent for why an unawaited one
27
+ * silently stopped working.
18
28
  */
19
29
 
20
30
  const fs = require("fs")
@@ -35,6 +45,21 @@ const STDIN_TIMEOUT_MS = 5000
35
45
 
36
46
  const tool = process.argv[2] || "unknown"
37
47
 
48
+ // Optional explicit assessment directory (argv[3]).
49
+ //
50
+ // Repo-scope hooks are written INTO an assessment, so they know its absolute
51
+ // path at install time and should never have to guess. That matters most for
52
+ // Copilot: VS Code Copilot Chat's payload carries no `cwd` and no workspace
53
+ // roots, so without this the only resolution left is the cwd walk-up (wrong
54
+ // whenever the editor's process starts elsewhere) or the active-assessments
55
+ // registry — and the registry is not always populated. `litmus init` removes
56
+ // the entry in project-scope mode, which silently disabled Copilot Chat capture
57
+ // for exactly the candidates whose HOME could not take the user-scope hook.
58
+ //
59
+ // Passing it explicitly also sidesteps the registry's first-match behaviour,
60
+ // which misattributes prompts when a candidate has more than one assessment.
61
+ const explicitAssessmentDir = process.argv[3] || null
62
+
38
63
  // A directory only counts as an assessment if it has a config.json. Without
39
64
  // this guard, the walk-up would resolve to `~/.litmus/` (created by the
40
65
  // user-scope install) for any Claude Code session whose cwd is in HOME but
@@ -49,15 +74,9 @@ function isAssessmentLitmusDir(litmusDir) {
49
74
  }
50
75
  }
51
76
 
52
- // Find the .litmus directory — walk up from cwd, then fall back to the
53
- // active-assessments registry that `litmus init` writes to ~/.litmus/.
54
- // The cwd walk-up handles the common case (candidate opens VS Code at the
55
- // assessment dir). The registry fallback handles the case where Claude Code,
56
- // Codex, or Copilot CLI fires from a cwd outside the assessment dir — e.g.
57
- // VS Code workspace opened at a parent dir, or the candidate ran `claude`
58
- // from their home dir.
59
- function findLitmusDir() {
60
- let dir = process.cwd()
77
+ // Walk up from `startDir` looking for an assessment .litmus dir.
78
+ function walkUpForLitmusDir(startDir) {
79
+ let dir = startDir
61
80
  for (let i = 0; i < 10; i++) {
62
81
  const candidate = path.join(dir, ".litmus")
63
82
  if (isAssessmentLitmusDir(candidate)) return candidate
@@ -65,6 +84,27 @@ function findLitmusDir() {
65
84
  if (parent === dir) break
66
85
  dir = parent
67
86
  }
87
+ return null
88
+ }
89
+
90
+ // Find the .litmus directory — walk up from any workspace roots the tool told
91
+ // us about, then from cwd, then fall back to the active-assessments registry
92
+ // that `litmus init` writes to ~/.litmus/.
93
+ //
94
+ // `hintDirs` matters because not every tool runs the hook from the workspace.
95
+ // Verified against a real Cursor 3.13.25 session: Cursor invokes the hook with
96
+ // cwd = ~/.cursor and passes the actual workspace in `workspace_roots`. Without
97
+ // the hint the cwd walk-up can never match, and we'd silently fall through to
98
+ // the registry — which returns the FIRST active assessment, misattributing
99
+ // prompts whenever a candidate has more than one on the go.
100
+ function findLitmusDir(hintDirs = []) {
101
+ for (const hint of hintDirs) {
102
+ if (typeof hint !== "string" || !hint) continue
103
+ const found = walkUpForLitmusDir(hint)
104
+ if (found) return found
105
+ }
106
+ const fromCwd = walkUpForLitmusDir(process.cwd())
107
+ if (fromCwd) return fromCwd
68
108
  try {
69
109
  const home = os.homedir()
70
110
  if (!home) return null
@@ -90,46 +130,133 @@ function readConfig(litmusDir) {
90
130
  }
91
131
  }
92
132
 
93
- // Fire-and-forget upload to /cli/activity.
94
- // Uses socket.unref() so Node exits immediately without waiting for the response
95
- // the OS TCP stack still flushes the outbound data after our process exits.
133
+ // Upload budget. The measured round trip to the backend is ~240ms (dns 15ms,
134
+ // tcp 39ms, TLS 154ms, complete 242ms against Railway), so 2s is generous while
135
+ // staying far inside every hook runner's allowance: Claude Code gives its
136
+ // UserPromptSubmit hook 5s, Cursor 10s, Codex 30s. Exceeding it is not worth
137
+ // delaying a candidate's prompt over — activity.jsonl already has the event and
138
+ // the submission zip carries it to the grader.
139
+ const UPLOAD_TIMEOUT_MS = 2000
140
+
141
+ /**
142
+ * POST the event to /cli/activity and WAIT for it to complete.
143
+ *
144
+ * This used to be fire-and-forget: it called `socket.unref()` so Node wouldn't
145
+ * hold the process open for the response. That only ever worked by accident —
146
+ * `readStdin`'s timeout timer was never cleared, which kept the event loop alive
147
+ * ~5s, comfortably longer than the upload. Clearing that timer (correctly, it was
148
+ * spending the entire hook budget doing nothing) dropped the process lifetime to
149
+ * ~26ms, which is BEFORE tcp connect at 39ms, let alone TLS at 154ms. Every
150
+ * hook-captured prompt silently stopped reaching candidate_activity_logs.
151
+ *
152
+ * That was invisible because grading still worked: the grader merges the in-zip
153
+ * activity.jsonl as well as the DB rows. But anything reading the DB alone went
154
+ * blank — including the report's own AI-prompts drawer, which queries
155
+ * candidateActivityLog and renders nothing when empty. Confirmed in the caroline
156
+ * DB: zero ai_prompt rows for a submission whose zip had three, while the
157
+ * long-lived watcher's uploads (heartbeat, env_detected) all landed.
158
+ *
159
+ * So we await it now, bounded. Resolves rather than rejects on every failure
160
+ * path: a telemetry upload must never fail a candidate's prompt. Both timers are
161
+ * cleared on settle — the bug above is exactly what an uncleared timer costs.
162
+ */
96
163
  function uploadEvent(config, event) {
97
- if (!config || !config.token || !config.backendUrl) return
164
+ if (!config || !config.token || !config.backendUrl) return Promise.resolve()
165
+
166
+ let url
167
+ try {
168
+ url = new URL(`${config.backendUrl}/cli/activity`)
169
+ } catch {
170
+ return Promise.resolve() // malformed backendUrl — nothing to do
171
+ }
98
172
 
99
173
  const body = JSON.stringify({ events: [event] })
100
- const url = new URL(`${config.backendUrl}/cli/activity`)
101
174
  const reqFn = url.protocol === "https:" ? https.request : http.request
102
175
 
103
- const req = reqFn(url, {
104
- method: "POST",
105
- headers: {
106
- "Content-Type": "application/json",
107
- "Content-Length": Buffer.byteLength(body),
108
- "Authorization": `Bearer ${config.token}`,
109
- },
110
- timeout: 5000,
176
+ return new Promise((resolve) => {
177
+ let settled = false
178
+ let guard = null
179
+ const done = () => {
180
+ if (settled) return
181
+ settled = true
182
+ if (guard) clearTimeout(guard)
183
+ resolve()
184
+ }
185
+
186
+ const req = reqFn(url, {
187
+ method: "POST",
188
+ headers: {
189
+ "Content-Type": "application/json",
190
+ "Content-Length": Buffer.byteLength(body),
191
+ "Authorization": `Bearer ${config.token}`,
192
+ },
193
+ timeout: UPLOAD_TIMEOUT_MS,
194
+ })
195
+ // Drain the response so the socket can close and the event loop empty.
196
+ req.on("response", (res) => { res.resume(); res.on("end", done); res.on("error", done) })
197
+ req.on("error", done) // network down, DNS, refused, destroyed
198
+ req.on("timeout", () => { req.destroy(); done() })
199
+ // Backstop in case none of the above fires (a socket wedged before connect).
200
+ guard = setTimeout(() => { try { req.destroy() } catch { /* already gone */ } done() }, UPLOAD_TIMEOUT_MS)
201
+ req.end(body)
111
202
  })
112
- req.on("error", () => {}) // Swallow — fire-and-forget
113
- req.on("timeout", () => { req.destroy() })
114
- req.on("socket", (socket) => { socket.unref() }) // Don't keep process alive for response
115
- req.end(body)
116
203
  }
117
204
 
118
205
  function readStdin() {
119
206
  return new Promise((resolve) => {
120
207
  let resolved = false
208
+ let timer = null
121
209
  const chunks = []
210
+ // clearTimeout is load-bearing, not tidiness. A pending timer keeps the
211
+ // Node event loop alive after main() has finished, so without this the
212
+ // process lingers for the FULL STDIN_TIMEOUT_MS on every single
213
+ // invocation even though stdin closed in milliseconds. That silently ate
214
+ // the entire 5s budget Claude Code allows its UserPromptSubmit hook.
215
+ const done = () => {
216
+ if (resolved) return
217
+ resolved = true
218
+ if (timer) clearTimeout(timer)
219
+ resolve(chunks.join(""))
220
+ }
122
221
  process.stdin.setEncoding("utf8")
123
- process.stdin.on("data", (chunk) => chunks.push(chunk))
124
- process.stdin.on("end", () => {
125
- if (!resolved) { resolved = true; resolve(chunks.join("")) }
222
+ process.stdin.on("data", (chunk) => {
223
+ chunks.push(chunk)
224
+ // Resolve as soon as the payload is a COMPLETE JSON document instead of
225
+ // waiting for EOF.
226
+ //
227
+ // Not every hook runner closes stdin after writing. Cursor demonstrably
228
+ // does not: every Cursor prompt burned the full STDIN_TIMEOUT_MS, which
229
+ // killed the 5s-budget Claude entry outright (exit 1). Waiting for EOF
230
+ // therefore spends the whole hook budget doing nothing, and the budget is
231
+ // what the awaited /cli/activity upload has to fit inside — so on any
232
+ // such runner the process is killed before the upload lands and prompts
233
+ // silently stop reaching candidate_activity_logs. That is precisely the
234
+ // regression 2c1aeeb1b fixed for Cursor; resolving on a complete document
235
+ // removes the whole failure mode rather than re-tuning timeouts per tool.
236
+ //
237
+ // Safe against partial reads: every payload is a JSON object, and a
238
+ // truncated object never parses (`{"prompt":` throws). Worst case this
239
+ // never fires and we fall back to EOF or the timeout, i.e. today's
240
+ // behaviour.
241
+ if (isCompleteJson(chunks.join(""))) done()
126
242
  })
127
- setTimeout(() => {
128
- if (!resolved) { resolved = true; resolve(chunks.join("")) }
129
- }, STDIN_TIMEOUT_MS)
243
+ process.stdin.on("end", done)
244
+ process.stdin.on("error", done) // closed/absent stdin must not hang either
245
+ timer = setTimeout(done, STDIN_TIMEOUT_MS)
130
246
  })
131
247
  }
132
248
 
249
+ function isCompleteJson(buf) {
250
+ const trimmed = buf.trim()
251
+ if (!trimmed) return false
252
+ try {
253
+ JSON.parse(trimmed)
254
+ return true
255
+ } catch {
256
+ return false
257
+ }
258
+ }
259
+
133
260
  function extractPrompt(input, toolName) {
134
261
  try {
135
262
  const data = JSON.parse(input)
@@ -145,10 +272,24 @@ function extractPrompt(input, toolName) {
145
272
  }
146
273
  }
147
274
  case "copilot": {
148
- // Copilot CLI userPromptSubmitted: { timestamp, cwd, prompt }
275
+ // GitHub Copilot, both surfaces. We register the hook under the
276
+ // PascalCase `UserPromptSubmit` key, which Copilot CLI maps onto its
277
+ // internal `userPromptSubmitted` (alias map verified in the first-party
278
+ // @github/copilot SDK) and which VS Code Copilot Chat uses natively —
279
+ // so one config serves the terminal binary, the cloud agent, VS Code
280
+ // and JetBrains.
281
+ //
282
+ // Payloads differ slightly by surface, so read both spellings:
283
+ // Copilot CLI { sessionId | session_id, timestamp, cwd, prompt }
284
+ // VS Code Chat { hook_event_name, session_id?, transcript_path?,
285
+ // timestamp, prompt } <- note: no cwd
286
+ // The missing cwd is why this arm must never rely on the hook's working
287
+ // directory to locate the assessment; resolution comes from the
288
+ // registry fallback in findLitmusDir.
289
+ const prompt = data.prompt || null
149
290
  return {
150
- prompt: data.prompt || null,
151
- sessionId: null,
291
+ prompt: typeof prompt === "string" ? prompt : JSON.stringify(prompt),
292
+ sessionId: data.session_id || data.sessionId || null,
152
293
  }
153
294
  }
154
295
  case "codex": {
@@ -161,6 +302,25 @@ function extractPrompt(input, toolName) {
161
302
  sessionId: data.session_id || null,
162
303
  }
163
304
  }
305
+ case "cursor": {
306
+ // Cursor beforeSubmitPrompt. Payload captured verbatim from a real
307
+ // Cursor 3.13.25 agent session:
308
+ // { conversation_id, generation_id, model, model_id, model_params,
309
+ // composer_mode, prompt, attachments, session_id, hook_event_name,
310
+ // cursor_version, workspace_roots, user_email, transcript_path }
311
+ // session_id and conversation_id carry the same value; prefer session_id
312
+ // to match every other tool here. `workspace_roots` is load-bearing —
313
+ // Cursor runs the hook with cwd = ~/.cursor, so it is the only reliable
314
+ // pointer back to the assessment directory.
315
+ // Deliberately NOT captured: user_email (PII we have no use for) and
316
+ // transcript_path (points at Cursor's own on-disk agent transcript).
317
+ const prompt = data.prompt || null
318
+ return {
319
+ prompt: typeof prompt === "string" ? prompt : JSON.stringify(prompt),
320
+ sessionId: data.session_id || data.conversation_id || null,
321
+ workspaceRoots: Array.isArray(data.workspace_roots) ? data.workspace_roots : [],
322
+ }
323
+ }
164
324
  default: {
165
325
  // Best-effort: look for common field names
166
326
  const prompt = data.prompt || data.user_message || data.content || null
@@ -171,25 +331,156 @@ function extractPrompt(input, toolName) {
171
331
  }
172
332
  }
173
333
  } catch {
174
- return { prompt: null, sessionId: null }
334
+ return { prompt: null, sessionId: null, workspaceRoots: [] }
335
+ }
336
+ }
337
+
338
+ // Cursor requires a JSON verdict on stdout; non-JSON output trips a parse error
339
+ // in its hook runtime and the integration silently breaks. We are pure
340
+ // observation and never block a prompt, so the verdict is a constant.
341
+ //
342
+ // Emitted FIRST, before any capture work, so a slow filesystem, a missing
343
+ // assessment, or an outright crash can never delay or swallow it — the
344
+ // candidate's agent proceeds regardless of whether we captured anything.
345
+ //
346
+ // Gated on the RAW argv tool, never on `effectiveTool`: Claude Code injects
347
+ // hook stdout into the candidate's prompt as added context, and `effectiveTool`
348
+ // relabels claude -> cursor when Claude Code is driven from inside Cursor.
349
+ // Keying off the relabel would leak `{"continue":true}` into those prompts.
350
+ function emitCursorVerdictIfNeeded() {
351
+ if (tool !== "cursor") return
352
+ try {
353
+ process.stdout.write(JSON.stringify({ continue: true }))
354
+ } catch {
355
+ // Closed/broken stdout — Cursor treats absent output as "proceed" anyway.
356
+ }
357
+ }
358
+
359
+ // Cursor merges EVERY config source it finds and runs all matching hooks, and it
360
+ // translates Claude Code's `UserPromptSubmit` onto its own `beforeSubmitPrompt`.
361
+ // Since `litmus init` installs into both ~/.cursor/hooks.json and
362
+ // ~/.claude/settings.json, one Cursor prompt fires us TWICE. Confirmed in
363
+ // Cursor's own hooks log:
364
+ //
365
+ // Found 2 hook(s) to execute for step: beforeSubmitPrompt
366
+ // Executing hook 1/2 from user config (~/.cursor/hooks.json)
367
+ // Executing hook 2/2 from claude-user config (~/.claude/settings.json)
368
+ //
369
+ // Both invocations got the identical Cursor payload, so a single prompt produced
370
+ // two activity rows with the same text and timestamp, differing only in `tool`
371
+ // (`cursor` and `claude`). That double-counts prompts and misattributes half of
372
+ // them to Claude Code, which was never involved.
373
+ //
374
+ // Dedupe by identity rather than by inspecting the log: the two hooks run
375
+ // concurrently (observed writing at the same millisecond), so any
376
+ // read-then-write check would race. Instead the claude-registered invocation
377
+ // stands down when the payload is visibly Cursor's — `cursor_version` is present
378
+ // on every beforeSubmitPrompt payload and never on a Claude Code one — leaving
379
+ // the dedicated cursor hook as the single writer.
380
+ //
381
+ // Gated on our cursor hook actually being installed. If that install failed
382
+ // (unwritable ~/.cursor, refused config shape) nothing else would capture the
383
+ // prompt, and dropping it would be worse than a duplicate.
384
+ function isCursorPayload(data) {
385
+ if (!data || typeof data !== "object") return false
386
+ return typeof data.cursor_version === "string"
387
+ || data.hook_event_name === "beforeSubmitPrompt"
388
+ }
389
+
390
+ // Dedupe by CLAIMING the prompt, not by predicting whether the sibling hook will
391
+ // succeed. An earlier version inspected ~/.cursor/hooks.json and stood down if a
392
+ // Cursor hook looked installed, but that is guesswork about another process, and
393
+ // the failure is asymmetric: guess wrong and the prompt is lost entirely, where
394
+ // the worst case in the other direction is a duplicate row. A hook can be
395
+ // configured and still not run (logger missing, truncated, unreadable, or broken
396
+ // in a way no amount of statting reveals), so there is no check that makes the
397
+ // prediction sound.
398
+ //
399
+ // Instead, both invocations race to create a marker for this specific prompt.
400
+ // `openSync(..., "wx")` is O_CREAT|O_EXCL, so exactly one wins — no read-then-write
401
+ // window, which matters because the two hooks run concurrently (observed writing
402
+ // in the same millisecond). The winner writes the event; the loser exits. If the
403
+ // Cursor hook never runs at all, the Claude invocation simply wins uncontested.
404
+ //
405
+ // Keyed on `generation_id`, which is per prompt submission. NOT conversation_id,
406
+ // which is stable for a whole thread and would collapse every prompt in a
407
+ // conversation into one. If it is absent we do not dedupe at all — a duplicate is
408
+ // the acceptable failure, a dropped prompt is not.
409
+ const CLAIM_DIR = path.join(os.homedir() || os.tmpdir(), ".litmus", "claims")
410
+ const CLAIM_TTL_MS = 60 * 60 * 1000
411
+
412
+ // Keep the markers from accumulating in the candidate's HOME forever. Cheap: this
413
+ // directory only ever holds an hour of prompts.
414
+ function pruneClaims() {
415
+ try {
416
+ const cutoff = Date.now() - CLAIM_TTL_MS
417
+ for (const name of fs.readdirSync(CLAIM_DIR)) {
418
+ const p = path.join(CLAIM_DIR, name)
419
+ try {
420
+ if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p)
421
+ } catch { /* raced with another invocation — fine */ }
422
+ }
423
+ } catch { /* no dir yet, or unreadable */ }
424
+ }
425
+
426
+ /** True when THIS invocation should write the event. */
427
+ function claimPrompt(generationId) {
428
+ if (typeof generationId !== "string" || !generationId) return true // can't dedupe → capture
429
+ const safe = generationId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128)
430
+ try {
431
+ fs.mkdirSync(CLAIM_DIR, { recursive: true })
432
+ pruneClaims()
433
+ fs.closeSync(fs.openSync(path.join(CLAIM_DIR, safe), "wx"))
434
+ return true
435
+ } catch (e) {
436
+ if (e && e.code === "EEXIST") return false // sibling already has it
437
+ return true // unwritable claim dir → fail toward capture
175
438
  }
176
439
  }
177
440
 
178
441
  async function main() {
179
- const litmusDir = findLitmusDir()
180
- if (!litmusDir) return // Not in a Litmus assessment — silently exit
442
+ emitCursorVerdictIfNeeded()
181
443
 
444
+ // stdin is read BEFORE resolving the assessment dir: the payload is what
445
+ // tells us where the workspace actually is (see findLitmusDir's hintDirs).
182
446
  const raw = await readStdin()
183
447
  if (!raw.trim()) return
184
448
 
185
- // Detect Cursor firing Claude Code hooks Cursor sets CURSOR_TRACE_ID
186
- const effectiveTool = (tool === "claude" && process.env.CURSOR_TRACE_ID)
449
+ // Detect Cursor firing Claude Code hooks. CURSOR_TRACE_ID is checked for the
450
+ // real Claude-Code-inside-Cursor case (a Claude-shaped payload from Cursor's
451
+ // integrated terminal), but it is NOT set when Cursor's own hooks service
452
+ // spawns us — verified against a real session, where the compat-path event
453
+ // stayed labelled `claude`. The payload shape is the reliable signal.
454
+ let parsed = null
455
+ try { parsed = JSON.parse(raw) } catch { /* extractPrompt reports the failure */ }
456
+ const cursorShaped = isCursorPayload(parsed)
457
+
458
+ // The relabel is what makes the claim below label-safe: whichever invocation
459
+ // wins the race, a Cursor-shaped payload is recorded as tool="cursor", so the
460
+ // event never blames Claude Code for a prompt it had no part in.
461
+ const effectiveTool = (tool === "claude" && (cursorShaped || process.env.CURSOR_TRACE_ID))
187
462
  ? "cursor"
188
463
  : tool
189
464
 
190
- const { prompt, sessionId } = extractPrompt(raw, effectiveTool)
465
+ const { prompt, sessionId, workspaceRoots } = extractPrompt(raw, effectiveTool)
191
466
  if (!prompt) return
192
467
 
468
+ // Explicit hint first: it is the only source that is correct by construction.
469
+ // Then the tool-supplied workspace roots, then cwd, then the registry.
470
+ const hintDirs = explicitAssessmentDir
471
+ ? [explicitAssessmentDir, ...(workspaceRoots || [])]
472
+ : (workspaceRoots || [])
473
+ const litmusDir = findLitmusDir(hintDirs)
474
+ if (!litmusDir) return // Not in a Litmus assessment — silently exit
475
+
476
+ // Claim as LATE as possible — only once we know we have a prompt and somewhere
477
+ // to put it. Claiming earlier would let an invocation that then bails (no
478
+ // prompt, no assessment in scope) consume the claim and silence the sibling
479
+ // that would have written successfully.
480
+ if (cursorShaped && !claimPrompt(parsed && parsed.generation_id)) {
481
+ return // the sibling hook is writing this same prompt
482
+ }
483
+
193
484
  const truncated = prompt.length > MAX_PROMPT_LENGTH
194
485
  ? prompt.slice(0, MAX_PROMPT_LENGTH) + "...[truncated]"
195
486
  : prompt
@@ -210,12 +501,14 @@ async function main() {
210
501
  // Non-critical
211
502
  }
212
503
 
213
- // 2. Upload to server (fire-and-forget, for real-time backup)
504
+ // 2. Upload to the server so the event reaches candidate_activity_logs. Awaited
505
+ // (bounded by UPLOAD_TIMEOUT_MS) because the process now exits far too fast for
506
+ // an unawaited request to survive — see uploadEvent.
214
507
  const config = readConfig(litmusDir)
215
- uploadEvent(config, event)
508
+ await uploadEvent(config, event)
216
509
 
217
- // No explicit process.exit() — the event loop drains naturally.
218
- // The HTTP socket is unref'd so it won't keep the process alive.
510
+ // No explicit process.exit() — the event loop drains naturally once the upload
511
+ // has settled and its timers are cleared.
219
512
  }
220
513
 
221
514
  main().catch(() => process.exit(0))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litmus-cli",
3
- "version": "1.3.9",
3
+ "version": "1.3.11",
4
4
  "description": "CLI tool for Litmus engineering assessments",
5
5
  "license": "MIT",
6
6
  "author": "elenazhao",