@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.
- package/package.json +1 -1
- package/src/hooks.js +131 -18
package/package.json
CHANGED
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
|
-
//
|
|
75
|
-
//
|
|
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
|
|
295
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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);
|