@ariso-ai/ari-hooks 0.1.8 → 0.1.10
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 +262 -62
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,
|
|
@@ -81,6 +82,33 @@ function agentTypeOf(input, agent) {
|
|
|
81
82
|
return 'codex';
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// Claude Code auto-delivers a background task's completion as a synthetic
|
|
86
|
+
// UserPromptSubmit turn — the entire prompt is a <task-notification> block,
|
|
87
|
+
// not something the user typed. Recording it verbatim as a "request"
|
|
88
|
+
// pollutes the activity feed with agent-internal bookkeeping, so swap it for
|
|
89
|
+
// a short human-readable stand-in built from the notification's <summary>.
|
|
90
|
+
const TASK_NOTIFICATION_RE = /^<task-notification>[\s\S]*<\/task-notification>$/;
|
|
91
|
+
const SUMMARY_RE = /<summary>([\s\S]*?)<\/summary>/;
|
|
92
|
+
const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
93
|
+
const decodeXmlEntities = (text) =>
|
|
94
|
+
text.replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => XML_ENTITIES[name]);
|
|
95
|
+
|
|
96
|
+
function taskNotificationStandIn(prompt) {
|
|
97
|
+
const trimmed = prompt.trim();
|
|
98
|
+
if (!TASK_NOTIFICATION_RE.test(trimmed)) return null;
|
|
99
|
+
const summary = trimmed.match(SUMMARY_RE)?.[1]?.trim();
|
|
100
|
+
return summary
|
|
101
|
+
? `The coding agent ran a background task: ${decodeXmlEntities(summary)}`
|
|
102
|
+
: 'The coding agent ran a background task.';
|
|
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
|
+
|
|
84
112
|
/**
|
|
85
113
|
* UserPromptSubmit (Claude Code) / beforeSubmitPrompt (Cursor): remember the
|
|
86
114
|
* prompt so the Stop hook can pair it with the turn's outcome. Both hosts
|
|
@@ -89,11 +117,18 @@ function agentTypeOf(input, agent) {
|
|
|
89
117
|
async function onUserPromptSubmit(input) {
|
|
90
118
|
const sessionId = sessionIdOf(input);
|
|
91
119
|
if (!sessionId || typeof input.prompt !== 'string') return;
|
|
120
|
+
const prompt = stripIdeSelection(input.prompt);
|
|
121
|
+
if (!prompt) return;
|
|
92
122
|
const session = loadSession(sessionId);
|
|
93
|
-
session.prompts.push(
|
|
123
|
+
session.prompts.push(taskNotificationStandIn(prompt) ?? prompt);
|
|
94
124
|
saveSession(sessionId, session);
|
|
95
125
|
}
|
|
96
126
|
|
|
127
|
+
// A model name from a hook payload, or null — hosts that include one send a
|
|
128
|
+
// plain non-empty string.
|
|
129
|
+
const modelOf = (value) =>
|
|
130
|
+
typeof value === 'string' && value.trim() ? value : null;
|
|
131
|
+
|
|
97
132
|
/**
|
|
98
133
|
* afterAgentResponse (Cursor only): Cursor's transcript is not the Claude
|
|
99
134
|
* Code JSONL that extractOutcome can parse, so capture the final assistant
|
|
@@ -105,6 +140,8 @@ async function onAgentResponse(input) {
|
|
|
105
140
|
if (!sessionId || typeof input.text !== 'string' || !input.text.trim()) return;
|
|
106
141
|
const session = loadSession(sessionId);
|
|
107
142
|
session.outcome = input.text;
|
|
143
|
+
const model = modelOf(input.model);
|
|
144
|
+
if (model) session.model = model;
|
|
108
145
|
saveSession(sessionId, session);
|
|
109
146
|
}
|
|
110
147
|
|
|
@@ -119,17 +156,7 @@ function assistantText(entry) {
|
|
|
119
156
|
.trim();
|
|
120
157
|
}
|
|
121
158
|
|
|
122
|
-
|
|
123
|
-
* Pull the final assistant text out of the transcript (JSONL). This is the
|
|
124
|
-
* "outcome" — we deliberately skip the intermediate steps/tool calls.
|
|
125
|
-
*
|
|
126
|
-
* `settled` reports whether the exchange actually ends in assistant text.
|
|
127
|
-
* When the transcript instead ends at a tool call/result or a half-written
|
|
128
|
-
* line, the final message hasn't been flushed yet and `text` is only the
|
|
129
|
-
* last narration before a tool ran — the caller should re-read rather than
|
|
130
|
-
* ship that as the outcome.
|
|
131
|
-
*/
|
|
132
|
-
function extractOutcome(transcriptPath) {
|
|
159
|
+
function parseTranscript(transcriptPath) {
|
|
133
160
|
const entries = [];
|
|
134
161
|
let tailPartial = false;
|
|
135
162
|
for (const line of readFileSync(transcriptPath, 'utf8').split('\n')) {
|
|
@@ -141,7 +168,20 @@ function extractOutcome(transcriptPath) {
|
|
|
141
168
|
tailPartial = true; // a line still being written
|
|
142
169
|
}
|
|
143
170
|
}
|
|
171
|
+
return { entries, tailPartial };
|
|
172
|
+
}
|
|
144
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Pull the final assistant text out of the transcript entries. This is the
|
|
176
|
+
* "outcome" — we deliberately skip the intermediate steps/tool calls.
|
|
177
|
+
*
|
|
178
|
+
* `settled` reports whether the exchange actually ends in assistant text.
|
|
179
|
+
* When the transcript instead ends at a tool call/result or a half-written
|
|
180
|
+
* line, the final message hasn't been flushed yet and `text` is only the
|
|
181
|
+
* last narration before a tool ran — the caller should re-read rather than
|
|
182
|
+
* ship that as the outcome.
|
|
183
|
+
*/
|
|
184
|
+
function extractOutcome(entries, tailPartial) {
|
|
145
185
|
let settled = tailPartial ? false : null;
|
|
146
186
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
147
187
|
const entry = entries[i];
|
|
@@ -166,12 +206,72 @@ function extractOutcome(transcriptPath) {
|
|
|
166
206
|
return { text: null, settled: false };
|
|
167
207
|
}
|
|
168
208
|
|
|
209
|
+
// A user entry that is an actual typed prompt — not a tool result, not host
|
|
210
|
+
// bookkeeping (isMeta), not a subagent's inner conversation (isSidechain).
|
|
211
|
+
function isUserPrompt(entry) {
|
|
212
|
+
if (entry.type !== 'user' || entry.isSidechain || entry.isMeta) return false;
|
|
213
|
+
const content = entry.message?.content;
|
|
214
|
+
if (typeof content === 'string') return content.trim().length > 0;
|
|
215
|
+
return (
|
|
216
|
+
Array.isArray(content) &&
|
|
217
|
+
content.some((block) => block.type === 'text') &&
|
|
218
|
+
!content.some((block) => block.type === 'tool_result')
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Model and token usage for the turn being reported. The transcript holds
|
|
224
|
+
* the whole session, so walk back past `promptCount` user prompts (this
|
|
225
|
+
* send covers that many queued prompts) and only count assistant entries
|
|
226
|
+
* after that point. Usage is keyed by message id — a message split across
|
|
227
|
+
* several JSONL entries (one per content block) repeats the same usage on
|
|
228
|
+
* each, and the last entry wins — then totalled over everything the API
|
|
229
|
+
* metered: input, cache writes/reads, and output. The model is named by
|
|
230
|
+
* the turn's last top-level assistant message; sidechain (subagent)
|
|
231
|
+
* entries still count toward tokens.
|
|
232
|
+
*/
|
|
233
|
+
function extractTurnStats(entries, promptCount) {
|
|
234
|
+
let boundary = -1;
|
|
235
|
+
let remaining = Math.max(1, promptCount);
|
|
236
|
+
for (let i = entries.length - 1; i >= 0 && remaining > 0; i--) {
|
|
237
|
+
if (isUserPrompt(entries[i])) {
|
|
238
|
+
boundary = i;
|
|
239
|
+
remaining--;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let model = null;
|
|
244
|
+
const usageById = new Map();
|
|
245
|
+
for (let i = boundary + 1; i < entries.length; i++) {
|
|
246
|
+
const entry = entries[i];
|
|
247
|
+
if (entry.type !== 'assistant') continue;
|
|
248
|
+
const message = entry.message ?? {};
|
|
249
|
+
// '<synthetic>' marks host-injected error messages, not a real model.
|
|
250
|
+
if (!entry.isSidechain && modelOf(message.model) && message.model !== '<synthetic>') {
|
|
251
|
+
model = message.model;
|
|
252
|
+
}
|
|
253
|
+
if (message.usage) usageById.set(message.id ?? `entry-${i}`, message.usage);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let tokens = null;
|
|
257
|
+
for (const usage of usageById.values()) {
|
|
258
|
+
tokens =
|
|
259
|
+
(tokens ?? 0) +
|
|
260
|
+
(usage.input_tokens ?? 0) +
|
|
261
|
+
(usage.cache_creation_input_tokens ?? 0) +
|
|
262
|
+
(usage.cache_read_input_tokens ?? 0) +
|
|
263
|
+
(usage.output_tokens ?? 0);
|
|
264
|
+
}
|
|
265
|
+
return { model, tokens };
|
|
266
|
+
}
|
|
267
|
+
|
|
169
268
|
const clamp = (text) =>
|
|
170
269
|
text.length > MAX_TEXT_LENGTH ? text.slice(0, MAX_TEXT_LENGTH) : text;
|
|
171
270
|
|
|
172
271
|
/**
|
|
173
272
|
* Stop: the turn is over — send the accumulated request(s) plus the final
|
|
174
|
-
* assistant message
|
|
273
|
+
* assistant message (and, when the host exposes them, the model used and
|
|
274
|
+
* the turn's token count) to Ari, then clear the per-session state.
|
|
175
275
|
*/
|
|
176
276
|
async function onStop(input, agent) {
|
|
177
277
|
// stop_hook_active means a stop hook already forced Claude to continue;
|
|
@@ -188,13 +288,36 @@ async function onStop(input, agent) {
|
|
|
188
288
|
// last_assistant_message; Claude Code sessions read it from the transcript,
|
|
189
289
|
// waiting for the final assistant message to land there (on timeout, fall
|
|
190
290
|
// back to the last text we did find — best effort).
|
|
191
|
-
|
|
192
|
-
|
|
291
|
+
const payloadOutcome = session.outcome ?? input.last_assistant_message ?? null;
|
|
292
|
+
let outcome = payloadOutcome;
|
|
293
|
+
// 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
|
|
297
|
+
// the transcript is read for stats even when the outcome is already in
|
|
298
|
+
// hand — but a stats failure must never cost us the activity itself.
|
|
299
|
+
let model = modelOf(input.model) ?? session.model ?? null;
|
|
300
|
+
let tokens = null;
|
|
301
|
+
if (input.transcript_path) {
|
|
193
302
|
const deadline = Date.now() + OUTCOME_SETTLE_TIMEOUT_MS;
|
|
194
303
|
for (;;) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
304
|
+
let entries, tailPartial;
|
|
305
|
+
try {
|
|
306
|
+
({ entries, tailPartial } = parseTranscript(input.transcript_path));
|
|
307
|
+
} catch (err) {
|
|
308
|
+
logError(err);
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
const { text, settled } = extractOutcome(entries, tailPartial);
|
|
312
|
+
outcome = payloadOutcome ?? text;
|
|
313
|
+
// Even with the outcome in hand, wait for the final message to flush
|
|
314
|
+
// so its usage makes it into the token count.
|
|
315
|
+
if (settled || Date.now() >= deadline) {
|
|
316
|
+
const stats = extractTurnStats(entries, session.prompts.length);
|
|
317
|
+
model = stats.model ?? model;
|
|
318
|
+
tokens = stats.tokens;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
198
321
|
await sleep(OUTCOME_POLL_INTERVAL_MS);
|
|
199
322
|
}
|
|
200
323
|
}
|
|
@@ -216,6 +339,9 @@ async function onStop(input, agent) {
|
|
|
216
339
|
agent_type: agentTypeOf(input, agent),
|
|
217
340
|
// Cursor sends workspace_roots instead of cwd.
|
|
218
341
|
cwd: input.cwd ?? input.workspace_roots?.[0] ?? process.cwd(),
|
|
342
|
+
// Only sent when known — the API treats absent and unknown alike.
|
|
343
|
+
...(model && { primary_model: model }),
|
|
344
|
+
...(tokens != null && { token_count: tokens }),
|
|
219
345
|
}),
|
|
220
346
|
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
221
347
|
});
|
|
@@ -232,23 +358,7 @@ const MAX_TASK_NAME_LENGTH = 200;
|
|
|
232
358
|
const oneLine = (text) =>
|
|
233
359
|
text.replace(/\s+/g, ' ').trim().slice(0, MAX_TASK_NAME_LENGTH);
|
|
234
360
|
|
|
235
|
-
|
|
236
|
-
* SessionStart: ask Ari for the top tasks Claude can take care of right now
|
|
237
|
-
* and surface them at boot — a visible list for the user (systemMessage)
|
|
238
|
-
* plus the full prompts for Claude (additionalContext) so it can run
|
|
239
|
-
* whichever one the user picks.
|
|
240
|
-
*/
|
|
241
|
-
async function onSessionStart(input) {
|
|
242
|
-
// Compaction restarts the session mid-conversation; the tasks were
|
|
243
|
-
// already offered, so don't show (or inject) them again.
|
|
244
|
-
if (input.source === 'compact') return;
|
|
245
|
-
// Cursor also fires sessionStart for headless background agents — there is
|
|
246
|
-
// no user watching who could pick a task.
|
|
247
|
-
if (input.is_background_agent) return;
|
|
248
|
-
|
|
249
|
-
const config = loadConfig();
|
|
250
|
-
if (!config.token) return;
|
|
251
|
-
|
|
361
|
+
async function fetchTasks(config) {
|
|
252
362
|
const response = await fetch(new URL('/agent-tasks', getApiUrl(config)), {
|
|
253
363
|
headers: { Authorization: `Bearer ${config.token}` },
|
|
254
364
|
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
@@ -260,7 +370,7 @@ async function onSessionStart(input) {
|
|
|
260
370
|
const body = await response.json();
|
|
261
371
|
// The API wraps the list ({ tasks: [...] }); accept a bare array too.
|
|
262
372
|
const list = Array.isArray(body) ? body : Array.isArray(body?.tasks) ? body.tasks : [];
|
|
263
|
-
|
|
373
|
+
return list
|
|
264
374
|
.filter(
|
|
265
375
|
(t) =>
|
|
266
376
|
t &&
|
|
@@ -270,33 +380,115 @@ async function onSessionStart(input) {
|
|
|
270
380
|
t.prompt.trim()
|
|
271
381
|
)
|
|
272
382
|
.slice(0, MAX_TASKS);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const NPM_PACKAGE = '@ariso-ai/ari-hooks';
|
|
386
|
+
const VERSION_CHECK_TIMEOUT_MS = 3_000;
|
|
387
|
+
|
|
388
|
+
const installedVersion = () =>
|
|
389
|
+
JSON.parse(
|
|
390
|
+
readFileSync(
|
|
391
|
+
join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
|
|
392
|
+
'utf8'
|
|
393
|
+
)
|
|
394
|
+
).version;
|
|
395
|
+
|
|
396
|
+
// Plain x.y.z releases only — no prerelease/build-metadata tags to worry about.
|
|
397
|
+
const isNewer = (latest, current) => {
|
|
398
|
+
const a = latest.split('.').map(Number);
|
|
399
|
+
const b = current.split('.').map(Number);
|
|
400
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
401
|
+
const x = a[i] ?? 0;
|
|
402
|
+
const y = b[i] ?? 0;
|
|
403
|
+
if (x !== y) return x > y;
|
|
404
|
+
}
|
|
405
|
+
return false;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Compare the installed version against what npm has published. A stale or
|
|
410
|
+
* unreachable registry must never break session start, so any failure just
|
|
411
|
+
* skips the check (returns null, same as "up to date").
|
|
412
|
+
*/
|
|
413
|
+
async function checkForUpdate() {
|
|
414
|
+
try {
|
|
415
|
+
const registryUrl = process.env.ARI_HOOKS_REGISTRY_URL || 'https://registry.npmjs.org';
|
|
416
|
+
const response = await fetch(new URL(`/${NPM_PACKAGE}/latest`, registryUrl), {
|
|
417
|
+
signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS),
|
|
418
|
+
});
|
|
419
|
+
if (!response.ok) return null;
|
|
420
|
+
const { version: latest } = await response.json();
|
|
421
|
+
const current = installedVersion();
|
|
422
|
+
return typeof latest === 'string' && isNewer(latest, current)
|
|
423
|
+
? { current, latest }
|
|
424
|
+
: null;
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* SessionStart: ask Ari for the top tasks Claude can take care of right now
|
|
432
|
+
* and surface them at boot — a visible list for the user (systemMessage)
|
|
433
|
+
* plus the full prompts for Claude (additionalContext) so it can run
|
|
434
|
+
* whichever one the user picks. Also checks whether a newer ari-hooks is
|
|
435
|
+
* published, since this is the one moment we know a user is watching.
|
|
436
|
+
*/
|
|
437
|
+
async function onSessionStart(input) {
|
|
438
|
+
// Compaction restarts the session mid-conversation; the tasks were
|
|
439
|
+
// already offered, so don't show (or inject) them again.
|
|
440
|
+
if (input.source === 'compact') return;
|
|
441
|
+
// Cursor also fires sessionStart for headless background agents — there is
|
|
442
|
+
// no user watching who could pick a task.
|
|
443
|
+
if (input.is_background_agent) return;
|
|
444
|
+
|
|
445
|
+
const config = loadConfig();
|
|
446
|
+
if (!config.token) return;
|
|
447
|
+
|
|
448
|
+
const [tasks, update] = await Promise.all([fetchTasks(config), checkForUpdate()]);
|
|
449
|
+
if (tasks.length === 0 && !update) return;
|
|
450
|
+
|
|
451
|
+
const updateNotice = update
|
|
452
|
+
? `ari-hooks ${update.current} is out of date (latest: ${update.latest}). ` +
|
|
453
|
+
`Update it: npm install -g ${NPM_PACKAGE}@latest`
|
|
454
|
+
: null;
|
|
455
|
+
|
|
456
|
+
const taskContext =
|
|
457
|
+
tasks.length > 0
|
|
458
|
+
? `The user has Ari connected via ari-hooks. At session start the user was ` +
|
|
459
|
+
`shown this list of suggested tasks:\n\n` +
|
|
460
|
+
tasks
|
|
461
|
+
.map(
|
|
462
|
+
(t, i) =>
|
|
463
|
+
`Task ${i + 1}: ${oneLine(t.taskName)}\nPrompt: ${clamp(t.prompt)}`
|
|
464
|
+
)
|
|
465
|
+
.join('\n\n') +
|
|
466
|
+
`\n\nIf the user asks to run one of these tasks (by number or name), ` +
|
|
467
|
+
`carry out that task's prompt as if the user had typed it. Do not start ` +
|
|
468
|
+
`any of these tasks unless the user asks.`
|
|
469
|
+
: null;
|
|
470
|
+
|
|
471
|
+
const additionalContext = [
|
|
472
|
+
updateNotice ? `Note for the user: ${updateNotice}` : null,
|
|
473
|
+
taskContext,
|
|
474
|
+
]
|
|
475
|
+
.filter(Boolean)
|
|
476
|
+
.join('\n\n');
|
|
287
477
|
|
|
288
478
|
// Cursor's sessionStart output is a flat { additional_context } and it has
|
|
289
479
|
// no user-visible systemMessage channel, so the agent itself must surface
|
|
290
|
-
// the list.
|
|
480
|
+
// the list (and the update notice).
|
|
291
481
|
if (isCursorInput(input)) {
|
|
292
482
|
writeSync(
|
|
293
483
|
1,
|
|
294
484
|
JSON.stringify({
|
|
295
485
|
additional_context:
|
|
296
486
|
additionalContext +
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
487
|
+
(tasks.length > 0
|
|
488
|
+
? `\n\nNote: unlike Claude Code, Cursor did NOT show the user this ` +
|
|
489
|
+
`list — briefly offer these tasks by name at the start of your ` +
|
|
490
|
+
`first reply.`
|
|
491
|
+
: ''),
|
|
300
492
|
}) + '\n'
|
|
301
493
|
);
|
|
302
494
|
return;
|
|
@@ -306,15 +498,23 @@ async function onSessionStart(input) {
|
|
|
306
498
|
// pushes our block below the fixed "SessionStart:<source> says:" prefix.
|
|
307
499
|
const BOLD = '\x1b[1m';
|
|
308
500
|
const CYAN = '\x1b[36m';
|
|
501
|
+
const YELLOW = '\x1b[33m';
|
|
309
502
|
const GREY = '\x1b[37m';
|
|
310
503
|
const RESET = '\x1b[0m';
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
504
|
+
|
|
505
|
+
const messageBlocks = [];
|
|
506
|
+
if (updateNotice) messageBlocks.push(`${YELLOW}⚠ ${updateNotice}${RESET}`);
|
|
507
|
+
if (tasks.length > 0) {
|
|
508
|
+
const visibleList = tasks
|
|
509
|
+
.map((t, i) => ` ${BOLD}${i + 1}.${RESET} ${oneLine(t.taskName)}`)
|
|
510
|
+
.join('\n');
|
|
511
|
+
messageBlocks.push(
|
|
512
|
+
`${BOLD}${CYAN}✻ Ari — things Claude can take care of for you right now${RESET}\n` +
|
|
513
|
+
`${visibleList}\n` +
|
|
514
|
+
`${GREY}Reply "run task 1" (or the task name) to start one.${RESET}`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
const systemMessage = `\n${messageBlocks.join('\n')}`;
|
|
318
518
|
|
|
319
519
|
// writeSync: process.exit(0) in runHook would race an async stdout write.
|
|
320
520
|
writeSync(
|