@agi-cli/server 0.1.86 → 0.1.87

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agi-cli/server",
3
- "version": "0.1.86",
3
+ "version": "0.1.87",
4
4
  "description": "HTTP API server for AGI CLI",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@agi-cli/sdk": "0.1.86",
33
- "@agi-cli/database": "0.1.86",
32
+ "@agi-cli/sdk": "0.1.87",
33
+ "@agi-cli/database": "0.1.87",
34
34
  "drizzle-orm": "^0.44.5",
35
35
  "hono": "^4.9.9",
36
36
  "zod": "^4.1.8"
@@ -3,6 +3,7 @@ export type AGIEventType =
3
3
  | 'session.updated'
4
4
  | 'message.created'
5
5
  | 'message.part.delta'
6
+ | 'reasoning.delta'
6
7
  | 'message.completed'
7
8
  | 'tool.call'
8
9
  | 'tool.delta'
@@ -28,7 +28,7 @@ export async function updateSessionTokensIncremental(
28
28
  opts: RunOpts,
29
29
  db: Awaited<ReturnType<typeof getDb>>,
30
30
  ) {
31
- if (!usage) return;
31
+ if (!usage || !db) return;
32
32
 
33
33
  // Read session totals
34
34
  const sessRows = await db
@@ -106,7 +106,7 @@ export async function updateSessionTokens(
106
106
  opts: RunOpts,
107
107
  db: Awaited<ReturnType<typeof getDb>>,
108
108
  ) {
109
- if (!fin.usage) return;
109
+ if (!fin.usage || !db) return;
110
110
 
111
111
  const sessRows = await db
112
112
  .select()
@@ -140,7 +140,7 @@ export async function updateMessageTokensIncremental(
140
140
  opts: RunOpts,
141
141
  db: Awaited<ReturnType<typeof getDb>>,
142
142
  ) {
143
- if (!usage) return;
143
+ if (!usage || !db) return;
144
144
 
145
145
  const msgRows = await db
146
146
  .select()
@@ -204,6 +204,8 @@ export async function completeAssistantMessage(
204
204
  opts: RunOpts,
205
205
  db: Awaited<ReturnType<typeof getDb>>,
206
206
  ) {
207
+ if (!db) return;
208
+
207
209
  // Only mark as complete - tokens are already tracked incrementally
208
210
  await db
209
211
  .update(messages)
@@ -221,6 +223,8 @@ export async function cleanupEmptyTextParts(
221
223
  opts: RunOpts,
222
224
  db: Awaited<ReturnType<typeof getDb>>,
223
225
  ) {
226
+ if (!db) return;
227
+
224
228
  const parts = await db
225
229
  .select()
226
230
  .from(messageParts)
@@ -21,6 +21,13 @@ export async function buildHistoryMessages(
21
21
  const ui: UIMessage[] = [];
22
22
 
23
23
  for (const m of rows) {
24
+ if (m.role === 'assistant' && m.status !== 'complete') {
25
+ debugLog(
26
+ `[buildHistoryMessages] Skipping assistant message ${m.id} with status ${m.status}`,
27
+ );
28
+ continue;
29
+ }
30
+
24
31
  const parts = await db
25
32
  .select()
26
33
  .from(messageParts)
@@ -54,6 +61,8 @@ export async function buildHistoryMessages(
54
61
  }> = [];
55
62
 
56
63
  for (const p of parts) {
64
+ if (p.type === 'reasoning') continue;
65
+
57
66
  if (p.type === 'text') {
58
67
  try {
59
68
  const obj = JSON.parse(p.content ?? '{}');
@@ -4,7 +4,8 @@ import type { AGIConfig } from '@agi-cli/sdk';
4
4
  import type { DB } from '@agi-cli/database';
5
5
  import { messages, messageParts, sessions } from '@agi-cli/database/schema';
6
6
  import { publish } from '../events/bus.ts';
7
- import { enqueueAssistantRun } from './runner.ts';
7
+ import { enqueueAssistantRun } from './session-queue.ts';
8
+ import { runSessionLoop } from './runner.ts';
8
9
  import { resolveModel } from './provider.ts';
9
10
  import type { ProviderId } from '@agi-cli/sdk';
10
11
  import { debugLog } from './debug.ts';
@@ -86,20 +87,6 @@ export async function dispatchAssistantMessage(
86
87
  model,
87
88
  createdAt: Date.now(),
88
89
  });
89
- const assistantPartId = crypto.randomUUID();
90
- const startTs = Date.now();
91
- await db.insert(messageParts).values({
92
- id: assistantPartId,
93
- messageId: assistantMessageId,
94
- index: 0,
95
- stepIndex: 0,
96
- type: 'text',
97
- content: JSON.stringify({ text: '' }),
98
- agent,
99
- provider,
100
- model,
101
- startedAt: startTs,
102
- });
103
90
  publish({
104
91
  type: 'message.created',
105
92
  sessionId,
@@ -111,17 +98,19 @@ export async function dispatchAssistantMessage(
111
98
  `[MESSAGE_SERVICE] Enqueuing assistant run with userContext: ${userContext ? `${userContext.substring(0, 50)}...` : 'NONE'}`,
112
99
  );
113
100
 
114
- enqueueAssistantRun({
115
- sessionId,
116
- assistantMessageId,
117
- assistantPartId,
118
- agent,
119
- provider,
120
- model,
121
- projectRoot: cfg.projectRoot,
122
- oneShot: Boolean(oneShot),
123
- userContext,
124
- });
101
+ enqueueAssistantRun(
102
+ {
103
+ sessionId,
104
+ assistantMessageId,
105
+ agent,
106
+ provider,
107
+ model,
108
+ projectRoot: cfg.projectRoot,
109
+ oneShot: Boolean(oneShot),
110
+ userContext,
111
+ },
112
+ runSessionLoop,
113
+ );
125
114
 
126
115
  void touchSessionLastActive({ db, sessionId });
127
116
 
@@ -15,9 +15,6 @@ import { toErrorPayload } from './error-handling.ts';
15
15
  import { getMaxOutputTokens } from './token-utils.ts';
16
16
  import {
17
17
  type RunOpts,
18
- enqueueAssistantRun as enqueueRun,
19
- abortSession as abortSessionQueue,
20
- getRunnerState,
21
18
  setRunning,
22
19
  dequeueJob,
23
20
  cleanupSession,
@@ -36,32 +33,19 @@ import {
36
33
  createFinishHandler,
37
34
  } from './stream-handlers.ts';
38
35
 
39
- /**
40
- * Enqueues an assistant run for processing.
41
- */
42
- export function enqueueAssistantRun(opts: Omit<RunOpts, 'abortSignal'>) {
43
- enqueueRun(opts, processQueue);
44
- }
45
-
46
- /**
47
- * Aborts an active session.
48
- */
49
- export function abortSession(sessionId: string) {
50
- abortSessionQueue(sessionId);
51
- }
36
+ export { enqueueAssistantRun, abortSession } from './session-queue.ts';
37
+ export { getRunnerState } from './session-queue.ts';
52
38
 
53
39
  /**
54
- * Processes the queue of assistant runs for a session.
40
+ * Main loop that processes the queue for a given session.
55
41
  */
56
- async function processQueue(sessionId: string) {
57
- const state = getRunnerState(sessionId);
58
- if (!state) return;
59
- if (state.running) return;
42
+ export async function runSessionLoop(sessionId: string) {
60
43
  setRunning(sessionId, true);
61
44
 
62
- while (state.queue.length > 0) {
63
- const job = dequeueJob(sessionId);
45
+ while (true) {
46
+ const job = await dequeueJob(sessionId);
64
47
  if (!job) break;
48
+
65
49
  try {
66
50
  await runAssistant(job);
67
51
  } catch (_err) {
@@ -170,12 +154,9 @@ async function runAssistant(opts: RunOpts) {
170
154
  const toolsTimer = time('runner:discoverTools');
171
155
  const allTools = await discoverProjectTools(cfg.projectRoot);
172
156
  toolsTimer.end({ count: allTools.length });
173
- const allowedNames = new Set([
174
- ...(agentCfg.tools || []),
175
- 'finish',
176
- 'progress_update',
177
- ]);
178
- const gated = allTools.filter((t) => allowedNames.has(t.name));
157
+ const allowedNames = new Set([...(agentCfg.tools || []), 'finish']);
158
+ const gated = allTools.filter((tool) => allowedNames.has(tool.name));
159
+ debugLog(`[tools] ${gated.length} allowed tools`);
179
160
 
180
161
  // FIX: For OAuth, ALWAYS prepend the system message because it's never in history
181
162
  // For API key mode, only add on first message (when additionalSystemMessages is empty)
@@ -184,6 +165,8 @@ async function runAssistant(opts: RunOpts) {
184
165
  ...history,
185
166
  ];
186
167
 
168
+ debugLog(`[RUNNER] About to create model with provider: ${opts.provider}`);
169
+ debugLog(`[RUNNER] About to create model ID: ${opts.model}`);
187
170
  debugLog(
188
171
  `[RUNNER] messagesWithSystemInstructions length: ${messagesWithSystemInstructions.length}`,
189
172
  );
@@ -199,22 +182,21 @@ async function runAssistant(opts: RunOpts) {
199
182
  );
200
183
  }
201
184
 
185
+ const model = await resolveModel(opts.provider, opts.model, cfg);
186
+ debugLog(
187
+ `[RUNNER] Model created: ${JSON.stringify({ id: model.modelId, provider: model.provider })}`,
188
+ );
189
+
190
+ const maxOutputTokens = getMaxOutputTokens(opts.provider, opts.model);
191
+ debugLog(`[RUNNER] maxOutputTokens for ${opts.model}: ${maxOutputTokens}`);
192
+
193
+ // Setup tool context
202
194
  const { sharedCtx, firstToolTimer, firstToolSeen } = await setupToolContext(
203
195
  opts,
204
196
  db,
205
197
  );
206
198
  const toolset = adaptTools(gated, sharedCtx, opts.provider);
207
199
 
208
- const modelTimer = time('runner:resolveModel');
209
- const model = await resolveModel(opts.provider, opts.model, cfg);
210
- modelTimer.end();
211
-
212
- const maxOutputTokens = getMaxOutputTokens(opts.provider, opts.model);
213
-
214
- let currentPartId = opts.assistantPartId;
215
- let accumulated = '';
216
- let stepIndex = 0;
217
-
218
200
  let _finishObserved = false;
219
201
  const unsubscribeFinish = subscribe(opts.sessionId, (evt) => {
220
202
  if (evt.type !== 'tool.result') return;
@@ -231,7 +213,7 @@ async function runAssistant(opts: RunOpts) {
231
213
  // State management helpers
232
214
  const getCurrentPartId = () => currentPartId;
233
215
  const getStepIndex = () => stepIndex;
234
- const updateCurrentPartId = (id: string) => {
216
+ const updateCurrentPartId = (id: string | null) => {
235
217
  currentPartId = id;
236
218
  };
237
219
  const updateAccumulated = (text: string) => {
@@ -242,16 +224,29 @@ async function runAssistant(opts: RunOpts) {
242
224
  return stepIndex;
243
225
  };
244
226
 
227
+ type ReasoningState = {
228
+ partId: string;
229
+ text: string;
230
+ providerMetadata?: unknown;
231
+ };
232
+ const reasoningStates = new Map<string, ReasoningState>();
233
+ const serializeReasoningContent = (state: ReasoningState) =>
234
+ JSON.stringify(
235
+ state.providerMetadata != null
236
+ ? { text: state.text, providerMetadata: state.providerMetadata }
237
+ : { text: state.text },
238
+ );
239
+
245
240
  // Create stream handlers
246
241
  const onStepFinish = createStepFinishHandler(
247
242
  opts,
248
243
  db,
249
- getCurrentPartId,
250
244
  getStepIndex,
251
- sharedCtx,
245
+ incrementStepIndex,
246
+ getCurrentPartId,
252
247
  updateCurrentPartId,
253
248
  updateAccumulated,
254
- incrementStepIndex,
249
+ sharedCtx,
255
250
  updateSessionTokensIncremental,
256
251
  updateMessageTokensIncremental,
257
252
  );
@@ -306,6 +301,11 @@ async function runAssistant(opts: RunOpts) {
306
301
  `[RUNNER] cachedSystem (spoof): ${typeof cachedSystem === 'string' ? cachedSystem.substring(0, 100) : JSON.stringify(cachedSystem).substring(0, 100)}`,
307
302
  );
308
303
 
304
+ // Part tracking - will be created on first text-delta
305
+ let currentPartId: string | null = null;
306
+ let accumulated = '';
307
+ let stepIndex = 0;
308
+
309
309
  try {
310
310
  // @ts-expect-error this is fine 🔥
311
311
  const result = streamText({
@@ -322,50 +322,173 @@ async function runAssistant(opts: RunOpts) {
322
322
  onFinish,
323
323
  });
324
324
 
325
- for await (const delta of result.textStream) {
326
- if (!delta) continue;
327
- if (!firstDeltaSeen) {
328
- firstDeltaSeen = true;
329
- streamStartTimer.end();
325
+ for await (const part of result.fullStream) {
326
+ if (!part) continue;
327
+ if (part.type === 'text-delta') {
328
+ const delta = part.text;
329
+ if (!delta) continue;
330
+ if (!firstDeltaSeen) {
331
+ firstDeltaSeen = true;
332
+ streamStartTimer.end();
333
+ }
334
+
335
+ // Create text part on first delta
336
+ if (!currentPartId) {
337
+ currentPartId = crypto.randomUUID();
338
+ sharedCtx.assistantPartId = currentPartId;
339
+ await db.insert(messageParts).values({
340
+ id: currentPartId,
341
+ messageId: opts.assistantMessageId,
342
+ index: sharedCtx.nextIndex(),
343
+ stepIndex: null,
344
+ type: 'text',
345
+ content: JSON.stringify({ text: '' }),
346
+ agent: opts.agent,
347
+ provider: opts.provider,
348
+ model: opts.model,
349
+ startedAt: Date.now(),
350
+ });
351
+ }
352
+
353
+ accumulated += delta;
354
+ publish({
355
+ type: 'message.part.delta',
356
+ sessionId: opts.sessionId,
357
+ payload: {
358
+ messageId: opts.assistantMessageId,
359
+ partId: currentPartId,
360
+ stepIndex,
361
+ delta,
362
+ },
363
+ });
364
+ await db
365
+ .update(messageParts)
366
+ .set({ content: JSON.stringify({ text: accumulated }) })
367
+ .where(eq(messageParts.id, currentPartId));
368
+ continue;
369
+ }
370
+
371
+ if (part.type === 'reasoning-start') {
372
+ const reasoningId = part.id;
373
+ if (!reasoningId) continue;
374
+ const reasoningPartId = crypto.randomUUID();
375
+ const state: ReasoningState = {
376
+ partId: reasoningPartId,
377
+ text: '',
378
+ providerMetadata: part.providerMetadata,
379
+ };
380
+ reasoningStates.set(reasoningId, state);
381
+ try {
382
+ await db.insert(messageParts).values({
383
+ id: reasoningPartId,
384
+ messageId: opts.assistantMessageId,
385
+ index: sharedCtx.nextIndex(),
386
+ stepIndex: getStepIndex(),
387
+ type: 'reasoning',
388
+ content: serializeReasoningContent(state),
389
+ agent: opts.agent,
390
+ provider: opts.provider,
391
+ model: opts.model,
392
+ startedAt: Date.now(),
393
+ });
394
+ } catch {}
395
+ continue;
396
+ }
397
+
398
+ if (part.type === 'reasoning-delta') {
399
+ const state = reasoningStates.get(part.id);
400
+ if (!state) continue;
401
+ state.text += part.text;
402
+ if (part.providerMetadata != null) {
403
+ state.providerMetadata = part.providerMetadata;
404
+ }
405
+ publish({
406
+ type: 'reasoning.delta',
407
+ sessionId: opts.sessionId,
408
+ payload: {
409
+ messageId: opts.assistantMessageId,
410
+ partId: state.partId,
411
+ stepIndex: getStepIndex(),
412
+ delta: part.text,
413
+ },
414
+ });
415
+ try {
416
+ await db
417
+ .update(messageParts)
418
+ .set({ content: serializeReasoningContent(state) })
419
+ .where(eq(messageParts.id, state.partId));
420
+ } catch {}
421
+ continue;
330
422
  }
331
- accumulated += delta;
423
+
424
+ if (part.type === 'reasoning-end') {
425
+ const state = reasoningStates.get(part.id);
426
+ if (!state) continue;
427
+ try {
428
+ await db
429
+ .update(messageParts)
430
+ .set({ completedAt: Date.now() })
431
+ .where(eq(messageParts.id, state.partId));
432
+ } catch {}
433
+ reasoningStates.delete(part.id);
434
+ }
435
+ }
436
+
437
+ // Emit finish-step at the end if there were no tool calls and no finish
438
+ const fs = firstToolSeen();
439
+ if (!fs && !_finishObserved) {
332
440
  publish({
333
- type: 'message.part.delta',
441
+ type: 'finish-step',
334
442
  sessionId: opts.sessionId,
335
- payload: {
336
- messageId: opts.assistantMessageId,
337
- partId: currentPartId,
338
- stepIndex,
339
- delta,
340
- },
443
+ payload: { reason: 'no-tool-calls' },
341
444
  });
342
- await db
343
- .update(messageParts)
344
- .set({ content: JSON.stringify({ text: accumulated }) })
345
- .where(eq(messageParts.id, currentPartId));
346
445
  }
347
- } catch (error) {
348
- const errorPayload = toErrorPayload(error);
349
- await db
350
- .update(messageParts)
351
- .set({
352
- content: JSON.stringify({ error: errorPayload }),
353
- })
354
- .where(eq(messageParts.id, currentPartId));
355
446
 
447
+ unsubscribeFinish();
448
+
449
+ await cleanupEmptyTextParts(opts, db);
450
+
451
+ firstToolTimer.end({ seen: firstToolSeen() });
452
+
453
+ debugLog(
454
+ `[RUNNER] Stream finished. finishSeen=${_finishObserved}, firstToolSeen=${fs}`,
455
+ );
456
+ } catch (err) {
457
+ unsubscribeFinish();
458
+ const payload = toErrorPayload(err);
459
+ debugLog(`[RUNNER] Error during stream: ${payload.message}`);
460
+ debugLog(
461
+ `[RUNNER] Error stack: ${err instanceof Error ? err.stack : 'no stack'}`,
462
+ );
463
+ debugLog(
464
+ `[RUNNER] db is: ${typeof db}, db.select is: ${typeof db?.select}`,
465
+ );
356
466
  publish({
357
- type: 'message.error',
467
+ type: 'error',
358
468
  sessionId: opts.sessionId,
359
- payload: {
360
- messageId: opts.assistantMessageId,
361
- partId: currentPartId,
362
- error: errorPayload,
363
- },
469
+ payload,
364
470
  });
365
- throw error;
366
- } finally {
367
- unsubscribeFinish();
368
- await cleanupEmptyTextParts(db, opts.assistantMessageId);
369
- firstToolTimer.end({ seen: firstToolSeen });
471
+ try {
472
+ await updateSessionTokensIncremental(
473
+ {
474
+ inputTokens: 0,
475
+ outputTokens: 0,
476
+ },
477
+ undefined,
478
+ opts,
479
+ db,
480
+ );
481
+ await updateMessageTokensIncremental(
482
+ {
483
+ inputTokens: 0,
484
+ outputTokens: 0,
485
+ },
486
+ undefined,
487
+ opts,
488
+ db,
489
+ );
490
+ await completeAssistantMessage({}, opts, db);
491
+ } catch {}
492
+ throw err;
370
493
  }
371
494
  }
@@ -3,7 +3,6 @@ import type { ProviderName } from './provider.ts';
3
3
  export type RunOpts = {
4
4
  sessionId: string;
5
5
  assistantMessageId: string;
6
- assistantPartId: string;
7
6
  agent: string;
8
7
  provider: ProviderName;
9
8
  model: string;
@@ -31,12 +31,12 @@ type AbortEvent = {
31
31
  export function createStepFinishHandler(
32
32
  opts: RunOpts,
33
33
  db: Awaited<ReturnType<typeof getDb>>,
34
- getCurrentPartId: () => string,
35
34
  getStepIndex: () => number,
36
- sharedCtx: ToolAdapterContext,
37
- updateCurrentPartId: (id: string) => void,
38
- updateAccumulated: (text: string) => void,
39
35
  incrementStepIndex: () => number,
36
+ getCurrentPartId: () => string | null,
37
+ updateCurrentPartId: (id: string | null) => void,
38
+ updateAccumulated: (text: string) => void,
39
+ sharedCtx: ToolAdapterContext,
40
40
  updateSessionTokensIncrementalFn: (
41
41
  usage: UsageData,
42
42
  providerMetadata: ProviderMetadata | undefined,
@@ -56,10 +56,12 @@ export function createStepFinishHandler(
56
56
  const stepIndex = getStepIndex();
57
57
 
58
58
  try {
59
- await db
60
- .update(messageParts)
61
- .set({ completedAt: finishedAt })
62
- .where(eq(messageParts.id, currentPartId));
59
+ if (currentPartId) {
60
+ await db
61
+ .update(messageParts)
62
+ .set({ completedAt: finishedAt })
63
+ .where(eq(messageParts.id, currentPartId));
64
+ }
63
65
  } catch {}
64
66
 
65
67
  // Update token counts incrementally after each step
@@ -104,25 +106,11 @@ export function createStepFinishHandler(
104
106
  } catch {}
105
107
 
106
108
  try {
109
+ // Increment step index but defer creating the new text part
110
+ // until we actually get a text-delta (so reasoning blocks can complete first)
107
111
  const newStepIndex = incrementStepIndex();
108
- const newPartId = crypto.randomUUID();
109
- const index = await sharedCtx.nextIndex();
110
- const nowTs = Date.now();
111
- await db.insert(messageParts).values({
112
- id: newPartId,
113
- messageId: opts.assistantMessageId,
114
- index,
115
- stepIndex: newStepIndex,
116
- type: 'text',
117
- content: JSON.stringify({ text: '' }),
118
- agent: opts.agent,
119
- provider: opts.provider,
120
- model: opts.model,
121
- startedAt: nowTs,
122
- });
123
- updateCurrentPartId(newPartId);
124
- sharedCtx.assistantPartId = newPartId;
125
112
  sharedCtx.stepIndex = newStepIndex;
113
+ updateCurrentPartId(null); // Signal that next text-delta should create new part
126
114
  updateAccumulated('');
127
115
  } catch {}
128
116
  };
@@ -1,6 +1,4 @@
1
1
  import type { getDb } from '@agi-cli/database';
2
- import { messageParts } from '@agi-cli/database/schema';
3
- import { eq } from 'drizzle-orm';
4
2
  import { time } from './debug.ts';
5
3
  import type { ToolAdapterContext } from '../tools/adapter.ts';
6
4
  import type { RunOpts } from './session-queue.ts';
@@ -18,12 +16,16 @@ export async function setupToolContext(
18
16
  const firstToolTimer = time('runner:first-tool-call');
19
17
  let firstToolSeen = false;
20
18
 
19
+ // Simple counter starting at 0 - first event gets 0, second gets 1, etc.
20
+ let currentIndex = 0;
21
+ const nextIndex = () => currentIndex++;
22
+
21
23
  const sharedCtx: RunnerToolContext = {
22
- nextIndex: async () => 0,
24
+ nextIndex,
23
25
  stepIndex: 0,
24
26
  sessionId: opts.sessionId,
25
27
  messageId: opts.assistantMessageId,
26
- assistantPartId: opts.assistantPartId,
28
+ assistantPartId: '', // Will be set by runner when first text part is created
27
29
  db,
28
30
  agent: opts.agent,
29
31
  provider: opts.provider,
@@ -36,23 +38,5 @@ export async function setupToolContext(
36
38
  },
37
39
  };
38
40
 
39
- let counter = 0;
40
- try {
41
- const existing = await db
42
- .select()
43
- .from(messageParts)
44
- .where(eq(messageParts.messageId, opts.assistantMessageId));
45
- if (existing.length) {
46
- const indexes = existing.map((p) => Number(p.index ?? 0));
47
- const maxIndex = Math.max(...indexes);
48
- if (Number.isFinite(maxIndex)) counter = maxIndex;
49
- }
50
- } catch {}
51
-
52
- sharedCtx.nextIndex = () => {
53
- counter += 1;
54
- return counter;
55
- };
56
-
57
41
  return { sharedCtx, firstToolTimer, firstToolSeen: () => firstToolSeen };
58
42
  }