protoagent 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.
@@ -16,6 +16,7 @@
16
16
  * React state accordingly. This keeps the core logic testable
17
17
  * and UI-independent.
18
18
  */
19
+ import { setMaxListeners } from 'node:events';
19
20
  import { getAllTools, handleToolCall } from './tools/index.js';
20
21
  import { generateSystemPrompt } from './system-prompt.js';
21
22
  import { subAgentTool, runSubAgent } from './sub-agent.js';
@@ -255,6 +256,17 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
255
256
  const abortSignal = options.abortSignal;
256
257
  const sessionId = options.sessionId;
257
258
  const requestDefaults = options.requestDefaults || {};
259
+ // The same AbortSignal is passed into every OpenAI SDK call and every
260
+ // sleepWithAbort() across all loop iterations and sub-agent calls.
261
+ // The SDK attaches an 'abort' listener per request, so on a long run
262
+ // the default limit of 10 listeners is quickly exceeded, producing the
263
+ // MaxListenersExceededWarning. AbortSignal is a Web API EventTarget,
264
+ // not a Node EventEmitter, so the instance method .setMaxListeners()
265
+ // doesn't exist on it — use the standalone setMaxListeners() from
266
+ // node:events instead, which handles both EventEmitter and EventTarget.
267
+ if (abortSignal) {
268
+ setMaxListeners(0, abortSignal); // 0 = unlimited, scoped to this signal only
269
+ }
258
270
  // Note: userInput is passed for context/logging but user message should already be in messages array
259
271
  // (added by the caller in handleSubmit for immediate UI display)
260
272
  const updatedMessages = [...messages];
@@ -267,6 +279,10 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
267
279
  let iterationCount = 0;
268
280
  let repairRetryCount = 0;
269
281
  let contextRetryCount = 0;
282
+ let retriggerCount = 0;
283
+ let truncateRetryCount = 0;
284
+ const MAX_RETRIGGERS = 3;
285
+ const MAX_TRUNCATE_RETRIES = 5;
270
286
  const validToolNames = getValidToolNames();
271
287
  while (iterationCount < maxIterations) {
272
288
  // Check if abort was requested
@@ -286,14 +302,15 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
286
302
  updatedMessages.push(...compacted);
287
303
  }
288
304
  }
