@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 +4 -0
- package/dist/tui.js +67 -4
- 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
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
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
|
}
|