@ariso-ai/ari-hooks 0.1.9 → 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 +369 -76
package/package.json
CHANGED
package/src/hooks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
2
3
|
import {
|
|
3
4
|
mkdirSync,
|
|
4
5
|
readFileSync,
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
writeSync,
|
|
7
8
|
rmSync,
|
|
8
9
|
appendFileSync,
|
|
10
|
+
statSync,
|
|
9
11
|
} from 'node:fs';
|
|
10
12
|
import { configDir, loadConfig, getApiUrl } from './config.js';
|
|
11
13
|
|
|
@@ -70,13 +72,12 @@ const isCursorInput = (input) => typeof input.cursor_version === 'string';
|
|
|
70
72
|
// bakes it into each agent's config file).
|
|
71
73
|
const AGENT_TYPES = { claude: 'claude-code', codex: 'codex', cursor: 'cursor' };
|
|
72
74
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// predate the flag — Cursor stamps cursor_version, Claude Code sends a
|
|
76
|
-
// 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.
|
|
77
77
|
function agentTypeOf(input, agent) {
|
|
78
|
-
if (AGENT_TYPES[agent]) return AGENT_TYPES[agent];
|
|
79
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];
|
|
80
81
|
if (input.transcript_path) return 'claude-code';
|
|
81
82
|
return 'codex';
|
|
82
83
|
}
|
|
@@ -101,6 +102,13 @@ function taskNotificationStandIn(prompt) {
|
|
|
101
102
|
: 'The coding agent ran a background task.';
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
// The IDE integration injects the user's current editor selection into the
|
|
106
|
+
// prompt as an <ide_selection> block — host-added context, not text the user
|
|
107
|
+
// typed. Strip it before recording so the activity feed shows only what the
|
|
108
|
+
// user actually said.
|
|
109
|
+
const IDE_SELECTION_RE = /<ide_selection>[\s\S]*?<\/ide_selection>/g;
|
|
110
|
+
const stripIdeSelection = (prompt) => prompt.replace(IDE_SELECTION_RE, '').trim();
|
|
111
|
+
|
|
104
112
|
/**
|
|
105
113
|
* UserPromptSubmit (Claude Code) / beforeSubmitPrompt (Cursor): remember the
|
|
106
114
|
* prompt so the Stop hook can pair it with the turn's outcome. Both hosts
|
|
@@ -109,11 +117,20 @@ function taskNotificationStandIn(prompt) {
|
|
|
109
117
|
async function onUserPromptSubmit(input) {
|
|
110
118
|
const sessionId = sessionIdOf(input);
|
|
111
119
|
if (!sessionId || typeof input.prompt !== 'string') return;
|
|
120
|
+
const prompt = stripIdeSelection(input.prompt);
|
|
121
|
+
if (!prompt) return;
|
|
112
122
|
const session = loadSession(sessionId);
|
|
113
|
-
|
|
123
|
+
if (input.turn_id && session.turnId === input.turn_id) return;
|
|
124
|
+
if (input.turn_id) session.turnId = input.turn_id;
|
|
125
|
+
session.prompts.push(taskNotificationStandIn(prompt) ?? prompt);
|
|
114
126
|
saveSession(sessionId, session);
|
|
115
127
|
}
|
|
116
128
|
|
|
129
|
+
// A model name from a hook payload, or null — hosts that include one send a
|
|
130
|
+
// plain non-empty string.
|
|
131
|
+
const modelOf = (value) =>
|
|
132
|
+
typeof value === 'string' && value.trim() ? value : null;
|
|
133
|
+
|
|
117
134
|
/**
|
|
118
135
|
* afterAgentResponse (Cursor only): Cursor's transcript is not the Claude
|
|
119
136
|
* Code JSONL that extractOutcome can parse, so capture the final assistant
|
|
@@ -125,6 +142,8 @@ async function onAgentResponse(input) {
|
|
|
125
142
|
if (!sessionId || typeof input.text !== 'string' || !input.text.trim()) return;
|
|
126
143
|
const session = loadSession(sessionId);
|
|
127
144
|
session.outcome = input.text;
|
|
145
|
+
const model = modelOf(input.model);
|
|
146
|
+
if (model) session.model = model;
|
|
128
147
|
saveSession(sessionId, session);
|
|
129
148
|
}
|
|
130
149
|
|
|
@@ -139,17 +158,7 @@ function assistantText(entry) {
|
|
|
139
158
|
.trim();
|
|
140
159
|
}
|
|
141
160
|
|
|
142
|
-
|
|
143
|
-
* Pull the final assistant text out of the transcript (JSONL). This is the
|
|
144
|
-
* "outcome" — we deliberately skip the intermediate steps/tool calls.
|
|
145
|
-
*
|
|
146
|
-
* `settled` reports whether the exchange actually ends in assistant text.
|
|
147
|
-
* When the transcript instead ends at a tool call/result or a half-written
|
|
148
|
-
* line, the final message hasn't been flushed yet and `text` is only the
|
|
149
|
-
* last narration before a tool ran — the caller should re-read rather than
|
|
150
|
-
* ship that as the outcome.
|
|
151
|
-
*/
|
|
152
|
-
function extractOutcome(transcriptPath) {
|
|
161
|
+
function parseTranscript(transcriptPath) {
|
|
153
162
|
const entries = [];
|
|
154
163
|
let tailPartial = false;
|
|
155
164
|
for (const line of readFileSync(transcriptPath, 'utf8').split('\n')) {
|
|
@@ -161,7 +170,20 @@ function extractOutcome(transcriptPath) {
|
|
|
161
170
|
tailPartial = true; // a line still being written
|
|
162
171
|
}
|
|
163
172
|
}
|
|
173
|
+
return { entries, tailPartial };
|
|
174
|
+
}
|
|
164
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Pull the final assistant text out of the transcript entries. This is the
|
|
178
|
+
* "outcome" — we deliberately skip the intermediate steps/tool calls.
|
|
179
|
+
*
|
|
180
|
+
* `settled` reports whether the exchange actually ends in assistant text.
|
|
181
|
+
* When the transcript instead ends at a tool call/result or a half-written
|
|
182
|
+
* line, the final message hasn't been flushed yet and `text` is only the
|
|
183
|
+
* last narration before a tool ran — the caller should re-read rather than
|
|
184
|
+
* ship that as the outcome.
|
|
185
|
+
*/
|
|
186
|
+
function extractOutcome(entries, tailPartial) {
|
|
165
187
|
let settled = tailPartial ? false : null;
|
|
166
188
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
167
189
|
const entry = entries[i];
|
|
@@ -186,12 +208,116 @@ function extractOutcome(transcriptPath) {
|
|
|
186
208
|
return { text: null, settled: false };
|
|
187
209
|
}
|
|
188
210
|
|
|
211
|
+
// A user entry that is an actual typed prompt — not a tool result, not host
|
|
212
|
+
// bookkeeping (isMeta), not a subagent's inner conversation (isSidechain).
|
|
213
|
+
function isUserPrompt(entry) {
|
|
214
|
+
if (entry.type !== 'user' || entry.isSidechain || entry.isMeta) return false;
|
|
215
|
+
const content = entry.message?.content;
|
|
216
|
+
if (typeof content === 'string') return content.trim().length > 0;
|
|
217
|
+
return (
|
|
218
|
+
Array.isArray(content) &&
|
|
219
|
+
content.some((block) => block.type === 'text') &&
|
|
220
|
+
!content.some((block) => block.type === 'tool_result')
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Model and token usage for the turn being reported. The transcript holds
|
|
226
|
+
* the whole session, so walk back past `promptCount` user prompts (this
|
|
227
|
+
* send covers that many queued prompts) and only count assistant entries
|
|
228
|
+
* after that point. Usage is keyed by message id — a message split across
|
|
229
|
+
* several JSONL entries (one per content block) repeats the same usage on
|
|
230
|
+
* each, and the last entry wins — then totalled over everything the API
|
|
231
|
+
* metered: input, cache writes/reads, and output. The model is named by
|
|
232
|
+
* the turn's last top-level assistant message; sidechain (subagent)
|
|
233
|
+
* entries still count toward tokens.
|
|
234
|
+
*/
|
|
235
|
+
function extractTurnStats(entries, promptCount) {
|
|
236
|
+
let boundary = -1;
|
|
237
|
+
let remaining = Math.max(1, promptCount);
|
|
238
|
+
for (let i = entries.length - 1; i >= 0 && remaining > 0; i--) {
|
|
239
|
+
if (isUserPrompt(entries[i])) {
|
|
240
|
+
boundary = i;
|
|
241
|
+
remaining--;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let model = null;
|
|
246
|
+
const usageById = new Map();
|
|
247
|
+
for (let i = boundary + 1; i < entries.length; i++) {
|
|
248
|
+
const entry = entries[i];
|
|
249
|
+
if (entry.type !== 'assistant') continue;
|
|
250
|
+
const message = entry.message ?? {};
|
|
251
|
+
// '<synthetic>' marks host-injected error messages, not a real model.
|
|
252
|
+
if (!entry.isSidechain && modelOf(message.model) && message.model !== '<synthetic>') {
|
|
253
|
+
model = message.model;
|
|
254
|
+
}
|
|
255
|
+
if (message.usage) usageById.set(message.id ?? `entry-${i}`, message.usage);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let tokens = null;
|
|
259
|
+
for (const usage of usageById.values()) {
|
|
260
|
+
tokens =
|
|
261
|
+
(tokens ?? 0) +
|
|
262
|
+
(usage.input_tokens ?? 0) +
|
|
263
|
+
(usage.cache_creation_input_tokens ?? 0) +
|
|
264
|
+
(usage.cache_read_input_tokens ?? 0) +
|
|
265
|
+
(usage.output_tokens ?? 0);
|
|
266
|
+
}
|
|
267
|
+
return { model, tokens };
|
|
268
|
+
}
|
|
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
|
+
|
|
189
314
|
const clamp = (text) =>
|
|
190
315
|
text.length > MAX_TEXT_LENGTH ? text.slice(0, MAX_TEXT_LENGTH) : text;
|
|
191
316
|
|
|
192
317
|
/**
|
|
193
318
|
* Stop: the turn is over — send the accumulated request(s) plus the final
|
|
194
|
-
* assistant message
|
|
319
|
+
* assistant message (and, when the host exposes them, the model used and
|
|
320
|
+
* the turn's token count) to Ari, then clear the per-session state.
|
|
195
321
|
*/
|
|
196
322
|
async function onStop(input, agent) {
|
|
197
323
|
// stop_hook_active means a stop hook already forced Claude to continue;
|
|
@@ -202,19 +328,61 @@ async function onStop(input, agent) {
|
|
|
202
328
|
|
|
203
329
|
const session = loadSession(sessionId);
|
|
204
330
|
if (session.prompts.length === 0) return;
|
|
331
|
+
if (input.turn_id && session.turnId && input.turn_id !== session.turnId) return;
|
|
205
332
|
|
|
206
333
|
// Cursor sessions get the outcome pushed to us via afterAgentResponse; Codex
|
|
207
334
|
// hands us the final text directly on the Stop payload as
|
|
208
335
|
// last_assistant_message; Claude Code sessions read it from the transcript,
|
|
209
336
|
// waiting for the final assistant message to land there (on timeout, fall
|
|
210
337
|
// back to the last text we did find — best effort).
|
|
211
|
-
|
|
212
|
-
|
|
338
|
+
const payloadOutcome = session.outcome ?? input.last_assistant_message ?? null;
|
|
339
|
+
let outcome = payloadOutcome;
|
|
340
|
+
// Hosts that hand us the outcome directly may also name the model on their
|
|
341
|
+
// payloads; Claude Code and Codex token usage lives in their transcripts.
|
|
342
|
+
// Recent Claude Code also sends last_assistant_message, so
|
|
343
|
+
// the transcript is read for stats even when the outcome is already in
|
|
344
|
+
// hand — but a stats failure must never cost us the activity itself.
|
|
345
|
+
let model = modelOf(input.model) ?? session.model ?? null;
|
|
346
|
+
let tokens = null;
|
|
347
|
+
const agentType = agentTypeOf(input, agent);
|
|
348
|
+
if (input.transcript_path && agentType === 'codex') {
|
|
213
349
|
const deadline = Date.now() + OUTCOME_SETTLE_TIMEOUT_MS;
|
|
214
350
|
for (;;) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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') {
|
|
367
|
+
const deadline = Date.now() + OUTCOME_SETTLE_TIMEOUT_MS;
|
|
368
|
+
for (;;) {
|
|
369
|
+
let entries, tailPartial;
|
|
370
|
+
try {
|
|
371
|
+
({ entries, tailPartial } = parseTranscript(input.transcript_path));
|
|
372
|
+
} catch (err) {
|
|
373
|
+
logError(err);
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
const { text, settled } = extractOutcome(entries, tailPartial);
|
|
377
|
+
outcome = payloadOutcome ?? text;
|
|
378
|
+
// Even with the outcome in hand, wait for the final message to flush
|
|
379
|
+
// so its usage makes it into the token count.
|
|
380
|
+
if (settled || Date.now() >= deadline) {
|
|
381
|
+
const stats = extractTurnStats(entries, session.prompts.length);
|
|
382
|
+
model = stats.model ?? model;
|
|
383
|
+
tokens = stats.tokens;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
218
386
|
await sleep(OUTCOME_POLL_INTERVAL_MS);
|
|
219
387
|
}
|
|
220
388
|
}
|
|
@@ -236,6 +404,9 @@ async function onStop(input, agent) {
|
|
|
236
404
|
agent_type: agentTypeOf(input, agent),
|
|
237
405
|
// Cursor sends workspace_roots instead of cwd.
|
|
238
406
|
cwd: input.cwd ?? input.workspace_roots?.[0] ?? process.cwd(),
|
|
407
|
+
// Only sent when known — the API treats absent and unknown alike.
|
|
408
|
+
...(model && { primary_model: model }),
|
|
409
|
+
...(tokens != null && { token_count: tokens }),
|
|
239
410
|
}),
|
|
240
411
|
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
241
412
|
});
|
|
@@ -243,7 +414,47 @@ async function onStop(input, agent) {
|
|
|
243
414
|
throw new Error(`POST /agent-activities failed: ${response.status}`);
|
|
244
415
|
}
|
|
245
416
|
|
|
246
|
-
|
|
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
|
+
}
|
|
247
458
|
}
|
|
248
459
|
|
|
249
460
|
const MAX_TASKS = 3;
|
|
@@ -252,23 +463,7 @@ const MAX_TASK_NAME_LENGTH = 200;
|
|
|
252
463
|
const oneLine = (text) =>
|
|
253
464
|
text.replace(/\s+/g, ' ').trim().slice(0, MAX_TASK_NAME_LENGTH);
|
|
254
465
|
|
|
255
|
-
|
|
256
|
-
* SessionStart: ask Ari for the top tasks Claude can take care of right now
|
|
257
|
-
* and surface them at boot — a visible list for the user (systemMessage)
|
|
258
|
-
* plus the full prompts for Claude (additionalContext) so it can run
|
|
259
|
-
* whichever one the user picks.
|
|
260
|
-
*/
|
|
261
|
-
async function onSessionStart(input) {
|
|
262
|
-
// Compaction restarts the session mid-conversation; the tasks were
|
|
263
|
-
// already offered, so don't show (or inject) them again.
|
|
264
|
-
if (input.source === 'compact') return;
|
|
265
|
-
// Cursor also fires sessionStart for headless background agents — there is
|
|
266
|
-
// no user watching who could pick a task.
|
|
267
|
-
if (input.is_background_agent) return;
|
|
268
|
-
|
|
269
|
-
const config = loadConfig();
|
|
270
|
-
if (!config.token) return;
|
|
271
|
-
|
|
466
|
+
async function fetchTasks(config) {
|
|
272
467
|
const response = await fetch(new URL('/agent-tasks', getApiUrl(config)), {
|
|
273
468
|
headers: { Authorization: `Bearer ${config.token}` },
|
|
274
469
|
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
@@ -280,7 +475,7 @@ async function onSessionStart(input) {
|
|
|
280
475
|
const body = await response.json();
|
|
281
476
|
// The API wraps the list ({ tasks: [...] }); accept a bare array too.
|
|
282
477
|
const list = Array.isArray(body) ? body : Array.isArray(body?.tasks) ? body.tasks : [];
|
|
283
|
-
|
|
478
|
+
return list
|
|
284
479
|
.filter(
|
|
285
480
|
(t) =>
|
|
286
481
|
t &&
|
|
@@ -290,33 +485,115 @@ async function onSessionStart(input) {
|
|
|
290
485
|
t.prompt.trim()
|
|
291
486
|
)
|
|
292
487
|
.slice(0, MAX_TASKS);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const NPM_PACKAGE = '@ariso-ai/ari-hooks';
|
|
491
|
+
const VERSION_CHECK_TIMEOUT_MS = 3_000;
|
|
492
|
+
|
|
493
|
+
const installedVersion = () =>
|
|
494
|
+
JSON.parse(
|
|
495
|
+
readFileSync(
|
|
496
|
+
join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
|
|
497
|
+
'utf8'
|
|
498
|
+
)
|
|
499
|
+
).version;
|
|
500
|
+
|
|
501
|
+
// Plain x.y.z releases only — no prerelease/build-metadata tags to worry about.
|
|
502
|
+
const isNewer = (latest, current) => {
|
|
503
|
+
const a = latest.split('.').map(Number);
|
|
504
|
+
const b = current.split('.').map(Number);
|
|
505
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
506
|
+
const x = a[i] ?? 0;
|
|
507
|
+
const y = b[i] ?? 0;
|
|
508
|
+
if (x !== y) return x > y;
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Compare the installed version against what npm has published. A stale or
|
|
515
|
+
* unreachable registry must never break session start, so any failure just
|
|
516
|
+
* skips the check (returns null, same as "up to date").
|
|
517
|
+
*/
|
|
518
|
+
async function checkForUpdate() {
|
|
519
|
+
try {
|
|
520
|
+
const registryUrl = process.env.ARI_HOOKS_REGISTRY_URL || 'https://registry.npmjs.org';
|
|
521
|
+
const response = await fetch(new URL(`/${NPM_PACKAGE}/latest`, registryUrl), {
|
|
522
|
+
signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS),
|
|
523
|
+
});
|
|
524
|
+
if (!response.ok) return null;
|
|
525
|
+
const { version: latest } = await response.json();
|
|
526
|
+
const current = installedVersion();
|
|
527
|
+
return typeof latest === 'string' && isNewer(latest, current)
|
|
528
|
+
? { current, latest }
|
|
529
|
+
: null;
|
|
530
|
+
} catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* SessionStart: ask Ari for the top tasks Claude can take care of right now
|
|
537
|
+
* and surface them at boot — a visible list for the user (systemMessage)
|
|
538
|
+
* plus the full prompts for Claude (additionalContext) so it can run
|
|
539
|
+
* whichever one the user picks. Also checks whether a newer ari-hooks is
|
|
540
|
+
* published, since this is the one moment we know a user is watching.
|
|
541
|
+
*/
|
|
542
|
+
async function onSessionStart(input) {
|
|
543
|
+
// Compaction restarts the session mid-conversation; the tasks were
|
|
544
|
+
// already offered, so don't show (or inject) them again.
|
|
545
|
+
if (input.source === 'compact') return;
|
|
546
|
+
// Cursor also fires sessionStart for headless background agents — there is
|
|
547
|
+
// no user watching who could pick a task.
|
|
548
|
+
if (input.is_background_agent) return;
|
|
549
|
+
|
|
550
|
+
const config = loadConfig();
|
|
551
|
+
if (!config.token) return;
|
|
552
|
+
|
|
553
|
+
const [tasks, update] = await Promise.all([fetchTasks(config), checkForUpdate()]);
|
|
554
|
+
if (tasks.length === 0 && !update) return;
|
|
555
|
+
|
|
556
|
+
const updateNotice = update
|
|
557
|
+
? `ari-hooks ${update.current} is out of date (latest: ${update.latest}). ` +
|
|
558
|
+
`Update it: npm install -g ${NPM_PACKAGE}@latest`
|
|
559
|
+
: null;
|
|
560
|
+
|
|
561
|
+
const taskContext =
|
|
562
|
+
tasks.length > 0
|
|
563
|
+
? `The user has Ari connected via ari-hooks. At session start the user was ` +
|
|
564
|
+
`shown this list of suggested tasks:\n\n` +
|
|
565
|
+
tasks
|
|
566
|
+
.map(
|
|
567
|
+
(t, i) =>
|
|
568
|
+
`Task ${i + 1}: ${oneLine(t.taskName)}\nPrompt: ${clamp(t.prompt)}`
|
|
569
|
+
)
|
|
570
|
+
.join('\n\n') +
|
|
571
|
+
`\n\nIf the user asks to run one of these tasks (by number or name), ` +
|
|
572
|
+
`carry out that task's prompt as if the user had typed it. Do not start ` +
|
|
573
|
+
`any of these tasks unless the user asks.`
|
|
574
|
+
: null;
|
|
575
|
+
|
|
576
|
+
const additionalContext = [
|
|
577
|
+
updateNotice ? `Note for the user: ${updateNotice}` : null,
|
|
578
|
+
taskContext,
|
|
579
|
+
]
|
|
580
|
+
.filter(Boolean)
|
|
581
|
+
.join('\n\n');
|
|
307
582
|
|
|
308
583
|
// Cursor's sessionStart output is a flat { additional_context } and it has
|
|
309
584
|
// no user-visible systemMessage channel, so the agent itself must surface
|
|
310
|
-
// the list.
|
|
585
|
+
// the list (and the update notice).
|
|
311
586
|
if (isCursorInput(input)) {
|
|
312
587
|
writeSync(
|
|
313
588
|
1,
|
|
314
589
|
JSON.stringify({
|
|
315
590
|
additional_context:
|
|
316
591
|
additionalContext +
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
592
|
+
(tasks.length > 0
|
|
593
|
+
? `\n\nNote: unlike Claude Code, Cursor did NOT show the user this ` +
|
|
594
|
+
`list — briefly offer these tasks by name at the start of your ` +
|
|
595
|
+
`first reply.`
|
|
596
|
+
: ''),
|
|
320
597
|
}) + '\n'
|
|
321
598
|
);
|
|
322
599
|
return;
|
|
@@ -326,15 +603,23 @@ async function onSessionStart(input) {
|
|
|
326
603
|
// pushes our block below the fixed "SessionStart:<source> says:" prefix.
|
|
327
604
|
const BOLD = '\x1b[1m';
|
|
328
605
|
const CYAN = '\x1b[36m';
|
|
606
|
+
const YELLOW = '\x1b[33m';
|
|
329
607
|
const GREY = '\x1b[37m';
|
|
330
608
|
const RESET = '\x1b[0m';
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
609
|
+
|
|
610
|
+
const messageBlocks = [];
|
|
611
|
+
if (updateNotice) messageBlocks.push(`${YELLOW}⚠ ${updateNotice}${RESET}`);
|
|
612
|
+
if (tasks.length > 0) {
|
|
613
|
+
const visibleList = tasks
|
|
614
|
+
.map((t, i) => ` ${BOLD}${i + 1}.${RESET} ${oneLine(t.taskName)}`)
|
|
615
|
+
.join('\n');
|
|
616
|
+
messageBlocks.push(
|
|
617
|
+
`${BOLD}${CYAN}✻ Ari — things Claude can take care of for you right now${RESET}\n` +
|
|
618
|
+
`${visibleList}\n` +
|
|
619
|
+
`${GREY}Reply "run task 1" (or the task name) to start one.${RESET}`
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const systemMessage = `\n${messageBlocks.join('\n')}`;
|
|
338
623
|
|
|
339
624
|
// writeSync: process.exit(0) in runHook would race an async stdout write.
|
|
340
625
|
writeSync(
|
|
@@ -358,14 +643,22 @@ export async function runHook(event, agent) {
|
|
|
358
643
|
try {
|
|
359
644
|
const raw = await readStdin();
|
|
360
645
|
const input = raw ? JSON.parse(raw) : {};
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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();
|
|
369
662
|
}
|
|
370
663
|
} catch (err) {
|
|
371
664
|
logError(err);
|