305
+ // Declare assistantMessage outside try block so it's accessible in catch
306
+ let assistantMessage;
289
307
  try {
290
308
  // Build tools list: core tools + sub-agent tool + dynamic (MCP) tools
291
309
  const allTools = [...getAllTools(), subAgentTool];
292
- logger.debug('Making API request', {
310
+ logger.info('Making API request', {
293
311
  model,
294
312
  toolsCount: allTools.length,
295
313
  messagesCount: updatedMessages.length,
296
- toolNames: allTools.map((t) => t.function?.name).join(', '),
297
314
  });
298
315
  // Log message structure for debugging provider compatibility
299
316
  for (const msg of updatedMessages) {
@@ -335,7 +352,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
335
352
  signal: abortSignal,
336
353
  });
337
354
  // Accumulate the streamed response
338
- const assistantMessage = {
355
+ assistantMessage = {
339
356
  role: 'assistant',
340
357
  content: '',
341
358
  tool_calls: [],
@@ -387,23 +404,47 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
387
404
  }
388
405
  }
389
406
  }
390
- // Emit usage info always emit, even without pricing (use estimates)
407
+ // Log API response with usage info at INFO level
391
408
  {
392
409
  const inputTokens = actualUsage?.prompt_tokens ?? estimateConversationTokens(updatedMessages);
393
410
  const outputTokens = actualUsage?.completion_tokens ?? estimateTokens(assistantMessage.content || '');
411
+ const cachedTokens = actualUsage?.prompt_tokens_details?.cached_tokens;
394
412
  const cost = pricing
395
- ? createUsageInfo(inputTokens, outputTokens, pricing).estimatedCost
413
+ ? createUsageInfo(inputTokens, outputTokens, pricing, cachedTokens).estimatedCost
396
414
  : 0;
397
415
  const contextPercent = pricing
398
416
  ? getContextInfo(updatedMessages, pricing).utilizationPercentage
399
417
  : 0;
418
+ logger.info('Received API response', {
419
+ model,
420
+ inputTokens,
421
+ outputTokens,
422
+ cachedTokens,
423
+ cost: cost > 0 ? `$${cost.toFixed(4)}` : 'N/A',
424
+ contextPercent: contextPercent > 0 ? `${contextPercent.toFixed(1)}%` : 'N/A',
425
+ hasToolCalls: assistantMessage.tool_calls.length > 0,
426
+ contentLength: assistantMessage.content?.length || 0,
427
+ });
400
428
  onEvent({
401
429
  type: 'usage',
402
430
  usage: { inputTokens, outputTokens, cost, contextPercent },
403
431
  });
404
432
  }
433
+ // Log the full assistant message for debugging
434
+ logger.debug('Assistant response details', {
435
+ contentLength: assistantMessage.content?.length || 0,
436
+ contentPreview: assistantMessage.content?.slice(0, 200) || '(empty)',
437
+ toolCallsCount: assistantMessage.tool_calls?.length || 0,
438
+ toolCalls: assistantMessage.tool_calls?.map((tc) => ({
439
+ id: tc.id,
440
+ name: tc.function?.name,
441
+ argsPreview: tc.function?.arguments?.slice(0, 100),
442
+ })),
443
+ });
405
444
  // Handle tool calls
406
445
  if (assistantMessage.tool_calls.length > 0) {
446
+ // Reset retrigger count on valid tool call response
447
+ retriggerCount = 0;
407
448
  // Clean up empty tool_calls entries (from sparse array)
408
449
  assistantMessage.tool_calls = assistantMessage.tool_calls.filter(Boolean);
409
450
  assistantMessage.tool_calls = assistantMessage.tool_calls.map((toolCall) => {
@@ -416,13 +457,33 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
416
457
  }
417
458
  return sanitized.toolCall;
418
459
  });
419
- logger.debug('Model returned tool calls', {
460
+ // Validate that all tool calls have valid JSON arguments
461
+ const invalidToolCalls = assistantMessage.tool_calls.filter((tc) => {
462
+ const args = tc.function?.arguments;
463
+ if (!args)
464
+ return false; // Empty args is valid
465
+ try {
466
+ JSON.parse(args);
467
+ return false; // Valid JSON
468
+ }
469
+ catch {
470
+ return true; // Invalid JSON
471
+ }
472
+ });
473
+ if (invalidToolCalls.length > 0) {
474
+ logger.warn('Assistant produced tool calls with invalid JSON, skipping this turn', {
475
+ invalidToolCalls: invalidToolCalls.map((tc) => ({
476
+ name: tc.function?.name,
477
+ argsPreview: tc.function?.arguments?.slice(0, 100),
478
+ })),
479
+ });
480
+ // Don't add the malformed assistant message to conversation
481
+ // The loop will continue and retry
482
+ continue;
483
+ }
484
+ logger.info('Model returned tool calls', {
420
485
  count: assistantMessage.tool_calls.length,
421
- calls: assistantMessage.tool_calls.map((tc) => ({
422
- id: tc.id,
423
- name: tc.function?.name,
424
- argsPreview: tc.function?.arguments?.slice(0, 100),
425
- })),
486
+ tools: assistantMessage.tool_calls.map((tc) => tc.function?.name).join(', '),
426
487
  });
427
488
  updatedMessages.push(assistantMessage);
428
489
  // Track which tool_call_ids still need a tool result message.
@@ -459,19 +520,25 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
459
520
  const subProgress = (evt) => {
460
521
  onEvent({
461
522
  type: 'sub_agent_iteration',
462
- subAgentTool: { tool: evt.tool, status: evt.status, iteration: evt.iteration },
523
+ subAgentTool: { tool: evt.tool, status: evt.status, iteration: evt.iteration, args: evt.args },
463
524
  });
464
525
  };
465
- result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress, abortSignal);
526
+ const subResult = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress, abortSignal, pricing);
527
+ result = subResult.response;
528
+ // Emit sub-agent usage for the UI to add to total cost
529
+ if (subResult.usage.inputTokens > 0 || subResult.usage.outputTokens > 0) {
530
+ onEvent({
531
+ type: 'sub_agent_iteration',
532
+ subAgentUsage: subResult.usage,
533
+ });
534
+ }
466
535
  }
