agen-vektor 0.3.13 → 0.3.15

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
@@ -50,14 +50,14 @@ incrementally with no flicker.
50
50
  ## Contents
51
51
 
52
52
  - [Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Command line](#command-line) · [TUI commands](#tui-commands)
53
- - [Provider configuration](#provider-configuration) · [Themes](#themes) · [Security model](#security-model) · [Architecture](#architecture) · [Development](#development)
53
+ - [Provider configuration](#provider-configuration) · [Themes](#themes) · [Security model](#security-model) · [Privacy](#privacy) · [Architecture](#architecture) · [Development](#development)
54
54
  - [Testing](#testing) · [Troubleshooting](#troubleshooting) · [Roadmap](#roadmap) · [License](#license)
55
55
 
56
56
  ## Features
57
57
 
58
58
  - **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.
59
59
  - **Agent loop** — plan → inspect → tool call → result → review → fix → retry until done (with an iteration cap).
60
- - **Tools** — read/write/edit files, list directories, search files, run shell commands, git, fetch web pages.
60
+ - **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).
61
61
  - **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.
62
62
  - **Multi-provider** — OpenAI, Anthropic, Gemini, OpenRouter, Ollama, and any OpenAI-compatible custom endpoint.
63
63
  - **Sessions & memory** — conversations persist to `~/.vector/sessions/`; a relaunch in the same project resumes the conversation automatically (agent keeps the last replies in context), `/continue` resumes any session, and `Ctrl+L`/`/clear` starts fresh.
@@ -232,7 +232,7 @@ here is OpenAI-compatible):
232
232
  AgentRouter uses `deepseek-v4-flash` while BitDeer uses
233
233
  `deepseek-ai/DeepSeek-V4-Flash`).
234
234
  - `/provider` lists built-ins + every configured custom and offers
235
- **➕ Add custom provider** (id → name → base URL → key). `/model` then
235
+ **Add custom provider** (id → name → base URL → key). `/model` then
236
236
  auto-discovers models from the endpoint's `/models` route and falls back
237
237
  to the `models` map above.
238
238
  - The old single-custom setup (`provider: "custom"` + `apiUrl`/`customName`
@@ -307,6 +307,24 @@ Every shell command is classified:
307
307
 
308
308
  `--yolo` auto-approves ASK-level actions but still prompts for DANGEROUS and refuses BLOCKED ones. API keys are never printed to the terminal, logs, diffs, or session history.
309
309
 
310
+ ## Privacy
311
+
312
+ **VectorHead collects nothing.** No telemetry, no analytics, no crash reporting, no usage tracking — there is no code in the app that could phone home. Every network destination in the source is auditable:
313
+
314
+ | Destination | When |
315
+ |---|---|
316
+ | Your configured AI provider (OpenAI, Anthropic, Gemini, OpenRouter, Ollama, custom) | Only when you chat — with **your own** API key |
317
+ | `vector-tui.methatech.eu.org` (VectorHead Free gateway) | Only if you pick the free tier; the shared provider key stays server-side, users never see it |
318
+ | Brave / Exa / Serper | Only when you use the web-search tool with your own key |
319
+ | `vector-tui.methatech.eu.org/v1/web-search` | Web search when you have NO search key of your own — the query is relayed by the gateway; your own key, when set, always takes precedence and bypasses the relay |
320
+ | Raw model catalog (public JSON) | To populate the free-tier model list |
321
+
322
+ **Stored locally** (all in `~/.vector/`): `config.json` (preferences, no secrets), `credentials.json` (only keys *you* enter, file mode 0600), sessions, memory, logs. Nothing else is written anywhere.
323
+
324
+ **No device identity.** The app does not generate a device id or send any tracking header (the old anonymous `device-id` + `X-Vector-Device` pair was fully removed in 0.3.14).
325
+
326
+ **The agent guards itself against secrets** — its own file tools refuse to read or write `.env`, private keys, `credentials.json`, kubeconfig, and similar files (override only via explicit `VECTOR_ALLOW_SENSITIVE=1`), so a prompt cannot accidentally exfiltrate them.
327
+
310
328
  ## Architecture
311
329
 
312
330
  ```
@@ -1,44 +1,6 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.Agent = void 0;
37
- /**
38
- * Agent — high-level facade bundling provider, tools, permissions
39
- * and the loop into a single object the TUI/CLI can drive.
40
- */
41
- const fs = __importStar(require("node:fs"));
42
4
  const factory_1 = require("../providers/factory");
43
5
  const registry_1 = require("../tools/registry");
44
6
  const filesystem_1 = require("../tools/filesystem");
@@ -47,10 +9,10 @@ const shell_1 = require("../tools/shell");
47
9
  const git_1 = require("../tools/git");
48
10
  const web_1 = require("../tools/web");
49
11
  const extras_1 = require("../tools/extras");
12
+ const design_lab_1 = require("../tools/design-lab");
50
13
  const apply_patch_1 = require("../tools/apply-patch");
51
14
  const permissions_1 = require("../security/permissions");
52
15
  const config_1 = require("../config/config");
53
- const paths_1 = require("../utils/paths");
54
16
  const free_tier_1 = require("../config/free-tier");
55
17
  const loop_1 = require("./loop");
56
18
  const rules_1 = require("./rules");
@@ -64,14 +26,13 @@ class Agent {
64
26
  permissions;
65
27
  cwd;
66
28
  constructor(opts) {
67
- // Free tier (vectorhead-free): define the provider entry, expose the
68
- // anonymous device id for the {env:} header template, and default a
69
- // FIRST RUN (no config.json yet) to the free tier so onboarding is
70
- // "install & pick a model" with zero keys. Existing configs keep their
71
- // active provider; only the free ENTRY is (re)defined.
72
- const firstRun = !opts.config && (0, free_tier_1.isFirstRun)(fs.existsSync((0, paths_1.getConfigPath)()));
29
+ // Free tier (vectorhead-free): the provider ENTRY is (re)defined here on
30
+ // every run (repair), and FIRST-RUN provider/model selection now happens
31
+ // in loadConfig() itself (2026-09-06) — the old Agent-side seed never ran
32
+ // on the real CLI path because buildAgent() always passes a config, so
33
+ // fresh installs landed on 'openrouter' (401 until /connect). No per-
34
+ // device identity: the gateway requires none (removed 2026-09-05).
73
35
  const seeded = (0, config_1.effectiveConfig)(opts.config);
74
- (0, free_tier_1.exportDeviceIdEnv)();
75
36
  if ((0, free_tier_1.ensureFreeProvider)(seeded)) {
76
37
  try {
77
38
  (0, config_1.saveConfig)(seeded);
@@ -80,10 +41,6 @@ class Agent {
80
41
  /* read-only home — the in-memory def still works this session */
81
42
  }
82
43
  }
83
- if (firstRun) {
84
- seeded.provider = free_tier_1.FREE_PROVIDER_ID;
85
- seeded.model = free_tier_1.FREE_DEFAULT_MODEL;
86
- }
87
44
  this.config = seeded;
88
45
  this.cwd = opts.cwd || process.cwd();
89
46
  // Seed Hermes-style SOUL.md/MEMORY.md/USER.md/SKILL.md on first run
@@ -106,6 +63,8 @@ class Agent {
106
63
  // Freebuff-style extras: glob, read_subtree, task_completed,
107
64
  // suggest_followups, render_ui.
108
65
  ...(0, extras_1.createExtrasTools)(),
66
+ // Design-lab gateway pipeline (Codespace + ui-ux-pro-max): run/status/result.
67
+ ...(0, design_lab_1.createDesignLabTools)(),
109
68
  // Unified-diff editing (Freebuff/Codebuff apply_patch format).
110
69
  (0, apply_patch_1.createApplyPatchTool)(),
111
70
  // Hermes-style progressive-disclosure skills (list_skills / read_skill).
@@ -167,6 +126,7 @@ class Agent {
167
126
  onFinal: cb.onFinal,
168
127
  onError: cb.onError,
169
128
  onActivity: cb.onActivity,
129
+ onRetryReset: cb.onRetryReset,
170
130
  };
171
131
  // Permissions callback must be live-updated as well
172
132
  this.permissions.setMode(this.config.permissionMode);
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sanitizeHistory = sanitizeHistory;
3
4
  exports.runAgentLoop = runAgentLoop;
4
5
  exports.compactMessages = compactMessages;
5
6
  exports.estimateContextSize = estimateContextSize;
@@ -18,6 +19,78 @@ const light_1 = require("./light");
18
19
  const prompts_1 = require("./prompts");
19
20
  const rules_1 = require("./rules");
20
21
  const skills_1 = require("./skills");
22
+ /**
23
+ * Repair a history window so it stays a VALID OpenAI-style conversation.
24
+ *
25
+ * The TUI continues each turn with `lastMessages.slice(-40)`. That slice can
26
+ * land MID-EXCHANGE: a 'tool' result whose assistant.tool_calls was cut off,
27
+ * or an assistant.tool_calls whose results were cut off. Strict OpenAI-
28
+ * compatible gateways reject the whole request with 400 ("tool_calls without
29
+ * a response" / "unknown tool_call_id"), which the user experiences as the
30
+ * agent dying "mid-task" after a few long turns — even though the current
31
+ * task had nothing wrong with it.
32
+ *
33
+ * Rules enforced here:
34
+ * 1. tool results with no preceding matching tool_calls → dropped (orphans);
35
+ * 2. an assistant.tool_calls interrupted by the next user message (or the
36
+ * end of history) is TRIMMED to the calls that were actually answered
37
+ * (dropped entirely when none were);
38
+ * 3. leading tool results (their assistant was sliced away) → dropped.
39
+ */
40
+ function sanitizeHistory(history) {
41
+ const msgs = history.filter((m) => m.role !== 'system');
42
+ const out = [];
43
+ // toolCallIds of the MOST RECENT assistant.tool_calls still awaiting results.
44
+ let expected = new Set();
45
+ /** Trim the latest assistant's toolCalls down to the answered subset. */
46
+ const trimPendingAssistant = () => {
47
+ if (expected.size === 0)
48
+ return;
49
+ for (let i = out.length - 1; i >= 0; i--) {
50
+ const prev = out[i];
51
+ if (prev.role === 'assistant' && prev.toolCalls && prev.toolCalls.length > 0) {
52
+ const answered = prev.toolCalls.filter((tc) => !expected.has(tc.id));
53
+ if (answered.length === prev.toolCalls.length)
54
+ break; // fully answered
55
+ if (answered.length === 0)
56
+ out.splice(i, 1);
57
+ else
58
+ out[i] = { ...prev, toolCalls: answered };
59
+ break;
60
+ }
61
+ if (prev.role === 'user')
62
+ break;
63
+ }
64
+ expected = new Set();
65
+ };
66
+ for (const m of msgs) {
67
+ if (m.role === 'assistant') {
68
+ if (m.toolCalls && m.toolCalls.length > 0) {
69
+ out.push({ ...m, toolCalls: [...m.toolCalls] });
70
+ expected = new Set(m.toolCalls.map((tc) => tc.id));
71
+ }
72
+ else {
73
+ out.push(m);
74
+ }
75
+ continue;
76
+ }
77
+ if (m.role === 'tool') {
78
+ if (!m.toolCallId || !expected.has(m.toolCallId))
79
+ continue; // orphan result
80
+ out.push(m);
81
+ expected.delete(m.toolCallId);
82
+ continue;
83
+ }
84
+ // user: close out any assistant still awaiting results first.
85
+ trimPendingAssistant();
86
+ out.push(m);
87
+ }
88
+ trimPendingAssistant(); // run died mid tool-execution (history tail)
89
+ let start = 0;
90
+ while (start < out.length && out[start].role === 'tool')
91
+ start++;
92
+ return start > 0 ? out.slice(start) : out;
93
+ }
21
94
  async function runAgentLoop(userRequest, opts, callbacks = {}) {
22
95
  const { provider, tools, permissions, config, cwd, signal } = opts;
23
96
  const maxIterations = config.maxIterations;
@@ -25,7 +98,7 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
25
98
  // On a continuation (history present) the project context is not
26
99
  // re-injected: the model already saw it in the first turn, and repeating
27
100
  // the folder listing on every turn makes the agent re-explain/echo it.
28
- const history = (opts.history ?? []).filter((m) => m.role !== 'system');
101
+ const history = sanitizeHistory(opts.history ?? []);
29
102
  const isContinuation = history.length > 0;
30
103
  // Casual small talk ("hallo", "hi", "apa kabar", "kamu siapa", …) gets a
31
104
  // plain, direct answer: no project context, no folder listing, no skills
@@ -95,7 +168,11 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
95
168
  let final = null;
96
169
  for await (const ev of provider.stream(params)) {
97
170
  if (ev.type === 'delta' && ev.delta) {
98
- content += ev.delta;
171
+ // Guarded merge (not naive +=): gateways that resend the full text
172
+ // so far must not accumulate into a doubled answer ("double chat",
173
+ // reported 2026-09-08 — the TUI guarded its own display but this
174
+ // accumulator fed the no-done fallback path naively).
175
+ content = (0, provider_1.mergeStreamDelta)(content, ev.delta);
99
176
  callbacks.onDelta?.(ev.delta);
100
177
  }
101
178
  else if (ev.type === 'delta' && ev.reasoning) {
@@ -122,6 +199,13 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
122
199
  }
123
200
  }
124
201
  if (final) {
202
+ // Defense in depth: a provider that still accumulates naively (custom
203
+ ///mock stream implementations) hands us done.result.content exactly
204
+ // doubled when its LAST delta resent the full text — "X…" + "X…" =
205
+ // content+content (custom/mock stream implementations). That shape is never a genuine reply; trust the
206
+ // guarded stream accumulation instead.
207
+ if (content && final.content === content + content)
208
+ final.content = content;
125
209
  if (final.reasoning && !lastReasoning) {
126
210
  // Reasoning appeared ONLY in the final result (no deltas were
127
211
  // streamed): emit once so the TUI still gets the thinking card.
@@ -154,7 +238,22 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
154
238
  callbacks.onStatus?.(iterations === 1 ? 'Thinking' : 'Thinking…');
155
239
  let result;
156
240
  try {
157
- result = await (0, provider_1.withRetry)(doCall, { retries: config.maxRetries, signal });
241
+ result = await (0, provider_1.withRetry)(doCall, {
242
+ retries: config.maxRetries,
243
+ signal,
244
+ onRetry: (attempt, err, waitMs) => {
245
+ // Surface the silent retry loop: without this the user stares at a
246
+ // frozen spinner for 1s+2s+4s while the gateway flakes.
247
+ // friendlyApiError keeps provider bodies (JSON, stack-ish text) out
248
+ // of the status bar — the raw dump leaked internals and pushed the
249
+ // timer/mode labels off the row.
250
+ callbacks.onStatus?.(`Retrying ${attempt}/${config.maxRetries} — ${(0, provider_1.friendlyApiError)(err)}, waiting ${Math.round(waitMs / 1000)}s`);
251
+ // A partially-streamed reply from the FAILED attempt must not leak
252
+ // into the UI or the next attempt's reasoning accumulator.
253
+ lastReasoning = '';
254
+ callbacks.onRetryReset?.();
255
+ },
256
+ });
158
257
  }
159
258
  catch (err) {
160
259
  if (err.name === 'AbortError' || err.message === 'aborted') {
@@ -166,20 +265,21 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
166
265
  callbacks.onError?.(new Error('API authentication failed (401). Check your API key.'));
167
266
  callbacks.onStatus?.('Auth error');
168
267
  stopped = true;
169
- finalContent = `⚠️ API authentication failed. Run \`vector /provider\` to configure your API key.`;
268
+ finalContent = `! API authentication failed. Run \`vector /provider\` to configure your API key.`;
170
269
  break;
171
270
  }
172
271
  if (err instanceof provider_1.ProviderError && err.status === 429) {
173
272
  callbacks.onError?.(new Error('Rate limited (429) by the provider.'));
174
273
  callbacks.onStatus?.('Rate limited');
175
274
  stopped = true;
176
- finalContent = '⚠️ Rate limited by the provider. Wait a moment and try again.';
275
+ finalContent = '! Rate limited by the provider. Wait a moment and try again.';
177
276
  break;
178
277
  }
179
278
  callbacks.onError?.(err);
180
279
  callbacks.onStatus?.('Error');
181
280
  stopped = true;
182
- finalContent = `⚠️ Error: ${err.message}`;
281
+ // Same sanitation as the retry line: no raw HTTP/JSON body in chat.
282
+ finalContent = `! Error: ${(0, provider_1.friendlyApiError)(err)}`;
183
283
  break;
184
284
  }
