protoagent 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -1,4 +1,7 @@
1
- # ProtoAgent
1
+ ```
2
+ █▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀
3
+ █▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █
4
+ ```
2
5
 
3
6
  A minimal, educational AI coding agent CLI written in TypeScript. It stays small enough to read in an afternoon, but it still has the core pieces you expect from a real coding agent: a streaming tool-use loop, approvals, sessions, MCP, skills, sub-agents, and cost tracking.
4
7
 
@@ -8,7 +11,7 @@ A minimal, educational AI coding agent CLI written in TypeScript. It stays small
8
11
  - **Built-in tools** — Read, write, edit, list, search, run shell commands, manage todos, and fetch web pages with `webfetch`
9
12
  - **Approval system** — Inline confirmation for file writes, file edits, and non-safe shell commands
10
13
  - **Session persistence** — Conversations and TODO state are saved automatically and can be resumed with `--session`
11
- - **Dynamic extensions** — Load skills on demand and add external tools through MCP servers
14
+ <!-- - **Dynamic extensions** — Load skills on demand and add external tools through MCP servers -->
12
15
  - **Sub-agents** — Delegate self-contained tasks to isolated child conversations
13
16
  - **Usage tracking** — Live token, context, and estimated cost display in the TUI
14
17
 
@@ -19,7 +22,12 @@ npm install -g protoagent
19
22
  protoagent
20
23
  ```
21
24
 
22
- On first run, ProtoAgent shows an inline setup flow where you pick a provider/model pair and enter an API key. Config is stored in `~/.local/share/protoagent/config.json` on macOS/Linux and `~/AppData/Local/protoagent/config.json` on Windows.
25
+ On first run, ProtoAgent shows an inline setup flow where you pick a provider/model pair and enter an API key. ProtoAgent stores that selection in `protoagent.jsonc`.
26
+
27
+ Runtime config lookup is simple:
28
+
29
+ - if `<cwd>/.protoagent/protoagent.jsonc` exists, ProtoAgent uses it
30
+ - otherwise it falls back to the shared user config at `~/.config/protoagent/protoagent.jsonc` on macOS/Linux and `~/AppData/Local/protoagent/protoagent.jsonc` on Windows
23
31
 
24
32
  You can also run the standalone wizard directly:
25
33
 
@@ -27,6 +35,29 @@ You can also run the standalone wizard directly:
27
35
  protoagent configure
28
36
  ```
29
37
 
38
+ Or configure a specific target non-interactively:
39
+
40
+ ```bash
41
+ protoagent configure --project --provider openai --model gpt-5-mini
42
+ protoagent configure --user --provider anthropic --model claude-sonnet-4-6
43
+ ```
44
+
45
+ To create a runtime config file for the current project or your shared user config, run:
46
+
47
+ ```bash
48
+ protoagent init
49
+ ```
50
+
51
+ `protoagent init` creates `protoagent.jsonc` in either `<cwd>/.protoagent/protoagent.jsonc` or your shared user config location and prints the exact path it used.
52
+
53
+ For scripts or non-interactive setup, use:
54
+
55
+ ```bash
56
+ protoagent init --project
57
+ protoagent init --user
58
+ protoagent init --project --force
59
+ ```
60
+
30
61
  ## Interactive Commands
31
62
 
32
63
  - `/help` — Show available slash commands
package/dist/App.js CHANGED
@@ -232,7 +232,7 @@ const InlineSetup = ({ onComplete }) => {
232
232
  model: selectedModelId,
233
233
  ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
234
234
  };
235
- writeConfig(newConfig);
235
+ writeConfig(newConfig, 'project');
236
236
  onComplete(newConfig);
237
237
  } })] }));
238
238
  };
@@ -365,8 +365,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
365
365
  });
366
366
  });
367
367
  await loadRuntimeConfig();
368
- // Load config — if none exists, show inline setup
369
- const loadedConfig = readConfig();
368
+ const loadedConfig = readConfig('active');
370
369
  if (!loadedConfig) {
371
370
  setNeedsSetup(true);
372
371
  return;
@@ -504,6 +503,17 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
504
503
  }
505
504
  }
506
505
  break;
506
+ case 'sub_agent_iteration':
507
+ if (event.subAgentTool) {
508
+ const { tool, status } = event.subAgentTool;
509
+ if (status === 'running') {
510
+ setActiveTool(`sub_agent → ${tool}`);
511
+ }
512
+ else {
513
+ setActiveTool(null);
514
+ }
515
+ }
516
+ break;
507
517
  case 'tool_call':
508
518
  if (event.toolCall) {
509
519
  const toolCall = event.toolCall;
@@ -126,6 +126,21 @@ function extractFirstCompleteJsonValue(value) {
126
126
  }
127
127
  return null;
128
128
  }