467
536
  else {
468
537
  result = await handleToolCall(name, args, { sessionId, abortSignal });
469
538
  }
470
- logger.debug('Tool result', {
539
+ logger.info('Tool completed', {
471
540
  tool: name,
472
- tool_call_id: toolCall.id,
473
541
  resultLength: result.length,
474
- resultPreview: result.slice(0, 200),
475
542
  });
476
543
  updatedMessages.push({
477
544
  role: 'tool',
@@ -517,14 +584,67 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
517
584
  role: 'assistant',
518
585
  content: assistantMessage.content,
519
586
  });
587
+ // Reset retrigger count on valid content response
588
+ retriggerCount = 0;
589
+ }
590
+ // Check if we need to retrigger: if the last message is a tool result
591
+ // but we got no assistant response (empty content, no tool_calls), the AI
592
+ // may have stopped prematurely. Inject a 'continue' prompt and retry.
593
+ const lastMessage = updatedMessages[updatedMessages.length - 1];
594
+ if (lastMessage?.role === 'tool' && retriggerCount < MAX_RETRIGGERS) {
595
+ retriggerCount++;
596
+ logger.warn('AI stopped after tool call without responding; retriggering', {
597
+ retriggerCount,
598
+ maxRetriggers: MAX_RETRIGGERS,
599
+ lastMessageRole: lastMessage.role,
600
+ assistantContent: assistantMessage.content || '(empty)',
601
+ hasToolCalls: assistantMessage.tool_calls.length > 0,
602
+ });
603
+ // Inject a 'continue' prompt to help the AI continue
604
+ updatedMessages.push({
605
+ role: 'user',
606
+ content: 'Please continue.',
607
+ });
608
+ continue;
520
609
  }
521
610
  repairRetryCount = 0;
611
+ retriggerCount = 0;
522
612
  onEvent({ type: 'done' });
523
613
  return updatedMessages;
524
614
  }
525
615
  catch (apiError) {
526
616
  if (abortSignal?.aborted || apiError?.name === 'AbortError' || apiError?.message === 'Operation aborted') {
527
617
  logger.debug('Agentic loop request aborted');
618
+ // If we have a partial assistant message with tool_calls, we need to
619
+ // add it to the conversation history before returning, otherwise the
620
+ // message sequence will be invalid (tool results without assistant tool_calls).
621
+ if (assistantMessage && (assistantMessage.content || assistantMessage.tool_calls?.length > 0)) {
622
+ // Clean up empty tool_calls entries
623
+ if (assistantMessage.tool_calls?.length > 0) {
624
+ assistantMessage.tool_calls = assistantMessage.tool_calls.filter(Boolean);
625
+ // Filter out tool calls with malformed/incomplete JSON arguments
626
+ assistantMessage.tool_calls = assistantMessage.tool_calls.filter((tc) => {
627
+ const args = tc.function?.arguments;
628
+ if (!args)
629
+ return true; // No args is valid
630
+ try {
631
+ JSON.parse(args);
632
+ return true; // Valid JSON
633
+ }
634
+ catch {
635
+ logger.warn('Filtering out tool call with malformed JSON arguments due to abort', {
636
+ tool: tc.function?.name,
637
+ argsPreview: args.slice(0, 100),
638
+ });
639
+ return false; // Invalid JSON, filter out
640
+ }
641
+ });
642
+ }
643
+ // Only add the assistant message if we have content or valid tool calls
644
+ if (assistantMessage.content || assistantMessage.tool_calls?.length > 0) {
645
+ updatedMessages.push(assistantMessage);
646
+ }
647
+ }
528
648
  emitAbortAndFinish(onEvent);
529
649
  return updatedMessages;
530
650
  }
@@ -557,21 +677,42 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
557
677
  });
558
678
  const retryableStatus = apiError?.status === 408 || apiError?.status === 409 || apiError?.status === 425;
559
679
  const retryableCode = ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENETUNREACH', 'EAI_AGAIN'].includes(apiError?.code);
