@hopper-agent/cli 0.1.0 → 0.2.0
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/dist/src/cli.js +222 -31
- package/dist/src/cli.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +19 -18
- package/LICENSE +0 -179
package/dist/src/cli.js
CHANGED
|
@@ -9,10 +9,10 @@ import { resolve, basename, isAbsolute, join, dirname } from 'node:path';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
-
import { ChatSurface, handleSlashCommand } from '@hopper-agent/tui';
|
|
12
|
+
import { ChatSurface, handleSlashCommand, COLD_START_BANNER } from '@hopper-agent/tui';
|
|
13
13
|
import { getTheme } from '@hopper-agent/render';
|
|
14
14
|
import { TurnLoop, SettingsManager, DEFAULT_SETTINGS, CronManager, TelegramGateway, ChannelRegistry, ChannelRouter, HeartbeatExecutor, HeartbeatDelivery, pickNextSchedule, scheduleLabel } from '@hopper-agent/core';
|
|
15
|
-
import { AnthropicProvider, OpenAIProvider } from '@hopper-agent/providers';
|
|
15
|
+
import { AnthropicProvider, OpenAIProvider, GoogleGeminiProvider } from '@hopper-agent/providers';
|
|
16
16
|
import { allTools } from '@hopper-agent/tools';
|
|
17
17
|
import { createMcpTools } from '@hopper-agent/mcp';
|
|
18
18
|
const VERSION = '0.5.1';
|
|
@@ -140,7 +140,8 @@ Usage:
|
|
|
140
140
|
hopper-agent Interactive TUI
|
|
141
141
|
hopper-agent "<prompt>" One-shot prompt
|
|
142
142
|
hopper-agent -p "<prompt>" Headless mode, stdout answer
|
|
143
|
-
hopper-agent resume
|
|
143
|
+
hopper-agent resume Resume last session
|
|
144
|
+
hopper-agent resume <id> Resume specific session
|
|
144
145
|
hopper-agent sessions list|show|rm Session management
|
|
145
146
|
hopper-agent --version Show version
|
|
146
147
|
|
|
@@ -398,7 +399,15 @@ function formatDuration(ms) {
|
|
|
398
399
|
const s = String(totalSeconds % 60).padStart(2, '0');
|
|
399
400
|
return `${h}:${m}:${s}`;
|
|
400
401
|
}
|
|
401
|
-
function estimateCost(model, inputTokens, outputTokens) {
|
|
402
|
+
function estimateCost(model, inputTokens, outputTokens, customCost) {
|
|
403
|
+
if (customCost) {
|
|
404
|
+
const parts = customCost.split(';').map(Number);
|
|
405
|
+
const inC = parts[0];
|
|
406
|
+
const outC = parts[1];
|
|
407
|
+
if (inC !== undefined && outC !== undefined && !isNaN(inC) && !isNaN(outC)) {
|
|
408
|
+
return (inputTokens * inC + outputTokens * outC) / 1_000_000;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
402
411
|
// Rough per-million-token pricing in USD; fall back to Sonnet rates.
|
|
403
412
|
const pricing = {
|
|
404
413
|
'claude-opus': { in: 15, out: 75 },
|
|
@@ -409,10 +418,14 @@ function estimateCost(model, inputTokens, outputTokens) {
|
|
|
409
418
|
const p = pricing[key];
|
|
410
419
|
return (inputTokens * p.in + outputTokens * p.out) / 1_000_000;
|
|
411
420
|
}
|
|
412
|
-
function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeName, system, settings, projects, cronManager: externalCronManager, onReminderHolder, telegramChatIdRef, telegramSubmitRef, telegramGateway, channelRegistry, channelRouter, activeChannelRef, mcpToolsRef, mcpClientsRef, onMcpReconnect, contextWindowSize }) {
|
|
421
|
+
function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeName, system, settings, projects, cronManager: externalCronManager, onReminderHolder, telegramChatIdRef, telegramSubmitRef, telegramGateway, channelRegistry, channelRouter, activeChannelRef, mcpToolsRef, mcpClientsRef, onMcpReconnect, contextWindowSize, resumeSession, resumeSessionId }) {
|
|
413
422
|
const [messages, setMessages] = useState([]);
|
|
414
423
|
// Multi-channel: per-channel message storage (keyed by channel ID)
|
|
415
|
-
|
|
424
|
+
// 'main' channel starts with the cold-start banner as the first message so it
|
|
425
|
+
// scrolls naturally with the conversation rather than being pinned to a header.
|
|
426
|
+
const [channelMessages, setChannelMessages] = useState({
|
|
427
|
+
main: [{ role: 'assistant', content: COLD_START_BANNER }],
|
|
428
|
+
});
|
|
416
429
|
const [activeChannelId, setActiveChannelId] = useState('main');
|
|
417
430
|
// Per-channel TurnLoop instances
|
|
418
431
|
const channelTurnLoopsRef = useRef(new Map());
|
|
@@ -431,6 +444,9 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
431
444
|
const [status, setStatus] = useState('idle');
|
|
432
445
|
const startedAt = useRef(Date.now());
|
|
433
446
|
const loopRef = useRef(null);
|
|
447
|
+
// Persist TurnLoop messages across turns so slash commands can read them
|
|
448
|
+
// even when loopRef.current is cleared (mode change, provider change, etc.)
|
|
449
|
+
const sessionMessagesRef = useRef([]);
|
|
434
450
|
const busyRef = useRef(false);
|
|
435
451
|
const toolGroupIndexRef = useRef(null);
|
|
436
452
|
const planFileRef = useRef(null);
|
|
@@ -516,7 +532,7 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
516
532
|
return () => clearInterval(id);
|
|
517
533
|
}, []);
|
|
518
534
|
const theme = getTheme(currentTheme);
|
|
519
|
-
const applySlashResult = useCallback((result, agentPrompt) => {
|
|
535
|
+
const applySlashResult = useCallback(async (result, agentPrompt) => {
|
|
520
536
|
const push = (content) => {
|
|
521
537
|
// Slash command results must go to the per-channel messages array
|
|
522
538
|
// since that's what the ChatSurface renders.
|
|
@@ -548,6 +564,14 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
548
564
|
settingsMgr.current.update({ model: result.model });
|
|
549
565
|
push(result.message);
|
|
550
566
|
break;
|
|
567
|
+
case 'update_model_cost':
|
|
568
|
+
settingsMgr.current.update({ MODEL_COST: result.modelCost });
|
|
569
|
+
push(result.message);
|
|
570
|
+
break;
|
|
571
|
+
case 'update_context_window':
|
|
572
|
+
settingsMgr.current.update({ MODEL_CONTEXT_WINDOW: result.contextWindow });
|
|
573
|
+
push(result.message);
|
|
574
|
+
break;
|
|
551
575
|
case 'update_provider': {
|
|
552
576
|
const newProvider = result.provider;
|
|
553
577
|
settingsMgr.current.update({ MODEL_PROVIDER: newProvider });
|
|
@@ -803,9 +827,141 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
803
827
|
push(result.message ?? 'Switched to Stats tab');
|
|
804
828
|
break;
|
|
805
829
|
}
|
|
830
|
+
case 'compact': {
|
|
831
|
+
const compact = result.compact;
|
|
832
|
+
const { messages, depth } = compact;
|
|
833
|
+
const originalSystem = currentSystem;
|
|
834
|
+
if (!streamProviderHolder.current?.complete) {
|
|
835
|
+
push('Compaction requires a provider that supports the complete() method.');
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
// Build compaction prompt based on depth
|
|
839
|
+
const conversationText = messages.map(m => {
|
|
840
|
+
if (m.role === 'user')
|
|
841
|
+
return `USER: ${m.content}`;
|
|
842
|
+
if (m.role === 'assistant')
|
|
843
|
+
return `ASSISTANT: ${m.content.map((b) => {
|
|
844
|
+
if (b.type === 'text')
|
|
845
|
+
return b.text;
|
|
846
|
+
if (b.type === 'thinking')
|
|
847
|
+
return b.thinking;
|
|
848
|
+
if (b.type === 'tool-use')
|
|
849
|
+
return `[tool: ${b.name}(${JSON.stringify(b.input).slice(0, 100)})]`;
|
|
850
|
+
return '';
|
|
851
|
+
}).join('\n')}`;
|
|
852
|
+
if (m.role === 'tool')
|
|
853
|
+
return `TOOL RESULT (${m.toolUseId}): ${m.content.slice(0, 500)}`;
|
|
854
|
+
return '';
|
|
855
|
+
}).join('\n');
|
|
856
|
+
const compactionPrompt = depth === 'brief'
|
|
857
|
+
? `You are compacting a conversation between a user and an AI coding agent.
|
|
858
|
+
Your job is to reduce the conversation to a single meaningful turn.
|
|
859
|
+
|
|
860
|
+
RULES:
|
|
861
|
+
- Reduce to approximately 2-4 messages (1 user request + 1-3 assistant/tool responses)
|
|
862
|
+
- Keep the FIRST user message (session intent)
|
|
863
|
+
- Keep the LAST user message (current intent)
|
|
864
|
+
- For assistant messages: keep only text that contains decisions, findings, or key facts
|
|
865
|
+
- For tool calls: keep only tool name + brief input — no need for full output
|
|
866
|
+
- For tool results: keep only if the output was critical to a decision (≤200 chars)
|
|
867
|
+
|
|
868
|
+
OUTPUT FORMAT:
|
|
869
|
+
Respond ONLY with a valid JSON array of Message objects. Use this exact format:
|
|
870
|
+
|
|
871
|
+
[{"role":"user","content":"User message text"},
|
|
872
|
+
{"role":"assistant","content":[{"type":"text","text":"Assistant response"}]},
|
|
873
|
+
{"role":"assistant","content":[{"type":"tool-use","id":"tu_1","name":"Read","input":{"path":"main.py"}}]},
|
|
874
|
+
{"role":"tool","toolUseId":"tu_1","content":"Tool output text"}]
|
|
875
|
+
|
|
876
|
+
CRITICAL: Output ONLY the JSON array. No markdown, no explanation, no code fences.`
|
|
877
|
+
: `You are compacting a conversation between a user and an AI coding agent.
|
|
878
|
+
Your job is to reduce the conversation size while preserving all important context.
|
|
879
|
+
|
|
880
|
+
RULES:
|
|
881
|
+
- Reduce to approximately 50% of the original message count
|
|
882
|
+
- Keep ALL user messages (they define the intent)
|
|
883
|
+
- For assistant messages with tool calls: keep the tool name and a brief description of what it did, but truncate long outputs
|
|
884
|
+
- For assistant text: keep full content unless very long (>200 chars), then summarize
|
|
885
|
+
- For tool results: if >500 chars, keep the first 300 chars + "[truncated, N chars total]"
|
|
886
|
+
- NEVER drop entire turns — always keep at least one response per user message
|
|
887
|
+
|
|
888
|
+
OUTPUT FORMAT:
|
|
889
|
+
Respond ONLY with a valid JSON array of Message objects. Use this exact format:
|
|
890
|
+
|
|
891
|
+
[{"role":"user","content":"User message text"},
|
|
892
|
+
{"role":"assistant","content":[{"type":"text","text":"Assistant response"}]},
|
|
893
|
+
{"role":"assistant","content":[{"type":"thinking","thinking":"thinking text","signature":"sig"}]},
|
|
894
|
+
{"role":"assistant","content":[{"type":"tool-use","id":"tu_1","name":"Read","input":{"path":"main.py"}}]},
|
|
895
|
+
{"role":"tool","toolUseId":"tu_1","content":"Tool output text"}]
|
|
896
|
+
|
|
897
|
+
Each message has: role, content (string for user/tool, array for assistant).
|
|
898
|
+
Assistant blocks: {type:"text",text:"..."} | {type:"thinking",thinking:"...",signature:"..."} | {type:"tool-use",id:"...",name:"...",input:{...}}
|
|
899
|
+
|
|
900
|
+
CRITICAL: Output ONLY the JSON array. No markdown, no explanation, no code fences.`;
|
|
901
|
+
try {
|
|
902
|
+
const provider = streamProviderHolder.current;
|
|
903
|
+
const completeResult = await provider.complete({
|
|
904
|
+
messages: messages,
|
|
905
|
+
system: compactionPrompt,
|
|
906
|
+
tools: [],
|
|
907
|
+
model: currentModel,
|
|
908
|
+
});
|
|
909
|
+
if (!completeResult.text) {
|
|
910
|
+
push('Compaction failed: no compacted messages generated.');
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
// Try to extract JSON from the response (LLM may add explanation text)
|
|
914
|
+
let jsonStr = completeResult.text.trim();
|
|
915
|
+
const firstBracket = jsonStr.indexOf('[');
|
|
916
|
+
const lastBracket = jsonStr.lastIndexOf(']');
|
|
917
|
+
if (firstBracket >= 0 && lastBracket > firstBracket) {
|
|
918
|
+
jsonStr = jsonStr.slice(firstBracket, lastBracket + 1);
|
|
919
|
+
}
|
|
920
|
+
// Parse and validate the compacted messages
|
|
921
|
+
let compactedMessages;
|
|
922
|
+
try {
|
|
923
|
+
compactedMessages = JSON.parse(jsonStr);
|
|
924
|
+
if (!Array.isArray(compactedMessages) || compactedMessages.length === 0) {
|
|
925
|
+
push('Compaction failed: invalid response format.');
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
catch {
|
|
930
|
+
push('Compaction failed: could not parse compacted messages. The model did not return valid JSON.');
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
// Build summary for display: extract any non-JSON text or generate a brief summary
|
|
934
|
+
const summaryText = `Compacted conversation to ${compactedMessages.length} messages (${depth}).\nThe agent will continue with the condensed context.`;
|
|
935
|
+
// Build new system prompt: original system + compacted messages summary
|
|
936
|
+
const compactedText = compactedMessages.map((m) => {
|
|
937
|
+
if (m.role === 'user')
|
|
938
|
+
return `USER: ${m.content}`;
|
|
939
|
+
if (m.role === 'assistant')
|
|
940
|
+
return `ASSISTANT: ${m.content.map((b) => (b.type === 'text' ? b.text : '')).join('\n')}`;
|
|
941
|
+
if (m.role === 'tool')
|
|
942
|
+
return `TOOL RESULT (${m.toolUseId}): ${m.content}`;
|
|
943
|
+
return '';
|
|
944
|
+
}).join('\n');
|
|
945
|
+
const newSystem = originalSystem + '\n\n# Conversation Summary\n\n' + compactedText;
|
|
946
|
+
// Null out old loop so next turn creates fresh loop with compacted state
|
|
947
|
+
loopRef.current = null;
|
|
948
|
+
// Update state
|
|
949
|
+
setCurrentSystem(newSystem);
|
|
950
|
+
sessionMessagesRef.current = compactedMessages;
|
|
951
|
+
setChannelMessages((prev) => ({
|
|
952
|
+
...prev,
|
|
953
|
+
[activeChannelId]: compactedMessages,
|
|
954
|
+
}));
|
|
955
|
+
push(summaryText);
|
|
956
|
+
}
|
|
957
|
+
catch (err) {
|
|
958
|
+
push(`Compaction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
959
|
+
}
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
806
962
|
}
|
|
807
963
|
}, []);
|
|
808
|
-
const onSlashCommand = useCallback((input) => {
|
|
964
|
+
const onSlashCommand = useCallback(async (input) => {
|
|
809
965
|
// Echo the submitted slash command as a user message so the transcript
|
|
810
966
|
// shows what the user typed alongside the assistant's reply.
|
|
811
967
|
const echoed = input.args ? `/${input.command} ${input.args}` : `/${input.command}`;
|
|
@@ -823,13 +979,15 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
823
979
|
cwd: currentCwd,
|
|
824
980
|
inputTokens,
|
|
825
981
|
outputTokens,
|
|
982
|
+
messages: loopRef.current?.getMessages() ?? sessionMessagesRef.current,
|
|
983
|
+
channelMessages: channelMessages[activeChannelId],
|
|
826
984
|
cronManager: cronManagerRef.current ?? undefined,
|
|
827
985
|
mcpClients: mcpClientsRef.current,
|
|
828
986
|
contextWindowSize: contextWindowSize ?? 200_000,
|
|
829
987
|
thinkingBudget: ({ low: 2000, medium: 6000, high: 16000, max: 32000, auto: 0 })[currentEffort ?? 'auto'] ?? 0,
|
|
830
988
|
};
|
|
831
989
|
const result = handleSlashCommand(input.command, input.args, ctx);
|
|
832
|
-
applySlashResult(result, input.args);
|
|
990
|
+
await applySlashResult(result, input.args);
|
|
833
991
|
// Spawn external agent subprocess after the slash command result is applied.
|
|
834
992
|
if (result.type === 'start_agent' && result.agentId) {
|
|
835
993
|
const agentId = result.agentId;
|
|
@@ -938,16 +1096,20 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
938
1096
|
'2': { name: 'openrouter', key: 'OPENROUTER_API_KEY', url: 'OPENROUTER_URL' },
|
|
939
1097
|
'3': { name: 'openai', key: 'OPENAI_API_KEY', url: 'OPENAI_URL' },
|
|
940
1098
|
'4': { name: 'anthropic', key: 'ANTHROPIC_API_KEY', url: 'ANTHROPIC_URL' },
|
|
1099
|
+
'5': { name: 'google', key: 'GOOGLE_API_KEY', url: 'GOOGLE_URL' },
|
|
941
1100
|
};
|
|
942
1101
|
const entry = providerMap[choice];
|
|
943
1102
|
if (!entry) {
|
|
944
|
-
setChannelMessages((prev) => ({ ...prev, [targetChannel]: [...(prev[targetChannel] || []), { role: 'assistant', content: 'Invalid choice. Please enter 1-
|
|
945
|
-
setMessages((prev) => [...prev, { role: 'assistant', content: 'Invalid choice. Please enter 1-
|
|
1103
|
+
setChannelMessages((prev) => ({ ...prev, [targetChannel]: [...(prev[targetChannel] || []), { role: 'assistant', content: 'Invalid choice. Please enter 1-5.\n 1) local\n 2) openrouter\n 3) openai\n 4) anthropic\n 5) google' }] }));
|
|
1104
|
+
setMessages((prev) => [...prev, { role: 'assistant', content: 'Invalid choice. Please enter 1-5.\n 1) local\n 2) openrouter\n 3) openai\n 4) anthropic\n 5) google' }]);
|
|
946
1105
|
return;
|
|
947
1106
|
}
|
|
948
1107
|
const updates = { MODEL_PROVIDER: entry.name };
|
|
949
1108
|
updates[entry.key] = '';
|
|
950
|
-
updates[entry.url] = entry.name === 'openrouter' ? 'https://openrouter.ai/api'
|
|
1109
|
+
updates[entry.url] = entry.name === 'openrouter' ? 'https://openrouter.ai/api'
|
|
1110
|
+
: entry.name === 'anthropic' ? 'https://api.anthropic.com'
|
|
1111
|
+
: entry.name === 'google' ? 'https://generativelanguage.googleapis.com/v1beta'
|
|
1112
|
+
: `https://api.${entry.name}.com/v1`;
|
|
951
1113
|
settingsMgr.current.update(updates);
|
|
952
1114
|
if (entry.name === 'local') {
|
|
953
1115
|
wizardRef.current = { phase: 'apiKey', provider: entry.name };
|
|
@@ -963,12 +1125,17 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
963
1125
|
const keyName = wizard.provider === 'local' ? 'MODEL_API_KEY'
|
|
964
1126
|
: wizard.provider === 'openrouter' ? 'OPENROUTER_API_KEY'
|
|
965
1127
|
: wizard.provider === 'openai' ? 'OPENAI_API_KEY'
|
|
966
|
-
: '
|
|
1128
|
+
: wizard.provider === 'google' ? 'GOOGLE_API_KEY'
|
|
1129
|
+
: 'ANTHROPIC_API_KEY';
|
|
967
1130
|
settingsMgr.current.update({ [keyName]: input });
|
|
968
1131
|
if (wizard.provider === 'local') {
|
|
969
1132
|
wizardRef.current = { phase: 'url', provider: wizard.provider };
|
|
970
1133
|
pushToChannel(targetChannel, `API key configured.\nWhat is your local API URL?\n Default: http://localhost:8080/v1\n (Press Enter for default):`);
|
|
971
1134
|
}
|
|
1135
|
+
else if (wizard.provider === 'google') {
|
|
1136
|
+
wizardRef.current = { phase: 'model', provider: wizard.provider };
|
|
1137
|
+
pushToChannel(targetChannel, `API key configured.\nWhich model do you want to use?\n Examples: gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash\n Enter model name:`);
|
|
1138
|
+
}
|
|
972
1139
|
else {
|
|
973
1140
|
wizardRef.current = { phase: 'model', provider: wizard.provider };
|
|
974
1141
|
pushToChannel(targetChannel, `API key configured.\nWhich model do you want to use?\n Examples: claude-sonnet-4-6, gpt-4o, o1, llama-3.1-405b\n Enter model name:`);
|
|
@@ -1054,6 +1221,8 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
1054
1221
|
effort: currentEffort,
|
|
1055
1222
|
system: currentSystem + buildModePreamble(currentMode, currentCwd),
|
|
1056
1223
|
sessionId: channel?.sessionId,
|
|
1224
|
+
resume: resumeSession,
|
|
1225
|
+
resumeSessionId: resumeSessionId,
|
|
1057
1226
|
onApprovalAsk: (req) => new Promise((resolve) => {
|
|
1058
1227
|
setPendingApproval({
|
|
1059
1228
|
toolName: req.name,
|
|
@@ -1143,6 +1312,16 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
1143
1312
|
globalThis.__hopperSessionId = e.id;
|
|
1144
1313
|
}
|
|
1145
1314
|
}),
|
|
1315
|
+
loop.eventBus.subscribe('session-resumed', (e) => {
|
|
1316
|
+
if (e.type === 'session-resumed' && e.turnsRecovered > 0) {
|
|
1317
|
+
const bannerText = `Context restored: ${e.turnsRecovered} turn(s) from session ${e.id}`;
|
|
1318
|
+
setMessages(prev => [...prev, { role: 'system', content: bannerText }]);
|
|
1319
|
+
setChannelMessages((prev) => ({
|
|
1320
|
+
...prev,
|
|
1321
|
+
[targetChannel]: [...(prev[targetChannel] || []), { role: 'system', content: bannerText }],
|
|
1322
|
+
}));
|
|
1323
|
+
}
|
|
1324
|
+
}),
|
|
1146
1325
|
loop.eventBus.subscribe('usage', (e) => {
|
|
1147
1326
|
if (e.type === 'usage') {
|
|
1148
1327
|
setInputTokens(e.inputTokens);
|
|
@@ -1232,6 +1411,8 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
1232
1411
|
if (runResult) {
|
|
1233
1412
|
channelRegistry.updateSession(targetChannel, runResult.sessionId);
|
|
1234
1413
|
}
|
|
1414
|
+
// Persist full TurnLoop messages for slash commands (e.g. /context messages)
|
|
1415
|
+
sessionMessagesRef.current = loop.getMessages();
|
|
1235
1416
|
unsubs.forEach(u => u());
|
|
1236
1417
|
toolGroupIndexRef.current = null;
|
|
1237
1418
|
busyRef.current = false;
|
|
@@ -1416,7 +1597,7 @@ function App({ provider, streamProviderHolder, model, approvalMode, cwd, themeNa
|
|
|
1416
1597
|
setMessages(prev => [...prev, { role: 'system', content: `Active project switched to ${p.label} (${p.projectPath})` }]);
|
|
1417
1598
|
}, []);
|
|
1418
1599
|
const project = basename(currentCwd);
|
|
1419
|
-
const cost = estimateCost(currentModel, inputTokens ?? 0, outputTokens ?? 0);
|
|
1600
|
+
const cost = estimateCost(currentModel, inputTokens ?? 0, outputTokens ?? 0, settingsMgr.current?.get().MODEL_COST);
|
|
1420
1601
|
// Build channel entries for the Channels tab
|
|
1421
1602
|
const channelList = channelRegistry.list();
|
|
1422
1603
|
const channelTabEntries = channelList.map(ch => ({
|
|
@@ -1448,7 +1629,7 @@ function createProvider(settings) {
|
|
|
1448
1629
|
process.env.OPENAI_API_KEY ||
|
|
1449
1630
|
'';
|
|
1450
1631
|
const openaiUrl = (typeof settings.OPENAI_URL === 'string' ? settings.OPENAI_URL.trim() : '') ||
|
|
1451
|
-
process.env.
|
|
1632
|
+
process.env.OPENAI_URL ||
|
|
1452
1633
|
'';
|
|
1453
1634
|
if (!openaiKey) {
|
|
1454
1635
|
console.error('hopper-agent: no OpenAI API key configured.\n' +
|
|
@@ -1488,16 +1669,31 @@ function createProvider(settings) {
|
|
|
1488
1669
|
const p = new AnthropicProvider(localKey || undefined, localUrl || undefined, { local: true });
|
|
1489
1670
|
return { name: 'local', stream: p.stream.bind(p), check: p.check.bind(p) };
|
|
1490
1671
|
}
|
|
1672
|
+
if (providerName === 'google') {
|
|
1673
|
+
const googleKey = (typeof settings.GOOGLE_API_KEY === 'string' ? settings.GOOGLE_API_KEY.trim() : '') ||
|
|
1674
|
+
process.env.GOOGLE_API_KEY ||
|
|
1675
|
+
'';
|
|
1676
|
+
const googleUrl = (typeof settings.GOOGLE_URL === 'string' ? settings.GOOGLE_URL.trim() : '') ||
|
|
1677
|
+
process.env.GOOGLE_URL ||
|
|
1678
|
+
'';
|
|
1679
|
+
if (!googleKey) {
|
|
1680
|
+
console.error('hopper-agent: no Google API key configured.\n' +
|
|
1681
|
+
' Run `hopper-agent` and use `/init` to configure, or set "GOOGLE_API_KEY" in ~/.hopper-agent/settings.json.');
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
const p = new GoogleGeminiProvider(googleKey, googleUrl || undefined);
|
|
1685
|
+
return { name: p.name, stream: p.stream.bind(p), check: p.check.bind(p) };
|
|
1686
|
+
}
|
|
1491
1687
|
// anthropic (default)
|
|
1492
|
-
const fromSettingsKey = typeof settings.
|
|
1493
|
-
const fromSettingsUrl = typeof settings.
|
|
1688
|
+
const fromSettingsKey = typeof settings.ANTHROPIC_API_KEY === 'string' ? settings.ANTHROPIC_API_KEY.trim() : '';
|
|
1689
|
+
const fromSettingsUrl = typeof settings.ANTHROPIC_URL === 'string' ? settings.ANTHROPIC_URL.trim() : '';
|
|
1494
1690
|
const apiKey = fromSettingsKey || process.env.ANTHROPIC_API_KEY || '';
|
|
1495
|
-
const baseUrl = fromSettingsUrl || process.env.
|
|
1691
|
+
const baseUrl = fromSettingsUrl || process.env.ANTHROPIC_URL || '';
|
|
1496
1692
|
if (!apiKey) {
|
|
1497
1693
|
console.error('hopper-agent: no API key configured.\n' +
|
|
1498
|
-
` Looked at ~/.hopper-agent/settings.json (
|
|
1694
|
+
` Looked at ~/.hopper-agent/settings.json (ANTHROPIC_API_KEY = ${fromSettingsKey ? '[set]' : '[missing/empty]'}),\n` +
|
|
1499
1695
|
` and ANTHROPIC_API_KEY env var (${process.env.ANTHROPIC_API_KEY ? '[set]' : '[missing]'}).\n` +
|
|
1500
|
-
' Run `hopper-agent` and use `/init` to configure, or set "
|
|
1696
|
+
' Run `hopper-agent` and use `/init` to configure, or set "ANTHROPIC_API_KEY" in ~/.hopper-agent/settings.json.');
|
|
1501
1697
|
return null;
|
|
1502
1698
|
}
|
|
1503
1699
|
const p = new AnthropicProvider(apiKey, baseUrl || undefined);
|
|
@@ -1742,17 +1938,12 @@ async function main() {
|
|
|
1742
1938
|
const result = await loop.run(args.prompt);
|
|
1743
1939
|
process.exit(result.reason === 'error' ? 1 : 0);
|
|
1744
1940
|
}
|
|
1745
|
-
//
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
// gaps between widgets) match the theme instead of showing whatever color
|
|
1749
|
-
// the user's terminal profile happens to use.
|
|
1941
|
+
// Override the terminal's default background via OSC 11 so the unpainted
|
|
1942
|
+
// cells (padding, gaps between widgets) match the theme instead of showing
|
|
1943
|
+
// whatever color the user's terminal profile happens to use.
|
|
1750
1944
|
const theme = getTheme(themeName);
|
|
1751
|
-
const ENTER_ALT = '\x1b[?1049h\x1b[H';
|
|
1752
|
-
const LEAVE_ALT = '\x1b[?1049l';
|
|
1753
|
-
const SET_BG = `\x1b]11;${theme.background}\x07`;
|
|
1754
1945
|
const RESET_BG = '\x1b]111\x07';
|
|
1755
|
-
process.stdout.write(
|
|
1946
|
+
process.stdout.write(`\x1b]11;${theme.background}\x07`);
|
|
1756
1947
|
let restored = false;
|
|
1757
1948
|
const restore = () => {
|
|
1758
1949
|
if (restored)
|
|
@@ -1762,7 +1953,7 @@ async function main() {
|
|
|
1762
1953
|
for (const client of mcpRawClientsRef.current) {
|
|
1763
1954
|
client.disconnect().catch(() => { });
|
|
1764
1955
|
}
|
|
1765
|
-
process.stdout.write(RESET_BG
|
|
1956
|
+
process.stdout.write(RESET_BG);
|
|
1766
1957
|
const id = globalThis.__hopperSessionId;
|
|
1767
1958
|
if (id) {
|
|
1768
1959
|
process.stdout.write(`\nSession saved. Resume with: hopper-agent resume ${id}\n`);
|
|
@@ -1865,7 +2056,7 @@ async function main() {
|
|
|
1865
2056
|
}
|
|
1866
2057
|
});
|
|
1867
2058
|
console.error(`[cron] Checker started. Pending: ${cronManager.pendingCount}`);
|
|
1868
|
-
render(_jsx(App, { provider: streamProviderHolder.current, streamProviderHolder: streamProviderHolder, model: model, approvalMode: approvalMode, cwd: cwd, themeName: themeName, system: system, settings: settings, projects: claudeProjects, cronManager: cronManager, onReminderHolder: onReminderHolder, telegramChatIdRef: telegramChatIdRef, telegramSubmitRef: telegramSubmitRef, telegramGateway: telegramGateway, channelRegistry: channelRegistry, channelRouter: channelRouter, activeChannelRef: activeChannelRef, mcpToolsRef: mcpToolsRef, mcpClientsRef: mcpClientsRef, onMcpReconnect: reconnectMcp, contextWindowSize: 200_000 }), {
|
|
2059
|
+
render(_jsx(App, { provider: streamProviderHolder.current, streamProviderHolder: streamProviderHolder, model: model, approvalMode: approvalMode, cwd: cwd, themeName: themeName, system: system, settings: settings, projects: claudeProjects, cronManager: cronManager, onReminderHolder: onReminderHolder, telegramChatIdRef: telegramChatIdRef, telegramSubmitRef: telegramSubmitRef, telegramGateway: telegramGateway, channelRegistry: channelRegistry, channelRouter: channelRouter, activeChannelRef: activeChannelRef, mcpToolsRef: mcpToolsRef, mcpClientsRef: mcpClientsRef, onMcpReconnect: reconnectMcp, contextWindowSize: 200_000, resumeSession: args.resume, resumeSessionId: args.resume ? args.prompt : undefined }), {
|
|
1869
2060
|
exitOnCtrlC: false,
|
|
1870
2061
|
});
|
|
1871
2062
|
}
|