185
285
  // Defense in depth: a misbehaving gateway can yield a sparse toolCalls
@@ -211,12 +311,21 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
211
311
  ]);
212
312
  const runOne = async (tc) => {
213
313
  callbacks.onToolCall?.(tc.name, tc.arguments, tc.id);
214
- const toolResult = await tools.execute(tc.name, tc.arguments, {
215
- cwd,
216
- permissions,
217
- onActivity: callbacks.onActivity,
218
- maxOutput: 30_000,
219
- });
314
+ let toolResult;
315
+ try {
316
+ toolResult = await tools.execute(tc.name, tc.arguments, {
317
+ cwd,
318
+ permissions,
319
+ onActivity: callbacks.onActivity,
320
+ maxOutput: 30_000,
321
+ });
322
+ }
323
+ catch (err) {
324
+ // Freebuff ToolCallItem: a FAILED call still leaves its card with a
325
+ // preview (input-built) but no "running" suffix — surface the error
326
+ // as a failed result so the TUI can finalize the card.
327
+ toolResult = { output: `ERROR: ${err.message}`, summary: 'tool error' };
328
+ }
220
329
  return { tc, toolResult };
221
330
  };
222
331
  try {
@@ -266,6 +375,24 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
266
375
  }
267
376
  continue;
268
377
  }