560
- if (apiError?.status === 400 && repairRetryCount < 2) {
561
- const sanitized = sanitizeMessagesForRetry(updatedMessages, getValidToolNames());
562
- if (sanitized.changed) {
563
- repairRetryCount++;
564
- updatedMessages.length = 0;
565
- updatedMessages.push(...sanitized.messages);
566
- logger.warn('400 response after malformed tool payload; retrying with sanitized messages', {
567
- repairRetryCount,
568
- });
569
- onEvent({
570
- type: 'error',
571
- error: 'Provider rejected the tool payload. Repairing the request and retrying...',
572
- transient: true,
573
- });
574
- continue;
680
+ // Handle 400 errors: try sanitization first, then truncate messages
681
+ if (apiError?.status === 400) {
682
+ // Try sanitization first
683
+ if (repairRetryCount < 2) {
684
+ const sanitized = sanitizeMessagesForRetry(updatedMessages, getValidToolNames());
685
+ if (sanitized.changed) {
686
+ repairRetryCount++;
687
+ updatedMessages.length = 0;
688
+ updatedMessages.push(...sanitized.messages);
689
+ logger.warn('400 response after malformed tool payload; retrying with sanitized messages', {
690
+ repairRetryCount,
691
+ });
692
+ // Silently retry without showing error to user
693
+ continue;
694
+ }
695
+ }
696
+ // If sanitization didn't help, try removing messages one at a time (up to 5)
697
+ if (truncateRetryCount < MAX_TRUNCATE_RETRIES) {
698
+ truncateRetryCount++;
699
+ const removedCount = Math.min(1, Math.max(0, updatedMessages.length - 2)); // Remove 1 at a time, keep system + at least 1 user
700
+ if (removedCount > 0) {
701
+ const removed = updatedMessages.splice(-removedCount);
702
+ logger.debug('400 error: removing message from history to attempt fix', {
703
+ truncateRetryCount,
704
+ maxRetries: MAX_TRUNCATE_RETRIES,
705
+ removedCount,
706
+ removedRoles: removed.map((m) => m.role),
707
+ removedPreviews: removed.map((m) => ({
708
+ role: m.role,
709
+ content: m.content?.slice(0, 100),
710
+ tool_calls: m.tool_calls?.map((tc) => tc.function?.name),
711
+ })),
712
+ });
713
+ // Silently retry without showing error to user
714
+ continue;
715
+ }
575
716
  }
576
717
  }
577
718
  // Handle context-window-exceeded (prompt too long) — attempt forced compaction
@@ -633,6 +774,16 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
633
774
  await sleepWithAbort(backoff, abortSignal);
634
775
  continue;
635
776
  }
777
+ // 400 error that couldn't be fixed by sanitization or truncation
778
+ if (apiError?.status === 400) {
779
+ onEvent({
780
+ type: 'error',
781
+ error: 'The conversation history appears to be corrupted and could not be automatically repaired. Try /clear to start fresh.',
782
+ transient: false,
783
+ });
784
+ onEvent({ type: 'done' });
785
+ return updatedMessages;
786
+ }
636
787
  // Non-retryable error
637
788
  onEvent({ type: 'error', error: errMsg });
638
789
  onEvent({ type: 'done' });
package/dist/cli.js CHANGED
@@ -22,12 +22,12 @@ const program = new Command();
22
22
  program
23
23
  .description('ProtoAgent — a simple, hackable coding agent CLI')
24
24
  .version(packageJson.version)
25
- .option('--dangerously-accept-all', 'Auto-approve all file writes and shell commands')
26
- .option('--log-level <level>', 'Log level: TRACE, DEBUG, INFO, WARN, ERROR', 'INFO')
25
+ .option('--dangerously-skip-permissions', 'Auto-approve all file writes and shell commands')
26
+ .option('--log-level <level>', 'Log level: TRACE, DEBUG, INFO, WARN, ERROR', 'DEBUG')
27
27
  .option('--session <id>', 'Resume a previous session by ID')
28
28
  .action((options) => {
29
29
  // Default action - start the main app
30
- render(_jsx(App, { dangerouslyAcceptAll: options.dangerouslyAcceptAll || false, logLevel: options.logLevel, sessionId: options.session }));
30
+ render(_jsx(App, { dangerouslySkipPermissions: options.dangerouslySkipPermissions || false, logLevel: options.logLevel, sessionId: options.session }));
31
31
  });
32
32
  // Configure subcommand
33
33
  program
package/dist/config.js CHANGED
@@ -16,21 +16,24 @@ function hardenPermissions(targetPath, mode) {
16
16
  chmodSync(targetPath, mode);
17
17
  }
18
18
  export function resolveApiKey(config) {
19
- const directApiKey = config.apiKey?.trim();
20
- if (directApiKey) {
21
- return directApiKey;
22
- }
23
19
  const provider = getProvider(config.provider);
20
+ // 1. Provider-specific environment variable
24
21
  if (provider?.apiKeyEnvVar) {
25
22
  const providerEnvOverride = process.env[provider.apiKeyEnvVar]?.trim();
26
23
  if (providerEnvOverride) {
27
24
  return providerEnvOverride;
28
25
  }
29
26
  }
27
+ // 2. Generic environment variable
30
28
  const envOverride = process.env.PROTOAGENT_API_KEY?.trim();
31
29
  if (envOverride) {
32
30
  return envOverride;
33
31
  }
32
+ // 3. Config file (either from selected provider or direct apiKey)
33
+ const directApiKey = config.apiKey?.trim();
34
+ if (directApiKey) {
35
+ return directApiKey;
36
+ }
34
37
  const providerApiKey = provider?.apiKey?.trim();
35
38
  if (providerApiKey) {
36
39
  return providerApiKey;
@@ -84,9 +87,47 @@ const RUNTIME_CONFIG_TEMPLATE = `{
84
87
  // - custom providers/models
85
88
  // - MCP server definitions
86
89
  // - request default parameters
87
- "providers": {},
90
+ "providers": {
91
+ // "provider-id": {
92
+ // "name": "Display Name",
93
+ // "baseURL": "https://api.example.com/v1",
94
+ // "apiKey": "your-api-key",
95
+ // "apiKeyEnvVar": "ENV_VAR_NAME",
96
+ // "headers": {
97
+ // "X-Custom-Header": "value"
98
+ // },
99
+ // "defaultParams": {},
100
+ // "models": {
101
+ // "model-id": {
102
+ // "name": "Display Name",
103
+ // "contextWindow": 128000,
104
+ // "inputPricePerMillion": 2.5,
105
+ // "outputPricePerMillion": 10.0,
106
+ // "cachedPricePerMillion": 1.25,
107
+ // "defaultParams": {}
108
+ // }
109
+ // }
110
+ // }
111
+ },
88
112
  "mcp": {
89
- "servers": {}
113
+ "servers": {
114
+ // "server-name": {
115
+ // "type": "stdio",
116
+ // "command": "npx",
117
+ // "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
118
+ // "env": { "KEY": "value" },
119
+ // "cwd": "/working/directory",
120
+ // "enabled": true,
121
+ // "timeoutMs": 30000
122
+ // },
123
+ // "http-server": {
124
+ // "type": "http",
125
+ // "url": "https://mcp-server.example.com",
126
+ // "headers": { "Authorization": "Bearer token" },
127
+ // "enabled": true,
128
+ // "timeoutMs": 30000
129
+ // }
130
+ }
90
131
  }
91
132
  }
92
133
  `;
@@ -215,7 +256,13 @@ export const ResetPrompt = ({ existingConfig, setStep, setConfigWritten }) => {
215
256
  }
216
257
  } })] }));
