zames_pro 2.75.5 → 2.75.6

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +10 -4
  3. package/dist/agent-loop.js +61 -1
  4. package/dist/browser.js +40 -8
  5. package/dist/commands.js +0 -1
  6. package/dist/config.js +8 -3
  7. package/dist/deepseek-ui.js +1 -3
  8. package/dist/i18n/common.js +13 -16
  9. package/dist/i18n/config.js +2 -2
  10. package/dist/i18n/help.js +2 -6
  11. package/dist/i18n/spinner.js +7 -1
  12. package/dist/index.js +0 -49
  13. package/dist/net-capture.js +43 -11
  14. package/dist/provider-handoff.js +0 -4
  15. package/dist/qwen-ui.js +0 -8
  16. package/dist/run-task.js +24 -1
  17. package/dist/shell.js +0 -6
  18. package/dist/system-prompt.js +52 -2
  19. package/dist/tools.js +33 -0
  20. package/dist/tui/AskQuestion.js +108 -0
  21. package/dist/tui/FullScreenApp.js +2 -1
  22. package/dist/tui/InputLine.js +0 -3
  23. package/dist/tui/StatusBar.js +0 -9
  24. package/dist/tui/TuiEditor.js +0 -43
  25. package/dist/tui/keys.js +0 -2
  26. package/dist/tui/run-ask.js +22 -0
  27. package/dist/tui/store.js +0 -2
  28. package/dist/tui/widgets.js +3 -9
  29. package/package.json +2 -2
  30. package/scripts/build-readme.mts +0 -101
  31. package/scripts/changelog-draft.mts +0 -93
  32. package/scripts/clean-dist.mts +0 -17
  33. package/scripts/coverage-gate.mts +0 -112
  34. package/scripts/ctd-live-driver.mts +0 -51
  35. package/scripts/ctd-live.tsx +0 -33
  36. package/scripts/escd-live-driver.mts +0 -37
  37. package/scripts/eslint-ts6.mts +0 -58
  38. package/scripts/inline-status-driver.mts +0 -31
  39. package/scripts/inline-status.tsx +0 -33
  40. package/scripts/mouse-churn.mts +0 -34
  41. package/scripts/qwen-models-smoke.mts +0 -117
  42. package/scripts/qwen-smoke.mts +0 -221
  43. package/scripts/release.mts +0 -199
  44. package/scripts/render-demo.mts +0 -154
  45. package/scripts/render-how-it-works.mts +0 -229
  46. package/scripts/ru-shot.mts +0 -20
  47. package/scripts/sched-smoke.mts +0 -150
  48. package/scripts/self-smoke.mts +0 -309
  49. package/scripts/tui-comp-shot.mts +0 -63
  50. package/scripts/tui-components.tsx +0 -62
  51. package/scripts/tui-config-driver.mts +0 -82
  52. package/scripts/tui-config-probe.tsx +0 -74
  53. package/scripts/tui-edit-driver.mts +0 -100
  54. package/scripts/tui-edit-probe.tsx +0 -38
  55. package/scripts/tui-editor-probe.tsx +0 -24
  56. package/scripts/tui-input-driver.mts +0 -54
  57. package/scripts/tui-input-shot.tsx +0 -59
  58. package/scripts/tui-log-driver.mts +0 -35
  59. package/scripts/tui-log-shot.tsx +0 -48
  60. package/scripts/tui-longpaste-driver.mts +0 -56
  61. package/scripts/tui-longpaste.tsx +0 -35
  62. package/scripts/tui-manyselect-shot.mts +0 -34
  63. package/scripts/tui-manyselect.tsx +0 -17
  64. package/scripts/tui-mouse-churn-probe.tsx +0 -28
  65. package/scripts/tui-paste-driver.mts +0 -88
  66. package/scripts/tui-paste-kinds.tsx +0 -76
  67. package/scripts/tui-pastetest.mts +0 -38
  68. package/scripts/tui-probe.tsx +0 -62
  69. package/scripts/tui-repro.tsx +0 -64
  70. package/scripts/tui-resize.mts +0 -70
  71. package/scripts/tui-scroll-driver.mts +0 -53
  72. package/scripts/tui-scroll-probe.tsx +0 -46
  73. package/scripts/tui-shot.mts +0 -106
  74. package/scripts/tui-status-driver.mts +0 -24
  75. package/scripts/tui-status-shot.tsx +0 -27
  76. package/scripts/tui-submit.tsx +0 -57
  77. package/scripts/tui-wheel-driver.mts +0 -61
  78. package/scripts/tui-wheel-probe.tsx +0 -48
  79. package/scripts/tui-widgets-driver.mts +0 -20
  80. package/scripts/tui-widgets.tsx +0 -44
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.75.6] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - **tools:** AskUser — a modal question with options (and a free-text escape)
15
+ - **skills:** tell the model to check and read a matching skill before a non-trivial task
16
+
17
+ ### Fixed
18
+
19
+ - **deepseek:** retry a FINISHED turn with only a truncated RESPONSE head
20
+ - **tui:** default ui.mouseScroll back to true (wheel scrolls the log)
21
+ - **tui:** default ui.mouseScroll back to false so native selection works
22
+
23
+ ### Changed
24
+
25
+ - prune dead code, untrack one-off TUI probes, ship only postinstall
26
+ - **tui:** drop the unused onCopy/hint affordance from CodeBlock
27
+ - **tui:** drop the code-block copy feature (Ctrl+Y / /copy-code)
28
+
10
29
  ## [2.75.5] - 2026-10-15
