@ariso-ai/ari-hooks 0.1.10 → 0.1.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/hooks.js +131 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ariso-ai/ari-hooks",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Set up Claude Code hooks that share your requests and their outcomes with Ari",
5
5
  "type": "module",
6
6
  "bin": {
package/src/hooks.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  writeSync,
8
8
  rmSync,
9
9
  appendFileSync,
10
+ statSync,
10
11
  } from 'node:fs';
11
12
  import { configDir, loadConfig, getApiUrl } from './config.js';
12
13
 
@@ -71,13 +72,12 @@ const isCursorInput = (input) => typeof input.cursor_version === 'string';
71
72
  // bakes it into each agent's config file).
72
73
  const AGENT_TYPES = { claude: 'claude-code', codex: 'codex', cursor: 'cursor' };
73
74
 
74
- // Which coding agent produced this turn. The --agent flag from the hook command
75
- // is the reliable source; fall back to sniffing the payload for installs that
76
- // predate the flag — Cursor stamps cursor_version, Claude Code sends a
77
- // transcript_path, and Codex has neither (it hands us last_assistant_message).
75
+ // Codex also sends transcript_path. Its turn_id distinguishes its turn hooks
76
+ // from Claude's, including older installations without an --agent flag.
78
77
  function agentTypeOf(input, agent) {
79
- if (AGENT_TYPES[agent]) return AGENT_TYPES[agent];
80
78
  if (isCursorInput(input)) return 'cursor';
79
+ if (typeof input.turn_id === 'string' && input.turn_id) return 'codex';
80
+ if (AGENT_TYPES[agent]) return AGENT_TYPES[agent];
81
81
  if (input.transcript_path) return 'claude-code';
82
82
  return 'codex';
83
83
  }
@@ -120,6 +120,8 @@ async function onUserPromptSubmit(input) {
120
120
  const prompt = stripIdeSelection(input.prompt);
121
121
  if (!prompt) return;
122
122
  const session = loadSession(sessionId);
123
+ if (input.turn_id && session.turnId === input.turn_id) return;
124
+ if (input.turn_id) session.turnId = input.turn_id;
123
125
  session.prompts.push(taskNotificationStandIn(prompt) ?? prompt);
124
126
  saveSession(sessionId, session);
125
127
  }
@@ -265,6 +267,50 @@ function extractTurnStats(entries, promptCount) {
265
267
  return { model, tokens };
266
268
  }
267
269
 
270
+ // Codex records cumulative session usage, not usage on assistant messages.
271
+ // Subtract the last total before this turn; repeated token_count events must
272
+ // not be summed. Cached input and reasoning output are already in the total.
273
+ function extractCodexTurnStats(entries, turnId) {
274
+ let boundary = -1;
275
+ for (let i = 0; i < entries.length; i++) {
276
+ const { type, payload } = entries[i];
277
+ const startsTurn = (type === 'event_msg' && payload?.type === 'task_started') ||
278
+ type === 'turn_context';
279
+ if (turnId) {
280
+ if (startsTurn && payload?.turn_id === turnId) {
281
+ boundary = i;
282
+ break;
283
+ }
284
+ } else if (type === 'event_msg' && payload?.type === 'user_message') {
285
+ boundary = i;
286
+ }
287
+ }
288
+ if (boundary < 0) return { model: null, tokens: null };
289
+
290
+ let baseline = 0;
291
+ let total = null;
292
+ let model = null;
293
+ for (let i = 0; i < entries.length; i++) {
294
+ const { type, payload } = entries[i];
295
+ if (i > boundary && turnId && payload?.turn_id && payload.turn_id !== turnId &&
296
+ (type === 'turn_context' || (type === 'event_msg' && payload.type === 'task_started'))) break;
297
+ if (i >= boundary && type === 'turn_context') model = modelOf(payload?.model) ?? model;
298
+ if (type === 'event_msg' && payload?.type === 'token_count') {
299
+ const usage = payload.info?.total_token_usage;
300
+ const count = usage?.total_tokens ?? (
301
+ Number.isFinite(usage?.input_tokens) && Number.isFinite(usage?.output_tokens)
302
+ ? usage.input_tokens + usage.output_tokens : null
303
+ );
304
+ if (Number.isFinite(count) && count >= 0) {
305
+ if (i < boundary) baseline = count;
306
+ else total = count;
307
+ }
308
+ }
309
+ if (i >= boundary && type === 'event_msg' && payload?.type === 'task_complete') break;
310
+ }
311
+ return { model, tokens: total != null && total >= baseline ? total - baseline : null };
312
+ }
313
+
268
314
  const clamp = (text) =>
269
315
  text.length > MAX_TEXT_LENGTH ? text.slice(0, MAX_TEXT_LENGTH) : text;
270
316
 
