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.
- package/dist/index.js +3990 -175
- package/package.json +4 -5
- package/dist/agent/permissions.js +0 -230
- package/dist/agent/run.js +0 -185
- package/dist/auth/device.js +0 -131
- package/dist/commands/chat.js +0 -135
- package/dist/commands/slash.js +0 -310
- package/dist/config.js +0 -95
- package/dist/errors.js +0 -35
- package/dist/session/store.js +0 -144
- package/dist/ui/input.js +0 -242
- package/dist/ui/render.js +0 -355
- package/dist/ui/theme.js +0 -49
package/dist/session/store.js
DELETED
|
@@ -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
|
-
}
|