hostwares-cli 2.4.1 → 2.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.
@@ -1,144 +0,0 @@
1
- import { createHash } from "crypto";
2
- import { join } from "path";
3
- import { existsSync, readFileSync, readdirSync, unlinkSync, mkdirSync } from "fs";
4
- import { HW_DIR, writeJsonSecure } from "../config.js";
5
- /**
6
- * Per-project session state: which server conversation this directory is
7
- * talking to, and a local record of the exchange.
8
- *
9
- * The bug this replaces was severe and silent. The old filename was:
10
- *
11
- * Buffer.from(cwd).toString("base64url").slice(0, 20)
12
- *
13
- * described in a comment as "hash the cwd". It is not a hash - it is base64 of
14
- * the path truncated to 20 characters, which is the first FIFTEEN BYTES of the
15
- * absolute path. Every directory under `/Users/<name>/` encodes to the same
16
- * filename. Verified on the reporting machine: one session file existed for
17
- * every project, its stored `cwd` was `/Users/<name>`, and each project
18
- * silently inherited and then overwrote the previous one's conversation.
19
- * `loadSession` never compared the stored cwd to the actual one, so nothing
20
- * ever surfaced the mismatch.
21
- *
22
- * Now: sha256 of the path, and the stored cwd is verified on load.
23
- */
24
- const SESSION_DIR = join(HW_DIR, "sessions");
25
- /** Bumped when the on-disk shape changes; older files are ignored, not crashed on. */
26
- const SCHEMA_VERSION = 2;
27
- function sessionPath(cwd) {
28
- const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 24);
29
- return join(SESSION_DIR, `${hash}.json`);
30
- }
31
- export function newSession(cwd = process.cwd()) {
32
- const now = new Date().toISOString();
33
- return {
34
- version: SCHEMA_VERSION,
35
- cwd,
36
- conversationId: null,
37
- createdAt: now,
38
- updatedAt: now,
39
- turns: [],
40
- creditsSpent: 0,
41
- };
42
- }
43
- /**
44
- * Load this directory's session, or null.
45
- *
46
- * Returns null rather than throwing for every failure mode - a corrupt or
47
- * foreign session file must degrade to "start fresh", never to a crash on
48
- * startup.
49
- *
50
- * Note what is NOT here: the old loader scanned past assistant messages for the
51
- * substrings "can't access your local", "outside my scope" and "out of scope",
52
- * and silently DELETED the entire session if it found any, printing
53
- * "↻ Fresh session (cleared stale context)". It was a workaround for a model
54
- * that used to refuse local access; the model no longer does. It also fired on
55
- * any legitimate discussion of those phrases, and it destroyed history with no
56
- * backup - which is a large part of why the CLI appeared to remember nothing.
57
- */
58
- export function loadSession(cwd = process.cwd()) {
59
- const file = sessionPath(cwd);
60
- if (!existsSync(file))
61
- return null;
62
- try {
63
- const data = JSON.parse(readFileSync(file, "utf8"));
64
- if (data.version !== SCHEMA_VERSION)
65
- return null;
66
- // The guard the old code lacked. With a correct hash this should never
67
- // fire, but a stale file from a renamed directory would otherwise resume
68
- // someone else's conversation into this project.
69
- if (data.cwd !== cwd)
70
- return null;
71
- if (!Array.isArray(data.turns))
72
- return null;
73
- return {
74
- version: SCHEMA_VERSION,
75
- cwd,
76
- conversationId: typeof data.conversationId === "string" ? data.conversationId : null,
77
- createdAt: data.createdAt ?? new Date().toISOString(),
78
- updatedAt: data.updatedAt ?? new Date().toISOString(),
79
- turns: data.turns.filter(isTurn),
80
- creditsSpent: typeof data.creditsSpent === "number" ? data.creditsSpent : 0,
81
- };
82
- }
83
- catch {
84
- return null;
85
- }
86
- }
87
- function isTurn(t) {
88
- return Boolean(t) && typeof t === "object"
89
- && typeof t.content === "string"
90
- && (t.role === "user" || t.role === "assistant");
91
- }
92
- /** How many turns to keep locally. The server holds the authoritative transcript. */
93
- const MAX_LOCAL_TURNS = 200;
94
- export function saveSession(session) {
95
- mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
96
- session.updatedAt = new Date().toISOString();
97
- if (session.turns.length > MAX_LOCAL_TURNS) {
98
- session.turns = session.turns.slice(-MAX_LOCAL_TURNS);
99
- }
100
- // Atomic + 0600: session files carry conversation text, and a half-written
101
- // one loses the session.
102
- writeJsonSecure(sessionPath(session.cwd), session);
103
- }
104
- /**
105
- * Append a turn.
106
- *
107
- * The timestamp is set HERE, once, when the turn is recorded. The old
108
- * autoSave() rewrote every entry with `new Date().toISOString()` on every save,
109
- * so the whole history collapsed to the time of the last write and the record
110
- * of when anything happened was destroyed.
111
- */
112
- export function appendTurn(session, role, content) {
113
- session.turns.push({ role, content, at: new Date().toISOString() });
114
- }
115
- export function deleteSession(cwd = process.cwd()) {
116
- try {
117
- unlinkSync(sessionPath(cwd));
118
- }
119
- catch { /* already gone */ }
120
- }
121
- /** Every session on this machine, newest first. Powers `hw sessions`. */
122
- export function listSessions() {
123
- if (!existsSync(SESSION_DIR))
124
- return [];
125
- const out = [];
126
- for (const name of readdirSync(SESSION_DIR)) {
127
- if (!name.endsWith(".json"))
128
- continue;
129
- try {
130
- const data = JSON.parse(readFileSync(join(SESSION_DIR, name), "utf8"));
131
- if (data.version !== SCHEMA_VERSION || !data.cwd)
132
- continue;
133
- out.push({
134
- cwd: data.cwd,
135
- conversationId: data.conversationId ?? null,
136
- updatedAt: data.updatedAt,
137
- turnCount: Array.isArray(data.turns) ? data.turns.length : 0,
138
- creditsSpent: data.creditsSpent ?? 0,
139
- });
140
- }
141
- catch { /* skip unreadable */ }
142
- }
143
- return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
144
- }
package/dist/ui/input.js DELETED
@@ -1,242 +0,0 @@
1
- import { createInterface } from "readline";
2
- import { c } from "./theme.js";
3
- /**
4
- * Single owner of stdin.
5
- *
6
- * This module exists because of one bug and a family of bugs behind it. The
7
- * permission prompt used to read a keypress like this:
8
- *
9
- * stdin.setRawMode(true);
10
- * stdin.once("data", d => { ... process.stdout.write(ch + "\n"); });
11
- *
12
- * while the REPL's readline interface was STILL attached to stdin and still in
13
- * terminal mode. Both received the keypress, so both echoed it: pressing `y`
14
- * once printed `yy`. A commit went in claiming to fix that; it stopped
15
- * multi-character answers echoing but never detached readline, so the bug
16
- * survived in the shipped build. Any leftover keystroke also stayed in
17
- * readline's line buffer and was submitted as the user's NEXT message.
18
- *
19
- * The fix is not more careful escape handling - it is having exactly one reader
20
- * at a time. `question()` and `readKey()` both go through `withExclusiveStdin`,
21
- * which pauses the REPL interface, does its work, and resumes it. Nothing else
22
- * in the codebase is allowed to touch `process.stdin`.
23
- */
24
- let repl = null;
25
- /** Serialises overlapping reads so two prompts can never race for stdin. */
26
- let queue = Promise.resolve();
27
- export function createReplInterface(prompt) {
28
- repl = createInterface({
29
- input: process.stdin,
30
- output: process.stdout,
31
- prompt,
32
- // Ctrl-C is handled explicitly via the SIGINT event so an in-flight
33
- // request can be aborted without killing the process.
34
- terminal: process.stdout.isTTY === true,
35
- historySize: 500,
36
- });
37
- return repl;
38
- }
39
- export function closeRepl() {
40
- repl?.close();
41
- repl = null;
42
- }
43
- /**
44
- * Run `fn` as the only consumer of stdin.
45
- *
46
- * Pausing the readline Interface is not enough on its own: in terminal mode,
47
- * with the REPL mid `for await (... of rl)`, readline keeps its own `data`
48
- * listener on stdin and echoes keys. Pressing `y` at a permission prompt then
49
- * registered TWICE — once echoed by readline, once by the raw reader below —
50
- * producing "yy" and, worse, feeding a stray "y" into the next prompt.
51
- *
52
- * The fix is to remove stdin's listeners entirely for the duration of `fn` and
53
- * restore them afterwards, so the raw reader is genuinely the only consumer.
54
- */
55
- async function withExclusiveStdin(fn) {
56
- const run = queue.then(async () => {
57
- const wasRepl = repl;
58
- const stdin = process.stdin;
59
- // Snapshot and detach every data/keypress listener currently on stdin
60
- // (these belong to readline). We reattach them in the finally block.
61
- const dataListeners = stdin.listeners("data");
62
- const keypressListeners = stdin.listeners("keypress");
63
- wasRepl?.pause();
64
- for (const l of dataListeners)
65
- stdin.removeListener("data", l);
66
- for (const l of keypressListeners)
67
- stdin.removeListener("keypress", l);
68
- try {
69
- return await fn();
70
- }
71
- finally {
72
- // Reattach readline's listeners, then repaint its prompt — resume() alone
73
- // leaves the line unpainted so the terminal looks frozen until a keypress.
74
- for (const l of dataListeners)
75
- stdin.on("data", l);
76
- for (const l of keypressListeners)
77
- stdin.on("keypress", l);
78
- if (wasRepl) {
79
- wasRepl.resume();
80
- }
81
- }
82
- });
83
- // Keep the chain alive even if this call rejects, or one failed prompt would
84
- // deadlock every later one.
85
- queue = run.catch(() => undefined);
86
- return run;
87
- }
88
- /**
89
- * Read a single keypress without waiting for Enter.
90
- *
91
- * Returns the lowercased character, or a named key for the control sequences
92
- * that must not be swallowed. Ctrl-C in raw mode arrives as the byte 0x03 and
93
- * does NOT raise SIGINT - the old code treated it as "any other key", i.e. a
94
- * silent "no", so Ctrl-C at a permission prompt neither cancelled nor exited.
95
- */
96
- export async function readKey(allowed) {
97
- if (!process.stdin.isTTY) {
98
- // Piped or CI: there is no interactive user to answer, and blocking forever
99
- // is the worst outcome. Callers treat "" as "no answer given".
100
- return "";
101
- }
102
- return withExclusiveStdin(() => new Promise(resolve => {
103
- const stdin = process.stdin;
104
- const wasRaw = stdin.isRaw === true;
105
- const cleanup = () => {
106
- stdin.removeListener("data", onData);
107
- // Restore precisely what we found. `wasRaw || false` (the old code) is
108
- // not the same thing: it restores raw mode whenever readline had it on,
109
- // which is a different state from "what it was before I touched it".
110
- if (stdin.isTTY)
111
- stdin.setRawMode(wasRaw);
112
- if (!wasRaw)
113
- stdin.pause();
114
- };
115
- const onData = (buf) => {
116
- const seq = buf.toString("utf8");
117
- let key;
118
- if (seq === "")
119
- key = "ctrl-c";
120
- else if (seq === "\u001b[A")
121
- key = "up";
122
- else if (seq === "\u001b[B")
123
- key = "down";
124
- else if (seq === "\u001b[C")
125
- key = "right";
126
- else if (seq === "\u001b[D")
127
- key = "left";
128
- else if (seq === "")
129
- key = "escape";
130
- else if (seq === "\r" || seq === "\n")
131
- key = "enter";
132
- else
133
- key = seq[0]?.toLowerCase() ?? "";
134
- // Ignore keys the caller does not accept instead of treating them as a
135
- // decision. Pressing an arrow key should not silently mean "no".
136
- if (allowed && !allowed.includes(key) && key !== "ctrl-c" && key !== "escape")
137
- return;
138
- cleanup();
139
- // Echo exactly once, and only a real character.
140
- if (key.length === 1)
141
- process.stdout.write(key);
142
- process.stdout.write("\n");
143
- resolve(key);
144
- };
145
- stdin.setRawMode(true);
146
- stdin.resume();
147
- stdin.on("data", onData);
148
- }));
149
- }
150
- /** Read a full line, with Enter to submit. Used for free-text answers. */
151
- export async function question(prompt) {
152
- if (!process.stdin.isTTY)
153
- return "";
154
- return withExclusiveStdin(() => new Promise(resolve => {
155
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
156
- rl.question(prompt, answer => { rl.close(); resolve(answer.trim()); });
157
- }));
158
- }
159
- /**
160
- * A yes/no confirmation with a safe default.
161
- *
162
- * `defaultAnswer` is what Enter means. It is false everywhere a destructive
163
- * action is involved: a user holding Enter to get through prompts must not be
164
- * able to approve a deletion by momentum.
165
- */
166
- export async function confirm(prompt, defaultAnswer = false) {
167
- const hint = defaultAnswer ? "[Y/n]" : "[y/N]";
168
- process.stdout.write(`${prompt} ${c.dim(hint)} `);
169
- const key = await readKey();
170
- if (key === "ctrl-c" || key === "escape")
171
- return false;
172
- if (key === "enter" || key === "")
173
- return defaultAnswer;
174
- return key === "y";
175
- }
176
- /**
177
- * Scope picker after `t` — Kiro-style: ↑↓ to navigate, ↵ to select
178
- * Returns 0: Specific paths, 1: Complete directory, 2: Entire Tool, -1: cancelled
179
- */
180
- export async function pickScope(options) {
181
- if (!process.stdin.isTTY)
182
- return 0;
183
- return withExclusiveStdin(() => new Promise(resolve => {
184
- let idx = 0;
185
- const stdin = process.stdin;
186
- const wasRaw = stdin.isRaw === true;
187
- const render = () => {
188
- // Move cursor up to redraw options in place
189
- // First render: just print, subsequent: overwrite
190
- process.stdout.write(`\n ${c.dim("Press")} ${c.bold("(↑↓)")} ${c.dim("to navigate ·")} ${c.bold("(↵)")} ${c.dim("to select scope")}\n`);
191
- for (let i = 0; i < options.length; i++) {
192
- const sel = i === idx;
193
- const arrow = sel ? c.cyan(">") : " ";
194
- const txt = sel ? c.bold(c.cyan(options[i])) : c.dim(options[i]);
195
- process.stdout.write(`${arrow} ${txt}\n`);
196
- }
197
- };
198
- const cleanup = () => {
199
- stdin.removeListener("data", onData);
200
- if (stdin.isTTY)
201
- stdin.setRawMode(wasRaw);
202
- if (!wasRaw)
203
- stdin.pause();
204
- };
205
- const onData = (buf) => {
206
- const seq = buf.toString("utf8");
207
- if (seq === "") {
208
- cleanup();
209
- resolve(-1);
210
- return;
211
- }
212
- if (seq === "") {
213
- cleanup();
214
- resolve(-1);
215
- return;
216
- }
217
- if (seq === "\u001b[A") {
218
- idx = (idx - 1 + options.length) % options.length; // up
219
- // Clear previous render (options.length+1 lines)
220
- process.stdout.write(`\x1b[${options.length + 1}A\x1b[J`);
221
- render();
222
- return;
223
- }
224
- if (seq === "\u001b[B") {
225
- idx = (idx + 1) % options.length; // down
226
- process.stdout.write(`\x1b[${options.length + 1}A\x1b[J`);
227
- render();
228
- return;
229
- }
230
- if (seq === "\r" || seq === "\n") {
231
- cleanup();
232
- process.stdout.write("\n");
233
- resolve(idx);
234
- return;
235
- }
236
- };
237
- stdin.setRawMode(true);
238
- stdin.resume();
239
- stdin.on("data", onData);
240
- render();
241
- }));
242
- }