@hncode/hncode 0.3.3 → 0.3.4
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/package.json +1 -1
- package/src/agent-task.js +78 -0
- package/src/agent.js +75 -45
- package/src/config.js +6 -0
- package/src/git.js +170 -1
- package/src/index.js +27 -13
- package/src/mcp.js +21 -13
- package/src/tui.js +278 -31
- package/src/update.js +35 -14
package/package.json
CHANGED
package/src/agent-task.js
CHANGED
|
@@ -176,3 +176,81 @@ export function statusColor(status) {
|
|
|
176
176
|
if (status === 'completed') return 'gray';
|
|
177
177
|
return 'red';
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
// ---- background-task transcript card (mirrors kimi-code) --------------------
|
|
181
|
+
// A background task reports its lifecycle as a one-line card in the transcript:
|
|
182
|
+
//
|
|
183
|
+
// ● agent task completed in background (review the diff)
|
|
184
|
+
// ✗ bash task failed in background (npm test · exit 1)
|
|
185
|
+
//
|
|
186
|
+
// `phase` drives the bullet colour (started / completed / failed); `headline`
|
|
187
|
+
// and the dim `detail` carry the rest. Ported from kimi-code's
|
|
188
|
+
// formatBackgroundTaskTranscript (apps/kimi-code/src/tui/utils/background-task-status.ts)
|
|
189
|
+
// so the two tools read the same way.
|
|
190
|
+
|
|
191
|
+
const BG_DETAIL_MAX = 240;
|
|
192
|
+
|
|
193
|
+
function bgTruncate(value) {
|
|
194
|
+
if (value === undefined || value === null) return undefined;
|
|
195
|
+
const collapsed = String(value).trim().replace(/\s+/g, ' ');
|
|
196
|
+
if (!collapsed) return undefined;
|
|
197
|
+
return collapsed.length <= BG_DETAIL_MAX ? collapsed : collapsed.slice(0, BG_DETAIL_MAX - 1) + '…';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function backgroundTaskPhase(status) {
|
|
201
|
+
if (status === 'running') return 'started';
|
|
202
|
+
if (status === 'completed') return 'completed';
|
|
203
|
+
return 'failed'; // failed / timed_out / killed / lost
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function bgSubject(kind) {
|
|
207
|
+
if (kind === 'agent') return 'agent task';
|
|
208
|
+
if (kind === 'question') return 'question task';
|
|
209
|
+
return 'bash task';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function bgHeadline(status, kind) {
|
|
213
|
+
const subject = bgSubject(kind);
|
|
214
|
+
switch (status) {
|
|
215
|
+
case 'running': return `${subject} started in background`;
|
|
216
|
+
case 'completed': return `${subject} completed in background`;
|
|
217
|
+
case 'failed': return `${subject} failed in background`;
|
|
218
|
+
case 'timed_out': return `${subject} timed out`;
|
|
219
|
+
case 'killed': return `${subject} stopped`;
|
|
220
|
+
case 'lost': return `${subject} lost`;
|
|
221
|
+
default: return `${subject} ${status}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function bgDetail(task) {
|
|
226
|
+
const parts = [];
|
|
227
|
+
const description = bgTruncate(task.description);
|
|
228
|
+
if (description !== undefined) parts.push(description);
|
|
229
|
+
|
|
230
|
+
if (task.status === 'completed' || task.status === 'failed') {
|
|
231
|
+
if (task.kind === 'process' && task.exitCode !== null && task.exitCode !== undefined) {
|
|
232
|
+
parts.push(`exit ${task.exitCode}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (task.status === 'killed') {
|
|
236
|
+
const reason = bgTruncate(task.stopReason);
|
|
237
|
+
parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped');
|
|
238
|
+
}
|
|
239
|
+
if (task.status === 'failed') {
|
|
240
|
+
const reason = bgTruncate(task.stopReason);
|
|
241
|
+
if (reason !== undefined) parts.push(reason);
|
|
242
|
+
}
|
|
243
|
+
if (task.status === 'timed_out') parts.push('timed out');
|
|
244
|
+
if (task.status === 'lost') parts.push('session restarted before completion');
|
|
245
|
+
|
|
246
|
+
return parts.length ? parts.join(' · ') : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Build the card payload for a task snapshot.
|
|
250
|
+
export function backgroundTaskCard(task) {
|
|
251
|
+
return {
|
|
252
|
+
phase: backgroundTaskPhase(task.status),
|
|
253
|
+
headline: bgHeadline(task.status, task.kind),
|
|
254
|
+
detail: bgDetail(task),
|
|
255
|
+
};
|
|
256
|
+
}
|
package/src/agent.js
CHANGED
|
@@ -24,20 +24,10 @@ function hashKey(s) {
|
|
|
24
24
|
return h.toString(36) + '-' + str.length.toString(36);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// ---- completion guard --------------------------------------------------------
|
|
28
|
-
// A turn is only complete when the model ends it with an actual answer. Models
|
|
29
|
-
// do stop mid-task: the stream ends right after a reasoning block, or with an
|
|
30
|
-
// empty assistant message, and the caller would report "done" for unfinished
|
|
31
|
-
// work. When that happens we tell the model what it did and give it more steps.
|
|
32
|
-
// Bounded, so a model that insists on stopping cannot loop forever.
|
|
33
|
-
export const MAX_NUDGES = 5;
|
|
34
27
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
+ 'summary of what changed and how you verified it. Otherwise keep going with the tools right '
|
|
39
|
-
+ 'now — do not stop silently.';
|
|
40
|
-
}
|
|
28
|
+
// Final instruction appended to the system prompt (see SYSTEM_PROMPT).
|
|
29
|
+
// (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
|
|
30
|
+
|
|
41
31
|
|
|
42
32
|
// Final instruction appended to the system prompt (see SYSTEM_PROMPT).
|
|
43
33
|
// (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
|
|
@@ -199,7 +189,6 @@ export class Agent {
|
|
|
199
189
|
let toolCalls = [];
|
|
200
190
|
let emittedEnd = false;
|
|
201
191
|
let thought = '';
|
|
202
|
-
let nudges = 0;
|
|
203
192
|
// decide whether running out of steps actually left the turn unfinished.
|
|
204
193
|
let hadToolCallsOnFinalStep = false;
|
|
205
194
|
|
|
@@ -252,8 +241,6 @@ export class Agent {
|
|
|
252
241
|
// No step cap: the loop runs until the model produces a final answer, the
|
|
253
242
|
// user interrupts (Esc / Ctrl-C), or an error ends the turn. maxSteps is
|
|
254
243
|
// kept only for callers that explicitly request a bound (0/undefined = none).
|
|
255
|
-
// Runaway safety comes from the completion guard (MAX_NUDGES) and the user's
|
|
256
|
-
// ability to interrupt, not from a fixed tool-round budget.
|
|
257
244
|
const stepLimit = (this.maxSteps > 0) ? this.maxSteps : Infinity;
|
|
258
245
|
for (let step = 0; step < stepLimit; step++) {
|
|
259
246
|
// Process any steer messages collected during the previous step
|
|
@@ -278,36 +265,90 @@ export class Agent {
|
|
|
278
265
|
// preserved rather than simply discarded.
|
|
279
266
|
const maxCtx = this.cfg.maxContextTokens || 512000;
|
|
280
267
|
let usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
281
|
-
if (maxCtx > 0 && usedNow >= maxCtx * 0.85) {
|
|
268
|
+
if (this.cfg.autoCompact !== false && maxCtx > 0 && usedNow >= maxCtx * 0.85) {
|
|
282
269
|
const before = usedNow;
|
|
283
270
|
// SHELL HOOK: PreCompact — a chance to back up the transcript before the
|
|
284
271
|
// older half is summarized away. Never blocks (the trim must go ahead).
|
|
285
272
|
await runShellHooks(this.hookConfig, 'PreCompact',
|
|
286
273
|
{ tokens: usedNow, messages: this.messages }, this.cfg.workspace);
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
this.
|
|
291
|
-
//
|
|
292
|
-
//
|
|
274
|
+
// Tell the TUI the trim is under way. Summarizing is a full model
|
|
275
|
+
// round-trip, so without this the UI sat silent for seconds mid-turn
|
|
276
|
+
// with no sign anything was happening.
|
|
277
|
+
this.onEvent({ type: 'compacting', before: usedNow, keep: 0, dropped: 0 });
|
|
278
|
+
//
|
|
279
|
+
// ORDER: SUMMARIZE FIRST, THEN TRIM. The summary is generated from the
|
|
280
|
+
// FULL history so the summarizing model can see what just happened — the
|
|
281
|
+
// most recent exchange is what tells it what the session is doing now.
|
|
282
|
+
// Trimming first and summarizing the leftovers summarized only the OLD
|
|
283
|
+
// part and lost the information the summary needed most.
|
|
284
|
+
//
|
|
285
|
+
// The transcript fed to the summarizer is BOUNDED: the full history can
|
|
286
|
+
// itself exceed the window, in which case the call fails and the trim
|
|
287
|
+
// would drop everything with nothing to show for it. Keep the MOST RECENT
|
|
288
|
+
// messages within the budget — they carry the live state — and clip any
|
|
289
|
+
// single oversized message.
|
|
290
|
+
const SUMMARY_INPUT_CHARS = 120_000;
|
|
291
|
+
const perMsgCap = 8_000;
|
|
292
|
+
const summaryParts = [];
|
|
293
|
+
let summaryChars = 0;
|
|
294
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
295
|
+
const m = this.messages[i];
|
|
296
|
+
const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '');
|
|
297
|
+
const clipped = raw.length > perMsgCap ? raw.slice(0, perMsgCap) + '\n…[clipped]' : raw;
|
|
298
|
+
const line = `${m.role}: ${clipped}`;
|
|
299
|
+
if (summaryChars + line.length > SUMMARY_INPUT_CHARS) break;
|
|
300
|
+
summaryChars += line.length;
|
|
301
|
+
summaryParts.unshift(line);
|
|
302
|
+
}
|
|
293
303
|
let summary = '';
|
|
294
|
-
if (
|
|
304
|
+
if (this.messages.length > 0) {
|
|
295
305
|
const summaryPrompt = [
|
|
296
306
|
{ 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.' },
|
|
297
|
-
|
|
307
|
+
{ role: 'user', content: summaryParts.join('\n\n') },
|
|
298
308
|
];
|
|
299
309
|
const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096, 2048) };
|
|
300
310
|
const llm = new LLM(cfgCopy);
|
|
301
311
|
try { summary = await llm.requestText(summaryPrompt); } catch { summary = ''; }
|
|
302
|
-
if (summary) {
|
|
303
|
-
this.messages = [{
|
|
304
|
-
role: 'system',
|
|
305
|
-
content: `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of what was trimmed:\n\n${summary}`,
|
|
306
|
-
}, ...this.messages];
|
|
307
|
-
}
|
|
308
312
|
}
|
|
313
|
+
// NOW trim. Keep under a TOKEN budget, not a message count: sizes vary by
|
|
314
|
+
// orders of magnitude (one Read result can dwarf fifty short turns), so a
|
|
315
|
+
// fixed 20%-of-messages slice could still leave the request above the
|
|
316
|
+
// threshold or throw away far more than needed. Walk backwards until the
|
|
317
|
+
// retained tail reaches ~30% of the window; always keep at least the last
|
|
318
|
+
// two so the newest exchange survives.
|
|
319
|
+
const keepBudget = Math.max(1, Math.floor(maxCtx * 0.3));
|
|
320
|
+
let keepFrom = this.messages.length;
|
|
321
|
+
let keptTokens = 0;
|
|
322
|
+
while (keepFrom > 0) {
|
|
323
|
+
const n = this.messages.length - keepFrom; // already-kept count
|
|
324
|
+
if (n >= 2 && keptTokens >= keepBudget) break;
|
|
325
|
+
const cand = this.messages[keepFrom - 1];
|
|
326
|
+
const t = estimateMessagesTokens([cand], this.cfg) - estimateMessagesTokens([], this.cfg);
|
|
327
|
+
// Always take at least the last two messages, however big they are.
|
|
328
|
+
if (n >= 2 && keptTokens + t > keepBudget) break;
|
|
329
|
+
keptTokens += t;
|
|
330
|
+
keepFrom--;
|
|
331
|
+
}
|
|
332
|
+
// BOUNDARY FIX: never let the kept slice START on a `tool` message whose
|
|
333
|
+
// assistant(toolCalls) was dropped — most APIs reject a tool result with
|
|
334
|
+
// no preceding call. Pull the boundary back over any leading tool rows.
|
|
335
|
+
while (keepFrom > 0 && keepFrom < this.messages.length && this.messages[keepFrom].role === 'tool') {
|
|
336
|
+
keepFrom--;
|
|
337
|
+
}
|
|
338
|
+
const keep = this.messages.slice(keepFrom);
|
|
339
|
+
const droppedCount = keepFrom;
|
|
340
|
+
// Final shape: SUMMARY + the most recent messages.
|
|
341
|
+
// ALWAYS prepend a marker, even when summarization failed. Silently
|
|
342
|
+
// dropping history left the model (and the user) with no idea that context
|
|
343
|
+
// was lost; a one-line note makes the gap explicit.
|
|
344
|
+
this.messages = [{
|
|
345
|
+
role: 'system',
|
|
346
|
+
content: summary
|
|
347
|
+
? `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of the conversation so far:\n\n${summary}`
|
|
348
|
+
: `[hncode] ${droppedCount} earlier message(s) were dropped to stay within the context window, and no summary could be generated. Their detail is no longer available.`,
|
|
349
|
+
}, ...keep];
|
|
309
350
|
usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
310
|
-
this.onEvent({ type: 'compacted', before, after: usedNow, kept:
|
|
351
|
+
this.onEvent({ type: 'compacted', before, after: usedNow, kept: keep.length });
|
|
311
352
|
}
|
|
312
353
|
// Live context gauge: report the estimated size of the request about to be
|
|
313
354
|
// sent, so the TUI's context readout is refreshed at every step rather than
|
|
@@ -363,18 +404,8 @@ export class Agent {
|
|
|
363
404
|
for (const msg of steers) this.messages.push({ role: 'user', content: msg });
|
|
364
405
|
continue;
|
|
365
406
|
} else if (!hasText) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const why = thought.trim()
|
|
369
|
-
? 'your last output was reasoning/thinking only, with no answer text'
|
|
370
|
-
: 'your final message was empty';
|
|
371
|
-
this.onEvent({ type: 'nudge', reason: why, attempt: nudges, max: MAX_NUDGES });
|
|
372
|
-
const last = this.messages[this.messages.length - 1];
|
|
373
|
-
const body = nudgeMessage(why);
|
|
374
|
-
if (last && last.role === 'user' && typeof last.content === 'string') last.content += '\n\n' + body;
|
|
375
|
-
else this.messages.push({ role: 'user', content: body });
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
407
|
+
// No tool calls and no answer text: the model stopped without saying
|
|
408
|
+
// anything. End the turn.
|
|
378
409
|
break;
|
|
379
410
|
} else {
|
|
380
411
|
// The model produced its answer. A steer typed WHILE that answer was
|
|
@@ -512,7 +543,6 @@ export class Agent {
|
|
|
512
543
|
this.onEvent({
|
|
513
544
|
type: 'incomplete',
|
|
514
545
|
reason: 'the step budget was exhausted while tools were still being called',
|
|
515
|
-
usedNudges: nudges,
|
|
516
546
|
});
|
|
517
547
|
}
|
|
518
548
|
if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
|
package/src/config.js
CHANGED
|
@@ -286,6 +286,12 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
286
286
|
autoUpdate: process.env.HNCODE_AUTO_UPDATE
|
|
287
287
|
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_UPDATE)
|
|
288
288
|
: (root.auto_update === true || root.auto_update === 'true'),
|
|
289
|
+
// AUTO-COMPACTION (/auto-compact): when true, the agent summarizes older
|
|
290
|
+
// history once a request reaches 85% of the model context. Default ON. Set
|
|
291
|
+
// `auto_compact = false` (or HNCODE_AUTO_COMPACT=0) to turn it off.
|
|
292
|
+
autoCompact: process.env.HNCODE_AUTO_COMPACT
|
|
293
|
+
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_COMPACT)
|
|
294
|
+
: !(root.auto_compact === false || root.auto_compact === 'false'),
|
|
289
295
|
// PROMPT CACHE (/cache): explicit cache breakpoints for long stable prefixes.
|
|
290
296
|
// Default ON — it only ever adds provider-recognised markers, and an
|
|
291
297
|
// unsupported field on a non-caching gateway is filtered out in cache.js.
|
package/src/git.js
CHANGED
|
@@ -329,4 +329,173 @@ export function statuslineInfo(cwd) {
|
|
|
329
329
|
deletions: unstaged.deletions + staged.deletions,
|
|
330
330
|
changed: unstaged.files + staged.files,
|
|
331
331
|
};
|
|
332
|
-
}
|
|
332
|
+
}
|
|
333
|
+
// ---- diff / log / push / stash / rebase -------------------------------------
|
|
334
|
+
// Thin wrappers over `git` for the TUI's slash commands. Each returns
|
|
335
|
+
// { ok, ... } and never throws, so a command handler can just print the result.
|
|
336
|
+
|
|
337
|
+
// Working-tree diff. `staged` shows the index, `path` limits to one file.
|
|
338
|
+
// `stat` returns only the summary line (cheap); otherwise the patch, bounded so a
|
|
339
|
+
// huge change cannot flood the transcript.
|
|
340
|
+
export function diffText(cwd, opts = {}) {
|
|
341
|
+
const args = ['diff'];
|
|
342
|
+
if (opts.staged) args.push('--cached');
|
|
343
|
+
const paths = opts.path ? [opts.path] : [];
|
|
344
|
+
if (opts.stat) args.push('--stat');
|
|
345
|
+
const r = runGit(paths.length ? args.concat(['--', ...paths]) : args, cwd);
|
|
346
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git diff failed', text: '' };
|
|
347
|
+
let text = r.stdout;
|
|
348
|
+
if (text.length > 200 * 1024) text = text.slice(0, 200 * 1024) + '\n... [diff truncated]';
|
|
349
|
+
return { ok: true, text, empty: !text.trim(), error: null };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Commit history, one commit per line: `<short-sha> <date> <subject>`.
|
|
353
|
+
// `count` defaults to 15; `path` limits to a file's history.
|
|
354
|
+
export function logText(cwd, opts = {}) {
|
|
355
|
+
const n = Math.max(1, Math.min(500, Number(opts.count) || 15));
|
|
356
|
+
const args = ['log', `-${n}`, '--pretty=format:%h %ad %s', '--date=short'];
|
|
357
|
+
if (opts.path) args.push('--', opts.path);
|
|
358
|
+
const r = runGit(args, cwd);
|
|
359
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git log failed', text: '' };
|
|
360
|
+
return { ok: true, text: r.stdout.trim(), empty: !r.stdout.trim(), error: null };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Push the current (or a named) branch. This is an OUTBOUND operation, so the
|
|
364
|
+
// caller must have confirmed first. `setUpstream` adds -u when the branch has no
|
|
365
|
+
// upstream yet. `force` is opt-in only.
|
|
366
|
+
export function push(cwd, opts = {}) {
|
|
367
|
+
const branch = String(opts.branch || '').trim();
|
|
368
|
+
const remote = String(opts.remote || 'origin').trim();
|
|
369
|
+
const args = ['push'];
|
|
370
|
+
if (opts.setUpstream) args.push('-u');
|
|
371
|
+
if (opts.force) args.push('--force-with-lease'); // safer than --force
|
|
372
|
+
args.push(remote);
|
|
373
|
+
if (branch) args.push(branch);
|
|
374
|
+
const r = runGit(args, cwd, { timeoutMs: 120_000 });
|
|
375
|
+
if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git push failed', output: r.stdout.trim() };
|
|
376
|
+
return { ok: true, output: (r.stdout + r.stderr).trim(), error: null };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Does the branch have an upstream? Used to decide whether push needs -u.
|
|
380
|
+
export function hasUpstream(cwd, branch) {
|
|
381
|
+
const b = branch || currentBranch(cwd);
|
|
382
|
+
if (!b) return false;
|
|
383
|
+
const r = runGit(['rev-parse', '--abbrev-ref', `${b}@{upstream}`], cwd);
|
|
384
|
+
return r.ok;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Commits the local branch is ahead/behind its upstream, e.g. "ahead 2, behind 0".
|
|
388
|
+
export function aheadBehind(cwd, branch) {
|
|
389
|
+
const b = branch || currentBranch(cwd);
|
|
390
|
+
if (!b) return '';
|
|
391
|
+
const r = runGit(['rev-list', '--left-right', '--count', `${b}...${b}@{upstream}`], cwd);
|
|
392
|
+
if (!r.ok) return '';
|
|
393
|
+
const [ahead, behind] = r.stdout.trim().split(/\s+/);
|
|
394
|
+
const parts = [];
|
|
395
|
+
if (Number(ahead)) parts.push(`ahead ${ahead}`);
|
|
396
|
+
if (Number(behind)) parts.push(`behind ${behind}`);
|
|
397
|
+
return parts.join(', ');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---- stash ------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
export function stashList(cwd) {
|
|
403
|
+
const r = runGit(['stash', 'list'], cwd);
|
|
404
|
+
return r.ok ? r.stdout.split('\n').filter(Boolean) : [];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function stashPush(cwd, message) {
|
|
408
|
+
const args = ['stash', 'push'];
|
|
409
|
+
if (message) args.push('-m', String(message));
|
|
410
|
+
const r = runGit(args, cwd);
|
|
411
|
+
return r.ok ? { ok: true, output: r.stdout.trim(), error: null }
|
|
412
|
+
: { ok: false, error: r.stderr.trim() || r.error || 'git stash failed' };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function stashPop(cwd) {
|
|
416
|
+
const r = runGit(['stash', 'pop'], cwd);
|
|
417
|
+
return r.ok ? { ok: true, output: r.stdout.trim(), error: null }
|
|
418
|
+
: { ok: false, error: r.stderr.trim() || r.error || 'git stash pop failed' };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- rebase -----------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
// Rebase the current branch onto `onto`. Returns the git output; on conflict the
|
|
424
|
+
// caller shows git's own message (the user resolves it outside, or with a prompt).
|
|
425
|
+
export function rebase(cwd, onto) {
|
|
426
|
+
const target = String(onto || '').trim();
|
|
427
|
+
if (!target) return { ok: false, error: 'a target branch is required' };
|
|
428
|
+
const r = runGit(['rebase', target], cwd, { timeoutMs: 120_000 });
|
|
429
|
+
if (!r.ok) return { ok: false, error: (r.stderr || r.stdout).trim() || 'git rebase failed' };
|
|
430
|
+
return { ok: true, output: r.stdout.trim(), error: null };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---- async variants (never block the event loop) ----------------------------
|
|
434
|
+
// `runGit` uses spawnSync: on the STARTUP path that freezes the TUI before its
|
|
435
|
+
// first paint, and a cold `git` was measured at ~80-100ms per call (×4 calls).
|
|
436
|
+
// The async form below runs the same command off the main thread, so the frame
|
|
437
|
+
// draws immediately and the badge fills in when the data arrives.
|
|
438
|
+
|
|
439
|
+
export function runGitAsync(args, cwd, opts = {}) {
|
|
440
|
+
return new Promise((resolve) => {
|
|
441
|
+
let child;
|
|
442
|
+
const timeout = opts.timeoutMs || 30_000;
|
|
443
|
+
try {
|
|
444
|
+
child = cp.spawn('git', [...GIT_ENV, ...args], {
|
|
445
|
+
cwd: cwd || process.cwd(),
|
|
446
|
+
windowsHide: true,
|
|
447
|
+
env: { ...process.env, LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' },
|
|
448
|
+
});
|
|
449
|
+
} catch (e) {
|
|
450
|
+
resolve({ ok: false, stdout: '', stderr: '', code: null, error: e.message });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
let stdout = '';
|
|
454
|
+
let stderr = '';
|
|
455
|
+
let done = false;
|
|
456
|
+
const finish = (r) => { if (!done) { done = true; clearTimeout(timer); resolve(r); } };
|
|
457
|
+
const timer = setTimeout(() => {
|
|
458
|
+
try { child.kill(); } catch {}
|
|
459
|
+
finish({ ok: false, stdout: '', stderr: '', code: null, error: 'git timed out' });
|
|
460
|
+
}, timeout);
|
|
461
|
+
if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
|
|
462
|
+
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
|
|
463
|
+
child.on('error', (e) => finish({ ok: false, stdout: '', stderr: '', code: null, error: e.message }));
|
|
464
|
+
child.on('close', (code) => finish({ ok: code === 0, stdout, stderr, code, error: null }));
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Async twin of statuslineInfo(). Same shape, same parser — only the four git
|
|
469
|
+
// invocations are concurrent instead of blocking. Each sub-call fails soft, so a
|
|
470
|
+
// non-repo (or a missing git) yields null exactly like the sync version.
|
|
471
|
+
export async function statuslineInfoAsync(cwd) {
|
|
472
|
+
const inside = await runGitAsync(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
473
|
+
if (!inside.ok) return null;
|
|
474
|
+
|
|
475
|
+
const stat = async (args) => {
|
|
476
|
+
const r = await runGitAsync(['diff', ...args, '--shortstat'], cwd);
|
|
477
|
+
if (!r.ok) return { files: 0, insertions: 0, deletions: 0 };
|
|
478
|
+
const s = r.stdout.trim();
|
|
479
|
+
const files = /(\d+) files? changed/.exec(s);
|
|
480
|
+
const ins = /(\d+) insertions?\(\+\)/.exec(s);
|
|
481
|
+
const del = /(\d+) deletions?\(-\)/.exec(s);
|
|
482
|
+
return {
|
|
483
|
+
files: files ? Number(files[1]) : 0,
|
|
484
|
+
insertions: ins ? Number(ins[1]) : 0,
|
|
485
|
+
deletions: del ? Number(del[1]) : 0,
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const [branchRes, unstaged, staged] = await Promise.all([
|
|
490
|
+
runGitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
|
|
491
|
+
stat([]),
|
|
492
|
+
stat(['--cached']),
|
|
493
|
+
]);
|
|
494
|
+
const branch = branchRes.ok ? branchRes.stdout.trim() : '';
|
|
495
|
+
return {
|
|
496
|
+
branch: branch || '',
|
|
497
|
+
insertions: unstaged.insertions + staged.insertions,
|
|
498
|
+
deletions: unstaged.deletions + staged.deletions,
|
|
499
|
+
changed: unstaged.files + staged.files,
|
|
500
|
+
};
|
|
501
|
+
}
|
package/src/index.js
CHANGED
|
@@ -356,35 +356,46 @@ export async function main(argv) {
|
|
|
356
356
|
await loadPlugins(cfg.pluginDir);
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
// Connect configured MCP servers and register their tools
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
359
|
+
// Connect configured MCP servers and register their tools.
|
|
360
|
+
//
|
|
361
|
+
// This is a FUNCTION rather than an inline block because the two modes want
|
|
362
|
+
// different timing:
|
|
363
|
+
// * headless (`-p`) MUST wait — the prompt runs immediately and its model
|
|
364
|
+
// request has to see the MCP tools in the tool list.
|
|
365
|
+
// * the TUI must NOT wait — connecting can take seconds (8s per wedged
|
|
366
|
+
// server), and blocking the first paint for that is unacceptable. The TUI
|
|
367
|
+
// starts this in the background and reports the result as a notice.
|
|
368
|
+
// Failures are reported but never fatal: an unreachable server must not stop
|
|
369
|
+
// the session from starting.
|
|
370
|
+
const connectMcp = async () => {
|
|
371
|
+
if (cfg.mcp === false) return { servers: [], toolCount: 0 };
|
|
363
372
|
try {
|
|
364
373
|
const mcp = await import('./mcp.js');
|
|
365
374
|
const { API } = await import('./plugin.js');
|
|
366
375
|
const mcpCfg = mcp.loadMcpConfig(cfg.workspace);
|
|
367
|
-
if (Object.keys(mcpCfg.servers).length) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
376
|
+
if (!Object.keys(mcpCfg.servers).length) return { servers: [], toolCount: 0 };
|
|
377
|
+
const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec));
|
|
378
|
+
mcp.setLiveConnections(res.servers);
|
|
379
|
+
if (process.env.HNCODE_MCP_VERBOSE === '1') {
|
|
380
|
+
for (const s of res.servers) {
|
|
381
|
+
console.error(s.ok ? `[hncode-mcp] ${s.name}: ${s.toolCount} tool(s)` : `[hncode-mcp] ${s.name}: ${s.error}`);
|
|
374
382
|
}
|
|
375
383
|
}
|
|
384
|
+
return res;
|
|
376
385
|
} catch (e) {
|
|
377
386
|
console.error(`hncode: MCP setup failed: ${e.message}`);
|
|
387
|
+
return { servers: [], toolCount: 0 };
|
|
378
388
|
}
|
|
379
|
-
}
|
|
389
|
+
};
|
|
380
390
|
|
|
381
|
-
// headless prompt
|
|
391
|
+
// headless prompt: MCP must be live BEFORE the request goes out.
|
|
382
392
|
if (args.prompt) {
|
|
383
393
|
const format = args['output-format'];
|
|
384
394
|
if (!isValidFormat(format)) {
|
|
385
395
|
console.error(`hncode: unknown --output-format "${format}". Use one of: ${FORMATS.join(', ')}.`);
|
|
386
396
|
return EXIT.CONFIG;
|
|
387
397
|
}
|
|
398
|
+
await connectMcp();
|
|
388
399
|
let session = null;
|
|
389
400
|
if (args.session) session = typeof args.session === 'string' ? sess.loadSession(args.session) : sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
390
401
|
else if (args.continue) session = sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
@@ -408,6 +419,9 @@ export async function main(argv) {
|
|
|
408
419
|
// Remote control: expose a local socket so a script can queue a prompt or
|
|
409
420
|
// query status on the running session (see ci.js).
|
|
410
421
|
control: args.control === true ? '' : (typeof args.control === 'string' ? args.control : null),
|
|
422
|
+
// MCP runs in the BACKGROUND once the TUI is up (see startTUI): connecting
|
|
423
|
+
// can take seconds and must not delay the first paint.
|
|
424
|
+
mcpConnect: connectMcp,
|
|
411
425
|
session: loadOrCreateSession(args, cfg),
|
|
412
426
|
};
|
|
413
427
|
await startTui(opts);
|
package/src/mcp.js
CHANGED
|
@@ -32,6 +32,11 @@ import cp from 'node:child_process';
|
|
|
32
32
|
export const PROTOCOL_VERSION = '2024-11-05';
|
|
33
33
|
export const CLIENT_INFO = { name: 'hncode', version: '0.3.1' };
|
|
34
34
|
|
|
35
|
+
// How long a server gets to complete the initialize + tools/list handshake. This
|
|
36
|
+
// runs BEFORE the TUI opens, so it is deliberately short: a wedged server should
|
|
37
|
+
// cost a few seconds, not the 30s a tool call is allowed.
|
|
38
|
+
export const CONNECT_TIMEOUT_MS = 8_000;
|
|
39
|
+
|
|
35
40
|
// Live connections made at startup, shared by every consumer of the MCP client
|
|
36
41
|
// (the CLI startup and the TUI's /mcp). Kept in this module rather than passed
|
|
37
42
|
// around so the status view can always see the real state.
|
|
@@ -222,7 +227,10 @@ export function parseSseForId(body, id) {
|
|
|
222
227
|
// Connect to one server, handshake, and list its tools.
|
|
223
228
|
// Returns { ok, tools, error, serverInfo, transport, isHttp } — never throws.
|
|
224
229
|
export async function connectServer(name, def, opts = {}) {
|
|
225
|
-
|
|
230
|
+
// CONNECT_TIMEOUT (not the 30s tool-call timeout): this runs during startup, so
|
|
231
|
+
// a wedged server must not hold the session hostage. 8s is generous for a
|
|
232
|
+
// handshake while keeping a dead server's cost to single-digit seconds.
|
|
233
|
+
const timeoutMs = opts.timeoutMs || CONNECT_TIMEOUT_MS;
|
|
226
234
|
let transport;
|
|
227
235
|
const isHttp = !!def.url;
|
|
228
236
|
try {
|
|
@@ -314,16 +322,15 @@ export function toHncodeSpec(server, tool, transport, isHttp) {
|
|
|
314
322
|
// Connect every configured server and register its tools through the plugin API.
|
|
315
323
|
// Returns { servers: [...], toolCount }.
|
|
316
324
|
export async function connectAll(config, registerTool, opts = {}) {
|
|
317
|
-
const servers = [];
|
|
318
|
-
let toolCount = 0;
|
|
319
325
|
const only = opts.only;
|
|
320
|
-
|
|
321
|
-
|
|
326
|
+
const entries = Object.entries((config && config.servers) || {}).filter(([name]) => !only || only.includes(name));
|
|
327
|
+
// Connect in PARALLEL. This runs on the startup path, before the TUI opens, so
|
|
328
|
+
// a serial loop meant one slow/wedged server delayed every later one — N dead
|
|
329
|
+
// servers cost N × timeout before the user saw anything. Runs concurrently, the
|
|
330
|
+
// whole set costs the SLOWEST single server instead of their sum.
|
|
331
|
+
const results = await Promise.all(entries.map(async ([name, def]) => {
|
|
322
332
|
const conn = await connectServer(name, def, opts);
|
|
323
|
-
if (!conn.ok) {
|
|
324
|
-
servers.push({ name, ok: false, toolCount: 0, error: conn.error, serverInfo: {} });
|
|
325
|
-
continue;
|
|
326
|
-
}
|
|
333
|
+
if (!conn.ok) return { name, ok: false, toolCount: 0, error: conn.error, serverInfo: {} };
|
|
327
334
|
let registered = 0;
|
|
328
335
|
let err = null;
|
|
329
336
|
for (const tool of conn.tools) {
|
|
@@ -336,11 +343,12 @@ export async function connectAll(config, registerTool, opts = {}) {
|
|
|
336
343
|
err = err || `tool "${tool.name}": ${e.message}`;
|
|
337
344
|
}
|
|
338
345
|
}
|
|
339
|
-
toolCount
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return { servers, toolCount };
|
|
346
|
+
return { name, ok: true, toolCount: registered, error: err, serverInfo: conn.serverInfo, transport: conn.transport, isHttp: conn.isHttp };
|
|
347
|
+
}));
|
|
348
|
+
const toolCount = results.reduce((n, r) => n + (r.toolCount || 0), 0);
|
|
349
|
+
return { servers: results, toolCount };
|
|
343
350
|
}
|
|
351
|
+
// Disconnect every connected server (kills stdio children). HTTP needs nothing.
|
|
344
352
|
|
|
345
353
|
// Disconnect every connected server (kills stdio children). HTTP needs nothing.
|
|
346
354
|
export function disconnectAll(connections) {
|
package/src/tui.js
CHANGED
|
@@ -34,7 +34,7 @@ import * as upd from './update.js';
|
|
|
34
34
|
import { renderTasksBrowser, handleTasksBrowserKey, visibleTasks } from './tasks-browser.js';
|
|
35
35
|
import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task-output-viewer.js';
|
|
36
36
|
import { renderSwarmProgress } from './swarm-progress.js';
|
|
37
|
-
import { sortedTasks, getTask, settleTask, STATUS_LABEL } from './agent-task.js';
|
|
37
|
+
import { sortedTasks, getTask, settleTask, STATUS_LABEL, backgroundTaskCard } from './agent-task.js';
|
|
38
38
|
import { localVersion } from './version.js';
|
|
39
39
|
import { listSkills, readSkill, importSkill, deleteSkill, skillPrompt, skillCompletions, normalizeSkillName, SKILL_PREFIX, skillsDir } from './skills.js';
|
|
40
40
|
import { loadHooks, describeHooks, runShellHooks, HOOK_EVENTS } from './hooks.js';
|
|
@@ -97,11 +97,18 @@ export const COMMANDS = [
|
|
|
97
97
|
{ name: 'branch', desc: 'List, create, or switch git branches', priority: 62, argumentHint: '[list | <name> | -c <name>]' },
|
|
98
98
|
{ name: 'worktree', desc: 'List, add, or remove git worktrees for parallel work', priority: 60, argumentHint: '[list | add <dir> [branch] | remove <dir>]' },
|
|
99
99
|
{ name: 'pr', desc: 'Show the URL to open a pull request for the current branch', priority: 60, argumentHint: '[base]' },
|
|
100
|
+
{ name: 'add', aliases: ['stage'], desc: 'Stage changes (all by default, or given paths)', priority: 62, argumentHint: '[<path> ...]' },
|
|
101
|
+
{ name: 'diff', desc: 'Show uncommitted changes', priority: 62, argumentHint: '[--staged] [<path>]' },
|
|
102
|
+
{ name: 'log', desc: 'Show recent commits', priority: 62, argumentHint: '[count] [<path>]' },
|
|
103
|
+
{ name: 'push', desc: 'Push the current branch (asks first)', priority: 62, argumentHint: '[branch] [-f]' },
|
|
104
|
+
{ name: 'stash', desc: 'Stash changes: list / push / pop', priority: 60, argumentHint: '[list | push [msg] | pop]' },
|
|
105
|
+
{ name: 'rebase', desc: 'Rebase the current branch onto another (asks first)', priority: 60, argumentHint: '<branch>' },
|
|
100
106
|
{ name: 'cache', desc: 'Toggle prompt caching for long stable prefixes (saves input tokens)', priority: 58, argumentHint: '[on|off]' },
|
|
101
107
|
{ name: 'logout', aliases: ['disconnect'], desc: 'Log out of a configured provider', priority: 40 },
|
|
102
108
|
{ name: 'feedback', aliases: ['bug'], desc: 'Send feedback to the maintainers', priority: 60 },
|
|
103
109
|
{ name: 'update', desc: 'Check for and install a newer version', priority: 40, argumentHint: '[-y]' },
|
|
104
110
|
{ name: 'auto-update', desc: 'Toggle automatic background updates (check at startup + every 30 min)', priority: 40, argumentHint: '[on|off]' },
|
|
111
|
+
{ name: 'auto-compact', aliases: ['autocompact'], desc: 'Toggle automatic context compaction at 85% of the window', priority: 40, argumentHint: '[on|off]' },
|
|
105
112
|
{ name: 'version', desc: 'Show version information', priority: 20 },
|
|
106
113
|
{ name: 'exit', aliases: ['quit', 'q'], desc: 'Exit the application', priority: 20 },
|
|
107
114
|
].sort((a, b) => (b.priority || 0) - (a.priority || 0) || a.name.localeCompare(b.name));
|
|
@@ -1561,6 +1568,37 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
|
|
|
1561
1568
|
return out;
|
|
1562
1569
|
}
|
|
1563
1570
|
|
|
1571
|
+
// A BACKGROUND TASK lifecycle card (kimi-style):
|
|
1572
|
+
// ● agent task completed in background (review the diff)
|
|
1573
|
+
// ✗ bash task failed in background (npm test · exit 1)
|
|
1574
|
+
// The bullet is `●` normally and `✗` for a failure; the colour follows the
|
|
1575
|
+
// phase. The task's conclusion sits underneath, indented, and is capped like
|
|
1576
|
+
// a tool result so one chatty subagent cannot flood the transcript.
|
|
1577
|
+
if (msg.role === 'bg_task') {
|
|
1578
|
+
const card = msg.card || { phase: 'completed', headline: 'background task finished', detail: undefined };
|
|
1579
|
+
const tone = card.phase === 'started' ? C.cyan : card.phase === 'completed' ? C.green : C.red;
|
|
1580
|
+
const bullet = card.phase === 'failed' ? '✗ ' : '● ';
|
|
1581
|
+
const out = [];
|
|
1582
|
+
const detail = card.detail ? col(` (${card.detail})`, C.gray) : '';
|
|
1583
|
+
out.push({ text: col(bullet, tone) + col(card.headline, tone) + detail, ind: '', raw: true });
|
|
1584
|
+
const body = String(msg.text || '').trim();
|
|
1585
|
+
if (body) {
|
|
1586
|
+
const indent = ' ';
|
|
1587
|
+
const avail = Math.max(1, width - visualCol(indent));
|
|
1588
|
+
const lines = body.replace(/\r\n/g, '\n').split('\n');
|
|
1589
|
+
const MAX = expanded ? 200 : 6;
|
|
1590
|
+
for (const ln of lines.slice(0, MAX)) {
|
|
1591
|
+
for (const w of (ln === '' ? [''] : wrapWords(ln, avail))) {
|
|
1592
|
+
out.push({ text: col(w, C.gray), ind: indent, raw: true });
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (lines.length > MAX) {
|
|
1596
|
+
out.push({ text: col(`… ${lines.length - MAX} more lines, ctrl+o to expand`, C.gray), ind: indent, raw: true });
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
return out;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1564
1602
|
// Tool output: a single "↳ " marker on the first line, then the output
|
|
1565
1603
|
// indented by 2 columns. Long output is capped (kimi caps result previews
|
|
1566
1604
|
// too) so one command cannot flood the whole transcript.
|
|
@@ -2607,11 +2645,22 @@ export function refreshGitInfo(state, opts = {}) {
|
|
|
2607
2645
|
if (!force && state.running) return;
|
|
2608
2646
|
state._gitCwd = cwd;
|
|
2609
2647
|
state._gitAt = now;
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2648
|
+
// ASYNC: does not block the caller. On the startup path this is what lets the
|
|
2649
|
+
// first frame draw immediately — the badge appears a moment later when git
|
|
2650
|
+
// answers, instead of freezing the TUI behind four spawnSync calls. `seq`
|
|
2651
|
+
// discards a stale reply if another refresh started while this one ran.
|
|
2652
|
+
const seq = (state._gitSeq = (state._gitSeq || 0) + 1);
|
|
2653
|
+
gitmod.statuslineInfoAsync(cwd)
|
|
2654
|
+
.then((info) => {
|
|
2655
|
+
if (state._gitSeq !== seq) return; // superseded by a newer refresh
|
|
2656
|
+
state._gitInfo = info;
|
|
2657
|
+
if (typeof state._requestRender === 'function') state._requestRender();
|
|
2658
|
+
})
|
|
2659
|
+
.catch(() => {
|
|
2660
|
+
if (state._gitSeq !== seq) return;
|
|
2661
|
+
state._gitInfo = null; // a git failure must never break rendering
|
|
2662
|
+
if (typeof state._requestRender === 'function') state._requestRender();
|
|
2663
|
+
});
|
|
2615
2664
|
}
|
|
2616
2665
|
|
|
2617
2666
|
// ---- pure frame composer (no TTY side effects) ----
|
|
@@ -3987,7 +4036,8 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
3987
4036
|
{ label: 'provider', sub: 'manage AI providers' },
|
|
3988
4037
|
{ label: 'statusline', sub: 'configure status line items' },
|
|
3989
4038
|
{ label: 'add-dir', sub: 'add an additional workspace directory' },
|
|
3990
|
-
{ label: 'auto-update', sub:
|
|
4039
|
+
{ label: 'auto-update', sub: `check npm for updates at startup + every 30 min — currently ${cfg.autoUpdate ? 'on' : 'off'}` },
|
|
4040
|
+
{ label: 'auto-compact', sub: `summarize older history at 85% of the window — currently ${cfg.autoCompact === false ? 'off' : 'on'}` },
|
|
3991
4041
|
],
|
|
3992
4042
|
onPick: (it) => { dispatch(it.label, '', state, cfg, session, h, submit, stdout); return true; },
|
|
3993
4043
|
});
|
|
@@ -4858,6 +4908,22 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
4858
4908
|
: 'Auto-update OFF');
|
|
4859
4909
|
return;
|
|
4860
4910
|
}
|
|
4911
|
+
case 'auto-compact': {
|
|
4912
|
+
// Toggle /auto-compact. Persists to config.toml so it survives restarts.
|
|
4913
|
+
const a = raw.toLowerCase();
|
|
4914
|
+
let want;
|
|
4915
|
+
if (a === 'on') want = true;
|
|
4916
|
+
else if (a === 'off') want = false;
|
|
4917
|
+
else want = cfg.autoCompact === false; // flip from the current state
|
|
4918
|
+
try { setConfigString('auto_compact', want ? 'true' : 'false'); }
|
|
4919
|
+
catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
|
|
4920
|
+
cfg.autoCompact = want;
|
|
4921
|
+
cfg.raw.auto_compact = want;
|
|
4922
|
+
app(want
|
|
4923
|
+
? 'Auto-compaction ON — older history is summarized once a request reaches 85% of the context window'
|
|
4924
|
+
: 'Auto-compaction OFF — history is no longer trimmed automatically (use /compact manually)');
|
|
4925
|
+
return;
|
|
4926
|
+
}
|
|
4861
4927
|
case 'update': {
|
|
4862
4928
|
// Manual update check. This is the ONLY place an update is ever reported;
|
|
4863
4929
|
// the automatic background check stays completely silent.
|
|
@@ -4928,28 +4994,35 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
4928
4994
|
case 'statusline':
|
|
4929
4995
|
openPicker({
|
|
4930
4996
|
title: 'Status line items',
|
|
4931
|
-
items:
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4997
|
+
items: (() => {
|
|
4998
|
+
// `sub` must be set here, not only in onPick: the picker renders
|
|
4999
|
+
// item.sub, so without an initial value every toggle showed a blank
|
|
5000
|
+
// state until the user toggled it once.
|
|
5001
|
+
const toggles = [
|
|
5002
|
+
['permission mode', 'slMode'],
|
|
5003
|
+
['model name', 'slModel'],
|
|
5004
|
+
['thinking effort', 'slEffort'],
|
|
5005
|
+
['current directory', 'slCwd'],
|
|
5006
|
+
['git branch & diff', 'slGit'],
|
|
5007
|
+
['background tasks', 'slTasks'],
|
|
5008
|
+
['rotating tips', 'slTips'],
|
|
5009
|
+
];
|
|
5010
|
+
return toggles.map(([label, key]) => {
|
|
5011
|
+
const isOn = state[key] !== false;
|
|
5012
|
+
return { label, key, kind: 'toggle', isOn, sub: isOn ? 'on' : 'off' };
|
|
5013
|
+
});
|
|
5014
|
+
})(),
|
|
4940
5015
|
hint: '↑↓ navigate · Enter toggle · Esc close',
|
|
4941
5016
|
onPick: (it) => {
|
|
4942
5017
|
it.isOn = !it.isOn;
|
|
4943
5018
|
it.sub = it.isOn ? 'on' : 'off';
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
if (key === 'slGit') refreshGitInfo(state, { force: true });
|
|
5019
|
+
state[it.key] = it.isOn;
|
|
5020
|
+
if (it.key === 'slGit') refreshGitInfo(state, { force: true });
|
|
4947
5021
|
app(`Status line: ${it.label} → ${it.isOn ? 'shown' : 'hidden'}`);
|
|
4948
5022
|
return false;
|
|
4949
5023
|
},
|
|
4950
5024
|
});
|
|
4951
5025
|
return;
|
|
4952
|
-
|
|
4953
5026
|
case 'export-md': {
|
|
4954
5027
|
const msgs = session.messages || [];
|
|
4955
5028
|
if (!msgs.length) { appErr('Nothing to export (empty session).'); return; }
|
|
@@ -5220,6 +5293,122 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
5220
5293
|
h.openPanel('hncode — git', lines);
|
|
5221
5294
|
return;
|
|
5222
5295
|
}
|
|
5296
|
+
case 'add': {
|
|
5297
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5298
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5299
|
+
// `/add` stages everything (`git add -A`); `/add <path> ...` stages just
|
|
5300
|
+
// those paths. This is the explicit staging step /commit does implicitly.
|
|
5301
|
+
const paths = raw.split(/\s+/).filter(Boolean);
|
|
5302
|
+
const r = gitmod.stage(paths, cwd);
|
|
5303
|
+
if (!r.ok) { appErr(`git add failed: ${r.error}`); return; }
|
|
5304
|
+
const staged = gitmod.stagedFiles(cwd);
|
|
5305
|
+
app(paths.length ? `Staged ${paths.length} path(s).` : 'Staged all changes.');
|
|
5306
|
+
say(`Index now holds ${staged.length} file(s):\n` + staged.map((f) => ' ' + f).join('\n'));
|
|
5307
|
+
return;
|
|
5308
|
+
}
|
|
5309
|
+
case 'diff': {
|
|
5310
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5311
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5312
|
+
const staged = /--staged|--cached/.test(raw);
|
|
5313
|
+
const path = raw.replace(/--staged|--cached/g, '').trim();
|
|
5314
|
+
const r = gitmod.diffText(cwd, { staged, path: path || undefined });
|
|
5315
|
+
if (!r.ok) { appErr(`git diff failed: ${r.error}`); return; }
|
|
5316
|
+
if (r.empty) { say(staged ? 'No staged changes.' : 'No uncommitted changes.'); return; }
|
|
5317
|
+
say((staged ? 'Staged changes' : 'Uncommitted changes') + (path ? ` in ${path}` : '') + ':\n' + r.text);
|
|
5318
|
+
return;
|
|
5319
|
+
}
|
|
5320
|
+
case 'log': {
|
|
5321
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5322
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5323
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
5324
|
+
const count = parts[0] && /^\d+$/.test(parts[0]) ? parts.shift() : undefined;
|
|
5325
|
+
const path = parts.join(' ') || undefined;
|
|
5326
|
+
const r = gitmod.logText(cwd, { count, path });
|
|
5327
|
+
if (!r.ok) { appErr(`git log failed: ${r.error}`); return; }
|
|
5328
|
+
if (r.empty) { say('No commits yet.'); return; }
|
|
5329
|
+
say(`Recent commits${path ? ` for ${path}` : ''}:\n` + r.text);
|
|
5330
|
+
return;
|
|
5331
|
+
}
|
|
5332
|
+
case 'push': {
|
|
5333
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5334
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5335
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
5336
|
+
const force = parts.includes('-f') || parts.includes('--force');
|
|
5337
|
+
const branchArg = parts.find((x) => !x.startsWith('-')) || '';
|
|
5338
|
+
const branch = branchArg || gitmod.currentBranch(cwd);
|
|
5339
|
+
const upstream = gitmod.hasUpstream(cwd, branch);
|
|
5340
|
+
const state2 = gitmod.aheadBehind(cwd, branch);
|
|
5341
|
+
const detail = `${branch}${upstream ? '' : ' (no upstream — will set one)'}${state2 ? ` — ${state2}` : ''}`;
|
|
5342
|
+
// Push is OUTBOUND: always confirm first, and say exactly what will happen.
|
|
5343
|
+
h.openPicker({
|
|
5344
|
+
title: `Push ${branch}?`,
|
|
5345
|
+
items: [
|
|
5346
|
+
{ label: 'Push', sub: `git push ${force ? '--force-with-lease ' : ''}origin ${detail}` },
|
|
5347
|
+
{ label: 'Cancel', sub: 'do not push' },
|
|
5348
|
+
],
|
|
5349
|
+
searchable: false,
|
|
5350
|
+
hint: '↑↓ navigate · Enter select · Esc cancel',
|
|
5351
|
+
onPick: (it) => {
|
|
5352
|
+
if (it.label !== 'Push') { app('Push cancelled.'); return true; }
|
|
5353
|
+
const r = gitmod.push(cwd, { branch: branchArg || undefined, force, setUpstream: !upstream });
|
|
5354
|
+
if (!r.ok) appErr(`Push failed: ${r.error}`);
|
|
5355
|
+
else { app(`Pushed ${branch}.`); say(`git push output:\n${r.output}`); }
|
|
5356
|
+
return true;
|
|
5357
|
+
},
|
|
5358
|
+
});
|
|
5359
|
+
return;
|
|
5360
|
+
}
|
|
5361
|
+
case 'stash': {
|
|
5362
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5363
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5364
|
+
const parts = raw.split(/\s+/).filter(Boolean);
|
|
5365
|
+
const sub = (parts[0] || '').toLowerCase();
|
|
5366
|
+
if (!sub || sub === 'list') {
|
|
5367
|
+
const list = gitmod.stashList(cwd);
|
|
5368
|
+
say(list.length ? `Stashes (${list.length}):\n` + list.map((s) => ' ' + s).join('\n') : 'No stashes.');
|
|
5369
|
+
return;
|
|
5370
|
+
}
|
|
5371
|
+
if (sub === 'push' || sub === 'save') {
|
|
5372
|
+
const msg = parts.slice(1).join(' ') || undefined;
|
|
5373
|
+
const r = gitmod.stashPush(cwd, msg);
|
|
5374
|
+
if (!r.ok) { appErr(`git stash failed: ${r.error}`); return; }
|
|
5375
|
+
app('Stashed changes.'); say(r.output || 'Changes stashed.');
|
|
5376
|
+
return;
|
|
5377
|
+
}
|
|
5378
|
+
if (sub === 'pop') {
|
|
5379
|
+
const r = gitmod.stashPop(cwd);
|
|
5380
|
+
if (!r.ok) { appErr(`git stash pop failed: ${r.error}`); return; }
|
|
5381
|
+
app('Restored from stash.'); say(r.output || 'Stash popped.');
|
|
5382
|
+
return;
|
|
5383
|
+
}
|
|
5384
|
+
appErr('Usage: /stash [list] | push [message] | pop');
|
|
5385
|
+
return;
|
|
5386
|
+
}
|
|
5387
|
+
case 'rebase': {
|
|
5388
|
+
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5389
|
+
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
5390
|
+
const onto = String(raw || '').trim();
|
|
5391
|
+
if (!onto) { appErr('Usage: /rebase <branch>'); return; }
|
|
5392
|
+
const cur = gitmod.currentBranch(cwd);
|
|
5393
|
+
// Rewrites history on the current branch: confirm before running.
|
|
5394
|
+
h.openPicker({
|
|
5395
|
+
title: `Rebase ${cur} onto ${onto}?`,
|
|
5396
|
+
items: [
|
|
5397
|
+
{ label: 'Rebase', sub: `git rebase ${onto}` },
|
|
5398
|
+
{ label: 'Cancel', sub: 'leave the branch as is' },
|
|
5399
|
+
],
|
|
5400
|
+
searchable: false,
|
|
5401
|
+
hint: '↑↓ navigate · Enter select · Esc cancel',
|
|
5402
|
+
onPick: (it) => {
|
|
5403
|
+
if (it.label !== 'Rebase') { app('Rebase cancelled.'); return true; }
|
|
5404
|
+
const r = gitmod.rebase(cwd, onto);
|
|
5405
|
+
if (!r.ok) appErr(`Rebase failed (resolve conflicts, then /rebase --continue or git rebase --abort): ${r.error}`);
|
|
5406
|
+
else { app(`Rebased ${cur} onto ${onto}.`); say(r.output || 'Rebase complete.'); }
|
|
5407
|
+
return true;
|
|
5408
|
+
},
|
|
5409
|
+
});
|
|
5410
|
+
return;
|
|
5411
|
+
}
|
|
5223
5412
|
case 'commit': {
|
|
5224
5413
|
const cwd = state.cwd || cfg.workspace || process.cwd();
|
|
5225
5414
|
if (!gitmod.isRepo(cwd)) { appErr(`Not a git repository: ${cwd}`); return; }
|
|
@@ -5477,6 +5666,11 @@ function commandPathsAreInside(state, cmd, cwd) {
|
|
|
5477
5666
|
function applyModel(state, cfg, next) {
|
|
5478
5667
|
cfg.model = next.model; cfg.innerModel = next.innerModel; cfg.provider = next.provider;
|
|
5479
5668
|
cfg.baseUrl = next.baseUrl; cfg.endpoint = next.endpoint; cfg.apiKey = next.apiKey; cfg.protocol = next.protocol;
|
|
5669
|
+
// Carry the resolved context window over too. resolveProvider/resolveModelArg
|
|
5670
|
+
// compute `maxContextTokens` for the NEW model, but it lives on the returned
|
|
5671
|
+
// object — without copying it here, cfg (and the gauge below) kept the previous
|
|
5672
|
+
// model's window after a switch.
|
|
5673
|
+
if (next.maxContextTokens != null) cfg.maxContextTokens = next.maxContextTokens;
|
|
5480
5674
|
state.model = cfg.model; state.provider = cfg.provider;
|
|
5481
5675
|
state.modelLabel = modelLabel(cfg) || cfg.model || '';
|
|
5482
5676
|
state.seenThinking = false;
|
|
@@ -5784,6 +5978,9 @@ export async function startTUI(opts) {
|
|
|
5784
5978
|
if (out) stdout.write(out);
|
|
5785
5979
|
}
|
|
5786
5980
|
function renderFrame() { paintNow(); }
|
|
5981
|
+
// Let module-level helpers (refreshGitInfo) ask for a repaint once their async
|
|
5982
|
+
// data lands, without holding a reference to this closure.
|
|
5983
|
+
state._requestRender = renderFrame;
|
|
5787
5984
|
// No frame-rate cap: paint on the next event-loop turn. Calls made within the
|
|
5788
5985
|
// same tick still coalesce (paintScheduled), so a burst of stream deltas
|
|
5789
5986
|
// produces one frame per tick at full speed instead of being throttled to
|
|
@@ -6151,11 +6348,46 @@ export async function startTUI(opts) {
|
|
|
6151
6348
|
// and doing it lazily on the first Ctrl+Shift+V made the shortcut look dead.
|
|
6152
6349
|
warmClipboard();
|
|
6153
6350
|
setTip(state);
|
|
6154
|
-
//
|
|
6155
|
-
//
|
|
6351
|
+
// Fill the git badge — asynchronously, AFTER the first paint. It used to run
|
|
6352
|
+
// before renderFrame() with a blocking spawnSync git, which added ~330ms to
|
|
6353
|
+
// startup; now the frame draws at once and the badge appears when git answers.
|
|
6156
6354
|
refreshGitInfo(state, { force: true });
|
|
6157
6355
|
renderFrame();
|
|
6158
6356
|
|
|
6357
|
+
// MCP: connect in the BACKGROUND, now that the UI is on screen. Each server
|
|
6358
|
+
// gets up to CONNECT_TIMEOUT_MS, so doing this before the first paint would
|
|
6359
|
+
// stall the whole TUI; instead we start it and report the outcome as a notice
|
|
6360
|
+
// ("MCP: connected to …") when it lands. Tools registered here become visible
|
|
6361
|
+
// to the model on its next turn.
|
|
6362
|
+
if (typeof opts.mcpConnect === 'function') {
|
|
6363
|
+
Promise.resolve()
|
|
6364
|
+
.then(() => opts.mcpConnect())
|
|
6365
|
+
.then((res) => {
|
|
6366
|
+
const servers = (res && res.servers) || [];
|
|
6367
|
+
if (!servers.length) return;
|
|
6368
|
+
// The notice line is a SINGLE status-line row, so report all servers in
|
|
6369
|
+
// one message — looping notice() per server only left the last one
|
|
6370
|
+
// visible. Failures are listed too, and the whole notice turns red when
|
|
6371
|
+
// any server failed.
|
|
6372
|
+
const okServers = servers.filter((s) => s.ok);
|
|
6373
|
+
const bad = servers.filter((s) => !s.ok);
|
|
6374
|
+
const parts = [];
|
|
6375
|
+
if (okServers.length) {
|
|
6376
|
+
const tools = okServers.reduce((n, s) => n + (s.toolCount || 0), 0);
|
|
6377
|
+
const names = okServers.map((s) => s.name).join(', ');
|
|
6378
|
+
parts.push(`MCP connected: ${names} (${tools} tool${tools === 1 ? '' : 's'})`);
|
|
6379
|
+
}
|
|
6380
|
+
if (bad.length) {
|
|
6381
|
+
parts.push(`MCP failed: ${bad.map((s) => s.name).join(', ')}`);
|
|
6382
|
+
}
|
|
6383
|
+
notice(parts.join(' · '), bad.length ? 'error' : 'info');
|
|
6384
|
+
// A connecting server may have changed the tool list; repaint so any
|
|
6385
|
+
// tool-count readout is current.
|
|
6386
|
+
renderFrame();
|
|
6387
|
+
})
|
|
6388
|
+
.catch(() => { /* MCP must never break the session */ });
|
|
6389
|
+
}
|
|
6390
|
+
|
|
6159
6391
|
const tipTimer = setInterval(() => { setTip(state); renderFrame(); }, TIP_INTERVAL);
|
|
6160
6392
|
// Git badge: a fixed 15s cadence. Deliberately NOT tied to the frame loop (that
|
|
6161
6393
|
// spawned git per keystroke) and NOT run mid-turn (the agent is still writing;
|
|
@@ -8094,16 +8326,19 @@ export async function startTUI(opts) {
|
|
|
8094
8326
|
renderSoon();
|
|
8095
8327
|
return;
|
|
8096
8328
|
}
|
|
8329
|
+
if (e.type === 'compacting') {
|
|
8330
|
+
// In-progress notice: the summary below is a full model round-trip, so
|
|
8331
|
+
// show that work is happening instead of leaving the turn silent.
|
|
8332
|
+
addChat({ role: 'system', text: `Context at ${fmtTokens(e.before || 0)} — auto-compacting…` });
|
|
8333
|
+
renderSoon();
|
|
8334
|
+
return;
|
|
8335
|
+
}
|
|
8097
8336
|
if (e.type === 'compacted') {
|
|
8098
8337
|
state.ctxTokens = e.after || state.ctxTokens;
|
|
8099
8338
|
state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
|
|
8100
8339
|
addChat({ role: 'system', text: `Context auto-compacted (${fmtTokens(e.before || 0)} → ${fmtTokens(e.after || 0)} tokens).` });
|
|
8101
8340
|
return;
|
|
8102
8341
|
}
|
|
8103
|
-
if (e.type === 'nudge') {
|
|
8104
|
-
// Silent internal signal - don't show to user
|
|
8105
|
-
return;
|
|
8106
|
-
}
|
|
8107
8342
|
if (e.type === 'incomplete') {
|
|
8108
8343
|
// Orange: this is a warning, not ordinary system output.
|
|
8109
8344
|
addChat({ role: 'warn', text: `Stopped: ${e.reason}. The task may be unfinished — send another message to continue.` });
|
|
@@ -8127,8 +8362,16 @@ export async function startTUI(opts) {
|
|
|
8127
8362
|
if (e.type === 'aborted') {
|
|
8128
8363
|
const t = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
|
|
8129
8364
|
if (t) t.pending = false;
|
|
8365
|
+
// Settle any RUNNING tool rows too. A tool that was mid-flight when the
|
|
8366
|
+
// user hit Esc never gets its tool_result, so its `pending` stayed true
|
|
8367
|
+
// and the `Using <Tool>` name kept pulsing forever. Clearing it drops the
|
|
8368
|
+
// name to plain cyan immediately.
|
|
8369
|
+
for (const m of state.chat) {
|
|
8370
|
+
if (m.role === 'tool' && m.pending) { m.pending = false; m.failed = true; }
|
|
8371
|
+
}
|
|
8130
8372
|
// Use a special role that won't show the ✓ icon
|
|
8131
8373
|
state.chat.push({ role: 'aborted', text: C.red + 'interrupted' + C.reset });
|
|
8374
|
+
renderFrame();
|
|
8132
8375
|
return;
|
|
8133
8376
|
}
|
|
8134
8377
|
if (e.type === 'steer') {
|
|
@@ -8299,12 +8542,12 @@ export async function startTUI(opts) {
|
|
|
8299
8542
|
if (agent.ctx) {
|
|
8300
8543
|
agent.ctx._onBackgroundTaskDone = (task) => {
|
|
8301
8544
|
if (!task) return;
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8545
|
+
// kimi-style lifecycle card: bullet colour by phase, a headline naming
|
|
8546
|
+
// the kind (agent / bash / question task) and what happened, and a dim
|
|
8547
|
+
// detail line. The task's conclusion is kept as the expandable body.
|
|
8548
|
+
const card = backgroundTaskCard(task);
|
|
8306
8549
|
const body = String(task.output || '').trim();
|
|
8307
|
-
addChat({ role: '
|
|
8550
|
+
addChat({ role: 'bg_task', card, text: body });
|
|
8308
8551
|
};
|
|
8309
8552
|
}
|
|
8310
8553
|
await agent.run();
|
|
@@ -8630,6 +8873,10 @@ export async function startTUI(opts) {
|
|
|
8630
8873
|
failed: !!(m && m.failed),
|
|
8631
8874
|
};
|
|
8632
8875
|
if (m && Array.isArray(m.diff)) out.diff = m.diff;
|
|
8876
|
+
// Keep `card`: a background-task lifecycle card carries its phase/headline/
|
|
8877
|
+
// detail here (see the `bg_task` renderer). Dropping it left the row with the
|
|
8878
|
+
// fallback text instead of what actually happened.
|
|
8879
|
+
if (m && m.card) out.card = m.card;
|
|
8633
8880
|
return out;
|
|
8634
8881
|
}
|
|
8635
8882
|
|
package/src/update.js
CHANGED
|
@@ -10,26 +10,48 @@
|
|
|
10
10
|
// resolve to a status string, never throw to the caller. The TUI calls them from
|
|
11
11
|
// timers / startup without awaiting.
|
|
12
12
|
|
|
13
|
-
import { spawn
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
14
|
import { localVersion } from './version.js';
|
|
15
15
|
|
|
16
16
|
const PKG_NAME = '@hncode/hncode';
|
|
17
|
+
// Spawn npm portably. On Windows npm is a `.cmd` shim, and Node refuses to spawn
|
|
18
|
+
// a `.cmd` directly (EINVAL) — it must go through a shell. Using
|
|
19
|
+
// `cmd.exe /c npm ...` keeps that behaviour while avoiding `shell: true`, which
|
|
20
|
+
// Node warns about (with a shell, args are concatenated rather than escaped).
|
|
21
|
+
function spawnNpm(args, opts) {
|
|
22
|
+
if (process.platform === 'win32') return spawn('cmd.exe', ['/c', 'npm', ...args], opts);
|
|
23
|
+
return spawn('npm', args, opts);
|
|
24
|
+
}
|
|
17
25
|
// How often the auto-check runs once we are past the startup check.
|
|
18
26
|
export const AUTO_UPDATE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
|
19
27
|
export { localVersion };
|
|
20
28
|
|
|
21
29
|
// Latest version published to npm, or '' if the registry is unreachable /
|
|
22
30
|
// not installed / not a package.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
//
|
|
32
|
+
// ASYNC on purpose: `execSync` blocks the whole event loop, so a cold `npm view`
|
|
33
|
+
// (measured at ~15s on a slow registry, vs ~2s warm) froze the TUI — no key
|
|
34
|
+
// handling, no repaint — for the entire duration. `spawn` runs the same command
|
|
35
|
+
// off the main thread, and the timeout kills it rather than waiting it out.
|
|
36
|
+
export function remoteVersion(timeoutMs = 10000) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let child;
|
|
39
|
+
try {
|
|
40
|
+
child = spawnNpm(['view', PKG_NAME, 'version', '--silent'], {
|
|
41
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
resolve('');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let out = '';
|
|
48
|
+
let done = false;
|
|
49
|
+
const finish = (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v); } };
|
|
50
|
+
const timer = setTimeout(() => { try { child.kill(); } catch {} finish(''); }, timeoutMs);
|
|
51
|
+
if (child.stdout) child.stdout.on('data', (d) => { out += d.toString(); });
|
|
52
|
+
child.on('error', () => finish(''));
|
|
53
|
+
child.on('close', () => finish((out || '').trim()));
|
|
54
|
+
});
|
|
33
55
|
}
|
|
34
56
|
|
|
35
57
|
// Parse a semver-ish "x.y.z" (ignore prerelease tags) into comparable numbers.
|
|
@@ -55,10 +77,9 @@ export function updateBackground() {
|
|
|
55
77
|
return new Promise((resolve) => {
|
|
56
78
|
try {
|
|
57
79
|
// `npm install -g` needs to run detached; no need to wait here.
|
|
58
|
-
const child =
|
|
80
|
+
const child = spawnNpm(['install', '-g', `${PKG_NAME}@latest`], {
|
|
59
81
|
detached: true,
|
|
60
82
|
stdio: 'ignore',
|
|
61
|
-
shell: process.platform === 'win32', // npm is npm.cmd on Windows
|
|
62
83
|
});
|
|
63
84
|
child.unref();
|
|
64
85
|
child.on('error', (e) => resolve({ ok: false, error: e.message }));
|
|
@@ -77,7 +98,7 @@ export function updateBackground() {
|
|
|
77
98
|
export async function checkAndUpdate() {
|
|
78
99
|
const local = localVersion();
|
|
79
100
|
if (!local) return { status: 'none', message: "can't read local version" };
|
|
80
|
-
const remote = remoteVersion();
|
|
101
|
+
const remote = await remoteVersion();
|
|
81
102
|
if (!remote) return { status: 'none', message: 'npm registry unreachable' };
|
|
82
103
|
|
|
83
104
|
if (isNewer(remote, local)) {
|