129
+ /**
130
+ * Repair invalid JSON escape sequences in a string value.
131
+ *
132
+ * JSON only allows: \" \\ \/ \b \f \n \r \t \uXXXX
133
+ * Models sometimes emit \| \! \- etc. (e.g. grep regex args) which make
134
+ * JSON.parse throw, and Anthropic strict-validates tool_call arguments on
135
+ * every subsequent request, bricking the session permanently.
136
+ *
137
+ * We double the backslash for any \X where X is not a valid JSON escape char.
138
+ */
139
+ function repairInvalidEscapes(value) {
140
+ // Match a backslash followed by any character that is NOT a valid JSON escape
141
+ // Valid escapes: " \ / b f n r t u
142
+ return value.replace(/\\([^"\\\/bfnrtu])/g, '\\\\$1');
143
+ }
129
144
  function normalizeJsonArguments(argumentsText) {
130
145
  const trimmed = argumentsText.trim();
131
146
  if (!trimmed)
@@ -157,6 +172,25 @@ function normalizeJsonArguments(argumentsText) {
157
172
  // Give up and return the original text below.
158
173
  }
159
174
  }
175
+ // Heuristic: repair invalid escape sequences (e.g. \| from grep regex args)
176
+ const repaired = repairInvalidEscapes(trimmed);
177
+ if (repaired !== trimmed) {
178
+ try {
179
+ JSON.parse(repaired);
180
+ return repaired;
181
+ }
182
+ catch {
183
+ // Try repair + first-value extraction together
184
+ const repairedFirst = extractFirstCompleteJsonValue(repaired);
185
+ if (repairedFirst) {
186
+ try {
187
+ JSON.parse(repairedFirst);
188
+ return repairedFirst;
189
+ }
190
+ catch { /* give up */ }
191
+ }
192
+ }
193
+ }
160
194
  return argumentsText;
161
195
  }
162
196
  function sanitizeToolCall(toolCall, validToolNames) {
@@ -231,6 +265,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
231
265
  }
232
266
  let iterationCount = 0;
233
267
  let repairRetryCount = 0;
268
+ let contextRetryCount = 0;
234
269
  const validToolNames = getValidToolNames();
235
270
  while (iterationCount < maxIterations) {
236
271
  // Check if abort was requested
@@ -389,10 +424,24 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
389
424
  })),
390
425
  });
391
426
  updatedMessages.push(assistantMessage);
427
+ // Track which tool_call_ids still need a tool result message.
428
+ // This set is used to inject stub responses on abort, preventing
429
+ // orphaned tool_call_ids from permanently bricking the session.
430
+ const pendingToolCallIds = new Set(assistantMessage.tool_calls.map((tc) => tc.id));
431
+ const injectStubsForPendingToolCalls = () => {
432
+ for (const id of pendingToolCallIds) {
433
+ updatedMessages.push({
434
+ role: 'tool',
435
+ tool_call_id: id,
436
+ content: 'Aborted by user.',
437
+ });
438
+ }
439
+ };
392
440
  for (const toolCall of assistantMessage.tool_calls) {
393
441
  // Check abort between tool calls
394
442
  if (abortSignal?.aborted) {
395
443
  logger.debug('Agentic loop aborted between tool calls');
444
+ injectStubsForPendingToolCalls();
396
445
  emitAbortAndFinish(onEvent);
397
446
  return updatedMessages;
398
447
  }
@@ -408,19 +457,14 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
408
457
  if (name === 'sub_agent') {
409
458
  const subProgress = (evt) => {
410
459
  onEvent({
411
- type: 'tool_call',
412
- toolCall: {
413
- id: toolCall.id,
414
- name: `sub_agent → ${evt.tool}`,
415
- args: '',
416
- status: evt.status === 'running' ? 'running' : 'done',
417
- },
460
+ type: 'sub_agent_iteration',
461
+ subAgentTool: { tool: evt.tool, status: evt.status, iteration: evt.iteration },
418
462
  });
419
463
  };
420
- result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress);
464
+ result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress, abortSignal);
421
465
  }
422
466
  else {
423
- result = await handleToolCall(name, args, { sessionId });
467
+ result = await handleToolCall(name, args, { sessionId, abortSignal });
424
468
  }
425
469
  logger.debug('Tool result', {
426
470
  tool: name,
@@ -433,6 +477,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
433
477
  tool_call_id: toolCall.id,
434
478
  content: result,
435
479
  });
480
+ pendingToolCallIds.delete(toolCall.id);
436
481
  onEvent({
437
482
  type: 'tool_result',
438
483
  toolCall: { id: toolCall.id, name, args: argsStr, status: 'done', result },
@@ -445,6 +490,14 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
445
490
  tool_call_id: toolCall.id,
446
491
  content: `Error: ${errMsg}`,
447
492
  });
493
+ pendingToolCallIds.delete(toolCall.id);
494
+ // If the tool was aborted, inject stubs for remaining pending calls and stop
495
+ if (abortSignal?.aborted || (err instanceof Error && (err.name === 'AbortError' || err.message === 'Operation aborted'))) {
496
+ logger.debug('Agentic loop aborted during tool execution');
497
+ injectStubsForPendingToolCalls();
498
+ emitAbortAndFinish(onEvent);
499
+ return updatedMessages;
500
+ }
448
501
  onEvent({
449
502
  type: 'tool_result',
450
503
  toolCall: { id: toolCall.id, name, args: argsStr, status: 'error', result: errMsg },
@@ -520,6 +573,48 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
520
573
  continue;
521
574
  }
522
575
  }
