@hncode/hncode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/install.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hncode auto-install script
4
+ * Creates default configuration and sets up the environment
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ // Get user home directory (cross-platform)
12
+ const homeDir = os.homedir();
13
+ const configDir = path.join(homeDir, '.hncode');
14
+ const configFile = path.join(configDir, 'config.toml');
15
+ const sessionsDir = path.join(configDir, 'sessions');
16
+ const pluginsDir = path.join(configDir, 'plugins');
17
+
18
+ console.log('🚀 Setting up hncode...\n');
19
+
20
+ // Create directories if they don't exist
21
+ const dirs = [configDir, sessionsDir, pluginsDir];
22
+ for (const dir of dirs) {
23
+ if (!fs.existsSync(dir)) {
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ console.log(`✓ Created directory: ${dir}`);
26
+ } else {
27
+ console.log(`✓ Directory exists: ${dir}`);
28
+ }
29
+ }
30
+
31
+ // Check if config file exists
32
+ if (!fs.existsSync(configFile)) {
33
+ const defaultConfig = `# hncode Configuration
34
+ # Generated by hncode install script
35
+
36
+ # Default model (change this to your preferred model)
37
+ default_model = "gpt-4o-mini"
38
+
39
+ # Optional: Add providers directly here
40
+ # Uncomment and configure as needed:
41
+ #
42
+ # [providers.openai]
43
+ # base_url = "https://api.openai.com/v1"
44
+ # api_key = "your-api-key-here"
45
+ # protocol = "openai"
46
+
47
+ # Or use environment variables:
48
+ # HNCODE_API_KEY="your-key"
49
+ # HNCODE_PROVIDER="openai"
50
+ # HNCODE_MODEL="gpt-4o-mini"
51
+
52
+ `;
53
+
54
+ fs.writeFileSync(configFile, defaultConfig);
55
+ console.log(`\n✅ Created default configuration at: ${configFile}`);
56
+ console.log('\n💡 Next steps:');
57
+ console.log(' 1. Edit ~/.hncode/config.toml with your API key');
58
+ console.log(' 2. Or set environment variables:');
59
+ console.log(' export HNCODE_API_KEY="your-key"');
60
+ console.log(' export HNCODE_PROVIDER="openai"');
61
+ console.log(' 3. Run "hncode" to start!');
62
+ } else {
63
+ console.log(`\nℹ️ Configuration already exists at: ${configFile}`);
64
+ console.log(' Run "hncode init" to regenerate or edit manually.');
65
+ }
66
+
67
+ console.log('\n✨ hncode is ready to use!');
68
+
69
+ // Try to refresh environment automatically
70
+ try {
71
+ const platform = os.platform();
72
+
73
+ if (platform === 'win32') {
74
+ // Windows: Use PowerShell to refresh environment
75
+ const { execSync } = require('child_process');
76
+ try {
77
+ execSync('Refresh-Environment', { stdio: 'pipe' });
78
+ console.log('✓ Environment refreshed automatically (PowerShell)');
79
+ } catch (e) {
80
+ console.log('ℹ️ To refresh environment in PowerShell:');
81
+ console.log(' . "$env:LOCALAPPDATA\\npm\\npm.cmd"');
82
+ console.log(' Or simply close and reopen your terminal');
83
+ }
84
+ } else {
85
+ // Unix-like systems
86
+ console.log('ℹ️ Please run one of these to refresh environment:');
87
+ console.log(' source ~/.zshrc # For Zsh');
88
+ console.log(' source ~/.bashrc # For Bash');
89
+ console.log(' Or simply close and reopen your terminal');
90
+ }
91
+ } catch (err) {
92
+ console.log('ℹ️ Note: You may need to restart your terminal');
93
+ }
94
+
95
+ console.log('');
96
+ console.log(' Type "hncode --help" for available commands.\n');
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hncode pre-install script
4
+ * Checks environment requirements before installation
5
+ */
6
+
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ console.log('🔍 Checking environment...\n');
11
+
12
+ // Check Node.js version
13
+ const nodeVersion = process.version.replace('v', '');
14
+ const [major] = nodeVersion.split('.').map(Number);
15
+
16
+ if (major < 20) {
17
+ console.warn(`⚠️ Warning: Node.js v${nodeVersion} detected.`);
18
+ console.warn(' hncode requires Node.js >= 20. Please upgrade.');
19
+ process.exit(1);
20
+ } else {
21
+ console.log(`✓ Node.js v${nodeVersion} ✓`);
22
+ }
23
+
24
+ // Check platform
25
+ const platform = os.platform();
26
+ console.log(`✓ Platform: ${platform}`);
27
+
28
+ // Check if already installed globally
29
+ try {
30
+ const { execSync } = require('child_process');
31
+ const npm = execSync('npm list -g hncode 2>&1').toString();
32
+ if (npm.includes('hncode')) {
33
+ console.log('ℹ️ hncode is already installed globally');
34
+ console.log(' Run "npm uninstall -g hncode" to reinstall.\n');
35
+ } else {
36
+ console.log('✓ Not installed globally yet\n');
37
+ }
38
+ } catch (e) {
39
+ console.log('✓ Not installed globally yet\n');
40
+ }
41
+
42
+ console.log('✅ Environment check passed!\n');
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@hncode/hncode",
3
+ "version": "0.1.0",
4
+ "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
+ "type": "module",
6
+ "bin": {
7
+ "hncode": "bin/hncode"
8
+ },
9
+ "scripts": {
10
+ "preinstall": "node bin/preinstall.js",
11
+ "install": "node bin/install.js"
12
+ },
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "license": "GPL-3.0-only",
17
+ "author": "NiceHello666",
18
+ "keywords": [
19
+ "cli",
20
+ "agent",
21
+ "coding",
22
+ "ai",
23
+ "tui"
24
+ ],
25
+ "dependencies": {}
26
+ }
package/src/agent.js ADDED
@@ -0,0 +1,373 @@
1
+ // The agent loop: sends the message history to the LLM, consumes the normalized
2
+ // event stream, executes any tool calls, appends results, and repeats until the
3
+ // model answers without tool calls (or a step/interrupt condition is hit).
4
+
5
+ import { LLM, setToolsList } from './llm.js';
6
+ import { tools, getTool, llmTools } from './tools/index.js';
7
+ import { estimateMessagesTokens } from './term.js';
8
+
9
+ // ---- completion guard --------------------------------------------------------
10
+ // A turn is only complete when the model ends it with an actual answer. Models
11
+ // do stop mid-task: the stream ends right after a reasoning block, or with an
12
+ // empty assistant message, and the caller would report "done" for unfinished
13
+ // work. When that happens we tell the model what it did and give it more steps.
14
+ // Bounded, so a model that insists on stopping cannot loop forever.
15
+ export const MAX_NUDGES = 5;
16
+
17
+ export function nudgeMessage(why) {
18
+ return `[hncode] Your turn ended without a final answer: ${why}. `
19
+ + 'This usually means the task is not finished. If the work IS complete, reply with a short '
20
+ + 'summary of what changed and how you verified it. Otherwise keep going with the tools right '
21
+ + 'now — do not stop silently.';
22
+ }
23
+
24
+ // Final instruction appended to the system prompt (see SYSTEM_PROMPT).
25
+ // (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
26
+
27
+ export const SYSTEM_PROMPT = `You are hncode, a coding agent running. You must monitor your context usage. When you detect that you're approaching the context limit (see your last messages about token counts), you should proactively call /compact to reduce the conversation history. in the user's terminal on ${process.platform}.
28
+ You operate inside a workspace directory. You can inspect files, edit code, run commands, and track tasks using the tools available.
29
+
30
+ Tools:
31
+ - Prefer Edit over Write for existing files.
32
+ - Read before you edit; never guess file contents. Never propose changes to code you haven't read.
33
+ - If a tool fails, diagnose why before switching tactics. Read the error; don't guess.
34
+ After 2 failed attempts on the same goal, STOP and report the blocker.
35
+ - Independent tool calls can run in parallel, but never parallelize calls with dependencies.
36
+
37
+ Scope:
38
+ - Do what was asked. Do not refactor, rename, or "improve" unrelated code.
39
+ - If the request is ambiguous, ask one short clarifying question before acting.
40
+ - If you make an assumption, state it explicitly.
41
+
42
+ Finishing:
43
+ - Never stop silently after a tool call.
44
+ - End every turn with a short report in the user's language:
45
+ Done: what changed (files / commands / result), and how you verified it.
46
+ Not done: what failed, what you tried, and what you need from the user.
47
+ - Never claim success without evidence. Cite file_path:line_number for any code you
48
+ reference. If you didn't run a command or read back the file, you don't know it worked.
49
+ - Be brief. No filler, no restating the request.
50
+ - After tools succeed, don't re-explain what the tool did. Report only the outcome.
51
+
52
+ Finishing a turn:
53
+ - End every turn with a written answer. Never stop right after a tool call, and never
54
+ stop with reasoning only: either keep calling tools until the task is done, or write
55
+ the summary. A turn that ends without an answer is flagged back to you with a reminder.
56
+ `;
57
+
58
+ export class Agent {
59
+ constructor({ cfg, messages, onEvent, maxSteps = 0, onToolStart, canInterrupt = () => false, onApproval }) {
60
+ this.cfg = cfg;
61
+ this.messages = messages;
62
+ this.onEvent = onEvent;
63
+ this.maxSteps = maxSteps;
64
+ this.onToolStart = onToolStart; // (name, args) => void (for headless/TTY overlays)
65
+ // Ensure the LLM client knows the bare tool specs (the client wraps them per-protocol).
66
+ // A toolFilter (array of tool names) limits which tools are exposed — used
67
+ // by Plan mode (read-only subset) and Focus mode (minimal subset first).
68
+ const filtered = Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
69
+ ? tools.filter((t) => cfg.toolFilter.includes(t.name))
70
+ : tools;
71
+ setToolsList(filtered);
72
+ // AbortSignal handed to every tool so a long-running one (Bash) is killed
73
+ // when the user presses Esc / Ctrl-C — otherwise the command keeps running
74
+ // after the turn was interrupted.
75
+ this.toolAbort = new AbortController();
76
+ this.ctx = { ...cfg, tasks: {}, todoState: Array.isArray(cfg.todoState) ? cfg.todoState.slice() : [], allowExternal: !!cfg.allowExternal, signal: this.toolAbort.signal };
77
+ // Live output stream for a RUNNING tool (Bash): the tool calls this with raw
78
+ // chunks and the agent tags them with the id of the tool call in flight, so
79
+ // the TUI can append them to the matching "Using …" row while it runs.
80
+ this._currentToolId = null;
81
+ this.ctx.onOutput = (chunk) => {
82
+ if (chunk && this._currentToolId) this.onEvent({ type: 'tool_output', id: this._currentToolId, chunk });
83
+ };
84
+ this.llm = new LLM(cfg);
85
+ this.stopRequested = false;
86
+ this.onApproval = onApproval; // async (toolName, args) => boolean
87
+ // Messages the user types while the agent is streaming. They are injected
88
+ // as user messages right AFTER the current round of tool calls, so the
89
+ // model sees "tool callback / user steer" together on the next request —
90
+ // exactly like kimi-code's steer (no separate turn feel).
91
+ this.steerQueue = [];
92
+ }
93
+
94
+ // Queue a steering message typed while streaming. It takes effect once the
95
+ // in-flight tool calls finish and the loop asks the model again.
96
+ steer(text) {
97
+ if (text && text.trim()) this.steerQueue.push(String(text).trim());
98
+ }
99
+ // True when a steer is pending injection.
100
+ get hasSteer() { return this.steerQueue.length > 0; }
101
+
102
+ interrupt() {
103
+ this.stopRequested = true;
104
+ try { this.toolAbort.abort(); } catch {}
105
+ if (this.llm) this.llm.abort();
106
+ }
107
+
108
+ async run() {
109
+ let text = '';
110
+ let toolCalls = [];
111
+ let emittedEnd = false;
112
+ // Reasoning text seen in the current step. A turn that ends with reasoning
113
+ // but no answer is a truncated turn (see the completion guard below).
114
+ let thought = '';
115
+ let nudges = 0;
116
+ // True when the most recent iteration still had tool calls pending; used to
117
+ // decide whether running out of steps actually left the turn unfinished.
118
+ let hadToolCallsOnFinalStep = false;
119
+
120
+ const handle = async (e) => {
121
+ switch (e.type) {
122
+ case 'data':
123
+ text += e.text;
124
+ this.onEvent({ type: 'data', text: e.text });
125
+ break;
126
+ case 'think':
127
+ // Reasoning tokens: forward verbatim so the TUI can render a live
128
+ // "thinking…" block. They are NOT part of the assistant message.
129
+ thought += e.text;
130
+ this.onEvent({ type: 'think', text: e.text });
131
+ break;
132
+ case 'tool_start':
133
+ toolCalls.push({ id: e.id, name: e.name, args: {}, argsJson: '' });
134
+ this.onEvent({ type: 'tool_start', id: e.id, name: e.name });
135
+ break;
136
+ case 'tool_args': {
137
+ const tc = toolCalls.find((t) => t.id === e.id);
138
+ if (tc) { tc.argsJson += e.chunk; }
139
+ // Forward the raw argument chunk so the TUI can stream a Write's
140
+ // content (or an Edit's old/new text) while the model is emitting it.
141
+ this.onEvent({ type: 'tool_args', id: e.id, chunk: e.chunk });
142
+ break;
143
+ }
144
+ case 'tool_end': {
145
+ const tc = toolCalls.find((t) => t.id === e.id);
146
+ if (tc) {
147
+ try { tc.args = tc.argsJson ? JSON.parse(tc.argsJson) : {}; }
148
+ catch { tc.args = { raw: tc.argsJson }; }
149
+ }
150
+ break;
151
+ }
152
+ case 'end':
153
+ emittedEnd = true;
154
+ break;
155
+ case 'aborted':
156
+ // The request was aborted (Esc / Ctrl-C): surface it and stop the loop.
157
+ this.stopRequested = true;
158
+ this.onEvent({ type: 'aborted' });
159
+ break;
160
+ case 'error':
161
+ this.onEvent({ type: 'error', error: e.error });
162
+ break;
163
+ }
164
+ };
165
+
166
+ // No step cap: the loop runs until the model produces a final answer, the
167
+ // user interrupts (Esc / Ctrl-C), or an error ends the turn. maxSteps is
168
+ // kept only for callers that explicitly request a bound (0/undefined = none).
169
+ // Runaway safety comes from the completion guard (MAX_NUDGES) and the user's
170
+ // ability to interrupt, not from a fixed tool-round budget.
171
+ const stepLimit = (this.maxSteps > 0) ? this.maxSteps : Infinity;
172
+ for (let step = 0; step < stepLimit; step++) {
173
+ // Process any steer messages collected during the previous step
174
+ if (this.steerQueue.length > 0) {
175
+ const steers = this.steerQueue.splice(0);
176
+ for (const text of steers) {
177
+ this.messages.push({ role: 'user', content: text });
178
+ this.onEvent({ type: 'steer', text });
179
+ }
180
+ }
181
+ if (this.stopRequested) { this.onEvent({ type: 'stopped' }); break; }
182
+ text = ''; toolCalls = []; emittedEnd = false; thought = '';
183
+ // Report the start of each model round so the TUI can count steps/rounds.
184
+ this.onEvent({ type: 'step_start', step, round: step + 1 });
185
+ // ---- auto-compaction (kimi's runtime strategy) ----
186
+ // Before each model call, if the estimated context usage is at or above
187
+ // 85% of the model's max context, summarize the older portion of the
188
+ // conversation and keep only the most recent 20% of messages (plus the
189
+ // summary). This mirrors kimi's triggerRatio=0.85 & maxRecentSizeRatio=0.2
190
+ // policy, using the same ASCII/4 + non-ASCII token estimator. The dropped
191
+ // messages are replaced by a single AI-generated summary so context is
192
+ // preserved rather than simply discarded.
193
+ const maxCtx = this.cfg.maxContextTokens || 512000;
194
+ let usedNow = estimateMessagesTokens(this.messages, this.cfg);
195
+ if (maxCtx > 0 && usedNow >= maxCtx * 0.85) {
196
+ const before = usedNow;
197
+ const keepN = Math.max(1, Math.ceil(this.messages.length * 0.2));
198
+ const keep = this.messages.slice(-keepN);
199
+ const dropped = this.messages.slice(0, this.messages.length - keepN);
200
+ this.messages = keep;
201
+ // Ask the model to summarize what was trimmed so far, then prepend it
202
+ // as a system message so the model retains the conversation arc.
203
+ let summary = '';
204
+ if (dropped.length > 0) {
205
+ const summaryPrompt = [
206
+ { role: 'system', content: 'Summarize the conversation history that follows. Capture the user\'s goals, the key decisions made, files created/modified, and the current state of any ongoing work. Be concise but thorough.' },
207
+ ...dropped,
208
+ ];
209
+ const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096, 2048) };
210
+ const llm = new LLM(cfgCopy);
211
+ try { summary = await llm.requestText(summaryPrompt); } catch { summary = ''; }
212
+ if (summary) {
213
+ this.messages = [{
214
+ role: 'system',
215
+ content: `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of what was trimmed:\n\n${summary}`,
216
+ }, ...this.messages];
217
+ }
218
+ }
219
+ usedNow = estimateMessagesTokens(this.messages, this.cfg);
220
+ this.onEvent({ type: 'compacted', before, after: usedNow, kept: keepN });
221
+ }
222
+ // Live context gauge: report the estimated size of the request about to be
223
+ // sent, so the TUI's context readout is refreshed at every step rather than
224
+ // only once the whole turn ends.
225
+ this.onEvent({ type: 'context', tokens: usedNow, max: maxCtx });
226
+
227
+ // Inject any user steering messages typed during the previous step.
228
+ // They are appended right after the last assistant/tool round so the
229
+ // model sees "tool callback / user steer" on this next request.
230
+ if (this.steerQueue.length > 0) {
231
+ for (const steerText of this.steerQueue.splice(0)) {
232
+ this.messages.push({ role: 'user', content: steerText });
233
+ this.onEvent({ type: 'steer', text: steerText });
234
+ }
235
+ }
236
+
237
+ await this.llm.request(this.messages, handle);
238
+
239
+ // An abort ends the turn: keep whatever text arrived, then stop.
240
+ if (this.stopRequested) {
241
+ if (text) this.messages.push({ role: 'assistant', content: text });
242
+ break;
243
+ }
244
+
245
+ // append assistant message —— 绝不 push 既无文本、又无 tool_calls 的空消息,
246
+ // 否则上游报 "content or tool_calls must be set"。
247
+ const hasText = !!(text && text.trim());
248
+ const hasCalls = toolCalls.length > 0;
249
+ if (hasText || hasCalls) {
250
+ this.messages.push({
251
+ role: 'assistant',
252
+ content: hasText ? text : null,
253
+ toolCalls: hasCalls ? toolCalls : undefined,
254
+ });
255
+ }
256
+
257
+ // ---- if tool calls exist, execute them ----
258
+ if (toolCalls.length > 0) {
259
+ // Execute tools below
260
+ } else if (this.steerQueue.length > 0) {
261
+ const steers = this.steerQueue.splice(0);
262
+ for (const msg of steers) this.messages.push({ role: 'user', content: msg });
263
+ continue;
264
+ } else if (!hasText) {
265
+ if (nudges < MAX_NUDGES) {
266
+ nudges++;
267
+ const why = thought.trim()
268
+ ? 'your last output was reasoning/thinking only, with no answer text'
269
+ : 'your final message was empty';
270
+ this.onEvent({ type: 'nudge', reason: why, attempt: nudges, max: MAX_NUDGES });
271
+ const last = this.messages[this.messages.length - 1];
272
+ const body = nudgeMessage(why);
273
+ if (last && last.role === 'user' && typeof last.content === 'string') last.content += '\n\n' + body;
274
+ else this.messages.push({ role: 'user', content: body });
275
+ continue;
276
+ }
277
+ break;
278
+ } else {
279
+ // The model produced its answer. A steer typed WHILE that answer was
280
+ // streaming still has to be delivered: drain it and take another step.
281
+ // (Checking only at tool boundaries dropped it, because a final answer
282
+ // has no tool boundary.)
283
+ if (this.steerQueue.length > 0) {
284
+ const steers = this.steerQueue.splice(0);
285
+ for (const msg of steers) this.messages.push({ role: 'user', content: msg });
286
+ continue;
287
+ }
288
+ break;
289
+ }
290
+
291
+ // execute tools
292
+ let allOk = true;
293
+ // Tool-subset enforcement: in Plan/Focus mode the model may still emit a
294
+ // tool that is not exposed. Block it with a fixed message so the model
295
+ // learns it must stick to the allowed subset.
296
+ const modeName = Array.isArray(this.cfg.toolFilter)
297
+ ? (this.cfg.toolFilter.includes('Bash') ? 'Focus' : 'Plan')
298
+ : undefined;
299
+ for (const tc of toolCalls) {
300
+ if (this.stopRequested) break;
301
+ // Tag output produced by THIS tool call so onOutput can route it to the
302
+ // right "Using …" row (a turn can run several tools in sequence).
303
+ this._currentToolId = tc.id;
304
+
305
+ // Permission check before executing each tool
306
+ let result;
307
+ if (this.onApproval) {
308
+ const ok = await this.onApproval(tc.name, tc.args);
309
+ if (!ok) {
310
+ result = `Blocked by user: tool "${tc.name}" was rejected.`;
311
+ this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
312
+ this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
313
+ this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
314
+ allOk = false;
315
+ continue;
316
+ }
317
+ }
318
+
319
+ const tool = getTool(tc.name);
320
+ if (modeName && !this.cfg.toolFilter.includes(tc.name)) {
321
+ result = `You CAN'T use this tool on ${modeName} Mode. Allowed: ${this.cfg.toolFilter.join(', ')}.`;
322
+ this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
323
+ this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
324
+ this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
325
+ allOk = false;
326
+ continue;
327
+ }
328
+ if (!tool) {
329
+ result = `Error: unknown tool "${tc.name}".`;
330
+ allOk = false;
331
+ } else {
332
+ if (this.onToolStart) this.onToolStart(tc.name, tc.args);
333
+ this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
334
+ try {
335
+ result = await tool.execute(tc.args, this.ctx);
336
+ } catch (err) {
337
+ result = `Error running ${tc.name}: ${err.message}`;
338
+ allOk = false;
339
+ }
340
+ this.ctx.lastResult = result;
341
+ // Surface the live TODO list so the TUI can render its panel.
342
+ if (tc.name === 'TodoList') {
343
+ this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
344
+ }
345
+ }
346
+ this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
347
+ this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
348
+ }
349
+
350
+ // Did THIS iteration still have tool calls? If the loop runs out of budget
351
+ // right after running them, the turn really is unfinished.
352
+ hadToolCallsOnFinalStep = toolCalls.length > 0;
353
+
354
+ // Report the context after this step's tool results were appended: that is
355
+ // exactly what the next request will carry, so the gauge grows with history.
356
+ this.onEvent({ type: 'context', tokens: estimateMessagesTokens(this.messages, this.cfg), max: maxCtx });
357
+
358
+ }
359
+
360
+ // With the cap removed this can only trigger for a caller that set an
361
+ // explicit finite maxSteps: the final iteration still produced tool calls,
362
+ // so the turn genuinely ran out of rounds. Checking the step index alone (as
363
+ // this used to) reported "budget exhausted" for turns that had finished.
364
+ if (Number.isFinite(stepLimit) && !this.stopRequested && hadToolCallsOnFinalStep) {
365
+ this.onEvent({
366
+ type: 'incomplete',
367
+ reason: 'the step budget was exhausted while tools were still being called',
368
+ usedNudges: nudges,
369
+ });
370
+ }
371
+ if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
372
+ }
373
+ }
@@ -0,0 +1,114 @@
1
+ // Clipboard access, fast enough for a keypress.
2
+ //
3
+ // Spawning a fresh PowerShell costs ~1.5-3s on Windows (cold start), which made
4
+ // Ctrl+Shift+C and every paste feel broken. So:
5
+ // copy -> clip.exe with UTF-16LE input (~100ms, no PowerShell at all).
6
+ // read -> ONE long-lived PowerShell child, pre-warmed at TUI start; a command
7
+ // on an already-running shell costs ~100-230ms instead of seconds.
8
+ // If the helper is unusable, fall back to a one-shot PowerShell.
9
+ // Non-Windows paths (pbcopy/pbpaste/xclip) are cheap enough to run per call.
10
+
11
+ import cp from 'node:child_process';
12
+
13
+ const SENT = '@@HNCODE_CLIP@@';
14
+ let helper = null; // long-lived PowerShell child
15
+ let helperBuf = ''; // stdout accumulated since the last sentinel
16
+ let helperQueue = []; // pending read() resolvers
17
+ let helperBusy = false; // one command in flight at a time
18
+
19
+ function win() { return process.platform === 'win32'; }
20
+
21
+ // ---------------------------------------------------------------- copy
22
+ export function copyText(text) {
23
+ const s = String(text == null ? '' : text);
24
+ if (!s) return false;
25
+ if (win()) {
26
+ // clip.exe takes UTF-16LE. (`cmd /c echo "…" | clip` mangled quotes, '%'
27
+ // and newlines; plain UTF-8 input turned non-ASCII into mojibake.)
28
+ cp.execSync('clip', { input: Buffer.from(s, 'utf16le'), windowsHide: true, timeout: 5000 });
29
+ return true;
30
+ }
31
+ const cmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
32
+ cp.execSync(cmd, { input: Buffer.from(s, 'utf8'), timeout: 5000 });
33
+ return true;
34
+ }
35
+
36
+ // ------------------------------------------------------- PowerShell helper
37
+ export function warmClipboard() {
38
+ if (!win() || helper) return;
39
+ try {
40
+ helper = cp.spawn('powershell', ['-NoProfile', '-NoLogo', '-Command', '-'], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
41
+ helper.stdout.on('data', (d) => {
42
+ helperBuf += d.toString('binary');
43
+ let i;
44
+ while ((i = helperBuf.indexOf(SENT)) >= 0) {
45
+ const chunk = helperBuf.slice(0, i);
46
+ helperBuf = helperBuf.slice(i + SENT.length);
47
+ const w = helperQueue.shift();
48
+ helperBusy = false;
49
+ if (w) w(chunk);
50
+ }
51
+ });
52
+ helper.on('exit', () => { helper = null; helperBusy = false; });
53
+ helper.on('error', () => { helper = null; helperBusy = false; });
54
+ helper.stdin.write('$ProgressPreference = "SilentlyContinue"\n');
55
+ } catch { helper = null; }
56
+ }
57
+
58
+ function helperRun(script, timeoutMs) {
59
+ warmClipboard();
60
+ if (!helper) return Promise.resolve(null);
61
+ return new Promise((resolve) => {
62
+ const timer = setTimeout(() => { resolve(null); }, timeoutMs);
63
+ helperQueue.push((out) => { clearTimeout(timer); resolve(out); });
64
+ try { helper.stdin.write(script + `\n'${SENT}'\n`); }
65
+ catch { clearTimeout(timer); resolve(null); }
66
+ });
67
+ }
68
+
69
+ // ---------------------------------------------------------------- read
70
+ // Base64 both ways: decoding PowerShell's stdout as UTF-8 garbled non-ASCII
71
+ // text (its pipe encoding follows the console code page), so instead of reading
72
+ // text we read base64 ASCII and decode it ourselves.
73
+ function readOnce() {
74
+ if (win()) {
75
+ const b64 = cp.execSync(
76
+ 'powershell -NoProfile -Command "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + \'\'))"',
77
+ { encoding: 'ascii', windowsHide: true, timeout: 8000 },
78
+ );
79
+ const s = String(b64).replace(/[^A-Za-z0-9+/=]/g, '');
80
+ return Buffer.from(s, 'base64').toString('utf8');
81
+ }
82
+ const cmd = process.platform === 'darwin' ? 'pbpaste' : 'xclip -selection clipboard -o';
83
+ return cp.execSync(cmd, { encoding: 'utf8', timeout: 5000 });
84
+ }
85
+
86
+ // Async read: uses the warm helper when available. Returns { text, via } so the
87
+ // caller can tell the user which path answered.
88
+ export async function readText(timeoutMs = 3000) {
89
+ if (win() && helper && !helperBusy) {
90
+ helperBusy = true;
91
+ const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + ""))', timeoutMs);
92
+ if (out != null) {
93
+ const b64 = out.replace(/\r/g, '').split('\n').filter((l) => l.trim()).pop() || '';
94
+ try { return { text: Buffer.from(b64.trim(), 'base64').toString('utf8'), via: 'helper' }; } catch {}
95
+ }
96
+ helperBusy = false;
97
+ }
98
+ try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
99
+ }
100
+
101
+ // Read clipboard as image (base64 PNG). Returns null if clipboard has no image.
102
+ export function readImage() {
103
+ if (!win()) return null;
104
+ try {
105
+ // PowerShell: Get-Clipboard -Image, save to temp file, read as base64
106
+ const b64 = cp.execSync(
107
+ 'powershell -NoProfile -Command "$img = Get-Clipboard -Image; if ($null -ne $img) { $path = [System.IO.Path]::GetTempFileName() + \'.png\'; $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); $bytes = [System.IO.File]::ReadAllBytes($path); Remove-Item $path; Write-Host ([Convert]::ToBase64String($bytes)) }"',
108
+ { encoding: 'ascii', windowsHide: true, timeout: 8000 },
109
+ );
110
+ const s = String(b64).replace(/[^A-Za-z0-9+/=]/g, '');
111
+ if (s.length > 0) return s;
112
+ return null;
113
+ } catch { return null; }
114
+ }