217
258
  };
218
- export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setStep, }) => {
259
+ export const TargetSelection = ({ title, subtitle, onSelect, }) => {
260
+ return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { color: "green", bold: true, children: title }), subtitle && _jsx(Text, { children: subtitle }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: [
261
+ { label: `Project config — ${getProjectRuntimeConfigPath()}`, value: 'project' },
262
+ { label: `Shared user config — ${getUserRuntimeConfigPath()}`, value: 'user' },
263
+ ], onChange: (value) => onSelect(value) }) })] }));
264
+ };
265
+ export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, onSelect, setStep, title, }) => {
219
266
  const items = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
220
267
  label: `${provider.name} - ${model.name}`,
221
268
  value: `${provider.id}:::${model.id}`,
@@ -224,11 +271,16 @@ export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setS
224
271
  const [providerId, modelId] = value.split(':::');
225
272
  setSelectedProviderId(providerId);
226
273
  setSelectedModelId(modelId);
227
- setStep(3);
274
+ if (onSelect) {
275
+ onSelect(providerId, modelId);
276
+ }
277
+ else {
278
+ setStep?.(3);
279
+ }
228
280
  };
229
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select an AI Model:" }), _jsx(Select, { options: items, onChange: handleSelect })] }));
281
+ return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { color: "green", bold: true, children: title }), _jsx(Text, { children: "Select an AI Model:" }), _jsx(Select, { options: items, onChange: handleSelect })] }));
230
282
  };
