@hncode/hncode 0.5.1 ā 0.5.2
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.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +1 -1
- package/src/agent-task.js +12 -1
- package/src/agent.js +111 -33
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +42 -6
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/llm.js +6 -0
- package/src/permissions.js +29 -7
- package/src/registry-browser.js +21 -6
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +95 -6
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +139 -0
- package/src/team/runner.js +347 -0
- package/src/team/spawn.js +247 -0
- package/src/team/store.js +145 -0
- package/src/team/tasks.js +164 -0
- package/src/team/team.js +311 -0
- package/src/team-panel.js +226 -0
- package/src/term.js +27 -0
- package/src/tool-result.js +20 -5
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +53 -3
- package/src/tools/edit.js +591 -436
- package/src/tools/git.js +15 -3
- package/src/tools/index.js +2 -0
- package/src/tools/team.js +398 -0
- package/src/tools/web-search.js +88 -75
- package/src/tui.js +5805 -4862
- package/src/update.js +285 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/bin/install.cjs
CHANGED
|
@@ -30,35 +30,27 @@ for (const dir of dirs) {
|
|
|
30
30
|
|
|
31
31
|
// Check if config file exists
|
|
32
32
|
if (!fs.existsSync(configFile)) {
|
|
33
|
-
const defaultConfig = `# hncode
|
|
34
|
-
#
|
|
33
|
+
const defaultConfig = `# hncode configuration
|
|
34
|
+
#
|
|
35
|
+
# Nothing here is required: every value has a default, and the same keys can be set with
|
|
36
|
+
# HNCODE_* environment variables or from /settings once hncode is running.
|
|
37
|
+
#
|
|
38
|
+
# Model and provider are deliberately left out. Naming one here starts hncode on a model
|
|
39
|
+
# you may have no key for, and the first turn fails against that provider instead of
|
|
40
|
+
# asking you to choose. Set up a provider with /provider, then pick a model with /model.
|
|
35
41
|
|
|
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
42
|
# [providers.openai]
|
|
43
43
|
# base_url = "https://api.openai.com/v1"
|
|
44
|
-
# api_key
|
|
44
|
+
# api_key = "sk-..." # or set HNCODE_API_KEY
|
|
45
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
46
|
`;
|
|
53
47
|
|
|
54
48
|
fs.writeFileSync(configFile, defaultConfig);
|
|
55
49
|
console.log(`\nā
Created default configuration at: ${configFile}`);
|
|
56
50
|
console.log('\nš” Next steps:');
|
|
57
|
-
console.log(' 1.
|
|
58
|
-
console.log(' 2.
|
|
59
|
-
console.log('
|
|
60
|
-
console.log(' export HNCODE_PROVIDER="openai"');
|
|
61
|
-
console.log(' 3. Run "hncode" to start!');
|
|
51
|
+
console.log(' 1. Start hncode and run /provider to add an API key');
|
|
52
|
+
console.log(' 2. Then /model to pick one');
|
|
53
|
+
console.log(' Both write to the config above, so there is nothing to edit by hand.');
|
|
62
54
|
} else {
|
|
63
55
|
console.log(`\nā¹ļø Configuration already exists at: ${configFile}`);
|
|
64
56
|
console.log(' Run "hncode init" to regenerate or edit manually.');
|
|
@@ -66,30 +58,18 @@ default_model = "gpt-4o-mini"
|
|
|
66
58
|
|
|
67
59
|
console.log('\n⨠hncode is ready to use!');
|
|
68
60
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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');
|
|
61
|
+
// Tell the user how to pick up the new command.
|
|
62
|
+
//
|
|
63
|
+
// This used to run PowerShell to prepend the npm global path, and print "Environment
|
|
64
|
+
// refreshed automatically". That cannot work: `execSync` starts a CHILD shell, so the
|
|
65
|
+
// `$env:Path` it sets dies with that process. The claim is gone; the note below is true.
|
|
66
|
+
if (os.platform() === 'win32') {
|
|
67
|
+
console.log('ā¹ļø If "hncode" is not found, open a new terminal (the PATH is updated at');
|
|
68
|
+
console.log(' login), or run this in the current one:');
|
|
69
|
+
console.log(' $env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" + $env:Path');
|
|
70
|
+
} else {
|
|
71
|
+
console.log('ā¹ļø If "hncode" is not found, open a new terminal, or re-read your shell');
|
|
72
|
+
console.log(' configuration: source ~/.zshrc (or ~/.bashrc)');
|
|
93
73
|
}
|
|
94
74
|
|
|
95
75
|
console.log('');
|
package/bin/preinstall.cjs
CHANGED
|
@@ -25,18 +25,4 @@ if (major < 20) {
|
|
|
25
25
|
const platform = os.platform();
|
|
26
26
|
console.log(`ā Platform: ${platform}`);
|
|
27
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
28
|
console.log('ā
Environment check passed!\n');
|
package/package.json
CHANGED
package/src/agent-task.js
CHANGED
|
@@ -76,7 +76,10 @@ function store(ctx) {
|
|
|
76
76
|
*/
|
|
77
77
|
export function createTask(ctx, kind, options = {}) {
|
|
78
78
|
const tasks = store(ctx);
|
|
79
|
-
|
|
79
|
+
// A teammate is its own kind rather than falling through to 'question': the id prefix is
|
|
80
|
+
// what a reader of a task list uses to tell at a glance what kind of work it is.
|
|
81
|
+
const PREFIX = { process: 'bash', agent: 'agent', teammate: 'mate' };
|
|
82
|
+
const prefix = PREFIX[kind] || 'question';
|
|
80
83
|
const task = {
|
|
81
84
|
taskId: newTaskId(prefix),
|
|
82
85
|
kind,
|
|
@@ -101,10 +104,18 @@ export function createTask(ctx, kind, options = {}) {
|
|
|
101
104
|
task.subagentType = options.subagentType;
|
|
102
105
|
task.model = options.model;
|
|
103
106
|
task.thinkingEffort = options.thinkingEffort;
|
|
107
|
+
} else if (kind === 'teammate') {
|
|
108
|
+
task.teamName = options.teamName;
|
|
109
|
+
task.memberName = options.memberName;
|
|
110
|
+
task.agentId = options.agentId;
|
|
104
111
|
} else if (kind === 'question') {
|
|
105
112
|
task.questionCount = options.questionCount || 0;
|
|
106
113
|
task.toolCallId = options.toolCallId;
|
|
107
114
|
}
|
|
115
|
+
// WHICH TASK THIS ONE BELONGS TO. A swarm's subagents are tasks of their own so /tasks can show
|
|
116
|
+
// what each is doing, and this is what groups them under the swarm that launched them. Absent
|
|
117
|
+
// for a top-level task, which is the common case.
|
|
118
|
+
if (options.parentTaskId) task.parentTaskId = String(options.parentTaskId);
|
|
108
119
|
// Every task exposes its buffered output through `output`. The chunks are
|
|
109
120
|
// stored raw and decoded lazily:
|
|
110
121
|
// * a process appends Buffer chunks -> decode as UTF-8
|
package/src/agent.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// model answers without tool calls (or a step/interrupt condition is hit).
|
|
4
4
|
|
|
5
5
|
import { LLM, setToolsList } from './llm.js';
|
|
6
|
-
import { tools, getTool, llmTools } from './tools/index.js';
|
|
6
|
+
import { tools, combinedTools, getTool, llmTools } from './tools/index.js';
|
|
7
7
|
import { DELEGATION_TOOLS } from './subagent-types.js';
|
|
8
8
|
import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
|
|
9
9
|
import { runHooks, runPatch, runPatchSync } from './plugin.js';
|
|
@@ -140,9 +140,10 @@ function hashKey(s) {
|
|
|
140
140
|
h = (h * 0x01000193) >>> 0;
|
|
141
141
|
}
|
|
142
142
|
return h.toString(36) + '-' + str.length.toString(36);
|
|
143
|
-
return h.toString(36) + '-' + str.length.toString(36);
|
|
144
143
|
}
|
|
145
144
|
|
|
145
|
+
// Render what an Edit actually changed, for the MODEL's tool result.
|
|
146
|
+
|
|
146
147
|
// Render what an Edit actually changed, for the MODEL's tool result.
|
|
147
148
|
//
|
|
148
149
|
// The UI has always received the edit through `editDiff` (see the tool_result
|
|
@@ -166,10 +167,18 @@ export function formatEditDiffForModel(diff, maxLines = 400) {
|
|
|
166
167
|
const out = [];
|
|
167
168
|
out.push('');
|
|
168
169
|
out.push('--- diff ---');
|
|
170
|
+
// Unchanged lines above and below, unmarked, so the change can be LOCATED. Without them this
|
|
171
|
+
// was a quoted block with no indication of where in the file it landed ā and identical-looking
|
|
172
|
+
// code appears in many places, so neither the model nor the user could confirm the edit hit
|
|
173
|
+
// the intended one rather than a same-text block further down.
|
|
174
|
+
const before = (diff.context && Array.isArray(diff.context.before)) ? diff.context.before : [];
|
|
175
|
+
const after = (diff.context && Array.isArray(diff.context.after)) ? diff.context.after : [];
|
|
176
|
+
for (const c of before) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
|
|
169
177
|
let n = startLine;
|
|
170
178
|
for (const l of oldLines) out.push(' ' + String(n++).padStart(4) + ' - ' + l);
|
|
171
179
|
n = startLine;
|
|
172
180
|
for (const l of newLines) out.push(' ' + String(n++).padStart(4) + ' + ' + l);
|
|
181
|
+
for (const c of after) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
|
|
173
182
|
// A whole-file edit (a Write-sized `new`) must not be echoed back in full.
|
|
174
183
|
if (out.length > maxLines + 3) {
|
|
175
184
|
const hidden = out.length - (maxLines + 3);
|
|
@@ -205,6 +214,37 @@ function toolResultLimitsEnabled() {
|
|
|
205
214
|
return !/^(0|off|false|no)$/i.test(String(process.env.HNCODE_TOOL_RESULT_LIMITS || ''));
|
|
206
215
|
}
|
|
207
216
|
|
|
217
|
+
/**
|
|
218
|
+
* A tool's return value, as the STRING the model can be sent.
|
|
219
|
+
*
|
|
220
|
+
* Every tool in `src/tools/` returns a plain string except the team tools, which return
|
|
221
|
+
* `{ content }` / `{ error }`. That object went into the `tool` message verbatim, and a provider
|
|
222
|
+
* that expects a string rejects the NEXT request with `missing type field in content item at
|
|
223
|
+
* index 0` ā so a teammate's first `SendMessage` worked and then the conversation died, one
|
|
224
|
+
* message in. The symptom was a team that says hello and then never answers again.
|
|
225
|
+
*
|
|
226
|
+
* Returns `null` for the one shape that must stay an OBJECT: `ReadMediaFile` returns
|
|
227
|
+
* `{ text, media: [...] }`, and the media parts travel as structured blocks the caller builds
|
|
228
|
+
* from the object. Stringifying that would attach no image at all, so the caller keeps it.
|
|
229
|
+
*
|
|
230
|
+
* Normalised here, at the single point every tool result passes through, rather than in the
|
|
231
|
+
* team tools: a second tool family making the same choice cannot reintroduce the bug.
|
|
232
|
+
*/
|
|
233
|
+
function toolResultText(value) {
|
|
234
|
+
if (typeof value === 'string') return value;
|
|
235
|
+
if (value == null) return '';
|
|
236
|
+
if (typeof value === 'object') {
|
|
237
|
+
// Leave a media result alone ā the caller needs the parts, not a sentence about them.
|
|
238
|
+
if (Array.isArray(value.media)) return null;
|
|
239
|
+
if (typeof value.content === 'string') return value.content;
|
|
240
|
+
if (typeof value.error === 'string') return value.error;
|
|
241
|
+
if (typeof value.text === 'string') return value.text;
|
|
242
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
243
|
+
}
|
|
244
|
+
return String(value);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
208
248
|
|
|
209
249
|
export const SYSTEM_PROMPT = `You are hncode, a coding agent in the user's terminal on ${process.platform}.
|
|
210
250
|
You work inside a workspace directory using the tools provided.
|
|
@@ -280,9 +320,9 @@ export class Agent {
|
|
|
280
320
|
// even if the filter would have allowed them. The catalog already excludes
|
|
281
321
|
// them from every profile; this is the hard backstop that also covers the
|
|
282
322
|
// `null` (= everything) case, so a subagent can never spawn another agent.
|
|
283
|
-
|
|
284
|
-
?
|
|
285
|
-
:
|
|
323
|
+
const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
324
|
+
? combinedTools().filter((t) => cfg.toolFilter.includes(t.name))
|
|
325
|
+
: combinedTools()
|
|
286
326
|
)
|
|
287
327
|
.filter((t) => !(cfg.noDelegation && DELEGATION_TOOLS.includes(t.name)))
|
|
288
328
|
// AgentSwarm is gated behind swarm mode, like Agent is gated behind the user
|
|
@@ -420,9 +460,10 @@ export class Agent {
|
|
|
420
460
|
const used = this.usedTokens();
|
|
421
461
|
const maxSize = Math.max(1, Math.floor((used || (maxCtx || 512000) * 0.85) * keepRatio));
|
|
422
462
|
const msgs = this.messages || [];
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
463
|
+
// How far the LEADING system block runs. A cut before `floor` would land inside it,
|
|
464
|
+
// splitting the assembled system prompt across the drop boundary. The block itself is
|
|
465
|
+
// carried over by the trim (renamed `leadingSystem` there), because a cut can only
|
|
466
|
+
// ever land at or after its end.
|
|
426
467
|
let floor = 0;
|
|
427
468
|
while (floor < msgs.length && msgs[floor] && msgs[floor].role === 'system') floor++;
|
|
428
469
|
let recent = 1;
|
|
@@ -435,13 +476,66 @@ export class Agent {
|
|
|
435
476
|
// call. It is also the memoised form, so repeating this walk after a large tool
|
|
436
477
|
// result is a WeakMap hit rather than a re-measure.
|
|
437
478
|
size += messageTokens(msgs[split], null);
|
|
438
|
-
|
|
479
|
+
// The guard the leading system block needs, and the reverse of the `split > floor`
|
|
480
|
+
// this used to test. A cut at `split` drops `[0..split-1]`, so `split >= floor`
|
|
481
|
+
// keeps the cut at or after the block's end and never inside it ā which is what
|
|
482
|
+
// the block's comment above asks for, and what `split > floor` inverted.
|
|
483
|
+
if (split >= floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
|
|
439
484
|
if (size >= maxSize && bestSplit !== undefined) break;
|
|
440
485
|
}
|
|
441
486
|
return bestSplit ?? 0;
|
|
442
487
|
}
|
|
443
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Trim the history at a legal cut and append the summary + continuation note.
|
|
491
|
+
*
|
|
492
|
+
* Extracted from `run()` so it can be tested against the REAL implementation. A test that
|
|
493
|
+
* re-implemented these four lines was green against a duplicated system prompt ā the copy
|
|
494
|
+
* carried its own guard, which is the failure mode of testing a restatement.
|
|
495
|
+
*
|
|
496
|
+
* Returns `{ split, dropped }`.
|
|
497
|
+
*
|
|
498
|
+
* Shape: `[ ...lead, ...kept, summary, continuation ]`.
|
|
499
|
+
*
|
|
500
|
+
* The summary is a USER message wrapped in a `<system-reminder>` (kimi's shape), not a
|
|
501
|
+
* `system` one. Two reasons:
|
|
502
|
+
* * the TUI rebuilds the system prompt from scratch each turn and SKIPS every `system`
|
|
503
|
+
* entry in the stored history, so a system summary was dropped before the model saw it;
|
|
504
|
+
* * the turn-end save filters `system` out of the session, so it was not persisted either
|
|
505
|
+
* ā history trimmed AND summary lost at both ends, which made "compaction" pure data loss.
|
|
506
|
+
* The continuation note goes LAST so the model's next move is "keep going" rather than
|
|
507
|
+
* "answer the summary".
|
|
508
|
+
*/
|
|
509
|
+
applyCompactionSplit(maxCtx, summary) {
|
|
510
|
+
const split = this.computeCompactSplit(maxCtx);
|
|
511
|
+
const keep = this.messages.slice(split);
|
|
512
|
+
const dropped = this.messages.length - keep.length;
|
|
513
|
+
// The LEADING system block is carried over explicitly. It sits at the head of the array
|
|
514
|
+
// and the cut drops everything before `split`, so it would otherwise be taken by every
|
|
515
|
+
// compaction ā leaving the request with no instructions, which is the loss the `floor`
|
|
516
|
+
// guard in computeCompactSplit exists to prevent.
|
|
517
|
+
let leadingSystem = 0;
|
|
518
|
+
while (leadingSystem < this.messages.length
|
|
519
|
+
&& this.messages[leadingSystem] && this.messages[leadingSystem].role === 'system') leadingSystem++;
|
|
520
|
+
// Only what the cut actually REMOVED. `keep` is `slice(split)`, so at `split === 0` it
|
|
521
|
+
// still contains the whole array including the block ā prepending the block then sends the
|
|
522
|
+
// system prompt twice, and `llm.js` concatenates every system message into the request, so
|
|
523
|
+
// it would stay doubled in every compacted request from then on.
|
|
524
|
+
const lead = split > 0 ? this.messages.slice(0, leadingSystem) : [];
|
|
525
|
+
const summaryText = summary
|
|
526
|
+
? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
|
|
527
|
+
: `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
|
|
528
|
+
this.messages = [
|
|
529
|
+
...lead,
|
|
530
|
+
...keep,
|
|
531
|
+
{ role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
|
|
532
|
+
{ role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
|
|
533
|
+
];
|
|
534
|
+
return { split, dropped };
|
|
535
|
+
}
|
|
536
|
+
|
|
444
537
|
async run() {
|
|
538
|
+
// Clear any interrupt flag left from a previous (interrupted) turn. This runs at
|
|
445
539
|
// Clear any interrupt flag left from a previous (interrupted) turn. This runs at
|
|
446
540
|
// the start of EVERY turn, so a stalled Esc/Ctrl-C from an earlier compaction can
|
|
447
541
|
// never suppress auto-compaction on the next message. (The constructor also
|
|
@@ -655,29 +749,7 @@ export class Agent {
|
|
|
655
749
|
break;
|
|
656
750
|
}
|
|
657
751
|
// NOW trim, at a split point the boundary rule allows.
|
|
658
|
-
const split = this.
|
|
659
|
-
const keep = this.messages.slice(split);
|
|
660
|
-
const droppedCount = this.messages.length - keep.length;
|
|
661
|
-
// Shape: [ ...kept, summary, continuation ].
|
|
662
|
-
//
|
|
663
|
-
// The summary is a USER message wrapped in a <system-reminder> (kimi's
|
|
664
|
-
// shape), not a `system` message. Two reasons:
|
|
665
|
-
// * the TUI rebuilds the system prompt from scratch each turn and SKIPS
|
|
666
|
-
// every `system` entry in the stored history, so a system summary was
|
|
667
|
-
// dropped before the model ever saw it;
|
|
668
|
-
// * the turn-end save filters `system` out of the session, so it was not
|
|
669
|
-
// persisted either ā history trimmed AND summary lost at both ends,
|
|
670
|
-
// which made "compaction" pure data loss.
|
|
671
|
-
// The continuation note goes LAST so the model's next move is "keep going"
|
|
672
|
-
// rather than "answer the summary".
|
|
673
|
-
const summaryText = summary
|
|
674
|
-
? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
|
|
675
|
-
: `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
|
|
676
|
-
this.messages = [
|
|
677
|
-
...keep,
|
|
678
|
-
{ role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
|
|
679
|
-
{ role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
|
|
680
|
-
];
|
|
752
|
+
const { split, dropped: droppedCount } = this.applyCompactionSplit(maxCtx, summary);
|
|
681
753
|
// The array was REPLACED, so every count the ledger held describes messages
|
|
682
754
|
// that no longer exist. `sync` would notice on its own (the first/last
|
|
683
755
|
// identities no longer match), but resetting here states the intent at the
|
|
@@ -931,7 +1003,13 @@ export class Agent {
|
|
|
931
1003
|
let _args = tc.args; let _skip = false;
|
|
932
1004
|
try { const out = await runPatch('toolDispatch', { name: tc.name, args: tc.args, ctx: this.ctx }, (c) => c); if (out) { _args = out.args !== undefined ? out.args : tc.args; if (out.skip) _skip = true; } } catch (e) { console.error('[hncode-plugin] toolDispatch error:', e.message); }
|
|
933
1005
|
if (_skip) { result = '[skipped by plugin: ' + tc.name + ']'; ok = true; }
|
|
934
|
-
else
|
|
1006
|
+
else {
|
|
1007
|
+
const raw = await tool.execute(_args, this.ctx);
|
|
1008
|
+
// `null` means the tool returned a structured MEDIA result: keep the object so the
|
|
1009
|
+
// image blocks can be built from it. Only a plain value is stringified.
|
|
1010
|
+
const text = toolResultText(raw);
|
|
1011
|
+
result = text === null ? raw : text;
|
|
1012
|
+
}
|
|
935
1013
|
} catch (err) {
|
|
936
1014
|
result = `Error running ${tc.name}: ${err.message}`;
|
|
937
1015
|
ok = false;
|
package/src/cell-buffer.js
CHANGED
|
@@ -126,6 +126,11 @@ export function renderCells(cells) {
|
|
|
126
126
|
}
|
|
127
127
|
out += c.ch;
|
|
128
128
|
}
|
|
129
|
+
// Close the LAST run too. Without this the final style stayed open past the end of the
|
|
130
|
+
// row, and a caller that concatenates rows ā `drawFrameBuffer` does, once per line ā
|
|
131
|
+
// carried it into the next one: a card drawn over a dimmed or highlighted row tinted
|
|
132
|
+
// every line below it, and the row's own trailing SGR was simply lost.
|
|
133
|
+
if (cur !== null && cur !== '') out += RESET;
|
|
129
134
|
return out;
|
|
130
135
|
}
|
|
131
136
|
|
package/src/clipboard.js
CHANGED
|
@@ -57,16 +57,34 @@ const ONESHOT_MS = 20000;
|
|
|
57
57
|
|
|
58
58
|
function win() { return process.platform === 'win32'; }
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Put text on the clipboard.
|
|
62
|
+
*
|
|
63
|
+
* WINDOWS: `Set-Clipboard` through PowerShell, NOT `clip.exe`.
|
|
64
|
+
*
|
|
65
|
+
* `clip.exe` takes UTF-16LE and needed a byte order mark to be read correctly ā without it
|
|
66
|
+
* non-ASCII arrived as mojibake (`äøęęµčÆ` came back as `-NåKmč«`, measured). But the mark is then
|
|
67
|
+
* part of the clipboard text: every copy began with a zero-width U+FEFF that showed up as a stray
|
|
68
|
+
* invisible character wherever it was pasted. Measured over six samples, `Set-Clipboard` was better
|
|
69
|
+
* on every one:
|
|
70
|
+
*
|
|
71
|
+
* copied clip.exe + BOM Set-Clipboard
|
|
72
|
+
* hello U+FEFF hello hello
|
|
73
|
+
* äøęęµčÆ U+FEFF äøęęµčÆ äøęęµčÆ
|
|
74
|
+
* line1\nline2 U+FEFF line1 CRLF line2 line1\nline2 (line endings untouched)
|
|
75
|
+
* emoji š ok U+FEFF emoji š ok emoji š ok
|
|
76
|
+
*
|
|
77
|
+
* It also needs no escaping: PowerShell receives a .NET string rather than a byte stream, so there
|
|
78
|
+
* is no console parser to satisfy ā which is what the BOM and the quoting were working around.
|
|
79
|
+
*
|
|
80
|
+
* `clip.exe` stays as a fallback for a machine with no PowerShell at all, where a copy carrying a
|
|
81
|
+
* stray BOM beats no copy.
|
|
82
|
+
*/
|
|
61
83
|
export function copyText(text) {
|
|
62
84
|
const s = String(text == null ? '' : text);
|
|
63
85
|
if (!s) return false;
|
|
64
86
|
if (win()) {
|
|
65
|
-
|
|
66
|
-
// and newlines; plain UTF-8 input turned non-ASCII into mojibake.) Prefix the
|
|
67
|
-
// UTF-16 byte order mark (FF FE) so clip.exe is never left to guess the endian
|
|
68
|
-
// or treat input as ANSI ā without it, non-ASCII on some Windows versions /
|
|
69
|
-
// code pages came out mojibake.
|
|
87
|
+
if (setClipboardText(s)) return true;
|
|
70
88
|
cp.execSync('clip', { input: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(s, 'utf16le')]), windowsHide: true, timeout: 5000 });
|
|
71
89
|
return true;
|
|
72
90
|
}
|
|
@@ -75,6 +93,24 @@ export function copyText(text) {
|
|
|
75
93
|
return true;
|
|
76
94
|
}
|
|
77
95
|
|
|
96
|
+
/** `Set-Clipboard` via PowerShell. False when no PowerShell can be run. */
|
|
97
|
+
function setClipboardText(s) {
|
|
98
|
+
// The text travels as base64 so nothing in it can end the script or be re-parsed. A quoted
|
|
99
|
+
// PowerShell literal would need every `'` doubled, and a newline or a backtick in the text would
|
|
100
|
+
// otherwise change what the script means.
|
|
101
|
+
const b64 = Buffer.from(s, 'utf16le').toString('base64');
|
|
102
|
+
const full = 'Add-Type -AssemblyName System.Windows.Forms; '
|
|
103
|
+
+ '[System.Windows.Forms.Clipboard]::SetText('
|
|
104
|
+
+ `[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String('${b64}')))`;
|
|
105
|
+
for (const exe of [_psExe, 'powershell']) {
|
|
106
|
+
try {
|
|
107
|
+
cp.execFileSync(exe, ['-NoProfile', '-NonInteractive', '-Command', full], { timeout: 10000, windowsHide: true });
|
|
108
|
+
return true;
|
|
109
|
+
} catch { /* try the next exe */ }
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
// Which PowerShell the helper runs. `pwsh` (7) starts far faster than the built-in
|
|
79
115
|
// `powershell` (5.1) ā measured 1.9s vs 11.5s for the same image probe ā and the image
|
|
80
116
|
// path needs the WinForms/Drawing assemblies. This is only the PREFERRED name: it is
|
package/src/context-bar.js
CHANGED
|
@@ -228,10 +228,51 @@ export function formatReadout(used, window, freeCols) {
|
|
|
228
228
|
return '';
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
/**
|
|
231
|
+
/**
|
|
232
|
+
* A token count as a compact string: `1200` -> `1.2k`, `97000` -> `97k`, `3400000` -> `3.4M`.
|
|
233
|
+
*
|
|
234
|
+
* Deliberately the SAME shape `fmtTokensShort` produces for the working row, because both appear
|
|
235
|
+
* in one frame ā the context bar and the live turn counter would otherwise disagree about the same
|
|
236
|
+
* quantity. Two rules:
|
|
237
|
+
*
|
|
238
|
+
* * one decimal while it carries information, dropped when it is a zero (`97000` is `97k`, not
|
|
239
|
+
* `97.0k` ā the trailing `.0` is three characters saying nothing);
|
|
240
|
+
* * K/M/B/T suffixes, with only the kilo lower-case, so `3.4M` here and `3.4M` on the working row
|
|
241
|
+
* read as one vocabulary.
|
|
242
|
+
*
|
|
243
|
+
* The previous version kept `.0` and disagreed with the working row on units entirely: it divided
|
|
244
|
+
* by 1024 where the row divided by 1000, so a 97k context read `94.7k` beside a `97k` turn.
|
|
245
|
+
*/
|
|
232
246
|
export function compactTokens(n) {
|
|
233
|
-
const v = Number(n) || 0;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
247
|
+
const v = Math.max(0, Math.floor(Number(n) || 0));
|
|
248
|
+
const units = [
|
|
249
|
+
{ at: 1e12, suffix: 'T' },
|
|
250
|
+
{ at: 1e9, suffix: 'B' },
|
|
251
|
+
{ at: 1e6, suffix: 'M' },
|
|
252
|
+
{ at: 1e3, suffix: 'k' },
|
|
253
|
+
];
|
|
254
|
+
for (let i = 0; i < units.length; i++) {
|
|
255
|
+
const u = units[i];
|
|
256
|
+
if (v < u.at) continue;
|
|
257
|
+
// The identical scaling rule the working row and the /ctx readout use: one decimal below 100,
|
|
258
|
+
// whole numbers above. Three formatters render token counts into one frame, so a single rule is
|
|
259
|
+
// the only way `262k` cannot appear beside `262.1k` for the same quantity.
|
|
260
|
+
const shown = scaleUnit(v / u.at);
|
|
261
|
+
// Rounding can reach the next unit (`999999` is `1000k`, which is `1m`), and showing `1000k`
|
|
262
|
+
// makes the reader convert a unit themselves.
|
|
263
|
+
if (Number(shown) >= 1000 && i > 0) return `${scaleUnit(v / units[i - 1].at)}${units[i - 1].suffix}`;
|
|
264
|
+
return `${shown}${u.suffix}`;
|
|
265
|
+
}
|
|
266
|
+
return String(v);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* One unit's worth of a count: `1.2`, `97`, `262`.
|
|
271
|
+
*
|
|
272
|
+
* Duplicated from tui.js rather than imported, because `context-bar.js` must stay a leaf module the
|
|
273
|
+
* TUI can import without a cycle. The two are pinned together by test/context-bar.test.mjs and
|
|
274
|
+
* test/working-tokens.test.mjs, which assert the same table of values through both.
|
|
275
|
+
*/
|
|
276
|
+
function scaleUnit(v) {
|
|
277
|
+
return v < 100 ? v.toFixed(1).replace(/\.0$/, '') : String(Math.round(v));
|
|
237
278
|
}
|
package/src/doctor.js
CHANGED
|
@@ -306,8 +306,14 @@ function checkGit(workspace, cp) {
|
|
|
306
306
|
const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
307
307
|
out.push(check('git', 'ok', `${branch || '(detached)'} in ${top}`));
|
|
308
308
|
|
|
309
|
+
// Because `run` pins cwd to the workspace, `config --get` reports the identity that a
|
|
310
|
+
// commit HERE would actually use ā not one inherited from an unrelated directory.
|
|
311
|
+
// A value that IS set is reported too: only a row in the report distinguishes
|
|
312
|
+
// "configured" from "the check never ran", and silence reads as success.
|
|
309
313
|
for (const key of ['user.name', 'user.email']) {
|
|
310
|
-
|
|
314
|
+
const value = run(['config', '--get', key]);
|
|
315
|
+
if (value) out.push(check(`git ${key}`, 'ok', value));
|
|
316
|
+
else {
|
|
311
317
|
out.push(check(`git ${key}`, 'warn', 'not set',
|
|
312
318
|
`Every commit fails without it. Run: git config --global ${key} "ā¦"`));
|
|
313
319
|
}
|
|
@@ -324,7 +330,6 @@ function checkGit(workspace, cp) {
|
|
|
324
330
|
}
|
|
325
331
|
}
|
|
326
332
|
return out;
|
|
327
|
-
return out;
|
|
328
333
|
}
|
|
329
334
|
|
|
330
335
|
/**
|
package/src/git.js
CHANGED
|
@@ -33,6 +33,10 @@ const GIT_ENV = [
|
|
|
33
33
|
|
|
34
34
|
// Run git with args in `cwd`. Returns { ok, stdout, stderr, code, error }.
|
|
35
35
|
// Never throws: a missing git binary or a non-repo is data, not an exception.
|
|
36
|
+
//
|
|
37
|
+
// SYNCHRONOUS, and therefore only for the queries made at STARTUP (the status bar's branch and
|
|
38
|
+
// sha). It blocks the event loop, so an AbortSignal cannot fire during it ā use `runGitAsync` for
|
|
39
|
+
// anything a tool call reaches.
|
|
36
40
|
export function runGit(args, cwd, opts = {}) {
|
|
37
41
|
const timeout = opts.timeoutMs || 30_000;
|
|
38
42
|
try {
|
|
@@ -53,7 +57,6 @@ export function runGit(args, cwd, opts = {}) {
|
|
|
53
57
|
return { ok: false, stdout: '', stderr: '', code: null, error: e.message };
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
|
-
|
|
57
60
|
export function isRepo(cwd) {
|
|
58
61
|
const r = runGit(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
59
62
|
return r.ok && r.stdout.trim() === 'true';
|
|
@@ -436,10 +439,20 @@ export function rebase(cwd, onto) {
|
|
|
436
439
|
// The async form below runs the same command off the main thread, so the frame
|
|
437
440
|
// draws immediately and the badge fills in when the data arrives.
|
|
438
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Run git without blocking the event loop.
|
|
444
|
+
*
|
|
445
|
+
* `opts.signal` makes the command interruptible, which is the whole reason a tool call must use this
|
|
446
|
+
* rather than `runGit`: a synchronous `spawnSync` holds the event loop, so an AbortSignal can never
|
|
447
|
+
* fire during it ā measured, `spawnSync` accepts a `signal` option and ignores it, waiting out the
|
|
448
|
+
* full timeout. With Esc interrupting a turn, `git status` or `git log` on a large repository would
|
|
449
|
+
* otherwise freeze the TUI for up to the timeout with no key able to reach it.
|
|
450
|
+
*/
|
|
439
451
|
export function runGitAsync(args, cwd, opts = {}) {
|
|
440
452
|
return new Promise((resolve) => {
|
|
441
453
|
let child;
|
|
442
454
|
const timeout = opts.timeoutMs || 30_000;
|
|
455
|
+
const signal = opts.signal;
|
|
443
456
|
try {
|
|
444
457
|
child = cp.spawn('git', [...GIT_ENV, ...args], {
|
|
445
458
|
cwd: cwd || process.cwd(),
|
|
@@ -453,11 +466,29 @@ export function runGitAsync(args, cwd, opts = {}) {
|
|
|
453
466
|
let stdout = '';
|
|
454
467
|
let stderr = '';
|
|
455
468
|
let done = false;
|
|
456
|
-
const finish = (r) => {
|
|
469
|
+
const finish = (r) => {
|
|
470
|
+
if (done) return;
|
|
471
|
+
done = true;
|
|
472
|
+
clearTimeout(timer);
|
|
473
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
474
|
+
resolve(r);
|
|
475
|
+
};
|
|
457
476
|
const timer = setTimeout(() => {
|
|
458
|
-
try { child.kill(); } catch {}
|
|
459
|
-
|
|
477
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
478
|
+
// The partial output is returned rather than dropped: it is often still useful, and an empty
|
|
479
|
+
// string would look like "git had nothing to say" instead of "git was cut short".
|
|
480
|
+
finish({ ok: false, stdout, stderr, code: null, error: 'git timed out' });
|
|
460
481
|
}, timeout);
|
|
482
|
+
// `interrupted` is a distinct error from a timeout, so the caller can tell a user's Esc from a
|
|
483
|
+
// command that hung.
|
|
484
|
+
const onAbort = () => {
|
|
485
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
486
|
+
finish({ ok: false, stdout, stderr, code: null, error: 'interrupted' });
|
|
487
|
+
};
|
|
488
|
+
if (signal) {
|
|
489
|
+
if (signal.aborted) { clearTimeout(timer); onAbort(); return; }
|
|
490
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
491
|
+
}
|
|
461
492
|
if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
|
|
462
493
|
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
|
|
463
494
|
child.on('error', (e) => finish({ ok: false, stdout: '', stderr: '', code: null, error: e.message }));
|
package/src/llm.js
CHANGED
|
@@ -421,6 +421,12 @@ export class LLM {
|
|
|
421
421
|
|
|
422
422
|
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
|
423
423
|
attempts = attempt;
|
|
424
|
+
// Reset per attempt. Left holding the PREVIOUS attempt's response, a network error on
|
|
425
|
+
// this attempt left `res` truthy and `!res.ok` true, so the loop reported the old
|
|
426
|
+
// status and the old body ā `LLM request failed (503)` for a connection that was
|
|
427
|
+
// actually refused, with `lastError` (the real cause) never reaching the user.
|
|
428
|
+
res = null;
|
|
429
|
+
lastError = null;
|
|
424
430
|
// Fresh controller per attempt so an abort aborts this specific request
|
|
425
431
|
// and the retry loop stops.
|
|
426
432
|
this.controller = new AbortController();
|