11
30
 
12
31
  ### Fixed
package/README.md CHANGED
@@ -258,8 +258,11 @@ The prompt is a small line editor with persistent history:
258
258
  Full-screen mode adds dialog navigation, so a long answer can be read and
259
259
  selected without leaving the terminal:
260
260
 
261
- - mouse wheel — scroll the dialog (never the input history; history is arrow
262
- keys only). Selecting text needs `Shift`+click, the standard TUI convention.
261
+ - `ui.mouseScroll=false` (default) — the terminal owns the mouse: normal text
262
+ selection works and the wheel is inert (it never scrolls the input history —
263
+ history is arrow keys only).
264
+ - `ui.mouseScroll=true` — the app consumes the wheel and scrolls the dialog;
265
+ selecting text then needs `Shift`+click (the TUI convention).
263
266
  - `PgUp` / `PgDn` — move up/down the dialog half a screen.
264
267
  - `Home` / `End` — jump to the very top / bottom of the dialog (only while the
265
268
  input line is empty; with text typed they move the caret as usual).
@@ -838,8 +841,11 @@ zames
838
841
  В полноэкранном режиме добавляется навигация по диалогу, чтобы длинный ответ
839
842
  можно было прочитать и выделить не покидая терминал:
840
843
 
841
- - колесо мыши — скролл диалога (историю ввода колесо НЕ листает; история —
842
- только стрелки). Выделение текста — `Shift`+клик, стандарт TUI.
844
+ - `ui.mouseScroll=false` (по умолчанию) — терминал владеет мышью: обычное
845
+ выделение текста работает, колесо инертно (и никогда не листает историю
846
+ ввода — история только стрелками).
847
+ - `ui.mouseScroll=true` — приложение перехватывает колесо и скроллит диалог;
848
+ выделение текста тогда — `Shift`+клик (стандарт TUI).
843
849
  - `PgUp` / `PgDn` — вверх/вниз по диалогу на пол-экрана.
844
850
  - `Home` / `End` — прыжок в самый верх / низ диалога (только когда строка ввода
845
851
  пуста; при набранном тексте они двигают курсор, как обычно).
@@ -7,7 +7,7 @@ import { substituteAttachmentMarkers } from './commands.js';
7
7
  import { loadHooks, runPreToolUse, runPostToolUse, runLifecycleHooks, } from './hooks.js';
8
8
  import { loadPermissions, decidePermission, } from './permissions.js';
9
9
  import { shouldRunDiagnostics, runDiagnostics, } from './diagnostics.js';
10
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, diagnostics = null, onAskPermission = undefined, selfImprovement = false, onSubagent = null, }) {
10
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, diagnostics = null, onAskPermission = undefined, selfImprovement = false, onSubagent = null, onAskUser = null, }) {
11
11
  // Resolve the hook config ONCE per task: a read per tool call would be
12
12
  // wasteful, and a mid-task edit of hooks.json is not something to chase.
13
13
  // `undefined` means "read .zames/hooks.json"; an explicit null disables
@@ -884,6 +884,66 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
884
884
  }
885
885
  continue;
886
886
  }
887
+ // `AskUser` is a SEAM like `Task`: the CALLER owns the modal UI, the
888
+ // loop only relays the question and folds the answer back as the tool
889
+ // result. Intercepted BEFORE the unknown-tool branch so the tool can be
890
+ // handled even though its `fn` is a stub.
891
+ if (call.tool === 'AskUser') {
892
+ const rawQ = typeof call.args.question === 'string'
893
+ ? call.args.question.trim()
894
+ : '';
895
+ const opts = Array.isArray(call.args.options)
896
+ ? call.args.options
897
+ .map((o) => String(o ?? '').trim())
898
+ .filter(Boolean)
899
+ .slice(0, 6)
900
+ : [];
901
+ const recRaw = Number(call.args.recommended);
902
+ const recommended = Number.isInteger(recRaw) && recRaw >= 0 && recRaw < opts.length
903
+ ? recRaw
904
+ : null;
905
+ const allowText = call.args.allow_text !== false;
906
+ if (!onAskUser || !rawQ || opts.length < 2) {
907
+ const err = !onAskUser
908
+ ? 'AskUser is not available in this run. Choose a sensible default, proceed, and state the assumption in your final answer.'
909
+ : 'AskUser requires a non-empty `question` and at least two `options`. Proceed with your best judgement instead.';
910
+ safeToolResult(err);
911
+ transcript?.log('tool_error', { tool: 'AskUser', error: err });
912
+ results.push({ tool: 'AskUser', result: err });
913
+ continue;
914
+ }
915
+ syncToolAbort();
916
+ safeToolCall('AskUser', call.args);
917
+ transcript?.log('ask_user', {
918
+ question: rawQ,
919
+ options: opts,
920
+ recommended,
921
+ allowText,
922
+ });
923
+ let answer;
924
+ try {
925
+ answer = await onAskUser({
926
+ question: rawQ,
927
+ options: opts,
928
+ recommended,
929
+ allowText,
930
+ });
931
+ }
932
+ catch (e) {
933
+ answer = `AskUser error: ${e.message}`;
934
+ }
935
+ const out = answer === null || answer === ''
936
+ ? '(the operator dismissed the question — choose a sensible default, proceed, and state the assumption)'
937
+ : 'The operator answered: ' + answer;
938
+ safeToolResult(out);
939
+ transcript?.log('ask_user_result', { answer: out });
940
+ results.push({ tool: 'AskUser', result: out });
941
+ if (browser._abort || browser._stopped) {
942
+ transcript?.log('user_aborted');
943
+ return '(прервано пользователем)';
944
+ }
945
+ continue;
946
+ }
887
947
  const tool = tools.find((t) => t.name === call.tool);