231
- export const ApiKeyInput = ({ selectedProviderId, selectedModelId, target, setStep, setConfigWritten, }) => {
283
+ export const ApiKeyInput = ({ selectedProviderId, selectedModelId, target = 'user', title, showProviderHeaders = true, onComplete, setStep, setConfigWritten, }) => {
232
284
  const [errorMessage, setErrorMessage] = useState('');
233
285
  const provider = getProvider(selectedProviderId);
234
286
  const canUseResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
@@ -243,10 +295,15 @@ export const ApiKeyInput = ({ selectedProviderId, selectedModelId, target, setSt
243
295
  ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
244
296
  };
245
297
  writeConfig(newConfig, target);
246
- setConfigWritten(true);
247
- setStep(4);
298
+ if (onComplete) {
299
+ onComplete(newConfig);
300
+ }
301
+ else {
302
+ setConfigWritten?.(true);
303
+ setStep?.(4);
304
+ }
248
305
  };
249
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [canUseResolvedAuth ? 'Optional API Key' : 'Enter API Key', " for ", provider?.name || selectedProviderId, ":"] }), provider?.headers && Object.keys(provider.headers).length > 0 && (_jsx(Text, { dimColor: true, children: "This provider can authenticate with configured headers or environment variables." })), errorMessage && _jsx(Text, { color: "red", children: errorMessage }), _jsx(PasswordInput, { placeholder: canUseResolvedAuth ? 'Press enter to keep resolved auth' : `Enter your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: handleApiKeySubmit })] }));
306
+ return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { color: "green", bold: true, children: title }), _jsxs(Text, { children: [canUseResolvedAuth ? 'Optional API Key' : 'Enter API Key', " for ", provider?.name || selectedProviderId, ":"] }), showProviderHeaders && provider?.headers && Object.keys(provider.headers).length > 0 && (_jsx(Text, { dimColor: true, children: "This provider can authenticate with configured headers or environment variables." })), errorMessage && _jsx(Text, { color: "red", children: errorMessage }), _jsx(PasswordInput, { placeholder: canUseResolvedAuth ? 'Press enter to keep resolved auth' : `Enter your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: handleApiKeySubmit })] }));
250
307
  };
251
308
  export const ConfigResult = ({ configWritten }) => {
252
309
  return (_jsxs(Box, { flexDirection: "column", children: [configWritten ? (_jsx(Text, { color: "green", children: "Configuration saved successfully!" })) : (_jsx(Text, { color: "yellow", children: "Configuration not changed." })), _jsx(Text, { children: "You can now run ProtoAgent." })] }));
@@ -259,15 +316,12 @@ export const ConfigureComponent = () => {
259
316
  const [selectedModelId, setSelectedModelId] = useState('');
260
317
  const [configWritten, setConfigWritten] = useState(false);
261
318
  if (step === 0) {
262
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Choose where to configure ProtoAgent:" }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: [
263
- { label: `Project config — ${getProjectRuntimeConfigPath()}`, value: 'project' },
264
- { label: `Shared user config — ${getUserRuntimeConfigPath()}`, value: 'user' },
265
- ], onChange: (value) => {
266
- setTarget(value);
267
- const existing = readConfig(value);
268
- setExistingConfig(existing);
269
- setStep(existing ? 1 : 2);
270
- } }) })] }));
319
+ return (_jsx(TargetSelection, { subtitle: "Choose where to configure ProtoAgent:", onSelect: (value) => {
320
+ setTarget(value);
321
+ const existing = readConfig(value);
322
+ setExistingConfig(existing);
323
+ setStep(existing ? 1 : 2);
324
+ } }));
271
325
  }
272
326
  switch (step) {
273
327
  case 1:
package/dist/mcp.js CHANGED
@@ -42,6 +42,7 @@ async function connectStdioServer(serverName, config) {
42
42
  ...(config.env || {}),
43
43
  },
44
44
  cwd: config.cwd,
45
+ stderr: 'pipe',
45
46
  });