378
+ // No tool calls → final answer. GUARD: an EMPTY response with no tool
379
+ // calls is a protocol glitch (gateway truncation, overloaded edge, a
380
+ // model that emitted only reasoning) — treating it as the final answer
381
+ // silently ends the run mid-task (reported as "macet di tengah jalan").
382
+ if (!result.content.trim() && result.toolCalls.length === 0) {
383
+ const empty = new provider_1.ProviderError('Upstream returned an empty response (no content, no tool calls).', 0, true);
384
+ if (iterations >= maxIterations) {
385
+ // No budget left for another model call — surface honestly.
386
+ callbacks.onError?.(empty);
387
+ callbacks.onStatus?.('Error');
388
+ stopped = true;
389
+ finalContent = `! ${empty.message}`;
390
+ break;
391
+ }
392
+ callbacks.onStatus?.('Empty response — retrying');
393
+ lastReasoning = '';
394
+ continue; // consume the iteration, ask the model again
395
+ }
269
396
  // No tool calls → final answer
270
397
  finalContent = result.content;
271
398
  callbacks.onStatus?.('Completed');
@@ -276,7 +403,7 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
276
403
  if (iterations >= maxIterations) {
277
404
  callbacks.onStatus?.('Iteration limit');
278
405
  if (!finalContent) {
279
- finalContent = `⚠️ Reached the maximum of ${maxIterations} iterations without completing the task. Run /settings to raise the limit or refine the request.`;
406
+ finalContent = `! Reached the maximum of ${maxIterations} iterations without completing the task. Run /settings to raise the limit or refine the request.`;
280
407
  }
281
408
  }
