@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
- export function nudgeMessage(why) {
36
- return `[hncode] Your turn ended without a final answer: ${why}. `
37
- + 'This usually means the task is not finished. If the work IS complete, reply with a short '
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
- const keepN = Math.max(1, Math.ceil(this.messages.length * 0.2));
288
- const keep = this.messages.slice(-keepN);
289
- const dropped = this.messages.slice(0, this.messages.length - keepN);
290
- this.messages = keep;
291
- // Ask the model to summarize what was trimmed so far, then prepend it
292
- // as a system message so the model retains the conversation arc.
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 (dropped.length > 0) {
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
- ...dropped,
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: keepN });
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
- if (nudges < MAX_NUDGES) {
367
- nudges++;
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, so a headless
360
- // `-p` run and the TUI both see them. Failures are reported but never fatal:
361
- // an unreachable server must not stop the session from starting.
362
- if (cfg.mcp !== false) {
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
- const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec));
369
- mcp.setLiveConnections(res.servers);
370
- if (process.env.HNCODE_MCP_VERBOSE === '1') {
371
- for (const s of res.servers) {
372
- console.error(s.ok ? `[hncode-mcp] ${s.name}: ${s.toolCount} tool(s)` : `[hncode-mcp] ${s.name}: ${s.error}`);
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
- const timeoutMs = opts.timeoutMs || 30_000;
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
- for (const [name, def] of Object.entries((config && config.servers) || {})) {
321
- if (only && !only.includes(name)) continue;
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 += registered;
340
- servers.push({ name, ok: true, toolCount: registered, error: err, serverInfo: conn.serverInfo, transport: conn.transport, isHttp: conn.isHttp });
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
- try {
2611
- state._gitInfo = gitmod.statuslineInfo(cwd);
2612
- } catch {
2613
- state._gitInfo = null; // a git failure must never break rendering
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: 'check npm for updates at startup + every 30 min' },
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
- { label: 'permission mode', kind: 'toggle', isOn: state.slMode !== false },
4933
- { label: 'model name', kind: 'toggle', isOn: state.slModel !== false },
4934
- { label: 'thinking effort', kind: 'toggle', isOn: state.slEffort !== false },
4935
- { label: 'current directory', kind: 'toggle', isOn: state.slCwd !== false },
4936
- { label: 'git branch & diff', kind: 'toggle', isOn: state.slGit !== false },
4937
- { label: 'background tasks', kind: 'toggle', isOn: state.slTasks !== false },
4938
- { label: 'rotating tips', kind: 'toggle', isOn: state.slTips !== false },
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
- const key = { 'permission mode': 'slMode', 'model name': 'slModel', 'thinking effort': 'slEffort', 'current directory': 'slCwd', 'git branch & diff': 'slGit', 'background tasks': 'slTasks', 'rotating tips': 'slTips' }[it.label];
4945
- state[key] = it.isOn;
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
- // Populate the git badge before the first paint so the status line is correct
6155
- // immediately (from then on the 15s timer / turn-end keep it current).
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
- const status = task.status || 'completed';
8303
- const head = status === 'completed'
8304
- ? `[background agent finished: ${task.taskId}]`
8305
- : `[background agent ${status}: ${task.taskId}${task.stopReason ? ' - ' + task.stopReason : ''}]`;
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: 'system', text: body ? `${head}\n${body}` : head });
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, execSync } from 'node:child_process';
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
- export function remoteVersion() {
24
- try {
25
- const out = execSync(`npm view ${PKG_NAME} version --silent`, {
26
- stdio: ['ignore', 'pipe', 'ignore'],
27
- timeout: 15000,
28
- }).toString().trim();
29
- return out || '';
30
- } catch {
31
- return '';
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 = spawn('npm', ['install', '-g', `${PKG_NAME}@latest`], {
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)) {