protoagent 0.1.3 → 0.1.5
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 +34 -3
- package/dist/App.js +134 -81
- package/dist/agentic-loop.js +31 -12
- package/dist/cli.js +59 -2
- package/dist/components/CollapsibleBox.js +2 -2
- package/dist/components/ConsolidatedToolMessage.js +3 -11
- package/dist/components/FormattedMessage.js +80 -4
- package/dist/config.js +199 -71
- package/dist/runtime-config.js +29 -14
- package/dist/sub-agent.js +4 -1
- package/dist/system-prompt.js +3 -1
- package/dist/tools/bash.js +23 -3
- package/dist/tools/edit-file.js +248 -16
- package/dist/tools/index.js +2 -2
- package/dist/tools/read-file.js +89 -3
- package/dist/tools/search-files.js +92 -1
- package/dist/utils/compactor.js +2 -1
- package/dist/utils/file-time.js +54 -0
- package/package.json +1 -1
- package/dist/components/Table.js +0 -275
package/README.md
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|
@@ -6,8 +6,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
* and cost/usage info. All heavy logic lives in `agentic-loop.ts`;
|
|
7
7
|
* this file is purely presentation + state wiring.
|
|
8
8
|
*/
|
|
9
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
10
|
-
import { Box, Text, useApp, useInput } from 'ink';
|
|
9
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
10
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
11
11
|
import { TextInput, Select, PasswordInput } from '@inkjs/ui';
|
|
12
12
|
import BigText from 'ink-big-text';
|
|
13
13
|
import { OpenAI } from 'openai';
|
|
@@ -24,7 +24,7 @@ import { generateSystemPrompt } from './system-prompt.js';
|
|
|
24
24
|
import { CollapsibleBox } from './components/CollapsibleBox.js';
|
|
25
25
|
import { ConsolidatedToolMessage } from './components/ConsolidatedToolMessage.js';
|
|
26
26
|
import { FormattedMessage } from './components/FormattedMessage.js';
|
|
27
|
-
function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0) {
|
|
27
|
+
function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0, deferTables = false) {
|
|
28
28
|
const rendered = [];
|
|
29
29
|
const skippedIndices = new Set();
|
|
30
30
|
messagesToRender.forEach((msg, localIndex) => {
|
|
@@ -35,6 +35,7 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
35
35
|
const msgAny = msg;
|
|
36
36
|
const isToolCall = msg.role === 'assistant' && msgAny.tool_calls && msgAny.tool_calls.length > 0;
|
|
37
37
|
const displayContent = 'content' in msg && typeof msg.content === 'string' ? msg.content : null;
|
|
38
|
+
const normalizedContent = normalizeMessageSpacing(displayContent || '');
|
|
38
39
|
const isFirstSystemMessage = msg.role === 'system' && !allMessages.slice(0, index).some((message) => message.role === 'system');
|
|
39
40
|
const previousMessage = index > 0 ? allMessages[index - 1] : null;
|
|
40
41
|
const followsToolMessage = previousMessage?.role === 'tool';
|
|
@@ -43,12 +44,15 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
43
44
|
const isConversationTurn = currentSpeaker === 'user' || currentSpeaker === 'assistant';
|
|
44
45
|
const previousWasConversationTurn = previousSpeaker === 'user' || previousSpeaker === 'assistant';
|
|
45
46
|
const speakerChanged = previousSpeaker !== currentSpeaker;
|
|
46
|
-
if
|
|
47
|
+
// Determine if we need a blank-line spacer above this message.
|
|
48
|
+
// At most one spacer is added per message to avoid doubling.
|
|
49
|
+
const needsSpacer = isFirstSystemMessage ||
|
|
50
|
+
(isConversationTurn && previousWasConversationTurn && speakerChanged) ||
|
|
51
|
+
followsToolMessage ||
|
|
52
|
+
(isToolCall && previousMessage != null);
|
|
53
|
+
if (needsSpacer) {
|
|
47
54
|
rendered.push(_jsx(Text, { children: " " }, `spacer-${index}`));
|
|
48
55
|
}
|
|
49
|
-
if (isConversationTurn && previousWasConversationTurn && speakerChanged) {
|
|
50
|
-
rendered.push(_jsx(Text, { children: " " }, `turn-spacer-${index}`));
|
|
51
|
-
}
|
|
52
56
|
if (msg.role === 'user') {
|
|
53
57
|
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: '> ' }), _jsx(Text, { children: displayContent })] }) }, index));
|
|
54
58
|
return;
|
|
@@ -58,8 +62,8 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
58
62
|
return;
|
|
59
63
|
}
|
|
60
64
|
if (isToolCall) {
|
|
61
|
-
if (
|
|
62
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content:
|
|
65
|
+
if (normalizedContent.length > 0) {
|
|
66
|
+
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, `${index}-text`));
|
|
63
67
|
}
|
|
64
68
|
const toolCalls = msgAny.tool_calls.map((tc) => ({
|
|
65
69
|
id: tc.id,
|
|
@@ -72,7 +76,7 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
72
76
|
const nextMsg = messagesToRender[nextLocalIndex];
|
|
73
77
|
if (nextMsg.role === 'tool' && nextMsg.tool_call_id === toolCall.id) {
|
|
74
78
|
toolResults.set(toolCall.id, {
|
|
75
|
-
content: nextMsg.content || '',
|
|
79
|
+
content: normalizeMessageSpacing(nextMsg.content || ''),
|
|
76
80
|
name: nextMsg.name || toolCall.name,
|
|
77
81
|
});
|
|
78
82
|
skippedIndices.add(nextLocalIndex);
|
|
@@ -84,19 +88,23 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
84
88
|
return;
|
|
85
89
|
}
|
|
86
90
|
if (msg.role === 'tool') {
|
|
87
|
-
rendered.push(_jsx(CollapsibleBox, { title: `${msgAny.name || 'tool'} result`, content:
|
|
91
|
+
rendered.push(_jsx(CollapsibleBox, { title: `${msgAny.name || 'tool'} result`, content: normalizedContent, dimColor: true, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
|
|
88
92
|
return;
|
|
89
93
|
}
|
|
90
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content:
|
|
94
|
+
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, index));
|
|
91
95
|
});
|
|
92
96
|
return rendered;
|
|
93
97
|
}
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
function normalizeMessageSpacing(message) {
|
|
99
|
+
const normalized = message.replace(/\r\n/g, '\n');
|
|
100
|
+
const lines = normalized.split('\n');
|
|
101
|
+
while (lines.length > 0 && lines[0].trim() === '') {
|
|
102
|
+
lines.shift();
|
|
103
|
+
}
|
|
104
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
105
|
+
lines.pop();
|
|
106
|
+
}
|
|
107
|
+
return lines.join('\n');
|
|
100
108
|
}
|
|
101
109
|
function getVisualSpeaker(message) {
|
|
102
110
|
if (!message)
|
|
@@ -186,13 +194,13 @@ const ApprovalPrompt = ({ request, onRespond }) => {
|
|
|
186
194
|
{ label: sessionApprovalLabel, value: 'approve_session' },
|
|
187
195
|
{ label: 'Reject', value: 'reject' },
|
|
188
196
|
];
|
|
189
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "
|
|
197
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, marginY: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Approval Required" }), _jsx(Text, { children: request.description }), request.detail && (_jsx(Text, { dimColor: true, children: request.detail.length > 200 ? request.detail.slice(0, 200) + '...' : request.detail })), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: items.map((item) => ({ value: item.value, label: item.label })), onChange: (value) => onRespond(value) }) })] }));
|
|
190
198
|
};
|
|
191
199
|
/** Cost/usage display in the status bar. */
|
|
192
200
|
const UsageDisplay = ({ usage, totalCost }) => {
|
|
193
201
|
if (!usage && totalCost === 0)
|
|
194
202
|
return null;
|
|
195
|
-
return (_jsxs(Box, { children: [usage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", usage.inputTokens, "\u2193 ", usage.outputTokens, "\u2191 | ctx: ", usage.contextPercent.toFixed(0), "%"] })), totalCost > 0 && (_jsxs(Text, { dimColor: true, children: [" | cost: $", totalCost.toFixed(4)] }))] }));
|
|
203
|
+
return (_jsxs(Box, { marginTop: 1, children: [usage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", usage.inputTokens, "\u2193 ", usage.outputTokens, "\u2191 | ctx: ", usage.contextPercent.toFixed(0), "%"] })), totalCost > 0 && (_jsxs(Text, { dimColor: true, children: [" | cost: $", totalCost.toFixed(4)] }))] }));
|
|
196
204
|
};
|
|
197
205
|
/** Inline setup wizard — shown when no config exists. */
|
|
198
206
|
const InlineSetup = ({ onComplete }) => {
|
|
@@ -224,13 +232,14 @@ const InlineSetup = ({ onComplete }) => {
|
|
|
224
232
|
model: selectedModelId,
|
|
225
233
|
...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
|
|
226
234
|
};
|
|
227
|
-
writeConfig(newConfig);
|
|
235
|
+
writeConfig(newConfig, 'project');
|
|
228
236
|
onComplete(newConfig);
|
|
229
237
|
} })] }));
|
|
230
238
|
};
|
|
231
239
|
// ─── Main App ───
|
|
232
240
|
export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
233
241
|
const { exit } = useApp();
|
|
242
|
+
const { stdout } = useStdout();
|
|
234
243
|
// Core state
|
|
235
244
|
const [config, setConfig] = useState(null);
|
|
236
245
|
const [completionMessages, setCompletionMessages] = useState([]);
|
|
@@ -242,7 +251,10 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
242
251
|
const [initialized, setInitialized] = useState(false);
|
|
243
252
|
const [needsSetup, setNeedsSetup] = useState(false);
|
|
244
253
|
const [logFilePath, setLogFilePath] = useState(null);
|
|
245
|
-
//
|
|
254
|
+
// Input reset key — incremented on submit to force TextInput remount and clear
|
|
255
|
+
const [inputResetKey, setInputResetKey] = useState(0);
|
|
256
|
+
const [inputWidthKey, setInputWidthKey] = useState(stdout?.columns ?? 80);
|
|
257
|
+
// Collapsible state — only applies to live (current turn) messages
|
|
246
258
|
const [expandedMessages, setExpandedMessages] = useState(new Set());
|
|
247
259
|
const expandLatestMessage = useCallback((index) => {
|
|
248
260
|
setExpandedMessages((prev) => {
|
|
@@ -259,6 +271,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
259
271
|
const [lastUsage, setLastUsage] = useState(null);
|
|
260
272
|
const [totalCost, setTotalCost] = useState(0);
|
|
261
273
|
const [spinnerFrame, setSpinnerFrame] = useState(0);
|
|
274
|
+
// Active tool tracking — shows which tool is currently executing
|
|
275
|
+
const [activeTool, setActiveTool] = useState(null);
|
|
262
276
|
// Session state
|
|
263
277
|
const [session, setSession] = useState(null);
|
|
264
278
|
// Quitting state — shows the resume command before exiting
|
|
@@ -269,6 +283,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
269
283
|
const assistantMessageRef = useRef(null);
|
|
270
284
|
// Abort controller for cancelling the current completion
|
|
271
285
|
const abortControllerRef = useRef(null);
|
|
286
|
+
// Debounce timer for text_delta renders (~50ms batching)
|
|
287
|
+
const textFlushTimerRef = useRef(null);
|
|
272
288
|
// ─── Post-config initialization (reused after inline setup) ───
|
|
273
289
|
const initializeWithConfig = useCallback(async (loadedConfig) => {
|
|
274
290
|
setConfig(loadedConfig);
|
|
@@ -313,6 +329,18 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
313
329
|
}, 100);
|
|
314
330
|
return () => clearInterval(interval);
|
|
315
331
|
}, [loading]);
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!stdout)
|
|
334
|
+
return;
|
|
335
|
+
const handleResize = () => {
|
|
336
|
+
setInputWidthKey(stdout.columns ?? 80);
|
|
337
|
+
};
|
|
338
|
+
handleResize();
|
|
339
|
+
stdout.on('resize', handleResize);
|
|
340
|
+
return () => {
|
|
341
|
+
stdout.off('resize', handleResize);
|
|
342
|
+
};
|
|
343
|
+
}, [stdout]);
|
|
316
344
|
useEffect(() => {
|
|
317
345
|
const init = async () => {
|
|
318
346
|
// Set log level and initialize log file
|
|
@@ -337,8 +365,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
337
365
|
});
|
|
338
366
|
});
|
|
339
367
|
await loadRuntimeConfig();
|
|
340
|
-
|
|
341
|
-
const loadedConfig = readConfig();
|
|
368
|
+
const loadedConfig = readConfig('active');
|
|
342
369
|
if (!loadedConfig) {
|
|
343
370
|
setNeedsSetup(true);
|
|
344
371
|
return;
|
|
@@ -416,7 +443,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
416
443
|
default:
|
|
417
444
|
return false;
|
|
418
445
|
}
|
|
419
|
-
}, [exit, session, completionMessages]);
|
|
446
|
+
}, [config, exit, session, completionMessages]);
|
|
420
447
|
// ─── Submit handler ───
|
|
421
448
|
const handleSubmit = useCallback(async (value) => {
|
|
422
449
|
const trimmed = value.trim();
|
|
@@ -427,10 +454,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
427
454
|
const handled = await handleSlashCommand(trimmed);
|
|
428
455
|
if (handled) {
|
|
429
456
|
setInputText('');
|
|
457
|
+
setInputResetKey((prev) => prev + 1);
|
|
430
458
|
return;
|
|
431
459
|
}
|
|
432
460
|
}
|
|
433
461
|
setInputText('');
|
|
462
|
+
setInputResetKey((prev) => prev + 1); // Force TextInput to remount and clear
|
|
434
463
|
setLoading(true);
|
|
435
464
|
setError(null);
|
|
436
465
|
setHelpMessage(null);
|
|
@@ -448,9 +477,9 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
448
477
|
const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
|
|
449
478
|
switch (event.type) {
|
|
450
479
|
case 'text_delta':
|
|
451
|
-
// Update the current assistant message in completionMessages
|
|
480
|
+
// Update the current assistant message in completionMessages
|
|
452
481
|
if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
|
|
453
|
-
// First text delta
|
|
482
|
+
// First text delta — create the assistant message immediately
|
|
454
483
|
const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
|
|
455
484
|
setCompletionMessages((prev) => {
|
|
456
485
|
assistantMessageRef.current = { message: assistantMsg, index: prev.length, kind: 'streaming_text' };
|
|
@@ -458,80 +487,96 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
458
487
|
});
|
|
459
488
|
}
|
|
460
489
|
else {
|
|
461
|
-
// Subsequent
|
|
490
|
+
// Subsequent deltas — accumulate in ref, debounce the render (~50ms)
|
|
462
491
|
assistantMessageRef.current.message.content += event.content || '';
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
492
|
+
if (!textFlushTimerRef.current) {
|
|
493
|
+
textFlushTimerRef.current = setTimeout(() => {
|
|
494
|
+
textFlushTimerRef.current = null;
|
|
495
|
+
setCompletionMessages((prev) => {
|
|
496
|
+
if (!assistantMessageRef.current)
|
|
497
|
+
return prev;
|
|
498
|
+
const updated = [...prev];
|
|
499
|
+
updated[assistantMessageRef.current.index] = { ...assistantMessageRef.current.message };
|
|
500
|
+
return updated;
|
|
501
|
+
});
|
|
502
|
+
}, 50);
|
|
503
|
+
}
|
|
468
504
|
}
|
|
469
505
|
break;
|
|
470
506
|
case 'tool_call':
|
|
471
507
|
if (event.toolCall) {
|
|
472
508
|
const toolCall = event.toolCall;
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
:
|
|
481
|
-
const assistantMsg = existingMessage || {
|
|
482
|
-
role: 'assistant',
|
|
483
|
-
content: '',
|
|
484
|
-
tool_calls: [],
|
|
485
|
-
};
|
|
486
|
-
const existingToolCallIndex = assistantMsg.tool_calls.findIndex((existingToolCall) => existingToolCall.id === toolCall.id);
|
|
487
|
-
const nextToolCall = {
|
|
488
|
-
id: toolCall.id,
|
|
489
|
-
type: 'function',
|
|
490
|
-
function: {
|
|
491
|
-
name: toolCall.name,
|
|
492
|
-
arguments: toolCall.args,
|
|
493
|
-
},
|
|
494
|
-
};
|
|
495
|
-
if (existingToolCallIndex === -1) {
|
|
496
|
-
assistantMsg.tool_calls.push(nextToolCall);
|
|
497
|
-
}
|
|
498
|
-
else {
|
|
499
|
-
assistantMsg.tool_calls[existingToolCallIndex] = nextToolCall;
|
|
509
|
+
setActiveTool(toolCall.name);
|
|
510
|
+
// Track the tool call in the ref WITHOUT triggering a render.
|
|
511
|
+
// The render will happen when tool_result arrives.
|
|
512
|
+
const existingRef = assistantMessageRef.current;
|
|
513
|
+
const assistantMsg = existingRef?.message
|
|
514
|
+
? {
|
|
515
|
+
...existingRef.message,
|
|
516
|
+
tool_calls: [...(existingRef.message.tool_calls || [])],
|
|
500
517
|
}
|
|
501
|
-
|
|
518
|
+
: { role: 'assistant', content: '', tool_calls: [] };
|
|
519
|
+
const nextToolCall = {
|
|
520
|
+
id: toolCall.id,
|
|
521
|
+
type: 'function',
|
|
522
|
+
function: { name: toolCall.name, arguments: toolCall.args },
|
|
523
|
+
};
|
|
524
|
+
const idx = assistantMsg.tool_calls.findIndex((tc) => tc.id === toolCall.id);
|
|
525
|
+
if (idx === -1) {
|
|
526
|
+
assistantMsg.tool_calls.push(nextToolCall);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
assistantMsg.tool_calls[idx] = nextToolCall;
|
|
530
|
+
}
|
|
531
|
+
if (!existingRef) {
|
|
532
|
+
// First tool call — we need to add the assistant message to state
|
|
533
|
+
setCompletionMessages((prev) => {
|
|
534
|
+
assistantMessageRef.current = {
|
|
535
|
+
message: assistantMsg,
|
|
536
|
+
index: prev.length,
|
|
537
|
+
kind: 'tool_call_assistant',
|
|
538
|
+
};
|
|
539
|
+
return [...prev, assistantMsg];
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
// Subsequent tool calls — just update the ref, no render
|
|
502
544
|
assistantMessageRef.current = {
|
|
545
|
+
...existingRef,
|
|
503
546
|
message: assistantMsg,
|
|
504
|
-
index: nextIndex,
|
|
505
547
|
kind: 'tool_call_assistant',
|
|
506
548
|
};
|
|
507
|
-
|
|
508
|
-
const updated = [...prev];
|
|
509
|
-
updated[existingRef.index] = assistantMsg;
|
|
510
|
-
return updated;
|
|
511
|
-
}
|
|
512
|
-
return [...prev, assistantMsg];
|
|
513
|
-
});
|
|
549
|
+
}
|
|
514
550
|
}
|
|
515
551
|
break;
|
|
516
552
|
case 'tool_result':
|
|
517
553
|
if (event.toolCall) {
|
|
518
554
|
const toolCall = event.toolCall;
|
|
555
|
+
setActiveTool(null);
|
|
519
556
|
if (toolCall.name === 'todo_read' || toolCall.name === 'todo_write') {
|
|
520
557
|
const currentAssistantIndex = assistantMessageRef.current?.index;
|
|
521
558
|
if (typeof currentAssistantIndex === 'number') {
|
|
522
559
|
expandLatestMessage(currentAssistantIndex);
|
|
523
560
|
}
|
|
524
561
|
}
|
|
525
|
-
//
|
|
526
|
-
setCompletionMessages((prev) =>
|
|
527
|
-
...prev
|
|
528
|
-
|
|
562
|
+
// Flush the assistant message update + tool result in a SINGLE state update
|
|
563
|
+
setCompletionMessages((prev) => {
|
|
564
|
+
const updated = [...prev];
|
|
565
|
+
// Sync assistant message (may have new tool_calls since last render)
|
|
566
|
+
if (assistantMessageRef.current) {
|
|
567
|
+
updated[assistantMessageRef.current.index] = {
|
|
568
|
+
...assistantMessageRef.current.message,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
// Append tool result
|
|
572
|
+
updated.push({
|
|
529
573
|
role: 'tool',
|
|
530
574
|
tool_call_id: toolCall.id,
|
|
531
575
|
content: toolCall.result || '',
|
|
532
576
|
name: toolCall.name,
|
|
533
|
-
}
|
|
534
|
-
|
|
577
|
+
});
|
|
578
|
+
return updated;
|
|
579
|
+
});
|
|
535
580
|
}
|
|
536
581
|
break;
|
|
537
582
|
case 'usage':
|
|
@@ -571,7 +616,16 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
571
616
|
setError('Unknown error');
|
|
572
617
|
}
|
|
573
618
|
break;
|
|
619
|
+
case 'iteration_done':
|
|
620
|
+
assistantMessageRef.current = null;
|
|
621
|
+
break;
|
|
574
622
|
case 'done':
|
|
623
|
+
// Clear any pending text delta timer
|
|
624
|
+
if (textFlushTimerRef.current) {
|
|
625
|
+
clearTimeout(textFlushTimerRef.current);
|
|
626
|
+
textFlushTimerRef.current = null;
|
|
627
|
+
}
|
|
628
|
+
setActiveTool(null);
|
|
575
629
|
setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
|
|
576
630
|
break;
|
|
577
631
|
}
|
|
@@ -617,18 +671,17 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
617
671
|
: completionMessages.length;
|
|
618
672
|
const archivedMessages = completionMessages.slice(0, liveStartIndex);
|
|
619
673
|
const liveMessages = completionMessages.slice(liveStartIndex);
|
|
620
|
-
const archivedMessageNodes = renderMessageList(archivedMessages, completionMessages, expandedMessages);
|
|
621
|
-
const liveMessageNodes = renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex);
|
|
622
|
-
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(BigText, { text: "ProtoAgent", font: "tiny", colors: ["#09A469"] }),
|
|
674
|
+
const archivedMessageNodes = useMemo(() => renderMessageList(archivedMessages, completionMessages, expandedMessages), [archivedMessages, completionMessages, expandedMessages]);
|
|
675
|
+
const liveMessageNodes = useMemo(() => renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex, loading), [liveMessages, completionMessages, expandedMessages, liveStartIndex, loading]);
|
|
676
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(BigText, { text: "ProtoAgent", font: "tiny", colors: ["#09A469"] }), config && (_jsxs(Text, { dimColor: true, children: ["Model: ", providerInfo?.name || config.provider, " / ", config.model, dangerouslyAcceptAll && _jsx(Text, { color: "red", children: " (auto-approve all)" }), session && _jsxs(Text, { dimColor: true, children: [" | Session: ", session.id.slice(0, 8)] })] })), logFilePath && _jsxs(Text, { dimColor: true, children: ["Debug logs: ", logFilePath] }), error && _jsx(Text, { color: "red", children: error }), helpMessage && (_jsx(CollapsibleBox, { title: "Help", content: helpMessage, titleColor: "green", dimColor: false, maxPreviewLines: 10, expanded: true })), !initialized && !error && !needsSetup && _jsx(Text, { children: "Initializing..." }), needsSetup && (_jsx(InlineSetup, { onComplete: (newConfig) => {
|
|
623
677
|
initializeWithConfig(newConfig).catch((err) => {
|
|
624
678
|
setError(`Initialization failed: ${err.message}`);
|
|
625
679
|
});
|
|
626
|
-
} })), _jsxs(Box, { flexDirection: "column", flexGrow: 1,
|
|
680
|
+
} })), _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [archivedMessageNodes, liveMessageNodes, threadErrors.map((threadError) => (_jsx(Box, { marginBottom: 1, borderStyle: "round", borderColor: "red", paddingX: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", threadError.message] }) }, `thread-error-${threadError.id}`))), loading && completionMessages.length > 0 && ((() => {
|
|
627
681
|
const lastMsg = completionMessages[completionMessages.length - 1];
|
|
628
|
-
// Show "Thinking..." only if the last message is a user message (no assistant response yet)
|
|
629
682
|
return lastMsg.role === 'user' ? _jsx(Text, { dimColor: true, children: "Thinking..." }) : null;
|
|
630
683
|
})()), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
|
|
631
684
|
pendingApproval.resolve(response);
|
|
632
685
|
setPendingApproval(null);
|
|
633
|
-
} }))] }), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), initialized && !pendingApproval && loading && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame],
|
|
686
|
+
} }))] }), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), initialized && !pendingApproval && loading && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame], ' ', activeTool ? `Running ${activeTool}...` : 'Working...'] }) })), initialized && !pendingApproval && (_jsx(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", children: _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: "green", bold: true, children: '>' }) }), _jsx(Box, { flexGrow: 1, minWidth: 10, children: _jsx(TextInput, { defaultValue: inputText, onChange: setInputText, placeholder: "Type your message... (/help for commands)", onSubmit: handleSubmit }, `${inputResetKey}-${inputWidthKey}`) })] }) }, `input-shell-${inputWidthKey}`)), quittingSession && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Session saved. Resume with:" }), _jsxs(Text, { color: "green", children: ["protoagent --session ", quittingSession.id] })] }))] }));
|
|
634
687
|
};
|
package/dist/agentic-loop.js
CHANGED
|
@@ -351,20 +351,19 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
|
-
// Emit usage info
|
|
355
|
-
|
|
354
|
+
// Emit usage info — always emit, even without pricing (use estimates)
|
|
355
|
+
{
|
|
356
356
|
const inputTokens = actualUsage?.prompt_tokens ?? estimateConversationTokens(updatedMessages);
|
|
357
357
|
const outputTokens = actualUsage?.completion_tokens ?? estimateTokens(assistantMessage.content || '');
|
|
358
|
-
const
|
|
359
|
-
|
|
358
|
+
const cost = pricing
|
|
359
|
+
? createUsageInfo(inputTokens, outputTokens, pricing).estimatedCost
|
|
360
|
+
: 0;
|
|
361
|
+
const contextPercent = pricing
|
|
362
|
+
? getContextInfo(updatedMessages, pricing).utilizationPercentage
|
|
363
|
+
: 0;
|
|
360
364
|
onEvent({
|
|
361
365
|
type: 'usage',
|
|
362
|
-
usage: {
|
|
363
|
-
inputTokens,
|
|
364
|
-
outputTokens,
|
|
365
|
-
cost: usageInfo.estimatedCost,
|
|
366
|
-
contextPercent: contextInfo.utilizationPercentage,
|
|
367
|
-
},
|
|
366
|
+
usage: { inputTokens, outputTokens, cost, contextPercent },
|
|
368
367
|
});
|
|
369
368
|
}
|
|
370
369
|
// Handle tool calls
|
|
@@ -391,6 +390,12 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
391
390
|
});
|
|
392
391
|
updatedMessages.push(assistantMessage);
|
|
393
392
|
for (const toolCall of assistantMessage.tool_calls) {
|
|
393
|
+
// Check abort between tool calls
|
|
394
|
+
if (abortSignal?.aborted) {
|
|
395
|
+
logger.debug('Agentic loop aborted between tool calls');
|
|
396
|
+
emitAbortAndFinish(onEvent);
|
|
397
|
+
return updatedMessages;
|
|
398
|
+
}
|
|
394
399
|
const { name, arguments: argsStr } = toolCall.function;
|
|
395
400
|
onEvent({
|
|
396
401
|
type: 'tool_call',
|
|
@@ -401,10 +406,21 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
401
406
|
let result;
|
|
402
407
|
// Handle sub-agent tool specially
|
|
403
408
|
if (name === 'sub_agent') {
|
|
404
|
-
|
|
409
|
+
const subProgress = (evt) => {
|
|
410
|
+
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
|
+
},
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress);
|
|
405
421
|
}
|
|
406
422
|
else {
|
|
407
|
-
result = await handleToolCall(name, args, { sessionId });
|
|
423
|
+
result = await handleToolCall(name, args, { sessionId, abortSignal });
|
|
408
424
|
}
|
|
409
425
|
logger.debug('Tool result', {
|
|
410
426
|
tool: name,
|
|
@@ -435,6 +451,9 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
435
451
|
});
|
|
436
452
|
}
|
|
437
453
|
}
|
|
454
|
+
// Signal UI that this iteration's tool calls are all done,
|
|
455
|
+
// so it can flush completed messages to static output.
|
|
456
|
+
onEvent({ type: 'iteration_done' });
|
|
438
457
|
// Continue loop — let the LLM process tool results
|
|
439
458
|
continue;
|
|
440
459
|
}
|
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
|
-
.
|
|
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);
|