@@ -282,6 +328,7 @@ async function onStop(input, agent) {
282
328
 
283
329
  const session = loadSession(sessionId);
284
330
  if (session.prompts.length === 0) return;
331
+ if (input.turn_id && session.turnId && input.turn_id !== session.turnId) return;
285
332
 
286
333
  // Cursor sessions get the outcome pushed to us via afterAgentResponse; Codex
287
334
  // hands us the final text directly on the Stop payload as
@@ -291,14 +338,32 @@ async function onStop(input, agent) {
291
338
  const payloadOutcome = session.outcome ?? input.last_assistant_message ?? null;
292
339
  let outcome = payloadOutcome;
293
340
  // Hosts that hand us the outcome directly may also name the model on their
294
- // payloads; Claude Code's model and token usage live only in the
295
- // transcript. Token counts are transcript-only the other hosts don't
296
- // report usage. Recent Claude Code also sends last_assistant_message, so
341
+ // payloads; Claude Code and Codex token usage lives in their transcripts.
342
+ // Recent Claude Code also sends last_assistant_message, so
297
343
  // the transcript is read for stats even when the outcome is already in
298
344
  // hand — but a stats failure must never cost us the activity itself.
299
345
  let model = modelOf(input.model) ?? session.model ?? null;
300
346
  let tokens = null;
301
- if (input.transcript_path) {
347
+ const agentType = agentTypeOf(input, agent);
348
+ if (input.transcript_path && agentType === 'codex') {
349
+ const deadline = Date.now() + OUTCOME_SETTLE_TIMEOUT_MS;
350
+ for (;;) {
351
+ try {
352
+ const { entries, tailPartial } = parseTranscript(input.transcript_path);
353
+ if (tailPartial && Date.now() < deadline) {
354
+ await sleep(OUTCOME_POLL_INTERVAL_MS);
355
+ continue;
356
+ }
357
+ const stats = extractCodexTurnStats(entries, input.turn_id ?? session.turnId);
358
+ model = stats.model ?? model;
359
+ tokens = stats.tokens;
360
+ } catch (err) {
361
+ if (err.code !== 'ENOENT') logError(err);
362
+ }
363
+ break;
364
+ }
365
+ }
366
+ if (input.transcript_path && agentType === 'claude-code') {
302
367
  const deadline = Date.now() + OUTCOME_SETTLE_TIMEOUT_MS;
303
368
  for (;;) {
304
369
  let entries, tailPartial;
@@ -349,7 +414,47 @@ async function onStop(input, agent) {
349
414
  throw new Error(`POST /agent-activities failed: ${response.status}`);
350
415
  }
351
416
 
352
- rmSync(sessionPath(sessionId), { force: true });
417
+ if (session.turnId) {
418
+ // Retain the turn receipt so a delayed duplicate prompt hook cannot
419
+ // resurrect an already submitted turn. A new turn id is still accepted.
420
+ saveSession(sessionId, { prompts: [], turnId: session.turnId });
421
+ } else {
422
+ rmSync(sessionPath(sessionId), { force: true });
423
+ }
424
+ }
425
+
426
+ // Project and user hooks can run concurrently. Serialize their read/modify/
427
+ // send cycle across processes, including prompts, so only one Stop consumes
428
+ // the queued request. Expire abandoned locks after more than a hook's normal
429
+ // lifetime; failures leave the session available for retry.
430
+ async function withSessionLock(sessionId, action) {
431
+ mkdirSync(sessionsDir(), { recursive: true });
432
+ const lockPath = `${sessionPath(sessionId)}.lock`;
433
+ const deadline = Date.now() + 25_000;
434
+ for (;;) {
435
+ try {
436
+ mkdirSync(lockPath);
437
+ break;
438
+ } catch (err) {
439
+ if (err.code !== 'EEXIST') throw err;
440
+ try {
441
+ if (Date.now() - statSync(lockPath).mtimeMs > 60_000) {
442
+ rmSync(lockPath, { recursive: true, force: true });
443
+ continue;
444
+ }
445
+ } catch (err) {
446
+ if (err.code === 'ENOENT') continue;
447
+ throw err;
448
+ }
449
+ if (Date.now() >= deadline) throw new Error('Timed out waiting for session hook lock');
450
+ await sleep(50);
451
+ }
452
+ }
453
+ try {
454
+ await action();
455
+ } finally {
456
+ rmSync(lockPath, { recursive: true, force: true });
457
+ }
353
458
  }
354
459
 
355
460
  const MAX_TASKS = 3;
@@ -538,14 +643,22 @@ export async function runHook(event, agent) {
538
643
  try {
539
644
  const raw = await readStdin();
540
645
  const input = raw ? JSON.parse(raw) : {};
541
- if (event === 'user-prompt-submit') {
542
- await onUserPromptSubmit(input);
543
- } else if (event === 'agent-response') {
544
- await onAgentResponse(input);
545
- } else if (event === 'stop') {
546
- await onStop(input, agent);
547
- } else if (event === 'session-start') {
548
- await onSessionStart(input);
646
+ const dispatch = async () => {
647
+ if (event === 'user-prompt-submit') {
648
+ await onUserPromptSubmit(input);
649
+ } else if (event === 'agent-response') {
650
+ await onAgentResponse(input);
651
+ } else if (event === 'stop') {
652
+ await onStop(input, agent);
653
+ } else if (event === 'session-start') {
654
+ await onSessionStart(input);
655
+ }
656
+ };
657
+ const sessionId = sessionIdOf(input);
658
+ if (sessionId && ['user-prompt-submit', 'agent-response', 'stop'].includes(event)) {
659
+ await withSessionLock(sessionId, dispatch);
660
+ } else {
661
+ await dispatch();
549
662
  }
550
663
  } catch (err) {
551
664
  logError(err);