46
47
  const client = new Client({
47
48
  name: 'protoagent',
@@ -50,6 +51,14 @@ async function connectStdioServer(serverName, config) {
50
51
  capabilities: {},
51
52
  });
52
53
  await client.connect(transport);
54
+ // Pipe stderr from the spawned process to the logger instead of letting it
55
+ // bleed through to the terminal and corrupt the Ink UI.
56
+ transport.stderr?.on('data', (data) => {
57
+ for (const line of data.toString('utf-8').split('\n')) {
58
+ if (line.trim())
59
+ logger.debug(`MCP [${serverName}] ${line}`);
60
+ }
61
+ });
53
62
  return {
54
63
  client,
55
64
  serverName,
@@ -169,3 +178,9 @@ export async function closeMcp() {
169
178
  }
170
179
  connections.clear();
171
180
  }
181
+ /**
182
+ * Get the names of all connected MCP servers.
183
+ */
184
+ export function getConnectedMcpServers() {
185
+ return Array.from(connections.keys());
186
+ }
package/dist/providers.js CHANGED
@@ -11,9 +11,9 @@ export const BUILTIN_PROVIDERS = [
11
11
  name: 'OpenAI',
12
12
  apiKeyEnvVar: 'OPENAI_API_KEY',
13
13
  models: [
14
- { id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 200_000, pricingPerMillionInput: 6.0, pricingPerMillionOutput: 24.0 },
15
- { id: 'gpt-5-mini', name: 'GPT-5 Mini', contextWindow: 200_000, pricingPerMillionInput: 0.15, pricingPerMillionOutput: 0.6 },
16
- { id: 'gpt-4.1', name: 'GPT-4.1', contextWindow: 128_000, pricingPerMillionInput: 2.5, pricingPerMillionOutput: 10.0 },
14
+ { id: 'gpt-5.4', name: 'GPT-5.4', contextWindow: 1_048_576, pricingPerMillionInput: 2.50, pricingPerMillionOutput: 15.00 },
15
+ { id: 'gpt-5-mini', name: 'GPT-5 Mini', contextWindow: 1_000_000, pricingPerMillionInput: 0.25, pricingPerMillionOutput: 2.00 },
16
+ { id: 'gpt-4.1', name: 'GPT-4.1', contextWindow: 1_048_576, pricingPerMillionInput: 2.0, pricingPerMillionOutput: 8.00 },
17
17
  ],
18
18
  },
19
19
  {
@@ -33,21 +33,12 @@ export const BUILTIN_PROVIDERS = [
33
33
  baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
34
34
  apiKeyEnvVar: 'GEMINI_API_KEY',
35
35
  models: [
36
- { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 0.075, pricingPerMillionOutput: 0.3 },
37
- { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 1.25, pricingPerMillionOutput: 10.0 },
38
- { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1_000_000, pricingPerMillionInput: 0.075, pricingPerMillionOutput: 0.3 },
36
+ { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 0.50, pricingPerMillionOutput: 3.0 },
37
+ { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 2.0, pricingPerMillionOutput: 12.0 },
38
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1_000_000, pricingPerMillionInput: 0.30, pricingPerMillionOutput: 2.5 },
39
39
  { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1_000_000, pricingPerMillionInput: 1.25, pricingPerMillionOutput: 10.0 },
40
40
  ],
41
41
  },
42
- {
43
- id: 'cerebras',
44
- name: 'Cerebras',
45
- baseURL: 'https://api.cerebras.ai/v1',
46
- apiKeyEnvVar: 'CEREBRAS_API_KEY',
47
- models: [
48
- { id: 'llama-4-scout-17b-16e-instruct', name: 'Llama 4 Scout 17B', contextWindow: 128_000, pricingPerMillionInput: 0.0, pricingPerMillionOutput: 0.0 },
49
- ],
50
- },
51
42
  ];
