zames_pro 2.88.2 → 2.90.0

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 (52) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +32 -585
  3. package/dist/agent-loop.js +47 -4
  4. package/dist/ask-retry.js +49 -0
  5. package/dist/ask-state.js +80 -0
  6. package/dist/browser-base.js +69 -0
  7. package/dist/browser.js +170 -41
  8. package/dist/command-registry.js +58 -0
  9. package/dist/commands.js +25 -2
  10. package/dist/completion-check.js +77 -0
  11. package/dist/config-commands.js +12 -4
  12. package/dist/config-menu.js +179 -81
  13. package/dist/config-schema.js +562 -0
  14. package/dist/config.js +19 -405
  15. package/dist/dev-commands.js +27 -0
  16. package/dist/dev-watch.js +96 -0
  17. package/dist/first-run.js +80 -0
  18. package/dist/glm-browser.js +133 -32
  19. package/dist/i18n/common.js +6 -3
  20. package/dist/i18n/config.js +78 -3
  21. package/dist/i18n/deepseek.js +1 -0
  22. package/dist/i18n/wizard.js +25 -0
  23. package/dist/i18n.js +6 -1
  24. package/dist/index.js +1299 -924
  25. package/dist/info-commands.js +61 -0
  26. package/dist/input/layout.js +38 -1
  27. package/dist/input.js +2 -2
  28. package/dist/password-store.js +48 -0
  29. package/dist/prompt-budget.js +23 -0
  30. package/dist/provider-errors.js +53 -0
  31. package/dist/provider-fallback.js +46 -0
  32. package/dist/qwen-browser.js +155 -39
  33. package/dist/run-task.js +2 -1
  34. package/dist/scheduler-commands.js +41 -0
  35. package/dist/send-slot.js +36 -0
  36. package/dist/session-commands.js +42 -0
  37. package/dist/spinner.js +3 -3
  38. package/dist/swallow.js +45 -0
  39. package/dist/system-prompt.js +2 -2
  40. package/dist/tree-commands.js +36 -0
  41. package/dist/tui/ConfigMenu.js +149 -64
  42. package/dist/tui/FullScreenApp.js +13 -4
  43. package/dist/tui/InputLine.js +20 -3
  44. package/dist/tui/LogView.js +11 -5
  45. package/dist/tui/StatusBar.js +4 -1
  46. package/dist/tui/TuiApp.js +11 -2
  47. package/dist/tui/layout.js +14 -1
  48. package/dist/tui/store.js +2 -1
  49. package/dist/tui/ui-colors.js +2 -0
  50. package/dist/width.js +10 -5
  51. package/llms-full.txt +16 -292
  52. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -2,16 +2,28 @@
2
2
  import './color.js';
3
3
  import path from 'path';
4
4
  import fs from 'fs/promises';
5
- import { readFileSync, statSync } from 'fs';
5
+ import { readFileSync } from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { theme, applyTheme, themeColors, themeNames, currentThemeName, isThemeName, } from './theme.js';
8
8
  import { setInputBorder } from './tui/ui-colors.js';
9
+ import { nonReloadableSources, diffChanged } from './dev-watch.js';
10
+ import { noteSwallowed, swallowedReport } from './swallow.js';
11
+ import { pickFallbackProvider } from './provider-fallback.js';
12
+ import { SYSTEM_PROMPT_BUDGET_BYTES } from './prompt-budget.js';
13
+ import { resolvePasswordStore } from './password-store.js';
14
+ import { dispatchCommand } from './command-registry.js';
15
+ import { createInfoCommands } from './info-commands.js';
16
+ import { createSessionCommands } from './session-commands.js';
17
+ import { createTreeCommands } from './tree-commands.js';
18
+ import { createSchedulerCommands } from './scheduler-commands.js';
19
+ import { createDevCommands } from './dev-commands.js';
20
+ import { shouldRunWizard, chooseLanguage, chooseProvider, providerNote, } from './first-run.js';
9
21
  import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
10
22
  import { QwenBrowser } from './qwen-browser.js';
11
23
  import { GlmBrowser } from './glm-browser.js';
12
24
  import { authMarkerHas } from './browser-pure.js';
13
25
  import { canShowWindow } from './browser-login.js';
14
- import { isProviderId, asProviderId, providerChatUrlPrefix, providerLabel, providerHasModelPicker, providerNeedsVisibleBrowser, providerConfigSection, PROVIDER_IDS, } from './providers.js';
26
+ import { isProviderId, asProviderId, providerGuestAllowed, providerChatUrlPrefix, providerLabel, providerHasModelPicker, providerNeedsVisibleBrowser, providerConfigSection, PROVIDER_IDS, } from './providers.js';
15
27
  import { createTools, mergeTools, filterToolsForReadOnly, filterToolsByAllowList, } from './tools.js';
16
28
  import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from './extraTools.js';
17
29
  import { runAgentLoop } from './agent-loop.js';
@@ -26,7 +38,7 @@ import { runConfigMenu } from './config-menu.js';
26
38
  import { runConfigMenuInk } from './tui/run-config-menu.js';
27
39
  import { formatConfigList } from './config-commands.js';
28
40
  import { handleConfigCommand as runConfigCommand } from './config-command.js';
29
- import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n.js';
41
+ import { translate, normalizeLocale, localeDisplayName, isLocale, DEFAULT_LOCALE, } from './i18n.js';
30
42
  import { Transcript } from './transcript.js';
31
43
  import { UndoStore, undoDateLabel } from './undo.js';
32
44
  import { CheckpointStore, pickRecord as pickCheckpoint, isFullStampSelector, } from './checkpoint.js';
@@ -131,7 +143,7 @@ setInputBorder(config.ui?.inputBorder);
131
143
  // Current interface/agent language. Changed by the /config lang <ru|en> command.
132
144
  let currentLocale = isLocale(config.ui?.locale)
133
145
  ? config.ui.locale
134
- : 'ru';
146
+ : DEFAULT_LOCALE;
135
147
  // Translation: reads currentLocale at call time, so a language change takes
136
148
  // effect immediately, without a restart (for lines printed afterwards).
137
149
  const t = (key, params) => translate(currentLocale)(key, params);
@@ -388,37 +400,37 @@ async function autoReload() {
388
400
  for (const e of errors)
389
401
  console.error(theme.warn(' ' + e));
390
402
  }
391
- warnIfBrowserChanged();
403
+ warnIfNonReloadableChanged();
392
404
  }
