agen-vektor 0.3.10 → 0.3.12

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
@@ -1,15 +1,23 @@
1
- # VectorHead
1
+ ```
2
+ ████████████████████████████████████████████████
3
+ █ █
4
+ █ V E C T O R A G E N T █
5
+ █ █
6
+ ████████████████████████████████████████████████
7
+ ```
8
+
9
+ <div align="center">
2
10
 
3
11
  **AI Coding Agent for Linux & Termux** — a terminal-native coding agent that inspects your project, plans, edits code, runs commands and tests, and iterates until the task is done.
4
12
 
5
- ```
6
- ██╗ ██╗███████╗ ██████╗████████╗ ██████╗ ██████╗ ██╗ ██╗███████╗ █████╗ ██████╗
7
- ██║ ██║██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗██║ ██║██╔════╝██╔══██╗██╔══██╗
8
- ██║ ██║█████╗ ██║ ██║ ██║ ██║██████╔╝███████║█████╗ ███████║██║ ██║
9
- ╚██╗ ██╔╝██╔══╝ ██║ ██║ ██║ ██║██╔══██╗██╔══██║██╔══╝ ██╔══██║██║ ██║
10
- ╚████╔╝ ███████╗╚██████╗ ██║ ╚██████╔╝██║ ██║██║ ██║███████╗██║ ██║██████╔╝
11
- ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝
12
- ```
13
+ ![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Termux-9EFC62?style=for-the-badge&labelColor=0b0f14)
14
+ ![Node](https://img.shields.io/badge/node-20%2B-339933?style=for-the-badge&labelColor=0b0f14)
15
+ ![License](https://img.shields.io/badge/license-MIT-3B82F6?style=for-the-badge&labelColor=0b0f14)
16
+ [![Tests](https://img.shields.io/github/actions/workflow/status/clickmamaheti-prog/vector-agent/ci.yml?style=for-the-badge&label=tests&labelColor=0b0f14)](https://github.com/clickmamaheti-prog/vector-agent/actions/workflows/ci.yml)
17
+
18
+ </div>
19
+
20
+ ---
13
21
 
14
22
  ```
15
23
  VectorHead ● Ready deepseek-ai/DeepSeek-V4-Flash
@@ -39,6 +47,12 @@ theme-system.ts + markdown-renderer.tsx), a compact agent-activity strip
39
47
  (⚙ tool calls), and a rounded input box with a block cursor — all rendered
40
48
  incrementally with no flicker.
41
49
 
50
+ ## Contents
51
+
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)
54
+ - [Testing](#testing) · [Troubleshooting](#troubleshooting) · [Roadmap](#roadmap) · [License](#license)
55
+
42
56
  ## Features
43
57
 
44
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.
@@ -55,10 +69,13 @@ Requirements: **Node.js 20+** and **npm** (Termux: `pkg install nodejs`).
55
69
 
56
70
  ### From source
57
71
 
58
- Dari source: clone repo ini, lalu `npm install && npm run build && npm link`
59
- (membuat `vector` tersedia di PATH).
72
+ Clone this repo, then:
60
73
 
61
- Then:
74
+ ```bash
75
+ npm install
76
+ npm run build
77
+ npm link # makes `vector` available on your PATH
78
+ ```
62
79
 
63
80
  ```bash
64
81
  vector
@@ -80,7 +97,7 @@ npm install -g agen-vektor
80
97
  vector
81
98
  ```
82
99
 
83
- > VectorHead has **zero runtime dependencies** and does not assume `systemd`, `sudo`, `apt`, or a desktop GUI — everything runs in a plain terminal.
100
+ > Vector Agent has **zero runtime dependencies** and does not assume `systemd`, `sudo`, `apt`, or a desktop GUI — everything runs in a plain terminal.
84
101
 
85
102
  ## Quick start
86
103
 
@@ -8,6 +8,11 @@ exports.identityPrompt = identityPrompt;
8
8
  exports.taskPrompt = taskPrompt;
9
9
  exports.continuationPrompt = continuationPrompt;
10
10
  exports.SYSTEM_PROMPT = `You are VectorHead, an AI coding agent operating inside a terminal.
11
+ VectorHead is an independent product (npm package "agen-vektor", binary
12
+ "vector", config "~/.vector/") — it is not a fork, port, or rebrand of any
13
+ other agent product. When asked about your identity, version, or origin,
14
+ answer as VectorHead only; never attribute yourself to other agent tools or
15
+ npm packages.
11
16
 
12
17
  You work autonomously inside the user's project directory. You can:
13
18
  - Inspect the project structure and files
package/dist/cli/index.js CHANGED
@@ -23,6 +23,12 @@ const paths_1 = require("../utils/paths");
23
23
  const logger_1 = require("../utils/logger");
24
24
  const keyboard_1 = require("./keyboard");
25
25
  const terminal_1 = require("../utils/terminal");
26
+ /** Disable auto-wrap margins (DECAWM off): a full-width row then CANNOT
27
+ * push the cursor into the pending-wrap state, so the next write can never
28
+ * flush a phantom newline and scroll the screen (the "dot keluar layar"/
29
+ * jitter bug). Restored after every paint so scrollback stays intact. */
30
+ const DECAWM_OFF = '\x1b[?7l';
31
+ const DECAWM_ON = '\x1b[?7h';
26
32
  const themes_1 = require("../tui/themes");
27
33
  const session_1 = require("../agent/session");
28
34
  const paths_2 = require("../utils/paths");
@@ -318,7 +324,7 @@ async function runTui(opts) {
318
324
  // the chat to flicker during multi-tool turns.
319
325
  if (app.isDirty()) {
320
326
  lastFrameRows = [];
321
- process.stdout.write(terminal_1.ANSI.hideCursor + app.render());
327
+ process.stdout.write(terminal_1.ANSI.hideCursor + DECAWM_OFF + (0, terminal_1.toAsciiSafe)(app.render()) + DECAWM_ON);
322
328
  app.consumeRender();
323
329
  }
324
330
  return;
@@ -337,7 +343,12 @@ async function runTui(opts) {
337
343
  out.push(cursor);
338
344
  lastCursorPos = cursor;
339
345
  out.push(terminal_1.ANSI.showCursor);
340
- process.stdout.write(terminal_1.ANSI.hideCursor + out.join(''));
346
+ // DECAWM guard: even one exactly-`cols`-wide row (input-box borders,
347
+ // header at the old width) leaves the cursor in the pending-wrap state;
348
+ // the NEXT repaint — e.g. the ~480ms blink dot — then auto-wraps first
349
+ // and the whole screen scrolls up one line. With auto-wrap disabled for
350
+ // the duration of the paint, that phantom newline is impossible.
351
+ process.stdout.write(terminal_1.ANSI.hideCursor + DECAWM_OFF + (0, terminal_1.toAsciiSafe)(out.join('')) + DECAWM_ON);
341
352
  };
342
353
  const onResize = () => {
343
354
  app.markDirty();
@@ -44,6 +44,7 @@ exports.effectiveConfig = effectiveConfig;
44
44
  * credentials.json (0600) or environment variables, never in config.json.
45
45
  */
46
46
  const fs = __importStar(require("node:fs"));
47
+ const path = __importStar(require("node:path"));
47
48
  const paths_1 = require("../utils/paths");
48
49
  const logger_1 = require("../utils/logger");
49
50
  const providers_1 = require("./providers");
@@ -51,7 +52,12 @@ exports.DEFAULT_CONFIG = {
51
52
  provider: 'openrouter',
52
53
  model: 'deepseek/deepseek-chat',
53
54
  protocol: 'openai',
54
- permissionMode: 'ask',
55
+ // User request (2026-09-04): the assistant must ACT without permission
56
+ // prompts — yolo is the default. Dangerous commands (rm, git push, chmod…)
57
+ // still prompt and blocked ones are always refused (see command-policy),
58
+ // so the guardrail floor stays intact. Opt back into prompting via
59
+ // VECTOR_ASK=1 or the /mode toggle in the TUI.
60
+ permissionMode: 'yolo',
55
61
  maxIterations: 25,
56
62
  maxRetries: 3,
57
63
  requestTimeoutMs: 120_000,
@@ -66,6 +72,22 @@ function loadConfig() {
66
72
  if (fs.existsSync(p)) {
67
73
  const raw = fs.readFileSync(p, 'utf8');
68
74
  const parsed = JSON.parse(raw);
75
+ // One-time mode migration (2026-09-04): permissionMode became 'yolo'
76
+ // by default — the assistant must act without prompting. Configs that
77
+ // still carry the OLD default ('ask') are flipped once; an explicit
78
+ // /mode toggle afterwards re-persists and is then respected forever.
79
+ if (parsed.permissionMode === 'ask') {
80
+ const marker = path.join((0, paths_1.getVectorDir)(), '.mode-migrated');
81
+ if (!fs.existsSync(marker)) {
82
+ parsed.permissionMode = 'yolo';
83
+ try {
84
+ fs.writeFileSync(marker, new Date().toISOString() + '\n');
85
+ }
86
+ catch {
87
+ /* best-effort */
88
+ }
89
+ }
90
+ }
69
91
  return { ...exports.DEFAULT_CONFIG, ...parsed };
70
92
  }
71
93
  }
@@ -92,6 +114,9 @@ function effectiveConfig(base) {
92
114
  out.theme = process.env.VECTOR_THEME;
93
115
  if (process.env.VECTOR_YOLO === '1')
94
116
  out.permissionMode = 'yolo';
117
+ // Opt back into per-action confirmation prompts.
118
+ if (process.env.VECTOR_ASK === '1')
119
+ out.permissionMode = 'ask';
95
120
  // Per-provider default model: when the provider comes from the environment
96
121
  // and no explicit model env was given, follow the provider definition's
97
122
  // declared default (config.json providers.<id>.model).
@@ -96,6 +96,19 @@ class AnthropicProvider {
96
96
  body.tools = toToolDefs(params.tools);
97
97
  if (params.temperature !== undefined)
98
98
  body.temperature = params.temperature;
99
+ // Extended thinking (Anthropic): ask for a reasoning budget so thinking
100
+ // models (Claude 3.7+/4 family) stream thinking_delta blocks — rendered
101
+ // as the • Thinking card. Harmless for non-thinking models? No — the API
102
+ // REJECTS thinking for models without extended-thinking support, so it
103
+ // is opt-in via VECTOR_ANTHROPIC_THINKING=1 (or a budget explicitly set
104
+ // here), keeping every other model working unchanged.
105
+ const wantThinking = process.env.VECTOR_ANTHROPIC_THINKING === '1';
106
+ const budget = Number(process.env.VECTOR_ANTHROPIC_THINKING_BUDGET || 0);
107
+ if (wantThinking || budget > 0) {
108
+ body.thinking = { type: 'enabled', budget_tokens: budget > 0 ? budget : 8_000 };
109
+ // Anthropic requires temperature unset (or 1) when thinking is on.
110
+ delete body.temperature;
111
+ }
99
112
  return body;
100
113
  }
101
114
  async post(body, signal) {
@@ -133,10 +146,13 @@ class AnthropicProvider {
133
146
  const data = await this.post(this.buildBody(params, false), params.signal);
134
147
  const content = data.content || [];
135
148
  let text = '';
149
+ let thinking = '';
136
150
  const toolCalls = [];
137
151
  for (const block of content) {
138
152
  if (block.type === 'text')
139
153
  text += String(block.text || '');
154
+ if (block.type === 'thinking')
155
+ thinking += String(block.thinking || '');
140
156
  if (block.type === 'tool_use') {
141
157
  toolCalls.push({
142
158
  id: String(block.id),
@@ -148,6 +164,7 @@ class AnthropicProvider {
148
164
  const usage = data.usage;
149
165
  return {
150
166
  content: text,
167
+ reasoning: thinking || undefined,
151
168
  toolCalls,
152
169
  stopReason: String(data.stop_reason || 'end_turn'),
153
170
  usage: usage
@@ -181,6 +198,7 @@ class AnthropicProvider {
181
198
  const decoder = new TextDecoder();
182
199
  let buffer = '';
183
200
  let content = '';
201
+ let thinking = '';
184
202
  const toolCalls = [];
185
203
  let currentTool = null;
186
204
  for (;;) {
@@ -210,12 +228,21 @@ class AnthropicProvider {
210
228
  arguments: '',
211
229
  };
212
230
  }
231
+ else if (evtName === 'content_block_start' && contentBlock?.type === 'thinking') {
232
+ // Thinking block starts — its deltas carry type thinking_delta.
233
+ }
213
234
  else if (evtName === 'content_block_delta') {
214
235
  const delta = json.delta;
215
236
  if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
216
237
  content += delta.text;
217
238
  yield { type: 'delta', delta: delta.text };
218
239
  }
240
+ else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
241
+ // Extended thinking (Claude 3.7+/4): surface as reasoning so
242
+ // the • Thinking card renders for these models too.
243
+ thinking += delta.thinking;
244
+ yield { type: 'delta', reasoning: delta.thinking };
245
+ }
219
246
  else if (delta?.type === 'input_json_delta' &&
220
247
  currentTool &&
221
248
  typeof delta.partial_json === 'string') {
@@ -252,7 +279,13 @@ class AnthropicProvider {
252
279
  toolCalls.push(currentTool);
253
280
  yield {
254
281
  type: 'done',
255
- result: { content, toolCalls, stopReason: 'end_turn', model: params.model },
282
+ result: {
283
+ content,
284
+ reasoning: thinking || undefined,
285
+ toolCalls,
286
+ stopReason: 'end_turn',
287
+ model: params.model,
288
+ },
256
289
  };
257
290
  }
258
291
  finally {
@@ -71,10 +71,17 @@ function extractResult(data) {
71
71
  const content0 = candidates[0]?.content || {};
72
72
  const parts = content0.parts || [];
73
73
  let content = '';
74
+ let reasoning = '';
74
75
  const toolCalls = [];
75
76
  for (const part of parts) {
76
- if (typeof part.text === 'string')
77
- content += part.text;
77
+ // Thinking models (Gemini 2.5/3 family) mark reasoning parts with
78
+ // thought: true — surface them as reasoning, never as answer text.
79
+ if (typeof part.text === 'string') {
80
+ if (part.thought === true)
81
+ reasoning += part.text;
82
+ else
83
+ content += part.text;
84
+ }
78
85
  if (part.functionCall) {
79
86
  const raw = part.functionCall;
80
87
  const fcs = Array.isArray(raw) ? raw : [raw];
@@ -87,7 +94,7 @@ function extractResult(data) {
87
94
  }
88
95
  }
89
96
  }
90
- return { content, toolCalls };
97
+ return { content, reasoning, toolCalls };
91
98
  }
92
99
  class GeminiProvider {
93
100
  id = 'gemini';
@@ -151,9 +158,10 @@ class GeminiProvider {
151
158
  throw new provider_1.ProviderError(`Gemini HTTP ${res.status}: ${text.slice(0, 300)}`, res.status, res.status >= 500);
152
159
  }
153
160
  const data = JSON.parse(text);
154
- const { content, toolCalls } = extractResult(data);
161
+ const { content, reasoning, toolCalls } = extractResult(data);
155
162
  return {
156
163
  content,
164
+ reasoning: reasoning || undefined,
157
165
  toolCalls,
158
166
  stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
159
167
  model: params.model,
@@ -169,6 +177,7 @@ class GeminiProvider {
169
177
  const decoder = new TextDecoder();
170
178
  let buffer = '';
171
179
  let content = '';
180
+ let reasoning = '';
172
181
  const toolCalls = [];
173
182
  for (;;) {
174
183
  const { done, value } = await reader.read();
@@ -187,7 +196,11 @@ class GeminiProvider {
187
196
  const json = safeJson(payload);
188
197
  if (!json)
189
198
  continue;
190
- const { content: deltaText, toolCalls: newCalls } = extractResult(json);
199
+ const { content: deltaText, reasoning: deltaThought, toolCalls: newCalls } = extractResult(json);
200
+ if (deltaThought) {
201
+ reasoning += deltaThought;
202
+ yield { type: 'delta', reasoning: deltaThought };
203
+ }
191
204
  if (deltaText) {
192
205
  content += deltaText;
193
206
  yield { type: 'delta', delta: deltaText };
@@ -202,6 +215,7 @@ class GeminiProvider {
202
215
  type: 'done',
203
216
  result: {
204
217
  content,
218
+ reasoning: reasoning || undefined,
205
219
  toolCalls,
206
220
  stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
207
221
  model: params.model,
@@ -94,8 +94,12 @@ class OllamaProvider {
94
94
  arguments: typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {}),
95
95
  };
96
96
  });
97
+ // Thinking models (Ollama ≥0.9: DeepSeek-R1, Qwen3 …) return the
98
+ // reasoning in message.thinking when the model supports it.
99
+ const thinking = typeof message.thinking === 'string' ? message.thinking : '';
97
100
  return {
98
101
  content: String(message.content || ''),
102
+ reasoning: thinking || undefined,
99
103
  toolCalls,
100
104
  stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
101
105
  model: String(data.model || params.model),
@@ -118,6 +122,7 @@ class OllamaProvider {
118
122
  const decoder = new TextDecoder();
119
123
  let buffer = '';
120
124
  let content = '';
125
+ let reasoning = '';
121
126
  const toolCalls = [];
122
127
  for (;;) {
123
128
  const { done, value } = await reader.read();
@@ -138,6 +143,7 @@ class OllamaProvider {
138
143
  type: 'done',
139
144
  result: {
140
145
  content,
146
+ reasoning: reasoning || undefined,
141
147
  toolCalls,
142
148
  stopReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
143
149
  model: params.model,
@@ -146,6 +152,12 @@ class OllamaProvider {
146
152
  return;
147
153
  }
148
154
  const message = (json.message || {});
155
+ // Thinking models stream reasoning in message.thinking (Ollama ≥0.9,
156
+ // DeepSeek-R1/Qwen3) — surface it so the • Thinking card renders.
157
+ if (typeof message.thinking === 'string' && message.thinking) {
158
+ reasoning += message.thinking;
159
+ yield { type: 'delta', reasoning: message.thinking };
160
+ }
149
161
  if (typeof message.content === 'string' && message.content) {
150
162
  content += message.content;
151
163
  yield { type: 'delta', delta: message.content };
@@ -165,7 +177,7 @@ class OllamaProvider {
165
177
  }
166
178
  yield {
167
179
  type: 'done',
168
- result: { content, toolCalls, stopReason: 'stop', model: params.model },
180
+ result: { content, reasoning: reasoning || undefined, toolCalls, stopReason: 'stop', model: params.model },
169
181
  };
170
182
  }
171
183
  async models() {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OpenAICompatProvider = void 0;
4
4
  exports.stripThinkTags = stripThinkTags;
5
+ exports.createThinkTagStreamSplitter = createThinkTagStreamSplitter;
5
6
  /**
6
7
  * OpenAI-compatible provider — used by OpenAI, OpenRouter and Custom.
7
8
  * Speaks /v1/chat/completions (and /models) with tool calling + streaming.
@@ -59,6 +60,81 @@ function extractReasoning(msg) {
59
60
  function stripThinkTags(text) {
60
61
  return text.replace(/<think>[\s\S]*?<\/think>/g, '').replace(/^\s*<think>[\s\S]*$/, '').trim();
61
62
  }
63
+ /**
64
+ * Incremental `<think>…</think>` tag splitter for STREAMING: OpenAI-compat
65
+ * gateways often proxy reasoning models (DeepSeek-R1, QwQ, Qwen3 …) that
66
+ * inline their thinking into `delta.content` instead of a separate
67
+ * `reasoning_content` field. Non-streaming already strips via
68
+ * stripThinkTags — the streaming path needs the same treatment or the
69
+ * • Thinking card never appears for those models (raw <think> text used to
70
+ * leak into the visible answer too).
71
+ */
72
+ function createThinkTagStreamSplitter() {
73
+ let inside = false;
74
+ let tail = ''; // partial tag spanning chunk boundaries
75
+ const flushTail = (emit) => {
76
+ // Find the highest safe split point: a '<' that MIGHT begin a tag when
77
+ // more text arrives is kept buffered; everything before it is emitted.
78
+ const lt = tail.indexOf('<');
79
+ if (lt === -1) {
80
+ emit(tail, inside);
81
+ tail = '';
82
+ return;
83
+ }
84
+ if (lt > 0) {
85
+ emit(tail.slice(0, lt), inside);
86
+ tail = tail.slice(lt);
87
+ }
88
+ // tail now starts with '<' — wait for more data.
89
+ };
90
+ return {
91
+ /** Feed one content delta; returns {content, reasoning} pieces. */
92
+ push(chunk) {
93
+ const content = [];
94
+ const reasoning = [];
95
+ const emit = (s, isReasoning) => {
96
+ if (s)
97
+ (isReasoning ? reasoning : content).push(s);
98
+ };
99
+ tail += chunk;
100
+ for (;;) {
101
+ if (!inside) {
102
+ const open = tail.indexOf('<think>');
103
+ if (open !== -1) {
104
+ emit(tail.slice(0, open), false);
105
+ tail = tail.slice(open + 7);
106
+ inside = true;
107
+ continue;
108
+ }
109
+ flushTail(emit);
110
+ break;
111
+ }
112
+ else {
113
+ const close = tail.indexOf('</think>');
114
+ if (close !== -1) {
115
+ emit(tail.slice(0, close), true);
116
+ tail = tail.slice(close + 8);
117
+ inside = false;
118
+ continue;
119
+ }
120
+ flushTail(emit);
121
+ break;
122
+ }
123
+ }
124
+ return { content, reasoning };
125
+ },
126
+ /** End of stream: flush any buffered remainder (an unterminated
127
+ * <think> block counts as reasoning). */
128
+ finish() {
129
+ const content = [];
130
+ const reasoning = [];
131
+ if (tail)
132
+ (inside ? reasoning : content).push(tail);
133
+ tail = '';
134
+ return { content, reasoning };
135
+ },
136
+ };
137
+ }
62
138
  function parseToolCalls(raw) {
63
139
  const calls = [];
64
140
  if (!Array.isArray(raw))
@@ -233,6 +309,10 @@ class OpenAICompatProvider {
233
309
  let content = '';
234
310
  let reasoning = '';
235
311
  const toolCalls = [];
312
+ // Reasoning models behind OpenAI-compat gateways may inline thinking
313
+ // into delta.content as <think>…</think> (DeepSeek-R1/QwQ/Qwen3).
314
+ // Split it out so the • Thinking card works on EVERY model.
315
+ const thinkSplit = createThinkTagStreamSplitter();
236
316
  for (;;) {
237
317
  const { done, value } = await reader.read();
238
318
  if (done)
@@ -246,6 +326,12 @@ class OpenAICompatProvider {
246
326
  continue;
247
327
  const payload = trimmed.slice(5).trim();
248
328
  if (payload === '[DONE]') {
329
+ // Flush any <think> splitter remainder before finishing.
330
+ const tail = thinkSplit.finish();
331
+ for (const r of tail.reasoning)
332
+ reasoning += r;
333
+ for (const c of tail.content)
334
+ content += c;
249
335
  yield {
250
336
  type: 'done',
251
337
  result: {
@@ -276,8 +362,15 @@ class OpenAICompatProvider {
276
362
  yield { type: 'delta', reasoning: rc };
277
363
  }
278
364
  if (typeof delta.content === 'string' && delta.content) {
279
- content += delta.content;
280
- yield { type: 'delta', delta: delta.content };
365
+ const pieces = thinkSplit.push(delta.content);
366
+ for (const r of pieces.reasoning) {
367
+ reasoning += r;
368
+ yield { type: 'delta', reasoning: r };
369
+ }
370
+ for (const c of pieces.content) {
371
+ content += c;
372
+ yield { type: 'delta', delta: c };
373
+ }
281
374
  }
282
375
  const tc = delta.tool_calls;
283
376
  if (Array.isArray(tc)) {
@@ -311,7 +404,12 @@ class OpenAICompatProvider {
311
404
  }
312
405
  }
313
406
  }
314
- // Stream ended without [DONE]
407
+ // Stream ended without [DONE] — flush the <think> splitter tail.
408
+ const tail = thinkSplit.finish();
409
+ for (const r of tail.reasoning)
410
+ reasoning += r;
411
+ for (const c of tail.content)
412
+ content += c;
315
413
  yield {
316
414
  type: 'done',
317
415
  result: {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.classifyCommand = classifyCommand;
4
+ const sensitive_files_1 = require("./sensitive-files");
4
5
  const BLOCKED_PATTERNS = [
5
6
  /(^|[;&|]\s*)\s*rm\s+(-[a-z]*[rf][a-z]*\s+)*\/\s*($|[^a-zA-Z])/,
6
7
  /(^|[;&|]\s*)\s*rm\s+(-[a-z]*[rf][a-z]*\s+)*~(\/|$)/,
@@ -59,6 +60,12 @@ const ASK_PATTERNS = [
59
60
  /** Commands that are safe to run without asking. */
60
61
  const SAFE_PATTERNS = [
61
62
  /(^|[;&|]\s*)\s*(ls|cat|head|tail|less|more|wc|grep|find|locate|which|type|file|stat|du|df|tree)\s/,
63
+ // Local dev-server plumbing (compound verification commands like
64
+ // `cd web && python3 -m http.server 8917 & sleep 1; curl localhost; kill %1`)
65
+ /(^|[;&|]\s*)\s*cd\s+\S+/,
66
+ /(^|[;&|]\s*)\s*sleep\s+\d/,
67
+ /(^|[;&|]\s*)\s*python3?\s+-m\s+http\.server\b/,
68
+ /(^|[;&|]\s*)\s*kill\s+%\d+/,
62
69
  /(^|[;&|]\s*)\s*echo\s/,
63
70
  /(^|[;&|]\s*)\s*node\s+(-v|--version)/,
64
71
  /(^|[;&|]\s*)\s*npm\s+(-v|--version|ls|list)\s*/,
@@ -78,11 +85,63 @@ function classifyCommand(command) {
78
85
  return { level: 'blocked', reason: `command matches blocked pattern: ${re.source}` };
79
86
  }
80
87
  }
88
+ // Sensitive-file escalation (Freebuff fileFilter spirit): a shell command
89
+ // that references a secret (.env, private keys, credentials.json, …) must
90
+ // be PROMPTED even in yolo mode — `cat .env` / `cp id_rsa /tmp` / piping
91
+ // secrets into the network is the classic exfiltration path around the
92
+ // fs-tool guard. Only `cat *.example` template references stay exempt so
93
+ // agents can still learn the config surface.
94
+ const tokens = command.split(/\s+/).map((t) => t.replace(/['"`]/g, ''));
95
+ const touchesSensitive = tokens.some((raw) => {
96
+ // curl-style --data=@file and @file forms: keep only the path part.
97
+ const t = raw.split('=').pop() ?? raw;
98
+ const base = (t.startsWith('@') ? t.slice(1) : t).split('/').pop() ?? '';
99
+ if (!base)
100
+ return false;
101
+ if (base.endsWith('.example') || base.endsWith('.sample'))
102
+ return false;
103
+ return (0, sensitive_files_1.isSensitiveFile)(base) || (0, sensitive_files_1.isSensitiveEnvFilePath)(base);
104
+ });
105
+ if (touchesSensitive) {
106
+ return { level: 'dangerous', reason: 'command references a sensitive file (secrets must be prompted even in yolo)' };
107
+ }
81
108
  for (const re of DANGEROUS_PATTERNS) {
82
109
  if (re.test(command)) {
83
110
  return { level: 'dangerous', reason: `command matches dangerous pattern: ${re.source}` };
84
111
  }
85
112
  }
113
+ // Loopback exemption (Freebuff-style smooth local dev): curl/wget whose
114
+ // EVERY network target is localhost/127.0.0.1 cannot exfiltrate anything
115
+ // off the machine, so testing a local dev server should not prompt. Runs
116
+ // AFTER the dangerous/sensitive checks so `curl | bash` and
117
+ // `curl --data @secrets http://localhost` still escalate. Any remote host
118
+ // in the same command (or an extra URL) falls through to the ASK pattern.
119
+ if (/(^|[;&|]\s*)\s*(curl|wget)\s/.test(command)) {
120
+ const targets = [];
121
+ for (const seg of command.split(/[;&|]+/)) {
122
+ const m = seg.trim().match(/^(curl|wget)\s+(.*)$/);
123
+ if (!m)
124
+ continue;
125
+ for (const raw of m[2].split(/\s+/)) {
126
+ const t = raw.replace(/['"`]/g, '').split('=').pop() ?? '';
127
+ if (t.startsWith('-'))
128
+ continue;
129
+ // URL/host-shaped token: scheme://host, host:port, or host.tld
130
+ if (/^https?:\/\//i.test(t) || (/^[\w.\-\[%]+(:\d+)?(\/|$|\?)/.test(t) && /[.:\[%]|localhost/.test(t))) {
131
+ targets.push(t);
132
+ }
133
+ }
134
+ }
135
+ const isLoopback = (tok) => {
136
+ let s = tok.replace(/^https?:\/\//i, '').toLowerCase();
137
+ s = (s.split('/')[0] ?? '').split(':')[0] ?? '';
138
+ return (s === 'localhost' || s === '127.0.0.1' || s === '::1' ||
139
+ s === '[::1]' || s === '[::]' || s.endsWith('.localhost'));
140
+ };
141
+ if (targets.length > 0 && targets.every(isLoopback)) {
142
+ return { level: 'safe', reason: 'curl/wget targets loopback only (local dev server test, no remote transfer)' };
143
+ }
144
+ }
86
145
  for (const re of ASK_PATTERNS) {
87
146
  if (re.test(command)) {
88
147
  return { level: 'ask', reason: `command matches ask pattern: ${re.source}` };