@hmharness/cli 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.d.ts CHANGED
@@ -39,6 +39,9 @@ export declare class TuiRuntime {
39
39
  private cwdName;
40
40
  private skillCount;
41
41
  private t;
42
+ /** installed hmharness version, shown in the header (v0.4.0 style);
43
+ * defaults to the running build so even a bare runtime identifies itself */
44
+ private version;
42
45
  /** persistent header tag, e.g. the active approval mode (🔥 YOLO) */
43
46
  private modeTag;
44
47
  /** rows for the `/model ` picker (configured providers first, set by driver) */
@@ -89,11 +92,15 @@ export declare class TuiRuntime {
89
92
  /** The palette data source: `/model ` opens the model picker, otherwise
90
93
  * slash commands. Rows are {name, desc} so both share one renderer. */
91
94
  private panelItems;
92
- configure(model: string, cwdName: string, skillCount: number, locale: Locale): void;
95
+ configure(model: string, cwdName: string, skillCount: number, locale: Locale, version?: string): void;
93
96
  destroy(): void;
94
97
  waitExit(): Promise<void>;
95
98
  private quit;
96
99
  addText(text: string, style?: 'dim' | 'plain' | 'err'): void;
100
+ /** The user's own input, chat-style: separated by a blank line above and
101
+ * below, right-aligned to the terminal width so it reads as "the human
102
+ * side" against left-aligned model output. */
103
+ addUser(text: string): void;
97
104
  startStream(kind: 'think' | 'say'): (chunk: string) => void;
98
105
  /** Collapse a streamed thinking block to its final folded summary line. */
99
106
  foldThinking(): void;
package/dist/tui.js CHANGED
@@ -11,11 +11,22 @@
11
11
  * Not a TTY? Prints a pointer to the plain REPL instead.
12
12
  */
13
13
  import { stdin, stdout } from 'node:process';
14
- import { basename } from 'node:path';
14
+ import { basename, join } from 'node:path';
15
+ import { createRequire } from 'node:module';
15
16
  import { loadConfig, homeDir, resolveProvider, listProviders, setChatRoute, setLocale, PROVIDER_PRESETS, addProviders, detectLocalProviders } from '@hmharness/kernel';
16
17
  import { listDrafts, listSkills, runBench, runEvolution } from '@hmharness/evolution';
17
18
  import { buildRegistry, runAgentTask, strings } from '@hmharness/agent';
18
19
  import { ensureWebDaemon, DEFAULT_WEB_PORT } from "./web-daemon.js";
20
+ /** installed version, shown in the TUI header (v0.4.0) so users always
21
+ * know which build they are talking to - resolves in both src/ and dist/ */
22
+ const HMH_VERSION = (() => {
23
+ try {
24
+ return createRequire(import.meta.url)('../package.json').version;
25
+ }
26
+ catch {
27
+ return '';
28
+ }
29
+ })();
19
30
  const RESET = '\x1b[0m';
20
31
  const DIM = (s) => `\x1b[2m${s}${RESET}`;
21
32
  const BOLD = (s) => `\x1b[1m${s}${RESET}`;
@@ -91,6 +102,7 @@ export const COMMANDS = [
91
102
  { name: '/bench', key: 'cmdBench' },
92
103
  { name: '/evolve', key: 'cmdEvolve' },
93
104
  { name: '/mcp', key: 'cmdMcp' },
105
+ { name: '/resume', key: 'cmdResume' },
94
106
  { name: '/status', key: 'cmdStatus' },
95
107
  { name: '/clear', key: 'cmdClear' },
96
108
  { name: '/web', key: 'cmdWeb' },
@@ -148,6 +160,9 @@ export class TuiRuntime {
148
160
  cwdName = '';
149
161
  skillCount = 0;
150
162
  t = strings();
163
+ /** installed hmharness version, shown in the header (v0.4.0 style);
164
+ * defaults to the running build so even a bare runtime identifies itself */
165
+ version = HMH_VERSION;
151
166
  /** persistent header tag, e.g. the active approval mode (🔥 YOLO) */
152
167
  modeTag = '';
153
168
  /** rows for the `/model ` picker (configured providers first, set by driver) */
@@ -270,11 +285,12 @@ export class TuiRuntime {
270
285
  }
271
286
  return matchCommands(input).map((c) => ({ name: c.name, desc: String(this.t[c.key]) }));
272
287
  }
273
- configure(model, cwdName, skillCount, locale) {
288
+ configure(model, cwdName, skillCount, locale, version) {
274
289
  this.model = model;
275
290
  this.cwdName = cwdName;
276
291
  this.skillCount = skillCount;
277
292
  this.t = strings(locale);
293
+ this.version = version ?? HMH_VERSION;
278
294
  this.dirty = true;
279
295
  }
280
296
  destroy() {
@@ -303,6 +319,21 @@ export class TuiRuntime {
303
319
  this.scrollFromBottom = 0;
304
320
  this.dirty = true;
305
321
  }
322
+ /** The user's own input, chat-style: separated by a blank line above and
323
+ * below, right-aligned to the terminal width so it reads as "the human
324
+ * side" against left-aligned model output. */
325
+ addUser(text) {
326
+ const width = Math.max(20, (stdout.columns || 100) - 2);
327
+ const lines = [''];
328
+ for (const l of wrapTo(text.replace(/\n+/g, ' '), width)) {
329
+ const pad = Math.max(1, width - strWidth(l));
330
+ lines.push(' '.repeat(pad) + BOLD(l));
331
+ }
332
+ lines.push('');
333
+ this.entries.push({ lines });
334
+ this.scrollFromBottom = 0;
335
+ this.dirty = true;
336
+ }
306
337
  startStream(kind) {
307
338
  const width = Math.max(20, (stdout.columns || 100) - 2);
308
339
  const lines = [];
@@ -626,7 +657,7 @@ export class TuiRuntime {
626
657
  // old right slot kept showing a stale "idle" - an orphan status.
627
658
  // Moving a thing means deleting it from where it was).
628
659
  // The ONLY live run indicator is the status line above the input box.
629
- const headLeft = ` ${BOLD('⚙ hmh')} ${DIM('·')} ${CYAN(this.model)} ${DIM('·')} ${this.cwdName} ${DIM('·')} ${this.skillCount} ${this.t.tuiSkills}` + (this.modeTag ? ` ${this.modeTag}` : '');
660
+ const headLeft = ` ${BOLD('⚙ hmh')}${this.version ? ` ${DIM('v' + this.version)}` : ''} ${DIM('·')} ${CYAN(this.model)} ${DIM('·')} ${this.cwdName} ${DIM('·')} ${this.skillCount} ${this.t.tuiSkills}` + (this.modeTag ? ` ${this.modeTag}` : '');
630
661
  frame.push(truncateTo(headLeft, W));
631
662
  frame.push(DIM('─'.repeat(W)));
632
663
  const allLines = [];
@@ -824,7 +855,7 @@ export async function tui(yes, noWeb = false) {
824
855
  const rt = new TuiRuntime();
825
856
  const skills = await listSkills(home);
826
857
  const chatModel = resolveProvider(cfg, 'chat').model;
827
- rt.configure(chatModel, basename(process.cwd()), skills.length, (cfg.locale ?? 'zh'));
858
+ rt.configure(chatModel, basename(process.cwd()), skills.length, (cfg.locale ?? 'zh'), HMH_VERSION);
828
859
  rt.setModelChoices(listProviders(cfg).map((v) => ({ name: v.name, desc: `${v.model}${v.purposes.length ? ' (' + v.purposes.join('/') + ')' : ''}` })));
829
860
  if (autoApprove)
830
861
  rt.setModeTag('🔥');
@@ -891,6 +922,47 @@ export async function tui(yes, noWeb = false) {
891
922
  rt.addText(up ? t.tuiWebLinked(DEFAULT_WEB_PORT) : t.tuiWebHint, 'dim');
892
923
  return;
893
924
  }
925
+ if (line === '/resume' || line.startsWith('/resume ')) {
926
+ const arg = line.slice(8).trim();
927
+ const { latestSession, loadTranscript } = await import('@hmharness/kernel');
928
+ if (!arg) {
929
+ // list the 8 newest sessions: id prefix (enough to disambiguate) + first user line
930
+ const { readdir } = await import('node:fs/promises');
931
+ let files = [];
932
+ try {
933
+ files = (await readdir(join(home, 'sessions'))).filter((f) => f.endsWith('.jsonl'));
934
+ }
935
+ catch { /* none */ }
936
+ files.sort();
937
+ const recent = files.slice(-8).reverse();
938
+ if (recent.length === 0) {
939
+ rt.addText(t.cmdResumeNone, 'dim');
940
+ return;
941
+ }
942
+ for (const f of recent) {
943
+ try {
944
+ const tr = await loadTranscript(join(home, 'sessions', f));
945
+ const firstUser = tr?.messages.find((m) => m.role === 'user')?.content ?? '';
946
+ rt.addText(`${CYAN(f.slice(0, 18))} ${(firstUser || '(no user line)').replace(/\n/g, ' ').slice(0, 60)}`, 'dim');
947
+ }
948
+ catch { /* skip unreadable */ }
949
+ }
950
+ rt.addText(t.cmdResumeHint, 'dim');
951
+ return;
952
+ }
953
+ const file = await latestSession(home, arg);
954
+ const tr = file ? await loadTranscript(file) : null;
955
+ if (!tr || tr.messages.length === 0) {
956
+ rt.addText(t.cmdResumeNotFound(arg), 'err');
957
+ return;
958
+ }
959
+ history = tr.messages;
960
+ const firstUser = tr.messages.find((m) => m.role === 'user')?.content ?? '';
961
+ rt.clearScreen();
962
+ rt.addUser(firstUser.replace(/\n/g, ' ').slice(0, 120));
963
+ rt.addText(t.cmdResumeLoaded(tr.messages.length), 'dim');
964
+ return;
965
+ }
894
966
  if (line === '/yolo' || line === '/yolo on' || line === '/yolo off') {
895
967
  const turnOn = line === '/yolo' ? !autoApprove : line === '/yolo on';
896
968
  autoApprove = turnOn;
@@ -902,7 +974,7 @@ export async function tui(yes, noWeb = false) {
902
974
  const target = nextLocale(cfg.locale ?? 'zh', line.slice(5));
903
975
  cfg = await setLocale(target);
904
976
  t = strings(target);
905
- rt.configure(chatModel, basename(process.cwd()), skills.length, target);
977
+ rt.configure(chatModel, basename(process.cwd()), skills.length, target, HMH_VERSION);
906
978
  rt.addText(GREEN('✓') + ' ' + t.langSwitched(target));
907
979
  return;
908
980
  }
@@ -1039,7 +1111,7 @@ export async function tui(yes, noWeb = false) {
1039
1111
  }
1040
1112
  return;
1041
1113
  }
1042
- rt.addText(`❯ ${line}`);
1114
+ rt.addUser(line);
1043
1115
  rt.setBusy(true, t.running);
1044
1116
  let appender = null;
1045
1117
  let kind = null;
@@ -1068,11 +1140,17 @@ export async function tui(yes, noWeb = false) {
1068
1140
  rt.foldThinking();
1069
1141
  appender = null;
1070
1142
  kind = null;
1071
- rt.addText(`${YELLOW('●')} ${CYAN(name)} ${DIM(JSON.stringify(args).slice(0, 100))}`);
1143
+ // fold the args to their essence: for run_command the command
1144
+ // string itself, otherwise a short JSON tail
1145
+ const brief = name === 'run_command' && typeof args.command === 'string'
1146
+ ? args.command
1147
+ : JSON.stringify(args);
1148
+ rt.addText(`${YELLOW('●')} ${CYAN(name)} ${DIM(brief.replace(/\s+/g, ' ').slice(0, 90))}`);
1072
1149
  },
1073
1150
  onToolResult: (name, output, isError) => {
1074
1151
  const dot = isError ? RED('✗') : GREEN('•');
1075
- rt.addText(` ${dot} ${DIM('⎿ ' + output.split('\n').slice(0, 2).join(' ').slice(0, 110))}`);
1152
+ const first = output.split('\n').find((l) => l.trim()) ?? '';
1153
+ rt.addText(` ${dot} ${DIM('⎿ ' + first.trim().slice(0, 100))}`);
1076
1154
  },
1077
1155
  },
1078
1156
  });
@@ -1,6 +1,9 @@
1
1
  export declare const DEFAULT_WEB_PORT = 7788;
2
2
  export declare function readWebPid(): number;
3
- /** cheap probe: does OUR server answer on the port (not just any listener)? */
3
+ /** cheap probe: does OUR server answer on the port (not just any listener)?
4
+ * Accepts ANY hmh /api/state shape - a daemon from an older build still
5
+ * serves the UI, and rejecting it made the TUI auto-link spawn a fresh
6
+ * daemon that died on EADDRINUSE every startup (the "lost setting" bug). */
4
7
  export declare function hmhWebUp(port: number): Promise<boolean>;
5
8
  export declare function stopWebDaemon(): boolean;
6
9
  /**
@@ -5,6 +5,7 @@
5
5
  * HMH_HOME; the daemon runs detached with no window and survives terminals.
6
6
  */
7
7
  import { spawn } from 'node:child_process';
8
+ import { execSync } from 'node:child_process';
8
9
  import { openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
9
10
  import { join } from 'node:path';
10
11
  import { homeDir } from '@hmharness/kernel';
@@ -27,41 +28,67 @@ function alive(pid) {
27
28
  return false;
28
29
  }
29
30
  }
30
- /** cheap probe: does OUR server answer on the port (not just any listener)? */
31
+ /** cheap probe: does OUR server answer on the port (not just any listener)?
32
+ * Accepts ANY hmh /api/state shape - a daemon from an older build still
33
+ * serves the UI, and rejecting it made the TUI auto-link spawn a fresh
34
+ * daemon that died on EADDRINUSE every startup (the "lost setting" bug). */
31
35
  export async function hmhWebUp(port) {
32
36
  try {
33
37
  const r = await fetch(`http://127.0.0.1:${port}/api/state`, { signal: AbortSignal.timeout(1500) });
34
38
  if (!r.ok)
35
39
  return false;
36
- const d = (await r.json());
37
- return typeof d.model === 'string';
40
+ const d = await r.json();
41
+ return !!d && typeof d === 'object';
38
42
  }
39
43
  catch {
40
44
  return false;
41
45
  }
42
46
  }
43
- export function stopWebDaemon() {
44
- const pid = readWebPid();
45
- if (!pid || !alive(pid)) {
46
- try {
47
- unlinkSync(join(homeDir(), 'web.pid'));
47
+ /** Windows-first: find the PID LISTENING on 127.0.0.1:<port> via netstat.
48
+ * Used to reclaim a port held by an orphaned/old daemon whose pid file is
49
+ * stale - `hmh web stop` must be able to evict it, or restarts never heal. */
50
+ function portOwnerPid(port) {
51
+ if (process.platform !== 'win32')
52
+ return 0;
53
+ try {
54
+ const out = execSync(`netstat -ano -p tcp`, { encoding: 'utf8', timeout: 5000 });
55
+ for (const line of out.split('\n')) {
56
+ const m = line.trim().match(new RegExp(`^(TCP)\\s+\\S*?:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)$`));
57
+ if (m)
58
+ return Number(m[2]);
48
59
  }
49
- catch { /* absent */ }
50
- return false;
51
60
  }
52
- if (process.platform === 'win32')
53
- spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
54
- else {
55
- try {
56
- process.kill(pid);
61
+ catch { /* netstat unavailable - give up quietly */ }
62
+ return 0;
63
+ }
64
+ export function stopWebDaemon() {
65
+ const pid = readWebPid();
66
+ let killed = false;
67
+ if (pid && alive(pid)) {
68
+ if (process.platform === 'win32')
69
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
70
+ else {
71
+ try {
72
+ process.kill(pid);
73
+ }
74
+ catch { /* gone */ }
57
75
  }
58
- catch { /* gone */ }
76
+ killed = true;
59
77
  }
60
78
  try {
61
79
  unlinkSync(join(homeDir(), 'web.pid'));
62
80
  }
63
81
  catch { /* absent */ }
64
- return true;
82
+ // stale pid file but the port is still held (orphaned/old daemon): evict
83
+ const owner = portOwnerPid(DEFAULT_WEB_PORT);
84
+ if (owner && owner !== pid) {
85
+ try {
86
+ spawn('taskkill', ['/PID', String(owner), '/T', '/F'], { windowsHide: true });
87
+ killed = true;
88
+ }
89
+ catch { /* best effort */ }
90
+ }
91
+ return killed;
65
92
  }
66
93
  /** Spawn the daemon (no window, detached). Returns the pid. */
67
94
  function spawnWebDaemon(port, entry = process.argv[1]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "hmharness command line: one-shot tasks, an interactive REPL, a fullscreen TUI, the web frontend, and direct tool invocation.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -43,11 +43,11 @@
43
43
  "build": "tsc -p tsconfig.build.json"
44
44
  },
45
45
  "dependencies": {
46
- "@hmharness/kernel": "0.4.0",
47
- "@hmharness/evolution": "0.4.0",
48
- "@hmharness/domain-harmony": "0.4.0",
49
- "@hmharness/domain-ops": "0.4.0",
50
- "@hmharness/agent": "0.4.0",
51
- "@hmharness/web": "0.4.0"
46
+ "@hmharness/kernel": "0.4.2",
47
+ "@hmharness/evolution": "0.4.2",
48
+ "@hmharness/domain-harmony": "0.4.2",
49
+ "@hmharness/domain-ops": "0.4.2",
50
+ "@hmharness/agent": "0.4.2",
51
+ "@hmharness/web": "0.4.2"
52
52
  }
53
53
  }