@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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 hội thoại",
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: "• Thao tác nguy hiểm sẽ hỏi phép, trừ khi bật yolo (Shift+Tab).",
63
- tip3: "• Ctrl+C 1 lần = dừng lượt hiện tại, 2 lần = thoát. CLI không tự tắt sau khi xong.",
67
+ tip2: "• Đang chạy vẫn 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
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
32
- let closed = false;
33
- const queue = []; // lines typed/piped, consumed in order
34
- let waiter = null; // resolver awaiting the next line
35
-
36
- rl.on("line", (line) => {
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
- } else {
42
- queue.push(line); // type-ahead / buffered input — never dropped
60
+ return;
43
61
  }
44
- });
45
- rl.on("close", () => {
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
- function ask(promptStr) {
63
- if (closed && !queue.length) return Promise.resolve(null);
64
- rl.setPrompt(promptStr);
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 — quick yolo toggle.
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
- readline.emitKeypressEvents(process.stdin);
72
- process.stdin.on("keypress", (_str, key) => {
73
- if (!key || key.name !== "tab" || !key.shift) return;
74
- state.yolo = !state.yolo;
75
- console.log(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
76
- rl.setPrompt(promptStr(false)); // cập nhật dòng trạng thái ngay lập tức
77
- rl.prompt(true);
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
- const raw = await ask(promptStr());
139
- if (raw == null) break; // stdin fully closed and drained
140
- const input = raw.trim();
283
+ let input;
284
+ if (pending.length) {
285
+ // 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 = ((await ask(c.tool(" cho phép? ") + c.dim("[y] có / [n] không / [a] luôn " + name + " › "))) ?? "n")
232
- .trim()
233
- .toLowerCase();
385
+ const a = await askPermission(name);
234
386
  if (a === "a") state.autoApprove.add(name);
235
- else if (a !== "y" && 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) => {
@@ -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
- const child = spawn(shell, args, { cwd: cwd() });
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));