@hmharness/cli 0.4.1 → 0.4.3

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
@@ -46,6 +46,8 @@ export declare class TuiRuntime {
46
46
  private modeTag;
47
47
  /** rows for the `/model ` picker (configured providers first, set by driver) */
48
48
  private modelChoices;
49
+ /** rows for the `/resume ` session picker (id prefix + first user line) */
50
+ private sessionChoices;
49
51
  /** Wheel/click handling: NO mouse reporting by default - select/copy
50
52
  * always works and terminals translate the wheel to arrow keys on the
51
53
  * alternate screen. Reporting turns on ONLY while a palette is open
@@ -63,9 +65,16 @@ export declare class TuiRuntime {
63
65
  name: string;
64
66
  desc: string;
65
67
  }>): void;
68
+ setSessionChoices(list: Array<{
69
+ name: string;
70
+ desc: string;
71
+ }>): void;
66
72
  /** Focus the /model picker (used by the bare `/model` command so the
67
73
  * printed list is never a dead end - the live palette opens on it). */
68
74
  openModelPicker(): void;
75
+ /** Focus the /resume session picker - same live palette as /model:
76
+ * arrows/wheel navigate, Enter loads the highlighted session. */
77
+ openSessionPicker(): void;
69
78
  /** Run the highlighted palette row (shared by Enter and palette clicks);
70
79
  * with no palette open it just submits the typed input. */
71
80
  private pickHighlighted;
@@ -89,14 +98,19 @@ export declare class TuiRuntime {
89
98
  * while it shows, clicks choose its rows and the wheel drives its
90
99
  * selection; drag-select resumes the moment it closes. */
91
100
  private syncMouseReporting;
92
- /** The palette data source: `/model ` opens the model picker, otherwise
93
- * slash commands. Rows are {name, desc} so both share one renderer. */
101
+ /** The palette data source: `/model ` opens the model picker, `/resume `
102
+ * the session picker, otherwise slash commands. Rows are {name, desc} so
103
+ * all three share one renderer, keyboard and click machinery. */
94
104
  private panelItems;
95
105
  configure(model: string, cwdName: string, skillCount: number, locale: Locale, version?: string): void;
96
106
  destroy(): void;
97
107
  waitExit(): Promise<void>;
98
108
  private quit;
99
109
  addText(text: string, style?: 'dim' | 'plain' | 'err'): void;
110
+ /** The user's own input, chat-style: separated by a blank line above and
111
+ * below, right-aligned to the terminal width so it reads as "the human
112
+ * side" against left-aligned model output. */
113
+ addUser(text: string): void;
100
114
  startStream(kind: 'think' | 'say'): (chunk: string) => void;
101
115
  /** Collapse a streamed thinking block to its final folded summary line. */
102
116
  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' },
@@ -166,6 +167,8 @@ export class TuiRuntime {
166
167
  modeTag = '';
167
168
  /** rows for the `/model ` picker (configured providers first, set by driver) */
168
169
  modelChoices = [];
170
+ /** rows for the `/resume ` session picker (id prefix + first user line) */
171
+ sessionChoices = [];
169
172
  /** Wheel/click handling: NO mouse reporting by default - select/copy
170
173
  * always works and terminals translate the wheel to arrow keys on the
171
174
  * alternate screen. Reporting turns on ONLY while a palette is open
@@ -194,6 +197,10 @@ export class TuiRuntime {
194
197
  this.modelChoices = list;
195
198
  this.dirty = true;
196
199
  }
200
+ setSessionChoices(list) {
201
+ this.sessionChoices = list;
202
+ this.dirty = true;
203
+ }
197
204
  /** Focus the /model picker (used by the bare `/model` command so the
198
205
  * printed list is never a dead end - the live palette opens on it). */
199
206
  openModelPicker() {
@@ -202,13 +209,26 @@ export class TuiRuntime {
202
209
  this.cmdIdx = 0;
203
210
  this.dirty = true;
204
211
  }
212
+ /** Focus the /resume session picker - same live palette as /model:
213
+ * arrows/wheel navigate, Enter loads the highlighted session. */
214
+ openSessionPicker() {
215
+ this.input = '/resume ';
216
+ this.caret = this.input.length;
217
+ this.cmdIdx = 0;
218
+ this.dirty = true;
219
+ }
205
220
  /** Run the highlighted palette row (shared by Enter and palette clicks);
206
221
  * with no palette open it just submits the typed input. */
207
222
  pickHighlighted() {
208
223
  const hits = this.panelItems(this.input);
209
224
  const pick = hits.length ? hits[Math.min(this.cmdIdx, hits.length - 1)].name : '';
210
225
  if (pick) {
211
- this.input = this.input.startsWith('/model') ? `/model ${pick} ` : pick + ' ';
226
+ if (this.input.startsWith('/model'))
227
+ this.input = `/model ${pick} `;
228
+ else if (this.input.startsWith('/resume'))
229
+ this.input = `/resume ${pick}`;
230
+ else
231
+ this.input = pick + ' ';
212
232
  this.caret = this.input.length;
213
233
  this.cmdIdx = 0;
214
234
  }
@@ -270,8 +290,9 @@ export class TuiRuntime {
270
290
  this.mouseReported = want;
271
291
  stdout.write(want ? '\x1b[?1000h\x1b[?1006h' : '\x1b[?1000l\x1b[?1006l');
272
292
  }
273
- /** The palette data source: `/model ` opens the model picker, otherwise
274
- * slash commands. Rows are {name, desc} so both share one renderer. */
293
+ /** The palette data source: `/model ` opens the model picker, `/resume `
294
+ * the session picker, otherwise slash commands. Rows are {name, desc} so
295
+ * all three share one renderer, keyboard and click machinery. */
275
296
  panelItems(input) {
276
297
  if (input === '/model' || input.startsWith('/model ')) {
277
298
  const q = input.slice(6).trim().toLowerCase();
@@ -282,6 +303,10 @@ export class TuiRuntime {
282
303
  const all = [...configured, ...rest];
283
304
  return q ? all.filter((i) => i.name.toLowerCase().startsWith(q)) : all;
284
305
  }
306
+ if (input === '/resume' || input.startsWith('/resume ')) {
307
+ const q = input.slice(7).trim().toLowerCase();
308
+ return q ? this.sessionChoices.filter((i) => i.name.toLowerCase().startsWith(q)) : this.sessionChoices;
309
+ }
285
310
  return matchCommands(input).map((c) => ({ name: c.name, desc: String(this.t[c.key]) }));
286
311
  }
287
312
  configure(model, cwdName, skillCount, locale, version) {
@@ -318,6 +343,21 @@ export class TuiRuntime {
318
343
  this.scrollFromBottom = 0;
319
344
  this.dirty = true;
320
345
  }
346
+ /** The user's own input, chat-style: separated by a blank line above and
347
+ * below, right-aligned to the terminal width so it reads as "the human
348
+ * side" against left-aligned model output. */
349
+ addUser(text) {
350
+ const width = Math.max(20, (stdout.columns || 100) - 2);
351
+ const lines = [''];
352
+ for (const l of wrapTo(text.replace(/\n+/g, ' '), width)) {
353
+ const pad = Math.max(1, width - strWidth(l));
354
+ lines.push(' '.repeat(pad) + BOLD(l));
355
+ }
356
+ lines.push('');
357
+ this.entries.push({ lines });
358
+ this.scrollFromBottom = 0;
359
+ this.dirty = true;
360
+ }
321
361
  startStream(kind) {
322
362
  const width = Math.max(20, (stdout.columns || 100) - 2);
323
363
  const lines = [];
@@ -493,6 +533,12 @@ export class TuiRuntime {
493
533
  this.openModelPicker();
494
534
  return;
495
535
  }
536
+ // same two-stage rule for /resume: bare command + Enter opens the
537
+ // session picker (focus moves to the list), never loads row 0 blindly
538
+ if (this.input === '/resume') {
539
+ this.openSessionPicker();
540
+ return;
541
+ }
496
542
  // palette open: Enter runs the highlighted row (a command, or a
497
543
  // /model target), not the raw input; without a palette it submits
498
544
  this.pickHighlighted();
@@ -503,7 +549,9 @@ export class TuiRuntime {
503
549
  if (hits.length) {
504
550
  this.input = this.input.startsWith('/model')
505
551
  ? `/model ${hits[Math.min(this.cmdIdx, hits.length - 1)].name} `
506
- : hits[Math.min(this.cmdIdx, hits.length - 1)].name + ' ';
552
+ : this.input.startsWith('/resume')
553
+ ? `/resume ${hits[Math.min(this.cmdIdx, hits.length - 1)].name}`
554
+ : hits[Math.min(this.cmdIdx, hits.length - 1)].name + ' ';
507
555
  this.caret = this.input.length;
508
556
  this.cmdIdx = 0;
509
557
  this.dirty = true;
@@ -906,6 +954,52 @@ export async function tui(yes, noWeb = false) {
906
954
  rt.addText(up ? t.tuiWebLinked(DEFAULT_WEB_PORT) : t.tuiWebHint, 'dim');
907
955
  return;
908
956
  }
957
+ if (line === '/resume' || line.startsWith('/resume ')) {
958
+ const arg = line.slice(8).trim();
959
+ const { latestSession, loadTranscript } = await import('@hmharness/kernel');
960
+ const { readdir } = await import('node:fs/promises');
961
+ // refresh the picker rows: id prefix + first user line as the preview
962
+ let files = [];
963
+ try {
964
+ files = (await readdir(join(home, 'sessions'))).filter((f) => f.endsWith('.jsonl'));
965
+ }
966
+ catch { /* none */ }
967
+ files.sort();
968
+ const recent = files.slice(-8).reverse();
969
+ const rows = [];
970
+ for (const f of recent) {
971
+ try {
972
+ const tr = await loadTranscript(join(home, 'sessions', f));
973
+ const firstUser = tr?.messages.find((m) => m.role === 'user')?.content ?? '';
974
+ rows.push({ name: f.slice(0, 18), desc: (firstUser || '(no user line)').replace(/\n/g, ' ').slice(0, 56) });
975
+ }
976
+ catch { /* skip unreadable */ }
977
+ }
978
+ rt.setSessionChoices(rows);
979
+ if (!arg) {
980
+ // bare /resume opens the LIVE picker - same arrows/wheel/Enter/click
981
+ // machinery as /model; a printed text list is a dead end (user-
982
+ // reported: "上下键无法选择")
983
+ if (rows.length === 0) {
984
+ rt.addText(t.cmdResumeNone, 'dim');
985
+ return;
986
+ }
987
+ rt.openSessionPicker();
988
+ return;
989
+ }
990
+ const file = await latestSession(home, arg);
991
+ const tr = file ? await loadTranscript(file) : null;
992
+ if (!tr || tr.messages.length === 0) {
993
+ rt.addText(t.cmdResumeNotFound(arg), 'err');
994
+ return;
995
+ }
996
+ history = tr.messages;
997
+ const firstUser = tr.messages.find((m) => m.role === 'user')?.content ?? '';
998
+ rt.clearScreen();
999
+ rt.addUser(firstUser.replace(/\n/g, ' ').slice(0, 120));
1000
+ rt.addText(t.cmdResumeLoaded(tr.messages.length), 'dim');
1001
+ return;
1002
+ }
909
1003
  if (line === '/yolo' || line === '/yolo on' || line === '/yolo off') {
910
1004
  const turnOn = line === '/yolo' ? !autoApprove : line === '/yolo on';
911
1005
  autoApprove = turnOn;
@@ -1054,7 +1148,7 @@ export async function tui(yes, noWeb = false) {
1054
1148
  }
1055
1149
  return;
1056
1150
  }
1057
- rt.addText(`❯ ${line}`);
1151
+ rt.addUser(line);
1058
1152
  rt.setBusy(true, t.running);
1059
1153
  let appender = null;
1060
1154
  let kind = null;
@@ -1083,11 +1177,17 @@ export async function tui(yes, noWeb = false) {
1083
1177
  rt.foldThinking();
1084
1178
  appender = null;
1085
1179
  kind = null;
1086
- rt.addText(`${YELLOW('●')} ${CYAN(name)} ${DIM(JSON.stringify(args).slice(0, 100))}`);
1180
+ // fold the args to their essence: for run_command the command
1181
+ // string itself, otherwise a short JSON tail
1182
+ const brief = name === 'run_command' && typeof args.command === 'string'
1183
+ ? args.command
1184
+ : JSON.stringify(args);
1185
+ rt.addText(`${YELLOW('●')} ${CYAN(name)} ${DIM(brief.replace(/\s+/g, ' ').slice(0, 90))}`);
1087
1186
  },
1088
1187
  onToolResult: (name, output, isError) => {
1089
1188
  const dot = isError ? RED('✗') : GREEN('•');
1090
- rt.addText(` ${dot} ${DIM('⎿ ' + output.split('\n').slice(0, 2).join(' ').slice(0, 110))}`);
1189
+ const first = output.split('\n').find((l) => l.trim()) ?? '';
1190
+ rt.addText(` ${dot} ${DIM('⎿ ' + first.trim().slice(0, 100))}`);
1091
1191
  },
1092
1192
  },
1093
1193
  });
@@ -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.3",
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.3",
47
+ "@hmharness/evolution": "0.4.3",
48
+ "@hmharness/domain-harmony": "0.4.3",
49
+ "@hmharness/domain-ops": "0.4.3",
50
+ "@hmharness/agent": "0.4.3",
51
+ "@hmharness/web": "0.4.3"
52
52
  }
53
53
  }