@hncode/hncode 0.5.2 → 0.5.5

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.
Files changed (45) hide show
  1. package/package.json +38 -38
  2. package/src/agent.js +172 -19
  3. package/src/cache.js +175 -130
  4. package/src/clipboard.js +69 -2
  5. package/src/config.js +10 -3
  6. package/src/index.js +29 -5
  7. package/src/keybindings.js +25 -3
  8. package/src/llm.js +54 -6
  9. package/src/mcp.js +419 -384
  10. package/src/plugin.js +95 -10
  11. package/src/remote.js +307 -289
  12. package/src/session-hub.js +37 -0
  13. package/src/settings-schema.js +2 -0
  14. package/src/shell-safety.js +610 -562
  15. package/src/swarm-progress.js +1 -1
  16. package/src/team/mailbox.js +216 -139
  17. package/src/team/permission.js +121 -0
  18. package/src/team/protocol.js +167 -0
  19. package/src/team/runner.js +127 -12
  20. package/src/team/spawn.js +63 -5
  21. package/src/team/store.js +31 -4
  22. package/src/team/tasks.js +15 -2
  23. package/src/team/team.js +51 -16
  24. package/src/team-panel.js +418 -20
  25. package/src/term-caps.js +457 -410
  26. package/src/term.js +142 -26
  27. package/src/tool-result.js +50 -2
  28. package/src/tools/agent.js +464 -436
  29. package/src/tools/bash.js +53 -14
  30. package/src/tools/diagnostics.js +13 -0
  31. package/src/tools/edit.js +727 -577
  32. package/src/tools/fetch-url.js +118 -5
  33. package/src/tools/glob.js +5 -3
  34. package/src/tools/grep.js +19 -5
  35. package/src/tools/ignore.js +17 -3
  36. package/src/tools/index.js +17 -3
  37. package/src/tools/read.js +13 -2
  38. package/src/tools/shell.js +145 -134
  39. package/src/tools/tasks.js +214 -141
  40. package/src/tools/team.js +13 -0
  41. package/src/tools/utils.js +12 -3
  42. package/src/tools/web-search.js +85 -34
  43. package/src/tui.js +2089 -714
  44. package/src/update.js +38 -3
  45. package/src/web.js +47 -1
package/src/update.js CHANGED
@@ -166,6 +166,28 @@ export function readUpdateStamp() {
166
166
  try { return JSON.parse(fs.readFileSync(updateStampFile(), 'utf8')); } catch { return null; }
167
167
  }
168
168
 
169
+ // npm's own stderr from the background install, beside the stamp. A FILE because
170
+ // the install outlives this process: a pipe belonged to hncode, and quitting
171
+ // mid-install closed it under npm — the install died on EPIPE partway through.
172
+ function updateLogFile() {
173
+ const dir = process.env.HNCODE_HOME || path.join(os.homedir(), '.hncode');
174
+ return path.join(dir, 'update-log.txt');
175
+ }
176
+
177
+ /** Last `n` bytes of a file, or ''. Never throws. */
178
+ function tailOfFile(p, n = 4000) {
179
+ try {
180
+ const st = fs.statSync(p);
181
+ const fh = fs.openSync(p, 'r');
182
+ try {
183
+ const start = Math.max(0, st.size - n);
184
+ const buf = Buffer.alloc(st.size - start);
185
+ fs.readSync(fh, buf, 0, buf.length, start);
186
+ return buf.toString('utf8');
187
+ } finally { fs.closeSync(fh); }
188
+ } catch { return ''; }
189
+ }
190
+
169
191
  /** Forget it, once it has been reported. Never throws. */
