@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 +16 -2
- package/dist/tui.js +108 -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
|
@@ -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,
|
|
93
|
-
* slash commands. Rows are {name, desc} so
|
|
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
|
-
|
|
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,
|
|
274
|
-
* slash commands. Rows are {name, desc} so
|
|
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
|
-
:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
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.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.
|
|
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.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
|
}
|