282
409
  return { content: finalContent, iterations, toolCalls, stopped, aborted, messages };
@@ -59,7 +59,17 @@ Rules:
59
59
  write_todos with ["Buat file", "Baca isinya"] before calling write_file.
60
60
  The ONLY exceptions — greetings, plain questions, small talk, and
61
61
  requests needing just ONE tool call: answer those directly with no
62
- write_todos.`;
62
+ write_todos.
63
+ 14. Suggested followups (Freebuff parity): end EVERY task response by
64
+ calling suggest_followups with exactly 3 clickable followup prompts
65
+ the user is likely to want next. Call it LAST — after the final
66
+ answer text and after all other tool calls. Each followup: a short
67
+ label (2–5 words) and a prompt phrased in the user's voice, one
68
+ sentence naming the OUTCOME (never the steps, file paths, or design
69
+ decisions). Make them specific to what you just did — alternatives,
70
+ related features, testing/verification you could run, or "Continue
71
+ with the next step" when a plan has more steps. Skip it only when
72
+ there is no sensible next step (greetings, goodbyes).`;
63
73
  /**
64
74
  * Identity line injected into the system prompt so the model can answer
65
75
  * "which model/provider are you?" truthfully (it has no other way to know).
@@ -286,7 +286,11 @@ metadata:
286
286
  1. \`git status --short\` and \`git log --oneline -5\` to understand state.
287
287
  2. Review changes: \`git diff --stat\`, then \`git diff\` for details.
288
288
  3. Stage only relevant files: \`git add <paths>\` (never \`git add -A\` blindly).
289
- 4. Commit with a concise message describing WHY, not just WHAT.
289
+ 4. Commit with a concise message describing WHY, not just WHAT. When committing
290
+ on the user's behalf, end the message with the attribution footer from the
291
+ git tool guidance (COMMIT_ATTRIBUTION_FOOTER + COMMIT_ATTRIBUTION_COAUTHOR,
292
+ see \`src/tools/git.ts\`) — unless the commit lands in somebody else's
293
+ repository on their behalf (attribution OFF, no trailer at all).
290
294
  5. NEVER run \`git push\` unless the user explicitly asks.
291
295
 
292
296
  ## Pitfalls
package/dist/cli/index.js CHANGED
@@ -184,7 +184,7 @@ async function runNonInteractive(opts) {
184
184
  catch {
185
185
  preview = args.slice(0, 80);
186
186
  }
187
- console.log(`${terminal_1.ANSI.brightYellow}⚙ ${t}${terminal_1.ANSI.reset} ${terminal_1.ANSI.dim}${preview}${terminal_1.ANSI.reset}`);
187
+ console.log(`${terminal_1.ANSI.brightYellow}· ${t}${terminal_1.ANSI.reset} ${terminal_1.ANSI.dim}${preview}${terminal_1.ANSI.reset}`);
188
188
  },
189
189
  });
190
190
  // Print the final reply. The loop uses the non-streaming path, so the
@@ -198,7 +198,7 @@ async function runNonInteractive(opts) {
198
198
  : reply + '\n');
199
199
  }
200
200
  else if (result.stopped || result.aborted) {
201
- console.log(`${terminal_1.ANSI.yellow}⏹ Stopped — no reply was produced.${terminal_1.ANSI.reset}`);
201
+ console.log(`${terminal_1.ANSI.yellow}Stopped — no reply was produced.${terminal_1.ANSI.reset}`);
202
202
  }
203
203
  console.log(`\n${terminal_1.ANSI.dim}— ${result.iterations} iterations, ${result.toolCalls} tool calls${terminal_1.ANSI.reset}`);
204
204
  // Persist the conversation (per-project session by default) so the
@@ -254,12 +254,12 @@ async function runTui(opts) {
254
254
  // into config via effectiveConfig inside the Agent), then config.json.
255
255
  const theme = opts.theme || config.theme || 'vectorhead';
256
256
  if (!(0, themes_1.applyThemeByName)(theme)) {
257
- console.warn(`${terminal_1.ANSI.yellow}⚠${terminal_1.ANSI.reset} Unknown theme "${theme}" — using vectorhead. Put custom themes in ~/.vector/themes/`);
257
+ console.warn(`${terminal_1.ANSI.yellow}!${terminal_1.ANSI.reset} Unknown theme "${theme}" — using vectorhead. Put custom themes in ~/.vector/themes/`);
258
258
  (0, themes_1.applyThemeByName)('vectorhead');
259
259
  }
260
260
  const hasKey = (0, providers_1.providerConfigured)(config, config.provider);
261
261
  if (!hasKey) {
262
- console.log(`${terminal_1.ANSI.yellow}⚠${terminal_1.ANSI.reset} Provider "${(0, factory_1.displayProviderName)(config)}" is not fully configured.\n` +
262
+ console.log(`${terminal_1.ANSI.yellow}!${terminal_1.ANSI.reset} Provider "${(0, factory_1.displayProviderName)(config)}" is not fully configured.\n` +
263
263
  `Set ${providerEnv(config.provider)} in your environment, or run /provider inside VectorHead to add one.\n`);
264
264
  }
265
265
  // Resolve session name — default to a persistent per-project session so
@@ -317,31 +317,52 @@ async function runTui(opts) {
317
317
  // frame rows by App.computeFrame, so a selector navigation is diffed per
318
318
  // ROW like everything else. The old modal branch forced a full-screen
319
319
  // repaint per keystroke (lastFrameRows = []) — the ↑/↓ "berkedip" in
320
- // /model. The cursor is HIDDEN while a modal is open (menus don't show a
321
- // text cursor) and restored at the input row when it closes.
320
+ // /model. The hardware cursor is now HIDDEN ALWAYS (Freebuff/opentui
321
+ // parity) — see the note in doRender below.
322
322
  let lastFrameRows = [];
323
- let lastCursorPos = '';
323
+ let lastCols = 0;
324
+ let lastRows = 0;
324
325
  const doRender = () => {
325
326
  const snap = app.frameSnapshot();
326
327
  const rows = snap.rows;
327
- const out = [];
328
- for (let i = 0; i < rows.length; i++) {
329
- if (lastFrameRows[i] !== rows[i]) {
330
- out.push((0, terminal_1.cursorTo)(i + 1, 1) + terminal_1.ANSI.clearLineEnd + rows[i]);
331
- }
332
- }
333
- lastFrameRows = rows;
334
- if (app.modalActive()) {
335
- out.push(terminal_1.ANSI.hideCursor);
336
- lastCursorPos = '';
328
+ const { rows: nowRows, cols: nowCols } = (0, terminal_1.getTerminalSize)();
329
+ // Mobile keyboard: when the terminal size CHANGES (keyboard up/down,
330
+ // rotation), Termux's alt-screen can shift/scroll, so incremental row
331
+ // diffs land at wrong offsets — logo/header painted TWICE (report:
332
+ // "makin parah"). Freebuff parity (@opentui full redraw on resize):
333
+ // a size change forces a FULL repaint — clear the whole alt screen and
334
+ // repaint every row from scratch. Between ticks (same size) the cheap
335
+ // row-diff still applies.
336
+ const resized = nowCols !== lastCols || nowRows !== lastRows;
337
+ let out;
338
+ if (resized) {
339
+ // Full repaint: absolute cursorTo per row — NEVER a trailing '\r\n'
340
+ // (an explicit LF on the bottom row SCROLLS the alt screen by one;
341
+ // every later diff would then paint one row off — ghost/double bars).
342
+ out = [terminal_1.ANSI.clearScreen + terminal_1.ANSI.cursorHome];
343
+ for (let i = 0; i < rows.length; i++)
344
+ out.push((0, terminal_1.cursorTo)(i + 1, 1) + rows[i]);
345
+ lastFrameRows = rows;
337
346
  }
338
347
  else {
339
- const cursor = (0, terminal_1.cursorTo)(snap.inputRow, snap.cursorCol);
340
- if (cursor !== lastCursorPos)
341
- out.push(cursor);
342
- lastCursorPos = cursor;
343
- out.push(terminal_1.ANSI.showCursor);
348
+ // Keyboard shrink: when the terminal loses rows, the new frame is
349
+ // shorter than the previous screen. Stale rows below the new frame
350
+ // must be CLEARED or the old input-box border + status bar remain
351
+ // painted there — the "double ╭╰ + Build • YOLO" report.
352
+ out = (0, terminal_1.diffFrameUpdates)(lastFrameRows, rows);
353
+ lastFrameRows = rows;
344
354
  }
355
+ lastCols = nowCols;
356
+ lastRows = nowRows;
357
+ // Hardware cursor SELALU disembunyikan (Freebuff/opentui parity): kursor
358
+ // terminal asli BERKEDIP dengan cadance bawaan terminal (~500ms). Setelah
359
+ // dot header & sheen logo dihapus, INILAH kedip terakhir yang dilaporkan
360
+ // user (2026-09-07: "masih ada blink di sebelah YOLO dan di dalam chat
361
+ // prompt") — dulu showCursor di-push di akhir tiap paint lalu kursor
362
+ // dibiarkan VISIBLE selama idle → berkedip terus tanpa henti. Caret kini
363
+ // murni blok ▍ statis lukisan InputBox.render (tidak berkedip);
364
+ // showCursor dikembalikan sekali saat exit (blok finally di bawah).
365
+ out.push(terminal_1.ANSI.hideCursor);
345
366
  // DECAWM guard: even one exactly-`cols`-wide row (input-box borders,
346
367
  // header at the old width) leaves the cursor in the pending-wrap state;
347
368
  // the NEXT repaint — e.g. the ~480ms blink dot — then auto-wraps first
@@ -425,7 +446,7 @@ async function main() {
425
446
  }
426
447
  // Warnings
427
448
  if (opts.yolo) {
428
- console.warn(`${terminal_1.ANSI.yellow}⚠ WARNING: --yolo mode skips permission prompts for ask-level actions.${terminal_1.ANSI.reset}`);
449
+ console.warn(`${terminal_1.ANSI.yellow}! WARNING: --yolo mode skips permission prompts for ask-level actions.${terminal_1.ANSI.reset}`);
429
450
  }
430
451
  (0, paths_1.ensureDirs)();
431
452
  logger_1.logger.info(`VectorHead v${VERSION} started in ${process.cwd()}`);
@@ -254,6 +254,13 @@ function parseKey(data) {
254
254
  i++;
255
255
  continue;
256
256
  }
257
+ if (code === 20) {
258
+ // Ctrl+T — used by the TUI to cycle the Thinking card (app.ts), so the
259
+ // plain letter 't' can always be typed into the input.
260
+ events.push({ name: 'ctrl_t' });
261
+ i++;
262
+ continue;
263
+ }
257
264
  // Printable
258
265
  if (code >= 32 && code <= 126) {
259
266
  events.push({ name: 'char', char: ch });
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DEFAULT_CONFIG = void 0;
37
+ exports.firstRunSeedFree = firstRunSeedFree;
37
38
  exports.loadConfig = loadConfig;
38
39
  exports.saveConfig = saveConfig;
39
40
  exports.effectiveConfig = effectiveConfig;
@@ -48,9 +49,17 @@ const path = __importStar(require("node:path"));
48
49
  const paths_1 = require("../utils/paths");
49
50
  const logger_1 = require("../utils/logger");
50
51
  const providers_1 = require("./providers");
52
+ // Free-tier onboarding (2026-09-06): a FIRST RUN (no config.json) seeds the
53
+ // vectorhead-free provider so `npm install && vector` chats immediately with
54
+ // zero keys (type-only import — no runtime cycle).
55
+ const free_tier_1 = require("./free-tier");
51
56
  exports.DEFAULT_CONFIG = {
52
- provider: 'openrouter',
53
- model: 'deepseek/deepseek-chat',
57
+ // Free tier is the zero-config default (2026-09-06): the gateway holds the
58
+ // real provider key server-side, so a fresh install needs NO API key.
59
+ // BYOK providers remain one /connect away. Previously this pointed at
60
+ // 'openrouter' (no key → 401 "Not connected" on every fresh install).
61
+ provider: free_tier_1.FREE_PROVIDER_ID,
62
+ model: free_tier_1.FREE_DEFAULT_MODEL,
54
63
  protocol: 'openai',
55
64
  // User request (2026-09-04): the assistant must ACT without permission
56
65
  // prompts — yolo is the default. Dangerous commands (rm, git push, chmod…)
@@ -65,6 +74,16 @@ exports.DEFAULT_CONFIG = {
65
74
  compactionThreshold: 80_000,
66
75
  theme: 'vectorhead',
67
76
  };
77
+ /**
78
+ * First-run onboarding seed (2026-09-06): DEFAULT_CONFIG points at the free
79
+ * tier, and the free provider ENTRY is always defined. Returns a NEW object
80
+ * (never mutates DEFAULT_CONFIG) plus true when the caller should persist it.
81
+ */
82
+ function firstRunSeedFree(base) {
83
+ const config = { ...exports.DEFAULT_CONFIG, ...base };
84
+ const changed = (0, free_tier_1.ensureFreeProvider)(config);
85
+ return { config, changed };
86
+ }
68
87
  function loadConfig() {
69
88
  (0, paths_1.ensureDirs)();
70
89
  const p = (0, paths_1.getConfigPath)();
@@ -92,9 +111,23 @@ function loadConfig() {
92
111
  }
93
112
  }
94
113
  catch (err) {
114
+ // UNREADABLE config.json: fall through WITHOUT persisting — never
115
+ // overwrite a user file we failed to parse (their config stays on disk).
95
116
  logger_1.logger.warn(`config load failed: ${err.message}`);
117
+ return firstRunSeedFree().config;
118
+ }
119
+ // No config.json (true first run): seed the free tier AND PERSIST it so the
120
+ // next run keeps the same provider (this was the dormant "first-run seeds
121
+ // default to the free tier" promise — the Agent-only seeding never ran on
122
+ // the real CLI path because buildAgent() always passes a config).
123
+ const seeded = firstRunSeedFree();
124
+ try {
125
+ saveConfig(seeded.config);
126
+ }
127
+ catch {
128
+ /* read-only home — the in-memory seed still works this session */
96
129
  }
97
- return { ...exports.DEFAULT_CONFIG };
130
+ return seeded.config;
98
131
  }
99
132
  function saveConfig(config) {
100
133
  (0, paths_1.ensureDirs)();