agen-vektor 0.3.25 → 0.3.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -1
- package/dist/agent/agent.js +42 -0
- package/dist/agent/loop.js +2 -0
- package/dist/agent/session.js +10 -1
- package/dist/cli/index.js +10 -0
- package/dist/config/config.js +9 -1
- package/dist/config/credentials.js +7 -1
- package/dist/config/free-tier.js +6 -3
- package/dist/tools/git.js +1 -1
- package/dist/tools/mcp.js +419 -0
- package/dist/tools/shell.js +47 -0
- package/dist/tui/app.js +90 -1
- package/dist/tui/commands.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ render cleanly right where you work.*
|
|
|
29
29
|
|
|
30
30
|
- **Interactive TUI** — chat, agent status, tool execution, diff viewer, input. Keyboard driven, mouse-aware, works on 80×24 terminals and survives resize. Scroll the chat with PgUp/PgDn, the mouse wheel, or ↑/↓ while the input line is empty.
|
|
31
31
|
- **Agent loop** — plan → inspect → tool call → result → review → fix → retry until done (with an iteration cap).
|
|
32
|
-
- **Tools** — read/write/edit files, list directories, search files, run shell commands, git, fetch web pages, and **live web search out of the box** (no key needed — searches relay through the VectorHead gateway; bring your own Exa/Serper/Brave key to use your own quota).
|
|
32
|
+
- **Tools** — read/write/edit files, list directories, search files, run shell commands, git, fetch web pages, and **live web search out of the box** (no key needed — searches relay through the VectorHead gateway; bring your own Exa/Serper/Brave key to use your own quota). Extensible via **MCP servers** (`mcp__<server>__<tool>`, local stdio JSON-RPC, always permission-ask).
|
|
33
33
|
- **Skills** — 14 bundled, agentskills.io-format skills with progressive disclosure (`list_skills` / `read_skill` tools, `/skills` in the TUI); drop your own into `~/.vector/skills/` or `.agents/skills/`.
|
|
34
34
|
- **Agent files** — SOUL.md / MEMORY.md / USER.md / AGENTS.md auto-seeded in `~/.vector/` and injected into the system prompt (identity, notes, preferences, project rules).
|
|
35
35
|
- **Security first** — command policy classifies every shell command (SAFE / ASK / DANGEROUS / BLOCKED), permission prompts before risky actions, `--yolo` mode with warnings, and API keys are never printed or persisted to sessions.
|
|
@@ -158,6 +158,7 @@ Type `/` inside VectorHead — suggestions filter as you type:
|
|
|
158
158
|
| `/continue` | Continue the current/last session |
|
|
159
159
|
| `/soul` `/memory` `/user` `/agents` | Edit agent files (SOUL/MEMORY/USER/AGENTS.md) |
|
|
160
160
|
| `/skills` | List installed skills |
|
|
161
|
+
| `/mcp` | Probe a configured MCP server and list the tools it provides |
|
|
161
162
|
| `/diff` | Show diff viewer |
|
|
162
163
|
| `/git` | Run `git status` |
|
|
163
164
|
| `/compact` | Compact context (truncate old messages) |
|
|
@@ -431,6 +432,43 @@ npm run build
|
|
|
431
432
|
- Persistent permission rules per project
|
|
432
433
|
- Windows support (WSL-focused)
|
|
433
434
|
|
|
435
|
+
## MCP (Model Context Protocol)
|
|
436
|
+
|
|
437
|
+
VectorHead can extend its own toolset with **local MCP servers** — small
|
|
438
|
+
programs spawned on the same device the agent runs on (Termux included),
|
|
439
|
+
speaking JSON-RPC 2.0 over stdio. No SDK, no extra dependencies, no hosting:
|
|
440
|
+
this is the same spawn pattern the `shell` tool uses (detached process group,
|
|
441
|
+
per-request timeouts, kill-on-exit — an exiting VectorHead never leaves
|
|
442
|
+
orphaned MCP processes behind).
|
|
443
|
+
|
|
444
|
+
Configure servers in `~/.vector/config.json` (mode 0600, so per-server `env`
|
|
445
|
+
secrets stay private):
|
|
446
|
+
|
|
447
|
+
```json
|
|
448
|
+
{
|
|
449
|
+
"mcpServers": {
|
|
450
|
+
"everything": {
|
|
451
|
+
"command": "npx",
|
|
452
|
+
"args": ["-y", "@modelcontextprotocol/server-everything"],
|
|
453
|
+
"enabled": true
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- On startup every enabled server is spawned, the `initialize` handshake
|
|
460
|
+
completes, and its catalog is imported into the tool registry as
|
|
461
|
+
`mcp__<server>__<tool>` (e.g. `mcp__everything__echo`) — collision-free
|
|
462
|
+
with built-in tools.
|
|
463
|
+
- A broken/garbage server is **skipped with a warning** — it can never
|
|
464
|
+
prevent the agent from starting.
|
|
465
|
+
- `/mcp` in the TUI probes a configured server on demand and lists the tools
|
|
466
|
+
it would provide.
|
|
467
|
+
- **Trust model:** MCP tools are third-party code. Every MCP tool runs with
|
|
468
|
+
`permission: 'ask'` — even in `--yolo` mode you approve each call — and
|
|
469
|
+
results are truncated like shell output. Tool descriptions come from the
|
|
470
|
+
server and are treated as untrusted data (prompt-injection surface).
|
|
471
|
+
|
|
434
472
|
## Changelog
|
|
435
473
|
|
|
436
474
|
### 0.3.15
|
package/dist/agent/agent.js
CHANGED
|
@@ -19,6 +19,8 @@ const rules_1 = require("./rules");
|
|
|
19
19
|
const skills_1 = require("./skills");
|
|
20
20
|
const skills_tool_1 = require("../tools/skills-tool");
|
|
21
21
|
const background_1 = require("../tools/background");
|
|
22
|
+
const mcp_1 = require("../tools/mcp");
|
|
23
|
+
const logger_1 = require("../utils/logger");
|
|
22
24
|
class Agent {
|
|
23
25
|
config;
|
|
24
26
|
provider;
|
|
@@ -75,10 +77,50 @@ class Agent {
|
|
|
75
77
|
// output_from_background / stop_background_job).
|
|
76
78
|
...(0, background_1.createBackgroundTools)(),
|
|
77
79
|
]);
|
|
80
|
+
this.initMcpServers();
|
|
78
81
|
}
|
|
79
82
|
async run(request, signal) {
|
|
80
83
|
return this.runLoop(request, [], signal);
|
|
81
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Spawn configured MCP servers and register their tools. Fire-and-forget
|
|
87
|
+
* (constructor must stay sync — the CLI path never awaited it): failures
|
|
88
|
+
* are contained per-server (warn + skip) and a broken MCP server can
|
|
89
|
+
* never prevent the agent from starting. Registration happens as soon as
|
|
90
|
+
* each server finishes its handshake.
|
|
91
|
+
*/
|
|
92
|
+
async initMcpServers() {
|
|
93
|
+
const servers = this.config.mcpServers ?? {};
|
|
94
|
+
for (const [id, cfg] of Object.entries(servers)) {
|
|
95
|
+
if (cfg && cfg.enabled === false)
|
|
96
|
+
continue;
|
|
97
|
+
if (!cfg || !cfg.command)
|
|
98
|
+
continue;
|
|
99
|
+
void this.initOneMcpServer(id, cfg);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async initOneMcpServer(id, cfg) {
|
|
103
|
+
const conn = new mcp_1.McpConnection(id, cfg);
|
|
104
|
+
try {
|
|
105
|
+
await conn.connect();
|
|
106
|
+
const specs = await conn.listTools();
|
|
107
|
+
this.tools.registerMany((0, mcp_1.mcpToolsFromSpecs)(conn, specs));
|
|
108
|
+
this.mcpConnections.push(conn);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// One broken MCP server must never take the agent down.
|
|
112
|
+
conn.close();
|
|
113
|
+
logger_1.logger.warn(`MCP server "${id}" skipped: ${err.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Live MCP connections owned by this Agent (closed via dispose()). */
|
|
117
|
+
mcpConnections = [];
|
|
118
|
+
/** Tear down MCP server processes (called on TUI exit / new run setup). */
|
|
119
|
+
dispose() {
|
|
120
|
+
for (const c of this.mcpConnections)
|
|
121
|
+
c.close();
|
|
122
|
+
this.mcpConnections = [];
|
|
123
|
+
}
|
|
82
124
|
/**
|
|
83
125
|
* Run continuing from a prior conversation (multi-turn continuity).
|
|
84
126
|
* The previous turn's messages are passed as history so the model
|
package/dist/agent/loop.js
CHANGED
|
@@ -345,6 +345,7 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
|
|
|
345
345
|
permissions,
|
|
346
346
|
onActivity: callbacks.onActivity,
|
|
347
347
|
maxOutput: 30_000,
|
|
348
|
+
signal,
|
|
348
349
|
});
|
|
349
350
|
}
|
|
350
351
|
catch (err) {
|
|
@@ -389,6 +390,7 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
|
|
|
389
390
|
permissions,
|
|
390
391
|
onActivity: callbacks.onActivity,
|
|
391
392
|
maxOutput: 30_000,
|
|
393
|
+
signal,
|
|
392
394
|
});
|
|
393
395
|
}
|
|
394
396
|
catch (err) {
|
package/dist/agent/session.js
CHANGED
|
@@ -103,7 +103,16 @@ function saveSession(name, messages, opts) {
|
|
|
103
103
|
})),
|
|
104
104
|
}));
|
|
105
105
|
const data = { meta, messages: sanitized };
|
|
106
|
-
|
|
106
|
+
// 0600 (security audit 2026-09-13): sessions hold the user's full
|
|
107
|
+
// conversation history (code paths, project names) — owner read/write only.
|
|
108
|
+
const p = fileFor(name);
|
|
109
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
110
|
+
try {
|
|
111
|
+
fs.chmodSync(p, 0o600);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* some filesystems (e.g. some Termux setups) ignore chmod */
|
|
115
|
+
}
|
|
107
116
|
}
|
|
108
117
|
function deleteSession(name) {
|
|
109
118
|
try {
|
package/dist/cli/index.js
CHANGED
|
@@ -299,6 +299,9 @@ async function runTui(opts) {
|
|
|
299
299
|
theme: opts.theme,
|
|
300
300
|
onExit: () => {
|
|
301
301
|
exit = true;
|
|
302
|
+
// MCP servers are detached process-group spawns (shell.ts pattern) —
|
|
303
|
+
// they would survive a clean TUI exit as orphans. Tear them down here.
|
|
304
|
+
agent.dispose();
|
|
302
305
|
},
|
|
303
306
|
});
|
|
304
307
|
app.setPermissionResolver((d) => {
|
|
@@ -429,6 +432,13 @@ async function runTui(opts) {
|
|
|
429
432
|
process.stdout.write(terminal_1.ANSI.showCursor);
|
|
430
433
|
process.stdout.removeListener('resize', onResize);
|
|
431
434
|
cleanup();
|
|
435
|
+
// Keluar TANPA syarat: dulu proses Node bisa tetap hidup setelah main
|
|
436
|
+
// loop berakhir — pending ask-permission promise atau child shell yang
|
|
437
|
+
// masih memegang stdio menjaga event loop tetap menyala dan terminal
|
|
438
|
+
// tampak "hang setengah keluar" (user harus kill manual, laporan
|
|
439
|
+
// 2026-09-14). MCP punya orphan guard-nya sendiri; di sini kita cukup
|
|
440
|
+
// memastikan TIDAK ADA yang menahan proses setelah TUI off.
|
|
441
|
+
process.exit(0);
|
|
432
442
|
}
|
|
433
443
|
}
|
|
434
444
|
function sleep(ms) {
|
package/dist/config/config.js
CHANGED
|
@@ -131,7 +131,15 @@ function loadConfig() {
|
|
|
131
131
|
}
|
|
132
132
|
function saveConfig(config) {
|
|
133
133
|
(0, paths_1.ensureDirs)();
|
|
134
|
-
|
|
134
|
+
// 0600 (security audit 2026-09-13): config.json can carry inline provider
|
|
135
|
+
// options.apiKey for custom providers — same restriction as credentials.json.
|
|
136
|
+
fs.writeFileSync((0, paths_1.getConfigPath)(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
137
|
+
try {
|
|
138
|
+
fs.chmodSync((0, paths_1.getConfigPath)(), 0o600);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* some filesystems (e.g. some Termux setups) ignore chmod */
|
|
142
|
+
}
|
|
135
143
|
}
|
|
136
144
|
/** Effective config, applying environment variable overrides. */
|
|
137
145
|
function effectiveConfig(base) {
|
|
@@ -146,5 +146,11 @@ function redact(text) {
|
|
|
146
146
|
return text
|
|
147
147
|
.replace(/(sk-[A-Za-z0-9_-]{8,})/g, 'sk-***REDACTED***')
|
|
148
148
|
.replace(/(Bearer\s+)[A-Za-z0-9._-]{12,}/gi, '$1***REDACTED***')
|
|
149
|
-
.replace(/(api[_-]?key["']?\s*[:=]\s*["']?)[^"'\s,;]+/gi, '$1***REDACTED***')
|
|
149
|
+
.replace(/(api[_-]?key["']?\s*[:=]\s*["']?)[^"'\s,;]+/gi, '$1***REDACTED***')
|
|
150
|
+
// Known vendor token shapes (security audit 2026-09-13): GitHub PATs,
|
|
151
|
+
// Google API keys, xAI keys, VectorHead gateway admin tokens.
|
|
152
|
+
.replace(/(ghp_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,})/g, '***REDACTED***')
|
|
153
|
+
.replace(/(AIza[A-Za-z0-9_-]{8,})/g, '***REDACTED***')
|
|
154
|
+
.replace(/(xai-[A-Za-z0-9]{8,})/g, '***REDACTED***')
|
|
155
|
+
.replace(/(vgadm_[A-Za-z0-9_-]{8,})/g, '***REDACTED***');
|
|
150
156
|
}
|
package/dist/config/free-tier.js
CHANGED
|
@@ -66,9 +66,11 @@ exports.FREE_PROVIDER_ID = 'vectorhead-free';
|
|
|
66
66
|
* (ketemu via smoke test 0.3.22). Router Kilo terverifikasi live end-to-end
|
|
67
67
|
* (chat 200, dijawab dots-3-note-preview:free). Riwayat trap yang sama:
|
|
68
68
|
* `deepseek-ai/DeepSeek-V4-Flash` → `claude-sonnet-4-6` → `glm-5.3-flash`
|
|
69
|
-
* (dua flip 2026-09-06/07) →
|
|
69
|
+
* (dua flip 2026-09-06/07) → `kilo-auto/free` (2026-09-13) → **z-ai/glm-5.3-free**
|
|
70
|
+
* (2026-09-14: katalog gateway tinggal 2 model — `kilo-auto/free` TIDAK lagi
|
|
71
|
+
* dilayani; default baru terverifikasi live: 1 request → 200 OK, jawaban "hai").
|
|
70
72
|
*/
|
|
71
|
-
exports.FREE_DEFAULT_MODEL = '
|
|
73
|
+
exports.FREE_DEFAULT_MODEL = 'z-ai/glm-5.3-free';
|
|
72
74
|
/** The providers-map entry for the free tier (idempotent — same shape always). */
|
|
73
75
|
function freeProviderDef() {
|
|
74
76
|
// When a catalog has been fetched in this process, the gateway URL and
|
|
@@ -94,7 +96,7 @@ function freeProviderDef() {
|
|
|
94
96
|
},
|
|
95
97
|
model: exports.FREE_DEFAULT_MODEL,
|
|
96
98
|
models: Object.keys(models).length > 0 ? models : {
|
|
97
|
-
[exports.FREE_DEFAULT_MODEL]: { name: '
|
|
99
|
+
[exports.FREE_DEFAULT_MODEL]: { name: 'GLM 5.3 (free)' },
|
|
98
100
|
},
|
|
99
101
|
};
|
|
100
102
|
}
|
|
@@ -108,6 +110,7 @@ exports.FREE_LEGACY_MODELS = [
|
|
|
108
110
|
'deepseek-ai/DeepSeek-V4-Flash',
|
|
109
111
|
'claude-sonnet-4-6',
|
|
110
112
|
'glm-5.3-flash',
|
|
113
|
+
'kilo-auto/free',
|
|
111
114
|
];
|
|
112
115
|
/**
|
|
113
116
|
* Ensure the free provider entry exists in the config's providers map
|
package/dist/tools/git.js
CHANGED
|
@@ -136,7 +136,7 @@ ${exports.gitCommitGuidePrompt}`,
|
|
|
136
136
|
if (!allowed) {
|
|
137
137
|
return { output: `Permission denied by user: ${cmd}` };
|
|
138
138
|
}
|
|
139
|
-
const res = await (0, shell_1.runShell)(cmd, ctx.cwd, { timeoutMs: 60_000 });
|
|
139
|
+
const res = await (0, shell_1.runShell)(cmd, ctx.cwd, { timeoutMs: 60_000, signal: ctx.signal });
|
|
140
140
|
const out = [res.stdout, res.stderr].filter(Boolean).join('\n[stderr]\n');
|
|
141
141
|
return {
|
|
142
142
|
output: `${res.exitCode === 0 ? 'exit 0' : `exit ${res.exitCode ?? '?'}`}${out ? '\n' + out : ''}`,
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.McpConnection = exports.MCP_CALL_TIMEOUT_MS = exports.MCP_CLIENT_INFO = exports.MCP_PROTOCOL_VERSION = void 0;
|
|
4
|
+
exports.mcpToolName = mcpToolName;
|
|
5
|
+
exports.mcpProbeTools = mcpProbeTools;
|
|
6
|
+
exports.mcpToolsFromSpecs = mcpToolsFromSpecs;
|
|
7
|
+
/**
|
|
8
|
+
* MCP (Model Context Protocol) client — minimal, zero-dependency.
|
|
9
|
+
*
|
|
10
|
+
* Speaks JSON-RPC 2.0 over stdio with LOCAL MCP servers, spawned on the same
|
|
11
|
+
* device the agent runs on (Termux included). This is exactly the `shell`
|
|
12
|
+
* tool's spawn pattern: detached process group + killTree + resolve-on-exit.
|
|
13
|
+
*
|
|
14
|
+
* Protocol surface implemented (spec 2026-07-28 subset):
|
|
15
|
+
* initialize → protocolVersion + capabilities handshake
|
|
16
|
+
* tools/list → tool catalog (imported into the registry, prefixed)
|
|
17
|
+
* tools/call → execute, result text becomes the loop's tool result
|
|
18
|
+
* notifications/* → accepted and ignored (initialized, cancelled, …)
|
|
19
|
+
*
|
|
20
|
+
* Tool MCP = UNTRUSTED third party (konsep.md 2026-09-13): results are
|
|
21
|
+
* truncated, permissions follow the normal SAFE/ASK/DANGEROUS flow and are
|
|
22
|
+
* NEVER auto-approved (the tool always declares `permission: 'ask'`).
|
|
23
|
+
*/
|
|
24
|
+
const node_child_process_1 = require("node:child_process");
|
|
25
|
+
/** Wire protocol version we negotiate (MCP spec 2026-07-28). */
|
|
26
|
+
exports.MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
27
|
+
/** Client info reported during the initialize handshake. */
|
|
28
|
+
exports.MCP_CLIENT_INFO = { name: 'vectorhead', version: '0.1.0' };
|
|
29
|
+
/** Fallback handshake timeout — an MCP server that never answers is skipped. */
|
|
30
|
+
const INIT_TIMEOUT_MS = 10_000;
|
|
31
|
+
/** Per-call timeout default for tools/call. */
|
|
32
|
+
exports.MCP_CALL_TIMEOUT_MS = 60_000;
|
|
33
|
+
/** Result text cap returned to the model (mirrors shell tool sizing). */
|
|
34
|
+
const MAX_RESULT_CHARS = 30_000;
|
|
35
|
+
/** All live MCP connections in this process (orphan guard, see exit hook). */
|
|
36
|
+
const liveConnections = new Set();
|
|
37
|
+
/** Registry-facing name: mcp__<server>__<tool> (collision-free, konsep.md). */
|
|
38
|
+
function mcpToolName(server, tool) {
|
|
39
|
+
return `mcp__${server}__${tool}`;
|
|
40
|
+
}
|
|
41
|
+
function encodeRequest(req) {
|
|
42
|
+
return JSON.stringify(req) + '\n';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Extract exactly ONE JSON value from the head of `buf`.
|
|
46
|
+
* Returns [value, rest] or null when no complete value is buffered yet.
|
|
47
|
+
* Uses an incremental brace/bracket/string-escape scanner — newline-delimited
|
|
48
|
+
* framing per the MCP stdio transport, but tolerant of pretty-printed output.
|
|
49
|
+
*/
|
|
50
|
+
function tryParseOne(buf) {
|
|
51
|
+
let i = 0;
|
|
52
|
+
const n = buf.length;
|
|
53
|
+
// skip leading whitespace
|
|
54
|
+
while (i < n && (buf[i] === ' ' || buf[i] === '\n' || buf[i] === '\r' || buf[i] === '\t'))
|
|
55
|
+
i++;
|
|
56
|
+
if (i >= n)
|
|
57
|
+
return null;
|
|
58
|
+
const start = i;
|
|
59
|
+
let depth = 0;
|
|
60
|
+
let inString = false;
|
|
61
|
+
let escaped = false;
|
|
62
|
+
for (; i < n; i++) {
|
|
63
|
+
const c = buf[i];
|
|
64
|
+
if (inString) {
|
|
65
|
+
if (escaped)
|
|
66
|
+
escaped = false;
|
|
67
|
+
else if (c === '\\')
|
|
68
|
+
escaped = true;
|
|
69
|
+
else if (c === '"')
|
|
70
|
+
inString = false;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (c === '"')
|
|
74
|
+
inString = true;
|
|
75
|
+
else if (c === '{' || c === '[')
|
|
76
|
+
depth++;
|
|
77
|
+
else if (c === '}' || c === ']') {
|
|
78
|
+
depth--;
|
|
79
|
+
if (depth === 0) {
|
|
80
|
+
const raw = buf.slice(start, i + 1);
|
|
81
|
+
try {
|
|
82
|
+
return { value: JSON.parse(raw), rest: buf.slice(i + 1) };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null; // malformed → caller treats the buffer as garbage
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (depth === 0 && c !== '{' && c !== '[') {
|
|
90
|
+
// Bare scalar (not valid JSON-RPC) — treat as garbage.
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null; // incomplete
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* One-shot helper used by the TUI /config surface and tests: connect,
|
|
98
|
+
* list tools, shut down. Nothing is left running.
|
|
99
|
+
*/
|
|
100
|
+
async function mcpProbeTools(server, cfg, timeoutMs = INIT_TIMEOUT_MS) {
|
|
101
|
+
const conn = new McpConnection(server, cfg);
|
|
102
|
+
try {
|
|
103
|
+
await conn.connect(timeoutMs);
|
|
104
|
+
const tools = await conn.listTools(timeoutMs);
|
|
105
|
+
return { ok: true, tools };
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
return { ok: false, error: err.message, tools: [] };
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
conn.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A live connection to ONE MCP server process. Lifecycle: connect() →
|
|
116
|
+
* listTools()/callTool() ×N → close(). Every pending request carries its own
|
|
117
|
+
* timeout so a wedged server can never hang the agent loop.
|
|
118
|
+
*/
|
|
119
|
+
class McpConnection {
|
|
120
|
+
server;
|
|
121
|
+
cfg;
|
|
122
|
+
child = null;
|
|
123
|
+
nextId = 1;
|
|
124
|
+
pending = new Map();
|
|
125
|
+
buf = '';
|
|
126
|
+
connectReject = null;
|
|
127
|
+
exitCode = null;
|
|
128
|
+
closed = false;
|
|
129
|
+
constructor(server, cfg) {
|
|
130
|
+
this.server = server;
|
|
131
|
+
this.cfg = cfg;
|
|
132
|
+
liveConnections.add(this);
|
|
133
|
+
}
|
|
134
|
+
/** Spawn the server process and complete the initialize handshake. */
|
|
135
|
+
connect(timeoutMs = INIT_TIMEOUT_MS) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
if (this.child) {
|
|
138
|
+
resolve();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
let settled = false;
|
|
142
|
+
const done = () => {
|
|
143
|
+
if (settled)
|
|
144
|
+
return;
|
|
145
|
+
settled = true;
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
this.connectReject = null;
|
|
148
|
+
};
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
this.connectReject = null;
|
|
151
|
+
if (settled)
|
|
152
|
+
return;
|
|
153
|
+
settled = true;
|
|
154
|
+
this.close();
|
|
155
|
+
reject(new Error(`MCP server "${this.server}" did not answer initialize within ${timeoutMs}ms`));
|
|
156
|
+
}, timeoutMs);
|
|
157
|
+
try {
|
|
158
|
+
this.child = (0, node_child_process_1.spawn)(this.cfg.command, this.cfg.args ?? [], {
|
|
159
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
160
|
+
env: { ...process.env, ...(this.cfg.env ?? {}) },
|
|
161
|
+
detached: true, // own process group → killTree works (shell.ts pattern)
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
done();
|
|
166
|
+
reject(new Error(`MCP server "${this.server}" failed to spawn: ${err.message}`));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.connectReject = (e) => {
|
|
170
|
+
done();
|
|
171
|
+
reject(e);
|
|
172
|
+
};
|
|
173
|
+
this.child.stdout?.on('data', (d) => this.onData(d));
|
|
174
|
+
this.child.stderr?.on('data', () => {
|
|
175
|
+
// stderr is diagnostics only — deliberately dropped, capped by the
|
|
176
|
+
// OS pipe; a chatty server cannot OOM us or block the connection.
|
|
177
|
+
});
|
|
178
|
+
this.child.on('error', (err) => {
|
|
179
|
+
const e = new Error(`MCP server "${this.server}" spawn error: ${err.message}`);
|
|
180
|
+
this.connectReject?.(e);
|
|
181
|
+
this.failAll(e);
|
|
182
|
+
});
|
|
183
|
+
this.child.on('exit', (code) => {
|
|
184
|
+
this.exitCode = code;
|
|
185
|
+
const e = new Error(`MCP server "${this.server}" exited (code ${code ?? '?'})`);
|
|
186
|
+
this.connectReject?.(e);
|
|
187
|
+
this.failAll(e);
|
|
188
|
+
});
|
|
189
|
+
this.send('initialize', {
|
|
190
|
+
protocolVersion: exports.MCP_PROTOCOL_VERSION,
|
|
191
|
+
capabilities: {},
|
|
192
|
+
clientInfo: exports.MCP_CLIENT_INFO,
|
|
193
|
+
})
|
|
194
|
+
.then(() => {
|
|
195
|
+
// Completed-initialization notification (spec: client MUST send
|
|
196
|
+
// this before any other request after initialize resolves).
|
|
197
|
+
this.notify('notifications/initialized');
|
|
198
|
+
done();
|
|
199
|
+
resolve();
|
|
200
|
+
})
|
|
201
|
+
.catch((err) => {
|
|
202
|
+
done();
|
|
203
|
+
this.close();
|
|
204
|
+
reject(err);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** Fetch the server's tool catalog. */
|
|
209
|
+
async listTools(timeoutMs = INIT_TIMEOUT_MS) {
|
|
210
|
+
const res = (await this.request('tools/list', {}, timeoutMs));
|
|
211
|
+
const list = Array.isArray(res?.tools) ? res.tools : [];
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const t of list) {
|
|
214
|
+
if (!t || typeof t.name !== 'string' || !t.name)
|
|
215
|
+
continue;
|
|
216
|
+
out.push({
|
|
217
|
+
server: this.server,
|
|
218
|
+
name: t.name,
|
|
219
|
+
description: typeof t.description === 'string' ? t.description : undefined,
|
|
220
|
+
inputSchema: t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : undefined,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Execute a tool call. Returns the concatenated text blocks of the result
|
|
227
|
+
* (MCP content array), or an "isError" message when the server flags it.
|
|
228
|
+
*/
|
|
229
|
+
async callTool(tool, args, timeoutMs = this.cfg.timeoutMs ?? exports.MCP_CALL_TIMEOUT_MS) {
|
|
230
|
+
const res = (await this.request('tools/call', { name: tool, arguments: args }, timeoutMs));
|
|
231
|
+
const parts = [];
|
|
232
|
+
for (const block of Array.isArray(res?.content) ? res.content : []) {
|
|
233
|
+
if (block && typeof block.text === 'string')
|
|
234
|
+
parts.push(block.text);
|
|
235
|
+
}
|
|
236
|
+
let text = parts.join('\n');
|
|
237
|
+
if (res?.isError)
|
|
238
|
+
text = text || 'MCP tool reported an error (no detail)';
|
|
239
|
+
if (!text && res?.structuredContent !== undefined) {
|
|
240
|
+
try {
|
|
241
|
+
text = JSON.stringify(res.structuredContent);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* fall through to empty */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { text: truncate(text, MAX_RESULT_CHARS), isError: Boolean(res?.isError), data: res?.structuredContent };
|
|
248
|
+
}
|
|
249
|
+
/** Kill the server process (whole process group) and fail pending calls. */
|
|
250
|
+
close() {
|
|
251
|
+
if (this.closed)
|
|
252
|
+
return;
|
|
253
|
+
this.closed = true;
|
|
254
|
+
liveConnections.delete(this);
|
|
255
|
+
const e = new Error(`MCP server "${this.server}" connection closed`);
|
|
256
|
+
this.connectReject?.(e);
|
|
257
|
+
this.failAll(e);
|
|
258
|
+
if (this.child && this.child.pid != null) {
|
|
259
|
+
try {
|
|
260
|
+
process.kill(-this.child.pid, 'SIGKILL');
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
/* group already gone */
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
this.child.kill('SIGKILL');
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
/* already gone */
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
this.child = null;
|
|
273
|
+
}
|
|
274
|
+
exitCodeOf() {
|
|
275
|
+
return this.exitCode;
|
|
276
|
+
}
|
|
277
|
+
/** PID of the spawned server process (null when not connected). */
|
|
278
|
+
childPid() {
|
|
279
|
+
return this.child?.pid ?? null;
|
|
280
|
+
}
|
|
281
|
+
// ── internals ──
|
|
282
|
+
onData(d) {
|
|
283
|
+
this.buf += d.toString();
|
|
284
|
+
// Guard: an insane buffer means the server is not speaking JSON-RPC.
|
|
285
|
+
if (this.buf.length > 4_000_000) {
|
|
286
|
+
const e = new Error(`MCP server "${this.server}" produced non-JSON output (buffer overflow)`);
|
|
287
|
+
this.connectReject?.(e);
|
|
288
|
+
this.failAll(e);
|
|
289
|
+
this.close();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
for (;;) {
|
|
293
|
+
const parsed = tryParseOne(this.buf);
|
|
294
|
+
if (!parsed)
|
|
295
|
+
break;
|
|
296
|
+
this.buf = parsed.rest;
|
|
297
|
+
const msg = parsed.value;
|
|
298
|
+
if (msg && typeof msg === 'object' && typeof msg.id === 'number' &&
|
|
299
|
+
!('method' in msg) // request/notification yang di-echo balik bukan respons
|
|
300
|
+
) {
|
|
301
|
+
if ('result' in msg || 'error' in msg) {
|
|
302
|
+
const p = this.pending.get(msg.id);
|
|
303
|
+
if (p) {
|
|
304
|
+
this.pending.delete(msg.id);
|
|
305
|
+
clearTimeout(p.timer);
|
|
306
|
+
if (msg.error)
|
|
307
|
+
p.reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
|
|
308
|
+
else
|
|
309
|
+
p.resolve(msg.result);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Notifications (method, no id) are intentionally ignored.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
failAll(e) {
|
|
317
|
+
for (const [, p] of this.pending) {
|
|
318
|
+
clearTimeout(p.timer);
|
|
319
|
+
p.reject(e);
|
|
320
|
+
}
|
|
321
|
+
this.pending.clear();
|
|
322
|
+
}
|
|
323
|
+
request(method, params, timeoutMs) {
|
|
324
|
+
const id = this.nextId++;
|
|
325
|
+
const req = { jsonrpc: '2.0', id, method, params };
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
const timer = setTimeout(() => {
|
|
328
|
+
this.pending.delete(id);
|
|
329
|
+
reject(new Error(`MCP server "${this.server}" timed out on ${method} after ${timeoutMs}ms`));
|
|
330
|
+
}, timeoutMs);
|
|
331
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
332
|
+
try {
|
|
333
|
+
this.child?.stdin?.write(encodeRequest(req));
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
this.pending.delete(id);
|
|
337
|
+
clearTimeout(timer);
|
|
338
|
+
reject(new Error(`MCP server "${this.server}" write failed: ${err.message}`));
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
notify(method, params) {
|
|
343
|
+
try {
|
|
344
|
+
this.child?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
/* best-effort */
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
send(method, params, timeoutMs = INIT_TIMEOUT_MS) {
|
|
351
|
+
return this.request(method, params, timeoutMs);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
exports.McpConnection = McpConnection;
|
|
355
|
+
function truncate(text, max) {
|
|
356
|
+
if (text.length <= max)
|
|
357
|
+
return text;
|
|
358
|
+
return text.slice(0, max) + `\n... [truncated ${text.length - max} chars]`;
|
|
359
|
+
}
|
|
360
|
+
// Safety net against ORPHANED MCP servers: every spawned server belongs to its
|
|
361
|
+
// own detached process group, which survives a normal process.exit(). No
|
|
362
|
+
// matter how the agent exits (TUI exit, unhandled error, process.exit in the
|
|
363
|
+
// CLI), live connections are killed here — close() is idempotent.
|
|
364
|
+
process.on('exit', () => {
|
|
365
|
+
for (const c of liveConnections)
|
|
366
|
+
c.close();
|
|
367
|
+
});
|
|
368
|
+
// ─── Registry bridge ─────────────────────────────────────────────────────────
|
|
369
|
+
/**
|
|
370
|
+
* Build registry Tool objects for an MCP server's catalog. Every tool is
|
|
371
|
+
* permission 'ask' — MCP servers are UNTRUSTED (konsep.md): even in yolo mode
|
|
372
|
+
* the PermissionManager keeps prompting (see security/permissions.ts).
|
|
373
|
+
*/
|
|
374
|
+
function mcpToolsFromSpecs(conn, specs) {
|
|
375
|
+
return specs.map((spec) => {
|
|
376
|
+
const name = mcpToolName(spec.server, spec.name);
|
|
377
|
+
const description = (spec.description ? spec.description.trim() : '') +
|
|
378
|
+
`\n\n[Provided by MCP server "${spec.server}" — third-party tool; args follow the server's JSON schema.]`;
|
|
379
|
+
const parameters = spec.inputSchema && Object.keys(spec.inputSchema).length > 0
|
|
380
|
+
? spec.inputSchema
|
|
381
|
+
: { type: 'object', properties: {}, additionalProperties: true };
|
|
382
|
+
return {
|
|
383
|
+
definition: { name, description, parameters },
|
|
384
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
385
|
+
async execute(args, ctx) {
|
|
386
|
+
ctx.onActivity?.('mcp', `${name} (via MCP server "${spec.server}")`);
|
|
387
|
+
// Permission gate BEFORE execution (shell.ts/git.ts pattern). The
|
|
388
|
+
// result-level label below is metadata for the TUI only — it must
|
|
389
|
+
// never be mistaken for an actual consent check.
|
|
390
|
+
const allowed = await ctx.permissions.decide({
|
|
391
|
+
tool: 'mcp',
|
|
392
|
+
summary: `${name} (via MCP server "${spec.server}")`,
|
|
393
|
+
level: 'ask',
|
|
394
|
+
});
|
|
395
|
+
if (!allowed) {
|
|
396
|
+
return {
|
|
397
|
+
output: `Permission denied by user:\n${name}`,
|
|
398
|
+
summary: `${name} → denied by user`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const r = await conn.callTool(spec.name, args);
|
|
403
|
+
return {
|
|
404
|
+
output: r.isError ? `MCP tool error: ${r.text}` : r.text,
|
|
405
|
+
data: { mcpServer: spec.server, mcpTool: spec.name, structured: r.data },
|
|
406
|
+
permission: 'ask',
|
|
407
|
+
summary: `${name} → ${r.isError ? 'error' : 'ok'}`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
return {
|
|
412
|
+
output: `ERROR: MCP server "${spec.server}" failed: ${err.message}`,
|
|
413
|
+
summary: `${name} → mcp failure`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
}
|
package/dist/tools/shell.js
CHANGED
|
@@ -13,6 +13,28 @@ const node_child_process_1 = require("node:child_process");
|
|
|
13
13
|
// watcher …) would otherwise keep the tool — and the whole agent loop —
|
|
14
14
|
// hanging until the grandchild dies ("sering nyangkut di shell", 2026-09-10).
|
|
15
15
|
const ORPHAN_GRACE_MS = 500;
|
|
16
|
+
// Every in-flight shell is tracked here so a hard exit can never leave one
|
|
17
|
+
// behind ("kadang hang keluar", 2026-09-14): a detached child holding the
|
|
18
|
+
// TTY pipes keeps the Node process alive AFTER the TUI main loop exits —
|
|
19
|
+
// the user sees a half-dead terminal. MCP already guards its servers the
|
|
20
|
+
// same way (mcp.ts orphan guard).
|
|
21
|
+
const activeShells = new Set();
|
|
22
|
+
let exitGuardInstalled = false;
|
|
23
|
+
function installExitGuard() {
|
|
24
|
+
if (exitGuardInstalled)
|
|
25
|
+
return;
|
|
26
|
+
exitGuardInstalled = true;
|
|
27
|
+
process.on('exit', () => {
|
|
28
|
+
for (const kill of activeShells) {
|
|
29
|
+
try {
|
|
30
|
+
kill();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* already gone */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
16
38
|
function runShell(command, cwd, opts = {}) {
|
|
17
39
|
return new Promise((resolve) => {
|
|
18
40
|
const timeoutMs = opts.timeoutMs || 120_000;
|
|
@@ -46,6 +68,28 @@ function runShell(command, cwd, opts = {}) {
|
|
|
46
68
|
/* already gone */
|
|
47
69
|
}
|
|
48
70
|
};
|
|
71
|
+
// Abort support: when the user interrupts the run (Esc / Ctrl+C) the
|
|
72
|
+
// whole shell tree is killed immediately and the tool resolves — the
|
|
73
|
+
// agent loop previously kept AWAITING a command the user had already
|
|
74
|
+
// abandoned (interrupt felt "macet" until the command finished on its
|
|
75
|
+
// own, 2026-09-14).
|
|
76
|
+
const onAbort = () => {
|
|
77
|
+
timedOut = true; // same reporting as a timeout: killed, not finished
|
|
78
|
+
killTree();
|
|
79
|
+
// 'exit' fires once the shell dies and finishes the promise.
|
|
80
|
+
};
|
|
81
|
+
if (opts.signal) {
|
|
82
|
+
if (opts.signal.aborted)
|
|
83
|
+
onAbort();
|
|
84
|
+
else
|
|
85
|
+
opts.signal.addEventListener('abort', onAbort, { once: true });
|
|
86
|
+
}
|
|
87
|
+
activeShells.add(killTree);
|
|
88
|
+
const releaseShell = () => {
|
|
89
|
+
activeShells.delete(killTree);
|
|
90
|
+
if (opts.signal)
|
|
91
|
+
opts.signal.removeEventListener('abort', onAbort);
|
|
92
|
+
};
|
|
49
93
|
const finish = (code) => {
|
|
50
94
|
if (settled)
|
|
51
95
|
return;
|
|
@@ -53,8 +97,10 @@ function runShell(command, cwd, opts = {}) {
|
|
|
53
97
|
clearTimeout(timer);
|
|
54
98
|
if (graceTimer)
|
|
55
99
|
clearTimeout(graceTimer);
|
|
100
|
+
releaseShell();
|
|
56
101
|
resolve({ stdout, stderr, exitCode: code, timedOut });
|
|
57
102
|
};
|
|
103
|
+
installExitGuard();
|
|
58
104
|
const timer = setTimeout(() => {
|
|
59
105
|
timedOut = true;
|
|
60
106
|
killTree(); // was: child.kill() only — orphaned children kept the pipe open and the promise hanging past the timeout
|
|
@@ -142,6 +188,7 @@ function createShellTool() {
|
|
|
142
188
|
}
|
|
143
189
|
const result = await runShell(command, ctx.cwd, {
|
|
144
190
|
timeoutMs: Number(args.timeout_ms) || 120_000,
|
|
191
|
+
signal: ctx.signal,
|
|
145
192
|
});
|
|
146
193
|
const maxOut = ctx.maxOutput || 30_000;
|
|
147
194
|
let out = '';
|
package/dist/tui/app.js
CHANGED
|
@@ -57,6 +57,7 @@ const themes_1 = require("./themes");
|
|
|
57
57
|
const diff_1 = require("./diff");
|
|
58
58
|
const commands_1 = require("./commands");
|
|
59
59
|
const suggest_1 = require("./suggest");
|
|
60
|
+
const mcp_1 = require("../tools/mcp");
|
|
60
61
|
const provider_1 = require("../providers/provider");
|
|
61
62
|
const planner_1 = require("../agent/planner");
|
|
62
63
|
const extras_1 = require("../tools/extras");
|
|
@@ -776,7 +777,13 @@ class App {
|
|
|
776
777
|
this.markDirty();
|
|
777
778
|
const p = this.pendingPrompt;
|
|
778
779
|
this.pendingPrompt = '';
|
|
779
|
-
await this.submit(p)
|
|
780
|
+
// Fire-and-forget: dulu `await this.submit(p)` menjadikan SELURUH agent
|
|
781
|
+
// run bagian dari tick() — main loop render di cli/index.ts menunggu
|
|
782
|
+
// di bawahnya, jadi `vector "task"` tampak BEKU (tanpa spinner, tanpa
|
|
783
|
+
// Working line, tanpa respons keyboard) sampai run selesai. Jalur
|
|
784
|
+
// Enter/followup lewat handleKey memang fire-and-forget sejak awal;
|
|
785
|
+
// initial prompt kini disamakan.
|
|
786
|
+
void this.submit(p).catch(() => { });
|
|
780
787
|
}
|
|
781
788
|
if (this.planToExecute) {
|
|
782
789
|
this.markDirty();
|
|
@@ -2159,6 +2166,13 @@ class App {
|
|
|
2159
2166
|
case '/settings':
|
|
2160
2167
|
this.modal = { type: 'settings' };
|
|
2161
2168
|
break;
|
|
2169
|
+
// MCP dialog (konsep.md 2026-09-13): probe each configured server
|
|
2170
|
+
// one-shot (connect → tools/list → close, nothing left running) and
|
|
2171
|
+
// show the result read-only. No server process is ever left behind by
|
|
2172
|
+
// this dialog — live connections belong to the Agent (initMcpServers).
|
|
2173
|
+
case '/mcp':
|
|
2174
|
+
await this.openMcpDialog();
|
|
2175
|
+
break;
|
|
2162
2176
|
case '/status':
|
|
2163
2177
|
this.modal = { type: 'status' };
|
|
2164
2178
|
break;
|
|
@@ -2570,6 +2584,33 @@ class App {
|
|
|
2570
2584
|
this.modal = { type: 'none' };
|
|
2571
2585
|
m.resolve({ allow: true, remember: false });
|
|
2572
2586
|
}
|
|
2587
|
+
else if (ev.name === 'ctrl_c') {
|
|
2588
|
+
// Dulu Ctrl+C di sini DIABAIKAN (handleKey hanya meng-intercept
|
|
2589
|
+
// saat modal.type === 'none') — modal Permission tampak "macet":
|
|
2590
|
+
// berapa kali pun ditekan tidak ada yang terjadi, dan user tidak
|
|
2591
|
+
// bisa menyelamatkan run dari tool yang mau dieksekusi. Kini
|
|
2592
|
+
// Ctrl+C menolak izin SEKALIGUS menghentikan run (konvensi
|
|
2593
|
+
// interrupt), lalu arm exit 2-detik persis jalur Ctrl+C biasa.
|
|
2594
|
+
const m = modal;
|
|
2595
|
+
this.modal = { type: 'none' };
|
|
2596
|
+
m.resolve({ allow: false, remember: false });
|
|
2597
|
+
const now = Date.now();
|
|
2598
|
+
this.ctrlCArmed = true;
|
|
2599
|
+
this.ctrlCArmedAt = now;
|
|
2600
|
+
setTimeout(() => {
|
|
2601
|
+
if (this.ctrlCArmedAt === now)
|
|
2602
|
+
this.ctrlCArmed = false;
|
|
2603
|
+
}, App.CTRL_C_WINDOW_MS);
|
|
2604
|
+
if (this.running) {
|
|
2605
|
+
this.wasAbortedByUser = true;
|
|
2606
|
+
this.abortController?.abort();
|
|
2607
|
+
this.status = 'Stopping…';
|
|
2608
|
+
this.statusColor = terminal_1.ANSI.yellow;
|
|
2609
|
+
this.running = false;
|
|
2610
|
+
this.applyInterruptionNotice();
|
|
2611
|
+
this.addSystem('Interrupted — press Ctrl+C once more within 2s to exit.');
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2573
2614
|
else if (ev.name === 'escape' || (ev.name === 'char' && (ev.char === 'n' || ev.char === 'N'))) {
|
|
2574
2615
|
const m = modal;
|
|
2575
2616
|
this.modal = { type: 'none' };
|
|
@@ -2838,6 +2879,54 @@ class App {
|
|
|
2838
2879
|
}
|
|
2839
2880
|
}
|
|
2840
2881
|
// ─── Message helpers ──────────────────────────────────────────
|
|
2882
|
+
/**
|
|
2883
|
+
* /mcp dialog: probe every configured MCP server one-shot (konsep.md
|
|
2884
|
+
* 2026-09-13). Uses mcpProbeTools (connect → tools/list → close) so the
|
|
2885
|
+
* dialog NEVER leaves a server process running — live connections belong
|
|
2886
|
+
* to the Agent. Per-server failures are contained (warn + skip); the
|
|
2887
|
+
* probe timeout is 6s so a wedged server can't freeze the TUI for long.
|
|
2888
|
+
*/
|
|
2889
|
+
async openMcpDialog() {
|
|
2890
|
+
const servers = Object.entries(this.agent.config.mcpServers ?? {});
|
|
2891
|
+
if (servers.length === 0) {
|
|
2892
|
+
this.addSystem('MCP: no servers configured. Add one to ~/.vector/config.json:\n' +
|
|
2893
|
+
' "mcpServers": { "everything": { "command": "npx",\n' +
|
|
2894
|
+
' "args": ["-y", "@modelcontextprotocol/server-everything"] } }');
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
this.addSystem(`MCP: probing ${servers.length} server(s)…`);
|
|
2898
|
+
const lines = [];
|
|
2899
|
+
for (const [id, cfg] of servers) {
|
|
2900
|
+
if (!cfg || !cfg.command) {
|
|
2901
|
+
lines.push(` ${id}: (no command — skipped)`);
|
|
2902
|
+
continue;
|
|
2903
|
+
}
|
|
2904
|
+
if (cfg.enabled === false) {
|
|
2905
|
+
lines.push(` ${id}: disabled (cfg.enabled=false)`);
|
|
2906
|
+
continue;
|
|
2907
|
+
}
|
|
2908
|
+
const t0 = Date.now();
|
|
2909
|
+
const probe = await (0, mcp_1.mcpProbeTools)(id, cfg, 6000);
|
|
2910
|
+
const ms = Date.now() - t0;
|
|
2911
|
+
if (!probe.ok) {
|
|
2912
|
+
lines.push(` ${id}: FAILED (${ms}ms) — ${probe.error ?? 'unknown error'}`);
|
|
2913
|
+
continue;
|
|
2914
|
+
}
|
|
2915
|
+
const args = cfg.args?.length ? ` ${cfg.args.join(' ')}` : '';
|
|
2916
|
+
lines.push(` ${id}: ok (${ms}ms) — command: ${cfg.command}${args}`);
|
|
2917
|
+
if (probe.tools.length === 0) {
|
|
2918
|
+
lines.push(' tools: (none advertised)');
|
|
2919
|
+
}
|
|
2920
|
+
else {
|
|
2921
|
+
for (const t of probe.tools) {
|
|
2922
|
+
lines.push(` - ${(0, mcp_1.mcpToolName)(id, t.name)}${t.description ? ` — ${t.description.split('\n')[0]}` : ''}`);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
lines.push('');
|
|
2927
|
+
lines.push('Registered tools stay permission: ask — MCP tools are never auto-approved.');
|
|
2928
|
+
this.modal = { type: 'text', title: 'MCP servers', body: lines };
|
|
2929
|
+
}
|
|
2841
2930
|
addSystem(text) {
|
|
2842
2931
|
this.markDirty();
|
|
2843
2932
|
this.messages.push({ kind: 'system', content: text, ts: Date.now() });
|
package/dist/tui/commands.js
CHANGED
|
@@ -25,6 +25,7 @@ exports.COMMANDS = [
|
|
|
25
25
|
{ id: 'session', description: 'manage sessions (list / switch)' },
|
|
26
26
|
{ id: 'session-name', description: 'set session name (arg optional)' },
|
|
27
27
|
{ id: 'settings', description: 'view settings (provider, model, limits, files)' },
|
|
28
|
+
{ id: 'mcp', description: 'MCP servers — probe config, list tools per server' },
|
|
28
29
|
{ id: 'status', description: 'agent status (running, cwd, session)' },
|
|
29
30
|
{ id: 'clear', description: 'clear chat (keep the same session id)' },
|
|
30
31
|
{ id: 'new', description: 'fresh conversation — new session id (old one saved)' },
|
package/package.json
CHANGED