@noobdemon/noob-cli 1.0.5 → 1.0.7
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/bin/noob.js +6 -1
- package/package.json +1 -1
- package/src/i18n.js +19 -3
- package/src/repl.js +240 -34
- package/src/sessions.js +109 -0
- package/src/tools.js +4 -1
package/bin/noob.js
CHANGED
|
@@ -7,7 +7,7 @@ import { t } from "../src/i18n.js";
|
|
|
7
7
|
import { checkLatest, runUpdate, CURRENT } from "../src/update.js";
|
|
8
8
|
|
|
9
9
|
const argv = process.argv.slice(2);
|
|
10
|
-
const opts = { yolo: false, model: undefined, prompt: undefined };
|
|
10
|
+
const opts = { yolo: false, model: undefined, prompt: undefined, continue: false, resume: false };
|
|
11
11
|
const positional = [];
|
|
12
12
|
|
|
13
13
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -15,6 +15,9 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
15
15
|
if (a === "--yolo" || a === "-y") opts.yolo = true;
|
|
16
16
|
else if (a === "--insecure-tls") process.env.NOOB_INSECURE_TLS = "1";
|
|
17
17
|
else if (a === "--model" || a === "-m") opts.model = argv[++i];
|
|
18
|
+
else if (a === "--continue" || a === "-c") opts.continue = true;
|
|
19
|
+
else if (a === "--resume" || a === "-r") opts.resume = true;
|
|
20
|
+
else if (a.startsWith("--resume=")) opts.resume = a.slice("--resume=".length);
|
|
18
21
|
else if (a === "--help" || a === "-h") {
|
|
19
22
|
printHelp();
|
|
20
23
|
process.exit(0);
|
|
@@ -90,6 +93,8 @@ Cách dùng:
|
|
|
90
93
|
Tuỳ chọn:
|
|
91
94
|
-m, --model <id> chọn mô hình (vd: gateway-claude-opus-4-7)
|
|
92
95
|
-y, --yolo tự động duyệt sửa file & chạy lệnh (cẩn thận)
|
|
96
|
+
-c, --continue tiếp tục phiên gần nhất (resume)
|
|
97
|
+
-r, --resume[=id] chọn phiên để tiếp tục (không id = hiện danh sách)
|
|
93
98
|
--insecure-tls tắt kiểm tra TLS (chỉ cho mạng có proxy chặn TLS)
|
|
94
99
|
-h, --help hiện trợ giúp
|
|
95
100
|
|
package/package.json
CHANGED
package/src/i18n.js
CHANGED
|
@@ -11,6 +11,9 @@ export const t = {
|
|
|
11
11
|
pressAgainToExit: "nhấn Ctrl+C lần nữa để thoát",
|
|
12
12
|
running: "đang chạy…",
|
|
13
13
|
denied: "đã từ chối",
|
|
14
|
+
queued: (n, txt) => `⏎ đã xếp hàng [${n}] · gửi khi model xong: ${txt}`,
|
|
15
|
+
queueCleared: (n) => `(đã xoá ${n} tin đang xếp hàng)`,
|
|
16
|
+
permRetry: "→ gõ y (đồng ý) · n (từ chối) · a (luôn cho phép)",
|
|
14
17
|
|
|
15
18
|
// auth
|
|
16
19
|
notLoggedIn:
|
|
@@ -51,7 +54,9 @@ export const t = {
|
|
|
51
54
|
cmdSearch: "/search bật/tắt chế độ tìm web",
|
|
52
55
|
cmdChat: "/chat quay lại chế độ chat thường",
|
|
53
56
|
cmdYolo: "/yolo bật/tắt tự duyệt (hoặc nhấn Shift+Tab)",
|
|
54
|
-
cmdClear: "/clear /new xoá ngữ cảnh
|
|
57
|
+
cmdClear: "/clear /new xoá ngữ cảnh (mở phiên mới, phiên cũ vẫn resume được)",
|
|
58
|
+
cmdResume: "/resume [id] tiếp tục phiên cũ (không id = chọn từ danh sách)",
|
|
59
|
+
cmdSessions: "/sessions liệt kê các phiên đã lưu",
|
|
55
60
|
cmdLogin: "/login <key> đăng nhập bằng API key",
|
|
56
61
|
cmdLogout: "/logout đăng xuất",
|
|
57
62
|
cmdUsage: "/usage xem hạn mức key còn lại",
|
|
@@ -59,8 +64,8 @@ export const t = {
|
|
|
59
64
|
cmdVersion: "/version /v xem version hiện tại + trạng thái yolo",
|
|
60
65
|
cmdExit: "/exit /quit thoát",
|
|
61
66
|
tip1: "• Mô tả việc cần làm; noob sẽ đọc/sửa file & chạy lệnh giúp bạn.",
|
|
62
|
-
tip2: "•
|
|
63
|
-
tip3: "• Ctrl+C 1 lần = dừng lượt
|
|
67
|
+
tip2: "• Đang chạy vẫn gõ tiếp được — tin sẽ xếp hàng & tự gửi khi model xong.",
|
|
68
|
+
tip3: "• Shift+Tab: bật/tắt yolo nhanh. Ctrl+C 1 lần = dừng lượt, 2 lần = thoát.",
|
|
64
69
|
|
|
65
70
|
// misc
|
|
66
71
|
yoloOn: "⚠ yolo BẬT — tự động duyệt mọi thao tác sửa file & chạy lệnh",
|
|
@@ -81,6 +86,17 @@ export const t = {
|
|
|
81
86
|
maxSteps: "_(đã dừng: chạm giới hạn số bước tool)_",
|
|
82
87
|
toolDenied: "Người dùng từ chối thao tác này. Hãy đổi cách làm hoặc hỏi lại.",
|
|
83
88
|
|
|
89
|
+
// sessions (lưu lịch sử + resume)
|
|
90
|
+
sessionResumed: (id) => `Đã khôi phục phiên ${id}`,
|
|
91
|
+
sessionNonePrev: "Chưa có phiên nào trước đó — bắt đầu phiên mới.",
|
|
92
|
+
sessionNotFound: (id) => `Không tìm thấy phiên "${id}".`,
|
|
93
|
+
sessionEmpty: "Chưa có phiên đã lưu nào.",
|
|
94
|
+
sessionPickTitle: "Chọn phiên để tiếp tục:",
|
|
95
|
+
sessionListTitle: "Các phiên đã lưu:",
|
|
96
|
+
sessionPickPrompt: (n) => `chọn phiên [1-${n}], Enter để bỏ qua › `,
|
|
97
|
+
sessionPickBad: "lựa chọn không hợp lệ.",
|
|
98
|
+
sessionResumeHint: "/resume <id> để tiếp tục một phiên · hoặc chạy: noob -c (phiên gần nhất)",
|
|
99
|
+
|
|
84
100
|
// update
|
|
85
101
|
cmdUpdate: "/update cập nhật noob lên bản mới nhất",
|
|
86
102
|
updateFound: (cur, lat) => `🆕 Có bản mới ${lat} (đang dùng ${cur}) — đang tự cập nhật nền…`,
|
package/src/repl.js
CHANGED
|
@@ -10,6 +10,7 @@ import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
|
|
|
10
10
|
import { config } from "./config.js";
|
|
11
11
|
import { t } from "./i18n.js";
|
|
12
12
|
import { checkLatest, runUpdate, CURRENT } from "./update.js";
|
|
13
|
+
import * as sessions from "./sessions.js";
|
|
13
14
|
|
|
14
15
|
export async function startRepl(opts = {}) {
|
|
15
16
|
const state = {
|
|
@@ -28,54 +29,108 @@ export async function startRepl(opts = {}) {
|
|
|
28
29
|
return c.user(nl + t.promptYou) + yolo + c.dim("v" + CURRENT + " › ");
|
|
29
30
|
};
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
// ── Input layer — KHÔNG ĐƯỢC tự tắt ───────────────────────────────────────
|
|
33
|
+
// Trên Windows CMD, readline có thể phát sự kiện 'close' KHÔNG phải vì người
|
|
34
|
+
// dùng muốn thoát (console/keypress tranh chấp, hoặc một tiến trình con chạm
|
|
35
|
+
// vào console). Nếu để 'close' đó kết thúc vòng lặp → CLI "tự động tắt" giữa
|
|
36
|
+
// chừng (thường ngay sau khi hỏi quyền). Hơn nữa, một khi readline đã đóng,
|
|
37
|
+
// không còn handle nào giữ stdin → event loop cạn → Node thoát code 0.
|
|
38
|
+
//
|
|
39
|
+
// Khắc phục: trên TTY ta DỰNG LẠI readline khi gặp 'close' bất thường và giữ
|
|
40
|
+
// nguyên reader đang chờ (người dùng chỉ thấy prompt hiện lại). Chỉ dừng thật
|
|
41
|
+
// khi: /exit, Ctrl+C hai lần, hoặc EOF thật (stdin piped đã cạn / 'close' dồn
|
|
42
|
+
// dập nghĩa là stdin đã mất).
|
|
43
|
+
let rl;
|
|
44
|
+
let closed = false; // đã ngừng đọc vĩnh viễn
|
|
45
|
+
let exiting = false; // ta chủ động thoát (/exit, Ctrl+C ×2)
|
|
46
|
+
let lastPrompt = ""; // áp lại sau khi dựng lại interface
|
|
47
|
+
// Hàng đợi tin nhắn (giống Claude Code): khi model đang chạy, gõ thêm câu hỏi
|
|
48
|
+
// → xếp vào `pending`; xong turn, main loop tự lấy câu kế tiếp gửi lên. CHỈ
|
|
49
|
+
// main loop tiêu thụ `pending`. Câu trả lời permission đi qua `waiter` riêng,
|
|
50
|
+
// nên tin xếp hàng KHÔNG bị nhầm thành câu trả lời "cho phép?".
|
|
51
|
+
const pending = [];
|
|
52
|
+
let waiter = null; // resolver đang chờ một dòng tươi (prompt / permission)
|
|
53
|
+
let closeAt = 0;
|
|
54
|
+
|
|
55
|
+
function deliver(line) {
|
|
37
56
|
if (waiter) {
|
|
38
57
|
const w = waiter;
|
|
39
58
|
waiter = null;
|
|
40
59
|
w(line);
|
|
41
|
-
|
|
42
|
-
queue.push(line); // type-ahead / buffered input — never dropped
|
|
60
|
+
return;
|
|
43
61
|
}
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
// Không ai đang hỏi → đây là tin xếp hàng cho lượt kế tiếp.
|
|
63
|
+
pending.push(line);
|
|
64
|
+
if (process.stdin.isTTY) console.log(c.dim(" " + t.queued(pending.length, truncate(line, 60))));
|
|
65
|
+
}
|
|
66
|
+
function endInput() {
|
|
46
67
|
closed = true;
|
|
47
68
|
if (waiter) {
|
|
48
69
|
const w = waiter;
|
|
49
70
|
waiter = null;
|
|
50
71
|
w(null);
|
|
51
72
|
}
|
|
52
|
-
}
|
|
73
|
+
}
|
|
74
|
+
function buildRl() {
|
|
75
|
+
const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
|
|
76
|
+
r.on("line", deliver);
|
|
77
|
+
r.on("close", () => {
|
|
78
|
+
if (exiting) return endInput(); // ta chủ động thoát
|
|
79
|
+
// EOF THẬT: stdin đã end/destroy (Ctrl+Z trên Windows, Ctrl+D trên *nix),
|
|
80
|
+
// hoặc stdin không phải TTY (piped) đã đọc hết. Chỉ lúc đó mới dừng.
|
|
81
|
+
if (!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed) {
|
|
82
|
+
return endInput();
|
|
83
|
+
}
|
|
84
|
+
// 'close' BẤT THƯỜNG trên một TTY còn sống (tranh chấp console khi
|
|
85
|
+
// paste/đa dòng, tiến trình con, v.v.) → KHÔNG BAO GIỜ thoát. Dựng lại
|
|
86
|
+
// interface và hiện lại prompt; reader đang chờ vẫn được giữ nguyên. Nếu
|
|
87
|
+
// close dồn dập thì hoãn 50ms để khỏi quay CPU — nhưng vẫn sống.
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const fast = now - closeAt < 50;
|
|
90
|
+
closeAt = now;
|
|
91
|
+
const rebuild = () => {
|
|
92
|
+
rl = buildRl();
|
|
93
|
+
if (lastPrompt) {
|
|
94
|
+
rl.setPrompt(lastPrompt);
|
|
95
|
+
rl.prompt();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (fast) setTimeout(rebuild, 50);
|
|
99
|
+
else rebuild();
|
|
100
|
+
});
|
|
101
|
+
return r;
|
|
102
|
+
}
|
|
103
|
+
rl = buildRl();
|
|
53
104
|
|
|
54
|
-
// Robust input: every line is captured by the 'line' event (nothing is lost
|
|
55
|
-
// while a turn is processing) and handed out one at a time. Works for an
|
|
56
|
-
// interactive TTY and for piped / non-TTY stdin (Git Bash, CI, etc.).
|
|
57
105
|
function nextLine() {
|
|
58
|
-
if (queue.length) return Promise.resolve(queue.shift());
|
|
59
106
|
if (closed) return Promise.resolve(null);
|
|
60
107
|
return new Promise((res) => (waiter = res));
|
|
61
108
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
109
|
+
// Dòng "tươi": dùng cho prompt chính VÀ cho permission. KHÔNG đụng tới
|
|
110
|
+
// `pending` (hàng đợi tin nhắn) — chỉ main loop mới rút từ `pending`.
|
|
111
|
+
function ask(prompt) {
|
|
112
|
+
if (closed) return Promise.resolve(null);
|
|
113
|
+
lastPrompt = prompt;
|
|
114
|
+
rl.setPrompt(prompt);
|
|
65
115
|
rl.prompt();
|
|
66
116
|
return nextLine();
|
|
67
117
|
}
|
|
68
118
|
|
|
69
|
-
// Shift+Tab —
|
|
119
|
+
// Shift+Tab — bật/tắt yolo nhanh (best-effort; gắn vào stdin nên sống qua mọi
|
|
120
|
+
// lần dựng lại rl; bọc try/catch để không bao giờ làm hỏng input).
|
|
70
121
|
if (process.stdin.isTTY) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
122
|
+
try {
|
|
123
|
+
readline.emitKeypressEvents(process.stdin);
|
|
124
|
+
process.stdin.on("keypress", (_str, key) => {
|
|
125
|
+
if (!key || key.name !== "tab" || !key.shift) return;
|
|
126
|
+
state.yolo = !state.yolo;
|
|
127
|
+
console.log(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
|
|
128
|
+
rl.setPrompt(promptStr(false)); // cập nhật dòng trạng thái ngay lập tức
|
|
129
|
+
rl.prompt(true);
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
/* không có Shift+Tab cũng được — vẫn dùng /yolo */
|
|
133
|
+
}
|
|
79
134
|
}
|
|
80
135
|
|
|
81
136
|
let abort = null; // active turn controller
|
|
@@ -84,6 +139,11 @@ export async function startRepl(opts = {}) {
|
|
|
84
139
|
if (abort) {
|
|
85
140
|
abort.abort();
|
|
86
141
|
abort = null;
|
|
142
|
+
if (pending.length) {
|
|
143
|
+
const n = pending.length;
|
|
144
|
+
pending.length = 0; // ngắt lượt → xoá luôn tin đang xếp hàng
|
|
145
|
+
console.log(c.dim(" " + t.queueCleared(n)));
|
|
146
|
+
}
|
|
87
147
|
console.log(c.err("\n ✗ " + t.interrupted));
|
|
88
148
|
return; // the main loop will redraw the prompt
|
|
89
149
|
}
|
|
@@ -111,6 +171,69 @@ export async function startRepl(opts = {}) {
|
|
|
111
171
|
if (!closed) rl.prompt(true);
|
|
112
172
|
});
|
|
113
173
|
|
|
174
|
+
// ── phiên (session): lưu lịch sử + resume giống Claude Code ───────────────
|
|
175
|
+
let session = null;
|
|
176
|
+
const persist = () => {
|
|
177
|
+
if (!session || !state.history.length) return; // đừng lưu phiên rỗng
|
|
178
|
+
session.history = state.history; // giữ đồng bộ tuyệt đối với history sống
|
|
179
|
+
session.model = state.model.id;
|
|
180
|
+
sessions.save(session);
|
|
181
|
+
};
|
|
182
|
+
async function restore(s) {
|
|
183
|
+
session = s;
|
|
184
|
+
state.history = s.history || [];
|
|
185
|
+
state.mode = "chat";
|
|
186
|
+
if (s.model) {
|
|
187
|
+
const m = findModel(s.model);
|
|
188
|
+
if (m) state.model = m;
|
|
189
|
+
}
|
|
190
|
+
console.log(c.ok(" ✓ " + t.sessionResumed(s.id)));
|
|
191
|
+
const turns = state.history.filter((m) => m.role === "user");
|
|
192
|
+
const tail = turns.slice(-5);
|
|
193
|
+
const base = turns.length - tail.length;
|
|
194
|
+
tail.forEach((m, i) => console.log(c.dim(` ${base + i + 1}. `) + truncate(m.content, 70)));
|
|
195
|
+
console.log("");
|
|
196
|
+
}
|
|
197
|
+
async function pickSession() {
|
|
198
|
+
const items = sessions.list(20);
|
|
199
|
+
if (!items.length) {
|
|
200
|
+
console.log(c.dim(" " + t.sessionEmpty) + "\n");
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
console.log("\n" + chalk.bold(" " + t.sessionPickTitle));
|
|
204
|
+
items.forEach((s, i) =>
|
|
205
|
+
console.log(
|
|
206
|
+
c.accent(` ${String(i + 1).padStart(2)}. `) +
|
|
207
|
+
chalk.bold(s.title || "(trống)") +
|
|
208
|
+
c.dim(` · ${s.turns} lượt · ${relTime(s.updatedAt)} · ${shortPath(s.cwd)}`),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
const ans = ((await ask(c.tool(" " + t.sessionPickPrompt(items.length)))) ?? "").trim();
|
|
212
|
+
if (!ans) return null;
|
|
213
|
+
const idx = parseInt(ans, 10) - 1;
|
|
214
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= items.length) {
|
|
215
|
+
console.log(c.err(" " + t.sessionPickBad) + "\n");
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const full = sessions.load(items[idx].id);
|
|
219
|
+
if (full) await restore(full);
|
|
220
|
+
return full;
|
|
221
|
+
}
|
|
222
|
+
function listSessions() {
|
|
223
|
+
const items = sessions.list(20);
|
|
224
|
+
if (!items.length) return console.log(c.dim(" " + t.sessionEmpty));
|
|
225
|
+
console.log("\n" + chalk.bold(" " + t.sessionListTitle));
|
|
226
|
+
items.forEach((s) =>
|
|
227
|
+
console.log(
|
|
228
|
+
c.dim(" " + s.id.padEnd(20)) +
|
|
229
|
+
chalk.bold(s.title || "(trống)") +
|
|
230
|
+
c.dim(` · ${s.turns} lượt · ${relTime(s.updatedAt)}`),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
console.log(c.dim("\n " + t.sessionResumeHint) + "\n");
|
|
234
|
+
}
|
|
235
|
+
const startFresh = () => (session = sessions.newSession({ cwd: process.cwd(), model: state.model.id }));
|
|
236
|
+
|
|
114
237
|
banner();
|
|
115
238
|
printStatus(state);
|
|
116
239
|
if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
|
|
@@ -128,16 +251,45 @@ export async function startRepl(opts = {}) {
|
|
|
128
251
|
.catch(() => {});
|
|
129
252
|
}
|
|
130
253
|
|
|
254
|
+
// Khôi phục phiên theo cờ dòng lệnh, hoặc mở phiên mới.
|
|
255
|
+
if (opts.continue) {
|
|
256
|
+
const s = sessions.latest();
|
|
257
|
+
if (s) await restore(s);
|
|
258
|
+
else {
|
|
259
|
+
startFresh();
|
|
260
|
+
console.log(c.dim(" " + t.sessionNonePrev) + "\n");
|
|
261
|
+
}
|
|
262
|
+
} else if (opts.resume === true) {
|
|
263
|
+
if (!(await pickSession())) startFresh();
|
|
264
|
+
} else if (typeof opts.resume === "string") {
|
|
265
|
+
const s = sessions.load(opts.resume);
|
|
266
|
+
if (s) await restore(s);
|
|
267
|
+
else {
|
|
268
|
+
console.log(c.err(" " + t.sessionNotFound(opts.resume)) + "\n");
|
|
269
|
+
startFresh();
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
startFresh();
|
|
273
|
+
}
|
|
274
|
+
|
|
131
275
|
if (opts.prompt) {
|
|
132
276
|
console.log(c.user(t.promptYou) + c.dim("› ") + opts.prompt);
|
|
133
277
|
await handle(opts.prompt);
|
|
278
|
+
persist();
|
|
134
279
|
}
|
|
135
280
|
|
|
136
281
|
// Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
|
|
137
282
|
while (true) {
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
283
|
+
let input;
|
|
284
|
+
if (pending.length) {
|
|
285
|
+
// Có tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
|
|
286
|
+
input = (pending.shift() ?? "").trim();
|
|
287
|
+
if (process.stdin.isTTY && input) console.log(promptStr() + input);
|
|
288
|
+
} else {
|
|
289
|
+
const raw = await ask(promptStr());
|
|
290
|
+
if (raw == null) break; // stdin fully closed and drained
|
|
291
|
+
input = raw.trim();
|
|
292
|
+
}
|
|
141
293
|
if (!input) continue;
|
|
142
294
|
// Bọc cả lượt: một lỗi trong xử lý lệnh/agent không được phép thoát ra
|
|
143
295
|
// ngoài vòng lặp (sẽ rơi vào .catch ở bin/noob.js → process.exit(1) =
|
|
@@ -149,10 +301,12 @@ export async function startRepl(opts = {}) {
|
|
|
149
301
|
continue;
|
|
150
302
|
}
|
|
151
303
|
await handle(input);
|
|
304
|
+
persist(); // lưu sau mỗi lượt → resume được kể cả khi tắt đột ngột
|
|
152
305
|
} catch (err) {
|
|
153
306
|
printError(err);
|
|
154
307
|
}
|
|
155
308
|
}
|
|
309
|
+
exiting = true;
|
|
156
310
|
rl.close();
|
|
157
311
|
process.exit(0);
|
|
158
312
|
|
|
@@ -228,11 +382,9 @@ export async function startRepl(opts = {}) {
|
|
|
228
382
|
else if (name === "edit_file") preview(`- ${truncate(input.old_string)}\n+ ${truncate(input.new_string)}`, input.path);
|
|
229
383
|
|
|
230
384
|
if (DESTRUCTIVE.has(name) && !state.yolo && !state.autoApprove.has(name)) {
|
|
231
|
-
const a =
|
|
232
|
-
.trim()
|
|
233
|
-
.toLowerCase();
|
|
385
|
+
const a = await askPermission(name);
|
|
234
386
|
if (a === "a") state.autoApprove.add(name);
|
|
235
|
-
else if (a
|
|
387
|
+
else if (a === "n") {
|
|
236
388
|
console.log(c.err(" " + t.denied));
|
|
237
389
|
return { allow: false };
|
|
238
390
|
}
|
|
@@ -251,6 +403,27 @@ export async function startRepl(opts = {}) {
|
|
|
251
403
|
}
|
|
252
404
|
}
|
|
253
405
|
|
|
406
|
+
// Đọc câu trả lời cho phép một cách CHẮC CHẮN. Chỉ chấp nhận y/n/a (hoặc
|
|
407
|
+
// Enter = đồng ý). Nếu nhận một dòng lạ & dài — gần như chắc là một tin nhắn
|
|
408
|
+
// bị paste/gõ nhầm vào đúng lúc prompt hiện ra (đây là thủ phạm làm hỏng &
|
|
409
|
+
// "tự tắt" trước đây) — thì KHÔNG coi là từ chối: xếp nó vào hàng đợi tin
|
|
410
|
+
// nhắn rồi HỎI LẠI. Nhờ vậy không thao tác nào bị quyết định bởi rác.
|
|
411
|
+
async function askPermission(name) {
|
|
412
|
+
while (true) {
|
|
413
|
+
const raw = await ask(c.tool(" cho phép? ") + c.dim("[y] có / [n] không / [a] luôn " + name + " › "));
|
|
414
|
+
if (raw == null) return "n"; // stdin đóng thật
|
|
415
|
+
const a = raw.trim().toLowerCase();
|
|
416
|
+
if (a === "" || a === "y" || a === "yes" || a === "có") return "y";
|
|
417
|
+
if (a === "n" || a === "no" || a === "không") return "n";
|
|
418
|
+
if (a === "a" || a === "always" || a === "luôn") return "a";
|
|
419
|
+
if (raw.trim().length > 3) {
|
|
420
|
+
pending.push(raw); // tin nhắn lạc → xếp hàng, gửi sau khi xong lượt
|
|
421
|
+
console.log(c.dim(" " + t.queued(pending.length, truncate(raw, 60))));
|
|
422
|
+
}
|
|
423
|
+
console.log(c.dim(" " + t.permRetry));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
254
427
|
// ── slash commands ─────────────────────────────────────────────────────
|
|
255
428
|
async function command(input) {
|
|
256
429
|
const [cmd, ...rest] = input.slice(1).split(/\s+/);
|
|
@@ -296,12 +469,32 @@ export async function startRepl(opts = {}) {
|
|
|
296
469
|
break;
|
|
297
470
|
case "clear":
|
|
298
471
|
case "new":
|
|
472
|
+
persist(); // giữ lại phiên cũ trên đĩa
|
|
299
473
|
state.history = [];
|
|
474
|
+
startFresh(); // phiên mới (phiên cũ vẫn resume được)
|
|
300
475
|
console.clear();
|
|
301
476
|
banner();
|
|
302
477
|
printStatus(state);
|
|
303
478
|
console.log(c.dim(" " + t.ctxCleared + "\n"));
|
|
304
479
|
break;
|
|
480
|
+
case "resume":
|
|
481
|
+
if (arg) {
|
|
482
|
+
const s = sessions.load(arg);
|
|
483
|
+
if (s) await restore(s);
|
|
484
|
+
else console.log(c.err(" " + t.sessionNotFound(arg)));
|
|
485
|
+
} else {
|
|
486
|
+
await pickSession();
|
|
487
|
+
}
|
|
488
|
+
break;
|
|
489
|
+
case "continue": {
|
|
490
|
+
const s = sessions.latest();
|
|
491
|
+
if (s) await restore(s);
|
|
492
|
+
else console.log(c.dim(" " + t.sessionNonePrev));
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case "sessions":
|
|
496
|
+
listSessions();
|
|
497
|
+
break;
|
|
305
498
|
case "cwd":
|
|
306
499
|
console.log(c.dim(" " + process.cwd()));
|
|
307
500
|
break;
|
|
@@ -315,6 +508,8 @@ export async function startRepl(opts = {}) {
|
|
|
315
508
|
case "exit":
|
|
316
509
|
case "quit":
|
|
317
510
|
case "q":
|
|
511
|
+
persist();
|
|
512
|
+
exiting = true;
|
|
318
513
|
console.log(c.dim(" " + t.bye));
|
|
319
514
|
return true;
|
|
320
515
|
default:
|
|
@@ -436,6 +631,8 @@ function printHelp() {
|
|
|
436
631
|
" " + t.cmdUsage,
|
|
437
632
|
" " + t.cmdUpdate,
|
|
438
633
|
" " + t.cmdClear,
|
|
634
|
+
" " + t.cmdResume,
|
|
635
|
+
" " + t.cmdSessions,
|
|
439
636
|
" " + t.cmdStatus,
|
|
440
637
|
" " + t.cmdVersion,
|
|
441
638
|
" " + t.cmdExit,
|
|
@@ -466,6 +663,15 @@ const shortCwd = () => {
|
|
|
466
663
|
const p = process.cwd();
|
|
467
664
|
return p.length > 48 ? "…" + p.slice(-47) : p;
|
|
468
665
|
};
|
|
666
|
+
const shortPath = (p = "") => (p.length > 30 ? "…" + p.slice(-29) : p);
|
|
667
|
+
const relTime = (ts) => {
|
|
668
|
+
const m = Math.round((Date.now() - ts) / 60000);
|
|
669
|
+
if (m < 1) return "vừa xong";
|
|
670
|
+
if (m < 60) return m + " phút trước";
|
|
671
|
+
const h = Math.round(m / 60);
|
|
672
|
+
if (h < 24) return h + " giờ trước";
|
|
673
|
+
return Math.round(h / 24) + " ngày trước";
|
|
674
|
+
};
|
|
469
675
|
const firstLine = (s) => (s.split("\n")[0] || "").slice(0, 100);
|
|
470
676
|
const truncate = (s = "", n = 120) => (s.length > n ? s.slice(0, n) + "…" : s).replace(/\n/g, "⏎");
|
|
471
677
|
const fmtTime = (iso) => {
|
package/src/sessions.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Lưu & khôi phục lịch sử hội thoại (giống --continue / --resume của Claude Code).
|
|
2
|
+
// Mỗi phiên là một file JSON trong ~/.noob/sessions/. Ghi sau mỗi lượt nên có rớt
|
|
3
|
+
// mạng / tắt máy vẫn resume được.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
const DIR = path.join(os.homedir(), ".noob", "sessions");
|
|
9
|
+
|
|
10
|
+
function ensure() {
|
|
11
|
+
try {
|
|
12
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
13
|
+
} catch {
|
|
14
|
+
/* best effort */
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function genId() {
|
|
19
|
+
const d = new Date();
|
|
20
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
21
|
+
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
22
|
+
return `${stamp}-${Math.random().toString(36).slice(2, 6)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function titleFrom(history = []) {
|
|
26
|
+
const first = history.find((m) => m.role === "user");
|
|
27
|
+
if (!first) return "";
|
|
28
|
+
return String(first.content).replace(/\s+/g, " ").trim().slice(0, 60);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function newSession({ cwd, model } = {}) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
return {
|
|
34
|
+
id: genId(),
|
|
35
|
+
createdAt: now,
|
|
36
|
+
updatedAt: now,
|
|
37
|
+
cwd: cwd || process.cwd(),
|
|
38
|
+
model: model || "",
|
|
39
|
+
title: "",
|
|
40
|
+
history: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function save(session) {
|
|
45
|
+
if (!session || !session.id) return false;
|
|
46
|
+
ensure();
|
|
47
|
+
session.updatedAt = Date.now();
|
|
48
|
+
if (!session.title) session.title = titleFrom(session.history);
|
|
49
|
+
try {
|
|
50
|
+
fs.writeFileSync(path.join(DIR, session.id + ".json"), JSON.stringify(session), "utf8");
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function load(id) {
|
|
58
|
+
if (!id) return null;
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(path.join(DIR, id + ".json"), "utf8"));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Tóm tắt nhẹ (không tải toàn bộ history vào view), sắp xếp mới nhất trước. */
|
|
67
|
+
export function list(limit = 30) {
|
|
68
|
+
ensure();
|
|
69
|
+
let files;
|
|
70
|
+
try {
|
|
71
|
+
files = fs.readdirSync(DIR).filter((f) => f.endsWith(".json"));
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const f of files) {
|
|
77
|
+
try {
|
|
78
|
+
const s = JSON.parse(fs.readFileSync(path.join(DIR, f), "utf8"));
|
|
79
|
+
out.push({
|
|
80
|
+
id: s.id,
|
|
81
|
+
updatedAt: s.updatedAt || s.createdAt || 0,
|
|
82
|
+
cwd: s.cwd || "",
|
|
83
|
+
model: s.model || "",
|
|
84
|
+
title: s.title || titleFrom(s.history || []),
|
|
85
|
+
turns: (s.history || []).filter((m) => m.role === "user").length,
|
|
86
|
+
});
|
|
87
|
+
} catch {
|
|
88
|
+
/* bỏ qua file hỏng */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
92
|
+
return out.slice(0, limit);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function latest() {
|
|
96
|
+
const l = list(1);
|
|
97
|
+
return l.length ? load(l[0].id) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function remove(id) {
|
|
101
|
+
try {
|
|
102
|
+
fs.unlinkSync(path.join(DIR, id + ".json"));
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const sessionsDir = DIR;
|
package/src/tools.js
CHANGED
|
@@ -119,7 +119,10 @@ export const TOOLS = {
|
|
|
119
119
|
const isWin = process.platform === "win32";
|
|
120
120
|
const shell = isWin ? "powershell.exe" : "/bin/bash";
|
|
121
121
|
const args = isWin ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command];
|
|
122
|
-
|
|
122
|
+
// stdin: "ignore" — tiến trình con KHÔNG được chạm vào console/stdin của
|
|
123
|
+
// CLI. Nếu để con kế thừa stdin, trên Windows nó có thể làm readline phát
|
|
124
|
+
// 'close' → CLI tự tắt. Đóng hẳn stdin con để tránh hoàn toàn.
|
|
125
|
+
const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
|
|
123
126
|
let out = "";
|
|
124
127
|
const killer = setTimeout(() => child.kill(), timeout);
|
|
125
128
|
child.stdout.on("data", (d) => (out += d));
|