@hmharness/cli 0.8.1 → 0.8.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/main.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * hmh init create HMH_HOME skeleton + config
6
6
  * hmh "do something" one-shot task (full agent loop, streaming)
7
7
  * hmh interactive REPL (conversation memory kept)
8
- * hmh resume [id-prefix] continue a past session by id prefix (or latest)
8
+ * hmh resume [id-prefix|--last] continue a past session (bare: codex-style picker)
9
9
  * hmh web [--port=7788] local web frontend (SSE streaming + approvals)
10
10
  * hmh tui lite terminal UI (status header + slash commands)
11
11
  * hmh ops [scan|brief|stats|status] ops keeper: radar / npm download stats
@@ -23,7 +23,7 @@ import readline from 'node:readline/promises';
23
23
  import { stdin, stdout } from 'node:process';
24
24
  import { join } from 'node:path';
25
25
  import { stopWebDaemon, startWebDaemon, hmhWebUp, readWebPid } from "./web-daemon.js";
26
- import { chat, homeDir, resolveProvider, initHome, latestSession, listProviders, loadConfig, loadTranscript, mcpServerTools, Registry, runLoop, setChatRoute, setLocale, } from '@hmharness/kernel';
26
+ import { chat, homeDir, resolveProvider, initHome, latestSession, listProviders, listSessions, loadConfig, loadTranscript, mcpServerTools, Registry, runLoop, setChatRoute, setLocale, } from '@hmharness/kernel';
27
27
  import { listSkills, listDrafts, promoteSkill, runBench, runEvolution, rollbackSkill, unpromoteSkill, } from '@hmharness/evolution';
28
28
  import { harmonyTools } from '@hmharness/domain-harmony';
29
29
  import { baseTools, buildRegistry, buildSystemPrompt, runAgentTask, strings } from '@hmharness/agent';
