@hopper-agent/cli 0.1.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/LICENSE +179 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +1876 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +44 -0
- package/templates/AGENTS.md +236 -0
- package/templates/HEARTBEAT.md +23 -0
- package/templates/MEMORY.md +19 -0
- package/templates/SOUL.md +53 -0
- package/templates/TODO.md +10 -0
- package/templates/TOOLS.md +26 -0
- package/templates/USER.md +28 -0
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,1876 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
process.title = 'hopper-agent';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { resolve, basename, isAbsolute, join, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
import { ChatSurface, handleSlashCommand } from '@hopper-agent/tui';
|
|
13
|
+
import { getTheme } from '@hopper-agent/render';
|
|
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';
|
|
16
|
+
import { allTools } from '@hopper-agent/tools';
|
|
17
|
+
import { createMcpTools } from '@hopper-agent/mcp';
|
|
18
|
+
const VERSION = '0.5.1';
|
|
19
|
+
// Resolve templates directory — included in package via "files" in package.json
|
|
20
|
+
// Prod: packages/cli/dist/src/cli.js → ../../templates
|
|
21
|
+
// Dev: packages/cli/src/cli.tsx → ../templates
|
|
22
|
+
const __dir = dirname(__filename);
|
|
23
|
+
const TEMPLATES_DIR = existsSync(join(__dir, '..', '..', 'templates'))
|
|
24
|
+
? join(__dir, '..', '..', 'templates') // prod: dist/src/
|
|
25
|
+
: join(__dir, '..', 'templates'); // dev: src/
|
|
26
|
+
function parseStreamJsonSummary(raw, prompt) {
|
|
27
|
+
// Parse Claude CLI stream-json output (JSONL format).
|
|
28
|
+
// --verbose adds __meta lines we skip.
|
|
29
|
+
// Actual format from real output:
|
|
30
|
+
// {"type":"assistant","message":{"content":[{"type":"thinking","thinking":"...","signature":""},{"type":"text","text":"Hello world!"}]}}
|
|
31
|
+
// {"type":"result","result":"Hello world!","..."}
|
|
32
|
+
const lines = raw.split('\n');
|
|
33
|
+
const texts = [];
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (!trimmed)
|
|
37
|
+
continue;
|
|
38
|
+
// Skip __meta lines and non-JSON noise
|
|
39
|
+
if (trimmed.startsWith('__meta'))
|
|
40
|
+
continue;
|
|
41
|
+
// Try to parse as JSON
|
|
42
|
+
try {
|
|
43
|
+
const obj = JSON.parse(trimmed);
|
|
44
|
+
// type: "assistant" — message.content is an array of blocks
|
|
45
|
+
if (obj.type === 'assistant' && obj.message && typeof obj.message === 'object') {
|
|
46
|
+
const msg = obj.message;
|
|
47
|
+
const contentArr = msg.content;
|
|
48
|
+
if (Array.isArray(contentArr)) {
|
|
49
|
+
for (const block of contentArr) {
|
|
50
|
+
if (typeof block !== 'object' || block === null)
|
|
51
|
+
continue;
|
|
52
|
+
const b = block;
|
|
53
|
+
// Only text blocks — skip thinking blocks (internal reasoning)
|
|
54
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
55
|
+
texts.push(b.text);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// type: "result" — final output has a flat "result" field
|
|
61
|
+
if (obj.type === 'result' && typeof obj.result === 'string') {
|
|
62
|
+
texts.push(obj.result);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Not JSON — skip
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Combine all text blocks
|
|
70
|
+
const fullText = texts.join('\n').trim();
|
|
71
|
+
if (fullText) {
|
|
72
|
+
const summary = fullText.length > 2000
|
|
73
|
+
? fullText.slice(0, 2000) + '... (truncated)'
|
|
74
|
+
: fullText;
|
|
75
|
+
return `Agent done work on project: ${basename(process.cwd())}\n${'---'.repeat(10)}\n${summary}`;
|
|
76
|
+
}
|
|
77
|
+
// Fallback: non-JSON lines that look like assistant output
|
|
78
|
+
const nonJsonLines = lines
|
|
79
|
+
.filter(l => l.trim() && !l.trim().startsWith('{'))
|
|
80
|
+
.filter(l => !l.trim().startsWith('['))
|
|
81
|
+
.join('\n')
|
|
82
|
+
.trim();
|
|
83
|
+
if (nonJsonLines) {
|
|
84
|
+
const summary = nonJsonLines.length > 2000
|
|
85
|
+
? nonJsonLines.slice(0, 2000) + '... (truncated)'
|
|
86
|
+
: nonJsonLines;
|
|
87
|
+
return `Agent done work on project: ${basename(process.cwd())}\n${'---'.repeat(10)}\n${summary}`;
|
|
88
|
+
}
|
|
89
|
+
return `Agent finished on project: ${basename(process.cwd())}`;
|
|
90
|
+
}
|
|
91
|
+
function parseArgs(argv) {
|
|
92
|
+
const args = {};
|
|
93
|
+
let i = 0;
|
|
94
|
+
while (i < argv.length) {
|
|
95
|
+
const arg = argv[i];
|
|
96
|
+
if (arg === '--version' || arg === '-v') {
|
|
97
|
+
args.version = true;
|
|
98
|
+
}
|
|
99
|
+
else if (arg === '--help' || arg === '-h') {
|
|
100
|
+
args.help = true;
|
|
101
|
+
}
|
|
102
|
+
else if (arg === '-p') {
|
|
103
|
+
args.headless = true;
|
|
104
|
+
}
|
|
105
|
+
else if (arg === '--output-format') {
|
|
106
|
+
i++;
|
|
107
|
+
args.outputFormat = argv[i];
|
|
108
|
+
}
|
|
109
|
+
else if (arg === '--model') {
|
|
110
|
+
i++;
|
|
111
|
+
args.model = argv[i];
|
|
112
|
+
}
|
|
113
|
+
else if (arg === '--approval-mode') {
|
|
114
|
+
i++;
|
|
115
|
+
args.approvalMode = argv[i];
|
|
116
|
+
}
|
|
117
|
+
else if (arg === '--session') {
|
|
118
|
+
i++;
|
|
119
|
+
args.session = argv[i];
|
|
120
|
+
}
|
|
121
|
+
else if (arg === '--cwd') {
|
|
122
|
+
i++;
|
|
123
|
+
args.cwd = argv[i];
|
|
124
|
+
}
|
|
125
|
+
else if (arg === 'resume' || arg === 'continue') {
|
|
126
|
+
args.resume = true;
|
|
127
|
+
}
|
|
128
|
+
else if (!arg.startsWith('-')) {
|
|
129
|
+
args.prompt = arg;
|
|
130
|
+
}
|
|
131
|
+
i++;
|
|
132
|
+
}
|
|
133
|
+
return args;
|
|
134
|
+
}
|
|
135
|
+
function printHelp() {
|
|
136
|
+
console.log(`
|
|
137
|
+
hopper-agent - the successor to claude-code
|
|
138
|
+
|
|
139
|
+
Usage:
|
|
140
|
+
hopper-agent Interactive TUI
|
|
141
|
+
hopper-agent "<prompt>" One-shot prompt
|
|
142
|
+
hopper-agent -p "<prompt>" Headless mode, stdout answer
|
|
143
|
+
hopper-agent resume [<id>] Resume session
|
|
144
|
+
hopper-agent sessions list|show|rm Session management
|
|
145
|
+
hopper-agent --version Show version
|
|
146
|
+
|
|
147
|
+
Options:
|
|
148
|
+
--model <model> Model to use
|
|
149
|
+
--approval-mode <mode> plan | edit | auto | yolo
|
|
150
|
+
--cwd <dir> Working directory
|
|
151
|
+
--output-format <format> stream-json
|
|
152
|
+
-v, --version Show version
|
|
153
|
+
-h, --help Show help
|
|
154
|
+
|
|
155
|
+
Slash Commands (in TUI):
|
|
156
|
+
/status Show version, model, and account info
|
|
157
|
+
/help Show all available commands
|
|
158
|
+
/model <model> Switch AI model
|
|
159
|
+
/effort <level> Set reasoning effort (low|medium|high|max|auto)
|
|
160
|
+
/mode <mode> Set approval mode (plan|edit|auto|yolo)
|
|
161
|
+
/theme <name> Change color theme
|
|
162
|
+
/debug [on|off] Toggle debug logging
|
|
163
|
+
/agent [--mode m] [--effort e] <prompt> Launch external agent
|
|
164
|
+
/remind <message at time> Create a reminder
|
|
165
|
+
/cron <list|delete|clear> Manage reminders
|
|
166
|
+
/channels <list|set-bot-token|set-user-id|disconnect> Manage Telegram channel
|
|
167
|
+
`.trim());
|
|
168
|
+
}
|
|
169
|
+
function buildModePreamble(mode, cwd) {
|
|
170
|
+
const plansDir = resolve(cwd, 'plans');
|
|
171
|
+
switch (mode) {
|
|
172
|
+
case 'plan':
|
|
173
|
+
return [
|
|
174
|
+
'',
|
|
175
|
+
'',
|
|
176
|
+
'# PLAN MODE (active)',
|
|
177
|
+
'You MUST NOT edit files, write files, or run shell commands — only Read/Glob/Grep are permitted.',
|
|
178
|
+
'',
|
|
179
|
+
'Your job this turn:',
|
|
180
|
+
`1. Think step by step about the request.`,
|
|
181
|
+
`2. Pick a concise THREE-word kebab-case slug that summarises it (e.g. "refactor-login-flow").`,
|
|
182
|
+
`3. Write the full plan to ${plansDir}/<three-word-slug>.md with sections: Context, Critical Files, Steps, Verification.`,
|
|
183
|
+
` (Use the Write tool — Write IS allowed in plan mode for files under ${plansDir}/ only, via the dedicated plan flow.)`,
|
|
184
|
+
`4. Stop. The user will review and approve. On approval the app will switch to auto mode and execute the plan.`,
|
|
185
|
+
].join('\n');
|
|
186
|
+
case 'edit':
|
|
187
|
+
return [
|
|
188
|
+
'',
|
|
189
|
+
'',
|
|
190
|
+
'# EDIT MODE (active)',
|
|
191
|
+
'Every mutating tool call (Edit, Write, Bash) requires explicit user approval.',
|
|
192
|
+
'Propose changes in small, easy-to-review steps. After each approval, continue.',
|
|
193
|
+
].join('\n');
|
|
194
|
+
case 'auto':
|
|
195
|
+
return [
|
|
196
|
+
'',
|
|
197
|
+
'',
|
|
198
|
+
'# AUTO MODE (active)',
|
|
199
|
+
'Think before you act. Non-destructive edits proceed automatically.',
|
|
200
|
+
'Ask only when a step could be harmful (destructive shell, large rewrites, anything irreversible).',
|
|
201
|
+
].join('\n');
|
|
202
|
+
case 'yolo':
|
|
203
|
+
return [
|
|
204
|
+
'',
|
|
205
|
+
'',
|
|
206
|
+
'# YOLO MODE (active)',
|
|
207
|
+
'Proceed end-to-end without asking. No approvals. Only stop when the task is complete or you hit a blocker you cannot resolve.',
|
|
208
|
+
].join('\n');
|
|
209
|
+
default:
|
|
210
|
+
return '';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const FILE_DESCRIPTIONS = {
|
|
214
|
+
'IDENTITY.md': { bucket: 'identity', description: 'Core identity — who you are, your name, your persona' },
|
|
215
|
+
'SOUL.md': { bucket: 'identity', description: 'Behavioural principles and personality' },
|
|
216
|
+
'USER.md': { bucket: 'user', description: 'Static profile of the human you help (name, timezone, skills, hardware)' },
|
|
217
|
+
'MEMORY.md': { bucket: 'memory', description: 'Curated long-term memory. Read when the user references past context; write here when asked to remember something' },
|
|
218
|
+
'TOOLS.md': { bucket: 'tools', description: 'Your local tool conventions and notes' },
|
|
219
|
+
'HEARTBEAT.md': { bucket: 'other', description: 'Heartbeat / liveness state log' },
|
|
220
|
+
};
|
|
221
|
+
function listMainFiles() {
|
|
222
|
+
const root = resolve(homedir(), '.hopper-agent');
|
|
223
|
+
const manifest = {
|
|
224
|
+
agentsMd: null, identity: [], user: [], memory: [], dailyMemory: [], tools: [], other: [],
|
|
225
|
+
};
|
|
226
|
+
if (!existsSync(root) || !statSync(root).isDirectory())
|
|
227
|
+
return manifest;
|
|
228
|
+
const agentsPath = resolve(root, 'AGENTS.md');
|
|
229
|
+
if (existsSync(agentsPath)) {
|
|
230
|
+
try {
|
|
231
|
+
const body = readFileSync(agentsPath, 'utf-8').trim();
|
|
232
|
+
if (body)
|
|
233
|
+
manifest.agentsMd = body;
|
|
234
|
+
}
|
|
235
|
+
catch { /* ignore */ }
|
|
236
|
+
}
|
|
237
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
238
|
+
if (!entry.isFile())
|
|
239
|
+
continue;
|
|
240
|
+
if (entry.name === 'AGENTS.md')
|
|
241
|
+
continue; // loaded as the operating manual
|
|
242
|
+
if (!/\.(md|markdown|txt)$/i.test(entry.name))
|
|
243
|
+
continue;
|
|
244
|
+
const full = resolve(root, entry.name);
|
|
245
|
+
const meta = FILE_DESCRIPTIONS[entry.name];
|
|
246
|
+
const ref = {
|
|
247
|
+
path: full,
|
|
248
|
+
label: `~/.hopper-agent/${entry.name}`,
|
|
249
|
+
description: meta?.description ?? entry.name.replace(/\.[^.]+$/, ''),
|
|
250
|
+
};
|
|
251
|
+
const bucket = meta?.bucket ?? 'other';
|
|
252
|
+
manifest[bucket].push(ref);
|
|
253
|
+
}
|
|
254
|
+
// Daily memory: most recent 5 by mtime.
|
|
255
|
+
const memoryDir = resolve(root, 'memory');
|
|
256
|
+
if (existsSync(memoryDir) && statSync(memoryDir).isDirectory()) {
|
|
257
|
+
const entries = readdirSync(memoryDir, { withFileTypes: true })
|
|
258
|
+
.filter(e => e.isFile() && /\.(md|txt)$/i.test(e.name))
|
|
259
|
+
.map(e => {
|
|
260
|
+
const full = resolve(memoryDir, e.name);
|
|
261
|
+
return { name: e.name, full, mtime: statSync(full).mtimeMs };
|
|
262
|
+
})
|
|
263
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
264
|
+
.slice(0, 5);
|
|
265
|
+
for (const e of entries) {
|
|
266
|
+
manifest.dailyMemory.push({
|
|
267
|
+
path: e.full,
|
|
268
|
+
label: `~/.hopper-agent/memory/${e.name}`,
|
|
269
|
+
description: 'Dated session log',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return manifest;
|
|
274
|
+
}
|
|
275
|
+
function renderRefs(refs) {
|
|
276
|
+
return refs.map(r => `- ${r.path} — ${r.description}`).join('\n');
|
|
277
|
+
}
|
|
278
|
+
function loadAgentPrompt(cwd) {
|
|
279
|
+
const parts = [];
|
|
280
|
+
const manifest = listMainFiles();
|
|
281
|
+
const mainRoot = resolve(homedir(), '.hopper-agent');
|
|
282
|
+
const memoryDir = resolve(mainRoot, 'memory');
|
|
283
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
284
|
+
// 1. Operating manual — AGENTS.md verbatim, or a built-in fallback.
|
|
285
|
+
if (manifest.agentsMd) {
|
|
286
|
+
parts.push(`# Your operating manual\n\n${manifest.agentsMd}`);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
parts.push([
|
|
290
|
+
'# Your operating manual',
|
|
291
|
+
'',
|
|
292
|
+
`Your persistent identity and memory live at: ${mainRoot}`,
|
|
293
|
+
'Use the Read tool with absolute paths to pull in context files when you need them.',
|
|
294
|
+
'When the user asks you to remember something, write it to MEMORY.md.',
|
|
295
|
+
].join('\n'));
|
|
296
|
+
}
|
|
297
|
+
// 2. Manifest of available-but-not-loaded context.
|
|
298
|
+
const manifestParts = [
|
|
299
|
+
'# Available context files',
|
|
300
|
+
'',
|
|
301
|
+
'These files exist in ~/.hopper-agent/ and you may Read them with the Read tool when relevant to the user\'s request. Paths are absolute — pass them directly to Read.',
|
|
302
|
+
];
|
|
303
|
+
if (manifest.identity.length) {
|
|
304
|
+
manifestParts.push('', '## Identity & personality', renderRefs(manifest.identity));
|
|
305
|
+
}
|
|
306
|
+
if (manifest.user.length) {
|
|
307
|
+
manifestParts.push('', '## The human you\'re helping', renderRefs(manifest.user));
|
|
308
|
+
}
|
|
309
|
+
if (manifest.memory.length || manifest.dailyMemory.length) {
|
|
310
|
+
manifestParts.push('', '## Memory');
|
|
311
|
+
if (manifest.memory.length)
|
|
312
|
+
manifestParts.push(renderRefs(manifest.memory));
|
|
313
|
+
if (manifest.dailyMemory.length) {
|
|
314
|
+
manifestParts.push(`Dated session logs (newest first) in ${memoryDir}:`, renderRefs(manifest.dailyMemory));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (manifest.tools.length) {
|
|
318
|
+
manifestParts.push('', '## Tool notes', renderRefs(manifest.tools));
|
|
319
|
+
}
|
|
320
|
+
if (manifest.other.length) {
|
|
321
|
+
manifestParts.push('', '## Other', renderRefs(manifest.other));
|
|
322
|
+
}
|
|
323
|
+
parts.push(manifestParts.join('\n'));
|
|
324
|
+
// 3. Write rules — keep them explicit and short.
|
|
325
|
+
parts.push([
|
|
326
|
+
'# Write rules (enforce these)',
|
|
327
|
+
'',
|
|
328
|
+
`- "remember X" or a fact worth keeping → append to ${resolve(mainRoot, 'MEMORY.md')}`,
|
|
329
|
+
`- Today's session log → append to ${resolve(memoryDir, `${today}.md`)} (create if missing)`,
|
|
330
|
+
`- Profile fact about the user changed → update ${resolve(mainRoot, 'USER.md')}`,
|
|
331
|
+
'- Never write memories or session events to USER.md.',
|
|
332
|
+
].join('\n'));
|
|
333
|
+
// 4. Project overrides — <cwd>/.hopper-agent/AGENTS.md layered on top of the global manual.
|
|
334
|
+
const projectPath = resolve(cwd, '.hopper-agent', 'AGENTS.md');
|
|
335
|
+
if (existsSync(projectPath)) {
|
|
336
|
+
parts.push(`# Project overrides (${projectPath})\n\n${readFileSync(projectPath, 'utf-8')}`);
|
|
337
|
+
}
|
|
338
|
+
// 5. Current time.
|
|
339
|
+
const now = new Date();
|
|
340
|
+
const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
341
|
+
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short' });
|
|
342
|
+
parts.push(`# Current time\n\n${dateStr}, ${timeStr}`);
|
|
343
|
+
return parts.join('\n\n');
|
|
344
|
+
}
|
|
345
|
+
function cwdFromProjectDir(rawDir) {
|
|
346
|
+
// Read cwd from a JSONL file in the project dir — fall back to reconstructing from dir name.
|
|
347
|
+
const projDir = join(homedir(), '.claude', 'projects', rawDir);
|
|
348
|
+
try {
|
|
349
|
+
const files = readdirSync(projDir).filter(f => f.endsWith('.jsonl'));
|
|
350
|
+
for (const f of files) {
|
|
351
|
+
const content = readFileSync(join(projDir, f), 'utf8');
|
|
352
|
+
for (const line of content.split('\n')) {
|
|
353
|
+
if (!line.includes('"cwd"'))
|
|
354
|
+
continue;
|
|
355
|
+
try {
|
|
356
|
+
const obj = JSON.parse(line);
|
|
357
|
+
if (typeof obj['cwd'] === 'string')
|
|
358
|
+
return obj['cwd'];
|
|
359
|
+
}
|
|
360
|
+
catch { /* skip */ }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch { /* fall through */ }
|
|
365
|
+
// Fallback: reconstruct from dir name (works for simple paths without dashes in dir names)
|
|
366
|
+
const m = rawDir.match(/^([A-Za-z])--(.+)$/);
|
|
367
|
+
if (m) {
|
|
368
|
+
const drive = m[1].toUpperCase();
|
|
369
|
+
return drive + ':\\' + m[2].replace(/-/g, '\\');
|
|
370
|
+
}
|
|
371
|
+
return rawDir;
|
|
372
|
+
}
|
|
373
|
+
function loadClaudeProjects() {
|
|
374
|
+
const dir = join(homedir(), '.claude', 'projects');
|
|
375
|
+
try {
|
|
376
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
377
|
+
return entries
|
|
378
|
+
.filter(e => e.isDirectory())
|
|
379
|
+
.map(e => {
|
|
380
|
+
const m = e.name.match(/^[A-Za-z]--(.+)$/);
|
|
381
|
+
const projectPath = cwdFromProjectDir(e.name);
|
|
382
|
+
return {
|
|
383
|
+
label: m ? m[1] : e.name,
|
|
384
|
+
rawDir: e.name,
|
|
385
|
+
source: 'claude-code',
|
|
386
|
+
projectPath,
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function formatDuration(ms) {
|
|
395
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
396
|
+
const h = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
|
|
397
|
+
const m = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
|
|
398
|
+
const s = String(totalSeconds % 60).padStart(2, '0');
|
|
399
|
+
return `${h}:${m}:${s}`;
|
|
400
|
+
}
|
|
401
|
+
function estimateCost(model, inputTokens, outputTokens) {
|
|
402
|
+
// Rough per-million-token pricing in USD; fall back to Sonnet rates.
|
|
403
|
+
const pricing = {
|
|
404
|
+
'claude-opus': { in: 15, out: 75 },
|
|
405
|
+
'claude-sonnet': { in: 3, out: 15 },
|
|
406
|
+
'claude-haiku': { in: 0.8, out: 4 },
|
|
407
|
+
};
|
|
408
|
+
const key = Object.keys(pricing).find(k => model.includes(k)) || 'claude-sonnet';
|
|
409
|
+
const p = pricing[key];
|
|
410
|
+
return (inputTokens * p.in + outputTokens * p.out) / 1_000_000;
|
|
411
|
+
}
|
|
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 }) {
|
|
413
|
+
const [messages, setMessages] = useState([]);
|
|
414
|
+
// Multi-channel: per-channel message storage (keyed by channel ID)
|
|
415
|
+
const [channelMessages, setChannelMessages] = useState({});
|
|
416
|
+
const [activeChannelId, setActiveChannelId] = useState('main');
|
|
417
|
+
// Per-channel TurnLoop instances
|
|
418
|
+
const channelTurnLoopsRef = useRef(new Map());
|
|
419
|
+
const [currentModel, setCurrentModel] = useState(model);
|
|
420
|
+
const [currentProvider, setCurrentProvider] = useState(provider?.name || '(not configured)');
|
|
421
|
+
const [currentTheme, setCurrentTheme] = useState(themeName);
|
|
422
|
+
const [currentMode, setCurrentMode] = useState(approvalMode);
|
|
423
|
+
const [currentEffort, setCurrentEffort] = useState(settings.effort);
|
|
424
|
+
const [currentDebug, setCurrentDebug] = useState(settings.debug);
|
|
425
|
+
const [currentCwd, setCurrentCwd] = useState(cwd);
|
|
426
|
+
const [currentSystem, setCurrentSystem] = useState(system);
|
|
427
|
+
const [inputTokens, setInputTokens] = useState(undefined);
|
|
428
|
+
const [outputTokens, setOutputTokens] = useState(undefined);
|
|
429
|
+
const [currentTab, setCurrentTab] = useState('assistant');
|
|
430
|
+
const [duration, setDuration] = useState('00:00:00');
|
|
431
|
+
const [status, setStatus] = useState('idle');
|
|
432
|
+
const startedAt = useRef(Date.now());
|
|
433
|
+
const loopRef = useRef(null);
|
|
434
|
+
const busyRef = useRef(false);
|
|
435
|
+
const toolGroupIndexRef = useRef(null);
|
|
436
|
+
const planFileRef = useRef(null);
|
|
437
|
+
// Pending approval requests keyed by toolCallId (for Telegram callback correlation)
|
|
438
|
+
const pendingApprovalsRef = useRef(new Map());
|
|
439
|
+
// ToolCallId of the current approval request (set by approval-request event, consumed by onApprovalAsk)
|
|
440
|
+
const pendingToolCallIdRef = useRef(null);
|
|
441
|
+
const [pendingApproval, setPendingApproval] = useState(null);
|
|
442
|
+
const shellChildRef = useRef(null);
|
|
443
|
+
// Persistent cwd for `!` shell commands. `cd foo` inside a one-shot spawn
|
|
444
|
+
// would evaporate when the subshell exits, so we track it ourselves and
|
|
445
|
+
// pass it to every subsequent spawn.
|
|
446
|
+
const shellCwdRef = useRef(cwd);
|
|
447
|
+
// Load existing settings so subsequent .update() calls (from slash commands)
|
|
448
|
+
// preserve keys like MODEL_URL / MODEL_API_KEY instead of resetting to
|
|
449
|
+
// defaults.
|
|
450
|
+
const settingsMgr = useRef(null);
|
|
451
|
+
if (!settingsMgr.current) {
|
|
452
|
+
const mgr = new SettingsManager();
|
|
453
|
+
mgr.load();
|
|
454
|
+
settingsMgr.current = mgr;
|
|
455
|
+
}
|
|
456
|
+
const sessionIdRef = useRef(null);
|
|
457
|
+
// CronManager instance — created externally, stored here for slash commands
|
|
458
|
+
const cronManagerRef = useRef(externalCronManager);
|
|
459
|
+
const wizardRef = useRef({ phase: 'idle' });
|
|
460
|
+
const TEMPLATE_FILES = ['AGENTS.md', 'SOUL.md', 'USER.md', 'TODO.md', 'MEMORY.md'];
|
|
461
|
+
// Store setMessages in a ref so the checker callback can call it
|
|
462
|
+
// without racing with a render cycle. The checker runs in setInterval,
|
|
463
|
+
// which can fire mid-render — calling setState then can silently fail.
|
|
464
|
+
const setMessagesRef = useRef(setMessages);
|
|
465
|
+
setMessagesRef.current = setMessages;
|
|
466
|
+
// Helper: push a message to both channel and legacy message arrays
|
|
467
|
+
const pushToChannel = useCallback((channelId, content) => {
|
|
468
|
+
setChannelMessages((prev) => {
|
|
469
|
+
const ch = prev[channelId] || [];
|
|
470
|
+
return { ...prev, [channelId]: [...ch, { role: 'assistant', content }] };
|
|
471
|
+
});
|
|
472
|
+
setMessages((prev) => [...prev, { role: 'assistant', content }]);
|
|
473
|
+
}, []);
|
|
474
|
+
// Wire Telegram approval decision callback so inline button taps resolve pending Promises
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
if (telegramGateway) {
|
|
477
|
+
telegramGateway.setOnApprovalDecision((toolCallId, approved) => {
|
|
478
|
+
const pending = pendingApprovalsRef.current.get(toolCallId);
|
|
479
|
+
if (pending) {
|
|
480
|
+
pending.resolve(approved);
|
|
481
|
+
pendingApprovalsRef.current.delete(toolCallId);
|
|
482
|
+
}
|
|
483
|
+
// Clear TUI approval UI — decision came from Telegram, not keyboard
|
|
484
|
+
setPendingApproval(null);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}, [telegramGateway]);
|
|
488
|
+
// Register the reminder callback synchronously so it's available
|
|
489
|
+
// before useEffect runs. Uses setMessagesRef to avoid render-cycle race.
|
|
490
|
+
// Telegram sends are handled in main()'s cron checker callback (fresh closure).
|
|
491
|
+
onReminderHolder.current = (task) => {
|
|
492
|
+
const timeStr = new Date(task.scheduledAt).toLocaleString();
|
|
493
|
+
const briefTask = task;
|
|
494
|
+
if (briefTask.heartbeatBrief) {
|
|
495
|
+
const label = task.schedule ? scheduleLabel(task.schedule.type) : 'MANUAL';
|
|
496
|
+
setChannelMessages(prev => {
|
|
497
|
+
const ch = prev[activeChannelId] || [];
|
|
498
|
+
return { ...prev, [activeChannelId]: [...ch, { role: 'heartbeat', title: label, content: briefTask.heartbeatBrief }] };
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
const content = `Hopper reminder:\nDate: ${timeStr}\n${task.message}`;
|
|
503
|
+
setChannelMessages(prev => {
|
|
504
|
+
const ch = prev[activeChannelId] || [];
|
|
505
|
+
return { ...prev, [activeChannelId]: [...ch, { role: 'system', content }] };
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const [agents, setAgents] = useState(new Map());
|
|
510
|
+
const agentsRef = useRef(new Map());
|
|
511
|
+
agentsRef.current = agents;
|
|
512
|
+
useEffect(() => {
|
|
513
|
+
const id = setInterval(() => {
|
|
514
|
+
setDuration(formatDuration(Date.now() - startedAt.current));
|
|
515
|
+
}, 1000);
|
|
516
|
+
return () => clearInterval(id);
|
|
517
|
+
}, []);
|
|
518
|
+
const theme = getTheme(currentTheme);
|
|
519
|
+
const applySlashResult = useCallback((result, agentPrompt) => {
|
|
520
|
+
const push = (content) => {
|
|
521
|
+
// Slash command results must go to the per-channel messages array
|
|
522
|
+
// since that's what the ChatSurface renders.
|
|
523
|
+
setChannelMessages((prev) => {
|
|
524
|
+
const ch = prev[activeChannelId] || [];
|
|
525
|
+
return { ...prev, [activeChannelId]: [...ch, { role: 'assistant', content }] };
|
|
526
|
+
});
|
|
527
|
+
// Also sync to legacy messages for backward compatibility.
|
|
528
|
+
setMessages(prev => [...prev, { role: 'assistant', content }]);
|
|
529
|
+
};
|
|
530
|
+
switch (result.type) {
|
|
531
|
+
case 'message':
|
|
532
|
+
if (result.message === 'init_wizard') {
|
|
533
|
+
push('Welcome to hopper-agent! Let\'s configure your AI provider.\n' +
|
|
534
|
+
'\n' +
|
|
535
|
+
'Which provider do you want to use?\n' +
|
|
536
|
+
' 1) local\n' +
|
|
537
|
+
' 2) openrouter\n' +
|
|
538
|
+
' 3) openai\n' +
|
|
539
|
+
' 4) anthropic\n' +
|
|
540
|
+
'\nType 1, 2, 3, or 4:');
|
|
541
|
+
wizardRef.current = { phase: 'provider' };
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
push(result.message);
|
|
545
|
+
break;
|
|
546
|
+
case 'update_model':
|
|
547
|
+
setCurrentModel(result.model);
|
|
548
|
+
settingsMgr.current.update({ model: result.model });
|
|
549
|
+
push(result.message);
|
|
550
|
+
break;
|
|
551
|
+
case 'update_provider': {
|
|
552
|
+
const newProvider = result.provider;
|
|
553
|
+
settingsMgr.current.update({ MODEL_PROVIDER: newProvider });
|
|
554
|
+
// Create new provider and swap into the holder
|
|
555
|
+
const settings = settingsMgr.current.get();
|
|
556
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
557
|
+
streamProviderHolder.current = createProvider(settings);
|
|
558
|
+
loopRef.current = null;
|
|
559
|
+
setCurrentProvider(streamProviderHolder.current.name);
|
|
560
|
+
push(result.message);
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case 'update_init': {
|
|
564
|
+
// Legacy: direct API key via /init <key>
|
|
565
|
+
settingsMgr.current.update({ MODEL_API_KEY: result.apiKey });
|
|
566
|
+
const settings = settingsMgr.current.get();
|
|
567
|
+
streamProviderHolder.current = createProvider(settings);
|
|
568
|
+
loopRef.current = null;
|
|
569
|
+
push(result.message + '\nProvider ready. You can now chat!');
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
case 'update_theme': {
|
|
573
|
+
setCurrentTheme(result.theme);
|
|
574
|
+
settingsMgr.current.update({ theme: result.theme });
|
|
575
|
+
// Repaint the terminal default-background to match the new theme so
|
|
576
|
+
// unpainted cells stay consistent.
|
|
577
|
+
const nextTheme = getTheme(result.theme);
|
|
578
|
+
process.stdout.write(`\x1b]11;${nextTheme.background}\x07`);
|
|
579
|
+
push(result.message);
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
case 'update_mode':
|
|
583
|
+
setCurrentMode(result.mode);
|
|
584
|
+
settingsMgr.current.update({ mode: result.mode });
|
|
585
|
+
loopRef.current = null;
|
|
586
|
+
push(result.message);
|
|
587
|
+
break;
|
|
588
|
+
case 'update_effort':
|
|
589
|
+
setCurrentEffort(result.effort);
|
|
590
|
+
settingsMgr.current.update({ effort: result.effort });
|
|
591
|
+
loopRef.current = null;
|
|
592
|
+
push(result.message);
|
|
593
|
+
break;
|
|
594
|
+
case 'update_debug':
|
|
595
|
+
setCurrentDebug(result.debug);
|
|
596
|
+
settingsMgr.current.update({ debug: result.debug });
|
|
597
|
+
push(result.message);
|
|
598
|
+
break;
|
|
599
|
+
case 'update_statusline':
|
|
600
|
+
push(result.message);
|
|
601
|
+
break;
|
|
602
|
+
case 'update_tools_per_call':
|
|
603
|
+
settingsMgr.current.update({ TOOLS_PER_CALL: result.toolsPerCall, WEBSEARCH_PER_CALL: result.websearchPerCall });
|
|
604
|
+
loopRef.current = null;
|
|
605
|
+
push(result.message);
|
|
606
|
+
break;
|
|
607
|
+
case 'update_websearch_per_call':
|
|
608
|
+
settingsMgr.current.update({ WEBSEARCH_PER_CALL: result.websearchPerCall });
|
|
609
|
+
loopRef.current = null;
|
|
610
|
+
push(result.message);
|
|
611
|
+
break;
|
|
612
|
+
case 'update_mcp': {
|
|
613
|
+
// Persist MCP servers to disk. handleMcp mutates ctx.settings in-place,
|
|
614
|
+
// but that's a shallow copy from .get() — settingsMgr never sees it.
|
|
615
|
+
// The mcpServers field on the result carries the JSON string to persist.
|
|
616
|
+
if (result.mcpServers) {
|
|
617
|
+
settingsMgr.current.update({ MCP_SERVERS: typeof result.mcpServers === 'string' ? JSON.parse(result.mcpServers) : result.mcpServers });
|
|
618
|
+
}
|
|
619
|
+
loopRef.current = null;
|
|
620
|
+
if (result.mcpServerId) {
|
|
621
|
+
push(`${result.message}\nServer "${result.mcpServerId}" changed — TurnLoop invalidated. New tools will be loaded on next message.`);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
push(`${result.message}\nMCP reloaded — TurnLoop invalidated. New tools will be loaded on next message.`);
|
|
625
|
+
}
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
case 'notification': {
|
|
629
|
+
const note = result.notification;
|
|
630
|
+
if (!note)
|
|
631
|
+
break;
|
|
632
|
+
// Heartbeat notification handling
|
|
633
|
+
if (note.type === 'heartbeat') {
|
|
634
|
+
if (note.enabled) {
|
|
635
|
+
push('Heartbeat enabled. The agent will run on all configured schedules.');
|
|
636
|
+
settingsMgr.current.update({ HEARTBEAT: 'on' });
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
push('Heartbeat disabled.');
|
|
640
|
+
settingsMgr.current.update({ HEARTBEAT: 'off' });
|
|
641
|
+
}
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
// Heartbeat schedule set
|
|
645
|
+
if (note.type === 'heartbeat-set') {
|
|
646
|
+
settingsMgr.current.update({ [note.key]: note.value });
|
|
647
|
+
// Re-evaluate all four schedules to pick the next earliest
|
|
648
|
+
const settings = settingsMgr.current.get();
|
|
649
|
+
cronManagerRef.current?.rescheduleFromSettings({
|
|
650
|
+
HEARTBEAT_TIMES: settings.HEARTBEAT_TIMES,
|
|
651
|
+
HEARTBEAT_DAILY: settings.HEARTBEAT_DAILY,
|
|
652
|
+
HEARTBEAT_WEEKLY: settings.HEARTBEAT_WEEKLY,
|
|
653
|
+
HEARTBEAT_MONTHLY: settings.HEARTBEAT_MONTHLY,
|
|
654
|
+
});
|
|
655
|
+
const scheduleLabel = {
|
|
656
|
+
HEARTBEAT_TIMES: `times: ${note.value}`,
|
|
657
|
+
HEARTBEAT_DAILY: `daily at ${note.value}`,
|
|
658
|
+
HEARTBEAT_WEEKLY: `weekly on ${note.value}`,
|
|
659
|
+
HEARTBEAT_MONTHLY: `monthly on day ${note.value}`,
|
|
660
|
+
};
|
|
661
|
+
push(`Heartbeat schedule updated: ${scheduleLabel[note.key] ?? note.value}`);
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
// Heartbeat now — run immediately
|
|
665
|
+
if (note.type === 'heartbeat-now') {
|
|
666
|
+
push('Heartbeat cycle starting...');
|
|
667
|
+
const settings = settingsMgr.current.get();
|
|
668
|
+
const provider = streamProviderHolder.current;
|
|
669
|
+
if (!provider) {
|
|
670
|
+
push('Provider not initialized. Cannot run heartbeat.');
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
// Merge built-in tools + MCP tools, just like the TUI TurnLoop
|
|
674
|
+
const mcpTools = mcpToolsRef.current;
|
|
675
|
+
const heartbeatTools = mcpTools.length > 0
|
|
676
|
+
? [...allTools, ...mcpTools]
|
|
677
|
+
: allTools;
|
|
678
|
+
const hasMcp = mcpTools.length > 0;
|
|
679
|
+
const executor = new HeartbeatExecutor({
|
|
680
|
+
provider: provider,
|
|
681
|
+
model: settings.model,
|
|
682
|
+
tools: heartbeatTools,
|
|
683
|
+
cwd: currentCwd,
|
|
684
|
+
settings,
|
|
685
|
+
effort: currentEffort,
|
|
686
|
+
});
|
|
687
|
+
executor.execute().then(async (result) => {
|
|
688
|
+
const formatted = HeartbeatDelivery.formatBrief(result);
|
|
689
|
+
setChannelMessages(prev => {
|
|
690
|
+
const ch = prev[activeChannelId] || [];
|
|
691
|
+
return { ...prev, [activeChannelId]: [...ch, { role: 'heartbeat', title: 'manual', content: formatted }] };
|
|
692
|
+
});
|
|
693
|
+
// Also deliver to Telegram if configured
|
|
694
|
+
const telegramChatId = settings.TELEGRAM_CHAT_ID;
|
|
695
|
+
if (telegramChatId && telegramGateway) {
|
|
696
|
+
const delivery = new HeartbeatDelivery({
|
|
697
|
+
chatId: telegramChatId,
|
|
698
|
+
telegramGateway,
|
|
699
|
+
});
|
|
700
|
+
await delivery.deliver(formatted);
|
|
701
|
+
}
|
|
702
|
+
}).catch((err) => {
|
|
703
|
+
push(`Heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
704
|
+
});
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
// Original reminder notification handling
|
|
708
|
+
{
|
|
709
|
+
const sysMsg = {
|
|
710
|
+
role: 'system',
|
|
711
|
+
content: `Hopper reminder:\nDate: ${note.scheduledAt}\n${note.message}`,
|
|
712
|
+
};
|
|
713
|
+
setChannelMessages((prev) => {
|
|
714
|
+
const ch = prev[activeChannelId] || [];
|
|
715
|
+
return { ...prev, [activeChannelId]: [...ch, sysMsg] };
|
|
716
|
+
});
|
|
717
|
+
setMessages(prev => [...prev, sysMsg]);
|
|
718
|
+
}
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
case 'start_agent': {
|
|
722
|
+
setAgents(prev => {
|
|
723
|
+
const next = new Map(prev);
|
|
724
|
+
next.set(result.agentId, {
|
|
725
|
+
id: result.agentId,
|
|
726
|
+
prompt: agentPrompt ?? '',
|
|
727
|
+
output: '',
|
|
728
|
+
status: 'running',
|
|
729
|
+
});
|
|
730
|
+
return next;
|
|
731
|
+
});
|
|
732
|
+
push(result.message);
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
case 'external': {
|
|
736
|
+
const ext = result.external;
|
|
737
|
+
if (ext === 'channels.set-bot-token') {
|
|
738
|
+
settingsMgr.current.update({ TELEGRAM_BOT_TOKEN: result.message });
|
|
739
|
+
push(`Telegram bot token set. Bot will start polling on next user ID configuration.`);
|
|
740
|
+
}
|
|
741
|
+
else if (ext === 'channels.set-user-id') {
|
|
742
|
+
settingsMgr.current.update({ TELEGRAM_USER_ID: result.message });
|
|
743
|
+
push(`Allowed user ID set. Telegram will only process messages from this user.`);
|
|
744
|
+
}
|
|
745
|
+
else if (ext === 'channels.set-chat-id') {
|
|
746
|
+
settingsMgr.current.update({ TELEGRAM_CHAT_ID: result.message });
|
|
747
|
+
push(`Telegram chat ID set. Reminders will now send to this chat.`);
|
|
748
|
+
}
|
|
749
|
+
else if (ext === 'channels.disconnect') {
|
|
750
|
+
settingsMgr.current.update({ TELEGRAM_BOT_TOKEN: '', TELEGRAM_USER_ID: '' });
|
|
751
|
+
push('Telegram integration disconnected.');
|
|
752
|
+
telegramGateway?.stop();
|
|
753
|
+
}
|
|
754
|
+
else if (ext === 'channels.switch') {
|
|
755
|
+
setActiveChannelId(result.message);
|
|
756
|
+
push(`Switched to channel: ${result.message}`);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
push(`Command /${ext} not yet implemented`);
|
|
760
|
+
}
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
case 'exit':
|
|
764
|
+
if (result.message)
|
|
765
|
+
push(result.message);
|
|
766
|
+
// Let the transcript paint before exiting.
|
|
767
|
+
setTimeout(() => process.exit(0), 30);
|
|
768
|
+
break;
|
|
769
|
+
case 'update_memory': {
|
|
770
|
+
const mem = result.memory;
|
|
771
|
+
const home = homedir();
|
|
772
|
+
const memoryPath = join(home, '.hopper-agent', 'MEMORY.md');
|
|
773
|
+
if (mem.operation === 'status') {
|
|
774
|
+
// Read key memory files and compute approximate tokens (~4 chars ≈ 1 token for English markdown)
|
|
775
|
+
const memDir = join(home, '.hopper-agent');
|
|
776
|
+
const files = ['AGENTS.md', 'MEMORY.md', 'USER.md', 'SOUL.md', 'IDENTITY.md', 'TOOLS.md', 'HEARTBEAT.md'];
|
|
777
|
+
const lines = ['Memory System Status:'];
|
|
778
|
+
let totalChars = 0;
|
|
779
|
+
for (const f of files) {
|
|
780
|
+
const fp = join(memDir, f);
|
|
781
|
+
if (existsSync(fp)) {
|
|
782
|
+
const content = readFileSync(fp, 'utf-8');
|
|
783
|
+
const tokens = Math.ceil(content.length / 4);
|
|
784
|
+
totalChars += content.length;
|
|
785
|
+
lines.push(` ${f.padEnd(15)} ~${tokens} tokens`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
lines.push(` ${'---'.padEnd(15)} ~${'---'}`);
|
|
789
|
+
lines.push(` ${'TOTAL'.padEnd(15)} ~${Math.ceil(totalChars / 4)} tokens`);
|
|
790
|
+
push(lines.join('\n'));
|
|
791
|
+
}
|
|
792
|
+
else if (mem.operation === 'add') {
|
|
793
|
+
const existing = existsSync(memoryPath) ? readFileSync(memoryPath, 'utf-8') : '';
|
|
794
|
+
const newContent = existing.trim() ? existing.trimEnd() + '\n' + mem.content : mem.content;
|
|
795
|
+
writeFileSync(memoryPath, newContent, 'utf-8');
|
|
796
|
+
push(result.message);
|
|
797
|
+
}
|
|
798
|
+
loopRef.current = null;
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
case 'switch_tab': {
|
|
802
|
+
setCurrentTab(result.tab ?? 'stats');
|
|
803
|
+
push(result.message ?? 'Switched to Stats tab');
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}, []);
|
|
808
|
+
const onSlashCommand = useCallback((input) => {
|
|
809
|
+
// Echo the submitted slash command as a user message so the transcript
|
|
810
|
+
// shows what the user typed alongside the assistant's reply.
|
|
811
|
+
const echoed = input.args ? `/${input.command} ${input.args}` : `/${input.command}`;
|
|
812
|
+
setChannelMessages((prev) => {
|
|
813
|
+
const ch = prev[activeChannelId] || [];
|
|
814
|
+
return { ...prev, [activeChannelId]: [...ch, { role: 'user', content: echoed }] };
|
|
815
|
+
});
|
|
816
|
+
setMessages(prev => [...prev, { role: 'user', content: echoed }]);
|
|
817
|
+
const ctx = {
|
|
818
|
+
settings: settingsMgr.current.get(),
|
|
819
|
+
version: VERSION,
|
|
820
|
+
model: currentModel,
|
|
821
|
+
provider: provider?.name || '(not configured)',
|
|
822
|
+
approvalMode: currentMode,
|
|
823
|
+
cwd: currentCwd,
|
|
824
|
+
inputTokens,
|
|
825
|
+
outputTokens,
|
|
826
|
+
cronManager: cronManagerRef.current ?? undefined,
|
|
827
|
+
mcpClients: mcpClientsRef.current,
|
|
828
|
+
contextWindowSize: contextWindowSize ?? 200_000,
|
|
829
|
+
thinkingBudget: ({ low: 2000, medium: 6000, high: 16000, max: 32000, auto: 0 })[currentEffort ?? 'auto'] ?? 0,
|
|
830
|
+
};
|
|
831
|
+
const result = handleSlashCommand(input.command, input.args, ctx);
|
|
832
|
+
applySlashResult(result, input.args);
|
|
833
|
+
// Spawn external agent subprocess after the slash command result is applied.
|
|
834
|
+
if (result.type === 'start_agent' && result.agentId) {
|
|
835
|
+
const agentId = result.agentId;
|
|
836
|
+
const prompt = input.args;
|
|
837
|
+
const agentCwd = currentCwd;
|
|
838
|
+
// Build claude CLI args with agent-specific mode/effort params
|
|
839
|
+
const claudeArgs = ['-p', prompt];
|
|
840
|
+
if (result.agentMode)
|
|
841
|
+
claudeArgs.unshift('--mode', result.agentMode);
|
|
842
|
+
if (result.agentEffort)
|
|
843
|
+
claudeArgs.unshift('--effort', result.agentEffort);
|
|
844
|
+
claudeArgs.unshift('--output-format', 'stream-json', '--verbose');
|
|
845
|
+
setAgents(prev => {
|
|
846
|
+
const next = new Map(prev);
|
|
847
|
+
const child = spawn('claude', claudeArgs, {
|
|
848
|
+
cwd: agentCwd,
|
|
849
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
850
|
+
});
|
|
851
|
+
const entry = next.get(agentId);
|
|
852
|
+
entry.child = child;
|
|
853
|
+
next.set(agentId, entry);
|
|
854
|
+
// Accumulate raw output for the Agents tab (full streaming)
|
|
855
|
+
const rawOutput = [];
|
|
856
|
+
child.stdout.on('data', (data) => {
|
|
857
|
+
const text = data.toString();
|
|
858
|
+
rawOutput.push(text);
|
|
859
|
+
setAgents(prev2 => {
|
|
860
|
+
const next2 = new Map(prev2);
|
|
861
|
+
const e = next2.get(agentId);
|
|
862
|
+
if (e) {
|
|
863
|
+
e.output += text;
|
|
864
|
+
next2.set(agentId, e);
|
|
865
|
+
}
|
|
866
|
+
return next2;
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
child.stderr.on('data', (data) => {
|
|
870
|
+
const text = data.toString();
|
|
871
|
+
rawOutput.push(text);
|
|
872
|
+
setAgents(prev2 => {
|
|
873
|
+
const next2 = new Map(prev2);
|
|
874
|
+
const e = next2.get(agentId);
|
|
875
|
+
if (e) {
|
|
876
|
+
e.output += text;
|
|
877
|
+
next2.set(agentId, e);
|
|
878
|
+
}
|
|
879
|
+
return next2;
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
child.on('close', (code) => {
|
|
883
|
+
setAgents(prev2 => {
|
|
884
|
+
const next2 = new Map(prev2);
|
|
885
|
+
const e = next2.get(agentId);
|
|
886
|
+
if (e) {
|
|
887
|
+
e.status = code === 0 ? 'completed' : 'error';
|
|
888
|
+
next2.set(agentId, e);
|
|
889
|
+
}
|
|
890
|
+
return next2;
|
|
891
|
+
});
|
|
892
|
+
// Parse stream-json output to extract assistant summary for the Assistant tab
|
|
893
|
+
const fullRaw = rawOutput.join('');
|
|
894
|
+
const summary = parseStreamJsonSummary(fullRaw, prompt);
|
|
895
|
+
const finishedAgent = agentsRef.current.get(agentId);
|
|
896
|
+
if (code === 0 && finishedAgent) {
|
|
897
|
+
setMessages(prev => [
|
|
898
|
+
...prev,
|
|
899
|
+
{
|
|
900
|
+
role: 'assistant',
|
|
901
|
+
content: summary,
|
|
902
|
+
title: `Agent: ${prompt}`,
|
|
903
|
+
},
|
|
904
|
+
]);
|
|
905
|
+
}
|
|
906
|
+
else if (finishedAgent) {
|
|
907
|
+
setMessages(prev => [
|
|
908
|
+
...prev,
|
|
909
|
+
{
|
|
910
|
+
role: 'assistant',
|
|
911
|
+
content: `Agent "${prompt}" failed (exit ${code}):\n\n${finishedAgent.output}`,
|
|
912
|
+
title: `Agent: ${prompt}`,
|
|
913
|
+
},
|
|
914
|
+
]);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
return next;
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}, [currentModel, currentMode, currentCwd, inputTokens, outputTokens, provider?.name, applySlashResult]);
|
|
921
|
+
const onSubmit = useCallback(async (text) => {
|
|
922
|
+
if (busyRef.current)
|
|
923
|
+
return;
|
|
924
|
+
// Determine target channel: Telegram sets activeChannelRef before calling onSubmit
|
|
925
|
+
const targetChannel = activeChannelRef.current ?? activeChannelId;
|
|
926
|
+
if (activeChannelRef.current) {
|
|
927
|
+
activeChannelRef.current = null;
|
|
928
|
+
setActiveChannelId(targetChannel);
|
|
929
|
+
}
|
|
930
|
+
// Handle interactive /init wizard steps
|
|
931
|
+
const wizard = wizardRef.current;
|
|
932
|
+
if (wizard.phase !== 'idle' && !text.trim().startsWith('/')) {
|
|
933
|
+
const input = text.trim();
|
|
934
|
+
if (wizard.phase === 'provider') {
|
|
935
|
+
const choice = input.toLowerCase();
|
|
936
|
+
const providerMap = {
|
|
937
|
+
'1': { name: 'local', key: 'MODEL_API_KEY', url: 'MODEL_URL' },
|
|
938
|
+
'2': { name: 'openrouter', key: 'OPENROUTER_API_KEY', url: 'OPENROUTER_URL' },
|
|
939
|
+
'3': { name: 'openai', key: 'OPENAI_API_KEY', url: 'OPENAI_URL' },
|
|
940
|
+
'4': { name: 'anthropic', key: 'ANTHROPIC_API_KEY', url: 'ANTHROPIC_URL' },
|
|
941
|
+
};
|
|
942
|
+
const entry = providerMap[choice];
|
|
943
|
+
if (!entry) {
|
|
944
|
+
setChannelMessages((prev) => ({ ...prev, [targetChannel]: [...(prev[targetChannel] || []), { role: 'assistant', content: 'Invalid choice. Please enter 1-4.\n 1) local\n 2) openrouter\n 3) openai\n 4) anthropic' }] }));
|
|
945
|
+
setMessages((prev) => [...prev, { role: 'assistant', content: 'Invalid choice. Please enter 1-4.\n 1) local\n 2) openrouter\n 3) openai\n 4) anthropic' }]);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const updates = { MODEL_PROVIDER: entry.name };
|
|
949
|
+
updates[entry.key] = '';
|
|
950
|
+
updates[entry.url] = entry.name === 'openrouter' ? 'https://openrouter.ai/api' : `https://api.${entry.name}.com/v1`;
|
|
951
|
+
settingsMgr.current.update(updates);
|
|
952
|
+
if (entry.name === 'local') {
|
|
953
|
+
wizardRef.current = { phase: 'apiKey', provider: entry.name };
|
|
954
|
+
pushToChannel(targetChannel, `${entry.name}: Enter your API key:`);
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
wizardRef.current = { phase: 'apiKey', provider: entry.name };
|
|
958
|
+
pushToChannel(targetChannel, `${entry.name}: Enter your API key:`);
|
|
959
|
+
}
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (wizard.phase === 'apiKey') {
|
|
963
|
+
const keyName = wizard.provider === 'local' ? 'MODEL_API_KEY'
|
|
964
|
+
: wizard.provider === 'openrouter' ? 'OPENROUTER_API_KEY'
|
|
965
|
+
: wizard.provider === 'openai' ? 'OPENAI_API_KEY'
|
|
966
|
+
: 'ANTHROPIC_API_KEY';
|
|
967
|
+
settingsMgr.current.update({ [keyName]: input });
|
|
968
|
+
if (wizard.provider === 'local') {
|
|
969
|
+
wizardRef.current = { phase: 'url', provider: wizard.provider };
|
|
970
|
+
pushToChannel(targetChannel, `API key configured.\nWhat is your local API URL?\n Default: http://localhost:8080/v1\n (Press Enter for default):`);
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
wizardRef.current = { phase: 'model', provider: wizard.provider };
|
|
974
|
+
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:`);
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (wizard.phase === 'url') {
|
|
979
|
+
const url = input || 'http://localhost:8080/v1';
|
|
980
|
+
settingsMgr.current.update({ MODEL_URL: url });
|
|
981
|
+
wizardRef.current = { phase: 'model', provider: wizard.provider };
|
|
982
|
+
pushToChannel(targetChannel, `URL configured: ${url}\nWhich model do you want to use?\n Examples: claude-sonnet-4-6, gpt-4o, o1, llama-3.1-405b\n Enter model name:`);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (wizard.phase === 'model') {
|
|
986
|
+
const model = input || 'claude-sonnet-4-6';
|
|
987
|
+
settingsMgr.current.update({ model });
|
|
988
|
+
const settings = settingsMgr.current.get();
|
|
989
|
+
streamProviderHolder.current = createProvider(settings);
|
|
990
|
+
loopRef.current = null;
|
|
991
|
+
setCurrentModel(model);
|
|
992
|
+
setCurrentProvider(streamProviderHolder.current.name);
|
|
993
|
+
wizardRef.current = { phase: 'idle' };
|
|
994
|
+
pushToChannel(targetChannel, `Configuration complete!\n Provider: ${wizard.provider || 'anthropic'}\n Model: ${model}\n\nWriting workspace files...`);
|
|
995
|
+
// Copy template files as-is to ~/.hopper-agent/
|
|
996
|
+
(async () => {
|
|
997
|
+
try {
|
|
998
|
+
const hopperDir = join(homedir(), '.hopper-agent');
|
|
999
|
+
if (!existsSync(hopperDir)) {
|
|
1000
|
+
mkdirSync(hopperDir, { recursive: true });
|
|
1001
|
+
}
|
|
1002
|
+
const memoryDir = join(hopperDir, 'memory');
|
|
1003
|
+
if (!existsSync(memoryDir)) {
|
|
1004
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
1005
|
+
}
|
|
1006
|
+
for (const filename of TEMPLATE_FILES) {
|
|
1007
|
+
const templatePath = join(TEMPLATES_DIR, filename);
|
|
1008
|
+
if (!existsSync(templatePath)) {
|
|
1009
|
+
pushToChannel(targetChannel, ` Skipped ${filename} (no template)`);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
const content = readFileSync(templatePath, 'utf-8');
|
|
1013
|
+
writeFileSync(join(hopperDir, filename), content, 'utf-8');
|
|
1014
|
+
pushToChannel(targetChannel, ` Wrote ${filename}`);
|
|
1015
|
+
}
|
|
1016
|
+
pushToChannel(targetChannel, '\nReady! Type a message to get started.');
|
|
1017
|
+
// Reload system prompt so the TurnLoop picks up the new AGENTS.md
|
|
1018
|
+
setCurrentSystem(loadAgentPrompt(currentCwd));
|
|
1019
|
+
}
|
|
1020
|
+
catch (err) {
|
|
1021
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1022
|
+
pushToChannel(targetChannel, `Failed to write files: ${msg}`);
|
|
1023
|
+
}
|
|
1024
|
+
})();
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (currentDebug) {
|
|
1029
|
+
console.error(`[debug] prompt: ${text}, channel: ${targetChannel}`);
|
|
1030
|
+
}
|
|
1031
|
+
// Get or create TurnLoop for the target channel
|
|
1032
|
+
const channelTurnLoops = channelTurnLoopsRef.current;
|
|
1033
|
+
let loop = channelTurnLoops.get(targetChannel);
|
|
1034
|
+
if (!loop) {
|
|
1035
|
+
// Reconnect MCP tools before building TurnLoop so we pick up
|
|
1036
|
+
// any changes made via /mcp add/remove from another channel.
|
|
1037
|
+
if (onMcpReconnect) {
|
|
1038
|
+
try {
|
|
1039
|
+
await onMcpReconnect();
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
/* reconnect failure is non-fatal */
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Try to resume an existing session for this channel
|
|
1046
|
+
const channel = channelRegistry.get(targetChannel);
|
|
1047
|
+
loop = new TurnLoop({
|
|
1048
|
+
provider: streamProviderHolder.current,
|
|
1049
|
+
model: currentModel,
|
|
1050
|
+
tools: [...allTools, ...mcpToolsRef.current],
|
|
1051
|
+
cwd: currentCwd,
|
|
1052
|
+
settings: settingsMgr.current.get(),
|
|
1053
|
+
approvalMode: currentMode,
|
|
1054
|
+
effort: currentEffort,
|
|
1055
|
+
system: currentSystem + buildModePreamble(currentMode, currentCwd),
|
|
1056
|
+
sessionId: channel?.sessionId,
|
|
1057
|
+
onApprovalAsk: (req) => new Promise((resolve) => {
|
|
1058
|
+
setPendingApproval({
|
|
1059
|
+
toolName: req.name,
|
|
1060
|
+
input: req.input,
|
|
1061
|
+
reason: req.reason,
|
|
1062
|
+
resolve,
|
|
1063
|
+
});
|
|
1064
|
+
// Also send Telegram approval request if this is a Telegram channel
|
|
1065
|
+
const telegramChatId = channelRouter.getTelegramChatId(targetChannel);
|
|
1066
|
+
if (telegramChatId) {
|
|
1067
|
+
const toolCallId = pendingToolCallIdRef.current;
|
|
1068
|
+
if (toolCallId) {
|
|
1069
|
+
pendingApprovalsRef.current.set(toolCallId, { resolve });
|
|
1070
|
+
channelRouter.sendTelegramApproval(telegramChatId, req.name, req.input, toolCallId);
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
// Fallback: generate a key so resolve still works
|
|
1074
|
+
const fallbackId = `tg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1075
|
+
pendingApprovalsRef.current.set(fallbackId, { resolve });
|
|
1076
|
+
channelRouter.sendTelegramApproval(telegramChatId, req.name, req.input, fallbackId);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}),
|
|
1080
|
+
});
|
|
1081
|
+
channelTurnLoops.set(targetChannel, loop);
|
|
1082
|
+
}
|
|
1083
|
+
const channelMsgs = channelMessages[targetChannel] || [];
|
|
1084
|
+
setChannelMessages((prev) => ({
|
|
1085
|
+
...prev,
|
|
1086
|
+
[targetChannel]: [...channelMsgs, { role: 'user', content: text }],
|
|
1087
|
+
}));
|
|
1088
|
+
// Also sync the legacy messages for backward compat
|
|
1089
|
+
setMessages((prev) => [...prev, { role: 'user', content: text }]);
|
|
1090
|
+
busyRef.current = true;
|
|
1091
|
+
setStatus('working');
|
|
1092
|
+
let assistantText = '';
|
|
1093
|
+
toolGroupIndexRef.current = null;
|
|
1094
|
+
const unsubs = [
|
|
1095
|
+
loop.eventBus.subscribe('assistant-delta', (e) => {
|
|
1096
|
+
if (e.type !== 'assistant-delta')
|
|
1097
|
+
return;
|
|
1098
|
+
assistantText += e.text;
|
|
1099
|
+
setChannelMessages((prev) => {
|
|
1100
|
+
const chMsgs = prev[targetChannel] || [];
|
|
1101
|
+
// Seal the tool-group: replace its content with a compact "[N tools]" summary.
|
|
1102
|
+
let base = chMsgs;
|
|
1103
|
+
const gi = toolGroupIndexRef.current;
|
|
1104
|
+
if (gi !== null && base[gi]?.role === 'tool-group') {
|
|
1105
|
+
const names = base[gi].content.split(' · ');
|
|
1106
|
+
const count = names.length;
|
|
1107
|
+
const summary = count <= 3
|
|
1108
|
+
? `[${names.join(' · ')}]`
|
|
1109
|
+
: `[${count} tools: ${names.slice(0, 2).join(', ')}, …]`;
|
|
1110
|
+
base = [...base.slice(0, gi), { role: 'tool-group', content: summary }, ...base.slice(gi + 1)];
|
|
1111
|
+
toolGroupIndexRef.current = null;
|
|
1112
|
+
}
|
|
1113
|
+
const last = base[base.length - 1];
|
|
1114
|
+
if (last && last.role === 'assistant') {
|
|
1115
|
+
return { ...prev, [targetChannel]: [...base.slice(0, -1), { role: 'assistant', content: assistantText }] };
|
|
1116
|
+
}
|
|
1117
|
+
return { ...prev, [targetChannel]: [...base, { role: 'assistant', content: assistantText }] };
|
|
1118
|
+
});
|
|
1119
|
+
// Also sync to legacy messages
|
|
1120
|
+
setMessages((prev) => {
|
|
1121
|
+
// Seal the tool-group
|
|
1122
|
+
let base = prev;
|
|
1123
|
+
const gi = toolGroupIndexRef.current;
|
|
1124
|
+
if (gi !== null && base[gi]?.role === 'tool-group') {
|
|
1125
|
+
const names = base[gi].content.split(' · ');
|
|
1126
|
+
const count = names.length;
|
|
1127
|
+
const summary = count <= 3
|
|
1128
|
+
? `[${names.join(' · ')}]`
|
|
1129
|
+
: `[${count} tools: ${names.slice(0, 2).join(', ')}, …]`;
|
|
1130
|
+
base = [...base.slice(0, gi), { role: 'tool-group', content: summary }, ...base.slice(gi + 1)];
|
|
1131
|
+
toolGroupIndexRef.current = null;
|
|
1132
|
+
}
|
|
1133
|
+
const last = base[base.length - 1];
|
|
1134
|
+
if (last && last.role === 'assistant') {
|
|
1135
|
+
return [...base.slice(0, -1), { role: 'assistant', content: assistantText }];
|
|
1136
|
+
}
|
|
1137
|
+
return [...base, { role: 'assistant', content: assistantText }];
|
|
1138
|
+
});
|
|
1139
|
+
}),
|
|
1140
|
+
loop.eventBus.subscribe('session-start', (e) => {
|
|
1141
|
+
if (e.type === 'session-start') {
|
|
1142
|
+
sessionIdRef.current = e.id;
|
|
1143
|
+
globalThis.__hopperSessionId = e.id;
|
|
1144
|
+
}
|
|
1145
|
+
}),
|
|
1146
|
+
loop.eventBus.subscribe('usage', (e) => {
|
|
1147
|
+
if (e.type === 'usage') {
|
|
1148
|
+
setInputTokens(e.inputTokens);
|
|
1149
|
+
setOutputTokens(e.outputTokens);
|
|
1150
|
+
}
|
|
1151
|
+
}),
|
|
1152
|
+
loop.eventBus.subscribe('tool-call', (e) => {
|
|
1153
|
+
if (e.type !== 'tool-call')
|
|
1154
|
+
return;
|
|
1155
|
+
// Extract a human-readable detail from the tool input.
|
|
1156
|
+
const detail = (() => {
|
|
1157
|
+
const data = e.input;
|
|
1158
|
+
if (data.path && typeof data.path === 'string')
|
|
1159
|
+
return data.path;
|
|
1160
|
+
if (data.file_path && typeof data.file_path === 'string')
|
|
1161
|
+
return data.file_path;
|
|
1162
|
+
if (data.path && typeof data.path === 'number')
|
|
1163
|
+
return String(data.path);
|
|
1164
|
+
if (data.query && typeof data.query === 'string')
|
|
1165
|
+
return data.query;
|
|
1166
|
+
if (data.url && typeof data.url === 'string')
|
|
1167
|
+
return data.url;
|
|
1168
|
+
if (data.text && typeof data.text === 'string')
|
|
1169
|
+
return data.text;
|
|
1170
|
+
if (data.command && typeof data.command === 'string')
|
|
1171
|
+
return data.command;
|
|
1172
|
+
if (data.prompt && typeof data.prompt === 'string')
|
|
1173
|
+
return data.prompt.slice(0, 80);
|
|
1174
|
+
if (Object.keys(data).length > 0) {
|
|
1175
|
+
return Object.entries(data).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ');
|
|
1176
|
+
}
|
|
1177
|
+
return '';
|
|
1178
|
+
})();
|
|
1179
|
+
const display = detail ? `${e.name}: ${detail}` : e.name;
|
|
1180
|
+
// Update both per-channel and legacy messages
|
|
1181
|
+
const updateToolCall = (msgs) => {
|
|
1182
|
+
const gi = toolGroupIndexRef.current;
|
|
1183
|
+
if (gi !== null && msgs[gi]?.role === 'tool-group') {
|
|
1184
|
+
const updated = { ...msgs[gi], content: `${msgs[gi].content} · ${display}` };
|
|
1185
|
+
return [...msgs.slice(0, gi), updated, ...msgs.slice(gi + 1)];
|
|
1186
|
+
}
|
|
1187
|
+
toolGroupIndexRef.current = msgs.length;
|
|
1188
|
+
return [...msgs, { role: 'tool-group', content: display }];
|
|
1189
|
+
};
|
|
1190
|
+
setChannelMessages((prev) => {
|
|
1191
|
+
const chMsgs = prev[targetChannel] || [];
|
|
1192
|
+
return { ...prev, [targetChannel]: updateToolCall(chMsgs) };
|
|
1193
|
+
});
|
|
1194
|
+
setMessages((prev) => updateToolCall(prev));
|
|
1195
|
+
}),
|
|
1196
|
+
loop.eventBus.subscribe('tool-result', (e) => {
|
|
1197
|
+
if (e.type !== 'tool-result')
|
|
1198
|
+
return;
|
|
1199
|
+
if (e.error) {
|
|
1200
|
+
const resultMsg = { role: 'tool', content: `Error: ${e.error}` };
|
|
1201
|
+
setChannelMessages((prev) => ({ ...prev, [targetChannel]: [...(prev[targetChannel] || []), resultMsg] }));
|
|
1202
|
+
setMessages((prev) => [...prev, resultMsg]);
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
// Detect plan file creation: Write result referencing plans/*.md while in plan mode.
|
|
1206
|
+
if (currentMode === 'plan' && e.output && typeof e.output === 'object') {
|
|
1207
|
+
const path = e.output.path;
|
|
1208
|
+
if (typeof path === 'string' && /plans[\\/][^\\/]+\.md$/i.test(path)) {
|
|
1209
|
+
planFileRef.current = path;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}),
|
|
1213
|
+
loop.eventBus.subscribe('error', (e) => {
|
|
1214
|
+
if (e.type === 'error') {
|
|
1215
|
+
const errMsg = { role: 'tool', content: 'Error: ' + e.message };
|
|
1216
|
+
setChannelMessages((prev) => ({ ...prev, [targetChannel]: [...(prev[targetChannel] || []), errMsg] }));
|
|
1217
|
+
setMessages((prev) => [...prev, errMsg]);
|
|
1218
|
+
}
|
|
1219
|
+
}),
|
|
1220
|
+
loop.eventBus.subscribe('approval-request', (e) => {
|
|
1221
|
+
if (e.type === 'approval-request' && e.decision === 'ask') {
|
|
1222
|
+
pendingToolCallIdRef.current = e.toolCallId;
|
|
1223
|
+
}
|
|
1224
|
+
}),
|
|
1225
|
+
];
|
|
1226
|
+
let runResult;
|
|
1227
|
+
try {
|
|
1228
|
+
runResult = await loop.run(text);
|
|
1229
|
+
}
|
|
1230
|
+
finally {
|
|
1231
|
+
// Update channel registry with the real session ID (handles placeholder → real session)
|
|
1232
|
+
if (runResult) {
|
|
1233
|
+
channelRegistry.updateSession(targetChannel, runResult.sessionId);
|
|
1234
|
+
}
|
|
1235
|
+
unsubs.forEach(u => u());
|
|
1236
|
+
toolGroupIndexRef.current = null;
|
|
1237
|
+
busyRef.current = false;
|
|
1238
|
+
setStatus('idle');
|
|
1239
|
+
// Route response back to Telegram if this is a Telegram channel
|
|
1240
|
+
const telegramChatId = channelRouter.getTelegramChatId(targetChannel);
|
|
1241
|
+
if (telegramChatId && assistantText) {
|
|
1242
|
+
const text = assistantText.length > 4096 ? assistantText.slice(0, 4096) : assistantText;
|
|
1243
|
+
await channelRouter.sendTelegramResponse(telegramChatId, text).catch(console.error);
|
|
1244
|
+
telegramGateway?.stopTyping(telegramChatId);
|
|
1245
|
+
}
|
|
1246
|
+
else {
|
|
1247
|
+
// Fallback: legacy Telegram routing for main channel
|
|
1248
|
+
const chatId = telegramChatIdRef.current;
|
|
1249
|
+
if (chatId && assistantText) {
|
|
1250
|
+
const text = assistantText.length > 4096 ? assistantText.slice(0, 4096) : assistantText;
|
|
1251
|
+
telegramGateway?.sendMessage(chatId, text).catch(console.error);
|
|
1252
|
+
telegramGateway?.stopTyping(chatId);
|
|
1253
|
+
}
|
|
1254
|
+
telegramChatIdRef.current = null;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
// Post-turn: if a plan file was just written, auto-switch to `auto` mode
|
|
1258
|
+
// and tell the user so they can approve by sending the next message.
|
|
1259
|
+
if (planFileRef.current) {
|
|
1260
|
+
const pf = planFileRef.current;
|
|
1261
|
+
planFileRef.current = null;
|
|
1262
|
+
const planMsg = {
|
|
1263
|
+
role: 'system',
|
|
1264
|
+
content: `Plan written to ${pf}. Switching to auto mode — send any message to execute, or /mode plan to keep planning.`,
|
|
1265
|
+
};
|
|
1266
|
+
setChannelMessages((prev) => ({
|
|
1267
|
+
...prev,
|
|
1268
|
+
[targetChannel]: [...(prev[targetChannel] || []), planMsg],
|
|
1269
|
+
}));
|
|
1270
|
+
setMessages(prev => [...prev, planMsg]);
|
|
1271
|
+
setCurrentMode('auto');
|
|
1272
|
+
settingsMgr.current.update({ mode: 'auto' });
|
|
1273
|
+
}
|
|
1274
|
+
}, [streamProviderHolder, currentModel, currentMode, currentCwd, currentSystem, currentDebug, currentEffort, activeChannelId, channelMessages, channelRegistry, channelRouter]);
|
|
1275
|
+
// Wire submit ref so gateway can inject Telegram messages into the turn loop
|
|
1276
|
+
useEffect(() => {
|
|
1277
|
+
telegramSubmitRef.current = onSubmit;
|
|
1278
|
+
}, [onSubmit, telegramSubmitRef]);
|
|
1279
|
+
const onBashCommand = useCallback((command) => {
|
|
1280
|
+
if (busyRef.current)
|
|
1281
|
+
return;
|
|
1282
|
+
busyRef.current = true;
|
|
1283
|
+
setStatus('running shell');
|
|
1284
|
+
setMessages(prev => [...prev, { role: 'user', content: `! ${command}` }]);
|
|
1285
|
+
// Handle `cd` (and `chdir`) ourselves so directory changes persist across
|
|
1286
|
+
// subsequent `!` invocations. Match: cd, cd <arg>, cd "a b"/'a b', chdir.
|
|
1287
|
+
const cdMatch = command.trim().match(/^(?:cd|chdir)(?:\s+(.+))?\s*$/i);
|
|
1288
|
+
if (cdMatch) {
|
|
1289
|
+
const rawArg = (cdMatch[1] ?? '').trim();
|
|
1290
|
+
const stripped = rawArg
|
|
1291
|
+
.replace(/^"(.*)"$/, '$1')
|
|
1292
|
+
.replace(/^'(.*)'$/, '$1');
|
|
1293
|
+
const target = stripped.length === 0
|
|
1294
|
+
? homedir()
|
|
1295
|
+
: stripped === '-'
|
|
1296
|
+
? shellCwdRef.current
|
|
1297
|
+
: stripped.startsWith('~')
|
|
1298
|
+
? resolve(homedir(), stripped.slice(1).replace(/^[\\/]/, ''))
|
|
1299
|
+
: isAbsolute(stripped)
|
|
1300
|
+
? stripped
|
|
1301
|
+
: resolve(shellCwdRef.current, stripped);
|
|
1302
|
+
let content;
|
|
1303
|
+
if (!existsSync(target)) {
|
|
1304
|
+
content = `$ ${command}\ncd: no such directory: ${target}\n[exit 1]`;
|
|
1305
|
+
}
|
|
1306
|
+
else if (!statSync(target).isDirectory()) {
|
|
1307
|
+
content = `$ ${command}\ncd: not a directory: ${target}\n[exit 1]`;
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
shellCwdRef.current = target;
|
|
1311
|
+
content = `$ ${command}\n${target}\n[exit 0]`;
|
|
1312
|
+
}
|
|
1313
|
+
setMessages(prev => [...prev, { role: 'tool', content }]);
|
|
1314
|
+
busyRef.current = false;
|
|
1315
|
+
setStatus('idle');
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const isWin = process.platform === 'win32';
|
|
1319
|
+
const shell = isWin ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
|
|
1320
|
+
const shellArgs = isWin ? ['/c', command] : ['-c', command];
|
|
1321
|
+
const child = spawn(shell, shellArgs, {
|
|
1322
|
+
cwd: shellCwdRef.current,
|
|
1323
|
+
env: process.env,
|
|
1324
|
+
});
|
|
1325
|
+
shellChildRef.current = child;
|
|
1326
|
+
let stdout = '';
|
|
1327
|
+
let stderr = '';
|
|
1328
|
+
child.stdout?.on('data', d => { stdout += d.toString(); });
|
|
1329
|
+
child.stderr?.on('data', d => { stderr += d.toString(); });
|
|
1330
|
+
child.on('close', code => {
|
|
1331
|
+
const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join('\n');
|
|
1332
|
+
const content = output.length
|
|
1333
|
+
? `$ ${command}\n${output}\n[exit ${code ?? 0}]`
|
|
1334
|
+
: `$ ${command}\n[exit ${code ?? 0}]`;
|
|
1335
|
+
setMessages(prev => [...prev, { role: 'tool', content }]);
|
|
1336
|
+
shellChildRef.current = null;
|
|
1337
|
+
busyRef.current = false;
|
|
1338
|
+
setStatus('idle');
|
|
1339
|
+
});
|
|
1340
|
+
child.on('error', err => {
|
|
1341
|
+
setMessages(prev => [
|
|
1342
|
+
...prev,
|
|
1343
|
+
{ role: 'tool', content: `$ ${command}\nError: ${err.message}` },
|
|
1344
|
+
]);
|
|
1345
|
+
shellChildRef.current = null;
|
|
1346
|
+
busyRef.current = false;
|
|
1347
|
+
setStatus('idle');
|
|
1348
|
+
});
|
|
1349
|
+
}, []);
|
|
1350
|
+
const onEffortChange = useCallback((effort) => {
|
|
1351
|
+
setCurrentEffort(effort);
|
|
1352
|
+
settingsMgr.current.update({ effort: effort });
|
|
1353
|
+
loopRef.current = null;
|
|
1354
|
+
const usr = { role: 'user', content: `/effort ${effort}` };
|
|
1355
|
+
const asst = { role: 'assistant', content: `Effort set to ${effort}.` };
|
|
1356
|
+
setChannelMessages((prev) => {
|
|
1357
|
+
const ch = prev[activeChannelId] || [];
|
|
1358
|
+
return { ...prev, [activeChannelId]: [...ch, usr, asst] };
|
|
1359
|
+
});
|
|
1360
|
+
setMessages(prev => [...prev, usr, asst]);
|
|
1361
|
+
}, []);
|
|
1362
|
+
const onModeChange = useCallback((mode) => {
|
|
1363
|
+
setCurrentMode(mode);
|
|
1364
|
+
settingsMgr.current.update({ mode: mode });
|
|
1365
|
+
// Rebuild the TurnLoop on next turn so the permission engine picks up
|
|
1366
|
+
// the new approval mode.
|
|
1367
|
+
loopRef.current = null;
|
|
1368
|
+
const usr = { role: 'user', content: `/mode ${mode}` };
|
|
1369
|
+
const asst = { role: 'assistant', content: `Mode set to ${mode}.` };
|
|
1370
|
+
setChannelMessages((prev) => {
|
|
1371
|
+
const ch = prev[activeChannelId] || [];
|
|
1372
|
+
return { ...prev, [activeChannelId]: [...ch, usr, asst] };
|
|
1373
|
+
});
|
|
1374
|
+
setMessages(prev => [...prev, usr, asst]);
|
|
1375
|
+
}, []);
|
|
1376
|
+
const onInterrupt = useCallback(() => {
|
|
1377
|
+
let interrupted = false;
|
|
1378
|
+
if (loopRef.current && busyRef.current) {
|
|
1379
|
+
loopRef.current.cancel();
|
|
1380
|
+
interrupted = true;
|
|
1381
|
+
}
|
|
1382
|
+
if (shellChildRef.current) {
|
|
1383
|
+
try {
|
|
1384
|
+
shellChildRef.current.kill('SIGTERM');
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
// ignore — child may have already exited
|
|
1388
|
+
}
|
|
1389
|
+
interrupted = true;
|
|
1390
|
+
}
|
|
1391
|
+
if (interrupted) {
|
|
1392
|
+
setMessages(prev => [...prev, { role: 'system', content: '[interrupted]' }]);
|
|
1393
|
+
}
|
|
1394
|
+
}, []);
|
|
1395
|
+
const onApprovalDecision = useCallback((decision) => {
|
|
1396
|
+
setPendingApproval(prev => {
|
|
1397
|
+
if (prev)
|
|
1398
|
+
prev.resolve(decision === 'allow');
|
|
1399
|
+
return null;
|
|
1400
|
+
});
|
|
1401
|
+
// Also clean up any stale Telegram pending approvals for this tool call
|
|
1402
|
+
const currentToolCallId = pendingToolCallIdRef.current;
|
|
1403
|
+
if (currentToolCallId) {
|
|
1404
|
+
pendingApprovalsRef.current.delete(currentToolCallId);
|
|
1405
|
+
}
|
|
1406
|
+
}, []);
|
|
1407
|
+
const onSelectProject = useCallback((p) => {
|
|
1408
|
+
if (!existsSync(p.projectPath) || !statSync(p.projectPath).isDirectory()) {
|
|
1409
|
+
setMessages(prev => [...prev, { role: 'system', content: `Cannot switch to project: directory not found (${p.projectPath})` }]);
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
setCurrentCwd(p.projectPath);
|
|
1413
|
+
shellCwdRef.current = p.projectPath;
|
|
1414
|
+
setCurrentSystem(loadAgentPrompt(p.projectPath));
|
|
1415
|
+
loopRef.current = null;
|
|
1416
|
+
setMessages(prev => [...prev, { role: 'system', content: `Active project switched to ${p.label} (${p.projectPath})` }]);
|
|
1417
|
+
}, []);
|
|
1418
|
+
const project = basename(currentCwd);
|
|
1419
|
+
const cost = estimateCost(currentModel, inputTokens ?? 0, outputTokens ?? 0);
|
|
1420
|
+
// Build channel entries for the Channels tab
|
|
1421
|
+
const channelList = channelRegistry.list();
|
|
1422
|
+
const channelTabEntries = channelList.map(ch => ({
|
|
1423
|
+
id: ch.id,
|
|
1424
|
+
type: ch.type,
|
|
1425
|
+
identifier: ch.identifier,
|
|
1426
|
+
displayName: ch.displayName,
|
|
1427
|
+
sessionId: ch.sessionId,
|
|
1428
|
+
messageCount: (channelMessages[ch.id] || []).length,
|
|
1429
|
+
isActive: ch.id === activeChannelId,
|
|
1430
|
+
}));
|
|
1431
|
+
return (_jsx(ChatSurface, { messages: channelMessages[activeChannelId] || [], model: currentModel, provider: currentProvider, approvalMode: currentMode, effort: currentEffort, inputTokens: inputTokens, outputTokens: outputTokens, contextWindowSize: contextWindowSize, project: project, duration: duration, costUsd: cost, activeTab: currentTab, status: status, contextMode: "CodeContext Zen", agent: currentModel, onSubmit: onSubmit, onSlashCommand: onSlashCommand, onBashCommand: onBashCommand, onInterrupt: onInterrupt, onEffortChange: onEffortChange, onModeChange: onModeChange, onCancel: () => process.exit(0), theme: theme, debug: currentDebug, projects: projects, agents: agents, channels: channelTabEntries, onChannelSelect: (channelId) => {
|
|
1432
|
+
setActiveChannelId(channelId);
|
|
1433
|
+
settingsMgr.current.update({ CHANNEL_TAB_ACTIVE: channelId });
|
|
1434
|
+
setCurrentTab('assistant');
|
|
1435
|
+
}, pendingApproval: pendingApproval ? {
|
|
1436
|
+
toolName: pendingApproval.toolName,
|
|
1437
|
+
input: pendingApproval.input,
|
|
1438
|
+
reason: pendingApproval.reason,
|
|
1439
|
+
} : null, onApprovalDecision: onApprovalDecision, onSelectProject: onSelectProject }));
|
|
1440
|
+
}
|
|
1441
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1442
|
+
function createProvider(settings) {
|
|
1443
|
+
const providerName = typeof settings.MODEL_PROVIDER === 'string'
|
|
1444
|
+
? settings.MODEL_PROVIDER.trim().toLowerCase()
|
|
1445
|
+
: 'anthropic';
|
|
1446
|
+
if (providerName === 'openai') {
|
|
1447
|
+
const openaiKey = (typeof settings.OPENAI_API_KEY === 'string' ? settings.OPENAI_API_KEY.trim() : '') ||
|
|
1448
|
+
process.env.OPENAI_API_KEY ||
|
|
1449
|
+
'';
|
|
1450
|
+
const openaiUrl = (typeof settings.OPENAI_URL === 'string' ? settings.OPENAI_URL.trim() : '') ||
|
|
1451
|
+
process.env.OPENAI_BASE_URL ||
|
|
1452
|
+
'';
|
|
1453
|
+
if (!openaiKey) {
|
|
1454
|
+
console.error('hopper-agent: no OpenAI API key configured.\n' +
|
|
1455
|
+
' Run `hopper-agent` and use `/init` to configure, or set "OPENAI_API_KEY" in ~/.hopper-agent/settings.json.');
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
const p = new OpenAIProvider(openaiKey, openaiUrl || undefined);
|
|
1459
|
+
return { name: p.name, stream: p.stream.bind(p), check: p.check.bind(p) };
|
|
1460
|
+
}
|
|
1461
|
+
if (providerName === 'openrouter') {
|
|
1462
|
+
const orKey = (typeof settings.OPENROUTER_API_KEY === 'string' ? settings.OPENROUTER_API_KEY.trim() : '') ||
|
|
1463
|
+
process.env.OPENROUTER_API_KEY ||
|
|
1464
|
+
'';
|
|
1465
|
+
const orUrl = (typeof settings.OPENROUTER_URL === 'string' ? settings.OPENROUTER_URL.trim() : '') ||
|
|
1466
|
+
process.env.OPENROUTER_URL ||
|
|
1467
|
+
'https://openrouter.ai/api/v1';
|
|
1468
|
+
if (!orKey) {
|
|
1469
|
+
console.error('hopper-agent: no OpenRouter API key configured.\n' +
|
|
1470
|
+
' Run `hopper-agent` and use `/init` to configure, or set "OPENROUTER_API_KEY" in ~/.hopper-agent/settings.json.');
|
|
1471
|
+
return null;
|
|
1472
|
+
}
|
|
1473
|
+
const p = new AnthropicProvider(orKey, orUrl);
|
|
1474
|
+
return { name: 'openrouter', stream: p.stream.bind(p), check: p.check.bind(p) };
|
|
1475
|
+
}
|
|
1476
|
+
if (providerName === 'local') {
|
|
1477
|
+
const localKey = (typeof settings.MODEL_API_KEY === 'string' ? settings.MODEL_API_KEY.trim() : '') ||
|
|
1478
|
+
process.env.MODEL_API_KEY ||
|
|
1479
|
+
'';
|
|
1480
|
+
const localUrl = (typeof settings.MODEL_URL === 'string' ? settings.MODEL_URL.trim() : '') ||
|
|
1481
|
+
process.env.MODEL_URL ||
|
|
1482
|
+
'';
|
|
1483
|
+
if (!localKey && !localUrl) {
|
|
1484
|
+
console.error('hopper-agent: no local provider config.\n' +
|
|
1485
|
+
' Run `hopper-agent` and use `/init` to configure, or set "MODEL_API_KEY" and "MODEL_URL" in ~/.hopper-agent/settings.json.');
|
|
1486
|
+
return null;
|
|
1487
|
+
}
|
|
1488
|
+
const p = new AnthropicProvider(localKey || undefined, localUrl || undefined, { local: true });
|
|
1489
|
+
return { name: 'local', stream: p.stream.bind(p), check: p.check.bind(p) };
|
|
1490
|
+
}
|
|
1491
|
+
// anthropic (default)
|
|
1492
|
+
const fromSettingsKey = typeof settings.MODEL_API_KEY === 'string' ? settings.MODEL_API_KEY.trim() : '';
|
|
1493
|
+
const fromSettingsUrl = typeof settings.MODEL_URL === 'string' ? settings.MODEL_URL.trim() : '';
|
|
1494
|
+
const apiKey = fromSettingsKey || process.env.ANTHROPIC_API_KEY || '';
|
|
1495
|
+
const baseUrl = fromSettingsUrl || process.env.ANTHROPIC_BASE_URL || '';
|
|
1496
|
+
if (!apiKey) {
|
|
1497
|
+
console.error('hopper-agent: no API key configured.\n' +
|
|
1498
|
+
` Looked at ~/.hopper-agent/settings.json (MODEL_API_KEY = ${fromSettingsKey ? '[set]' : '[missing/empty]'}),\n` +
|
|
1499
|
+
` 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 "MODEL_API_KEY" in ~/.hopper-agent/settings.json.');
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1503
|
+
const p = new AnthropicProvider(apiKey, baseUrl || undefined);
|
|
1504
|
+
return { name: p.name, stream: p.stream.bind(p), check: p.check.bind(p) };
|
|
1505
|
+
}
|
|
1506
|
+
async function main() {
|
|
1507
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1508
|
+
const cwd = args.cwd || process.cwd();
|
|
1509
|
+
if (args.version) {
|
|
1510
|
+
console.log(`hopper-agent ${VERSION}`);
|
|
1511
|
+
process.exit(0);
|
|
1512
|
+
}
|
|
1513
|
+
if (args.help) {
|
|
1514
|
+
printHelp();
|
|
1515
|
+
process.exit(0);
|
|
1516
|
+
}
|
|
1517
|
+
// Load persisted settings
|
|
1518
|
+
const settingsManager = new SettingsManager();
|
|
1519
|
+
const settings = settingsManager.load();
|
|
1520
|
+
// CLI flags override settings, settings override defaults
|
|
1521
|
+
const model = args.model || settings.model || DEFAULT_SETTINGS.model;
|
|
1522
|
+
const themeName = settings.theme || DEFAULT_SETTINGS.theme;
|
|
1523
|
+
const approvalMode = args.approvalMode || settings.mode || DEFAULT_SETTINGS.mode;
|
|
1524
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1525
|
+
const streamProviderHolder = { current: null };
|
|
1526
|
+
streamProviderHolder.current = createProvider(settings);
|
|
1527
|
+
const system = loadAgentPrompt(cwd);
|
|
1528
|
+
// Initialize cron manager for reminders
|
|
1529
|
+
const cronManager = new CronManager();
|
|
1530
|
+
// Auto-create unified heartbeat task if HEARTBEAT=on
|
|
1531
|
+
const hbSettings = cronManager.load();
|
|
1532
|
+
const s = settingsManager.load();
|
|
1533
|
+
if (s.HEARTBEAT === 'on' && !hbSettings.tasks.some((t) => t.type === 'heartbeat')) {
|
|
1534
|
+
const picked = pickNextSchedule({
|
|
1535
|
+
HEARTBEAT_TIMES: s.HEARTBEAT_TIMES,
|
|
1536
|
+
HEARTBEAT_DAILY: s.HEARTBEAT_DAILY,
|
|
1537
|
+
HEARTBEAT_WEEKLY: s.HEARTBEAT_WEEKLY,
|
|
1538
|
+
HEARTBEAT_MONTHLY: s.HEARTBEAT_MONTHLY,
|
|
1539
|
+
}, Date.now());
|
|
1540
|
+
if (picked) {
|
|
1541
|
+
cronManager.createHeartbeat('Heartbeat: unified schedule', picked);
|
|
1542
|
+
console.error(`[cron] Created unified heartbeat task (${picked.type}: ${picked.value})`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
else if (hbSettings.tasks.some((t) => t.type === 'heartbeat')) {
|
|
1546
|
+
// Consolidate: if multiple pending heartbeat tasks exist, cancel duplicates
|
|
1547
|
+
const hbTasks = hbSettings.tasks.filter((t) => t.type === 'heartbeat' && t.status === 'pending');
|
|
1548
|
+
if (hbTasks.length > 1) {
|
|
1549
|
+
hbTasks.sort((a, b) => a.scheduledAt - b.scheduledAt);
|
|
1550
|
+
for (const dup of hbTasks.slice(1)) {
|
|
1551
|
+
dup.status = 'cancelled';
|
|
1552
|
+
console.error('[cron] Consolidated duplicate heartbeat task');
|
|
1553
|
+
}
|
|
1554
|
+
cronManager.save();
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
// Mutable holder for the reminder notification callback.
|
|
1558
|
+
// Created outside the component since hooks can't be called in main().
|
|
1559
|
+
const onReminderHolder = { current: null };
|
|
1560
|
+
// Ref to the submit function — the Telegram gateway uses this to inject messages into the turn loop
|
|
1561
|
+
const telegramSubmitRef = { current: null };
|
|
1562
|
+
// Ref to store the chat ID of the current Telegram conversation (set by gateway callback)
|
|
1563
|
+
const telegramChatIdRef = { current: null };
|
|
1564
|
+
// Ref to track the active channel for multi-channel support
|
|
1565
|
+
const activeChannelRef = { current: null };
|
|
1566
|
+
// Channel management for multi-channel conversation separation
|
|
1567
|
+
const channelRegistry = new ChannelRegistry();
|
|
1568
|
+
// Send function for ChannelRouter
|
|
1569
|
+
const sendTelegram = async (chatId, text) => {
|
|
1570
|
+
if (telegramGateway) {
|
|
1571
|
+
await telegramGateway.sendMessage(chatId, text);
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
// Send approval request for ChannelRouter
|
|
1575
|
+
const sendApproval = async (chatId, toolName, input, toolCallId) => {
|
|
1576
|
+
if (telegramGateway) {
|
|
1577
|
+
// Build a human-readable summary from the tool input
|
|
1578
|
+
const data = input;
|
|
1579
|
+
const esc = (s) => {
|
|
1580
|
+
const str = typeof s === 'string' ? s : JSON.stringify(s);
|
|
1581
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/[^\x20-\x7E]/g, '');
|
|
1582
|
+
};
|
|
1583
|
+
let summary = '';
|
|
1584
|
+
if (toolName === 'Edit') {
|
|
1585
|
+
const filePath = esc(data.path ?? data.file_path ?? 'unknown');
|
|
1586
|
+
const oldLines = esc(data.old_string)?.split('\n').slice(0, 3).join(' ') || '';
|
|
1587
|
+
const newLines = esc(data.new_string)?.split('\n').slice(0, 3).join(' ') || '';
|
|
1588
|
+
summary = `File: ${filePath}\n---\n${oldLines}\n+++\n${newLines}`;
|
|
1589
|
+
}
|
|
1590
|
+
else if (data.command && typeof data.command === 'string')
|
|
1591
|
+
summary = 'Command: ' + esc(data.command);
|
|
1592
|
+
else if (data.path && typeof data.path === 'string')
|
|
1593
|
+
summary = 'Path: ' + esc(data.path);
|
|
1594
|
+
else if (data.file_path && typeof data.file_path === 'string')
|
|
1595
|
+
summary = 'Path: ' + esc(data.file_path);
|
|
1596
|
+
else if (data.query && typeof data.query === 'string')
|
|
1597
|
+
summary = 'Query: ' + esc(data.query);
|
|
1598
|
+
else if (data.url && typeof data.url === 'string')
|
|
1599
|
+
summary = 'URL: ' + esc(data.url);
|
|
1600
|
+
else if (data.text && typeof data.text === 'string')
|
|
1601
|
+
summary = 'Text: ' + esc(data.text).slice(0, 100);
|
|
1602
|
+
else
|
|
1603
|
+
summary = esc(JSON.stringify(data));
|
|
1604
|
+
try {
|
|
1605
|
+
await telegramGateway.sendApprovalRequest(chatId, toolCallId, toolName, summary);
|
|
1606
|
+
}
|
|
1607
|
+
catch (err) {
|
|
1608
|
+
console.error('[cli] sendApproval failed:', err);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
const channelRouter = new ChannelRouter(channelRegistry, sendTelegram, settingsManager, sendApproval);
|
|
1613
|
+
// Create the "main" channel if it doesn't exist
|
|
1614
|
+
const mainSessionId = ''; // Will be created on first turn
|
|
1615
|
+
channelRegistry.getOrCreate('cli', 'main', mainSessionId, 'Main');
|
|
1616
|
+
// Create Telegram gateway outside React lifecycle so it stays alive
|
|
1617
|
+
let telegramGateway = null;
|
|
1618
|
+
if (settings.TELEGRAM_BOT_TOKEN && settings.TELEGRAM_USER_ID) {
|
|
1619
|
+
telegramGateway = new TelegramGateway({
|
|
1620
|
+
botToken: settings.TELEGRAM_BOT_TOKEN,
|
|
1621
|
+
allowedUserId: settings.TELEGRAM_USER_ID,
|
|
1622
|
+
onUserMessage: (ctx) => {
|
|
1623
|
+
console.error(`[telegram-gateway] onUserMessage: chatId=${ctx.chatId}, isGroup=${ctx.isGroup}`);
|
|
1624
|
+
// Route through ChannelRouter to get the channel for this chat
|
|
1625
|
+
const route = channelRouter.onTelegramMessage(ctx);
|
|
1626
|
+
if (route) {
|
|
1627
|
+
// Store for response routing
|
|
1628
|
+
telegramChatIdRef.current = ctx.chatId;
|
|
1629
|
+
settingsManager.update({ TELEGRAM_CHAT_ID: ctx.chatId });
|
|
1630
|
+
// Set active channel so onSubmit knows where to route
|
|
1631
|
+
activeChannelRef.current = route.channelId;
|
|
1632
|
+
if (telegramSubmitRef.current) {
|
|
1633
|
+
telegramSubmitRef.current(ctx.text);
|
|
1634
|
+
}
|
|
1635
|
+
else {
|
|
1636
|
+
console.error('[telegram-gateway] No submit handler available');
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
else {
|
|
1640
|
+
console.error('[telegram-gateway] Message rejected (group or channel error)');
|
|
1641
|
+
}
|
|
1642
|
+
},
|
|
1643
|
+
});
|
|
1644
|
+
telegramGateway.start().catch(console.error);
|
|
1645
|
+
console.error(`[telegram-gateway] Bot started for user ${settings.TELEGRAM_USER_ID}`);
|
|
1646
|
+
}
|
|
1647
|
+
// --- MCP server connections ---
|
|
1648
|
+
const mcpServersRaw = settings.MCP_SERVERS;
|
|
1649
|
+
const mcpToolsRef = { current: [] };
|
|
1650
|
+
const mcpClientsRef = { current: [] };
|
|
1651
|
+
const mcpRawClientsRef = { current: [] };
|
|
1652
|
+
const parseMcpConfigs = (raw) => {
|
|
1653
|
+
if (!raw)
|
|
1654
|
+
return [];
|
|
1655
|
+
try {
|
|
1656
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
1657
|
+
return Object.values(parsed).map((v) => {
|
|
1658
|
+
const r = v;
|
|
1659
|
+
return {
|
|
1660
|
+
id: r.id ?? '',
|
|
1661
|
+
name: r.name ?? '',
|
|
1662
|
+
transport: r.transport ?? 'stdio',
|
|
1663
|
+
command: r.command,
|
|
1664
|
+
args: r.args,
|
|
1665
|
+
env: r.env,
|
|
1666
|
+
url: r.url,
|
|
1667
|
+
headers: r.headers,
|
|
1668
|
+
};
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
catch {
|
|
1672
|
+
return [];
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
const reconnectMcp = async () => {
|
|
1676
|
+
// Disconnect existing clients
|
|
1677
|
+
for (const client of mcpRawClientsRef.current) {
|
|
1678
|
+
client.disconnect().catch(() => { });
|
|
1679
|
+
}
|
|
1680
|
+
mcpRawClientsRef.current = [];
|
|
1681
|
+
mcpClientsRef.current = [];
|
|
1682
|
+
mcpToolsRef.current = [];
|
|
1683
|
+
// Read fresh settings from disk so we pick up settingsManager updates
|
|
1684
|
+
const freshSettings = settingsManager.get();
|
|
1685
|
+
const configs = parseMcpConfigs(freshSettings.MCP_SERVERS);
|
|
1686
|
+
if (configs.length > 0) {
|
|
1687
|
+
try {
|
|
1688
|
+
console.error(`[mcp] Reconnecting to ${configs.length} MCP server(s)...`);
|
|
1689
|
+
const result = await createMcpTools(configs);
|
|
1690
|
+
mcpToolsRef.current = result.tools;
|
|
1691
|
+
mcpClientsRef.current = result.clients.map((c) => ({
|
|
1692
|
+
serverId: c.serverId,
|
|
1693
|
+
isConnected: c.isConnected,
|
|
1694
|
+
tools: c.tools,
|
|
1695
|
+
}));
|
|
1696
|
+
mcpRawClientsRef.current = result.clients;
|
|
1697
|
+
console.error(`[mcp] Connected — ${mcpToolsRef.current.length} tool(s) from ${mcpClientsRef.current.length} server(s)`);
|
|
1698
|
+
}
|
|
1699
|
+
catch (err) {
|
|
1700
|
+
console.error(`[mcp] Reconnect failed: ${err instanceof Error ? err.message : err}`);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
// Initial MCP connection
|
|
1705
|
+
if (mcpServersRaw && (typeof mcpServersRaw === 'string' || typeof mcpServersRaw === 'object')) {
|
|
1706
|
+
try {
|
|
1707
|
+
const configs = parseMcpConfigs(mcpServersRaw);
|
|
1708
|
+
if (configs.length > 0) {
|
|
1709
|
+
console.error(`[mcp] Connecting to ${configs.length} MCP server(s)...`);
|
|
1710
|
+
const result = await createMcpTools(configs);
|
|
1711
|
+
mcpToolsRef.current = result.tools;
|
|
1712
|
+
mcpClientsRef.current = result.clients.map((c) => ({
|
|
1713
|
+
serverId: c.serverId,
|
|
1714
|
+
isConnected: c.isConnected,
|
|
1715
|
+
tools: c.tools,
|
|
1716
|
+
}));
|
|
1717
|
+
mcpRawClientsRef.current = result.clients;
|
|
1718
|
+
console.error(`[mcp] Connected — ${mcpToolsRef.current.length} tool(s) exposed from ${mcpClientsRef.current.length} server(s)`);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
catch (err) {
|
|
1722
|
+
console.error(`[mcp] Failed to load MCP configs: ${err instanceof Error ? err.message : err}`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
const mergedTools = [...allTools, ...mcpToolsRef.current];
|
|
1726
|
+
if (args.prompt && args.headless) {
|
|
1727
|
+
const loop = new TurnLoop({
|
|
1728
|
+
provider: streamProviderHolder.current,
|
|
1729
|
+
model,
|
|
1730
|
+
tools: mergedTools,
|
|
1731
|
+
cwd,
|
|
1732
|
+
settings,
|
|
1733
|
+
approvalMode: args.approvalMode || 'yolo',
|
|
1734
|
+
effort: settings.effort,
|
|
1735
|
+
system,
|
|
1736
|
+
});
|
|
1737
|
+
loop.eventBus.subscribe('assistant-delta', (e) => {
|
|
1738
|
+
if (e.type === 'assistant-delta') {
|
|
1739
|
+
process.stdout.write(e.text);
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
const result = await loop.run(args.prompt);
|
|
1743
|
+
process.exit(result.reason === 'error' ? 1 : 0);
|
|
1744
|
+
}
|
|
1745
|
+
// Switch to the alternate screen buffer so the TUI owns the full window and
|
|
1746
|
+
// the header stays pinned at the top. Restore on exit. We also override the
|
|
1747
|
+
// terminal's default background via OSC 11 so the unpainted cells (padding,
|
|
1748
|
+
// gaps between widgets) match the theme instead of showing whatever color
|
|
1749
|
+
// the user's terminal profile happens to use.
|
|
1750
|
+
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
|
+
const RESET_BG = '\x1b]111\x07';
|
|
1755
|
+
process.stdout.write(ENTER_ALT + SET_BG);
|
|
1756
|
+
let restored = false;
|
|
1757
|
+
const restore = () => {
|
|
1758
|
+
if (restored)
|
|
1759
|
+
return;
|
|
1760
|
+
restored = true;
|
|
1761
|
+
// Disconnect MCP clients
|
|
1762
|
+
for (const client of mcpRawClientsRef.current) {
|
|
1763
|
+
client.disconnect().catch(() => { });
|
|
1764
|
+
}
|
|
1765
|
+
process.stdout.write(RESET_BG + LEAVE_ALT);
|
|
1766
|
+
const id = globalThis.__hopperSessionId;
|
|
1767
|
+
if (id) {
|
|
1768
|
+
process.stdout.write(`\nSession saved. Resume with: hopper-agent resume ${id}\n`);
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
process.on('exit', restore);
|
|
1772
|
+
process.on('SIGINT', () => {
|
|
1773
|
+
telegramGateway?.stop();
|
|
1774
|
+
cronManager.stopChecker();
|
|
1775
|
+
for (const client of mcpRawClientsRef.current) {
|
|
1776
|
+
client.disconnect().catch(() => { });
|
|
1777
|
+
}
|
|
1778
|
+
restore();
|
|
1779
|
+
process.exit(0);
|
|
1780
|
+
});
|
|
1781
|
+
process.on('SIGTERM', () => {
|
|
1782
|
+
telegramGateway?.stop();
|
|
1783
|
+
cronManager.stopChecker();
|
|
1784
|
+
for (const client of mcpRawClientsRef.current) {
|
|
1785
|
+
client.disconnect().catch(() => { });
|
|
1786
|
+
}
|
|
1787
|
+
restore();
|
|
1788
|
+
process.exit(0);
|
|
1789
|
+
});
|
|
1790
|
+
const claudeProjects = loadClaudeProjects();
|
|
1791
|
+
// Start the background checker — fires reminders every 60 seconds.
|
|
1792
|
+
// Notification rendering is handled by App via onReminderHolder.
|
|
1793
|
+
// Telegram sends happen here (in main()'s closure) so settingsManager
|
|
1794
|
+
// is always fresh — never stale from React props.
|
|
1795
|
+
cronManager.startChecker(60_000, async (task) => {
|
|
1796
|
+
console.error(`[cron] Fired: ${task.message}`);
|
|
1797
|
+
// Handle heartbeat tasks
|
|
1798
|
+
if (task.type === 'heartbeat' && task.schedule) {
|
|
1799
|
+
const scheduleType = task.schedule.type;
|
|
1800
|
+
const settings = settingsManager.load();
|
|
1801
|
+
if (settings.HEARTBEAT !== 'on') {
|
|
1802
|
+
console.error(`[cron] Heartbeat disabled, skipping: ${scheduleType}`);
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
console.error(`[cron] Running heartbeat: ${scheduleType}`);
|
|
1806
|
+
const mcpTools = mcpToolsRef.current;
|
|
1807
|
+
const heartbeatTools = mcpTools.length > 0
|
|
1808
|
+
? [...allTools, ...mcpTools]
|
|
1809
|
+
: allTools;
|
|
1810
|
+
const executor = new HeartbeatExecutor({
|
|
1811
|
+
provider: streamProviderHolder.current,
|
|
1812
|
+
model: settings.model,
|
|
1813
|
+
tools: heartbeatTools,
|
|
1814
|
+
cwd: cwd,
|
|
1815
|
+
settings,
|
|
1816
|
+
effort: 'auto',
|
|
1817
|
+
scheduleType,
|
|
1818
|
+
});
|
|
1819
|
+
try {
|
|
1820
|
+
const result = await executor.execute();
|
|
1821
|
+
const formatted = HeartbeatDelivery.formatBrief(result);
|
|
1822
|
+
const label = `[${scheduleLabel(task.schedule.type)}] `;
|
|
1823
|
+
// Deliver to Telegram if configured
|
|
1824
|
+
const tgChatId = settings.TELEGRAM_CHAT_ID || null;
|
|
1825
|
+
const tgChat = tgChatId ?? telegramChatIdRef.current;
|
|
1826
|
+
if (tgChat && telegramGateway) {
|
|
1827
|
+
await telegramGateway.sendMessage(tgChat, label + formatted);
|
|
1828
|
+
}
|
|
1829
|
+
// Re-evaluate all four schedules and update the task's next fire time
|
|
1830
|
+
const currentSettings = settingsManager.load();
|
|
1831
|
+
cronManager.rescheduleFromSettings({
|
|
1832
|
+
HEARTBEAT_TIMES: currentSettings.HEARTBEAT_TIMES,
|
|
1833
|
+
HEARTBEAT_DAILY: currentSettings.HEARTBEAT_DAILY,
|
|
1834
|
+
HEARTBEAT_WEEKLY: currentSettings.HEARTBEAT_WEEKLY,
|
|
1835
|
+
HEARTBEAT_MONTHLY: currentSettings.HEARTBEAT_MONTHLY,
|
|
1836
|
+
});
|
|
1837
|
+
// Show in TUI via reminder holder (attaches brief to task for UI)
|
|
1838
|
+
const briefTask = task;
|
|
1839
|
+
briefTask.heartbeatBrief = formatted;
|
|
1840
|
+
onReminderHolder.current?.(briefTask);
|
|
1841
|
+
}
|
|
1842
|
+
catch (err) {
|
|
1843
|
+
console.error(`[cron] Heartbeat failed:`, err instanceof Error ? err.message : err);
|
|
1844
|
+
}
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
// Original reminder handling
|
|
1848
|
+
const cb = onReminderHolder.current;
|
|
1849
|
+
if (cb)
|
|
1850
|
+
cb(task);
|
|
1851
|
+
else
|
|
1852
|
+
console.error(`[cron] Reminder fired: ${task.message} but no UI handler`);
|
|
1853
|
+
// Send to Telegram if configured — settings.json is the source of truth
|
|
1854
|
+
const settings = settingsManager.load(); // reload from disk
|
|
1855
|
+
const settingsChatId = settings.TELEGRAM_CHAT_ID || null;
|
|
1856
|
+
const chatId = settingsChatId ?? telegramChatIdRef.current;
|
|
1857
|
+
console.error(`[cron] telegramGateway=${!!telegramGateway} chatId=${chatId} botToken=${!!settings.TELEGRAM_BOT_TOKEN} settings.TELEGRAM_CHAT_ID=${settings.TELEGRAM_CHAT_ID}`);
|
|
1858
|
+
if (chatId && telegramGateway) {
|
|
1859
|
+
const timeStr = new Date(task.scheduledAt).toLocaleString();
|
|
1860
|
+
const reminderText = `Hopper reminder:\nDate: ${timeStr}\n${task.message}`;
|
|
1861
|
+
console.error(`[cron] Sending to Telegram chat ${chatId}: ${reminderText}`);
|
|
1862
|
+
telegramGateway.sendMessage(chatId, reminderText).catch((err) => {
|
|
1863
|
+
console.error('[cron] Telegram send failed:', err instanceof Error ? err.message : err);
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
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 }), {
|
|
1869
|
+
exitOnCtrlC: false,
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
main().catch((err) => {
|
|
1873
|
+
console.error('hopper-agent error:', err);
|
|
1874
|
+
process.exit(1);
|
|
1875
|
+
});
|
|
1876
|
+
//# sourceMappingURL=cli.js.map
|