576
+ // Handle context-window-exceeded (prompt too long) — attempt forced compaction
577
+ // This fires when our token estimate was too low (e.g. base64 images from MCP tools)
578
+ // and the request actually hit the hard provider limit.
579
+ const isContextTooLong = apiError?.status === 400 &&
580
+ typeof errMsg === 'string' &&
581
+ /prompt.{0,30}too long|context.{0,30}length|maximum.{0,30}token|tokens?.{0,10}exceed/i.test(errMsg);
582
+ if (isContextTooLong && contextRetryCount < 2) {
583
+ contextRetryCount++;
584
+ logger.warn(`Prompt too long (attempt ${contextRetryCount}); forcing compaction`, { errMsg });
585
+ onEvent({
586
+ type: 'error',
587
+ error: 'Prompt too long. Compacting conversation and retrying...',
588
+ transient: true,
589
+ });
590
+ if (pricing) {
591
+ // Use the normal LLM-based compaction path
592
+ try {
593
+ const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow,
594
+ // Pass the context window itself as currentTokens to force compaction
595
+ pricing.contextWindow, requestDefaults, sessionId);
596
+ updatedMessages.length = 0;
597
+ updatedMessages.push(...compacted);
598
+ }
599
+ catch (compactErr) {
600
+ logger.error(`Forced compaction failed: ${compactErr}`);
601
+ // Fall through to truncation fallback below
602
+ }
603
+ }
604
+ // Fallback: truncate any tool result messages whose content looks like
605
+ // base64 or is extremely large (e.g. MCP screenshot data)
606
+ const MAX_TOOL_RESULT_CHARS = 20_000;
607
+ for (let i = 0; i < updatedMessages.length; i++) {
608
+ const m = updatedMessages[i];
609
+ if (m.role === 'tool' && typeof m.content === 'string' && m.content.length > MAX_TOOL_RESULT_CHARS) {
610
+ updatedMessages[i] = {
611
+ ...m,
612
+ content: m.content.slice(0, MAX_TOOL_RESULT_CHARS) + '\n... (truncated — content was too large)',
613
+ };
614
+ }
615
+ }
616
+ continue;
617
+ }
523
618
  // Retry on 429 (rate limit) with backoff
