zames_pro 1.1.1 → 1.2.1

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/src/index.js CHANGED
@@ -8,11 +8,18 @@ import { DeepSeekBrowser } from './browser.js'
8
8
  import { createTools } from './tools.js'
9
9
  import { runAgentLoop } from './agent-loop.js'
10
10
  import { createSpinner } from './spinner.js'
11
+ import { LineEditor } from './input.js'
11
12
  import { loadConfig, CONFIG_PATHS, ZAMES_HOME } from './config.js'
12
13
  import { Transcript } from './transcript.js'
13
14
  import { UndoStore } from './undo.js'
14
15
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js'
15
16
  import { closeWeb } from './web.js'
17
+ import {
18
+ saveSession,
19
+ loadLastSession,
20
+ listSessions,
21
+ sessionsDir,
22
+ } from './sessions.js'
16
23
 
17
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
18
25
 
@@ -59,40 +66,26 @@ const chatIdArg = getArg('--chat', null)
59
66
  // переотправляется (он уже есть в начале чата). Флаг --resend-prompt
60
67
  // заставляет дослать его заново — например, если промпт обновился.
61
68
  const resendPrompt = hasFlag('--resend-prompt')
62
- // --new-chat: намеренно начать с чистого чата, игнорируя сохранённый
63
- // --resume-last: при старте вернуться в последний сохранённый чат.
64
- // По умолчанию НЕ восстанавливаем — открывается новый чат.
65
- // (Флаг --new-chat сохранён для совместимости и ничего не меняет.)
69
+ // --new-chat: намеренно начать с чистого чата, игнорируя сохранённую сессию.
70
+ // --resume-last: оставлен для совместимости (восстановление и так по умолчанию).
71
+ // По умолчанию последняя сессия для рабочей директории восстанавливается.
72
+ const newChatFlag = hasFlag('--new-chat')
66
73
  const resumeLastFlag = hasFlag('--resume-last')
67
74
 
