@hmharness/cli 0.4.1 → 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
@@ -97,6 +97,10 @@ export declare class TuiRuntime {
97
97
  waitExit(): Promise<void>;
98
98
  private quit;
99
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;
100
104
  startStream(kind: 'think' | 'say'): (chunk: string) => void;
101
105
  /** Collapse a streamed thinking block to its final folded summary line. */
102
106
  foldThinking(): void;
package/dist/tui.js CHANGED
@@ -11,7 +11,7 @@
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
15
  import { createRequire } from 'node:module';
16
16
  import { loadConfig, homeDir, resolveProvider, listProviders, setChatRoute, setLocale, PROVIDER_PRESETS, addProviders, detectLocalProviders } from '@hmharness/kernel';
17
17
  import { listDrafts, listSkills, runBench, runEvolution } from '@hmharness/evolution';
@@ -102,6 +102,7 @@ export const COMMANDS = [
102
102
  { name: '/bench', key: 'cmdBench' },
103
103
  { name: '/evolve', key: 'cmdEvolve' },
104
104
  { name: '/mcp', key: 'cmdMcp' },
105
+ { name: '/resume', key: 'cmdResume' },
105
106
  { name: '/status', key: 'cmdStatus' },
106
107
  { name: '/clear', key: 'cmdClear' },
107
108
  { name: '/web', key: 'cmdWeb' },
@@ -318,6 +319,21 @@ export class TuiRuntime {
318
319
  this.scrollFromBottom = 0;
319
320
  this.dirty = true;
320
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
+ }
321
337
  startStream(kind) {
322
338
  const width = Math.max(20, (stdout.columns || 100) - 2);
323
339
  const lines = [];
@@ -906,6 +922,47 @@ export async function tui(yes, noWeb = false) {
906
922
  rt.addText(up ? t.tuiWebLinked(DEFAULT_WEB_PORT) : t.tuiWebHint, 'dim');
907
923
  return;
908
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
+ }
909
966
  if (line === '/yolo' || line === '/yolo on' || line === '/yolo off') {
910
967
  const turnOn = line === '/yolo' ? !autoApprove : line === '/yolo on';
911
968
  autoApprove = turnOn;
@@ -1054,7 +1111,7 @@ export async function tui(yes, noWeb = false) {
1054
1111
  }
1055
1112
  return;
1056
1113
  }
1057
- rt.addText(`❯ ${line}`);
1114
+ rt.addUser(line);
1058
1115
  rt.setBusy(true, t.running);
1059
1116
  let appender = null;
1060
1117
  let kind = null;
@@ -1083,11 +1140,17 @@ export async function tui(yes, noWeb = false) {
1083
1140
  rt.foldThinking();
1084
1141
  appender = null;
1085
1142
  kind = null;
1086
- 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))}`);
1087
1149
  },
1088
1150
  onToolResult: (name, output, isError) => {
1089
1151
  const dot = isError ? RED('✗') : GREEN('•');
1090
- 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))}`);
1091
1154
  },
1092
1155
  },
1093
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.1",
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.1",
47
- "@hmharness/evolution": "0.4.1",
48
- "@hmharness/domain-harmony": "0.4.1",
49
- "@hmharness/domain-ops": "0.4.1",
50
- "@hmharness/agent": "0.4.1",
51
- "@hmharness/web": "0.4.1"
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
  }