393
- // `browser.ts` is NOT in RELOADABLE: it is imported statically (once) and the
394
- // live DeepSeekBrowser instance owns the Playwright context/page/timers, so a
395
- // hot swap is unsafe. In dev mode (`npm run dev` = tsx over src/) the operator
396
- // therefore keeps running the OLD browser code after editing it, and a fix
397
- // "does not work" until the process is restarted. We detect the mtime change
398
- // and print a ONE-TIME hint so this is never a silent trap again.
399
- let browserMtime = 0;
400
- let browserChangedWarned = false;
401
- function browserSourcePath() {
402
- // In dev the running file is src/index.ts; in the built dist — dist/index.js
403
- // with the sources next to it (../src). Either way we look for browser.ts.
404
- const srcDir = /[.]ts$/.test(new URL(import.meta.url).pathname)
405
+ // Modules in RELOADABLE are hot-swapped by /reload; EVERYTHING else in src/
406
+ // keeps running the copy loaded at startup. In dev mode (`npm run dev` = tsx
407
+ // over src/) an edit to such a file therefore "does not work" until the
408
+ // process is restarted - the operator sees the OLD code with no signal. We
409
+ // watch the whole non-reloadable set (computed from the tree, not hardcoded)
410
+ // and print ONE warning listing the changed files.
411
+ const nonReloadableMtimes = new Map();
412
+ let nonReloadableWarned = false;
413
+ function srcDirPath() {
414
+ // In dev the running file is src/index.ts; in the built dist - dist/index.js
415
+ // with the sources next to it (../src).
416
+ return /[.]ts$/.test(new URL(import.meta.url).pathname)
405
417
  ? path.dirname(fileURLToPath(import.meta.url))
406
418
  : path.join(__dirname, '..', 'src');
407
- return path.join(srcDir, 'browser.ts');
408
419
  }
409
- function warnIfBrowserChanged() {
410
- if (!devMode)
420
+ function warnIfNonReloadableChanged() {
421
+ if (!devMode || nonReloadableWarned)
411
422
  return;
412
423
  try {
413
- const p = browserSourcePath();
414
- const st = statSync(p);
415
- if (!browserMtime) {
416
- browserMtime = st.mtimeMs;
424
+ const files = nonReloadableSources(srcDirPath());
425
+ const changed = diffChanged(files, nonReloadableMtimes);
426
+ if (changed.length === 0)
417
427
  return;
418
- }
419
- if (st.mtimeMs !== browserMtime && !browserChangedWarned) {
420
- browserChangedWarned = true;
421
- console.error(theme.warn(t('reload.browser_restart')));
428
+ // The first snapshot only records the state; it must not warn.
429
+ if (nonReloadableMtimes.size === files.length && changed.length > 0) {
430
+ nonReloadableWarned = true;
431
+ console.error(theme.warn(t('reload.non_reloadable_restart', {
432
+ files: changed.map((f) => path.basename(f)).join(', '),
433
+ })));
422
434
  }
423
435
  }
424
436
  catch { }
@@ -696,10 +708,15 @@ function buildSlashCommands() {
696
708
  // We put the agent's temporary files (one-off scripts, etc.) in
697
709
  // <project>/tmp — this folder is in .gitignore and is cleaned on every launch.
698
710
  const TMP_DIR = path.join(__dirname, '..', 'tmp');
711
+ // An explicit override keeps a test/verification run from WIPING the real
712
+ // <project>/tmp on launch (that directory holds the operator's attachments).
713
+ const EFFECTIVE_TMP_DIR = process.env.ZAMES_TMP_DIR
714
+ ? path.resolve(process.env.ZAMES_TMP_DIR)
715
+ : TMP_DIR;
699
716
  async function cleanTmpDir() {
700
717
  try {
701
- await fs.rm(TMP_DIR, { recursive: true, force: true });
702
- await fs.mkdir(TMP_DIR, { recursive: true });
718
+ await fs.rm(EFFECTIVE_TMP_DIR, { recursive: true, force: true });
719
+ await fs.mkdir(EFFECTIVE_TMP_DIR, { recursive: true });
703
720
  }
704
721
  catch (e) {
705
722
  if (debug)
@@ -1094,9 +1111,87 @@ async function main() {
1094
1111
  // with a clear message instead of retrying into a dead chat.
1095
1112
  let contextLimitRecovery = null;
1096
1113
  const onContextLimit = () => contextLimitRecovery ? contextLimitRecovery() : Promise.resolve(false);
1114
+ /**
1115
+ * N19: the FIRST-RUN wizard. A brand-new install (no `~/.zames/config.json`)
1116
+ * otherwise lands straight in a chat with no explanation of the language,
1117
+ * the backend choice or why DeepSeek needs a real sign-in. It runs ONCE,
1118
+ * BEFORE the input loop (inside the loop it blocked the very first prompt),
1119
+ * never in a scripted / non-TTY session, and Esc accepts the defaults.
1120
+ */
1121
+ const runFirstRunWizard = async () => {
1122
+ if (!shouldRunWizard({
1123
+ configExists: (() => {
1124
+ try {
1125
+ return readFileSync(CONFIG_PATHS.HOME_CONFIG, 'utf-8').length > 0;
1126
+ }
1127
+ catch {
1128
+ return false;
1129
+ }
1130
+ })(),
1131
+ isTTY: !!(process.stdin.isTTY && process.stdout.isTTY),
1132
+ nonInteractiveLogin: process.env.ZAMES_NONINTERACTIVE_LOGIN === '1',
1133
+ oneShot: !!getArg('--task', null),
1134
+ })) {
1135
+ return;
1136
+ }
1137
+ try {
1138
+ console.log(theme.system(t('wizard.welcome')));
1139
+ // A raw line reader, NOT askOperatorConfirm: that one is a y/N dialog
1140
+ // (Enter = "no"), which is wrong here — the DEFAULT for a fresh install is
1141
+ // English, so an empty answer must keep it. Read a word and normalize.
1142
+ const answer = await promptOnce(t('wizard.language') + ' ');
1143
+ const wantRu = /^(ru|russian|\u0440\u0443\u0441)/i.test(String(answer || '').trim());
1144
+ const locale = wantRu ? 'ru' : 'en';
1145
+ config.ui.locale = locale;
1146
+ currentLocale = locale;
1147
+ chooseLanguage({ step: 0, locale: null, provider: null, needsLogin: false }, locale);
1148
+ // A provider id is free-form, not yes/no: read it with the raw prompt
1149
+ // (the same one the sign-in uses). An empty answer keeps the default.
1150
+ const rawProvider = await promptOnce(theme.prompt(t('wizard.provider')));
1151
+ const picked = isProviderId(String(rawProvider || '').trim())
1152
+ ? asProviderId(String(rawProvider || '').trim())
1153
+ : asProviderId(config.provider);
1154
+ chooseProvider({ step: 1, locale, provider: null, needsLogin: false }, picked);
1155
+ config.provider = picked;
1156
+ setProviderEnabledFlag(picked);
1157
+ console.log(theme.dim(providerNote(picked)));
1158
+ try {
1159
+ writeConfigValue('home', 'provider', picked);
1160
+ writeConfigValue('home', 'ui.locale', locale);
1161
+ }
1162
+ catch (e) {
1163
+ noteSwallowed('wizard_config_write', e, debug);
1164
+ }
1165
+ console.log(theme.system(t('wizard.hint')));
1166
+ }
1167
+ catch (e) {
1168
+ // Esc / a closed stdin: accept the defaults, never block the first run.
1169
+ noteSwallowed('first_run_wizard', e, debug);
1170
+ console.log(theme.dim(t('wizard.skipped')));
1171
+ }
1172
+ };
1173
+ // N19: the wizard runs ONCE here, BEFORE the input loop and before the
1174
+ // browser is launched (a provider picked here must be the one that starts).
1175
+ // It used to be inserted INSIDE the loop, where it held the first prompt:
1176
+ // the terminal looked ready while a modal waited, and every command hung.
1177
+ await runFirstRunWizard();
1097
1178
  let browser = createProviderBrowser(asProviderId(config.provider), onContextLimit);
1098
- const bootSpinner = mod.createSpinner(currentLocale);
1099
- bootSpinner.thinking();
1179
+ // NO ora spinner while the TUI is going to own the terminal.
1180
+ //
1181
+ // ora writes to stderr and erases/redraws ITS OWN line; Ink (the TUI)
1182
+ // repaints the WHOLE frame through log-update on the same terminal. The two
1183
+ // invalidate each other every tick: measured 53 MB / ~5.9 M `\e[1A\e[0K`
1184
+ // sequences in 60 s of `waitForLogin` (the operator saw a caret that "does
1185
+ // not work" — the screen was being repainted ~100k times a second). The TUI
1186
+ // status bar already animates that phase, so the ora spinner is only for the
1187
+ // non-TUI path. `bootSpinner` stays a no-op stop() object so the error path
1188
+ // below needs no branching.
1189
+ const tuiWillRun = !!(process.stdin.isTTY && process.stdout.isTTY) && !!config.ui.tui;
1190
+ const bootSpinner = tuiWillRun
1191
+ ? { thinking() { }, stop() { }, setLocale() { } }
1192
+ : mod.createSpinner(currentLocale);
1193
+ if (!tuiWillRun)
1194
+ bootSpinner.thinking();
1100
1195
  try {
1101
1196
  await browser.launch();
1102
1197
  bootSpinner.stop();
@@ -1134,64 +1229,6 @@ async function main() {
1134
1229
  warnUiHealth(await browser.probeUiHealth());
1135
1230
  }
1136
1231
  catch { }
1137
- // One-shot mode
1138
- if (task) {
1139
- const baseTools = mod.createTools(currentWorkdir, {
1140
- undo,
1141
- readOnly: planMode,
1142
- });
1143
- const tools = mergeTools(baseTools, mcpPool?.tools, planMode);
1144
- let freshChat = true;
1145
- let sendSystemPrompt = true;
1146
- // By default we start a new chat. To continue a previous session —
1147
- // explicitly: --chat <id> or --resume-last.
1148
- let resumeId = chatIdArg;
1149
- if (!resumeId && resumeLastFlag && !newChatFlag) {
1150
- const last = loadLastSession(currentWorkdir, config.provider);
1151
- if (last && last.id) {
1152
- resumeId = last.id;
1153
- console.log(theme.system(t('msg.resuming', { id: resumeId })));
1154
- }
1155
- }
1156
- if (resumeId) {
1157
- try {
1158
- console.log(theme.system(t('msg.opening_chat', { id: resumeId })));
1159
- await browser.openChat(resumeId);
1160
- freshChat = false;
1161
- sendSystemPrompt =
1162
- resendPrompt || config.browser.resendPromptOnResume === true;
1163
- saveLastChat(resumeId, currentWorkdir);
1164
- }
1165
- catch (e) {
1166
- console.error(theme.error(t('msg.open_chat_error', { v: e.message })));
1167
- }
1168
- }
1169
- await autoReload();
1170
- const result = await mod.runTask(runTaskDeps(), browser, tools, task, currentWorkdir, {
1171
- transcript,
1172
- freshChat,
1173
- sendSystemPrompt,
1174
- selfImprovement: devMode,
1175
- onChatReady: (chatId) => {
1176
- if (chatId)
1177
- saveLastChat(chatId, currentWorkdir);
1178
- },
1179
- });
1180
- // One-shot mode (--task) is documented for scripts/CI. A failed agent run
1181
- // used to exit 0, so the CI job stayed green; surface it as exit code 1.
1182
- if (!result.ok)
1183
- process.exitCode = 1;
1184
- await browser.close();
1185
- await mod.closeWeb().catch(() => { });
1186
- if (mcpPool)
1187
- await mcpPool.close().catch(() => { });
1188
- await transcript.close();
1189
- return;
1190
- }
1191
- if (providerNeedsVisibleBrowser(config.provider) &&
1192
- headlessFor(config.provider)) {
1193
- console.log(theme.warn(t('provider.needs_headed', { v: providerLabel(config.provider) })));
1194
- }
1195
1232
  console.log(theme.system(t('msg.interactive')));
1196
1233
  console.log(theme.system(t('msg.queue_hint')));
1197
1234
  console.log(theme.dim(t('msg.first_hint')));
@@ -1270,6 +1307,13 @@ async function main() {
1270
1307
  // /resume <n> handler that read it was removed).
1271
1308
  let lastChats;
1272
1309
  let currentChatId = null;
1310
+ // The chat the NEXT send must land in. `currentChatId` is only a cache of
1311
+ // "the id seen on the page"; after a page restart that cache is stale and
1312
+ // the URL carries no id, so `freshChatNext` opened a NEW chat and the
1313
+ // operator lost the resumed dialogue. This id is set on resume/restore and
1314
+ // cleared when a genuinely fresh chat starts; the agent loop reads it via
1315
+ // `browser.resumeChatId`.
1316
+ let resumeChatId = null;
1273
1317
  // The last task the operator sent (used by /retry). Plain text only.
1274
1318
  let lastTaskText = '';
1275
1319
  let running = true;
@@ -1477,6 +1521,7 @@ async function main() {
1477
1521
  console.log(theme.system(t('msg.opening_chat', { id: resumeId })));
1478
1522
  await browser.openChat(resumeId);
1479
1523
  currentChatId = resumeId;
1524
+ resumeChatId = resumeId;
1480
1525
  freshChatNext = false;
1481
1526
  sendSystemPromptNext = promptOnResume();
1482
1527
  saveLastChat(resumeId, currentWorkdir);
@@ -1537,7 +1582,7 @@ async function main() {
1537
1582
  locale: currentLocale,
1538
1583
  });
1539
1584
  editor = ed;
1540
- ed.setTmpDir(TMP_DIR);
1585
+ ed.setTmpDir(EFFECTIVE_TMP_DIR);
1541
1586
  // Provider badge: show which backend is answering ([Qwen]/[DeepSeek]).
1542
1587
  ed.setProvider(providerLabel(config.provider));
1543
1588
  // Header for full-screen mode (title / version / working directory).
@@ -1676,7 +1721,7 @@ async function main() {
1676
1721
  const image = parseImagePaste(raw);
1677
1722
  if (image) {
1678
1723
  const name = 'paste' + extForMime(image.mime);
1679
- const p = await saveToTemp(TMP_DIR, name, image.data);
1724
+ const p = await saveToTemp(EFFECTIVE_TMP_DIR, name, image.data);
1680
1725
  const att = ed.attachments.add({
1681
1726
  path: p,
1682
1727
  name,
@@ -1750,7 +1795,7 @@ async function main() {
1750
1795
  }
1751
1796
  const mime = sniffMime(res.data) || 'image/png';
1752
1797
  const name = 'clipboard-' + Date.now() + (extForMime(mime) || '.png');
1753
- const p = await saveToTemp(TMP_DIR, name, res.data);
1798
+ const p = await saveToTemp(EFFECTIVE_TMP_DIR, name, res.data);
1754
1799
  const att = ed.attachments.add({
1755
1800
  path: p,
1756
1801
  name,
@@ -1818,6 +1863,22 @@ async function main() {
1818
1863
  void handleGoal(text);
1819
1864
  return;
1820
1865
  }
1866
+ // `/tasks` is READ-ONLY (it prints the TodoWrite checklist from the
1867
+ // shared store): it must never be queued behind the running task, since
1868
+ // the whole point is to see what the agent is doing RIGHT NOW.
1869
+ if (/^\/tasks\s*$/i.test(text.trim())) {
1870
+ const items = getTodos(todoStore);
1871
+ if (!items.length) {
1872
+ ed.printAbove(theme.dim(t('tasks.empty')));
1873
+ }
1874
+ else {
1875
+ ed.printAbove(theme.system(t('tasks.title')));
1876
+ for (const line of renderTodos(items).split(NL).slice(1)) {
1877
+ ed.printAbove(theme.assistant(line));
1878
+ }
1879
+ }
1880
+ return;
1881
+ }
1821
1882
  }
1822
1883
  const attachments = (items || []).map((a) => ({
1823
1884
  path: a.path,
@@ -2326,6 +2387,7 @@ async function main() {
2326
2387
  try {
2327
2388
  await browser.openChat(pick.id);
2328
2389
  currentChatId = pick.id;
2390
+ resumeChatId = pick.id;
2329
2391
  freshChatNext = false;
2330
2392
  sendSystemPromptNext = promptOnResume();
2331
2393
  saveLastChat(pick.id, currentWorkdir, pick.title);
@@ -2420,357 +2482,1165 @@ async function main() {
2420
2482
  await openResumedChat({ id: pick.id, title: pick.title || '' });
2421
2483
  return true;
2422
2484
  };
2423
- while (running) {
2424
- let input;
2425
- let inputAttachments = [];
2426
- // Set by `/goal <condition>` to fall through into a task turn with this
2427
- // text instead of returning to the prompt (Claude Code starts a turn the
2428
- // moment a goal is set). Reset every iteration because it is declared here.
2429
- let taskOverride = null;
2485
+ /**
2486
+ * N8: continue the task on an AUTHORIZED fallback provider after the active
2487
+ * one exhausted its retries. Returns true when the backend really switched
2488
+ * (the caller then resends the same prompt). A guest session or a dead
2489
+ * marker is never used: the operator is told which provider can be
2490
+ * authorized and how.
2491
+ */
2492
+ const fallbackToAuthorizedProvider = async (err) => {
2493
+ const configured = (config.browser.fallbackProviders || []).filter(isProviderId);
2494
+ if (!configured.length)
2495
+ return false;
2496
+ const active = asProviderId(config.provider);
2497
+ const candidates = configured
2498
+ .filter((id) => id !== active)
2499
+ .map((id) => ({
2500
+ id,
2501
+ guestAllowed: providerGuestAllowed(id),
2502
+ // Only a REAL session counts. The marker is the evidence; the switch
2503
+ // below re-checks it with waitForLogin().
2504
+ guestSession: !authMarkerExists(id),
2505
+ authMarker: authMarkerExists(id) ? 'yes' : '',
2506
+ }));
2507
+ const decision = await pickFallbackProvider(active, candidates, async (id) => authMarkerExists(id), (raw, id) => raw === 'yes' && authMarkerExists(id));
2508
+ if (!decision.provider) {
2509
+ console.error(theme.warn(t('provider.fallback_none', {
2510
+ from: providerLabel(active),
2511
+ providers: (decision.authorizable.length
2512
+ ? decision.authorizable
2513
+ : configured)
2514
+ .map((id) => providerLabel(id))
2515
+ .join(', ') || '-',
2516
+ })));
2517
+ return false;
2518
+ }
2519
+ const target = decision.provider;
2520
+ if (config.browser.fallbackMode !== 'auto') {
2521
+ const ok = await askOperatorConfirm(editor, t('provider.fallback_ask', {
2522
+ from: providerLabel(active),
2523
+ to: providerLabel(target),
2524
+ }), t, currentLocale);
2525
+ if (!ok)
2526
+ return false;
2527
+ }
2528
+ console.log(theme.system(t('provider.fallback_switch', {
2529
+ from: providerLabel(active),
2530
+ to: providerLabel(target),
2531
+ })));
2532
+ transcript.log('provider_fallback', {
2533
+ from: active,
2534
+ to: target,
2535
+ error: err.message.slice(0, 200),
2536
+ });
2537
+ // A switch failure leaves the old browser in place, so returning false is
2538
+ // safe. The handoff machinery is the SAME one /provider uses.
2430
2539
  try {
2431
- if (editor) {
2432
- editor.setPrompt(buildPrompt());
2433
- const msg = await takeInput();
2434
- input = msg ? msg.text : null;
2435
- inputAttachments = msg?.attachments || [];
2540
+ config.provider = target;
2541
+ setProviderEnabledFlag(target);
2542
+ writeConfigValue('home', 'provider', target);
2543
+ const nextBrowser = createProviderBrowser(target, onContextLimit);
2544
+ await nextBrowser.waitForLogin();
2545
+ const prev = browser;
2546
+ browser = nextBrowser;
2547
+ try {
2548
+ await prev.close();
2436
2549
  }
2437
- else {
2438
- let tail;
2439
- // The non-TTY fallback prompt mirrors buildPrompt(): bare, no dir.
2440
- if (reviewMode) {
2441
- tail =
2442
- theme.dim('(') +
2443
- theme.warn(t('prompt.review')) +
2444
- theme.dim(':') +
2445
- theme.dir(reviewMode.snapName) +
2446
- theme.dim(') ');
2447
- }
2448
- input = await promptOnce(theme.prompt('❯ ') + tail);
2550
+ catch (e) {
2551
+ noteSwallowed('fallback_close_prev', e, debug);
2449
2552
  }
2553
+ currentChatId = await browser.getCurrentChatId();
2554
+ resumeChatId = null;
2555
+ saveLastChat(currentChatId, currentWorkdir);
2556
+ freshChatNext = true;
2557
+ sendSystemPromptNext = true;
2558
+ return true;
2450
2559
  }
2451
- catch {
2452
- break;
2560
+ catch (e) {
2561
+ console.error(theme.error(t('provider.switch_failed', {
2562
+ v: providerLabel(target),
2563
+ error: e.message,
2564
+ })));
2565
+ return false;
2453
2566
  }
2454
- const trimmed = (input || '').trim();
2455
- if (!trimmed)
2456
- continue;
2457
- const lower = trimmed.toLowerCase();
2458
- if (['/exit', '/quit', 'exit', 'quit'].includes(lower))
2459
- break;
2460
- // Dev-only commands (backlog / self-review) must not run outside dev mode.
2461
- // Hiding them from /help is not enough: a user could type the command by
2462
- // hand, and it would rewrite their project's BACKLOG.md or snapshot src/.
2463
- if (!devMode) {
2464
- const devCmd = mod.commands.isDevOnlyCommand(lower);
2465
- if (devCmd) {
2466
- console.error(theme.warn(t('msg.dev_only', { v: devCmd })));
2467
- continue;
2567
+ };
2568
+ // One-shot mode
2569
+ if (task) {
2570
+ const baseTools = mod.createTools(currentWorkdir, {
2571
+ undo,
2572
+ readOnly: planMode,
2573
+ });
2574
+ const tools = mergeTools(baseTools, mcpPool?.tools, planMode);
2575
+ let freshChat = true;
2576
+ let sendSystemPrompt = true;
2577
+ // By default we start a new chat. To continue a previous session —
2578
+ // explicitly: --chat <id> or --resume-last.
2579
+ let resumeId = chatIdArg;
2580
+ if (!resumeId && resumeLastFlag && !newChatFlag) {
2581
+ const last = loadLastSession(currentWorkdir, config.provider);
2582
+ if (last && last.id) {
2583
+ resumeId = last.id;
2584
+ console.log(theme.system(t('msg.resuming', { id: resumeId })));
2468
2585
  }
2469
2586
  }
2470
- // `!command` — run a shell command directly, without asking the model
2471
- // (like Claude Code's bash mode). The same sandbox guard as the Bash tool
2472
- // applies, so a direct command cannot leave the project either.
2473
- if (trimmed.startsWith('!')) {
2474
- const cmd = trimmed.slice(1).trim();
2475
- if (!cmd) {
2476
- console.error(theme.warn(t('shell.empty')));
2477
- continue;
2478
- }
2587
+ if (resumeId) {
2479
2588
  try {
2480
- assertCommandInsideRoot(currentWorkdir, cmd);
2589
+ console.log(theme.system(t('msg.opening_chat', { id: resumeId })));
2590
+ await browser.openChat(resumeId);
2591
+ freshChat = false;
2592
+ sendSystemPrompt =
2593
+ resendPrompt || config.browser.resendPromptOnResume === true;
2594
+ saveLastChat(resumeId, currentWorkdir);
2481
2595
  }
2482
2596
  catch (e) {
2483
- console.error(theme.error(e.message));
2484
- continue;
2485
- }
2486
- transcript.log('shell_command', { command: cmd });
2487
- if (editor)
2488
- editor.lock(t('shell.running'));
2489
- try {
2490
- const out = await runShell(currentWorkdir, cmd);
2491
- console.log(theme.system(out));
2492
- transcript.log('shell_result', { command: cmd, result: out });
2493
- }
2494
- finally {
2495
- if (editor)
2496
- editor.unlock();
2597
+ console.error(theme.error(t('msg.open_chat_error', { v: e.message })));
2497
2598
  }
2498
- continue;
2499
2599
  }
2500
- if (lower === '/help' ||
2501
- lower === 'help' ||
2502
- lower.startsWith('/help ') ||
2503
- lower.startsWith('help ')) {
2504
- const topic = trimmed.replace(/^\/?help\s*/i, '').trim();
2505
- if (!topic) {
2506
- printHelp();
2507
- }
2508
- else {
2509
- // /help <command> — print the full description of ONE command instead
2510
- // of making the operator scroll the whole list.
2511
- const q = '/' + topic.replace(/^\//, '').toLowerCase();
2512
- const match = buildSlashCommands().filter((c) => c.name.toLowerCase() === q ||
2513
- c.name.toLowerCase().startsWith(q + ' '));
2514
- if (!match.length) {
2515
- console.error(theme.warn(t('help.no_topic', { v: topic })));
2516
- }
2517
- else {
2518
- for (const c of match) {
2519
- console.log(theme.assistant(c.name) + theme.dim(' ' + c.description));
2520
- }
2521
- }
2522
- }
2523
- continue;
2600
+ await autoReload();
2601
+ const result = await mod.runTask(runTaskDeps(), browser, tools, task, currentWorkdir, {
2602
+ transcript,
2603
+ freshChat,
2604
+ sendSystemPrompt,
2605
+ selfImprovement: devMode,
2606
+ onChatReady: (chatId) => {
2607
+ if (chatId)
2608
+ saveLastChat(chatId, currentWorkdir);
2609
+ },
2610
+ });
2611
+ // One-shot mode (--task) is documented for scripts/CI. A failed agent run
2612
+ // used to exit 0, so the CI job stayed green; surface it as exit code 1.
2613
+ if (!result.ok)
2614
+ process.exitCode = 1;
2615
+ await browser.close();
2616
+ await mod.closeWeb().catch(() => { });
2617
+ if (mcpPool)
2618
+ await mcpPool.close().catch(() => { });
2619
+ await transcript.close();
2620
+ return;
2621
+ }
2622
+ if (providerNeedsVisibleBrowser(config.provider) &&
2623
+ headlessFor(config.provider)) {
2624
+ console.log(theme.warn(t('provider.needs_headed', { v: providerLabel(config.provider) })));
2625
+ }
2626
+ /** The live session state rendered by /status (and the registry handler). */
2627
+ const statusSnapshot = () => {
2628
+ const statusToggles = browser.getToggleStatesSync();
2629
+ return {
2630
+ workdir: currentWorkdir,
2631
+ reviewMode: reviewMode ? reviewMode.snapName : null,
2632
+ origDir: reviewMode ? reviewMode.originalWorkdir : null,
2633
+ chatId: currentChatId,
2634
+ freshNext: freshChatNext,
2635
+ promptNext: sendSystemPromptNext,
2636
+ lastChat: loadLastChat(currentWorkdir),
2637
+ sessionsDir: sessionsDir(),
2638
+ devMode,
2639
+ maxIterations: currentMaxIter(),
2640
+ headless,
2641
+ debug,
2642
+ deepThinking: statusToggles.deepThinking,
2643
+ webSearch: statusToggles.webSearch,
2644
+ undo: config.undo.enabled,
2645
+ checkpoint: config.checkpoint.enabled,
2646
+ transcriptFile: transcript.file,
2647
+ goal: sessionGoal,
2648
+ localeName: localeDisplayName(currentLocale),
2649
+ mcpToolCount: mcpPool ? mcpPool.status().toolCount : null,
2650
+ tokenUsage: browser.getLastTokenUsage(),
2651
+ };
2652
+ };
2653
+ /** /sessions [filter]: the saved sessions (a pick list in the TUI). */
2654
+ const sessionsCommand = async (filter) => {
2655
+ // /sessions <query> filters by title or workdir (substring). Sorted by
2656
+ // recency already (listSessions).
2657
+ const all = filterSessions(listSessions(), filter);
2658
+ console.log(theme.system(t('sessions.dir', { v: sessionsDir() })));
2659
+ if (!all.length) {
2660
+ console.log(theme.system(t('sessions.none')));
2524
2661
  }
2525
- if (['/new', '/clear', 'new'].includes(lower)) {
2526
- if (editor)
2527
- editor.lock(t('msg.input_locked'));
2528
- try {
2529
- await browser.newChat();
2530
- freshChatNext = false;
2531
- sendSystemPromptNext = true;
2532
- // A new chat starts a fresh checklist: keeping the old tasks would
2533
- // label them as "restored" in a chat that never saw them.
2534
- resetTodos(todoStore);
2535
- currentChatId = await browser.getCurrentChatId();
2536
- saveLastChat(currentChatId, currentWorkdir);
2537
- transcript.log('new_chat');
2538
- // Start the new chat on a clean screen (like /clear in a shell).
2539
- if (editor)
2540
- editor.clearScreen();
2541
- console.log(theme.system(t('msg.new_chat')));
2542
- console.log(theme.system(t('msg.new_chat_ok') + String.fromCharCode(10)));
2543
- }
2544
- catch (e) {
2545
- console.error(theme.error(t('msg.new_chat_error', { v: e.message })), e.message);
2546
- }
2547
- finally {
2548
- if (editor)
2549
- editor.unlock();
2662
+ else {
2663
+ // In the TUI, open a SELECT LIST and resume the chosen session
2664
+ // directly (same behavior as /chats), instead of only listing ids.
2665
+ const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
2666
+ if (canPick && editor) {
2667
+ const opened = await pickSessionInteractively(all);
2668
+ if (opened)
2669
+ return;
2550
2670
  }
2551
- continue;
2671
+ all.forEach((s, i) => {
2672
+ const n = String(i + 1).padStart(2, ' ');
2673
+ const mark = s.id === currentChatId ? theme.user(' *') : '';
2674
+ const title = s.title ? ` ${s.title}` : '';
2675
+ const wd = s.workdir ? theme.dim(` [${dirLabel(s.workdir)}]`) : '';
2676
+ const age = mod.commands.formatRelativeTime(s.updatedAt, t);
2677
+ const ageText = age ? theme.dim(' ' + age) : '';
2678
+ console.log(` ${theme.user(n)}. ${s.id.slice(0, 8)}…${title}${wd}${ageText}${mark}`);
2679
+ });
2680
+ console.log(theme.system(t('sessions.restore_hint')));
2552
2681
  }
2553
- if (lower === '/self-review' || lower.startsWith('/self-review ')) {
2554
- const focus = trimmed.slice('/self-review'.length).trim();
2555
- // Remember where to return
2556
- const originalWorkdir = reviewMode
2557
- ? reviewMode.originalWorkdir
2558
- : currentWorkdir;
2559
- if (editor)
2560
- editor.lock(t('msg.input_locked'));
2561
- try {
2562
- const result = await mod.selfReview({
2563
- browser,
2564
- config,
2565
- focus: focus || undefined,
2566
- transcript,
2567
- locale: currentLocale,
2568
- });
2569
- // Switch to review mode:
2570
- // - working directory = snapshot
2571
- // - we do NOT reset the chat — inside selfReview a fresh chat was
2572
- // already created and the review prompt sent, we continue in it
2573
- reviewMode = {
2574
- snapDir: result.snapDir,
2575
- snapName: path.basename(result.snapDir),
2576
- originalWorkdir,
2577
- };
2578
- currentWorkdir = result.snapDir;
2579
- freshChatNext = false;
2580
- sendSystemPromptNext = false;
2581
- currentChatId = await browser.getCurrentChatId();
2582
- saveLastChat(currentChatId, currentWorkdir);
2583
- console.log(theme.user(t('self.review_hint', { name: reviewMode.snapName })));
2682
+ };
2683
+ /** /last: reopen the last chat of this working directory. */
2684
+ const lastCommand = async () => {
2685
+ // Quick resume of the last chat for THIS working directory — otherwise
2686
+ // finding it meant /chats (a network fetch) or copying the id.
2687
+ const last = loadLastSession(currentWorkdir, config.provider);
2688
+ if (!last || !last.id) {
2689
+ console.error(theme.error(t('sessions.none_here')));
2690
+ return;
2691
+ }
2692
+ if (editor)
2693
+ editor.lock(t('msg.input_locked'));
2694
+ try {
2695
+ if (editor)
2696
+ editor.clearScreen();
2697
+ console.log(theme.system(t('msg.opening_chat', { id: last.id })));
2698
+ await browser.openChat(last.id);
2699
+ currentChatId = last.id;
2700
+ resumeChatId = last.id;
2701
+ freshChatNext = false;
2702
+ sendSystemPromptNext = promptOnResume();
2703
+ saveLastChat(last.id, currentWorkdir, last.title);
2704
+ transcript.log('resume_chat', { id: last.id, title: last.title });
2705
+ console.log(theme.assistant(t('msg.chat_opened')));
2706
+ loadSessionTodos(last.id);
2707
+ await printRestoredHistory(browser, editor, last.id);
2708
+ }
2709
+ catch (e) {
2710
+ console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
2711
+ }
2712
+ finally {
2713
+ if (editor)
2714
+ editor.unlock();
2715
+ }
2716
+ };
2717
+ /** /resume-id <id>: open a chat by its full id. */
2718
+ const resumeIdCommand = async (raw) => {
2719
+ const id = parseResumeId(raw);
2720
+ if (!id) {
2721
+ console.error(theme.error(t('sessions.resume_id_usage')));
2722
+ return;
2723
+ }
2724
+ // A chat id only exists on the backend that created it, so opening a
2725
+ // session saved by ANOTHER provider fails deep inside the browser with a
2726
+ // confusing error. Refuse up front and name the right /provider command.
2727
+ const refusal = resumeProviderRefusal(readSession(id), config.provider, t);
2728
+ if (refusal) {
2729
+ console.error(theme.error(refusal));
2730
+ return;
2731
+ }
2732
+ if (editor)
2733
+ editor.lock(t('msg.input_locked'));
2734
+ try {
2735
+ if (editor)
2736
+ editor.clearScreen();
2737
+ console.log(theme.system(t('msg.opening_chat', { id })));
2738
+ await browser.openChat(id);
2739
+ currentChatId = id;
2740
+ resumeChatId = id;
2741
+ freshChatNext = false;
2742
+ sendSystemPromptNext = promptOnResume();
2743
+ saveLastChat(id, currentWorkdir);
2744
+ transcript.log('resume_chat', { id });
2745
+ console.log(theme.assistant(t('msg.chat_opened')) +
2746
+ theme.system(String.fromCharCode(10)));
2747
+ loadSessionTodos(id);
2748
+ await printRestoredHistory(browser, editor, id);
2749
+ }
2750
+ catch (e) {
2751
+ console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
2752
+ }
2753
+ finally {
2754
+ if (editor)
2755
+ editor.unlock();
2756
+ }
2757
+ };
2758
+ /** /chats [filter]: the backend chat list (internals hidden). */
2759
+ const chatsCommand = async (filter) => {
2760
+ const spin = editor || mod.createSpinner(currentLocale);
2761
+ if (editor)
2762
+ editor.lock(t('msg.input_locked'));
2763
+ spin.thinking();
2764
+ try {
2765
+ const all = await browser.listChats(30);
2766
+ // Hide subagent chats: they are real DeepSeek chats, but they are
2767
+ // internal context-isolation artifacts whose report already lives in
2768
+ // the parent chat, so resuming one is never what the operator wants.
2769
+ // The scraper runs inside the page and cannot localize: a row without
2770
+ // text comes back with an EMPTY title, so the label is applied HERE
2771
+ // (one place, in the operator's language) instead of a hardcoded one.
2772
+ lastChats = all
2773
+ .filter((c) => !isInternalChat(c.id))
2774
+ .map((c) => ({
2775
+ ...c,
2776
+ title: c.title && c.title.trim() ? c.title : t('chats.untitled'),
2777
+ }));
2778
+ spin.stop();
2779
+ // An optional query filters by title (substring, case-insensitive),
2780
+ // so a long chat list does not have to be scanned by eye.
2781
+ const shown = filter
2782
+ ? lastChats.filter((c) => c.title.toLowerCase().includes(filter))
2783
+ : lastChats;
2784
+ if (!shown.length) {
2785
+ console.log(theme.system(t('chats.none_hint')));
2584
2786
  }
2585
- catch (e) {
2586
- console.error(theme.error(t('self.review_failed')), e.message);
2587
- if (debug)
2588
- console.error(e.stack);
2787
+ else {
2788
+ // In the TUI, open a real SELECT LIST (like /model) instead of
2789
+ // dumping text: pick a chat and it opens directly. The text path
2790
+ // below stays for non-TTY / the legacy editor.
2791
+ const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
2792
+ if (canPick && editor) {
2793
+ const openChat = await pickChatInteractively(shown);
2794
+ if (openChat)
2795
+ return;
2796
+ }
2797
+ console.log(theme.system(t('chats.recent')));
2798
+ // The number is the index in the FULL list (a filter only narrows
2799
+ // the VIEW), and the full id in brackets is what /resume-id takes.
2800
+ for (const c of shown) {
2801
+ const idx = lastChats.indexOf(c);
2802
+ const n = String(idx + 1).padStart(2, ' ');
2803
+ console.log(` ${theme.user(n)}. ${c.title} ${theme.system('(' + c.id.slice(0, 8) + '…)')}`);
2804
+ }
2805
+ console.log(theme.system(t('chats.use_resume')));
2589
2806
  }
2590
- finally {
2807
+ }
2808
+ catch (e) {
2809
+ spin.stop();
2810
+ console.error(theme.error(t('chats.fetch_error')), e.message);
2811
+ }
2812
+ finally {
2813
+ if (editor)
2814
+ editor.unlock();
2815
+ }
2816
+ };
2817
+ /** /new and /clear: a fresh chat on a clean screen. */
2818
+ const newChatCommand = async () => {
2819
+ if (editor)
2820
+ editor.lock(t('msg.input_locked'));
2821
+ try {
2822
+ await browser.newChat();
2823
+ resumeChatId = null;
2824
+ freshChatNext = false;
2825
+ sendSystemPromptNext = true;
2826
+ // A new chat starts a fresh checklist: keeping the old tasks would
2827
+ // label them as "restored" in a chat that never saw them.
2828
+ resetTodos(todoStore);
2829
+ currentChatId = await browser.getCurrentChatId();
2830
+ saveLastChat(currentChatId, currentWorkdir);
2831
+ transcript.log('new_chat');
2832
+ // Start the new chat on a clean screen (like /clear in a shell).
2833
+ if (editor)
2834
+ editor.clearScreen();
2835
+ console.log(theme.system(t('msg.new_chat')));
2836
+ console.log(theme.system(t('msg.new_chat_ok') + String.fromCharCode(10)));
2837
+ }
2838
+ catch (e) {
2839
+ console.error(theme.error(t('msg.new_chat_error', { v: e.message })), e.message);
2840
+ }
2841
+ finally {
2842
+ if (editor)
2843
+ editor.unlock();
2844
+ }
2845
+ };
2846
+ /** /diff and /diffstat: the working-tree (or staged) diff. */
2847
+ const diffCommand = async (staged, stat) => {
2848
+ const { runGitArgs } = await import('./gitTools.js');
2849
+ const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], currentWorkdir, 5000);
2850
+ if (probe.trim() !== 'true') {
2851
+ console.error(theme.error(t('diff.not_repo')));
2852
+ return;
2853
+ }
2854
+ const out = await runGitArgs(stat
2855
+ ? mod.commands.diffStatGitArgs(staged)
2856
+ : mod.commands.diffGitArgs(staged), currentWorkdir, 20_000);
2857
+ console.log(theme.system(stat
2858
+ ? mod.commands.formatDiffStat(out, { maxLines: 60 }, t)
2859
+ : mod.commands.formatDiff(out, { maxLines: 400 }, t)));
2860
+ };
2861
+ /** /undo: revert the last file edit. */
2862
+ const undoCommand = async () => {
2863
+ const result = await undo.undoLast();
2864
+ if (result.ok && result.record) {
2865
+ console.log(theme.assistant(t('undo.reverted', { v: result.record.originalPath })) +
2866
+ theme.system(result.record.existed ? t('undo.restored') : t('undo.deleted')));
2867
+ transcript.log('undo', { path: result.record.originalPath });
2868
+ }
2869
+ else {
2870
+ // undo.ts returns machine codes ('empty'/'disabled') — localize here so
2871
+ // an English UI never shows a Russian reason.
2872
+ const reasonKey = result.reason === 'empty'
2873
+ ? 'undo.reason_empty'
2874
+ : result.reason === 'disabled'
2875
+ ? 'undo.reason_disabled'
2876
+ : null;
2877
+ const reason = reasonKey ? t(reasonKey) : String(result.reason ?? '');
2878
+ console.error(theme.error(t('undo.failed', { v: reason })));
2879
+ }
2880
+ };
2881
+ /** /undo-list (/history): the recent undo records. */
2882
+ const undoListCommand = async () => {
2883
+ const list = await undo.list(10);
2884
+ if (!list.length) {
2885
+ console.log(theme.system(t('undo.empty')));
2886
+ }
2887
+ else {
2888
+ for (const r of list) {
2889
+ const stamp = undoDateLabel(r.stamp);
2890
+ const flag = r.existed ? t('undo.changed') : t('undo.created');
2891
+ console.log(theme.system(`${stamp} [${flag}] ${r.originalPath}`));
2892
+ }
2893
+ }
2894
+ };
2895
+ /** /rewind-list (/checkpoints): the recent tree checkpoints. */
2896
+ const rewindListCommand = async () => {
2897
+ const list = await checkpoints.list(10);
2898
+ if (!list.length) {
2899
+ console.log(theme.system(t('rewind.empty')));
2900
+ }
2901
+ else {
2902
+ console.log(theme.system(t('rewind.list_title')));
2903
+ list.forEach((r, i) => {
2904
+ const stamp = undoDateLabel(r.stamp);
2905
+ const label = r.label ? ' [' + t('rewind.label_backup') + ']' : '';
2906
+ console.log(theme.system(` ${i + 1}. ${stamp} ${r.workdir}${label}`));
2907
+ });
2908
+ }
2909
+ };
2910
+ /** /rewind [n|stamp] [--yes]: restore the working tree from a checkpoint. */
2911
+ const rewindCommand = async (selector, line) => {
2912
+ if (editor)
2913
+ editor.lock(t('msg.input_locked'));
2914
+ try {
2915
+ // The DISPLAY window is the newest 10, but a FULL stamp must resolve
2916
+ // against the whole index (restore() searches all of it): an older
2917
+ // checkpoint picked by its stamp used to report "/rewind failed", and a
2918
+ // stamp sharing its first digits with a newer one could resolve to
2919
+ // THAT wrong record.
2920
+ const list = await checkpoints.list(10);
2921
+ const all = isFullStampSelector(selector)
2922
+ ? await checkpoints.list(Number.MAX_SAFE_INTEGER)
2923
+ : list;
2924
+ const target = pickCheckpoint(list, selector, all);
2925
+ if (!target) {
2926
+ console.error(theme.error(t('rewind.failed', { v: selector })));
2927
+ return;
2928
+ }
2929
+ // Confirmation: a rewind overwrites the working tree, so it must never
2930
+ // happen on a muscle-memory Enter.
2931
+ const stamp = new Date(target.stamp).toLocaleString();
2932
+ let answer = 'n';
2933
+ if (process.stdin.isTTY && process.stdout.isTTY) {
2934
+ // promptOnce reads stdin itself. While the LineEditor owns the
2935
+ // terminal we must hand it over first (the same pause/resume the
2936
+ // /config menu uses), or both readers fight for the same keys.
2591
2937
  if (editor)
2592
- editor.unlock();
2938
+ editor.pause();
2939
+ try {
2940
+ answer = await promptOnce(theme.warn(t('rewind.confirm', { v: stamp })) + ' [y/N] ');
2941
+ }
2942
+ finally {
2943
+ if (editor)
2944
+ editor.resume();
2945
+ }
2946
+ }
2947
+ else if (line.indexOf('--yes') !== -1) {
2948
+ // Non-TTY (piped): do not guess. Require an explicit --yes.
2949
+ answer = 'y';
2950
+ }
2951
+ if (!/^(y|yes|д|да)$/i.test(answer.trim())) {
2952
+ console.log(theme.system(t('rewind.cancelled')));
2953
+ return;
2954
+ }
2955
+ const res = await checkpoints.restore(selector);
2956
+ if (res.ok && res.record) {
2957
+ console.log(theme.assistant(t('rewind.reverted', { v: String(res.record.stamp) })));
2958
+ if (res.backup) {
2959
+ console.log(theme.system(t('rewind.backup', { v: String(res.backup.stamp) })));
2960
+ }
2961
+ transcript.log('rewind', { stamp: res.record.stamp });
2962
+ }
2963
+ else {
2964
+ const reasonKey = res.reason === 'not_found'
2965
+ ? 'rewind.reason_not_found'
2966
+ : res.reason === 'archive_missing'
2967
+ ? 'rewind.reason_archive_missing'
2968
+ : res.reason === 'extract_failed'
2969
+ ? 'rewind.reason_extract_failed'
2970
+ : res.reason === 'disabled'
2971
+ ? 'rewind.reason_disabled'
2972
+ : null;
2973
+ const reason = reasonKey ? t(reasonKey) : String(res.reason ?? '');
2974
+ console.error(theme.error(t('rewind.failed', { v: reason })));
2593
2975
  }
2594
- continue;
2595
2976
  }
2596
- if (lower === '/self-fix' || lower.startsWith('/self-fix ')) {
2597
- const rest = trimmed.slice('/self-fix'.length).trim();
2598
- if (!rest) {
2599
- console.error(theme.error(t('self.fix_usage')));
2600
- continue;
2977
+ finally {
2978
+ if (editor)
2979
+ editor.unlock();
2980
+ }
2981
+ };
2982
+ /** /loop <interval> <task>. */
2983
+ const loopCommand = (rest) => {
2984
+ const sp = rest.indexOf(' ');
2985
+ const intervalStr = sp === -1 ? rest : rest.slice(0, sp);
2986
+ const taskStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
2987
+ const ms = parseInterval(intervalStr);
2988
+ if (!ms && intervalStr) {
2989
+ // Say WHY the job was not added. A bare `10` is not a typo the agent
2990
+ // can spot: it IS a valid interval (10 SECONDS), while an operator who
2991
+ // meant ten minutes needs the `m` suffix.
2992
+ console.error(theme.error(t('sched.interval_typo', { v: intervalStr })));
2993
+ return;
2994
+ }
2995
+ if (!ms || !taskStr) {
2996
+ console.error(theme.error(t('sched.loop_usage')));
2997
+ return;
2998
+ }
2999
+ // A very short loop (e.g. "/loop 1s ...") would keep the agent sending
3000
+ // back-to-back and hit the rate limit. Enforce a floor; the send throttle
3001
+ // is 15s, so anything faster is pointless anyway.
3002
+ if (ms < MIN_LOOP_MS) {
3003
+ console.error(theme.error(t('sched.loop_too_short', {
3004
+ min: formatInterval(MIN_LOOP_MS),
3005
+ })));
3006
+ return;
3007
+ }
3008
+ const job = scheduler.addLoop(ms, taskStr, Date.now());
3009
+ startScheduleTicker();
3010
+ console.log(theme.system(t('sched.added', {
3011
+ id: String(job.id),
3012
+ when: formatInterval(ms),
3013
+ task: taskStr,
3014
+ })));
3015
+ // Tell the operator WHEN it will actually run. A scheduled task never
3016
+ // interrupts a running turn: it is queued and starts only when the
3017
+ // agent is idle (or right after the current task). The bare "added"
3018
+ // line made a first /loop look like a no-op, so the operator re-sent the
3019
+ // task by hand (the very report this line answers).
3020
+ console.log(theme.dim(t('sched.added_hint')));
3021
+ };
3022
+ /** /cron "<expr>" <task>. */
3023
+ const cronCommand = (rest) => {
3024
+ // A cron expression is exactly 5 whitespace-separated fields; the task
3025
+ // is everything after them.
3026
+ const parts = rest.split(/\s+/);
3027
+ if (parts.length < 6) {
3028
+ console.error(theme.error(t('sched.cron_usage')));
3029
+ return;
3030
+ }
3031
+ const cronExpr = parts.slice(0, 5).join(' ');
3032
+ const taskStr = parts.slice(5).join(' ').trim();
3033
+ if (!parseCron(cronExpr) || !taskStr) {
3034
+ console.error(theme.error(t('sched.cron_usage')));
3035
+ return;
3036
+ }
3037
+ const job = scheduler.addCron(cronExpr, taskStr, Date.now());
3038
+ if (!job) {
3039
+ console.error(theme.error(t('sched.cron_usage')));
3040
+ return;
3041
+ }
3042
+ startScheduleTicker();
3043
+ const nextIn = formatInterval(Math.max(0, job.nextAt - Date.now()));
3044
+ console.log(theme.system(t('sched.added', {
3045
+ id: String(job.id),
3046
+ when: cronExpr + ' (in ~' + nextIn + ')',
3047
+ task: taskStr,
3048
+ })));
3049
+ console.log(theme.dim(t('sched.added_hint')));
3050
+ };
3051
+ /** /jobs [rm <id>|clear]. */
3052
+ const jobsCommand = (raw) => {
3053
+ const sub = raw.trim().toLowerCase();
3054
+ if (sub === 'clear') {
3055
+ const n = scheduler.clear();
3056
+ if (scheduleTimer) {
3057
+ clearInterval(scheduleTimer);
3058
+ scheduleTimer = null;
3059
+ }
3060
+ console.log(theme.system(t('sched.cleared', { n: String(n) })));
3061
+ return;
3062
+ }
3063
+ if (sub.startsWith('rm ') || sub.startsWith('remove ')) {
3064
+ const id = Number(sub.replace(/^(rm|remove)\s+/, ''));
3065
+ if (!Number.isInteger(id) || !scheduler.remove(id)) {
3066
+ console.error(theme.error(t('sched.rm_usage')));
3067
+ return;
2601
3068
  }
2602
- const sp = rest.indexOf(' ');
2603
- const name = sp === -1 ? rest : rest.slice(0, sp);
2604
- const focus = sp === -1 ? '' : rest.slice(sp + 1).trim();
2605
- const snapRoot = path.join(ZAMES_HOME, 'snapshots', name);
2606
- const stat = await fs.stat(snapRoot).catch(() => null);
2607
- if (!stat || !stat.isDirectory()) {
2608
- console.error(theme.error(t('self.snapshot_not_found', { v: snapRoot })));
2609
- continue;
3069
+ if (!scheduler.count() && scheduleTimer) {
3070
+ clearInterval(scheduleTimer);
3071
+ scheduleTimer = null;
2610
3072
  }
2611
- const originalWorkdir = reviewMode
2612
- ? reviewMode.originalWorkdir
2613
- : currentWorkdir;
2614
- // Fresh chat + review prompt for this snapshot
2615
- try {
2616
- const { runAgentLoop: ral } = await import('./agent-loop.js');
2617
- const tools = mod.createTools(snapRoot, { undo: null });
2618
- await browser.newChat();
2619
- const sysPrompt = mod.buildSystemPrompt({
2620
- workdir: snapRoot,
3073
+ console.log(theme.system(t('sched.removed', { id: String(id) })));
3074
+ return;
3075
+ }
3076
+ const jobs = scheduler.list();
3077
+ if (!jobs.length) {
3078
+ console.log(theme.dim(t('sched.none')));
3079
+ return;
3080
+ }
3081
+ console.log(theme.system(t('sched.title')));
3082
+ for (const j of jobs) {
3083
+ console.log(theme.assistant(formatJobLine(j, Date.now())));
3084
+ }
3085
+ console.log(theme.dim(t('sched.remove_hint')));
3086
+ };
3087
+ /** /compact: summarize the chat and continue in a fresh one. */
3088
+ const compactCommand = async () => {
3089
+ // Compaction: ask DeepSeek (in the CURRENT chat) to compress the
3090
+ // history into a handover summary, then start a NEW chat, resend the
3091
+ // system prompt and post the summary as the carried-over context.
3092
+ // Shared with the AUTO-compact path via performCompact() so both
3093
+ // paths stay identical (same retries, same fallback, same resend).
3094
+ const res = await performCompact({
3095
+ browser,
3096
+ providerLabel: providerLabel(config.provider),
3097
+ currentChatId,
3098
+ workdir: currentWorkdir,
3099
+ locale: currentLocale,
3100
+ task: task ?? undefined,
3101
+ transcript,
3102
+ ui: editor,
3103
+ buildSystemPrompt: mod.buildSystemPrompt,
3104
+ tools: mod.createTools(currentWorkdir, { undo, todos: todoStore }),
3105
+ answerTimeoutMs: providerAnswerTimeoutMs(),
3106
+ fallbackLimit: COMPACT_FALLBACK_LIMIT,
3107
+ selfImprovement: devMode,
3108
+ });
3109
+ if (res.ok) {
3110
+ currentChatId = res.chatId;
3111
+ saveLastChat(currentChatId, currentWorkdir);
3112
+ freshChatNext = false;
3113
+ // The new chat already carries the system prompt and the context.
3114
+ sendSystemPromptNext = false;
3115
+ }
3116
+ };
3117
+ /** /goal: set/show/clear the condition. Returns whether it was SET. */
3118
+ const goalCommand = async (line) => {
3119
+ const sub = await handleGoal(line);
3120
+ return sub === 'set' && !!sessionGoal;
3121
+ };
3122
+ /** Dev-only self-development commands (registry, group e). */
3123
+ const selfReviewCommand = async (raw) => {
3124
+ const focus = raw.trim();
3125
+ // Remember where to return
3126
+ const originalWorkdir = reviewMode
3127
+ ? reviewMode.originalWorkdir
3128
+ : currentWorkdir;
3129
+ if (editor)
3130
+ editor.lock(t('msg.input_locked'));
3131
+ try {
3132
+ const result = await mod.selfReview({
3133
+ browser,
3134
+ config,
3135
+ focus: focus || undefined,
3136
+ transcript,
3137
+ locale: currentLocale,
3138
+ });
3139
+ // Switch to review mode:
3140
+ // - working directory = snapshot
3141
+ // - we do NOT reset the chat — inside selfReview a fresh chat was
3142
+ // already created and the review prompt sent, we return in it
3143
+ reviewMode = {
3144
+ snapDir: result.snapDir,
3145
+ snapName: path.basename(result.snapDir),
3146
+ originalWorkdir,
3147
+ };
3148
+ currentWorkdir = result.snapDir;
3149
+ freshChatNext = false;
3150
+ sendSystemPromptNext = false;
3151
+ currentChatId = await browser.getCurrentChatId();
3152
+ saveLastChat(currentChatId, currentWorkdir);
3153
+ console.log(theme.user(t('self.review_hint', { name: reviewMode.snapName })));
3154
+ }
3155
+ catch (e) {
3156
+ console.error(theme.error(t('self.review_failed')), e.message);
3157
+ if (debug)
3158
+ console.error(e.stack);
3159
+ }
3160
+ finally {
3161
+ if (editor)
3162
+ editor.unlock();
3163
+ }
3164
+ };
3165
+ const selfFixCommand = async (raw) => {
3166
+ const rest = raw.trim();
3167
+ if (!rest) {
3168
+ console.error(theme.error(t('self.fix_usage')));
3169
+ return;
3170
+ }
3171
+ const sp = rest.indexOf(' ');
3172
+ const name = sp === -1 ? rest : rest.slice(0, sp);
3173
+ const focus = sp === -1 ? '' : rest.slice(sp + 1).trim();
3174
+ const snapRoot = path.join(ZAMES_HOME, 'snapshots', name);
3175
+ const stat = await fs.stat(snapRoot).catch(() => null);
3176
+ if (!stat || !stat.isDirectory()) {
3177
+ console.error(theme.error(t('self.snapshot_not_found', { v: snapRoot })));
3178
+ return;
3179
+ }
3180
+ const originalWorkdir = reviewMode
3181
+ ? reviewMode.originalWorkdir
3182
+ : currentWorkdir;
3183
+ // Fresh chat + review prompt for this snapshot
3184
+ try {
3185
+ const { runAgentLoop: ral } = await import('./agent-loop.js');
3186
+ const tools = mod.createTools(snapRoot, { undo: null });
3187
+ await browser.newChat();
3188
+ const sysPrompt = mod.buildSystemPrompt({
3189
+ workdir: snapRoot,
3190
+ tools,
3191
+ locale: currentLocale,
3192
+ });
3193
+ console.log(theme.system(t('self.init_review_chat')));
3194
+ await browser.ask(sysPrompt, { timeout: 60_000 });
3195
+ if (focus) {
3196
+ const ui = mod.createSpinner(currentLocale);
3197
+ ui.thinking();
3198
+ await ral({
3199
+ browser,
2621
3200
  tools,
3201
+ task: focus,
3202
+ workdir: snapRoot,
3203
+ maxIterations: currentMaxIter(),
3204
+ freshChat: false,
3205
+ sendSystemPrompt: false,
3206
+ transcript,
3207
+ onThinking: () => ui.thinking(),
3208
+ onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
3209
+ onToolResult: (r) => ui.toolResult(r),
3210
+ onAssistantMessage: (m) => {
3211
+ ui.assistant(m);
3212
+ },
3213
+ onWarning: (m) => ui.warning(m),
2622
3214
  locale: currentLocale,
2623
3215
  });
2624
- console.log(theme.system(t('self.init_review_chat')));
2625
- await browser.ask(sysPrompt, { timeout: 60_000 });
2626
- if (focus) {
2627
- const ui = mod.createSpinner(currentLocale);
2628
- ui.thinking();
2629
- await ral({
2630
- browser,
2631
- tools,
2632
- task: focus,
2633
- workdir: snapRoot,
2634
- maxIterations: currentMaxIter(),
2635
- freshChat: false,
2636
- sendSystemPrompt: false,
2637
- transcript,
2638
- onThinking: () => ui.thinking(),
2639
- onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
2640
- onToolResult: (r) => ui.toolResult(r),
2641
- onAssistantMessage: (m) => {
2642
- ui.assistant(m);
2643
- },
2644
- onWarning: (m) => ui.warning(m),
2645
- locale: currentLocale,
2646
- });
2647
- }
2648
- reviewMode = {
2649
- snapDir: snapRoot,
2650
- snapName: name,
2651
- originalWorkdir,
2652
- };
2653
- currentWorkdir = snapRoot;
2654
- freshChatNext = false;
2655
- sendSystemPromptNext = false;
2656
- currentChatId = await browser.getCurrentChatId();
2657
- saveLastChat(currentChatId, currentWorkdir);
2658
- console.log(theme.user(t('self.fix_hint', { name })));
2659
3216
  }
2660
- catch (e) {
2661
- console.error(theme.error(t('self.enter_failed')), e.message);
3217
+ reviewMode = {
3218
+ snapDir: snapRoot,
3219
+ snapName: name,
3220
+ originalWorkdir,
3221
+ };
3222
+ currentWorkdir = snapRoot;
3223
+ freshChatNext = false;
3224
+ sendSystemPromptNext = false;
3225
+ currentChatId = await browser.getCurrentChatId();
3226
+ saveLastChat(currentChatId, currentWorkdir);
3227
+ console.log(theme.user(t('self.fix_hint', { name })));
3228
+ }
3229
+ catch (e) {
3230
+ console.error(theme.error(t('self.enter_failed')), e.message);
3231
+ }
3232
+ };
3233
+ const selfDoneCommand = async (_raw) => {
3234
+ if (!reviewMode) {
3235
+ console.log(theme.system(t('self.not_in_review')));
3236
+ return;
3237
+ }
3238
+ const back = reviewMode.originalWorkdir;
3239
+ reviewMode = null;
3240
+ currentWorkdir = back;
3241
+ // Since the chat is busy with the review context, we'll create a new one for regular work
3242
+ freshChatNext = true;
3243
+ sendSystemPromptNext = true;
3244
+ console.log(theme.system(t('self.done_hint', { v: back })));
3245
+ };
3246
+ const selfListCommand = async (_raw) => {
3247
+ try {
3248
+ await mod.selfList({ config, locale: currentLocale });
3249
+ }
3250
+ catch (e) {
3251
+ console.error(theme.error(t('msg.error')), e.message);
3252
+ }
3253
+ };
3254
+ const selfDiffCommand = async (raw) => {
3255
+ const name = raw.trim();
3256
+ if (!name) {
3257
+ console.error(theme.error(t('self.diff_usage')));
3258
+ return;
3259
+ }
3260
+ try {
3261
+ await mod.selfDiff({ config, name });
3262
+ }
3263
+ catch (e) {
3264
+ console.error(theme.error(t('msg.error')), e.message);
3265
+ }
3266
+ };
3267
+ const selfApplyCommand = async (raw) => {
3268
+ const name = raw.trim();
3269
+ if (!name) {
3270
+ console.error(theme.error(t('self.apply_usage')));
3271
+ return;
3272
+ }
3273
+ try {
3274
+ await mod.selfApply({ config, name });
3275
+ }
3276
+ catch (e) {
3277
+ console.error(theme.error(t('msg.error')), e.message);
3278
+ }
3279
+ };
3280
+ const improveCommand = async (raw) => {
3281
+ // Backlog-driven self-improvement: pick the next open BACKLOG item (or a
3282
+ // specific id) and run the standard task loop on it. The agent reads
3283
+ // BACKLOG.md itself for the details, so a FRESH agent needs no external
3284
+ // context — this is the reproducible hand-off path.
3285
+ const arg = raw.trim();
3286
+ let backlogText;
3287
+ try {
3288
+ backlogText = await fs.readFile(path.join(currentWorkdir, 'BACKLOG.md'), 'utf-8');
3289
+ }
3290
+ catch {
3291
+ console.error(theme.warn(t('improve.no_backlog')));
3292
+ return;
3293
+ }
3294
+ const items = mod.commands.parseBacklogItems(backlogText);
3295
+ const item = mod.commands.nextBacklogItem(items, arg || undefined);
3296
+ if (!item) {
3297
+ console.log(theme.dim(arg ? t('improve.not_found', { v: arg }) : t('improve.all_done')));
3298
+ return;
3299
+ }
3300
+ console.log(theme.system(t('improve.start', { id: item.id, title: item.title })));
3301
+ const improveBase = mod.createTools(currentWorkdir, {
3302
+ undo,
3303
+ todos: todoStore,
3304
+ readOnly: planMode,
3305
+ });
3306
+ const improveTools = mergeTools(improveBase, mcpPool?.tools, planMode);
3307
+ if (editor)
3308
+ editor.busy = true;
3309
+ try {
3310
+ await mod.runTask(runTaskDeps(), browser, improveTools, mod.commands.buildImprovePrompt(item), currentWorkdir, {
3311
+ transcript,
3312
+ freshChat: false,
3313
+ sendSystemPrompt: false,
3314
+ queue: pendingQueue,
3315
+ ui: editor || null,
3316
+ onChatReady: (chatId) => {
3317
+ if (chatId) {
3318
+ currentChatId = chatId;
3319
+ saveLastChat(chatId, currentWorkdir);
3320
+ }
3321
+ },
3322
+ getTokenUsage: () => browser.getLastTokenUsage(),
3323
+ selfImprovement: devMode,
3324
+ });
3325
+ }
3326
+ finally {
3327
+ if (editor)
3328
+ editor.busy = false;
3329
+ }
3330
+ // Auto-prune: a finished item keeps its summary line but loses the
3331
+ // archived <details> copy of its original text, so BACKLOG.md does not
3332
+ // grow on every /improve. Best-effort — a read/write failure must not
3333
+ // break the command.
3334
+ try {
3335
+ const raw = await fs.readFile(path.join(currentWorkdir, 'BACKLOG.md'), 'utf-8');
3336
+ const col = mod.collapseBacklog(raw);
3337
+ if (col.changed) {
3338
+ await fs.writeFile(path.join(currentWorkdir, 'BACKLOG.md'), col.text, 'utf-8');
3339
+ console.log(theme.dim(t('improve.collapsed', { n: String(col.collapsed) })));
3340
+ }
3341
+ }
3342
+ catch { }
3343
+ };
3344
+ const backlogCommand = async (raw) => {
3345
+ // Record an improvement idea without implementing it. `/backlog
3346
+ // collapse` prunes the archived blocks instead. The LLM only PLACES
3347
+ // the item; the pure helpers in src/backlog.ts own the format.
3348
+ const note = raw.trim();
3349
+ const backlogFile = path.join(currentWorkdir, 'BACKLOG.md');
3350
+ let backlogText;
3351
+ try {
3352
+ backlogText = await fs.readFile(backlogFile, 'utf-8');
3353
+ }
3354
+ catch {
3355
+ console.error(theme.warn(t('backlog.none')));
3356
+ return;
3357
+ }
3358
+ if (!note) {
3359
+ console.log(theme.dim(t('backlog.usage')));
3360
+ return;
3361
+ }
3362
+ if (note === 'collapse') {
3363
+ const col = mod.collapseBacklog(backlogText);
3364
+ if (!col.changed) {
3365
+ console.log(theme.dim(t('backlog.empty')));
3366
+ }
3367
+ else {
3368
+ await fs.writeFile(backlogFile, col.text, 'utf-8');
3369
+ console.log(theme.system(t('improve.collapsed', { n: String(col.collapsed) })));
3370
+ }
3371
+ return;
3372
+ }
3373
+ // Deterministic append: no model round-trip is needed to record a
3374
+ // one-line note, and a pure helper cannot invent a different id than
3375
+ // the one the operator is shown.
3376
+ const parsed = parseBacklogNote(note);
3377
+ const res = mod.appendBacklogItem(backlogText, parsed);
3378
+ await fs.writeFile(backlogFile, res.text, 'utf-8');
3379
+ console.log(theme.system(t('backlog.adding', { v: res.id })));
3380
+ };
3381
+ while (running) {
3382
+ let input;
3383
+ let inputAttachments = [];
3384
+ // Set by `/goal <condition>` to fall through into a task turn with this
3385
+ // text instead of returning to the prompt (Claude Code starts a turn the
3386
+ // moment a goal is set). Reset every iteration because it is declared here.
3387
+ let taskOverride = null;
3388
+ // Publish the chat the next send must join (see resumeChatId above):
3389
+ // the loop may restart the page, which loses the URL id.
3390
+ browser.resumeChatId = resumeChatId;
3391
+ try {
3392
+ if (editor) {
3393
+ editor.setPrompt(buildPrompt());
3394
+ const msg = await takeInput();
3395
+ input = msg ? msg.text : null;
3396
+ inputAttachments = msg?.attachments || [];
3397
+ }
3398
+ else {
3399
+ let tail;
3400
+ // The non-TTY fallback prompt mirrors buildPrompt(): bare, no dir.
3401
+ if (reviewMode) {
3402
+ // Widen: a helper function may have reset `reviewMode` to null,
3403
+ // which TS narrows to `never` at this point otherwise.
3404
+ const rm = reviewMode;
3405
+ tail =
3406
+ theme.dim('(') +
3407
+ theme.warn(t('prompt.review')) +
3408
+ theme.dim(':') +
3409
+ theme.dir(rm ? rm.snapName : '') +
3410
+ theme.dim(') ');
3411
+ }
3412
+ input = await promptOnce(theme.prompt('❯ ') + tail);
2662
3413
  }
3414
+ }
3415
+ catch {
3416
+ break;
3417
+ }
3418
+ const trimmed = (input || '').trim();
3419
+ if (!trimmed)
2663
3420
  continue;
3421
+ const lower = trimmed.toLowerCase();
3422
+ if (['/exit', '/quit', 'exit', 'quit'].includes(lower))
3423
+ break;
3424
+ // Group (a): the informational commands (/status, /chat, /pwd,
3425
+ // /transcript, /cost, /limits, /memory, /skills). The dispatch is a DATA
3426
+ // lookup, so the command is dispatched and advertised from the same entry.
3427
+ {
3428
+ const infoCommands = createInfoCommands({
3429
+ t,
3430
+ statusLines: () => formatStatus(statusSnapshot(), t),
3431
+ currentChatId: () => currentChatId,
3432
+ resolveChatId: () => browser.getCurrentChatId(),
3433
+ chatUrl: (id) => `${browserProviderUrl()}/${id}`,
3434
+ workdir: () => currentWorkdir,
3435
+ transcriptFile: () => transcript.file,
3436
+ costReport: () => {
3437
+ let stats = mod.commands.summarizeTranscript([]);
3438
+ if (transcript.file) {
3439
+ try {
3440
+ stats = mod.commands.summarizeTranscript(mod.commands.parseTranscript(readFileSync(transcript.file, 'utf-8')));
3441
+ }
3442
+ catch (e) {
3443
+ noteSwallowed('cost_report', e, debug);
3444
+ }
3445
+ }
3446
+ return [
3447
+ mod.commands.renderCost(stats, transcript.file, browser.getLastTokenUsage(), t),
3448
+ ];
3449
+ },
3450
+ limitsReport: async () => {
3451
+ const { readSendEvents, summarizeSends, sendLogPath } = await import('./send-telemetry.js');
3452
+ const logsDir = config.transcript.dir;
3453
+ const events = readSendEvents(logsDir, config.provider);
3454
+ if (!events.length) {
3455
+ return [
3456
+ limitsEmptyMessage(sendLogPath(logsDir, config.provider), t),
3457
+ ];
3458
+ }
3459
+ return formatLimits(summarizeSends(events), {
3460
+ providerLabel: providerLabel(config.provider),
3461
+ intervalMs: browser.sendIntervalMs(),
3462
+ adaptive: providerAdaptiveThrottle(config.provider),
3463
+ }, t).map((line) => line.kind === 'title' ? line.text : ' ' + line.text);
3464
+ },
3465
+ memoryReport: async () => {
3466
+ const { loadProjectContext } = await import('./context.js');
3467
+ const ctx = await loadProjectContext(currentWorkdir);
3468
+ const out = [];
3469
+ const show = (title, files) => {
3470
+ out.push(title);
3471
+ if (!files.length) {
3472
+ out.push(t('common.none'));
3473
+ return;
3474
+ }
3475
+ for (const f of files)
3476
+ out.push(f.path);
3477
+ };
3478
+ show(t('memory.agents'), ctx.agents);
3479
+ show(t('memory.memory'), ctx.memory);
3480
+ return out;
3481
+ },
3482
+ skillsReport: async () => {
3483
+ const { loadSkills } = await import('./context.js');
3484
+ const skills = await loadSkills(currentWorkdir);
3485
+ if (!skills.length)
3486
+ return [t('skills.none')];
3487
+ const out = [t('skills.title', { n: String(skills.length) })];
3488
+ for (const s of skills) {
3489
+ const src = s.path ? s.source : 'built-in';
3490
+ out.push('/' +
3491
+ s.name +
3492
+ ' [' +
3493
+ src +
3494
+ '] ' +
3495
+ (s.description || t('common.none')));
3496
+ if (s.path)
3497
+ out.push(s.path);
3498
+ }
3499
+ return out;
3500
+ },
3501
+ });
3502
+ if (await dispatchCommand(infoCommands, trimmed, {
3503
+ print: (line) => console.log(line),
3504
+ devMode,
3505
+ })) {
3506
+ continue;
3507
+ }
2664
3508
  }
2665
- if (lower === '/self-done') {
2666
- if (!reviewMode) {
2667
- console.log(theme.system(t('self.not_in_review')));
3509
+ // Group (b): the session and chat commands (/new, /sessions, /chats,
3510
+ // /last, /resume-id). The handlers above own the heavy logic; the registry
3511
+ // owns the NAMES, aliases and argument forwarding.
3512
+ {
3513
+ const sessionCommands = createSessionCommands({
3514
+ t,
3515
+ newChat: () => newChatCommand(),
3516
+ sessions: (filter) => sessionsCommand(filter),
3517
+ chats: (filter) => chatsCommand(filter),
3518
+ last: () => lastCommand(),
3519
+ resumeId: (id) => resumeIdCommand(id),
3520
+ parseResumeId: (raw) => parseResumeId(raw),
3521
+ printResumeUsage: () => console.error(theme.error(t('sessions.resume_id_usage'))),
3522
+ });
3523
+ if (await dispatchCommand(sessionCommands, trimmed, {
3524
+ print: (line) => console.log(line),
3525
+ devMode,
3526
+ })) {
2668
3527
  continue;
2669
3528
  }
2670
- const back = reviewMode.originalWorkdir;
2671
- reviewMode = null;
2672
- currentWorkdir = back;
2673
- // Since the chat is busy with the review context, we'll create a new one for regular work
2674
- freshChatNext = true;
2675
- sendSystemPromptNext = true;
2676
- console.log(theme.system(t('self.done_hint', { v: back })));
2677
- continue;
2678
3529
  }
2679
- if (lower === '/self-list') {
2680
- try {
2681
- await mod.selfList({ config, locale: currentLocale });
3530
+ // Group (c): git / undo / rewind commands (/diff, /diffstat, /undo,
3531
+ // /undo-list, /rewind-list, /rewind).
3532
+ {
3533
+ const treeCommands = createTreeCommands({
3534
+ diff: (staged) => diffCommand(staged, false),
3535
+ diffstat: (staged) => diffCommand(staged, true),
3536
+ undo: () => undoCommand(),
3537
+ undoList: () => undoListCommand(),
3538
+ rewindList: () => rewindListCommand(),
3539
+ rewind: (selector, line) => rewindCommand(selector, line),
3540
+ });
3541
+ if (await dispatchCommand(treeCommands, trimmed, {
3542
+ print: (line) => console.log(line),
3543
+ devMode,
3544
+ })) {
3545
+ continue;
2682
3546
  }
2683
- catch (e) {
2684
- console.error(theme.error(t('msg.error')), e.message);
3547
+ }
3548
+ // Group (d): the scheduler and context commands (/compact, /goal, /loop,
3549
+ // /cron, /jobs). /goal needs the result: setting a condition STARTS a
3550
+ // turn, so the branch above (goalCommand) keeps the fall-through and this
3551
+ // group only owns /compact, /loop, /cron and /jobs.
3552
+ {
3553
+ const schedulerCommands = createSchedulerCommands({
3554
+ compact: () => compactCommand(),
3555
+ goal: async (line) => ((await goalCommand(line)) ? 'set' : 'handled'),
3556
+ currentGoal: () => sessionGoal,
3557
+ loop: (rest) => loopCommand(rest),
3558
+ cron: (rest) => cronCommand(rest),
3559
+ jobs: (rest) => jobsCommand(rest),
3560
+ }).filter((c) => c.name !== 'goal');
3561
+ if (await dispatchCommand(schedulerCommands, trimmed, {
3562
+ print: (line) => console.log(line),
3563
+ devMode,
3564
+ })) {
3565
+ continue;
3566
+ }
3567
+ }
3568
+ // Group (e): the dev-only self-development commands (/improve, /backlog,
3569
+ // /self-*). Every entry carries `devOnly`, so a regular user is rejected
3570
+ // with msg.dev_only BEFORE any handler runs (hiding them from /help is not
3571
+ // enough — the command can be typed by hand).
3572
+ {
3573
+ const devCommands = createDevCommands({
3574
+ improve: (arg) => improveCommand(arg),
3575
+ backlog: (note) => backlogCommand(note),
3576
+ selfReview: (focus) => selfReviewCommand(focus),
3577
+ selfFix: (rest) => selfFixCommand(rest),
3578
+ selfDone: () => selfDoneCommand(''),
3579
+ selfList: () => selfListCommand(''),
3580
+ selfDiff: (name) => selfDiffCommand(name),
3581
+ selfApply: (name) => selfApplyCommand(name),
3582
+ });
3583
+ if (await dispatchCommand(devCommands, trimmed, {
3584
+ print: (line) => console.log(line),
3585
+ devMode,
3586
+ onDevOnlyRejected: (name) => console.error(theme.warn(t('msg.dev_only', { v: '/' + name }))),
3587
+ })) {
3588
+ continue;
2685
3589
  }
2686
- continue;
2687
3590
  }
2688
- if (lower === '/self-diff' || lower.startsWith('/self-diff ')) {
2689
- const name = trimmed.slice('/self-diff'.length).trim();
2690
- if (!name) {
2691
- console.error(theme.error(t('self.diff_usage')));
3591
+ // `!command` — run a shell command directly, without asking the model
3592
+ // (like Claude Code's bash mode). The same sandbox guard as the Bash tool
3593
+ // applies, so a direct command cannot leave the project either.
3594
+ if (trimmed.startsWith('!')) {
3595
+ const cmd = trimmed.slice(1).trim();
3596
+ if (!cmd) {
3597
+ console.error(theme.warn(t('shell.empty')));
2692
3598
  continue;
2693
3599
  }
2694
3600
  try {
2695
- await mod.selfDiff({ config, name });
3601
+ assertCommandInsideRoot(currentWorkdir, cmd);
2696
3602
  }
2697
3603
  catch (e) {
2698
- console.error(theme.error(t('msg.error')), e.message);
2699
- }
2700
- continue;
2701
- }
2702
- if (lower === '/self-apply' || lower.startsWith('/self-apply ')) {
2703
- const name = trimmed.slice('/self-apply'.length).trim();
2704
- if (!name) {
2705
- console.error(theme.error(t('self.apply_usage')));
3604
+ console.error(theme.error(e.message));
2706
3605
  continue;
2707
3606
  }
3607
+ transcript.log('shell_command', { command: cmd });
3608
+ if (editor)
3609
+ editor.lock(t('shell.running'));
2708
3610
  try {
2709
- await mod.selfApply({ config, name });
3611
+ const out = await runShell(currentWorkdir, cmd);
3612
+ console.log(theme.system(out));
3613
+ transcript.log('shell_result', { command: cmd, result: out });
2710
3614
  }
2711
- catch (e) {
2712
- console.error(theme.error(t('msg.error')), e.message);
3615
+ finally {
3616
+ if (editor)
3617
+ editor.unlock();
2713
3618
  }
2714
3619
  continue;
2715
3620
  }
2716
- if (lower === '/chats' || lower.startsWith('/chats ')) {
2717
- const filter = trimmed.slice('/chats'.length).trim().toLowerCase();
2718
- const spin = editor || mod.createSpinner(currentLocale);
2719
- if (editor)
2720
- editor.lock(t('msg.input_locked'));
2721
- spin.thinking();
2722
- try {
2723
- const all = await browser.listChats(30);
2724
- // Hide subagent chats: they are real DeepSeek chats, but they are
2725
- // internal context-isolation artifacts whose report already lives in
2726
- // the parent chat, so resuming one is never what the operator wants.
2727
- // The scraper runs inside the page and cannot localize: a row without
2728
- // text comes back with an EMPTY title, so the label is applied HERE
2729
- // (one place, in the operator's language) instead of a hardcoded one.
2730
- lastChats = all
2731
- .filter((c) => !isInternalChat(c.id))
2732
- .map((c) => ({
2733
- ...c,
2734
- title: c.title && c.title.trim() ? c.title : t('chats.untitled'),
2735
- }));
2736
- spin.stop();
2737
- // An optional query filters by title (substring, case-insensitive),
2738
- // so a long chat list does not have to be scanned by eye.
2739
- const shown = filter
2740
- ? lastChats.filter((c) => c.title.toLowerCase().includes(filter))
2741
- : lastChats;
2742
- if (!shown.length) {
2743
- console.log(theme.system(t('chats.none_hint')));
3621
+ if (lower === '/help' ||
3622
+ lower === 'help' ||
3623
+ lower.startsWith('/help ') ||
3624
+ lower.startsWith('help ')) {
3625
+ const topic = trimmed.replace(/^\/?help\s*/i, '').trim();
3626
+ if (!topic) {
3627
+ printHelp();
3628
+ }
3629
+ else {
3630
+ // /help <command> — print the full description of ONE command instead
3631
+ // of making the operator scroll the whole list.
3632
+ const q = '/' + topic.replace(/^\//, '').toLowerCase();
3633
+ const match = buildSlashCommands().filter((c) => c.name.toLowerCase() === q ||
3634
+ c.name.toLowerCase().startsWith(q + ' '));
3635
+ if (!match.length) {
3636
+ console.error(theme.warn(t('help.no_topic', { v: topic })));
2744
3637
  }
2745
3638
  else {
2746
- // In the TUI, open a real SELECT LIST (like /model) instead of
2747
- // dumping text: pick a chat and it opens directly. The text path
2748
- // below stays for non-TTY / the legacy editor.
2749
- const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
2750
- if (canPick && editor) {
2751
- const openChat = await pickChatInteractively(shown);
2752
- if (openChat)
2753
- continue;
2754
- }
2755
- console.log(theme.system(t('chats.recent')));
2756
- // The number is the index in the FULL list (a filter only narrows
2757
- // the VIEW), and the full id in brackets is what /resume-id takes.
2758
- for (const c of shown) {
2759
- const idx = lastChats.indexOf(c);
2760
- const n = String(idx + 1).padStart(2, ' ');
2761
- console.log(` ${theme.user(n)}. ${c.title} ${theme.system('(' + c.id.slice(0, 8) + '…)')}`);
3639
+ for (const c of match) {
3640
+ console.log(theme.assistant(c.name) + theme.dim(' ' + c.description));
2762
3641
  }
2763
- console.log(theme.system(t('chats.use_resume')));
2764
3642
  }
2765
3643
  }
2766
- catch (e) {
2767
- spin.stop();
2768
- console.error(theme.error(t('chats.fetch_error')), e.message);
2769
- }
2770
- finally {
2771
- if (editor)
2772
- editor.unlock();
2773
- }
2774
3644
  continue;
2775
3645
  }
2776
3646
  if (lower === '/provider' || lower.startsWith('/provider ')) {
@@ -3049,128 +3919,14 @@ async function main() {
3049
3919
  }
3050
3920
  continue;
3051
3921
  }
3052
- if (lower === '/sessions' ||
3053
- lower === '/session' ||
3054
- lower.startsWith('/sessions ') ||
3055
- lower.startsWith('/session ')) {
3056
- const filter = trimmed
3057
- .replace(/^\/sessions?/i, '')
3058
- .trim()
3059
- .toLowerCase();
3060
- // /sessions <query> filters by title or workdir (substring). Sorted by
3061
- // recency already (listSessions).
3062
- const all = filterSessions(listSessions(), filter);
3063
- console.log(theme.system(t('sessions.dir', { v: sessionsDir() })));
3064
- if (!all.length) {
3065
- console.log(theme.system(t('sessions.none')));
3066
- }
3067
- else {
3068
- // In the TUI, open a SELECT LIST and resume the chosen session
3069
- // directly (same behavior as /chats), instead of only listing ids.
3070
- const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
3071
- if (canPick && editor) {
3072
- const opened = await pickSessionInteractively(all);
3073
- if (opened)
3074
- continue;
3075
- }
3076
- all.forEach((s, i) => {
3077
- const n = String(i + 1).padStart(2, ' ');
3078
- const mark = s.id === currentChatId ? theme.user(' *') : '';
3079
- const title = s.title ? ` ${s.title}` : '';
3080
- const wd = s.workdir ? theme.dim(` [${dirLabel(s.workdir)}]`) : '';
3081
- const age = mod.commands.formatRelativeTime(s.updatedAt, t);
3082
- const ageText = age ? theme.dim(' ' + age) : '';
3083
- console.log(` ${theme.user(n)}. ${s.id.slice(0, 8)}…${title}${wd}${ageText}${mark}`);
3084
- });
3085
- console.log(theme.system(t('sessions.restore_hint')));
3086
- }
3087
- continue;
3088
- }
3089
- if (lower === '/last') {
3090
- // Quick resume of the last chat for THIS working directory — otherwise
3091
- // finding it meant /chats (a network fetch) or copying the id.
3092
- const last = loadLastSession(currentWorkdir, config.provider);
3093
- if (!last || !last.id) {
3094
- console.error(theme.error(t('sessions.none_here')));
3095
- continue;
3096
- }
3097
- if (editor)
3098
- editor.lock(t('msg.input_locked'));
3099
- try {
3100
- if (editor)
3101
- editor.clearScreen();
3102
- console.log(theme.system(t('msg.opening_chat', { id: last.id })));
3103
- await browser.openChat(last.id);
3104
- currentChatId = last.id;
3105
- freshChatNext = false;
3106
- sendSystemPromptNext = promptOnResume();
3107
- saveLastChat(last.id, currentWorkdir, last.title);
3108
- transcript.log('resume_chat', { id: last.id, title: last.title });
3109
- console.log(theme.assistant(t('msg.chat_opened')));
3110
- loadSessionTodos(last.id);
3111
- await printRestoredHistory(browser, editor, last.id);
3112
- }
3113
- catch (e) {
3114
- console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
3115
- }
3116
- finally {
3117
- if (editor)
3118
- editor.unlock();
3119
- }
3120
- continue;
3121
- }
3122
- if (lower.startsWith('/resume-id ')) {
3123
- const id = parseResumeId(trimmed.slice('/resume-id'.length));
3124
- if (!id) {
3125
- console.error(theme.error(t('sessions.resume_id_usage')));
3126
- continue;
3127
- }
3128
- // A chat id only exists on the backend that created it, so opening a
3129
- // session saved by ANOTHER provider fails deep inside the browser with a
3130
- // confusing error. Refuse up front and name the right /provider command.
3131
- const refusal = resumeProviderRefusal(readSession(id), config.provider, t);
3132
- if (refusal) {
3133
- console.error(theme.error(refusal));
3134
- continue;
3135
- }
3136
- if (editor)
3137
- editor.lock(t('msg.input_locked'));
3138
- try {
3139
- if (editor)
3140
- editor.clearScreen();
3141
- console.log(theme.system(t('msg.opening_chat', { id })));
3142
- await browser.openChat(id);
3143
- currentChatId = id;
3144
- freshChatNext = false;
3145
- sendSystemPromptNext = promptOnResume();
3146
- saveLastChat(id, currentWorkdir);
3147
- transcript.log('resume_chat', { id });
3148
- console.log(theme.assistant(t('msg.chat_opened')) +
3149
- theme.system(String.fromCharCode(10)));
3150
- loadSessionTodos(id);
3151
- await printRestoredHistory(browser, editor, id);
3152
- }
3153
- catch (e) {
3154
- console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
3155
- }
3156
- finally {
3157
- if (editor)
3158
- editor.unlock();
3159
- }
3160
- continue;
3161
- }
3162
3922
  if (lower === '/pwd') {
3163
3923
  console.log(theme.system(currentWorkdir));
3164
3924
  continue;
3165
3925
  }
3166
3926
  if (lower === '/goal' || lower.startsWith('/goal ')) {
3167
- const sub = await handleGoal(trimmed);
3168
- // Claude Code parity: setting a goal STARTS a turn immediately, with the
3169
- // condition itself as the directive (no separate prompt needed). The
3170
- // completion loop in runTask() then keeps working until the evaluator
3171
- // reports MET/IMPOSSIBLE. We fall through to the regular task path with
3172
- // an override task text instead of returning to the prompt.
3173
- if (sub === 'set' && sessionGoal) {
3927
+ // A freshly SET goal FALLS THROUGH: the condition itself becomes the
3928
+ // next task instead of returning to the prompt (Claude Code parity).
3929
+ if (await goalCommand(trimmed)) {
3174
3930
  taskOverride =
3175
3931
  'Work toward this completion goal until it is satisfied. ' +
3176
3932
  'Do not call respond with a final summary until the condition is met.';
@@ -3179,113 +3935,6 @@ async function main() {
3179
3935
  continue;
3180
3936
  }
3181
3937
  }
3182
- if (lower === '/loop' || lower.startsWith('/loop ')) {
3183
- const rest = trimmed.slice('/loop'.length).trim();
3184
- const sp = rest.indexOf(' ');
3185
- const intervalStr = sp === -1 ? rest : rest.slice(0, sp);
3186
- const taskStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
3187
- const ms = parseInterval(intervalStr);
3188
- if (!ms && intervalStr) {
3189
- // Say WHY the job was not added. A bare `10` is not a typo the agent
3190
- // can spot: it IS a valid interval (10 SECONDS), while an operator who
3191
- // meant ten minutes needs the `m` suffix.
3192
- console.error(theme.error(t('sched.interval_typo', { v: intervalStr })));
3193
- continue;
3194
- }
3195
- if (!ms || !taskStr) {
3196
- console.error(theme.error(t('sched.loop_usage')));
3197
- continue;
3198
- }
3199
- // A very short loop (e.g. "/loop 1s ...") would keep the agent sending
3200
- // back-to-back and hit the rate limit. Enforce a floor; the send throttle
3201
- // is 15s, so anything faster is pointless anyway.
3202
- if (ms < MIN_LOOP_MS) {
3203
- console.error(theme.error(t('sched.loop_too_short', {
3204
- min: formatInterval(MIN_LOOP_MS),
3205
- })));
3206
- continue;
3207
- }
3208
- const job = scheduler.addLoop(ms, taskStr, Date.now());
3209
- startScheduleTicker();
3210
- console.log(theme.system(t('sched.added', {
3211
- id: String(job.id),
3212
- when: formatInterval(ms),
3213
- task: taskStr,
3214
- })));
3215
- // Tell the operator WHEN it will actually run. A scheduled task never
3216
- // interrupts a running turn: it is queued and starts only when the
3217
- // agent is idle (or right after the current task). The bare "added"
3218
- // line made a first /loop look like a no-op, so the operator re-sent the
3219
- // task by hand (the very report this line answers).
3220
- console.log(theme.dim(t('sched.added_hint')));
3221
- continue;
3222
- }
3223
- if (lower === '/cron' || lower.startsWith('/cron ')) {
3224
- const rest = trimmed.slice('/cron'.length).trim();
3225
- // A cron expression is exactly 5 whitespace-separated fields; the task
3226
- // is everything after them.
3227
- const parts = rest.split(/\s+/);
3228
- if (parts.length < 6) {
3229
- console.error(theme.error(t('sched.cron_usage')));
3230
- continue;
3231
- }
3232
- const cronExpr = parts.slice(0, 5).join(' ');
3233
- const taskStr = parts.slice(5).join(' ').trim();
3234
- if (!parseCron(cronExpr) || !taskStr) {
3235
- console.error(theme.error(t('sched.cron_usage')));
3236
- continue;
3237
- }
3238
- const job = scheduler.addCron(cronExpr, taskStr, Date.now());
3239
- if (!job) {
3240
- console.error(theme.error(t('sched.cron_usage')));
3241
- continue;
3242
- }
3243
- startScheduleTicker();
3244
- const nextIn = formatInterval(Math.max(0, job.nextAt - Date.now()));
3245
- console.log(theme.system(t('sched.added', {
3246
- id: String(job.id),
3247
- when: cronExpr + ' (in ~' + nextIn + ')',
3248
- task: taskStr,
3249
- })));
3250
- console.log(theme.dim(t('sched.added_hint')));
3251
- continue;
3252
- }
3253
- if (lower === '/jobs' || lower.startsWith('/jobs ')) {
3254
- const sub = trimmed.slice('/jobs'.length).trim().toLowerCase();
3255
- if (sub === 'clear') {
3256
- const n = scheduler.clear();
3257
- if (scheduleTimer) {
3258
- clearInterval(scheduleTimer);
3259
- scheduleTimer = null;
3260
- }
3261
- console.log(theme.system(t('sched.cleared', { n: String(n) })));
3262
- continue;
3263
- }
3264
- if (sub.startsWith('rm ') || sub.startsWith('remove ')) {
3265
- const id = Number(sub.replace(/^(rm|remove)\s+/, ''));
3266
- if (!Number.isInteger(id) || !scheduler.remove(id)) {
3267
- console.error(theme.error(t('sched.rm_usage')));
3268
- continue;
3269
- }
3270
- if (!scheduler.count() && scheduleTimer) {
3271
- clearInterval(scheduleTimer);
3272
- scheduleTimer = null;
3273
- }
3274
- console.log(theme.system(t('sched.removed', { id: String(id) })));
3275
- continue;
3276
- }
3277
- const jobs = scheduler.list();
3278
- if (!jobs.length) {
3279
- console.log(theme.dim(t('sched.none')));
3280
- continue;
3281
- }
3282
- console.log(theme.system(t('sched.title')));
3283
- for (const j of jobs) {
3284
- console.log(theme.assistant(formatJobLine(j, Date.now())));
3285
- }
3286
- console.log(theme.dim(t('sched.remove_hint')));
3287
- continue;
3288
- }
3289
3938
  if (lower === '/queue' || lower.startsWith('/queue ')) {
3290
3939
  const q = mod.commands.parseQueueCommand(trimmed);
3291
3940
  const view = renderQueueView(q ? q.sub : null, pendingQueue.length, pendingQueue);
@@ -3553,35 +4202,6 @@ async function main() {
3553
4202
  editor.setCommands(buildSlashCommands());
3554
4203
  continue;
3555
4204
  }
3556
- if (lower === '/status') {
3557
- const statusToggles = browser.getToggleStatesSync();
3558
- const statusLines = formatStatus({
3559
- workdir: currentWorkdir,
3560
- reviewMode: reviewMode ? reviewMode.snapName : null,
3561
- origDir: reviewMode ? reviewMode.originalWorkdir : null,
3562
- chatId: currentChatId,
3563
- freshNext: freshChatNext,
3564
- promptNext: sendSystemPromptNext,
3565
- lastChat: loadLastChat(currentWorkdir),
3566
- sessionsDir: sessionsDir(),
3567
- devMode,
3568
- maxIterations: currentMaxIter(),
3569
- headless,
3570
- debug,
3571
- deepThinking: statusToggles.deepThinking,
3572
- webSearch: statusToggles.webSearch,
3573
- undo: config.undo.enabled,
3574
- checkpoint: config.checkpoint.enabled,
3575
- transcriptFile: transcript.file,
3576
- goal: sessionGoal,
3577
- localeName: localeDisplayName(currentLocale),
3578
- mcpToolCount: mcpPool ? mcpPool.status().toolCount : null,
3579
- tokenUsage: browser.getLastTokenUsage(),
3580
- }, t);
3581
- for (const line of statusLines)
3582
- console.log(theme.system(line));
3583
- continue;
3584
- }
3585
4205
  if (lower === '/config' || lower.startsWith('/config ')) {
3586
4206
  await handleConfigCommand(trimmed);
3587
4207
  continue;
@@ -3690,30 +4310,6 @@ async function main() {
3690
4310
  }
3691
4311
  continue;
3692
4312
  }
3693
- if (lower === '/diff' || lower.startsWith('/diff ')) {
3694
- const staged = lower.indexOf('--staged') !== -1;
3695
- const { runGitArgs } = await import('./gitTools.js');
3696
- const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], currentWorkdir, 5000);
3697
- if (probe.trim() !== 'true') {
3698
- console.error(theme.error(t('diff.not_repo')));
3699
- continue;
3700
- }
3701
- const out = await runGitArgs(mod.commands.diffGitArgs(staged), currentWorkdir, 20_000);
3702
- console.log(theme.system(mod.commands.formatDiff(out, { maxLines: 400 }, t)));
3703
- continue;
3704
- }
3705
- if (lower === '/diffstat' || lower.startsWith('/diffstat ')) {
3706
- const staged = lower.indexOf('--staged') !== -1;
3707
- const { runGitArgs } = await import('./gitTools.js');
3708
- const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], currentWorkdir, 5000);
3709
- if (probe.trim() !== 'true') {
3710
- console.error(theme.error(t('diff.not_repo')));
3711
- continue;
3712
- }
3713
- const out = await runGitArgs(mod.commands.diffStatGitArgs(staged), currentWorkdir, 20_000);
3714
- console.log(theme.system(mod.commands.formatDiffStat(out, { maxLines: 60 }, t)));
3715
- continue;
3716
- }
3717
4313
  if (lower === '/limits') {
3718
4314
  const { readSendEvents, summarizeSends, sendLogPath } = await import('./send-telemetry.js');
3719
4315
  const logsDir = config.transcript.dir;
@@ -3816,6 +4412,35 @@ async function main() {
3816
4412
  transcriptOk: !!transcript.file,
3817
4413
  authSaved: authMarkerExists(),
3818
4414
  contextLimit: effectiveContextLimit(),
4415
+ // N12: the system-prompt size against the N6 budget. The prompt
4416
+ // is rebuilt here because /doctor is a READ-ONLY view: nothing
4417
+ // else in the process holds the string.
4418
+ systemPromptBytes: Buffer.byteLength(mod.buildSystemPrompt({
4419
+ workdir: currentWorkdir,
4420
+ tools: mod.createTools(currentWorkdir, {
4421
+ undo,
4422
+ todos: todoStore,
4423
+ }),
4424
+ locale: currentLocale,
4425
+ selfImprovement: devMode,
4426
+ provider: config.provider,
4427
+ })),
4428
+ systemPromptBudget: SYSTEM_PROMPT_BUDGET_BYTES,
4429
+ // N21: WHERE the password lives, and whether that store is real
4430
+ // on this machine (a degraded keychain shows as a WARN).
4431
+ passwordStore: resolvePasswordStore(config.browser.passwordStore, {
4432
+ secretTool: false,
4433
+ security: false,
4434
+ powershell: false,
4435
+ platform: process.platform === 'darwin'
4436
+ ? 'darwin'
4437
+ : process.platform === 'win32'
4438
+ ? 'win32'
4439
+ : 'linux',
4440
+ }),
4441
+ // N7: the top swallowed-failure tags — a broken selector stops
4442
+ // being invisible.
4443
+ swallowedTop: swallowedReport(5),
3819
4444
  hasOrigin: gitOk
3820
4445
  ? (await runGitArgs(['remote'], currentWorkdir, 5000)).includes('origin')
3821
4446
  : undefined,
@@ -3852,139 +4477,6 @@ async function main() {
3852
4477
  console.log(theme.system(t('adddir.note', { v: res.path })));
3853
4478
  continue;
3854
4479
  }
3855
- if (lower === '/compact') {
3856
- // Compaction: ask DeepSeek (in the CURRENT chat) to compress the
3857
- // history into a handover summary, then start a NEW chat, resend the
3858
- // system prompt and post the summary as the carried-over context.
3859
- // Shared with the AUTO-compact path via performCompact() so both
3860
- // paths stay identical (same retries, same fallback, same resend).
3861
- const res = await performCompact({
3862
- browser,
3863
- providerLabel: providerLabel(config.provider),
3864
- currentChatId,
3865
- workdir: currentWorkdir,
3866
- locale: currentLocale,
3867
- task: task ?? undefined,
3868
- transcript,
3869
- ui: editor,
3870
- buildSystemPrompt: mod.buildSystemPrompt,
3871
- tools: mod.createTools(currentWorkdir, { undo, todos: todoStore }),
3872
- answerTimeoutMs: providerAnswerTimeoutMs(),
3873
- fallbackLimit: COMPACT_FALLBACK_LIMIT,
3874
- selfImprovement: devMode,
3875
- });
3876
- if (res.ok) {
3877
- currentChatId = res.chatId;
3878
- saveLastChat(currentChatId, currentWorkdir);
3879
- freshChatNext = false;
3880
- // The new chat already carries the system prompt and the context.
3881
- sendSystemPromptNext = false;
3882
- }
3883
- continue;
3884
- }
3885
- if (lower === '/improve' || lower.startsWith('/improve ')) {
3886
- // Backlog-driven self-improvement: pick the next open BACKLOG item (or a
3887
- // specific id) and run the standard task loop on it. The agent reads
3888
- // BACKLOG.md itself for the details, so a FRESH agent needs no external
3889
- // context — this is the reproducible hand-off path.
3890
- const arg = trimmed.slice('/improve'.length).trim();
3891
- let backlogText;
3892
- try {
3893
- backlogText = await fs.readFile(path.join(currentWorkdir, 'BACKLOG.md'), 'utf-8');
3894
- }
3895
- catch {
3896
- console.error(theme.warn(t('improve.no_backlog')));
3897
- continue;
3898
- }
3899
- const items = mod.commands.parseBacklogItems(backlogText);
3900
- const item = mod.commands.nextBacklogItem(items, arg || undefined);
3901
- if (!item) {
3902
- console.log(theme.dim(arg ? t('improve.not_found', { v: arg }) : t('improve.all_done')));
3903
- continue;
3904
- }
3905
- console.log(theme.system(t('improve.start', { id: item.id, title: item.title })));
3906
- const improveBase = mod.createTools(currentWorkdir, {
3907
- undo,
3908
- todos: todoStore,
3909
- readOnly: planMode,
3910
- });
3911
- const improveTools = mergeTools(improveBase, mcpPool?.tools, planMode);
3912
- if (editor)
3913
- editor.busy = true;
3914
- try {
3915
- await mod.runTask(runTaskDeps(), browser, improveTools, mod.commands.buildImprovePrompt(item), currentWorkdir, {
3916
- transcript,
3917
- freshChat: false,
3918
- sendSystemPrompt: false,
3919
- queue: pendingQueue,
3920
- ui: editor || null,
3921
- onChatReady: (chatId) => {
3922
- if (chatId) {
3923
- currentChatId = chatId;
3924
- saveLastChat(chatId, currentWorkdir);
3925
- }
3926
- },
3927
- getTokenUsage: () => browser.getLastTokenUsage(),
3928
- selfImprovement: devMode,
3929
- });
3930
- }
3931
- finally {
3932
- if (editor)
3933
- editor.busy = false;
3934
- }
3935
- // Auto-prune: a finished item keeps its summary line but loses the
3936
- // archived <details> copy of its original text, so BACKLOG.md does not
3937
- // grow on every /improve. Best-effort — a read/write failure must not
3938
- // break the command.
3939
- try {
3940
- const raw = await fs.readFile(path.join(currentWorkdir, 'BACKLOG.md'), 'utf-8');
3941
- const col = mod.collapseBacklog(raw);
3942
- if (col.changed) {
3943
- await fs.writeFile(path.join(currentWorkdir, 'BACKLOG.md'), col.text, 'utf-8');
3944
- console.log(theme.dim(t('improve.collapsed', { n: String(col.collapsed) })));
3945
- }
3946
- }
3947
- catch { }
3948
- continue;
3949
- }
3950
- if (lower === '/backlog' || lower.startsWith('/backlog ')) {
3951
- // Record an improvement idea without implementing it. `/backlog
3952
- // collapse` prunes the archived blocks instead. The LLM only PLACES
3953
- // the item; the pure helpers in src/backlog.ts own the format.
3954
- const note = trimmed.slice('/backlog'.length).trim();
3955
- const backlogFile = path.join(currentWorkdir, 'BACKLOG.md');
3956
- let backlogText;
3957
- try {
3958
- backlogText = await fs.readFile(backlogFile, 'utf-8');
3959
- }
3960
- catch {
3961
- console.error(theme.warn(t('backlog.none')));
3962
- continue;
3963
- }
3964
- if (!note) {
3965
- console.log(theme.dim(t('backlog.usage')));
3966
- continue;
3967
- }
3968
- if (note === 'collapse') {
3969
- const col = mod.collapseBacklog(backlogText);
3970
- if (!col.changed) {
3971
- console.log(theme.dim(t('backlog.empty')));
3972
- }
3973
- else {
3974
- await fs.writeFile(backlogFile, col.text, 'utf-8');
3975
- console.log(theme.system(t('improve.collapsed', { n: String(col.collapsed) })));
3976
- }
3977
- continue;
3978
- }
3979
- // Deterministic append: no model round-trip is needed to record a
3980
- // one-line note, and a pure helper cannot invent a different id than
3981
- // the one the operator is shown.
3982
- const parsed = parseBacklogNote(note);
3983
- const res = mod.appendBacklogItem(backlogText, parsed);
3984
- await fs.writeFile(backlogFile, res.text, 'utf-8');
3985
- console.log(theme.system(t('backlog.adding', { v: res.id })));
3986
- continue;
3987
- }
3988
4480
  if (lower === '/review' || lower.startsWith('/review ')) {
3989
4481
  const rest = trimmed.slice('/review'.length).trim();
3990
4482
  const staged = rest.indexOf('--staged') !== -1;
@@ -4016,128 +4508,6 @@ async function main() {
4016
4508
  }
4017
4509
  continue;
4018
4510
  }
4019
- if (lower === '/undo') {
4020
- const result = await undo.undoLast();
4021
- if (result.ok && result.record) {
4022
- console.log(theme.assistant(t('undo.reverted', { v: result.record.originalPath })) +
4023
- theme.system(result.record.existed ? t('undo.restored') : t('undo.deleted')));
4024
- transcript.log('undo', { path: result.record.originalPath });
4025
- }
4026
- else {
4027
- // undo.ts returns machine codes ('empty'/'disabled') — localize here so
4028
- // an English UI never shows a Russian reason.
4029
- const reasonKey = result.reason === 'empty'
4030
- ? 'undo.reason_empty'
4031
- : result.reason === 'disabled'
4032
- ? 'undo.reason_disabled'
4033
- : null;
4034
- const reason = reasonKey ? t(reasonKey) : String(result.reason ?? '');
4035
- console.error(theme.error(t('undo.failed', { v: reason })));
4036
- }
4037
- continue;
4038
- }
4039
- if (lower === '/undo-list' || lower === '/history') {
4040
- const list = await undo.list(10);
4041
- if (!list.length) {
4042
- console.log(theme.system(t('undo.empty')));
4043
- }
4044
- else {
4045
- for (const r of list) {
4046
- const stamp = undoDateLabel(r.stamp);
4047
- const flag = r.existed ? t('undo.changed') : t('undo.created');
4048
- console.log(theme.system(`${stamp} [${flag}] ${r.originalPath}`));
4049
- }
4050
- }
4051
- continue;
4052
- }
4053
- if (lower === '/rewind-list' || lower === '/checkpoints') {
4054
- const list = await checkpoints.list(10);
4055
- if (!list.length) {
4056
- console.log(theme.system(t('rewind.empty')));
4057
- }
4058
- else {
4059
- console.log(theme.system(t('rewind.list_title')));
4060
- list.forEach((r, i) => {
4061
- const stamp = undoDateLabel(r.stamp);
4062
- const label = r.label ? ' [' + t('rewind.label_backup') + ']' : '';
4063
- console.log(theme.system(` ${i + 1}. ${stamp} ${r.workdir}${label}`));
4064
- });
4065
- }
4066
- continue;
4067
- }
4068
- if (lower === '/rewind' || lower.startsWith('/rewind ')) {
4069
- const selector = trimmed.slice('/rewind'.length).trim() || '1';
4070
- if (editor)
4071
- editor.lock(t('msg.input_locked'));
4072
- try {
4073
- // The DISPLAY window is the newest 10, but a FULL stamp must resolve
4074
- // against the whole index (restore() searches all of it): an older
4075
- // checkpoint picked by its stamp used to report "/rewind failed", and a
4076
- // stamp sharing its first digits with a newer one could resolve to
4077
- // THAT wrong record.
4078
- const list = await checkpoints.list(10);
4079
- const all = isFullStampSelector(selector)
4080
- ? await checkpoints.list(Number.MAX_SAFE_INTEGER)
4081
- : list;
4082
- const target = pickCheckpoint(list, selector, all);
4083
- if (!target) {
4084
- console.error(theme.error(t('rewind.failed', { v: selector })));
4085
- continue;
4086
- }
4087
- // Confirmation: a rewind overwrites the working tree, so it must never
4088
- // happen on a muscle-memory Enter.
4089
- const stamp = new Date(target.stamp).toLocaleString();
4090
- let answer = 'n';
4091
- if (process.stdin.isTTY && process.stdout.isTTY) {
4092
- // promptOnce reads stdin itself. While the LineEditor owns the
4093
- // terminal we must hand it over first (the same pause/resume the
4094
- // /config menu uses), or both readers fight for the same keys.
4095
- if (editor)
4096
- editor.pause();
4097
- try {
4098
- answer = await promptOnce(theme.warn(t('rewind.confirm', { v: stamp })) + ' [y/N] ');
4099
- }
4100
- finally {
4101
- if (editor)
4102
- editor.resume();
4103
- }
4104
- }
4105
- else if (trimmed.indexOf('--yes') !== -1) {
4106
- // Non-TTY (piped): do not guess. Require an explicit --yes.
4107
- answer = 'y';
4108
- }
4109
- if (!/^(y|yes|д|да)$/i.test(answer.trim())) {
4110
- console.log(theme.system(t('rewind.cancelled')));
4111
- continue;
4112
- }
4113
- const res = await checkpoints.restore(selector);
4114
- if (res.ok && res.record) {
4115
- console.log(theme.assistant(t('rewind.reverted', { v: String(res.record.stamp) })));
4116
- if (res.backup) {
4117
- console.log(theme.system(t('rewind.backup', { v: String(res.backup.stamp) })));
4118
- }
4119
- transcript.log('rewind', { stamp: res.record.stamp });
4120
- }
4121
- else {
4122
- const reasonKey = res.reason === 'not_found'
4123
- ? 'rewind.reason_not_found'
4124
- : res.reason === 'archive_missing'
4125
- ? 'rewind.reason_archive_missing'
4126
- : res.reason === 'extract_failed'
4127
- ? 'rewind.reason_extract_failed'
4128
- : res.reason === 'disabled'
4129
- ? 'rewind.reason_disabled'
4130
- : null;
4131
- const reason = reasonKey ? t(reasonKey) : String(res.reason ?? '');
4132
- console.error(theme.error(t('rewind.failed', { v: reason })));
4133
- }
4134
- }
4135
- finally {
4136
- if (editor)
4137
- editor.unlock();
4138
- }
4139
- continue;
4140
- }
4141
4511
  if (lower === '/debug-dom') {
4142
4512
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
4143
4513
  const file = path.join(config.transcript.dir, `dom-${stamp}.html`);
@@ -4349,6 +4719,11 @@ async function main() {
4349
4719
  }
4350
4720
  : null,
4351
4721
  onContextLimit: recoverFromContextLimit,
4722
+ // N8: when the active provider exhausted its retries, continue on an
4723
+ // AUTHORIZED fallback (auth marker + a confirmed login, never a
4724
+ // guest) when the operator configured one. The switch reuses the
4725
+ // existing provider handoff; the SAME task is then retried.
4726
+ onProviderExhausted: async (err) => await fallbackToAuthorizedProvider(err),
4352
4727
  autoCompactPct: config.browser.autoCompactPct,
4353
4728
  contextLimit: effectiveContextLimit(),
4354
4729
  getTokenUsage: () => browser.getLastTokenUsage(),