170
192
  export function clearUpdateStamp() {
171
193
  try { fs.unlinkSync(updateStampFile()); return true; } catch { return false; }
@@ -202,13 +224,23 @@ export function updateBackground(version = '') {
202
224
  return;
203
225
  }
204
226
 
227
+ // stderr lands in a file (survives this process); when the file cannot be
228
+ // opened, fall back to a pipe and the in-memory capture below.
229
+ const logFile = updateLogFile();
230
+ let logFd = null;
231
+ try {
232
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
233
+ logFd = fs.openSync(logFile, 'w');
234
+ } catch { logFd = null; }
235
+
205
236
  let child;
206
237
  try {
207
238
  child = spawnNpm(['install', '-g', `${PKG_NAME}@latest`], {
208
239
  detached: true,
209
- stdio: ['ignore', 'ignore', 'pipe'],
240
+ stdio: ['ignore', 'ignore', logFd == null ? 'pipe' : logFd],
210
241
  });
211
242
  } catch (e) {
243
+ if (logFd != null) { try { fs.closeSync(logFd); } catch { /* already gone */ } }
212
244
  releaseInstallLock();
213
245
  const error = (e && e.message) || String(e);
214
246
  writeUpdateStamp({ status: 'error', version, error });
@@ -217,7 +249,7 @@ export function updateBackground(version = '') {
217
249
  }
218
250
 
219
251
  let stderr = '';
220
- if (child.stderr) {
252
+ if (logFd == null && child.stderr) {
221
253
  child.stderr.setEncoding('utf8');
222
254
  child.stderr.on('data', (d) => {
223
255
  stderr += d;
@@ -230,17 +262,20 @@ export function updateBackground(version = '') {
230
262
  // it prevents.
231
263
  child.on('error', (e) => {
232
264
  releaseInstallLock();
265
+ if (logFd != null) { try { fs.closeSync(logFd); } catch { /* already gone */ } }
233
266
  writeUpdateStamp({ status: 'error', version, error: (e && e.message) || String(e) });
234
267
  });
235
268
  child.on('close', (code) => {
236
269
  releaseInstallLock();
270
+ if (logFd != null) { try { fs.closeSync(logFd); } catch { /* already gone */ } }
237
271
  // Exit code 0 is the only thing that means the files were replaced. Anything else —
238
272
  // no permission, an unreachable registry, a killed process — is recorded with npm's
239
273
  // own last words, which is what makes the next launch's message actionable.
240
274
  if (code === 0) {
241
275
  writeUpdateStamp({ status: 'installed', version });
242
276
  } else {
243
- const tail = stderr.trim().split(/\r?\n/).slice(-6).join('\n');
277
+ const raw = logFd != null ? tailOfFile(logFile) : stderr;
278
+ const tail = raw.trim().split(/\r?\n/).slice(-6).join('\n');
244
279
  writeUpdateStamp({ status: 'error', version, code, error: tail || `npm exited ${code}` });
245
280
  }
246
281
  });
package/src/web.js CHANGED
@@ -270,7 +270,53 @@ attachWebSocket(server, {
270
270
  }
271
271
 
272
272
  // --- API ------------------------------------------------------------------
273
- if (p === '/api/state') return json(res, 200, { ok: true, ...hub.snapshot() });
273
+ if (p === '/api/state') {
274
+ // WHAT TO SEND, decided by the caller.
275
+ //
276
+ // `/api/state` used to answer with one fixed shape: every row, every tool result, the whole
277
+ // transcript. That is what a browser wants and the wrong answer for a script asking "what did the
278
+ // last turn do", which pays for the entire session to read the last few rows.
279
+ //
280
+ // ?detail=full every row, tool results included (the default: unchanged behaviour)
281
+ // ?detail=summary drop tool RESULTS, keep the "Used Bash (cmd)" rows and the assistant text
282
+ // ?detail=minimal assistant and user text only — no tool rows at all
283
+ // ?turn=last only the most recent turn (a turn starts at a user message)
284
+ // ?turn=<n> only turn n, counting from 1
285
+ // ?limit=<n> at most n rows, taken from the end
286
+ //
287
+ // All of them compose, so `/api/state?detail=summary&turn=last` is one request for "what just
288
+ // happened, and with which tools" without the payload of the results themselves.
289
+ const detail = String(url.searchParams.get('detail') || 'full');
290
+ const turn = url.searchParams.get('turn');
291
+ const limit = Number(url.searchParams.get('limit'));
292
+ const snap = hub.snapshot();
293
+ let rows = snap.rows;
294
+ // A turn begins at a user message; splitting on those is what makes "the last turn" mean the same
295
+ // thing here as it does in the transcript.
296
+ if (turn === 'last' || (turn && /^\d+$/.test(turn))) {
297
+ const starts = [];
298
+ rows.forEach((r, idx) => { if (r.role === 'user') starts.push(idx); });
299
+ const which = turn === 'last' ? starts.length - 1 : Number(turn) - 1;
300
+ if (which >= 0 && which < starts.length) {
301
+ const from = starts[which];
302
+ const to = which + 1 < starts.length ? starts[which + 1] : rows.length;
303
+ rows = rows.slice(from, to);
304
+ } else {
305
+ rows = []; // no such turn: an empty list is the honest answer
306
+ }
307
+ }
308
+ if (detail === 'summary' || detail === 'minimal') {
309
+ // `summary` keeps the tool ROWS but clears their output: the caller wants to know which tools
310
+ // ran, not what they printed. `minimal` drops the rows entirely.
311
+ rows = rows.filter((r) => r.role !== 'tool' || detail === 'summary');
312
+ if (detail === 'summary') {
313
+ rows = rows.map((r) => (r.role === 'tool' ? { ...r, text: '', liveOutput: '', diff: undefined } : r));
314
+ }
315
+ }
316
+ // Any row carrying a `card` is an interactive prompt; those are never trimmed by `minimal`.
317
+ if (Number.isFinite(limit) && limit > 0) rows = rows.slice(-Math.floor(limit));
318
+ return json(res, 200, { ok: true, ...snap, rows, hidden: snap.totalRows - rows.length, detail, turn: turn || null });
319
+ }
274
320
 
275
321
  // The session list for the left rail. `listSavedSessions` already powers the
276
322
  // daemon's landing page, so a session page shows the same names in the same