888
948
  if (!tool) {
889
949
  const err = `Unknown tool: ${call.tool}`;
package/dist/browser.js CHANGED
@@ -459,15 +459,20 @@ export class DeepSeekBrowser {
459
459
  const cid = url.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
460
460
  if (cid)
461
461
  this._netChatId = cid[0];
462
+ // A live hang: the turn ENDED (`quasi_status: FINISHED`) with no real
463
+ // answer — only reasoning, or a RESPONSE fragment truncated to a lone
464
+ // `{`. The extractor yields '' or the fragment, so the OLD
465
+ // `!extracted` guard skipped the check and the finish loop waited out
466
+ // the whole askDeadlineMs. Run the check on the RAW body regardless of
467
+ // whether a (bogus) fragment was extracted.
468
+ if (isFinishedWithoutAnswer(body)) {
469
+ // The UI shows Stopped + Continue. Record it with a fresh timestamp
470
+ // so the ask() loops do not wait out the timeout — they click
471
+ // Continue / resend.
472
+ this._netNoAnswer = true;
473
+ this._netCapture = '';
474
+ }
462
475
  if (!extracted) {
463
- if (isFinishedWithoutAnswer(body)) {
464
- // A FINISHED turn with NO RESPONSE fragment (only reasoning): the
465
- // UI shows Stopped + Continue. Record it with a fresh timestamp
466
- // so the ask() loops do not wait out the timeout — they click
467
- // Continue / resend.
468
- this._netNoAnswer = true;
469
- this._netCapture = '';
470
- }
471
476
  // A truncated turn (generation_err / INCOMPLETE) also has no
472
477
  // RESPONSE; make the body visible to the incomplete check by
473
478
  // refreshing the timestamp.
@@ -2044,6 +2049,12 @@ export class DeepSeekBrowser {
2044
2049
  // Ticks since the SSE body reported FINISHED-with-answer while the DOM
2045
2050
  // heuristics kept the loop waiting (see the safety net below).
2046
2051
  let netFinishedTicks = 0;
2052
+ // Ticks since the SSE body reported FINISHED but produced NO real answer
2053
+ // (only reasoning / a truncated head) while no Continue button was found.
2054
+ // Waiting out the full answerTimeoutMs (up to 15 min) on a turn the server
2055
+ // already ENDED with nothing is pointless — after a short grace period the
2056
+ // loop gives up so ask() resends the prompt instead of a 4-minute spinner.
2057
+ let deadTicks = 0;
2047
2058
  while (Date.now() < deadline) {
2048
2059
  if (this._abort || this._askCancelled) {
2049
2060
  return last || '(прервано пользователем)';
@@ -2160,6 +2171,27 @@ export class DeepSeekBrowser {
2160
2171
  else {
2161
2172
  netFinishedTicks = 0;
2162
2173
  }
2174
+ // DEAD-TURN SAFETY NET. The server ENDED the turn (`quasi_status:
2175
+ // FINISHED`) but produced no real answer (only reasoning, or a truncated
2176
+ // RESPONSE head) AND there is no Continue button to resume it. The
2177
+ // `truncated`/`noAnswer` branches above throw at once when the raw body
2178
+ // says so; this is the belt-and-braces variant that also covers a body
2179
+ // whose flags were cleared by an earlier Continue click. Waiting out the
2180
+ // full answerTimeoutMs (up to 15 min) is pointless — after a short grace
2181
+ // period give up so ask() resends the prompt instead of a long spinner.
2182
+ if (this._netCaptureAt >= this._lastSentAt &&
2183
+ isFinishedWithAnswer(this._netBody) === false &&
2184
+ isGenerationIncompleteText(this._netBody) === false &&
2185
+ (this._netNoAnswer || isFinishedWithoutAnswer(this._netBody))) {
2186
+ deadTicks++;
2187
+ if (deadTicks >= 6 && !(await this._continueButtonVisible())) {
2188
+ this._askDebug('THROW dead-turn (finished without answer)');
2189
+ throw new GenerationIncompleteError('finished without answer');
2190
+ }
2191
+ }
2192
+ else {
2193
+ deadTicks = 0;
2194
+ }
2163
2195
  if (isNew && cur === last) {
2164
2196
  stable++;
2165
2197
  // `stabilityChecks` / `stabilityDelayMs` are the real knobs here.
package/dist/commands.js CHANGED
@@ -987,7 +987,6 @@ export const SLASH_COMMANDS = [
987
987
  { name: '/rename', key: 'help.cmd.rename' },
988
988
  { name: '/context', key: 'help.cmd.context' },
989
989
  { name: '/copy', key: 'help.cmd.copy' },
990
- { name: '/copy-code', key: 'help.cmd.copy_code' },
991
990
  { name: '/chat', key: 'help.cmd.chat' },
992
991
  { name: '/cd', key: 'help.cmd.cd' },
993
992
  { name: '/pwd', key: 'help.cmd.pwd' },
package/dist/config.js CHANGED
@@ -71,7 +71,11 @@ export const DEFAULTS = {
71
71
  incompleteWaitMs: 2000,
72
72
  autoContinue: true,
73
73
  continueMinGapMs: 1500,
74
- askDeadlineMs: 240000,
74
+ // The agent-loop watchdog for ONE ask(). It only bounds a SILENT send:
75
+ // a turn the server already ENDED with no answer (FINISHED + truncated
76
+ // RESPONSE) is detected by the network detectors and retried in seconds,
77
+ // not waited out. 240s used to mean a 4-minute spinner per dead turn.
78
+ askDeadlineMs: 120000,
75
79
  maxAfterToolRetries: 6,
76
80
  autoCompact: false,
77
81
  autoCompactPct: 95,
@@ -113,8 +117,9 @@ export const DEFAULTS = {
113
117
  // REPORTING (1000/1006) plus alternate-scroll OFF (1007l), so the wheel
114
118
  // arrives as SGR events we consume and never as Up/Down arrows. The
115
119
  // trade-off is that the terminal no longer does its own drag-select, so
116
- // selecting text needs SHIFT+DRAG (the standard TUI convention). Set to
117
- // false to give the terminal the mouse back (native select, wheel inert).
120
+ // selecting text needs SHIFT+DRAG (the standard TUI convention: Claude
121
+ // Code / Codex / opencode do the same). Set to false to give the terminal
122
+ // the mouse back (native select, wheel inert).
118
123
  mouseScroll: true,
119
124
  },
120
125
  // Off by default: a full tsc/eslint run after every edit is slow; opt in.
@@ -18,7 +18,7 @@ export const CHAT_URL = 'https://chat.deepseek.com/';
18
18
  export const CHAT_ID_RE = /\/chat\/s\/([a-zA-Z0-9_-]+)/;
19
19
  // DeepSeek's chat URL shape. A direct navigation always yields the requested
20
20
  // chat (the sidebar link click is tried first, this is the fallback).
21
- export const CHAT_PATH_PREFIX = 'a/chat/s/';
21
+ const CHAT_PATH_PREFIX = 'a/chat/s/';
22
22
  export function chatUrl(id) {
23
23
  return CHAT_URL + CHAT_PATH_PREFIX + id;
24
24
  }
@@ -177,7 +177,6 @@ export const MFA_CODE_SELECTORS = [
177
177
  '.ds-mfa-verification-modal input[type="tel"]',
178
178
  'input[inputmode="numeric"][maxlength="6"]',
179
179
  ];
180
- export const MFA_MODAL_SELECTOR = '.ds-mfa-verification-modal, [role="dialog"]';
181
180
  export const MFA_SUBMIT_SELECTORS = [
182
181
  'div[role="button"].ds-mfa-verification-submit',
183
182
  '.ds-mfa-verification-modal div[role="button"].ds-button--primary',
@@ -264,7 +263,6 @@ export const INCOMPLETE_STATUS_RE = /"quasi_status"(?::|,"v":)"INCOMPLETE"/i;
264
263
  // fragment. The UI shows Stopped + Continue. Matches both the JSON-property
265
264
  // and the BATCH shape (see INCOMPLETE_STATUS_RE).
266
265
  export const FINISHED_STATUS_RE = /"quasi_status"(?::|,"v":)"FINISHED"/i;
267
- export const RESPONSE_CONTENT_RE = /"type":"RESPONSE","content":"[^"]/;
268
266
  // Only a completion/continue turn carries a real answer. Gating the capture
269
267
  // assignment to these URLs keeps a future endpoint (which happens to return a
270
268
  // `content`/`text` field) from clobbering the answer and looking fresh.
@@ -117,6 +117,19 @@ export const commonMessages = {
117
117
  en: 'Allow this call?',
118
118
  },
119
119
  'perm.title': { ru: 'Подтверждение', en: 'Confirmation' },
120
+ // The AskUser modal (the model asks the operator a question with options).
121
+ 'askuser.title': { ru: 'Вопрос от агента', en: 'Question from the agent' },
122
+ 'askuser.other': {
123
+ ru: 'Свой вариант — введу текстом',
124
+ en: 'Other — I will type my own answer',
125
+ },
126
+ 'askuser.recommended': { ru: 'рекомендую', en: 'recommended' },
127
+ 'askuser.your_answer': { ru: 'Ваш ответ:', en: 'Your answer:' },
128
+ 'askuser.hint_select': { ru: 'выбор', en: 'select' },
129
+ 'askuser.hint_ok': { ru: 'ок', en: 'ok' },
130
+ 'askuser.hint_dismiss': { ru: 'закрыть', en: 'dismiss' },
131
+ 'askuser.hint_send': { ru: 'отправить', en: 'send' },
132
+ 'askuser.hint_cancel': { ru: 'отмена', en: 'cancel' },
120
133
  'confirm.yes': { ru: 'Да', en: 'Yes' },
121
134
  'confirm.no': { ru: 'Нет', en: 'No' },
122
135
  'perm.ask_sub': {
@@ -246,22 +259,6 @@ export const commonMessages = {
246
259
  ru: 'Не удалось скопировать (нет {tool} или он недоступен).',
247
260
  en: 'Could not copy ({tool} missing or unavailable).',
248
261
  },
249
- 'copy.no_code': {
250
- ru: 'В последнем ответе нет блоков кода.',
251
- en: 'The last answer has no code blocks.',
252
- },
253
- 'copy.bad_index': {
254
- ru: 'Нет блока кода #{n}.',
255
- en: 'No code block #{n}.',
256
- },
257
- 'copy_code.list': {
258
- ru: 'Блоки кода последнего ответа:',
259
- en: 'Code blocks in the last answer:',
260
- },
261
- 'copy_code.item': {
262
- ru: ' #{n} {lang} — {lines} стр.',
263
- en: ' #{n} {lang} — {lines} lines',
264
- },
265
262
  'retry.running': { ru: 'Повторяю задачу…', en: 'Retrying the task...' },
266
263
  'rename.usage': {
267
264
  ru: 'Использование: /rename <title>',
@@ -44,8 +44,8 @@ export const configMessages = {
44
44
  en: 'Full-screen TUI mode (alternate screen)',
45
45
  },
46
46
  'cfg.f.ui_mouseScroll': {
47
- ru: 'Прокрутка лога колесом мыши (выделение текста — Shift+клик)',
48
- en: 'Mouse-wheel log scrolling (select text with Shift+click)',
47
+ ru: 'Колесо: лог или терминал (выкл = выделение без Shift)',
48
+ en: 'Wheel: log or terminal (off = select without Shift)',
49
49
  },
50
50
  'cfg.f.maxIterations': {
51
51
  ru: 'Лимит итераций на задачу',
package/dist/i18n/help.js CHANGED
@@ -102,8 +102,8 @@ export const helpMessages = {
102
102
  en: 'PgUp / PgDn up/down the dialog (Home/End — top/bottom) [full-screen]',
103
103
  },
104
104
  'help.key.wheel': {
105
- ru: 'колесо мыши скролл диалога; выделение — Shift+клик',
106
- en: 'mouse wheel scroll the dialog; select text with Shift+click',
105
+ ru: 'ui.mouseScroll on (умолч.) — колесо скроллит диалог (выделение Shift); off — выделение мышью',
106
+ en: 'ui.mouseScroll on (default) — wheel scrolls the dialog (select: Shift); off — select with the mouse',
107
107
  },
108
108
  'help.key.bang': {
109
109
  ru: '!команда выполнить shell-команду напрямую (мимо модели)',
@@ -201,10 +201,6 @@ export const helpMessages = {
201
201
  ru: '/copy скопировать последний ответ в буфер обмена',
202
202
  en: '/copy copy the last answer to the clipboard',
203
203
  },
204
- 'help.cmd.copy_code': {
205
- ru: '/copy-code [номер] копировать блок кода из последнего ответа (Ctrl+Y)',
206
- en: '/copy-code [n] copy a code block from the last answer (Ctrl+Y)',
207
- },
208
204
  'help.cmd.cost': { ru: '/cost статистика сессии', en: '/cost session stats' },
209
205
  'help.cmd.export': {
210
206
  ru: '/export [file] выгрузить сессию в Markdown',
@@ -35,11 +35,17 @@ export const spinnerMessages = {
35
35
  ru: 'Напишите задачу и нажмите Enter. /help — команды, Ctrl+J — новая строка.',
36
36
  en: 'Type a task and press Enter. /help for commands, Ctrl+J for a new line.',
37
37
  },
38
- // One-line key hint UNDER the input box (full-screen mode).
38
+ // One-line key hint UNDER the input box (full-screen mode). Two variants:
39
+ // with in-app wheel scroll the mouse belongs to the app, so selection needs
40
+ // Shift; without it the terminal keeps the mouse and the wheel is inert.
39
41
  'editor.keys_hint': {
40
42
  ru: 'выделение — Shift+клик · колесо — скролл · PgUp/PgDn — вверх/вниз · Home/End — в начало/конец',
41
43
  en: 'select — Shift+click · wheel — scroll · PgUp/PgDn — up/down · Home/End — top/bottom',
42
44
  },
45
+ 'editor.keys_hint_select': {
46
+ ru: 'выделение — мышью · PgUp/PgDn — вверх/вниз · Home/End — в начало/конец',
47
+ en: 'select — drag · PgUp/PgDn — up/down · Home/End — top/bottom',
48
+ },
43
49
  // Reverse search (Ctrl+R) status line. The query may be empty.
44
50
  'editor.search_prompt': {
45
51
  ru: '(обратный поиск) `{q}` — Ctrl+R дальше, Enter принять, Esc отмена',
package/dist/index.js CHANGED
@@ -35,7 +35,6 @@ import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } f
35
35
  import { createCliArgs } from './cli-args.js';
36
36
  import { qwenContextWindow } from './qwen-ui.js';
37
37
  import { renderMarkdown, setAnswerWidth } from './markdown.js';
38
- import { extractCodeBlocks } from './code-blocks.js';
39
38
  import { contentWidth } from './width.js';
40
39
  import { renderStartupBanner } from './banner.js';
41
40
  import { runStatusLineCommand } from './statusline.js';
@@ -1528,7 +1527,6 @@ async function main() {
1528
1527
  }) + theme.dim(' (' + res.via + ')')));
1529
1528
  return att;
1530
1529
  };
1531
- ed.onCopyText = (text) => writeClipboardText(text);
1532
1530
  ed.onSubmit = (text, items) => {
1533
1531
  // While the agent is busy the main command loop is blocked on
1534
1532
  // `await runTask()`, so ONLY commands that make sense without the main
@@ -3239,53 +3237,6 @@ async function main() {
3239
3237
  }
3240
3238
  continue;
3241
3239
  }
3242
- if (lower === '/copy-code' || lower.startsWith('/copy-code ')) {
3243
- const arg = trimmed.slice('/copy-code'.length).trim();
3244
- const blocks = extractCodeBlocks(lastAssistantMessage ?? '');
3245
- if (!blocks.length) {
3246
- console.log(theme.dim(t('copy.no_code')));
3247
- continue;
3248
- }
3249
- if (arg === 'list') {
3250
- console.log(theme.system(t('copy_code.list')));
3251
- for (const b of blocks) {
3252
- console.log(theme.assistant(t('copy_code.item', {
3253
- n: String(b.index),
3254
- lang: b.lang || 'text',
3255
- lines: String(b.text.split(String.fromCharCode(10)).length),
3256
- })));
3257
- }
3258
- continue;
3259
- }
3260
- // Prefer the TUI copy path (it reports into the log); fall back to a
3261
- // direct clipboard write for the legacy editor / non-TTY.
3262
- const n = arg ? Number(arg) : undefined;
3263
- if (n !== undefined && (!Number.isInteger(n) || n < 1)) {
3264
- console.log(theme.warn(t('copy.bad_index', { n: arg })));
3265
- continue;
3266
- }
3267
- if (editor &&
3268
- 'copyCode' in editor &&
3269
- typeof editor.copyCode === 'function') {
3270
- editor.copyCode(n);
3271
- continue;
3272
- }
3273
- const pick = n !== undefined
3274
- ? blocks.find((b) => b.index === n)
3275
- : blocks[blocks.length - 1];
3276
- if (!pick) {
3277
- console.log(theme.warn(t('copy.bad_index', { n: String(n) })));
3278
- continue;
3279
- }
3280
- const res2 = writeClipboardText(pick.text);
3281
- if (res2.ok) {
3282
- console.log(theme.assistant(t('copy.done', { tool: res2.via })));
3283
- }
3284
- else {
3285
- console.error(theme.warn(t('copy.failed', { tool: res2.via })));
3286
- }
3287
- continue;
3288
- }
3289
3240
  if (lower === '/retry') {
3290
3241
  if (!lastTaskText) {
3291
3242
  console.log(theme.dim(t('retry.none')));
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import { FINISHED_STATUS_RE, RESPONSE_CONTENT_RE } from './deepseek-ui.js';
4
+ import { FINISHED_STATUS_RE } from './deepseek-ui.js';
5
5
  // Extraction of the RAW model answer text from DeepSeek's network data.
6
6
  //
7
7
  // Why: the answer is read from the RENDERED DOM, and DeepSeek's rendering
@@ -160,15 +160,43 @@ export function extractAnswer(body) {
160
160
  return sse;
161
161
  return extractFromJson(body);
162
162
  }
163
+ // The smallest RESPONSE that counts as a REAL answer rather than a truncated
164
+ // head. A live hang: DeepSeek began the RESPONSE fragment with a single `{`
165
+ // (`"type":"RESPONSE","content":"{"`), then the stream ended with
166
+ // `quasi_status: FINISHED` and no tool-call JSON ever followed. The OLD
167
+ // RESPONSE_CONTENT_RE (one non-quote char) matched that `{`, so the body looked
168
+ // "finished WITH an answer" (isFinishedWithAnswer === true,
169
+ // isFinishedWithoutAnswer === false): the finish loop kept polling for a
170
+ // complete answer and `ask()` gave up only at the askDeadlineMs watchdog — the
171
+ // operator watched a spinner for ~4 minutes with no tool call.
172
+ // A real answer (a tool-call JSON, prose, a `respond` call) is far longer, so
173
+ // anything below this is treated as a truncated/empty turn and retried.
174
+ const MIN_FINISHED_ANSWER_CHARS = 4;
175
+ // How many chars of RESPONSE text the body carries, or -1 when there is NO
176
+ // RESPONSE fragment at all. The fragment is SSE/JSON-encoded, so the content
177
+ // may contain `\"` escapes AND raw `"` inside the JSON (the capture is the RAW
178
+ // body — `{"type":"RESPONSE","content":"{"tool": ...` is a real shape). A naive
179
+ // `[^"]*` therefore stops at the first escaped quote and MIS-reads a long
180
+ // answer as one char. Reuse the real extractor instead of a regex: it decodes
181
+ // the content properly, so the length is exact (we only compare it to a small
182
+ // threshold; the answer text itself comes from extractAnswer()).
183
+ function responseContentLength(body) {
184
+ const b = String(body || '');
185
+ if (!/"type":"RESPONSE"/.test(b))
186
+ return -1;
187
+ return extractFromSse(b).length;
188
+ }
163
189
  // True when the SSE body shows a turn that ENDED (`quasi_status: FINISHED`)
164
- // but produced NO answer text — the reasoning (THINK fragment) was generated
165
- // and then the model stopped WITHOUT writing a RESPONSE fragment.
190
+ // but produced NO real answer text — the reasoning (THINK fragment) was
191
+ // generated and then the model stopped WITHOUT writing a RESPONSE fragment,
192
+ // OR the RESPONSE fragment is a truncated head (a lone `{`, a couple of
193
+ // chars).
166
194
  //
167
195
  // This is the "Stopped + Continue button" case: DeepSeek finishes the turn
168
196
  // with only reasoning, the UI shows a Continue button, and extractAnswer()
169
- // returns '' (it only collects RESPONSE fragments). The old code then saw an
170
- // empty capture, ignored it, and the finish loop waited out the whole timeout
171
- // and threw ds.send_no_new_answer — to the operator the agent "hung on Stopped".
197
+ // returns '' or a fragment. The old code saw a non-empty capture (the `{`),
198
+ // treated the turn as answered, and the finish loop waited out the whole
199
+ // askDeadlineMs — to the operator the agent "hung on Stopped".
172
200
  // Unlike generation_err, `quasi_status` here is FINISHED, so
173
201
  // isGenerationIncompleteText() does NOT catch it.
174
202
  //
@@ -179,11 +207,12 @@ export function isFinishedWithoutAnswer(body) {
179
207
  const finished = FINISHED_STATUS_RE.test(b);
180
208
  if (!finished)
181
209
  return false;
182
- // A RESPONSE fragment with actual content means the model DID answer.
183
- if (RESPONSE_CONTENT_RE.test(b))
210
+ // A RESPONSE fragment with real content means the model DID answer.
211
+ const len = responseContentLength(b);
212
+ if (len >= MIN_FINISHED_ANSWER_CHARS)
184
213
  return false;
185
- // The turn carried reasoning (THINK) or was truncated mid-way; treat the
186
- // absence of response content as "no answer produced".
214
+ // No RESPONSE fragment at all (len === -1) or only a truncated head:
215
+ // treat the absence of a real answer as "no answer produced".
187
216
  return true;
188
217
  }
189
218
  /**
@@ -202,7 +231,10 @@ export function isFinishedWithAnswer(body) {
202
231
  return false;
203
232
  if (!FINISHED_STATUS_RE.test(b))
204
233
  return false;
205
- return RESPONSE_CONTENT_RE.test(b);
234
+ // A truncated RESPONSE head (a lone `{`) is NOT a complete answer: the
235
+ // finish loop would return it as the model's text and the tool-call parser
236
+ // would fail on it. Require real content (see MIN_FINISHED_ANSWER_CHARS).
237
+ return responseContentLength(b) >= MIN_FINISHED_ANSWER_CHARS;
206
238
  }
207
239
  // The CONTEXT size (in tokens) DeepSeek reports for the current answer.
208
240
  //
@@ -67,7 +67,3 @@ export function clearProviderHandoff() {
67
67
  }
68
68
  catch { }
69
69
  }
70
- /** For tests: the file the handoff lives in. */
71
- export function providerHandoffFile() {
72
- return HANDOFF_FILE;
73
- }
package/dist/qwen-ui.js CHANGED
@@ -21,9 +21,6 @@ export const CHAT_ID_RE = /\/c\/([A-Za-z0-9_-]+)/;
21
21
  export function chatUrl(id) {
22
22
  return CHAT_URL + 'c/' + id;
23
23
  }
24
- // The two-step API. `NEW_CHAT_URL` creates a chat and returns its id;
25
- // `COMPLETIONS_URL(id)` streams the answer.
26
- export const NEW_CHAT_URL = 'https://chat.qwen.ai/api/v2/chats/new';
27
24
  export const MODELS_URL = 'https://chat.qwen.ai/api/v2/models/';
28
25
  // The chat list, newest first, EXCLUDING project chats (verified live on a
29
26
  // signed-in session). Used by listChats() instead of scraping the sidebar.
@@ -36,10 +33,6 @@ export function chatHistoryUrl(chatId, limit = 50) {
36
33
  '?direction=up&limit=' +
37
34
  String(limit));
38
35
  }
39
- export function completionsUrl(chatId) {
40
- return ('https://chat.qwen.ai/api/v2/chat/completions?chat_id=' +
41
- encodeURIComponent(chatId));
42
- }
43
36
  // The completions SSE endpoint carries the real answer; used to gate the
44
37
  // network capture so an unrelated JSON endpoint never clobbers it.
45
38
  export const ANSWER_URL_RE = /\/api\/v2\/chat\/completions/i;
@@ -164,7 +157,6 @@ export const ATTACH_SELECTORS = ['input#filesUpload', 'input[type="file"]'];
164
157
  // ---------- login ----------
165
158
  // Qwen's sign-in is a SEPARATE origin (auth.qwen.ai) opened from the header
166
159
  // "Log in" button. Email / Google / GitHub are accepted; no phone required.
167
- export const LOGIN_BUTTON_RE = /^\s*(log ?in|sign ?in|войти)\s*$/i;
168
160
  export const PASSWORD_SELECTORS = [
169
161
  'input[type="password"]',
170
162
  'input[autocomplete="current-password"]',
package/dist/run-task.js CHANGED
@@ -278,7 +278,7 @@ async function evaluateGoal(browser, condition, config, transcript) {
278
278
  }
279
279
  export async function runTask(deps, browser, tools, taskText, workdir, opts, attachments = []) {
280
280
  const { t, locale, debug, maxIter, config, runAgentLoop, createSpinner } = deps;
281
- const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, goalIsCondition = false, onGoalCleared = null, goalMaxTurns = GOAL_MAX_TURNS, goalQuery = null, todosQuery = null, selfImprovement = false, onSubagent = null, } = opts;
281
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, goalIsCondition = false, onGoalCleared = null, goalMaxTurns = GOAL_MAX_TURNS, goalQuery = null, todosQuery = null, selfImprovement = false, onSubagent = null, onAskUser = undefined, } = opts;
282
282
  // The ACTIVE goal for the duration of this run. It is mutable because a
283
283
  // completion goal can be cleared mid-run (verdict MET/IMPOSSIBLE, or the
284
284
  // operator runs `/goal clear` while the task is in flight). `goalQuery`
@@ -410,6 +410,29 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
410
410
  getTokenUsage,
411
411
  selfImprovement,
412
412
  onSubagent,
413
+ // The `AskUser` seam. Default: pause the editor, show the Ink modal,
414
+ // resume; on a non-TTY (or no editor) it resolves null so the model is
415
+ // told to pick a default rather than being stuck on a question nobody
416
+ // can see.
417
+ onAskUser: onAskUser === undefined
418
+ ? async (req) => {
419
+ if (!editor)
420
+ return null;
421
+ editor.pause();
422
+ try {
423
+ const { askQuestionInk } = await import('./tui/run-ask.js');
424
+ return await askQuestionInk({
425
+ question: req.question,
426
+ options: req.options,
427
+ recommended: req.recommended,
428
+ allowText: req.allowText,
429
+ });
430
+ }
431
+ finally {
432
+ editor.resume();
433
+ }
434
+ }
435
+ : onAskUser,
413
436
  // N38: the check command appended to a tool result after an edit
414
437
  // (off by default; config.diagnostics).
415
438
  diagnostics: deps.config.diagnostics,
package/dist/shell.js CHANGED
@@ -195,12 +195,6 @@ export function killBackground(id) {
195
195
  BG_PROCS.delete(id);
196
196
  return `Killed background process ${id}.`;
197
197
  }
198
- /** List running background processes (for /status and cleanup). */
199
- export function listBackground() {
200
- return [...BG_PROCS.values()]
201
- .filter((p) => p.status === 'running')
202
- .map((p) => ({ id: p.id, command: p.command }));
203
- }
204
198
  /** Kill every background process — called on exit so no orphan survives. */
205
199
  export function killAllBackground() {
206
200
  for (const id of [...BG_PROCS.keys()])