68
- // ---------- last chat persistence ----------
69
- // Запоминаем последний открытый chat id, чтобы после перезапуска процесса
70
- // (в т.ч. автоперезапуска через `node --watch`) автоматически вернуться
71
- // в ту же сессию, а не создавать новый чат.
72
- const LAST_CHAT_FILE = path.join(ZAMES_HOME, 'last-chat.json')
73
-
74
- async function saveLastChat(id) {
75
+ // ---------- session persistence ----------
76
+ // Сессии (чаты DeepSeek) храним в ~/.zames/.sessions, чтобы они переживали
77
+ // перезапуск процесса. При старте из той же рабочей директории последняя
78
+ // сессия восстанавливается автоматически (если не передан --new-chat).
79
+ // Раньше здесь был один файл last-chat.json, который терялся при смене
80
+ // проекта и не давал списка сессий для восстановления.
81
+ function saveLastChat(id, workdir = '', title = '') {
75
82
  if (!id) return
76
- try {
77
- await fs.mkdir(ZAMES_HOME, { recursive: true })
78
- await fs.writeFile(
79
- LAST_CHAT_FILE,
80
- JSON.stringify({ id, updatedAt: new Date().toISOString() }, null, 2),
81
- 'utf-8',
82
- )
83
- } catch (e) {
84
- if (debug) console.error('last-chat: не удалось сохранить:', e.message)
85
- }
83
+ saveSession({ id, workdir, title })
86
84
  }
87
85
 
88
- async function loadLastChat() {
89
- try {
90
- const raw = await fs.readFile(LAST_CHAT_FILE, 'utf-8')
91
- const data = JSON.parse(raw)
92
- return data && typeof data.id === 'string' ? data.id : null
93
- } catch {
94
- return null
95
- }
86
+ function loadLastChat(workdir = '') {
87
+ const s = loadLastSession(workdir)
88
+ return s ? s.id : null
96
89
  }
97
90
 
98
91
 
@@ -189,8 +182,8 @@ ${theme.bold('Опции CLI:')}
189
182
  --dir <path> рабочая директория агента
190
183
  --task <text> задача одной строкой
191
184
  --chat <id> продолжить существующий чат по id
192
- --resume-last вернуться в последний сохранённый чат
193
- --new-chat начать новый чат (по умолчанию и так новый)
185
+ --resume-last вернуться в последний сохранённый чат (по умолчанию)
186
+ --new-chat начать новый чат, не восстанавливать сессию
194
187
  --resend-prompt дослать system-prompt в существующий чат
195
188
  --max-iter <n> лимит итераций (по умолчанию ${config.maxIterations})
196
189
  --headless браузер без UI
@@ -200,8 +193,14 @@ ${theme.bold('Опции CLI:')}
200
193
  --version, -v показать версию
201
194
  --help, -h эта справка
202
195
 
196
+ ${theme.bold('Пока агент работает:')}
197
+ печать + Enter поставить сообщение в очередь (уйдёт после текущей задачи)
198
+ Esc, Ctrl+C прервать текущую генерацию
199
+
203
200
  ${theme.bold('Обычные команды:')}
204
201
  /new, /clear новый чат (сброс контекста)
202
+ /sessions список сохранённых сессий (~/.zames/.sessions)
203
+ /resume-id <id> восстановить сессию по полному id
205
204
  /chats список последних чатов DeepSeek
206
205
  /resume <n> открыть чат №n из /chats
207
206
  /chat показать текущий chat id
@@ -230,6 +229,7 @@ ${theme.bold('Самообзор (отладка агента):')}
230
229
  ${theme.bold('Файлы:')}
231
230
  Логи: ${config.transcript.dir}
232
231
  Undo: ~/.zames/undo
232
+ Сессии: ${sessionsDir()}
233
233
  Профиль: ~/.zames/profile
234
234
  Снапшоты: ~/.zames/snapshots
235
235
  Временные: <проект>/tmp (в .gitignore, чистится при запуске)
@@ -464,29 +464,143 @@ async function promptOnce(question) {
464
464
  }
465
465
 
466
466
  // Слежение за клавиатурой во время работы агента.
467
- // Esc — прервать текущую генерацию (клик Stop в браузере).
468
- function watchEscape(onEscape) {
467
+ //
468
+ // Терминал остаётся живым, пока агент думает:
469
+ // - Esc (или Ctrl+C) — прервать текущую генерацию (клик Stop в браузере);
470
+ // - набор текста + Enter — поставить сообщение в очередь; оно уйдёт
471
+ // агенту сразу после того, как текущая задача завершится
472
+ // (как «отправить во время генерации» в веб-версии DeepSeek).
473
+ //
474
+ // Ввод буферизуется без построчного редактора: набранный текст отображается
475
+ // в строке спиннера через onChange -> ui.setPending(). Enter отправляет буфер
476
+ // в onQueue, пустой Enter игнорируется. Поддержаны Backspace, Ctrl+U, Esc-
477
+ // последовательности (стрелки/Home/End/Delete игнорируются) и bracketed paste.
478
+ function watchInput({ onEscape, onChange, onQueue } = {}) {
469
479
  const stdin = process.stdin
470
- if (!stdin.isTTY) return () => {}
480
+ if (!stdin.isTTY || !stdin.setRawMode) return () => {}
481
+
482
+ // Управляющие символы собираем из кодов: в этом файле нельзя писать
483
+ // «сырые» ESC/CR/LF в строковых литералах (см. AGENTS.md).
484
+ const ESC = String.fromCharCode(27)
485
+ const CSI = String.fromCharCode(91)
486
+ const CR = String.fromCharCode(13)
487
+ const LF = String.fromCharCode(10)
488
+
471
489
  const wasRaw = stdin.isRaw
472
- if (stdin.setRawMode) stdin.setRawMode(true)
490
+ stdin.setRawMode(true)
473
491
  stdin.resume()
474
- const handler = (buf) => {
475
- if (buf.length === 1 && buf[0] === 27) {
476
- onEscape()
492
+ process.stdout.write(ESC + '[?2004h')
493
+
494
+ let buf = ''
495
+ let inPaste = false
496
+ const PASTE_START = ESC + '[200~'
497
+ const PASTE_END = ESC + '[201~'
498
+ const CSI_RE = new RegExp('^' + CSI + '[0-9;]*[A-Za-z~]')
499
+
500
+ const emitChange = () => {
501
+ if (onChange) onChange(buf)
502
+ }
503
+
504
+ // Вставленный текст: переводы строк внутри многострочной вставки
505
+ // трактуем как пробелы — сообщение уходит одной строкой.
506
+ const insert = (text) => {
507
+ buf += text
508
+ .split(CR + LF).join(' ')
509
+ .split(CR).join(' ')
510
+ .split(LF).join(' ')
511
+ }
512
+
513
+ function onData(data) {
514
+ let s = data.toString('utf-8')
515
+
516
+ // Одиночный Esc — прервать генерацию. Стрелки приходят целым чанком
517
+ // и сюда не попадают.
518
+ if (!inPaste && s === ESC) {
519
+ if (onEscape) onEscape()
520
+ return
521
+ }
522
+
523
+ while (s.length) {
524
+ if (inPaste) {
525
+ const end = s.indexOf(PASTE_END)
526
+ if (end === -1) {
527
+ insert(s)
528
+ s = ''
529
+ } else {
530
+ insert(s.slice(0, end))
531
+ s = s.slice(end + PASTE_END.length)
532
+ inPaste = false
533
+ }
534
+ emitChange()
535
+ continue
536
+ }
537
+
538
+ const start = s.indexOf(PASTE_START)
539
+ if (start !== -1) {
540
+ const before = s.slice(0, start)
541
+ s = s.slice(start + PASTE_START.length)
542
+ inPaste = true
543
+ if (before) {
544
+ insert(before)
545
+ emitChange()
546
+ }
547
+ continue
548
+ }
549
+
550
+ const ch = s[0]
551
+ const code = s.charCodeAt(0)
552
+ s = s.slice(1)
553
+
554
+ if (ch === CR || ch === LF) {
555
+ const text = buf.trim()
556
+ buf = ''
557
+ emitChange()
558
+ if (text && onQueue) onQueue(text)
559
+ continue
560
+ }
561
+ if (code === 3) {
562
+ // Ctrl+C — как Esc: прервать генерацию.
563
+ if (onEscape) onEscape()
564
+ continue
565
+ }
566
+ if (code === 21) {
567
+ // Ctrl+U — очистить набранное.
568
+ buf = ''
569
+ emitChange()
570
+ continue
571
+ }
572
+ if (code === 127 || code === 8) {
573
+ // Backspace.
574
+ if (buf) {
575
+ buf = buf.slice(0, -1)
576
+ emitChange()
577
+ }
578
+ continue
579
+ }
580
+ if (ch === ESC) {
581
+ // Escape-последовательность (стрелки, Home/End, Delete) — пропускаем.
582
+ const m = s.match(CSI_RE)
583
+ if (m) s = s.slice(m[0].length)
584
+ continue
585
+ }
586
+ if (code < 32) continue // прочие управляющие — игнорируем
587
+
588
+ buf += ch
589
+ emitChange()
477
590
  }
478
591
  }
592
+
593
+ const handler = (data) => onData(data)
479
594
  stdin.on('data', handler)
480
595
  return () => {
481
596
  stdin.removeListener('data', handler)
597
+ process.stdout.write(ESC + '[?2004l')
482
598
  if (stdin.setRawMode) stdin.setRawMode(wasRaw || false)
483
599
  }
484
600
  }
485
601
 
486
602
  // ---------- workdir resolution ----------
487
603
 
488
- // ---------- workdir resolution ----------
489
-
490
604
  // Агент работает в директории, из которой его запустили (process.cwd()).
491
605
  // Это корень sandbox: инструменты не могут выходить выше него.
492
606
  async function resolveWorkdir() {
@@ -502,47 +616,75 @@ async function resolveWorkdir() {
502
616
  // ---------- task runner ----------
503
617
 
504
618
  async function runTask(browser, tools, taskText, workdir, opts) {
505
- const { transcript, freshChat, sendSystemPrompt } = opts
506
-
507
- const ui = mod.createSpinner()
508
- ui.thinking()
509
-
510
- // Esc во время работы — прервать генерацию.
511
- const stopWatching = watchEscape(() => {
512
- ui.stop()
513
- console.error(theme.warn('⏹ Esc — прерываю генерацию...'))
514
- browser.stopGeneration().catch(() => {})
515
- })
516
-
517
- let finished = false
619
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor } = opts
620
+
621
+ // В TTY-режиме UI — это LineEditor: он владеет вводом (очередь, Esc,
622
+ // Ctrl+C) и рисует статус НАД постоянной строкой ввода. В не-TTY режиме
623
+ // (пайпы) — обычный спиннер + watchInput.
624
+ const ui = editor || mod.createSpinner()
625
+ const stopWatching = editor
626
+ ? () => {}
627
+ : watchInput({
628
+ onEscape: () => {
629
+ ui.stop()
630
+ console.error(theme.warn('⏹ Esc — прерываю генерацию...'))
631
+ browser.stopGeneration().catch(() => {})
632
+ },
633
+ onChange: (text) => ui.setPending(text),
634
+ onQueue: (text) => {
635
+ queue.push(text)
636
+ ui.setPending(null)
637
+ ui.stop()
638
+ console.log(
639
+ theme.user('📨 В очередь (' + queue.length + '): ') + theme.assistant(text),
640
+ )
641
+ ui.thinking()
642
+ },
643
+ })
518
644
 
519
645
  try {
520
- await mod.runAgentLoop({
521
- browser,
522
- tools,
523
- task: taskText,
524
- workdir,
525
- maxIterations: maxIter,
526
- freshChat,
527
- sendSystemPrompt,
528
- transcript,
529
- onThinking: () => ui.thinking(),
530
- onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
531
- onToolResult: (result) => ui.toolResult(result),
532
- onAssistantMessage: (msg) => {
533
- ui.assistant(msg)
534
- finished = true
535
- },
536
- debugLog: debug,
537
- })
646
+ let next = { task: taskText, freshChat, sendSystemPrompt }
647
+
648
+ // Выполняем задачу, затем — всё, что пользователь успел напечатать за
649
+ // время работы. Очередь может пополняться прямо во время дренажа.
650
+ while (true) {
651
+ ui.thinking()
652
+ await mod.runAgentLoop({
653
+ browser,
654
+ tools,
655
+ task: next.task,
656
+ workdir,
657
+ maxIterations: maxIter,
658
+ freshChat: next.freshChat,
659
+ sendSystemPrompt: next.sendSystemPrompt,
660
+ transcript,
661
+ onThinking: () => ui.thinking(),
662
+ onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
663
+ onToolResult: (result) => ui.toolResult(result),
664
+ onAssistantMessage: (msg) => ui.assistant(msg),
665
+ debugLog: debug,
666
+ })
667
+
668
+ if (!queue.length) break
669
+
670
+ const queued = queue.shift()
671
+ ui.stop()
672
+ if (editor) {
673
+ editor.printAbove(theme.user('▶ Из очереди: ') + theme.assistant(queued))
674
+ } else {
675
+ console.log(theme.user('▶ Из очереди: ') + theme.assistant(queued))
676
+ }
677
+ transcript?.log('queued_task', { task: queued })
678
+ next = { task: queued, freshChat: false, sendSystemPrompt: false }
679
+ }
538
680
  } catch (e) {
539
681
  ui.stop()
540
- console.error(theme.error('\n✖ Ошибка агента:'), e.message)
682
+ console.error(theme.error(String.fromCharCode(10) + '✖ Ошибка агента:'), e.message)
541
683
  if (debug) console.error(e.stack)
542
684
  transcript?.log('agent_error', { error: e.message })
543
685
  } finally {
544
686
  stopWatching()
545
- if (!finished) ui.stop()
687
+ ui.stop()
546
688
  }
547
689
  }
548
690
 
@@ -624,13 +766,15 @@ async function main() {
624
766
  let freshChat = true
625
767
  let sendSystemPrompt = true
626
768
 
627
- // По умолчанию — новый чат. Продолжить прошлый можно явно:
628
- // --chat <id> открыть конкретный чат
629
- // --resume-last вернуться в последний сохранённый чат
769
+ // По умолчанию восстанавливаем последнюю сессию для этой директории.
770
+ // Начать с чистого листа — --new-chat.
630
771
  let resumeId = chatIdArg
631
- if (!resumeId && resumeLastFlag) {
632
- resumeId = await loadLastChat()
633
- if (resumeId) console.log(theme.system(`Восстанавливаю чат ${resumeId}...`))
772
+ if (!resumeId && !newChatFlag) {
773
+ const last = loadLastSession(currentWorkdir)
774
+ if (last && last.id) {
775
+ resumeId = last.id
776
+ console.log(theme.system(`Восстанавливаю сессию ${resumeId}...`))
777
+ }
634
778
  }
635
779
 
636
780
  if (resumeId) {
@@ -639,6 +783,7 @@ async function main() {
639
783
  await browser.openChat(resumeId)
640
784
  freshChat = false
641
785
  sendSystemPrompt = resendPrompt
786
+ saveLastChat(resumeId, currentWorkdir)
642
787
  } catch (e) {
643
788
  console.error(theme.error(`Не удалось открыть чат: ${e.message}`))
644
789
  }
@@ -659,7 +804,12 @@ async function main() {
659
804
 
660
805
  console.log(
661
806
  theme.system(
662
- 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.\n',
807
+ 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.',
808
+ ),
809
+ )
810
+ console.log(
811
+ theme.system(
812
+ 'Пока агент работает, можно печатать следующее сообщение — оно уйдёт в очередь (Enter — отправить, Esc — прервать).',
663
813
  ),
664
814
  )
665
815
 
@@ -669,6 +819,10 @@ async function main() {
669
819
  let currentChatId = null
670
820
  let running = true
671
821
 
822
+ // Сообщения, набранные пользователем, пока агент работал. runTask
823
+ // забирает их по одному после завершения текущей задачи.
824
+ const pendingQueue = []
825
+
672
826
  // ---------- review mode state ----------
673
827
  // null — обычный режим.
674
828
  // { snapDir, snapName, originalWorkdir } — мы внутри снапшота, чат уже
@@ -686,13 +840,23 @@ async function main() {
686
840
  process.exit(0)
687
841
  })
688
842
 
689
- // По умолчанию — новый чат. Продолжить прошлый можно явно:
843
+ // Восстановление сессии при старте. По умолчанию возвращаемся в последний
844
+ // чат для этой рабочей директории (сессии лежат в ~/.zames/.sessions),
845
+ // чтобы контекст не терялся после перезапуска. Явно начать с чистого листа
846
+ // можно флагом --new-chat.
690
847
  // --chat <id> открыть конкретный чат
691
- // --resume-last вернуться в последний сохранённый чат
848
+ // --new-chat не восстанавливать, начать новый чат
692
849
  let resumeId = chatIdArg
693
- if (!resumeId && resumeLastFlag) {
694
- resumeId = await loadLastChat()
695
- if (resumeId) console.log(theme.system(`Восстанавливаю чат ${resumeId}...`))
850
+ if (!resumeId && !newChatFlag) {
851
+ const last = loadLastSession(currentWorkdir)
852
+ if (last && last.id) {
853
+ resumeId = last.id
854
+ console.log(
855
+ theme.system(
856
+ `Восстанавливаю сессию ${last.id}${last.title ? ` (${last.title})` : ''}...`,
857
+ ),
858
+ )
859
+ }
696
860
  }
697
861
 
698
862
  if (resumeId) {
@@ -702,25 +866,105 @@ async function main() {
702
866
  currentChatId = resumeId
703
867
  freshChatNext = false
704
868
  sendSystemPromptNext = resendPrompt
705
- await saveLastChat(resumeId)
869
+ saveLastChat(resumeId, currentWorkdir)
706
870
  console.log(theme.system(`Чат открыт: ${resumeId}\n`))
707
871
  } catch (e) {
708
872
  console.error(theme.error(`Не удалось открыть чат: ${e.message}`))
709
873
  }
710
874
  }
711
875
 
876
+ // ---------- ввод: постоянная строка внизу + статус сверху ----------
877
+ // В TTY используем LineEditor: он владеет вводом всё время, показывает
878
+ // статус над строкой ввода и печатает ответы агента ВЫШЕ неё, поэтому
879
+ // набранный текст никогда не затирается выводом. В не-TTY (пайп) —
880
+ // старый promptOnce.
881
+ let editor = null
882
+ let waiter = null
883
+ const takeInput = () => {
884
+ if (pendingQueue.length) return Promise.resolve(pendingQueue.shift())
885
+ return new Promise((resolve) => {
886
+ waiter = resolve
887
+ })
888
+ }
889
+
890
+ const buildPrompt = () => {
891
+ let tail
892
+ if (reviewMode) {
893
+ tail = theme.warn('REVIEW') + theme.dim(':') + theme.dir(reviewMode.snapName)
894
+ } else {
895
+ tail = theme.dir(dirLabel(currentWorkdir))
896
+ }
897
+ return theme.prompt('❯ ') + tail + theme.dim(' › ')
898
+ }
899
+
900
+ if (process.stdin.isTTY && process.stdout.isTTY) {
901
+ editor = new LineEditor({ prompt: buildPrompt() })
902
+ editor.onSubmit = (text) => {
903
+ pendingQueue.push(text)
904
+ if (waiter) {
905
+ const r = waiter
906
+ waiter = null
907
+ r(pendingQueue.shift())
908
+ }
909
+ }
910
+ editor.onEscape = () => {
911
+ if (editor.busy) {
912
+ editor.printAbove(theme.warn('⏹ Esc — прерываю генерацию...'))
913
+ browser.stopGeneration().catch(() => {})
914
+ }
915
+ }
916
+ editor.onCtrlC = () => {
917
+ if (editor.busy) {
918
+ editor.printAbove(theme.warn('⏹ Ctrl+C — прерываю генерацию...'))
919
+ browser.stopGeneration().catch(() => {})
920
+ } else {
921
+ // Не заняты — выходим. Будим takeInput(), чтобы цикл завершился.
922
+ running = false
923
+ if (waiter) {
924
+ const r = waiter
925
+ waiter = null
926
+ r(null)
927
+ }
928
+ }
929
+ }
930
+ editor.start()
931
+
932
+ // Весь вывод команд (console.log/error) должен идти ВЫШЕ строки ввода,
933
+ // иначе он затирает набираемый текст. Пока редактор активен, заворачиваем
934
+ // оба потока в editor.printAbove.
935
+ const origLog = console.log.bind(console)
936
+ const origErr = console.error.bind(console)
937
+ const fmt = (a) =>
938
+ typeof a === 'string' ? a : (() => {
939
+ try {
940
+ return JSON.stringify(a)
941
+ } catch {
942
+ return String(a)
943
+ }
944
+ })()
945
+ console.log = (...a) => editor.printAbove(a.map(fmt).join(' '))
946
+ console.error = (...a) => editor.printAbove(a.map(fmt).join(' '))
947
+ // Сохраняем на случай отладки.
948
+ editor._origLog = origLog
949
+ editor._origErr = origErr
950
+ }
951
+
712
952
  while (running) {
713
953
  let input
714
954
  try {
715
- // Компактное приглашение: золотая стрелка + светло-голубая директория.
716
- let tail
717
- if (reviewMode) {
718
- tail =
719
- theme.warn('REVIEW') + theme.dim(':') + theme.dir(reviewMode.snapName)
955
+ if (editor) {
956
+ editor.setPrompt(buildPrompt())
957
+ input = await takeInput()
720
958
  } else {
721
- tail = theme.dir(dirLabel(currentWorkdir))
959
+ let tail
960
+ if (reviewMode) {
961
+ tail =
962
+ theme.warn('REVIEW') + theme.dim(':') + theme.dir(reviewMode.snapName)
963
+ } else {
964
+ tail = theme.dir(dirLabel(currentWorkdir))
965
+ }
966
+ input = await promptOnce(theme.prompt('❯ ') + tail + theme.dim(' › '))
722
967
  }
723
- input = await promptOnce(theme.prompt('❯ ') + tail + theme.dim(' › '))
724
968
  } catch {
725
969
  break
726
970
  }
@@ -744,7 +988,7 @@ async function main() {
744
988
  freshChatNext = false
745
989
  sendSystemPromptNext = true
746
990
  currentChatId = await browser.getCurrentChatId()
747
- await saveLastChat(currentChatId)
991
+ saveLastChat(currentChatId, currentWorkdir)
748
992
  transcript.log('new_chat')
749
993
  console.log(theme.system('Новый чат.\n'))
750
994
  } catch (e) {
@@ -784,7 +1028,7 @@ async function main() {
784
1028
  freshChatNext = false
785
1029
  sendSystemPromptNext = false
786
1030
  currentChatId = await browser.getCurrentChatId()
787
- await saveLastChat(currentChatId)
1031
+ saveLastChat(currentChatId, currentWorkdir)
788
1032
 
789
1033
  console.log(
790
1034
  theme.user(
@@ -852,10 +1096,6 @@ async function main() {
852
1096
  sendSystemPrompt: false,
853
1097
  transcript,
854
1098
  onThinking: () => ui.thinking(),
855
- onAssistantThought: (text) => {
856
- ui.stop()
857
- console.log(theme.system('\n💭 ' + text.slice(0, 1200) + '\n'))
858
- },
859
1099
  onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
860
1100
  onToolResult: (r) => ui.toolResult(r),
861
1101
  onAssistantMessage: (m) => {
@@ -873,7 +1113,7 @@ async function main() {
873
1113
  freshChatNext = false
874
1114
  sendSystemPromptNext = false
875
1115
  currentChatId = await browser.getCurrentChatId()
876
- await saveLastChat(currentChatId)
1116
+ saveLastChat(currentChatId, currentWorkdir)
877
1117
 
878
1118
  console.log(
879
1119
  theme.user(
@@ -943,7 +1183,7 @@ async function main() {
943
1183
  // ---------- Обычные команды ----------
944
1184
 
945
1185
  if (lower === '/chats') {
946
- const spin = mod.createSpinner()
1186
+ const spin = editor || mod.createSpinner()
947
1187
  spin.thinking()
948
1188
  try {
949
1189
  lastChats = await browser.listChats(30)
@@ -1000,7 +1240,7 @@ async function main() {
1000
1240
  currentChatId = pick.id
1001
1241
  freshChatNext = false
1002
1242
  sendSystemPromptNext = resendPrompt
1003
- await saveLastChat(pick.id)
1243
+ saveLastChat(pick.id, currentWorkdir, pick.title)
1004
1244
  transcript.log('resume_chat', { id: pick.id, title: pick.title })
1005
1245
  console.log(
1006
1246
  theme.assistant(`Чат открыт.`) +
@@ -1031,6 +1271,51 @@ async function main() {
1031
1271
  continue
1032
1272
  }
1033
1273
 
1274
+ if (lower === '/sessions' || lower === '/session') {
1275
+ const all = listSessions()
1276
+ console.log(theme.system(`Папка сессий: ${sessionsDir()}`))
1277
+ if (!all.length) {
1278
+ console.log(
1279
+ theme.system(
1280
+ 'Сохранённых сессий нет. Они появятся после первой задачи/чата.',
1281
+ ),
1282
+ )
1283
+ } else {
1284
+ all.forEach((s, i) => {
1285
+ const n = String(i + 1).padStart(2, ' ')
1286
+ const mark = s.id === currentChatId ? theme.user(' *') : ''
1287
+ const title = s.title ? ` ${s.title}` : ''
1288
+ const wd = s.workdir ? theme.dim(` [${dirLabel(s.workdir)}]`) : ''
1289
+ console.log(` ${theme.user(n)}. ${s.id.slice(0, 8)}…${title}${wd}${mark}`)
1290
+ })
1291
+ console.log(
1292
+ theme.system('Восстановить: /resume-id <id> (полный id) или /resume <n> после /chats.'),
1293
+ )
1294
+ }
1295
+ continue
1296
+ }
1297
+
1298
+ if (lower.startsWith('/resume-id ')) {
1299
+ const id = trimmed.slice('/resume-id'.length).trim()
1300
+ if (!id) {
1301
+ console.error(theme.error('Использование: /resume-id <chat id>'))
1302
+ continue
1303
+ }
1304
+ try {
1305
+ console.log(theme.system(`Открываю чат ${id}...`))
1306
+ await browser.openChat(id)
1307
+ currentChatId = id
1308
+ freshChatNext = false
1309
+ sendSystemPromptNext = resendPrompt
1310
+ saveLastChat(id, currentWorkdir)
1311
+ transcript.log('resume_chat', { id })
1312
+ console.log(theme.assistant('Чат открыт.') + theme.system(String.fromCharCode(10)))
1313
+ } catch (e) {
1314
+ console.error(theme.error('Не удалось открыть чат:'), e.message)
1315
+ }
1316
+ continue
1317
+ }
1318
+
1034
1319
  if (lower === '/pwd') {
1035
1320
  console.log(theme.system(currentWorkdir))
1036
1321
  continue
@@ -1081,8 +1366,9 @@ async function main() {
1081
1366
  theme.system(`Resend prompt (--resend-prompt): ${resendPrompt ? 'да' : 'нет'}`),
1082
1367
  )
1083
1368
  console.log(
1084
- theme.system(`Last chat: ${(await loadLastChat()) || 'нет'}`),
1369
+ theme.system(`Last chat: ${loadLastChat(currentWorkdir) || 'нет'}`),
1085
1370
  )
1371
+ console.log(theme.system(`Сессии: ${sessionsDir()}`))
1086
1372
  console.log(
1087
1373
  theme.system(`Dev mode (auto-reload): ${devMode ? 'вкл' : 'выкл'}`),
1088
1374
  )
@@ -1195,20 +1481,28 @@ async function main() {
1195
1481
  transcript.log('user_task', { task: trimmed, workdir: currentWorkdir })
1196
1482
 
1197
1483
  const tools = mod.createTools(currentWorkdir, { undo })
1198
- await runTask(browser, tools, trimmed, currentWorkdir, {
1199
- transcript,
1200
- freshChat: freshChatNext,
1201
- sendSystemPrompt: sendSystemPromptNext,
1202
- })
1484
+ if (editor) editor.busy = true
1485
+ try {
1486
+ await runTask(browser, tools, trimmed, currentWorkdir, {
1487
+ transcript,
1488
+ freshChat: freshChatNext,
1489
+ sendSystemPrompt: sendSystemPromptNext,
1490
+ queue: pendingQueue,
1491
+ ui: editor || null,
1492
+ })
1493
+ } finally {
1494
+ if (editor) editor.busy = false
1495
+ }
1203
1496
 
1204
1497
  freshChatNext = false
1205
1498
  sendSystemPromptNext = false
1206
1499
  if (!currentChatId) {
1207
1500
  currentChatId = await browser.getCurrentChatId()
1208
1501
  }
1209
- await saveLastChat(currentChatId)
1502
+ saveLastChat(currentChatId, currentWorkdir)
1210
1503
  }
1211
1504
 
1505
+ if (editor) editor.dispose()
1212
1506
  await browser.close().catch(() => {})
1213
1507
  await mod.closeWeb().catch(() => {})
1214
1508
  transcript.close()