524
619
  if (apiError?.status === 429) {
525
620
  const retryAfter = parseInt(apiError?.headers?.['retry-after'] || '5', 10);
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { readFileSync } from 'node:fs';
13
13
  import { render } from 'ink';
14
14
  import { Command } from 'commander';
15
15
  import { App } from './App.js';
16
- import { ConfigureComponent } from './config.js';
16
+ import { ConfigureComponent, InitComponent, readConfig, writeConfig, writeInitConfig } from './config.js';
17
17
  // Get package.json version
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = path.dirname(__filename);
@@ -33,7 +33,64 @@ program
33
33
  program
34
34
  .command('configure')
35
35
  .description('Configure AI model and API key settings')
36
- .action(() => {
36
+ .option('--project', 'Write <cwd>/.protoagent/protoagent.jsonc')
37
+ .option('--user', 'Write the shared user protoagent.jsonc')
38
+ .option('--provider <id>', 'Provider id to configure')
39
+ .option('--model <id>', 'Model id to configure')
40
+ .option('--api-key <key>', 'Explicit API key to store in protoagent.jsonc')
41
+ .action((options) => {
42
+ if (options.project || options.user || options.provider || options.model || options.apiKey) {
43
+ if (options.project && options.user) {
44
+ console.error('Choose only one of --project or --user.');
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ if (!options.provider || !options.model) {
49
+ console.error('Non-interactive configure requires --provider and --model.');
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ const target = options.project ? 'project' : 'user';
54
+ const resultPath = writeConfig({
55
+ provider: options.provider,
56
+ model: options.model,
57
+ ...(typeof options.apiKey === 'string' && options.apiKey.trim() ? { apiKey: options.apiKey.trim() } : {}),
58
+ }, target);
59
+ console.log('Configured ProtoAgent:');
60
+ console.log(resultPath);
61
+ const selected = readConfig(target);
62
+ if (selected) {
63
+ console.log(`${selected.provider} / ${selected.model}`);
64
+ }
65
+ return;
66
+ }
37
67
  render(_jsx(ConfigureComponent, {}));
38
68
  });
69
+ program
70
+ .command('init')
71
+ .description('Create a project-local or shared ProtoAgent runtime config')
72
+ .option('--project', 'Write <cwd>/.protoagent/protoagent.jsonc')
73
+ .option('--user', 'Write the shared user protoagent.jsonc')
74
+ .option('--force', 'Overwrite an existing target file')
75
+ .action((options) => {
76
+ if (options.project || options.user) {
77
+ if (options.project && options.user) {
78
+ console.error('Choose only one of --project or --user.');
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+ const result = writeInitConfig(options.project ? 'project' : 'user', process.cwd(), {
83
+ overwrite: Boolean(options.force),
84
+ });
85
+ const message = result.status === 'created'
86
+ ? 'Created ProtoAgent config:'
87
+ : result.status === 'overwritten'
88
+ ? 'Overwrote ProtoAgent config:'
89
+ : 'ProtoAgent config already exists:';
90
+ console.log(message);
91
+ console.log(result.path);
92
+ return;
93
+ }
94
+ render(_jsx(InitComponent, {}));
95
+ });
39
96
  program.parse(process.argv);
package/dist/config.js CHANGED
@@ -2,10 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
- import { useState, useEffect } from 'react';
5
+ import { useState } from 'react';
6
6
  import { Box, Text } from 'ink';
7
7
  import { Select, TextInput, PasswordInput } from '@inkjs/ui';
8
- import { loadRuntimeConfig } from './runtime-config.js';
8
+ import { parse } from 'jsonc-parser';
9
+ import { getActiveRuntimeConfigPath } from './runtime-config.js';
9
10
  import { getAllProviders, getProvider } from './providers.js';
10
11
  const CONFIG_DIR_MODE = 0o700;
11
12
  const CONFIG_FILE_MODE = 0o600;
@@ -56,80 +57,150 @@ export const getConfigDirectory = () => {
56
57
  }
57
58
  return path.join(homeDir, '.local', 'share', 'protoagent');
58
59
  };
59
- export const getConfigPath = () => {
60
- return path.join(getConfigDirectory(), 'config.json');
61
- };
62
- export const ensureConfigDirectory = () => {
63
- const dir = getConfigDirectory();
64
- if (!existsSync(dir)) {
65
- mkdirSync(dir, { recursive: true, mode: CONFIG_DIR_MODE });
60
+ export const getUserRuntimeConfigDirectory = () => {
61
+ const homeDir = os.homedir();
62
+ if (process.platform === 'win32') {
63
+ return path.join(homeDir, 'AppData', 'Local', 'protoagent');
66
64
  }
67
- hardenPermissions(dir, CONFIG_DIR_MODE);
65
+ return path.join(homeDir, '.config', 'protoagent');
68
66
  };
69
- export const readConfig = () => {
70
- const configPath = getConfigPath();
71
- if (existsSync(configPath)) {
72
- try {
73
- const content = readFileSync(configPath, 'utf8');
74
- const raw = JSON.parse(content);
75
- // Handle legacy format: { provider, model, credentials: { KEY: "..." } }
76
- let apiKey = raw.apiKey;
77
- if (!apiKey && raw.credentials && typeof raw.credentials === 'object') {
78
- const provider = getAllProviders().find((p) => p.id === raw.provider);
79
- if (provider?.apiKeyEnvVar) {
80
- apiKey = raw.credentials[provider.apiKeyEnvVar];
81
- }
82
- // Fallback: grab the first non-empty value
83
- if (!apiKey) {
84
- apiKey = Object.values(raw.credentials).find((v) => typeof v === 'string' && v.length > 0);
85
- }
86
- }
87
- if (!raw.provider || !raw.model) {
88
- return null;
89
- }
90
- return {
91
- provider: raw.provider,
92
- model: raw.model,
93
- apiKey: typeof apiKey === 'string' && apiKey.trim().length > 0 ? apiKey.trim() : undefined,
94
- };
95
- }
96
- catch (error) {
97
- console.error('Error reading config file:', error);
67
+ export const getUserRuntimeConfigPath = () => {
68
+ return path.join(getUserRuntimeConfigDirectory(), 'protoagent.jsonc');
69
+ };
70
+ export const getProjectRuntimeConfigDirectory = (cwd = process.cwd()) => {
71
+ return path.join(cwd, '.protoagent');
72
+ };
73
+ export const getProjectRuntimeConfigPath = (cwd = process.cwd()) => {
74
+ return path.join(getProjectRuntimeConfigDirectory(cwd), 'protoagent.jsonc');
75
+ };
76
+ export const getInitConfigPath = (target, cwd = process.cwd()) => {
77
+ return target === 'project' ? getProjectRuntimeConfigPath(cwd) : getUserRuntimeConfigPath();
78
+ };
79
+ const RUNTIME_CONFIG_TEMPLATE = `{
80
+ // Add project or user-wide ProtoAgent runtime config here.
81
+ // Example uses:
82
+ // - choose the active provider/model by making it the first provider
83
+ // and the first model under that provider
84
+ // - custom providers/models
85
+ // - MCP server definitions
86
+ // - request default parameters
87
+ "providers": {},
88
+ "mcp": {
89
+ "servers": {}
90
+ }
91
+ }
92
+ `;
93
+ function ensureDirectory(targetDir) {
94
+ if (!existsSync(targetDir)) {
95
+ mkdirSync(targetDir, { recursive: true, mode: CONFIG_DIR_MODE });
96
+ }
97
+ hardenPermissions(targetDir, CONFIG_DIR_MODE);
98
+ }
99
+ function isPlainObject(value) {
100
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
101
+ }
102
+ function readRuntimeConfigFileSync(configPath) {
103
+ if (!existsSync(configPath)) {
104
+ return null;
105
+ }
106
+ try {
107
+ const content = readFileSync(configPath, 'utf8');
108
+ const errors = [];
109
+ const parsed = parse(content, errors, { allowTrailingComma: true, disallowComments: false });
110
+ if (errors.length > 0 || !isPlainObject(parsed)) {
98
111
  return null;
99
112
  }
113
+ return parsed;
114
+ }
115
+ catch (error) {
116
+ console.error('Error reading runtime config file:', error);
117
+ return null;
118
+ }
119
+ }
120
+ function getConfiguredProviderAndModel(runtimeConfig) {
121
+ for (const [providerId, providerConfig] of Object.entries(runtimeConfig.providers || {})) {
122
+ const modelId = Object.keys(providerConfig.models || {})[0];
123
+ if (!modelId)
124
+ continue;
125
+ const apiKey = typeof providerConfig.apiKey === 'string' && providerConfig.apiKey.trim().length > 0
126
+ ? providerConfig.apiKey.trim()
127
+ : undefined;
128
+ return {
129
+ provider: providerId,
130
+ model: modelId,
131
+ ...(apiKey ? { apiKey } : {}),
132
+ };
100
133
  }
101
134
  return null;
102
- };
103
- export const writeConfig = (config) => {
104
- ensureConfigDirectory();
105
- const configPath = getConfigPath();
106
- const normalizedConfig = {
107
- provider: config.provider,
108
- model: config.model,
135
+ }
136
+ function writeRuntimeConfigFile(configPath, runtimeConfig) {
137
+ ensureDirectory(path.dirname(configPath));
138
+ writeFileSync(configPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, { encoding: 'utf8', mode: CONFIG_FILE_MODE });
139
+ hardenPermissions(configPath, CONFIG_FILE_MODE);
140
+ }
141
+ function upsertSelectedConfig(runtimeConfig, config) {
142
+ const existingProviders = runtimeConfig.providers || {};
143
+ const currentProvider = existingProviders[config.provider] || {};
144
+ const currentModels = currentProvider.models || {};
145
+ const selectedModelConfig = currentModels[config.model] || {};
146
+ const nextProvider = {
147
+ ...currentProvider,
109
148
  ...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
149
+ models: Object.fromEntries([
150
+ [config.model, selectedModelConfig],
151
+ ...Object.entries(currentModels).filter(([modelId]) => modelId !== config.model),
152
+ ]),
153
+ };
154
+ if (!config.apiKey?.trim()) {
155
+ delete nextProvider.apiKey;
156
+ }
157
+ return {
158
+ ...runtimeConfig,
159
+ providers: Object.fromEntries([
160
+ [config.provider, nextProvider],
161
+ ...Object.entries(existingProviders).filter(([providerId]) => providerId !== config.provider),
162
+ ]),
110
163
  };
111
- writeFileSync(configPath, JSON.stringify(normalizedConfig, null, 2), { encoding: 'utf8', mode: CONFIG_FILE_MODE });
164
+ }
165
+ export function writeInitConfig(target, cwd = process.cwd(), options = {}) {
166
+ const configPath = getInitConfigPath(target, cwd);
167
+ const alreadyExists = existsSync(configPath);
168
+ if (alreadyExists) {
169
+ if (!options.overwrite) {
170
+ return { path: configPath, status: 'exists' };
171
+ }
172
+ }
173
+ else {
174
+ ensureDirectory(path.dirname(configPath));
175
+ }
176
+ writeFileSync(configPath, RUNTIME_CONFIG_TEMPLATE, { encoding: 'utf8', mode: CONFIG_FILE_MODE });
112
177
  hardenPermissions(configPath, CONFIG_FILE_MODE);
178
+ return { path: configPath, status: alreadyExists ? 'overwritten' : 'created' };
179
+ }
180
+ export const readConfig = (target = 'active', cwd = process.cwd()) => {
181
+ const configPath = target === 'active' ? getActiveRuntimeConfigPath() : getInitConfigPath(target, cwd);
182
+ if (!configPath) {
183
+ return null;
184
+ }
185
+ const runtimeConfig = readRuntimeConfigFileSync(configPath);
186
+ if (!runtimeConfig) {
187
+ return null;
188
+ }
189
+ return getConfiguredProviderAndModel(runtimeConfig);
113
190
  };
114
- export const InitialLoading = ({ setExistingConfig, setStep }) => {
115
- useEffect(() => {
116
- loadRuntimeConfig()
117
- .then(() => {
118
- const config = readConfig();
119
- if (config) {
120
- setExistingConfig(config);
121
- setStep(1);
122
- }
123
- else {
124
- setStep(2);
125
- }
126
- })
127
- .catch((error) => {
128
- console.error('Error loading runtime config:', error);
129
- setStep(2);
130
- });
131
- }, []);
132
- return _jsx(Text, { children: "Loading configuration..." });
191
+ export function getDefaultConfigTarget(cwd = process.cwd()) {
192
+ const activeConfigPath = getActiveRuntimeConfigPath();
193
+ if (activeConfigPath === getProjectRuntimeConfigPath(cwd)) {
194
+ return 'project';
195
+ }
196
+ return 'user';
197
+ }
198
+ export const writeConfig = (config, target = 'user', cwd = process.cwd()) => {
199
+ const configPath = getInitConfigPath(target, cwd);
200
+ const runtimeConfig = readRuntimeConfigFileSync(configPath) || { providers: {}, mcp: { servers: {} } };
201
+ const nextRuntimeConfig = upsertSelectedConfig(runtimeConfig, config);
202
+ writeRuntimeConfigFile(configPath, nextRuntimeConfig);
203
+ return configPath;
133
204
  };
134
205
  export const ResetPrompt = ({ existingConfig, setStep, setConfigWritten }) => {
135
206
  const [resetInput, setResetInput] = useState('');
@@ -157,7 +228,7 @@ export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setS
157
228
  };
158
229
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select an AI Model:" }), _jsx(Select, { options: items, onChange: handleSelect })] }));
159
230
  };
160
- export const ApiKeyInput = ({ selectedProviderId, selectedModelId, setStep, setConfigWritten, }) => {
231
+ export const ApiKeyInput = ({ selectedProviderId, selectedModelId, target, setStep, setConfigWritten, }) => {
161
232
  const [errorMessage, setErrorMessage] = useState('');
162
233
  const provider = getProvider(selectedProviderId);
163
234
  const canUseResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
@@ -171,7 +242,7 @@ export const ApiKeyInput = ({ selectedProviderId, selectedModelId, setStep, setC
171
242
  model: selectedModelId,
172
243
  ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
173
244
  };
174
- writeConfig(newConfig);
245
+ writeConfig(newConfig, target);
175
246
  setConfigWritten(true);
176
247
  setStep(4);
177
248
  };
@@ -182,22 +253,79 @@ export const ConfigResult = ({ configWritten }) => {
182
253
  };
183
254
  export const ConfigureComponent = () => {
184
255
  const [step, setStep] = useState(0);
256
+ const [target, setTarget] = useState(getDefaultConfigTarget());
185
257
  const [existingConfig, setExistingConfig] = useState(null);
186
258
  const [selectedProviderId, setSelectedProviderId] = useState('');
187
259
  const [selectedModelId, setSelectedModelId] = useState('');
188
260
  const [configWritten, setConfigWritten] = useState(false);
261
+ 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
+ } }) })] }));
271
+ }
189
272
  switch (step) {
190
- case 0:
191
- return _jsx(InitialLoading, { setExistingConfig: setExistingConfig, setStep: setStep });
192
273
  case 1:
193
274
  return _jsx(ResetPrompt, { existingConfig: existingConfig, setStep: setStep, setConfigWritten: setConfigWritten });
194
275
  case 2:
195
276
  return (_jsx(ModelSelection, { setSelectedProviderId: setSelectedProviderId, setSelectedModelId: setSelectedModelId, setStep: setStep }));
196
277
  case 3:
197
- return (_jsx(ApiKeyInput, { selectedProviderId: selectedProviderId, selectedModelId: selectedModelId, setStep: setStep, setConfigWritten: setConfigWritten }));
278
+ return (_jsx(ApiKeyInput, { selectedProviderId: selectedProviderId, selectedModelId: selectedModelId, target: target, setStep: setStep, setConfigWritten: setConfigWritten }));
198
279
  case 4:
199
280
  return _jsx(ConfigResult, { configWritten: configWritten });
200
281
  default:
201
282
  return _jsx(Text, { children: "Unknown step." });
202
283
  }
203
284
  };
285
+ export const InitComponent = () => {
286
+ const [selectedTarget, setSelectedTarget] = useState(null);
287
+ const [result, setResult] = useState(null);
288
+ const options = [
289
+ {
290
+ label: 'Project config',
291
+ value: 'project',
292
+ description: getProjectRuntimeConfigPath(),
293
+ },
294
+ {
295
+ label: 'Shared user config',
296
+ value: 'user',
297
+ description: getUserRuntimeConfigPath(),
298
+ },
299
+ ];
300
+ const activeTarget = selectedTarget ?? 'project';
301
+ const activeOption = options.find((option) => option.value === activeTarget) ?? options[0];
302
+ if (selectedTarget && !result) {
303
+ const selectedPath = getInitConfigPath(selectedTarget);
304
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Config already exists:" }), _jsx(Text, { children: selectedPath }), _jsx(Text, { children: "Overwrite it? (y/n)" }), _jsx(TextInput, { onSubmit: (answer) => {
305
+ if (answer.trim().toLowerCase() === 'y') {
306
+ setResult(writeInitConfig(selectedTarget, process.cwd(), { overwrite: true }));
307
+ }
308
+ else {
309
+ setResult({ path: selectedPath, status: 'exists' });
310
+ }
311
+ } })] }));
312
+ }
313
+ if (result) {
314
+ const color = result.status === 'exists' ? 'yellow' : 'green';
315
+ const message = result.status === 'created'
316
+ ? 'Created ProtoAgent config:'
317
+ : result.status === 'overwritten'
318
+ ? 'Overwrote ProtoAgent config:'
319
+ : 'ProtoAgent config already exists:';
320
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: color, children: message }), _jsx(Text, { children: result.path })] }));
321
+ }
322
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Create a ProtoAgent runtime config:" }), _jsx(Text, { dimColor: true, children: "Select where to write `protoagent.jsonc`." }), _jsx(Text, { dimColor: true, children: activeOption.description }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: options.map((option) => ({ label: option.label, value: option.value })), onChange: (value) => {
323
+ const target = value;
324
+ const configPath = getInitConfigPath(target);
325
+ if (existsSync(configPath)) {
326
+ setSelectedTarget(target);
327
+ return;
328
+ }
329
+ setResult(writeInitConfig(target));
330
+ } }) })] }));
331
+ };
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { parse, printParseErrorCode } from 'jsonc-parser';
@@ -16,13 +17,26 @@ const DEFAULT_RUNTIME_CONFIG = {
16
17
  mcp: { servers: {} },
17
18
  };
