@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 +8 -1
- package/dist/tui.js +86 -8
- package/dist/web-daemon.d.ts +4 -1
- package/dist/web-daemon.js +44 -17
- package/package.json +7 -7
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/web-daemon.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/web-daemon.js
CHANGED
|
@@ -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 =
|
|
37
|
-
return typeof d
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
76
|
+
killed = true;
|
|
59
77
|
}
|
|
60
78
|
try {
|
|
61
79
|
unlinkSync(join(homeDir(), 'web.pid'));
|
|
62
80
|
}
|
|
63
81
|
catch { /* absent */ }
|
|
64
|
-
|
|
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.
|
|
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.
|
|
47
|
-
"@hmharness/evolution": "0.4.
|
|
48
|
-
"@hmharness/domain-harmony": "0.4.
|
|
49
|
-
"@hmharness/domain-ops": "0.4.
|
|
50
|
-
"@hmharness/agent": "0.4.
|
|
51
|
-
"@hmharness/web": "0.4.
|
|
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
|
}
|