@@ -61,6 +61,7 @@ async function runTask(task, taskOpts = {}) {
61
61
  cfg,
62
62
  yes: taskOpts.yes,
63
63
  resumeMessages: taskOpts.resumeMessages,
64
+ sessionId: taskOpts.sessionId,
64
65
  events: {
65
66
  onLine: (l) => stdout.write(DIM(` ${l}\n`)),
66
67
  onDelta: (kind, chunk) => {
@@ -92,7 +93,7 @@ async function runTask(task, taskOpts = {}) {
92
93
  // working transcript minus the system prompt and the task line we appended
93
94
  return { messages: result.messages, sessionId: result.sessionId };
94
95
  }
95
- async function repl(yes, initialHistory) {
96
+ async function repl(yes, initialHistory, initialSessionId) {
96
97
  const home = homeDir();
97
98
  let cfg = await loadConfig();
98
99
  let autoApprove = yes;
@@ -115,6 +116,9 @@ async function repl(yes, initialHistory) {
115
116
  // The REPL keeps conversation memory across its own lines (and any
116
117
  // resumed history); each line re-injects fresh memory/skills.
117
118
  let history = initialHistory ? [...initialHistory] : [];
119
+ // one rollout per conversation: the first task creates it, later lines and
120
+ // `hmh resume` sessions append to it (codex thread semantics)
121
+ let currentSessionId = initialSessionId;
118
122
  try {
119
123
  while (true) {
120
124
  let line;
@@ -214,6 +218,7 @@ async function repl(yes, initialHistory) {
214
218
  // line-mode twin of the TUI /clear: clear the conversation so the
215
219
  // next task starts fresh (REPL counterpart was missing)
216
220
  history = [];
221
+ currentSessionId = undefined;
217
222
  stdout.write(DIM(t.cmdClearDone) + '\n');
218
223
  continue;
219
224
  }
@@ -254,10 +259,12 @@ async function repl(yes, initialHistory) {
254
259
  continue;
255
260
  }
256
261
  try {
257
- const r = await runTask(line, { yes: autoApprove, sharedRl: rl, registry: reg, clients, resumeMessages: history });
262
+ const r = await runTask(line, { yes: autoApprove, sharedRl: rl, registry: reg, clients, resumeMessages: history, sessionId: currentSessionId });
258
263
  // working transcript = [system, ...resumeMessages, user, ...new turns];
259
264
  // only the NEW turns (past the replayed prefix) extend history.
260
265
  history = [...history, { role: 'user', content: line }, ...r.messages.slice(history.length + 2)];
266
+ // one rollout per REPL conversation (codex thread semantics)
267
+ currentSessionId = r.sessionId;
261
268
  }
262
269
  catch (err) {
263
270
  stdout.write(`error: ${String(err)}\n`);
@@ -328,7 +335,8 @@ async function main() {
328
335
  usage:
329
336
  hmh "do something" one-shot task (full agent loop, streaming)
330
337
  hmh interactive REPL (conversation memory kept, /help for commands)
331
- hmh resume [id-prefix] continue a past session by id prefix (or latest)
338
+ hmh resume [id-prefix|--last] continue a past session (bare = full-screen picker,
339
+ --last = newest session in this directory)
332
340
  hmh web start|stop|status web UI as a silent background daemon (no window,
333
341
  survives closing everything; log ~/.hmharness/web.log)
334
342
  hmh web [--port=7788] web UI in the foreground (debugging)
@@ -750,7 +758,23 @@ flags:
750
758
  }
751
759
  if (cmd === 'resume') {
752
760
  await initHome();
753
- const file = await latestSession(homeDir(), arg);
761
+ const home = homeDir();
762
+ // bare `hmh resume` on a TTY = codex `codex resume`: the TUI comes up
763
+ // with the full-frame picker already open (typeahead, cwd filter, sort)
764
+ if (!arg && !rest.includes('--last') && stdin.isTTY) {
765
+ const { tui } = await import("./tui.js");
766
+ await tui(yes, false, { resumeAtStart: true });
767
+ return;
768
+ }
769
+ // `--last`: newest rollout in THIS cwd (codex resume --last)
770
+ let file = null;
771
+ if (rest.includes('--last')) {
772
+ const page = await listSessions(home, { cwd: process.cwd(), limit: 1 });
773
+ file = page.items[0]?.file ?? null;
774
+ }
775
+ else {
776
+ file = await latestSession(home, arg);
777
+ }
754
778
  if (!file) {
755
779
  stdout.write(arg ? `No session matches prefix "${arg}".\n` : 'No sessions yet.\n');
756
780
  return;
@@ -761,7 +785,8 @@ flags:
761
785
  return;
762
786
  }
763
787
  stdout.write(DIM(`resuming ${tr.id} · ${tr.messages.length} messages · model ${tr.model}\n`));
764
- await repl(yes, tr.messages);
788
+ // repl keeps appending to THIS rollout for the whole conversation
789
+ await repl(yes, tr.messages, tr.id);
765
790
  return;
766
791
  }
767
792
  if (cmd && !cmd.startsWith('-')) {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Codex-style resume picker (transplant of codex-rs/tui/src/resume_picker.rs,
3
+ * trimmed to hmharness's surface). This module is the pure state machine +
4
+ * formatting; TuiRuntime owns rendering and key plumbing. Codex behaviors kept:
5
+ * - typeahead: case-insensitive substring across title/id/cwd/branch
6
+ * (codex Row::matches_query)
7
+ * - toolbar Filter[Cwd|All] x Sort[Updated|Created], Tab cycles focus,
8
+ * arrows change the focused value, changes trigger a reload
9
+ * (codex toggle_sort_key / SessionFilterMode)
10
+ * - Enter accepts, Esc clears the query first then closes, Backspace pops
11
+ * (codex handle_key)
12
+ * - paging: rows arrive page by page; moving within LOAD_NEAR_THRESHOLD of
13
+ * the end asks for the next page (codex maybe_load_more_for_scroll);
14
+ * a query that matches nothing loaded yet keeps searching forward
15
+ * (codex SearchState::Active)
16
+ */
17
+ import type { SessionSummary } from '@hmharness/kernel';
18
+ export type PickerSort = 'updated' | 'created';
19
+ /** 0 = filter control, 1 = sort control (codex toolbar Tab cycle) */
20
+ export type PickerFocus = 0 | 1;
21
+ export interface PickerState {
22
+ query: string;
23
+ rows: SessionSummary[];
24
+ selected: number;
25
+ sort: PickerSort;
26
+ cwdOnly: boolean;
27
+ focus: PickerFocus;
28
+ /** next listSessions cursor; null = scan complete */
29
+ nextCursor: string | null;
30
+ /** a (re)load is in flight - renders the loading markers */
31
+ loading: boolean;
32
+ /** true before the first page lands (initial vs empty-result states) */
33
+ initial: boolean;
34
+ }
35
+ export declare function initialPickerState(sort?: PickerSort, cwdOnly?: boolean): PickerState;
36
+ /** codex Row::matches_query: lowercase substring across every display field. */
37
+ export declare function matchesQuery(row: SessionSummary, q: string): boolean;
38
+ export declare function visibleRows(state: PickerState): SessionSummary[];
39
+ export type PickerEffect = {
40
+ kind: 'none';
41
+ } | {
42
+ kind: 'reload';
43
+ } | {
44
+ kind: 'load-more';
45
+ } | {
46
+ kind: 'search-more';
47
+ } | {
48
+ kind: 'accept';
49
+ row: SessionSummary;
50
+ } | {
51
+ kind: 'close';
52
+ };
53
+ /** Normalize a raw stdin chunk to the picker's key vocabulary; anything the
54
+ * picker does not own returns null so the caller leaves it to the runtime. */
55
+ export declare function pickerKey(data: string): string | null;
56
+ export declare function reducePicker(state: PickerState, key: string): {
57
+ state: PickerState;
58
+ effect: PickerEffect;
59
+ };
60
+ /** "2m" / "3h" / "4d" relative time for the row tail. */
61
+ export declare function relTime(iso: string, now?: number): string;
62
+ /** One list row: `❯ 2026-09-12T17-30 run a harmless command 2h`.
63
+ * The id prefix (filename timestamp - codex shows the same) is the anchor
64
+ * the eye scans by; the title truncates into the remaining width. */
65
+ export declare function formatRow(row: SessionSummary, selected: boolean, width: number): string;
66
+ /** The search line: `query_` plus the toolbar on the right
67
+ * (codex: `Filter:[Cwd] Sort:[Updated]`, focused control highlighted). */
68
+ export declare function toolbarLine(state: PickerState, labels: {
69
+ filter: string;
70
+ sort: string;
71
+ cwd: string;
72
+ all: string;
73
+ updated: string;
74
+ created: string;
75
+ }, width: number, paint: (s: string, on: boolean) => string): string;
@@ -0,0 +1,151 @@
1
+ const LOAD_NEAR = 5; // codex LOAD_NEAR_THRESHOLD
2
+ export function initialPickerState(sort = 'updated', cwdOnly = true) {
3
+ return { query: '', rows: [], selected: 0, sort, cwdOnly, focus: 0, nextCursor: null, loading: true, initial: true };
4
+ }
5
+ /** codex Row::matches_query: lowercase substring across every display field. */
6
+ export function matchesQuery(row, q) {
7
+ if (!q)
8
+ return true;
9
+ const needle = q.toLowerCase();
10
+ return [row.title, row.id, row.cwd, row.branch ?? ''].some((f) => f.toLowerCase().includes(needle));
11
+ }
12
+ export function visibleRows(state) {
13
+ return state.query ? state.rows.filter((r) => matchesQuery(r, state.query)) : state.rows;
14
+ }
15
+ /** Normalize a raw stdin chunk to the picker's key vocabulary; anything the
16
+ * picker does not own returns null so the caller leaves it to the runtime. */
17
+ export function pickerKey(data) {
18
+ if (/^\x1bO[A-H]$/.test(data))
19
+ data = '\x1b[' + data[2]; // SS3 arrows, same as TuiRuntime
20
+ if (data === '\x1b[A' || data === '\x1b[B')
21
+ return data === '\x1b[A' ? 'up' : 'down';
22
+ if (data === '\r')
23
+ return 'enter';
24
+ if (data === '\x1b')
25
+ return 'esc';
26
+ if (data === '\t')
27
+ return 'tab';
28
+ if (data === '\x1b[C')
29
+ return 'right';
30
+ if (data === '\x1b[D')
31
+ return 'left';
32
+ if (data === '\x7f' || data === '\b')
33
+ return 'backspace';
34
+ if (data === '\x03')
35
+ return 'ctrl-c';
36
+ // single printable char (no modifiers) feeds the typeahead
37
+ if (data.length === 1 && data >= ' ')
38
+ return 'char:' + data;
39
+ return null;
40
+ }
41
+ export function reducePicker(state, key) {
42
+ const next = { ...state, rows: state.rows };
43
+ if (key.startsWith('char:')) {
44
+ next.query += key.slice(5);
45
+ next.selected = 0;
46
+ const vis = visibleRows(next);
47
+ return {
48
+ state: next,
49
+ effect: vis.length === 0 && next.nextCursor ? { kind: 'search-more' } : { kind: 'none' },
50
+ };
51
+ }
52
+ switch (key) {
53
+ case 'backspace':
54
+ if (next.query)
55
+ next.query = next.query.slice(0, -1);
56
+ next.selected = 0;
57
+ return { state: next, effect: { kind: 'none' } };
58
+ case 'up': {
59
+ const vis = visibleRows(next);
60
+ if (vis.length === 0)
61
+ return { state: next, effect: { kind: 'none' } };
62
+ next.selected = Math.max(0, next.selected - 1);
63
+ return { state: next, effect: nearEnd(next) };
64
+ }
65
+ case 'down': {
66
+ const vis = visibleRows(next);
67
+ if (vis.length === 0)
68
+ return { state: next, effect: next.nextCursor ? { kind: 'search-more' } : { kind: 'none' } };
69
+ next.selected = Math.min(vis.length - 1, next.selected + 1);
70
+ return { state: next, effect: nearEnd(next) };
71
+ }
72
+ case 'tab':
73
+ next.focus = next.focus === 0 ? 1 : 0;
74
+ return { state: next, effect: { kind: 'none' } };
75
+ case 'left':
76
+ case 'right': {
77
+ if (next.focus === 0)
78
+ next.cwdOnly = key === 'left'; // left -> Cwd, right -> All
79
+ else
80
+ next.sort = next.sort === 'updated' ? 'created' : 'updated';
81
+ next.selected = 0;
82
+ next.rows = [];
83
+ next.nextCursor = null;
84
+ next.loading = true;
85
+ next.initial = true;
86
+ return { state: next, effect: { kind: 'reload' } };
87
+ }
88
+ case 'enter': {
89
+ const vis = visibleRows(next);
90
+ if (vis.length === 0)
91
+ return { state: next, effect: { kind: 'none' } };
92
+ return { state: next, effect: { kind: 'accept', row: vis[Math.min(next.selected, vis.length - 1)] } };
93
+ }
94
+ case 'esc':
95
+ // codex: query non-empty -> clear it; empty -> exit
96
+ if (next.query) {
97
+ next.query = '';
98
+ next.selected = 0;
99
+ return { state: next, effect: { kind: 'none' } };
100
+ }
101
+ return { state: next, effect: { kind: 'close' } };
102
+ case 'ctrl-c':
103
+ return { state: next, effect: { kind: 'close' } };
104
+ default:
105
+ return { state: next, effect: { kind: 'none' } };
106
+ }
107
+ }
108
+ function nearEnd(state) {
109
+ const vis = visibleRows(state);
110
+ if (state.nextCursor && !state.loading && state.selected >= vis.length - LOAD_NEAR)
111
+ return { kind: 'load-more' };
112
+ return { kind: 'none' };
113
+ }
114
+ /** "2m" / "3h" / "4d" relative time for the row tail. */
115
+ export function relTime(iso, now = Date.now()) {
116
+ const ms = now - Date.parse(iso);
117
+ if (!Number.isFinite(ms))
118
+ return '';
119
+ const m = Math.floor(ms / 60_000);
120
+ if (m < 1)
121
+ return 'now';
122
+ if (m < 60)
123
+ return m + 'm';
124
+ const h = Math.round(m / 60);
125
+ if (h < 24)
126
+ return h + 'h';
127
+ return Math.round(h / 24) + 'd';
128
+ }
129
+ /** One list row: `❯ 2026-09-12T17-30 run a harmless command 2h`.
130
+ * The id prefix (filename timestamp - codex shows the same) is the anchor
131
+ * the eye scans by; the title truncates into the remaining width. */
132
+ export function formatRow(row, selected, width) {
133
+ const marker = selected ? '❯ ' : ' ';
134
+ const id = row.id.slice(0, 16);
135
+ const title = (row.title || '').replace(/\s+/g, ' ').trim();
136
+ const ago = relTime(row.updatedAt);
137
+ const titleWidth = Math.max(8, width - marker.length - id.length - 2 - ago.length - 1);
138
+ const shown = title.length > titleWidth ? title.slice(0, titleWidth - 1) + '…' : title.padEnd(titleWidth);
139
+ return `${marker}${id} ${shown} ${ago}`;
140
+ }
141
+ /** The search line: `query_` plus the toolbar on the right
142
+ * (codex: `Filter:[Cwd] Sort:[Updated]`, focused control highlighted). */
143
+ export function toolbarLine(state, labels, width, paint) {
144
+ const f = `${labels.filter}:[${state.cwdOnly ? labels.cwd : labels.all}]`;
145
+ const s = `${labels.sort}:[${state.sort === 'updated' ? labels.updated : labels.created}]`;
146
+ const bar = `${f} ${s}`;
147
+ const room = Math.max(0, width - bar.length - 2);
148
+ const q = (state.query + '_').slice(0, room);
149
+ const pad = ' '.repeat(Math.max(1, room - q.length));
150
+ return q + pad + (state.focus === 0 ? paint(f, true) + ' ' + paint(s, false) : paint(f, false) + ' ' + paint(s, true));
151
+ }
package/dist/tui.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type SessionSummary } from '@hmharness/kernel';
1
2
  import { type Locale } from '@hmharness/agent';
2
3
  /** desc keys index into Strings (agent i18n); matched by name at runtime. */
3
4
  export declare const COMMANDS: Array<{
@@ -49,8 +50,6 @@ export declare class TuiRuntime {
49
50
  private modeTag;
50
51
  /** rows for the `/model ` picker (configured providers first, set by driver) */
51
52
  private modelChoices;
52
- /** rows for the `/resume ` session picker (id prefix + first user line) */
53
- private sessionChoices;
54
53
  /** Wheel/click handling: NO mouse reporting by default - select/copy
55
54
  * always works and terminals translate the wheel to arrow keys on the
56
55
  * alternate screen. Reporting turns on ONLY while a palette is open
@@ -68,16 +67,26 @@ export declare class TuiRuntime {
68
67
  name: string;
69
68
  desc: string;
70
69
  }>): void;
71
- setSessionChoices(list: Array<{
72
- name: string;
73
- desc: string;
74
- }>): void;
75
70
  /** Focus the /model picker (used by the bare `/model` command so the
76
71
  * printed list is never a dead end - the live palette opens on it). */
77
72
  openModelPicker(): void;
78
- /** Focus the /resume session picker - same live palette as /model:
79
- * arrows/wheel navigate, Enter loads the highlighted session. */
80
- openSessionPicker(): void;
73
+ /** While open the picker owns every key/wheel event and the whole frame -
74
+ * the codex alt-screen picker contract. resolve fires on Enter (resume)
75
+ * or Esc/Ctrl-C (close). */
76
+ private resumeModal;
77
+ openResumePicker(): Promise<{
78
+ kind: 'resume';
79
+ row: SessionSummary;
80
+ } | {
81
+ kind: 'close';
82
+ } | null>;
83
+ /** Fetch one listSessions page (reset=true restarts from page 1 after a
84
+ * toolbar change). Dedupes by id, drops stale responses by token. */
85
+ private loadPickerPage;
86
+ private pickerInput;
87
+ /** Full-frame picker layout (codex draw_picker): title / search+toolbar /
88
+ * list window / two hint lines / position footer. */
89
+ private renderResumePicker;
81
90
  /** Run the highlighted palette row (shared by Enter and palette clicks);
82
91
  * with no palette open it just submits the typed input. */
83
92
  private pickHighlighted;
@@ -101,9 +110,10 @@ export declare class TuiRuntime {
101
110
  * while it shows, clicks choose its rows and the wheel drives its
102
111
  * selection; drag-select resumes the moment it closes. */
103
112
  private syncMouseReporting;
104
- /** The palette data source: `/model ` opens the model picker, `/resume `
105
- * the session picker, otherwise slash commands. Rows are {name, desc} so
106
- * all three share one renderer, keyboard and click machinery. */
113
+ /** The palette data source: `/model ` opens the model picker, otherwise
114
+ * slash commands. (/resume submits straight through to the driver, which
115
+ * opens the Codex-style full-frame picker - resume-picker.ts.) Rows are
116
+ * {name, desc} so both share one renderer, keyboard and click machinery. */
107
117
  private panelItems;
108
118
  configure(model: string, cwdName: string, skillCount: number, locale: Locale, version?: string): void;
109
119
  destroy(): void;
@@ -129,10 +139,8 @@ export declare class TuiRuntime {
129
139
  private totalLines;
130
140
  private onKey;
131
141
  render(): void;
142
+ private flushFrame;
132
143
  }
133
- /** First user line of a session file WITHOUT a full parse: peek the first
134
- * 64KB, scan lines for the first user event. With hundreds of sessions a
135
- * full loadTranscript per row would stall the picker for seconds; the user
136
- * event is always near the head, so the peek is O(64KB) per session. */
137
- export declare function firstUserLinePeek(file: string): Promise<string>;
138
- export declare function tui(yes: boolean, noWeb?: boolean): Promise<void>;
144
+ export declare function tui(yes: boolean, noWeb?: boolean, opts?: {
145
+ resumeAtStart?: boolean;
146
+ }): Promise<void>;
package/dist/tui.js CHANGED
@@ -11,13 +11,13 @@
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, join } from 'node:path';
14
+ import { basename } from 'node:path';
15
15
  import { createRequire } from 'node:module';
16
- import { open as fopen } from 'node:fs/promises';
17
- import { loadConfig, homeDir, resolveProvider, listProviders, setChatRoute, setLocale, PROVIDER_PRESETS, addProviders, detectLocalProviders } from '@hmharness/kernel';
16
+ import { loadConfig, homeDir, resolveProvider, listProviders, setChatRoute, setLocale, PROVIDER_PRESETS, addProviders, detectLocalProviders, latestSession, listSessions, loadTranscript } from '@hmharness/kernel';
18
17
  import { listDrafts, listSkills, runBench, runEvolution } from '@hmharness/evolution';
19
18
  import { buildRegistry, runAgentTask, strings } from '@hmharness/agent';
20
19
  import { ensureWebDaemon, DEFAULT_WEB_PORT } from "./web-daemon.js";
20
+ import { formatRow, initialPickerState, pickerKey, reducePicker, toolbarLine, visibleRows } from "./resume-picker.js";
21
21
  /** installed version, shown in the TUI header (v0.4.0) so users always
22
22
  * know which build they are talking to - resolves in both src/ and dist/ */
23
23
  const HMH_VERSION = (() => {
@@ -171,8 +171,6 @@ export class TuiRuntime {
171
171
  modeTag = '';
172
172
  /** rows for the `/model ` picker (configured providers first, set by driver) */
173
173
  modelChoices = [];
174
- /** rows for the `/resume ` session picker (id prefix + first user line) */
175
- sessionChoices = [];
176
174
  /** Wheel/click handling: NO mouse reporting by default - select/copy
177
175
  * always works and terminals translate the wheel to arrow keys on the
178
176
  * alternate screen. Reporting turns on ONLY while a palette is open
@@ -201,10 +199,6 @@ export class TuiRuntime {
201
199
  this.modelChoices = list;
202
200
  this.dirty = true;
203
201
  }
204
- setSessionChoices(list) {
205
- this.sessionChoices = list;
206
- this.dirty = true;
207
- }
208
202
  /** Focus the /model picker (used by the bare `/model` command so the
209
203
  * printed list is never a dead end - the live palette opens on it). */
210
204
  openModelPicker() {
@@ -213,14 +207,115 @@ export class TuiRuntime {
213
207
  this.cmdIdx = 0;
214
208
  this.dirty = true;
215
209
  }
216
- /** Focus the /resume session picker - same live palette as /model:
217
- * arrows/wheel navigate, Enter loads the highlighted session. */
218
- openSessionPicker() {
219
- this.input = '/resume ';
220
- this.caret = this.input.length;
221
- this.cmdIdx = 0;
210
+ /* ---------------- Codex-style resume picker modal ---------------- */
211
+ /** While open the picker owns every key/wheel event and the whole frame -
212
+ * the codex alt-screen picker contract. resolve fires on Enter (resume)
213
+ * or Esc/Ctrl-C (close). */
214
+ resumeModal = null;
215
+ openResumePicker() {
216
+ if (this.resumeModal)
217
+ return Promise.resolve(null);
218
+ return new Promise((resolve) => {
219
+ this.resumeModal = { st: initialPickerState('updated', true), resolve, token: 0 };
220
+ this.dirty = true;
221
+ void this.loadPickerPage(undefined, true);
222
+ });
223
+ }
224
+ /** Fetch one listSessions page (reset=true restarts from page 1 after a
225
+ * toolbar change). Dedupes by id, drops stale responses by token. */
226
+ async loadPickerPage(cursor, reset) {
227
+ const modal = this.resumeModal;
228
+ if (!modal)
229
+ return;
230
+ const token = ++modal.token;
231
+ const st0 = reset
232
+ ? { ...modal.st, rows: [], selected: 0, nextCursor: null, loading: true, initial: true }
233
+ : { ...modal.st, loading: true };
234
+ modal.st = st0;
235
+ this.dirty = true;
236
+ let page;
237
+ try {
238
+ page = await listSessions(homeDir(), {
239
+ ...(reset ? {} : { cursor }),
240
+ sort: st0.sort,
241
+ cwd: st0.cwdOnly ? process.cwd() : null,
242
+ });
243
+ }
244
+ catch {
245
+ page = { items: [], nextCursor: null, numScanned: 0, reachedScanCap: false };
246
+ }
247
+ const live = this.resumeModal;
248
+ if (!live || live.token !== token)
249
+ return;
250
+ const seen = new Set(reset ? [] : live.st.rows.map((r) => r.id));
251
+ live.st = {
252
+ ...live.st,
253
+ rows: [...live.st.rows, ...page.items.filter((i) => !seen.has(i.id))],
254
+ nextCursor: page.nextCursor,
255
+ loading: false,
256
+ initial: false,
257
+ };
222
258
  this.dirty = true;
223
259
  }
260
+ pickerInput(key) {
261
+ const modal = this.resumeModal;
262
+ if (!modal)
263
+ return;
264
+ const { state, effect } = reducePicker(modal.st, key);
265
+ modal.st = state;
266
+ this.dirty = true;
267
+ if (effect.kind === 'accept') {
268
+ this.resumeModal = null;
269
+ modal.resolve({ kind: 'resume', row: effect.row });
270
+ }
271
+ else if (effect.kind === 'close') {
272
+ this.resumeModal = null;
273
+ modal.resolve({ kind: 'close' });
274
+ }
275
+ else if (effect.kind === 'reload') {
276
+ void this.loadPickerPage(undefined, true);
277
+ }
278
+ else if ((effect.kind === 'load-more' || effect.kind === 'search-more') && !state.loading && state.nextCursor) {
279
+ void this.loadPickerPage(state.nextCursor, false);
280
+ }
281
+ }
282
+ /** Full-frame picker layout (codex draw_picker): title / search+toolbar /
283
+ * list window / two hint lines / position footer. */
284
+ renderResumePicker(frame, W, H) {
285
+ const st = this.resumeModal.st;
286
+ const t = this.t;
287
+ const count = ` ${st.rows.length}${st.loading ? '…' : ''} `;
288
+ frame.push(truncateTo(BOLD(' ' + t.pickerTitle) + ' '.repeat(Math.max(1, W - strWidth(t.pickerTitle) - count.length - 1)) + DIM(count), W));
289
+ frame.push(DIM('─'.repeat(W)));
290
+ frame.push(' ' + toolbarLine(st, {
291
+ filter: t.pickerLabelFilter, sort: t.pickerLabelSort, cwd: t.pickerValCwd, all: t.pickerValAll, updated: t.pickerValUpdated, created: t.pickerValCreated,
292
+ }, W - 2, (s, on) => (on ? CYAN(BOLD(s)) : DIM(s))));
293
+ frame.push(DIM('─'.repeat(W)));
294
+ const vis = visibleRows(st);
295
+ const listH = Math.max(3, H - 8);
296
+ const from = vis.length > listH
297
+ ? Math.min(Math.max(0, st.selected - Math.floor(listH / 2)), vis.length - listH)
298
+ : 0;
299
+ if (from > 0)
300
+ frame.push(DIM(' ↑ more'));
301
+ for (let i = 0; i < listH && from + i < vis.length; i++) {
302
+ const sel = from + i === st.selected;
303
+ const plain = truncateTo(formatRow(vis[from + i], sel, W - 1), W - 1);
304
+ frame.push(sel ? '\x1b[7m' + plain + ' '.repeat(Math.max(0, W - 1 - strWidth(plain))) + '\x1b[27m' : ' ' + plain);
305
+ }
306
+ if (st.loading)
307
+ frame.push(DIM(' ' + (vis.length === 0 ? t.pickerLoading : t.pickerMore)));
308
+ else if (vis.length === 0)
309
+ frame.push(DIM(' ' + (st.query ? t.pickerNoMatch : t.pickerEmpty)));
310
+ else if (from + listH < vis.length)
311
+ frame.push(DIM(' ↓ more'));
312
+ frame.push(DIM('─'.repeat(W)));
313
+ frame.push(DIM(truncateTo(' ' + t.pickerHint1, W - 1)));
314
+ frame.push(DIM(truncateTo(' ' + t.pickerHint2, W - 1)));
315
+ const pos = vis.length ? st.selected + 1 : 0;
316
+ const posLine = t.pickerPos(pos, vis.length, vis.length ? String(Math.round((pos / vis.length) * 100)) : '0');
317
+ frame.push(' '.repeat(Math.max(0, W - posLine.length - 1)) + DIM(posLine));
318
+ }
224
319
  /** Run the highlighted palette row (shared by Enter and palette clicks);
225
320
  * with no palette open it just submits the typed input. */
226
321
  pickHighlighted() {
@@ -229,8 +324,6 @@ export class TuiRuntime {
229
324
  if (pick) {
230
325
  if (this.input.startsWith('/model'))
231
326
  this.input = `/model ${pick} `;
232
- else if (this.input.startsWith('/resume'))
233
- this.input = `/resume ${pick}`;
234
327
  else
235
328
  this.input = pick + ' ';
236
329
  this.caret = this.input.length;
@@ -294,9 +387,10 @@ export class TuiRuntime {
294
387
  this.mouseReported = want;
295
388
  stdout.write(want ? '\x1b[?1000h\x1b[?1006h' : '\x1b[?1000l\x1b[?1006l');
296
389
  }
297
- /** The palette data source: `/model ` opens the model picker, `/resume `
298
- * the session picker, otherwise slash commands. Rows are {name, desc} so
299
- * all three share one renderer, keyboard and click machinery. */
390
+ /** The palette data source: `/model ` opens the model picker, otherwise
391
+ * slash commands. (/resume submits straight through to the driver, which
392
+ * opens the Codex-style full-frame picker - resume-picker.ts.) Rows are
393
+ * {name, desc} so both share one renderer, keyboard and click machinery. */
300
394
  panelItems(input) {
301
395
  if (input === '/model' || input.startsWith('/model ')) {
302
396
  const q = input.slice(6).trim().toLowerCase();
@@ -307,10 +401,6 @@ export class TuiRuntime {
307
401
  const all = [...configured, ...rest];
308
402
  return q ? all.filter((i) => i.name.toLowerCase().startsWith(q)) : all;
309
403
  }
310
- if (input === '/resume' || input.startsWith('/resume ')) {
311
- const q = input.slice(7).trim().toLowerCase();
312
- return q ? this.sessionChoices.filter((i) => i.name.toLowerCase().startsWith(q)) : this.sessionChoices;
313
- }
314
404
  return matchCommands(input).map((c) => ({ name: c.name, desc: String(this.t[c.key]) }));
315
405
  }
316
406
  configure(model, cwdName, skillCount, locale, version) {
@@ -337,6 +427,12 @@ export class TuiRuntime {
337
427
  }
338
428
  quit() {
339
429
  this.running = false;
430
+ // an open picker never resolves on its own once the TUI exits
431
+ if (this.resumeModal) {
432
+ const resolve = this.resumeModal.resolve;
433
+ this.resumeModal = null;
434
+ resolve({ kind: 'close' });
435
+ }
340
436
  this.exitResolve?.();
341
437
  }
342
438
  /* ---------------- content API ---------------- */
@@ -471,6 +567,15 @@ export class TuiRuntime {
471
567
  // matches so navigation never silently dies
472
568
  if (/^\x1bO[A-H]$/.test(data))
473
569
  data = '\x1b[' + data[2];
570
+ // resume picker modal owns every event while open - keyboard AND wheel
571
+ // (codex: the picker runs its own event loop until it resolves)
572
+ if (this.resumeModal) {
573
+ const wheel = parseWheel(data);
574
+ const key = wheel !== 0 ? (wheel < 0 ? 'up' : 'down') : pickerKey(data);
575
+ if (key !== null)
576
+ this.pickerInput(key);
577
+ return;
578
+ }
474
579
  // wheel-only mouse routing: 64 = wheel-up, 65 = wheel-down. With
475
580
  // button-event mode (1002) everything else - click, drag, release,
476
581
  // motion - still belongs to the terminal's native selection.
@@ -546,16 +651,12 @@ export class TuiRuntime {
546
651
  // Claude Code's two-stage flow: Enter shows the dialog, arrows/wheel
547
652
  // move, a second Enter confirms the highlighted row. The old behavior
548
653
  // silently switched to the first model on the very first Enter.
654
+ // (/resume needs no interception: submitting it opens the Codex-style
655
+ // modal via the driver, and '/resume <prefix>' loads directly.)
549
656
  if (this.input === '/model') {
550
657
  this.openModelPicker();
551
658
  return;
552
659
  }
553
- // same two-stage rule for /resume: bare command + Enter opens the
554
- // session picker (focus moves to the list), never loads row 0 blindly
555
- if (this.input === '/resume') {
556
- this.openSessionPicker();
557
- return;
558
- }
559
660
  // palette open: Enter runs the highlighted row (a command, or a
560
661
  // /model target), not the raw input; without a palette it submits
561
662
  this.pickHighlighted();
@@ -566,9 +667,7 @@ export class TuiRuntime {
566
667
  if (hits.length) {
567
668
  this.input = this.input.startsWith('/model')
568
669
  ? `/model ${hits[Math.min(this.cmdIdx, hits.length - 1)].name} `
569
- : this.input.startsWith('/resume')
570
- ? `/resume ${hits[Math.min(this.cmdIdx, hits.length - 1)].name}`
571
- : hits[Math.min(this.cmdIdx, hits.length - 1)].name + ' ';
670
+ : hits[Math.min(this.cmdIdx, hits.length - 1)].name + ' ';
572
671
  this.caret = this.input.length;
573
672
  this.cmdIdx = 0;
574
673
  this.dirty = true;
@@ -695,6 +794,11 @@ export class TuiRuntime {
695
794
  const W = stdout.columns || 100;
696
795
  const H = stdout.rows || 30;
697
796
  const frame = [];
797
+ if (this.resumeModal) {
798
+ this.renderResumePicker(frame, W, H);
799
+ this.flushFrame(frame, H);
800
+ return;
801
+ }
698
802
  // modal mouse reporting follows the palette (see syncMouseReporting);
699
803
  // click rows are re-recorded every frame because screen positions move
700
804
  this.syncMouseReporting();
@@ -882,6 +986,9 @@ export class TuiRuntime {
882
986
  // pending wrap a full-width line leaves behind — "\x1b[B" joins would skip
883
987
  // a row on immediate-wrap terminals (conhost) and push the frame past the
884
988
  // last line, scrolling the header away and clipping the input box.
989
+ this.flushFrame(frame, H);
990
+ }
991
+ flushFrame(frame, H) {
885
992
  const visible = frame.slice(0, H);
886
993
  let out = '';
887
994
  for (let i = 0; i < visible.length; i++)
@@ -890,35 +997,7 @@ export class TuiRuntime {
890
997
  }
891
998
  }
892
999
  /* ---------------- driver ---------------- */
893
- /** First user line of a session file WITHOUT a full parse: peek the first
894
- * 64KB, scan lines for the first user event. With hundreds of sessions a
895
- * full loadTranscript per row would stall the picker for seconds; the user
896
- * event is always near the head, so the peek is O(64KB) per session. */
897
- export async function firstUserLinePeek(file) {
898
- try {
899
- const fh = await fopen(file, 'r');
900
- try {
901
- const buf = Buffer.alloc(65_536);
902
- const { bytesRead } = await fh.read(buf, 0, 65_536, 0);
903
- for (const line of buf.toString('utf8', 0, bytesRead).split('\n')) {
904
- if (!line.includes('"user"'))
905
- continue;
906
- try {
907
- const ev = JSON.parse(line);
908
- if (ev.t === 'user' && typeof ev.text === 'string')
909
- return ev.text;
910
- }
911
- catch { /* partial line at the buffer edge - no preview */ }
912
- }
913
- }
914
- finally {
915
- await fh.close();
916
- }
917
- }
918
- catch { /* unreadable file - no preview */ }
919
- return '';
920
- }
921
- export async function tui(yes, noWeb = false) {
1000
+ export async function tui(yes, noWeb = false, opts = {}) {
922
1001
  let cfg = await loadConfig();
923
1002
  let autoApprove = yes || cfg.approval === 'auto';
924
1003
  if (!stdin.isTTY) {
@@ -950,6 +1029,16 @@ export async function tui(yes, noWeb = false) {
950
1029
  void notifyUpdate(home, current, (latest) => rt.addText(`↑ ${t.updateHint(latest)}`, 'dim'));
951
1030
  }
952
1031
  let history = [];
1032
+ // Codex thread semantics: one rollout file per conversation. The first task
1033
+ // creates it; every later turn (and everything after /resume) appends to it.
1034
+ let currentSessionId;
1035
+ // `hmh resume` startup: the picker comes up before the first prompt (codex
1036
+ // `codex resume` behavior); Esc leaves a fresh conversation
1037
+ if (opts.resumeAtStart) {
1038
+ const pick = await rt.openResumePicker();
1039
+ if (pick?.kind === 'resume')
1040
+ await resumeInto(pick.row.file);
1041
+ }
953
1042
  // Task queue: new submissions during a running task are queued (not
954
1043
  // rejected, not run concurrently — sequential execution preserves history
955
1044
  // integrity). Slash commands still run immediately (they're quick).
@@ -1008,6 +1097,7 @@ export async function tui(yes, noWeb = false) {
1008
1097
  cfg,
1009
1098
  yes: autoApprove,
1010
1099
  resumeMessages: history,
1100
+ sessionId: currentSessionId,
1011
1101
  signal: currentAbort.signal,
1012
1102
  approvalAsk: (name, args) => rt.requestApproval(name, args),
1013
1103
  events: {
@@ -1047,6 +1137,7 @@ export async function tui(yes, noWeb = false) {
1047
1137
  },
1048
1138
  });
1049
1139
  rt.setBusy(false);
1140
+ currentSessionId = result.sessionId;
1050
1141
  rt.setStatus(`↑${result.usage.promptTokens} ↓${result.usage.completionTokens} tok · ${result.turns} turns · ${result.toolUses} tools`);
1051
1142
  history = [...history, { role: 'user', content: line }, ...result.messages.slice(history.length + 2)];
1052
1143
  }
@@ -1058,6 +1149,49 @@ export async function tui(yes, noWeb = false) {
1058
1149
  currentAbort = null;
1059
1150
  }
1060
1151
  }
1152
+ /** Load a rollout into the conversation: `history` for the model, a tail
1153
+ * window for the eye, and currentSessionId so the next turn APPENDS to
1154
+ * the same rollout (codex Resume semantics - one thread, one file). */
1155
+ async function resumeInto(file) {
1156
+ const tr = await loadTranscript(file);
1157
+ if (!tr || tr.messages.length === 0) {
1158
+ rt.addText(t.cmdResumeNotFound(tr?.id ?? file), 'err');
1159
+ return;
1160
+ }
1161
+ history = tr.messages;
1162
+ currentSessionId = tr.id;
1163
+ rt.clearScreen();
1164
+ // long sessions render from the tail so the visible window stays usable
1165
+ // while the complete transcript lives in `history` for the model
1166
+ const MAX_RENDER = 80;
1167
+ const msgs = tr.messages;
1168
+ const skipped = Math.max(0, msgs.length - MAX_RENDER);
1169
+ rt.addText('--- resumed ' + tr.id + ' · ' + msgs.length + ' messages'
1170
+ + (skipped > 0 ? ' (' + skipped + ' earlier kept in context, not shown)' : '')
1171
+ + ' ---', 'dim');
1172
+ for (const m of (skipped > 0 ? msgs.slice(skipped) : msgs)) {
1173
+ const text = typeof m.content === 'string' ? m.content : '';
1174
+ if (m.role === 'user') {
1175
+ rt.addUser(text.replace(/\n+/g, ' ').slice(0, 400));
1176
+ }
1177
+ else if (m.role === 'assistant') {
1178
+ const calls = m.tool_calls ?? [];
1179
+ if (text.trim())
1180
+ rt.addText(text.slice(0, 2000));
1181
+ for (const c of calls)
1182
+ rt.addText('● ' + CYAN(String(c.function?.name ?? 'tool')) + DIM(' …'), 'dim');
1183
+ }
1184
+ else if (m.role === 'tool') {
1185
+ const first = text.split('\n').find((l) => l.trim()) ?? '';
1186
+ if (first)
1187
+ rt.addText(' ' + DIM('⎿ ' + first.trim().slice(0, 100)), 'dim');
1188
+ }
1189
+ else if (m.role === 'system' && text && !text.startsWith('[context pruned')) {
1190
+ rt.addText(DIM(text.slice(0, 300)), 'dim');
1191
+ }
1192
+ }
1193
+ rt.addText(t.cmdResumeLoaded(tr.messages.length), 'dim');
1194
+ }
1061
1195
  async function handleLine(line) {
1062
1196
  if (line === '/exit' || line === '/quit') {
1063
1197
  rt.destroy();
@@ -1087,8 +1221,12 @@ export async function tui(yes, noWeb = false) {
1087
1221
  rt.addText(COMMANDS.map((c) => ' ' + c.name.padEnd(11) + ' ' + String(t[c.key])).join('\n'), 'dim');
1088
1222
  return;
1089
1223
  }
1224
+ // /clear = new thread (codex /new): blank screen, drop the in-memory
1225
+ // transcript AND start a fresh rollout on the next task
1090
1226
  if (line === '/clear') {
1091
1227
  rt.clearScreen();
1228
+ history = [];
1229
+ currentSessionId = undefined;
1092
1230
  return;
1093
1231
  }
1094
1232
  if (line === '/status') {
@@ -1123,76 +1261,26 @@ export async function tui(yes, noWeb = false) {
1123
1261
  }
1124
1262
  if (line === '/resume' || line.startsWith('/resume ')) {
1125
1263
  const arg = line.slice(8).trim();
1126
- const { latestSession, loadTranscript } = await import('@hmharness/kernel');
1127
- const { readdir } = await import('node:fs/promises');
1128
- // ALL sessions, newest first - no arbitrary "recent 8" cap (user
1129
- // challenge: "不应该是所有历史会话吗"). Previews use the 64KB peek,
1130
- // never a full parse, so hundreds of rows still open instantly; the
1131
- // palette scrolls and head-prefix filtering narrows fast.
1132
- let files = [];
1133
- try {
1134
- files = (await readdir(join(home, 'sessions'))).filter((f) => f.endsWith('.jsonl'));
1135
- }
1136
- catch { /* none */ }
1137
- files.sort();
1138
- const recent = files.reverse();
1139
- const rows = [];
1140
- for (const f of recent) {
1141
- const firstUser = await firstUserLinePeek(join(home, 'sessions', f));
1142
- rows.push({ name: f.slice(0, 18), desc: (firstUser || '(无预览)').replace(/\n/g, ' ').slice(0, 56) });
1143
- }
1144
- rt.setSessionChoices(rows);
1264
+ // bare /resume opens the Codex-style full-frame picker (typeahead,
1265
+ // cwd filter, sort toolbar, lazy pages). It is a modal: it cannot share
1266
+ // the screen with a running task's streaming output.
1145
1267
  if (!arg) {
1146
- // bare /resume opens the LIVE picker - same arrows/wheel/Enter/click
1147
- // machinery as /model; a printed text list is a dead end (user-
1148
- // reported: "上下键无法选择")
1149
- if (rows.length === 0) {
1150
- rt.addText(t.cmdResumeNone, 'dim');
1268
+ if (taskRunning) {
1269
+ rt.addText('task running - stop it first (empty Enter), then /resume', 'dim');
1151
1270
  return;
1152
1271
  }
1153
- rt.openSessionPicker();
1272
+ const pick = await rt.openResumePicker();
1273
+ if (!pick || pick.kind !== 'resume')
1274
+ return;
1275
+ await resumeInto(pick.row.file);
1154
1276
  return;
1155
1277
  }
1156
1278
  const file = await latestSession(home, arg);
1157
- const tr = file ? await loadTranscript(file) : null;
1158
- if (!tr || tr.messages.length === 0) {
1279
+ if (!file) {
1159
1280
  rt.addText(t.cmdResumeNotFound(arg), 'err');
1160
1281
  return;
1161
1282
  }
1162
- history = tr.messages;
1163
- rt.clearScreen();
1164
- // Render the FULL session window, not just the first line (user
1165
- // feedback: "恢复的应该是整个会话窗口,而不仅仅是片段"). Long sessions
1166
- // are bounded from the tail so the visible window stays usable while
1167
- // the complete transcript still lives in `history` for the model.
1168
- const MAX_RENDER = 80;
1169
- const msgs = tr.messages;
1170
- const skipped = Math.max(0, msgs.length - MAX_RENDER);
1171
- rt.addText('--- resumed ' + (tr.id || arg) + ' · ' + msgs.length + ' messages'
1172
- + (skipped > 0 ? ' (' + skipped + ' earlier kept in context, not shown)' : '')
1173
- + ' ---', 'dim');
1174
- for (const m of (skipped > 0 ? msgs.slice(skipped) : msgs)) {
1175
- const text = typeof m.content === 'string' ? m.content : '';
1176
- if (m.role === 'user') {
1177
- rt.addUser(text.replace(/\n+/g, ' ').slice(0, 400));
1178
- }
1179
- else if (m.role === 'assistant') {
1180
- const calls = m.tool_calls ?? [];
1181
- if (text.trim())
1182
- rt.addText(text.slice(0, 2000));
1183
- for (const c of calls)
1184
- rt.addText('● ' + CYAN(String(c.function?.name ?? 'tool')) + DIM(' …'), 'dim');
1185
- }
1186
- else if (m.role === 'tool') {
1187
- const first = text.split('\n').find((l) => l.trim()) ?? '';
1188
- if (first)
1189
- rt.addText(' ' + DIM('⎿ ' + first.trim().slice(0, 100)), 'dim');
1190
- }
1191
- else if (m.role === 'system' && text && !text.startsWith('[context pruned')) {
1192
- rt.addText(DIM(text.slice(0, 300)), 'dim');
1193
- }
1194
- }
1195
- rt.addText(t.cmdResumeLoaded(tr.messages.length), 'dim');
1283
+ await resumeInto(file);
1196
1284
  return;
1197
1285
  }
1198
1286
  if (line === '/yolo' || line === '/yolo on' || line === '/yolo off') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.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,13 +43,13 @@
43
43
  "build": "tsc -p tsconfig.build.json"
44
44
  },
45
45
  "dependencies": {
46
- "@hmharness/kernel": "0.8.0",
46
+ "@hmharness/kernel": "0.8.2",
47
47
  "@hmharness/observability": "0.7.0",
48
- "@hmharness/evolution": "0.8.1",
48
+ "@hmharness/evolution": "0.8.3",
49
49
  "@hmharness/domain-harmony": "0.8.0",
50
50
  "@hmharness/domain-ops": "0.8.0",
51
- "@hmharness/agent": "0.8.1",
51
+ "@hmharness/agent": "0.8.2",
52
52
  "@hmharness/evaluation": "0.8.0",
53
- "@hmharness/web": "0.8.0"
53
+ "@hmharness/web": "0.8.2"
54
54
  }
55
55
  }