18
19
  let runtimeConfigCache = null;
19
- function getRuntimeConfigPaths() {
20
+ function getProjectRuntimeConfigPath() {
21
+ return path.join(process.cwd(), '.protoagent', 'protoagent.jsonc');
22
+ }
23
+ function getUserRuntimeConfigPath() {
20
24
  const homeDir = os.homedir();
21
- return [
22
- path.join(homeDir, '.config', 'protoagent', 'protoagent.jsonc'),
23
- path.join(homeDir, '.protoagent', 'protoagent.jsonc'),
24
- path.join(process.cwd(), '.protoagent', 'protoagent.jsonc'),
25
- ];
25
+ if (process.platform === 'win32') {
26
+ return path.join(homeDir, 'AppData', 'Local', 'protoagent', 'protoagent.jsonc');
27
+ }
28
+ return path.join(homeDir, '.config', 'protoagent', 'protoagent.jsonc');
29
+ }
30
+ export function getActiveRuntimeConfigPath() {
31
+ const projectPath = getProjectRuntimeConfigPath();
32
+ if (existsSync(projectPath)) {
33
+ return projectPath;
34
+ }
35
+ const userPath = getUserRuntimeConfigPath();
36
+ if (existsSync(userPath)) {
37
+ return userPath;
38
+ }
39
+ return null;
26
40
  }
27
41
  function isPlainObject(value) {
28
42
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -156,16 +170,17 @@ export async function loadRuntimeConfig(forceReload = false) {
156
170
  if (!forceReload && runtimeConfigCache) {
157
171
  return runtimeConfigCache;
158
172
  }
159
- let merged = DEFAULT_RUNTIME_CONFIG;
160
- for (const configPath of getRuntimeConfigPaths()) {
173
+ const configPath = getActiveRuntimeConfigPath();
174
+ let loaded = DEFAULT_RUNTIME_CONFIG;
175
+ if (configPath) {
161
176
  const fileConfig = await readRuntimeConfigFile(configPath);
162
- if (!fileConfig)
163
- continue;
164
- logger.debug(`Loaded runtime config`, { path: configPath });
165
- merged = mergeRuntimeConfig(merged, fileConfig);
177
+ if (fileConfig) {
178
+ logger.debug('Loaded runtime config', { path: configPath });
179
+ loaded = mergeRuntimeConfig(DEFAULT_RUNTIME_CONFIG, fileConfig);
180
+ }
166
181
  }
167
- runtimeConfigCache = merged;
168
- return merged;
182
+ runtimeConfigCache = loaded;
183
+ return loaded;
169
184
  }
170
185
  export function getRuntimeConfig() {
171
186
  return runtimeConfigCache || DEFAULT_RUNTIME_CONFIG;
package/dist/sub-agent.js CHANGED
@@ -39,7 +39,7 @@ export const subAgentTool = {
39
39
  * Run a sub-agent with its own isolated conversation.
40
40
  * Returns the sub-agent's final text response.
41
41
  */
42
- export async function runSubAgent(client, model, task, maxIterations = 30, requestDefaults = {}, onProgress) {
42
+ export async function runSubAgent(client, model, task, maxIterations = 30, requestDefaults = {}, onProgress, abortSignal) {
43
43
  const op = logger.startOperation('sub-agent');
44
44
  const subAgentSessionId = `sub-agent-${crypto.randomUUID()}`;
45
45
  const systemPrompt = await generateSystemPrompt();
@@ -56,13 +56,17 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
56
56
  ];
57
57
  try {
58
58
  for (let i = 0; i < maxIterations; i++) {
59
+ // Check abort at the top of each iteration
60
+ if (abortSignal?.aborted) {
61
+ return '(sub-agent aborted)';
62
+ }
59
63
  const response = await client.chat.completions.create({
60
64
  ...requestDefaults,
61
65
  model,
62
66
  messages,
63
67
  tools: getAllTools(),
64
68
  tool_choice: 'auto',
65
- });
69
+ }, { signal: abortSignal });
66
70
  const message = response.choices[0]?.message;
67
71
  if (!message)
68
72
  break;
@@ -70,12 +74,16 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
70
74
  if (message.tool_calls && message.tool_calls.length > 0) {
71
75
  messages.push(message);
72
76
  for (const toolCall of message.tool_calls) {
77
+ // Check abort between tool calls
78
+ if (abortSignal?.aborted) {
79
+ return '(sub-agent aborted)';
80
+ }
73
81
  const { name, arguments: argsStr } = toolCall.function;
74
82
  logger.debug(`Sub-agent tool call: ${name}`);
75
83
  onProgress?.({ tool: name, status: 'running', iteration: i });
76
84
  try {
77
85
  const args = JSON.parse(argsStr);
78
- const result = await handleToolCall(name, args, { sessionId: subAgentSessionId });
86
+ const result = await handleToolCall(name, args, { sessionId: subAgentSessionId, abortSignal });
79
87
  messages.push({
80
88
  role: 'tool',
81
89
  tool_call_id: toolCall.id,
@@ -114,7 +114,7 @@ async function isSafe(command) {
114
114
  }
115
115
  return validateCommandPaths(tokens);
116
116
  }
117
- export async function runBash(command, timeoutMs = 30_000, sessionId) {
117
+ export async function runBash(command, timeoutMs = 30_000, sessionId, abortSignal) {
118
118
  // Layer 1: hard block
119
119
  if (isDangerous(command)) {
120
120
  return `Error: Command blocked for safety. "${command}" contains a dangerous pattern that cannot be executed.`;
@@ -139,6 +139,7 @@ export async function runBash(command, timeoutMs = 30_000, sessionId) {
139
139
  let stdout = '';
140
140
  let stderr = '';
141
141
  let timedOut = false;
142
+ let aborted = false;
142
143
  const child = spawn(command, [], {
143
144
  shell: true,
144
145
  cwd: process.cwd(),
@@ -147,13 +148,31 @@ export async function runBash(command, timeoutMs = 30_000, sessionId) {
147
148
  });
148
149
  child.stdout?.on('data', (data) => { stdout += data.toString(); });
149
150
  child.stderr?.on('data', (data) => { stderr += data.toString(); });
150
- const timer = setTimeout(() => {
151
- timedOut = true;
151
+ const terminateChild = () => {
152
152
  child.kill('SIGTERM');
153
153
  setTimeout(() => child.kill('SIGKILL'), 2000);
154
+ };
155
+ const onAbort = () => {
156
+ aborted = true;
157
+ terminateChild();
158
+ };
159
+ if (abortSignal?.aborted) {
160
+ onAbort();
161
+ }
162
+ else {
163
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
164
+ }
165
+ const timer = setTimeout(() => {
166
+ timedOut = true;
167
+ terminateChild();
154
168
  }, timeoutMs);
155
169
  child.on('close', (code) => {
156
170
  clearTimeout(timer);
171
+ abortSignal?.removeEventListener('abort', onAbort);
172
+ if (aborted) {
173
+ resolve(`Command aborted by user.\nPartial stdout:\n${stdout.slice(0, 5000)}\nPartial stderr:\n${stderr.slice(0, 2000)}`);
174
+ return;
175
+ }
157
176
  if (timedOut) {
158
177
  resolve(`Command timed out after ${timeoutMs / 1000}s.\nPartial stdout:\n${stdout.slice(0, 5000)}\nPartial stderr:\n${stderr.slice(0, 2000)}`);
159
178
  return;
@@ -172,6 +191,7 @@ export async function runBash(command, timeoutMs = 30_000, sessionId) {
172
191
  });
173
192
  child.on('error', (err) => {
174
193
  clearTimeout(timer);
194
+ abortSignal?.removeEventListener('abort', onAbort);
175
195
  resolve(`Error executing command: ${err.message}`);
176
196
  });
177
197
  });
@@ -71,7 +71,7 @@ export async function handleToolCall(toolName, args, context = {}) {
71
71
  case 'search_files':
72
72
  return await searchFiles(args.search_term, args.directory_path, args.case_sensitive, args.file_extensions);
73
73
  case 'bash':
74
- return await runBash(args.command, args.timeout_ms, context.sessionId);
74
+ return await runBash(args.command, args.timeout_ms, context.sessionId, context.abortSignal);
75
75
  case 'todo_read':
76
76
  return readTodos(context.sessionId);
77
77
  case 'todo_write':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protoagent",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",