52
43
  function sanitizeDefaultParams(defaultParams) {
53
44
  if (!defaultParams || Object.keys(defaultParams).length === 0)
@@ -67,6 +58,7 @@ function mergeModelLists(baseModels, overrideModels) {
67
58
  contextWindow: override.contextWindow ?? current?.contextWindow ?? 0,
68
59
  pricingPerMillionInput: override.inputPricePerMillion ?? current?.pricingPerMillionInput ?? 0,
69
60
  pricingPerMillionOutput: override.outputPricePerMillion ?? current?.pricingPerMillionOutput ?? 0,
61
+ pricingPerMillionCached: override.cachedPricePerMillion ?? current?.pricingPerMillionCached,
70
62
  defaultParams: sanitizeDefaultParams({
71
63
  ...(current?.defaultParams || {}),
72
64
  ...(override.defaultParams || {}),
@@ -109,6 +101,7 @@ export function getModelPricing(providerId, modelId) {
109
101
  return {
110
102
  inputPerToken: details.pricingPerMillionInput / 1_000_000,
111
103
  outputPerToken: details.pricingPerMillionOutput / 1_000_000,
104
+ cachedPerToken: details.pricingPerMillionCached != null ? details.pricingPerMillionCached / 1_000_000 : undefined,
112
105
  contextWindow: details.contextWindow,
113
106
  };
114
107
  }
package/dist/sessions.js CHANGED
@@ -9,22 +9,32 @@
9
9
  import fs from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
  import os from 'node:os';
12
- import crypto from 'node:crypto';
13
12
  import { chmodSync } from 'node:fs';
14
13
  import { logger } from './utils/logger.js';
15
14
  const SESSION_DIR_MODE = 0o700;
16
15
  const SESSION_FILE_MODE = 0o600;
17
16
  const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
17
+ const SHORT_ID_PATTERN = /^[0-9a-z]{8}$/i;
18
18
  function hardenPermissions(targetPath, mode) {
19
19
  if (process.platform === 'win32')
20
20
  return;
21
21
  chmodSync(targetPath, mode);
22
22
  }
23
23
  function assertValidSessionId(id) {
24
- if (!SESSION_ID_PATTERN.test(id)) {
24
+ // Accept both legacy UUIDs and new short IDs
25
+ if (!SESSION_ID_PATTERN.test(id) && !SHORT_ID_PATTERN.test(id)) {
25
26
  throw new Error(`Invalid session ID: ${id}`);
26
27
  }
27
28
  }
29
+ /** Generate a short, readable session ID (8 alphanumeric characters). */
30
+ function generateSessionId() {
31
+ const chars = '0123456789abcdefghijklmnopqrstuvwxyz';
32
+ let id = '';
33
+ for (let i = 0; i < 8; i++) {
34
+ id += chars.charAt(Math.floor(Math.random() * chars.length));
35
+ }
36
+ return id;
37
+ }
28
38
  export function ensureSystemPromptAtTop(messages, systemPrompt) {
29
39
  const firstSystemIndex = messages.findIndex((message) => message.role === 'system');
30
40
  if (firstSystemIndex === -1) {
@@ -62,7 +72,7 @@ function sessionPath(id) {
62
72
  /** Create a new session. */
63
73
  export function createSession(model, provider) {
64
74
  return {
65
- id: crypto.randomUUID(),
75
+ id: generateSessionId(),
66
76
  title: 'New session',
67
77
  createdAt: new Date().toISOString(),
68
78
  updatedAt: new Date().toISOString(),
package/dist/skills.js CHANGED
@@ -36,6 +36,7 @@ function parseFrontmatter(rawContent) {
36
36
  function isValidSkillName(name) {
37
37
  return name.length >= 1 && name.length <= 64 && VALID_SKILL_NAME.test(name);
38
38
  }
39
+ // normalizeMetadata ensures the metadata field is an object with string values, or undefined if not provided or invalid
39
40
  function normalizeMetadata(value) {
40
41
  if (!value || typeof value !== 'object' || Array.isArray(value))
41
42
  return undefined;
@@ -89,7 +90,7 @@ async function loadSkillFromDirectory(skillDir, source) {
89
90
  const rawContent = await fs.readFile(location, 'utf8');
90
91
  const parsed = parseFrontmatter(rawContent);
91
92
  const skill = validateSkill(parsed, skillDir, source, location);
92
- logger.debug(`Loaded skill: ${skill.name} (${source})`, { location });
93
+ logger.info(`Loaded skill: ${skill.name} (${source})`, { location });
93
94
  return skill;
94
95
  }
95
96
  catch (error) {