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 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
@@ -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
@@ -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) {
@@ -103,7 +103,16 @@ function saveSession(name, messages, opts) {
103
103
  })),
104
104
  }));
105
105
  const data = { meta, messages: sanitized };
106
- fs.writeFileSync(fileFor(name), JSON.stringify(data, null, 2));
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) {
@@ -131,7 +131,15 @@ function loadConfig() {
131
131
  }
132
132
  function saveConfig(config) {
133
133
  (0, paths_1.ensureDirs)();
134
- fs.writeFileSync((0, paths_1.getConfigPath)(), JSON.stringify(config, null, 2) + '\n');
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
  }
@@ -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) → sekarang kilo-auto/free.
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 = 'kilo-auto/free';
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: 'Kilo Auto (free)' },
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
+ }
@@ -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() });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agen-vektor",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
4
4
  "description": "VectorHead (agen-vektor) — AI Coding Agent CLI/TUI for Linux & Termux. Multi-provider, tool calling, session, permission system.",
5
5
  "type": "commonjs",
6
6
  "bin": {