zames_pro 2.89.0 → 2.90.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +53 -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-schema.js +8 -0
  12. package/dist/config.js +14 -0
  13. package/dist/dev-commands.js +27 -0
  14. package/dist/dev-watch.js +96 -0
  15. package/dist/first-run.js +80 -0
  16. package/dist/glm-browser.js +133 -32
  17. package/dist/i18n/common.js +6 -3
  18. package/dist/i18n/config.js +16 -0
  19. package/dist/i18n/wizard.js +25 -0
  20. package/dist/i18n.js +6 -1
  21. package/dist/index.js +1289 -919
  22. package/dist/info-commands.js +61 -0
  23. package/dist/input/layout.js +38 -1
  24. package/dist/input.js +2 -2
  25. package/dist/password-store.js +48 -0
  26. package/dist/prompt-budget.js +23 -0
  27. package/dist/provider-errors.js +53 -0
  28. package/dist/provider-fallback.js +46 -0
  29. package/dist/qwen-browser.js +155 -39
  30. package/dist/run-task.js +2 -1
  31. package/dist/scheduler-commands.js +41 -0
  32. package/dist/send-slot.js +36 -0
  33. package/dist/session-commands.js +42 -0
  34. package/dist/spinner.js +3 -3
  35. package/dist/swallow.js +45 -0
  36. package/dist/system-prompt.js +2 -2
  37. package/dist/tree-commands.js +36 -0
  38. package/dist/tui/FullScreenApp.js +13 -4
  39. package/dist/tui/InputLine.js +20 -3
  40. package/dist/tui/LogView.js +11 -5
  41. package/dist/tui/StatusBar.js +4 -1
  42. package/dist/tui/TuiApp.js +11 -2
  43. package/dist/tui/TuiEditor.js +21 -4
  44. package/dist/tui/layout.js +14 -1
  45. package/dist/tui/store.js +2 -1
  46. package/dist/tui/ui-colors.js +2 -0
  47. package/dist/width.js +10 -5
  48. package/llms-full.txt +16 -292
  49. 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 { }
@@ -1099,9 +1111,87 @@ async function main() {
1099
1111
  // with a clear message instead of retrying into a dead chat.
1100
1112
  let contextLimitRecovery = null;
1101
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();
1102
1178
  let browser = createProviderBrowser(asProviderId(config.provider), onContextLimit);
1103
- const bootSpinner = mod.createSpinner(currentLocale);
1104
- 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();
1105
1195
  try {
1106
1196
  await browser.launch();
1107
1197
  bootSpinner.stop();
@@ -1139,64 +1229,6 @@ async function main() {
1139
1229
  warnUiHealth(await browser.probeUiHealth());
1140
1230
  }
1141
1231
  catch { }
1142
- // One-shot mode
1143
- if (task) {
1144
- const baseTools = mod.createTools(currentWorkdir, {
1145
- undo,
1146
- readOnly: planMode,
1147
- });
1148
- const tools = mergeTools(baseTools, mcpPool?.tools, planMode);
1149
- let freshChat = true;
1150
- let sendSystemPrompt = true;
1151
- // By default we start a new chat. To continue a previous session —
1152
- // explicitly: --chat <id> or --resume-last.
1153
- let resumeId = chatIdArg;
1154
- if (!resumeId && resumeLastFlag && !newChatFlag) {
1155
- const last = loadLastSession(currentWorkdir, config.provider);
1156
- if (last && last.id) {
1157
- resumeId = last.id;
1158
- console.log(theme.system(t('msg.resuming', { id: resumeId })));
1159
- }
1160
- }
1161
- if (resumeId) {
1162
- try {
1163
- console.log(theme.system(t('msg.opening_chat', { id: resumeId })));
1164
- await browser.openChat(resumeId);
1165
- freshChat = false;
1166
- sendSystemPrompt =
1167
- resendPrompt || config.browser.resendPromptOnResume === true;
1168
- saveLastChat(resumeId, currentWorkdir);
1169
- }
1170
- catch (e) {
1171
- console.error(theme.error(t('msg.open_chat_error', { v: e.message })));
1172
- }
1173
- }
1174
- await autoReload();
1175
- const result = await mod.runTask(runTaskDeps(), browser, tools, task, currentWorkdir, {
1176
- transcript,
1177
- freshChat,
1178
- sendSystemPrompt,
1179
- selfImprovement: devMode,
1180
- onChatReady: (chatId) => {
1181
- if (chatId)
1182
- saveLastChat(chatId, currentWorkdir);
1183
- },
1184
- });
1185
- // One-shot mode (--task) is documented for scripts/CI. A failed agent run
1186
- // used to exit 0, so the CI job stayed green; surface it as exit code 1.
1187
- if (!result.ok)
1188
- process.exitCode = 1;
1189
- await browser.close();
1190
- await mod.closeWeb().catch(() => { });
1191
- if (mcpPool)
1192
- await mcpPool.close().catch(() => { });
1193
- await transcript.close();
1194
- return;
1195
- }
1196
- if (providerNeedsVisibleBrowser(config.provider) &&
1197
- headlessFor(config.provider)) {
1198
- console.log(theme.warn(t('provider.needs_headed', { v: providerLabel(config.provider) })));
1199
- }
1200
1232
  console.log(theme.system(t('msg.interactive')));
1201
1233
  console.log(theme.system(t('msg.queue_hint')));
1202
1234
  console.log(theme.dim(t('msg.first_hint')));
@@ -1275,6 +1307,13 @@ async function main() {
1275
1307
  // /resume <n> handler that read it was removed).
1276
1308
  let lastChats;
1277
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;
1278
1317
  // The last task the operator sent (used by /retry). Plain text only.
1279
1318
  let lastTaskText = '';
1280
1319
  let running = true;
@@ -1482,6 +1521,7 @@ async function main() {
1482
1521
  console.log(theme.system(t('msg.opening_chat', { id: resumeId })));
1483
1522
  await browser.openChat(resumeId);
1484
1523
  currentChatId = resumeId;
1524
+ resumeChatId = resumeId;
1485
1525
  freshChatNext = false;
1486
1526
  sendSystemPromptNext = promptOnResume();
1487
1527
  saveLastChat(resumeId, currentWorkdir);
@@ -1823,6 +1863,22 @@ async function main() {
1823
1863
  void handleGoal(text);
1824
1864
  return;
1825
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
+ }
1826
1882
  }
1827
1883
  const attachments = (items || []).map((a) => ({
1828
1884
  path: a.path,
@@ -2331,6 +2387,7 @@ async function main() {
2331
2387
  try {
2332
2388
  await browser.openChat(pick.id);
2333
2389
  currentChatId = pick.id;
2390
+ resumeChatId = pick.id;
2334
2391
  freshChatNext = false;
2335
2392
  sendSystemPromptNext = promptOnResume();
2336
2393
  saveLastChat(pick.id, currentWorkdir, pick.title);
@@ -2425,357 +2482,1165 @@ async function main() {
2425
2482
  await openResumedChat({ id: pick.id, title: pick.title || '' });
2426
2483
  return true;
2427
2484
  };
2428
- while (running) {
2429
- let input;
2430
- let inputAttachments = [];
2431
- // Set by `/goal <condition>` to fall through into a task turn with this
2432
- // text instead of returning to the prompt (Claude Code starts a turn the
2433
- // moment a goal is set). Reset every iteration because it is declared here.
2434
- 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.
2435
2539
  try {
2436
- if (editor) {
2437
- editor.setPrompt(buildPrompt());
2438
- const msg = await takeInput();
2439
- input = msg ? msg.text : null;
2440
- 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();
2441
2549
  }
2442
- else {
2443
- let tail;
2444
- // The non-TTY fallback prompt mirrors buildPrompt(): bare, no dir.
2445
- if (reviewMode) {
2446
- tail =
2447
- theme.dim('(') +
2448
- theme.warn(t('prompt.review')) +
2449
- theme.dim(':') +
2450
- theme.dir(reviewMode.snapName) +
2451
- theme.dim(') ');
2452
- }
2453
- input = await promptOnce(theme.prompt('❯ ') + tail);
2550
+ catch (e) {
2551
+ noteSwallowed('fallback_close_prev', e, debug);
2454
2552
  }
2553
+ currentChatId = await browser.getCurrentChatId();
2554
+ resumeChatId = null;
2555
+ saveLastChat(currentChatId, currentWorkdir);
2556
+ freshChatNext = true;
2557
+ sendSystemPromptNext = true;
2558
+ return true;
2455
2559
  }
2456
- catch {
2457
- 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;
2458
2566
  }
2459
- const trimmed = (input || '').trim();
2460
- if (!trimmed)
2461
- continue;
2462
- const lower = trimmed.toLowerCase();
2463
- if (['/exit', '/quit', 'exit', 'quit'].includes(lower))
2464
- break;
2465
- // Dev-only commands (backlog / self-review) must not run outside dev mode.
2466
- // Hiding them from /help is not enough: a user could type the command by
2467
- // hand, and it would rewrite their project's BACKLOG.md or snapshot src/.
2468
- if (!devMode) {
2469
- const devCmd = mod.commands.isDevOnlyCommand(lower);
2470
- if (devCmd) {
2471
- console.error(theme.warn(t('msg.dev_only', { v: devCmd })));
2472
- 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 })));
2473
2585
  }
2474
2586
  }
2475
- // `!command` — run a shell command directly, without asking the model
2476
- // (like Claude Code's bash mode). The same sandbox guard as the Bash tool
2477
- // applies, so a direct command cannot leave the project either.
2478
- if (trimmed.startsWith('!')) {
2479
- const cmd = trimmed.slice(1).trim();
2480
- if (!cmd) {
2481
- console.error(theme.warn(t('shell.empty')));
2482
- continue;
2483
- }
2587
+ if (resumeId) {
2484
2588
  try {
2485
- 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);
2486
2595
  }
2487
2596
  catch (e) {
2488
- console.error(theme.error(e.message));
2489
- continue;
2490
- }
2491
- transcript.log('shell_command', { command: cmd });
2492
- if (editor)
2493
- editor.lock(t('shell.running'));
2494
- try {
2495
- const out = await runShell(currentWorkdir, cmd);
2496
- console.log(theme.system(out));
2497
- transcript.log('shell_result', { command: cmd, result: out });
2498
- }
2499
- finally {
2500
- if (editor)
2501
- editor.unlock();
2597
+ console.error(theme.error(t('msg.open_chat_error', { v: e.message })));
2502
2598
  }
2503
- continue;
2504
2599
  }
2505
- if (lower === '/help' ||
2506
- lower === 'help' ||
2507
- lower.startsWith('/help ') ||
2508
- lower.startsWith('help ')) {
2509
- const topic = trimmed.replace(/^\/?help\s*/i, '').trim();
2510
- if (!topic) {
2511
- printHelp();
2512
- }
2513
- else {
2514
- // /help <command> — print the full description of ONE command instead
2515
- // of making the operator scroll the whole list.
2516
- const q = '/' + topic.replace(/^\//, '').toLowerCase();
2517
- const match = buildSlashCommands().filter((c) => c.name.toLowerCase() === q ||
2518
- c.name.toLowerCase().startsWith(q + ' '));
2519
- if (!match.length) {
2520
- console.error(theme.warn(t('help.no_topic', { v: topic })));
2521
- }
2522
- else {
2523
- for (const c of match) {
2524
- console.log(theme.assistant(c.name) + theme.dim(' ' + c.description));
2525
- }
2526
- }
2527
- }
2528
- 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')));
2529
2661
  }
2530
- if (['/new', '/clear', 'new'].includes(lower)) {
2531
- if (editor)
2532
- editor.lock(t('msg.input_locked'));
2533
- try {
2534
- await browser.newChat();
2535
- freshChatNext = false;
2536
- sendSystemPromptNext = true;
2537
- // A new chat starts a fresh checklist: keeping the old tasks would
2538
- // label them as "restored" in a chat that never saw them.
2539
- resetTodos(todoStore);
2540
- currentChatId = await browser.getCurrentChatId();
2541
- saveLastChat(currentChatId, currentWorkdir);
2542
- transcript.log('new_chat');
2543
- // Start the new chat on a clean screen (like /clear in a shell).
2544
- if (editor)
2545
- editor.clearScreen();
2546
- console.log(theme.system(t('msg.new_chat')));
2547
- console.log(theme.system(t('msg.new_chat_ok') + String.fromCharCode(10)));
2548
- }
2549
- catch (e) {
2550
- console.error(theme.error(t('msg.new_chat_error', { v: e.message })), e.message);
2551
- }
2552
- finally {
2553
- if (editor)
2554
- 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;
2555
2670
  }
2556
- 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')));
2557
2681
  }
2558
- if (lower === '/self-review' || lower.startsWith('/self-review ')) {
2559
- const focus = trimmed.slice('/self-review'.length).trim();
2560
- // Remember where to return
2561
- const originalWorkdir = reviewMode
2562
- ? reviewMode.originalWorkdir
2563
- : currentWorkdir;
2564
- if (editor)
2565
- editor.lock(t('msg.input_locked'));
2566
- try {
2567
- const result = await mod.selfReview({
2568
- browser,
2569
- config,
2570
- focus: focus || undefined,
2571
- transcript,
2572
- locale: currentLocale,
2573
- });
2574
- // Switch to review mode:
2575
- // - working directory = snapshot
2576
- // - we do NOT reset the chat — inside selfReview a fresh chat was
2577
- // already created and the review prompt sent, we continue in it
2578
- reviewMode = {
2579
- snapDir: result.snapDir,
2580
- snapName: path.basename(result.snapDir),
2581
- originalWorkdir,
2582
- };
2583
- currentWorkdir = result.snapDir;
2584
- freshChatNext = false;
2585
- sendSystemPromptNext = false;
2586
- currentChatId = await browser.getCurrentChatId();
2587
- saveLastChat(currentChatId, currentWorkdir);
2588
- 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')));
2589
2786
  }
2590
- catch (e) {
2591
- console.error(theme.error(t('self.review_failed')), e.message);
2592
- if (debug)
2593
- 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')));
2594
2806
  }
2595
- 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.
2596
2937
  if (editor)
2597
- 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 })));
2598
2975
  }
2599
- continue;
2600
2976
  }
2601
- if (lower === '/self-fix' || lower.startsWith('/self-fix ')) {
2602
- const rest = trimmed.slice('/self-fix'.length).trim();
2603
- if (!rest) {
2604
- console.error(theme.error(t('self.fix_usage')));
2605
- 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;
2606
3068
  }
2607
- const sp = rest.indexOf(' ');
2608
- const name = sp === -1 ? rest : rest.slice(0, sp);
2609
- const focus = sp === -1 ? '' : rest.slice(sp + 1).trim();
2610
- const snapRoot = path.join(ZAMES_HOME, 'snapshots', name);
2611
- const stat = await fs.stat(snapRoot).catch(() => null);
2612
- if (!stat || !stat.isDirectory()) {
2613
- console.error(theme.error(t('self.snapshot_not_found', { v: snapRoot })));
2614
- continue;
3069
+ if (!scheduler.count() && scheduleTimer) {
3070
+ clearInterval(scheduleTimer);
3071
+ scheduleTimer = null;
2615
3072
  }
2616
- const originalWorkdir = reviewMode
2617
- ? reviewMode.originalWorkdir
2618
- : currentWorkdir;
2619
- // Fresh chat + review prompt for this snapshot
2620
- try {
2621
- const { runAgentLoop: ral } = await import('./agent-loop.js');
2622
- const tools = mod.createTools(snapRoot, { undo: null });
2623
- await browser.newChat();
2624
- const sysPrompt = mod.buildSystemPrompt({
2625
- 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,
2626
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),
2627
3214
  locale: currentLocale,
2628
3215
  });
2629
- console.log(theme.system(t('self.init_review_chat')));
2630
- await browser.ask(sysPrompt, { timeout: 60_000 });
2631
- if (focus) {
2632
- const ui = mod.createSpinner(currentLocale);
2633
- ui.thinking();
2634
- await ral({
2635
- browser,
2636
- tools,
2637
- task: focus,
2638
- workdir: snapRoot,
2639
- maxIterations: currentMaxIter(),
2640
- freshChat: false,
2641
- sendSystemPrompt: false,
2642
- transcript,
2643
- onThinking: () => ui.thinking(),
2644
- onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
2645
- onToolResult: (r) => ui.toolResult(r),
2646
- onAssistantMessage: (m) => {
2647
- ui.assistant(m);
2648
- },
2649
- onWarning: (m) => ui.warning(m),
2650
- locale: currentLocale,
2651
- });
2652
- }
2653
- reviewMode = {
2654
- snapDir: snapRoot,
2655
- snapName: name,
2656
- originalWorkdir,
2657
- };
2658
- currentWorkdir = snapRoot;
2659
- freshChatNext = false;
2660
- sendSystemPromptNext = false;
2661
- currentChatId = await browser.getCurrentChatId();
2662
- saveLastChat(currentChatId, currentWorkdir);
2663
- console.log(theme.user(t('self.fix_hint', { name })));
2664
3216
  }
2665
- catch (e) {
2666
- 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);
2667
3413
  }
3414
+ }
3415
+ catch {
3416
+ break;
3417
+ }
3418
+ const trimmed = (input || '').trim();
3419
+ if (!trimmed)
2668
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
+ }
2669
3508
  }
2670
- if (lower === '/self-done') {
2671
- if (!reviewMode) {
2672
- 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
+ })) {
2673
3527
  continue;
2674
3528
  }
2675
- const back = reviewMode.originalWorkdir;
2676
- reviewMode = null;
2677
- currentWorkdir = back;
2678
- // Since the chat is busy with the review context, we'll create a new one for regular work
2679
- freshChatNext = true;
2680
- sendSystemPromptNext = true;
2681
- console.log(theme.system(t('self.done_hint', { v: back })));
2682
- continue;
2683
3529
  }
2684
- if (lower === '/self-list') {
2685
- try {
2686
- 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;
2687
3546
  }
2688
- catch (e) {
2689
- 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;
2690
3589
  }
2691
- continue;
2692
3590
  }
2693
- if (lower === '/self-diff' || lower.startsWith('/self-diff ')) {
2694
- const name = trimmed.slice('/self-diff'.length).trim();
2695
- if (!name) {
2696
- 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')));
2697
3598
  continue;
2698
3599
  }
2699
3600
  try {
2700
- await mod.selfDiff({ config, name });
3601
+ assertCommandInsideRoot(currentWorkdir, cmd);
2701
3602
  }
2702
3603
  catch (e) {
2703
- console.error(theme.error(t('msg.error')), e.message);
2704
- }
2705
- continue;
2706
- }
2707
- if (lower === '/self-apply' || lower.startsWith('/self-apply ')) {
2708
- const name = trimmed.slice('/self-apply'.length).trim();
2709
- if (!name) {
2710
- console.error(theme.error(t('self.apply_usage')));
3604
+ console.error(theme.error(e.message));
2711
3605
  continue;
2712
3606
  }
3607
+ transcript.log('shell_command', { command: cmd });
3608
+ if (editor)
3609
+ editor.lock(t('shell.running'));
2713
3610
  try {
2714
- 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 });
2715
3614
  }
2716
- catch (e) {
2717
- console.error(theme.error(t('msg.error')), e.message);
3615
+ finally {
3616
+ if (editor)
3617
+ editor.unlock();
2718
3618
  }
2719
3619
  continue;
2720
3620
  }
2721
- if (lower === '/chats' || lower.startsWith('/chats ')) {
2722
- const filter = trimmed.slice('/chats'.length).trim().toLowerCase();
2723
- const spin = editor || mod.createSpinner(currentLocale);
2724
- if (editor)
2725
- editor.lock(t('msg.input_locked'));
2726
- spin.thinking();
2727
- try {
2728
- const all = await browser.listChats(30);
2729
- // Hide subagent chats: they are real DeepSeek chats, but they are
2730
- // internal context-isolation artifacts whose report already lives in
2731
- // the parent chat, so resuming one is never what the operator wants.
2732
- // The scraper runs inside the page and cannot localize: a row without
2733
- // text comes back with an EMPTY title, so the label is applied HERE
2734
- // (one place, in the operator's language) instead of a hardcoded one.
2735
- lastChats = all
2736
- .filter((c) => !isInternalChat(c.id))
2737
- .map((c) => ({
2738
- ...c,
2739
- title: c.title && c.title.trim() ? c.title : t('chats.untitled'),
2740
- }));
2741
- spin.stop();
2742
- // An optional query filters by title (substring, case-insensitive),
2743
- // so a long chat list does not have to be scanned by eye.
2744
- const shown = filter
2745
- ? lastChats.filter((c) => c.title.toLowerCase().includes(filter))
2746
- : lastChats;
2747
- if (!shown.length) {
2748
- 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 })));
2749
3637
  }
2750
3638
  else {
2751
- // In the TUI, open a real SELECT LIST (like /model) instead of
2752
- // dumping text: pick a chat and it opens directly. The text path
2753
- // below stays for non-TTY / the legacy editor.
2754
- const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
2755
- if (canPick && editor) {
2756
- const openChat = await pickChatInteractively(shown);
2757
- if (openChat)
2758
- continue;
2759
- }
2760
- console.log(theme.system(t('chats.recent')));
2761
- // The number is the index in the FULL list (a filter only narrows
2762
- // the VIEW), and the full id in brackets is what /resume-id takes.
2763
- for (const c of shown) {
2764
- const idx = lastChats.indexOf(c);
2765
- const n = String(idx + 1).padStart(2, ' ');
2766
- 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));
2767
3641
  }
2768
- console.log(theme.system(t('chats.use_resume')));
2769
3642
  }
2770
3643
  }
2771
- catch (e) {
2772
- spin.stop();
2773
- console.error(theme.error(t('chats.fetch_error')), e.message);
2774
- }
2775
- finally {
2776
- if (editor)
2777
- editor.unlock();
2778
- }
2779
3644
  continue;
2780
3645
  }
2781
3646
  if (lower === '/provider' || lower.startsWith('/provider ')) {
@@ -3054,128 +3919,14 @@ async function main() {
3054
3919
  }
3055
3920
  continue;
3056
3921
  }
3057
- if (lower === '/sessions' ||
3058
- lower === '/session' ||
3059
- lower.startsWith('/sessions ') ||
3060
- lower.startsWith('/session ')) {
3061
- const filter = trimmed
3062
- .replace(/^\/sessions?/i, '')
3063
- .trim()
3064
- .toLowerCase();
3065
- // /sessions <query> filters by title or workdir (substring). Sorted by
3066
- // recency already (listSessions).
3067
- const all = filterSessions(listSessions(), filter);
3068
- console.log(theme.system(t('sessions.dir', { v: sessionsDir() })));
3069
- if (!all.length) {
3070
- console.log(theme.system(t('sessions.none')));
3071
- }
3072
- else {
3073
- // In the TUI, open a SELECT LIST and resume the chosen session
3074
- // directly (same behavior as /chats), instead of only listing ids.
3075
- const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
3076
- if (canPick && editor) {
3077
- const opened = await pickSessionInteractively(all);
3078
- if (opened)
3079
- continue;
3080
- }
3081
- all.forEach((s, i) => {
3082
- const n = String(i + 1).padStart(2, ' ');
3083
- const mark = s.id === currentChatId ? theme.user(' *') : '';
3084
- const title = s.title ? ` ${s.title}` : '';
3085
- const wd = s.workdir ? theme.dim(` [${dirLabel(s.workdir)}]`) : '';
3086
- const age = mod.commands.formatRelativeTime(s.updatedAt, t);
3087
- const ageText = age ? theme.dim(' ' + age) : '';
3088
- console.log(` ${theme.user(n)}. ${s.id.slice(0, 8)}…${title}${wd}${ageText}${mark}`);
3089
- });
3090
- console.log(theme.system(t('sessions.restore_hint')));
3091
- }
3092
- continue;
3093
- }
3094
- if (lower === '/last') {
3095
- // Quick resume of the last chat for THIS working directory — otherwise
3096
- // finding it meant /chats (a network fetch) or copying the id.
3097
- const last = loadLastSession(currentWorkdir, config.provider);
3098
- if (!last || !last.id) {
3099
- console.error(theme.error(t('sessions.none_here')));
3100
- continue;
3101
- }
3102
- if (editor)
3103
- editor.lock(t('msg.input_locked'));
3104
- try {
3105
- if (editor)
3106
- editor.clearScreen();
3107
- console.log(theme.system(t('msg.opening_chat', { id: last.id })));
3108
- await browser.openChat(last.id);
3109
- currentChatId = last.id;
3110
- freshChatNext = false;
3111
- sendSystemPromptNext = promptOnResume();
3112
- saveLastChat(last.id, currentWorkdir, last.title);
3113
- transcript.log('resume_chat', { id: last.id, title: last.title });
3114
- console.log(theme.assistant(t('msg.chat_opened')));
3115
- loadSessionTodos(last.id);
3116
- await printRestoredHistory(browser, editor, last.id);
3117
- }
3118
- catch (e) {
3119
- console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
3120
- }
3121
- finally {
3122
- if (editor)
3123
- editor.unlock();
3124
- }
3125
- continue;
3126
- }
3127
- if (lower.startsWith('/resume-id ')) {
3128
- const id = parseResumeId(trimmed.slice('/resume-id'.length));
3129
- if (!id) {
3130
- console.error(theme.error(t('sessions.resume_id_usage')));
3131
- continue;
3132
- }
3133
- // A chat id only exists on the backend that created it, so opening a
3134
- // session saved by ANOTHER provider fails deep inside the browser with a
3135
- // confusing error. Refuse up front and name the right /provider command.
3136
- const refusal = resumeProviderRefusal(readSession(id), config.provider, t);
3137
- if (refusal) {
3138
- console.error(theme.error(refusal));
3139
- continue;
3140
- }
3141
- if (editor)
3142
- editor.lock(t('msg.input_locked'));
3143
- try {
3144
- if (editor)
3145
- editor.clearScreen();
3146
- console.log(theme.system(t('msg.opening_chat', { id })));
3147
- await browser.openChat(id);
3148
- currentChatId = id;
3149
- freshChatNext = false;
3150
- sendSystemPromptNext = promptOnResume();
3151
- saveLastChat(id, currentWorkdir);
3152
- transcript.log('resume_chat', { id });
3153
- console.log(theme.assistant(t('msg.chat_opened')) +
3154
- theme.system(String.fromCharCode(10)));
3155
- loadSessionTodos(id);
3156
- await printRestoredHistory(browser, editor, id);
3157
- }
3158
- catch (e) {
3159
- console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
3160
- }
3161
- finally {
3162
- if (editor)
3163
- editor.unlock();
3164
- }
3165
- continue;
3166
- }
3167
3922
  if (lower === '/pwd') {
3168
3923
  console.log(theme.system(currentWorkdir));
3169
3924
  continue;
3170
3925
  }
3171
3926
  if (lower === '/goal' || lower.startsWith('/goal ')) {
3172
- const sub = await handleGoal(trimmed);
3173
- // Claude Code parity: setting a goal STARTS a turn immediately, with the
3174
- // condition itself as the directive (no separate prompt needed). The
3175
- // completion loop in runTask() then keeps working until the evaluator
3176
- // reports MET/IMPOSSIBLE. We fall through to the regular task path with
3177
- // an override task text instead of returning to the prompt.
3178
- 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)) {
3179
3930
  taskOverride =
3180
3931
  'Work toward this completion goal until it is satisfied. ' +
3181
3932
  'Do not call respond with a final summary until the condition is met.';
@@ -3184,113 +3935,6 @@ async function main() {
3184
3935
  continue;
3185
3936
  }
3186
3937
  }
3187
- if (lower === '/loop' || lower.startsWith('/loop ')) {
3188
- const rest = trimmed.slice('/loop'.length).trim();
3189
- const sp = rest.indexOf(' ');
3190
- const intervalStr = sp === -1 ? rest : rest.slice(0, sp);
3191
- const taskStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
3192
- const ms = parseInterval(intervalStr);
3193
- if (!ms && intervalStr) {
3194
- // Say WHY the job was not added. A bare `10` is not a typo the agent
3195
- // can spot: it IS a valid interval (10 SECONDS), while an operator who
3196
- // meant ten minutes needs the `m` suffix.
3197
- console.error(theme.error(t('sched.interval_typo', { v: intervalStr })));
3198
- continue;
3199
- }
3200
- if (!ms || !taskStr) {
3201
- console.error(theme.error(t('sched.loop_usage')));
3202
- continue;
3203
- }
3204
- // A very short loop (e.g. "/loop 1s ...") would keep the agent sending
3205
- // back-to-back and hit the rate limit. Enforce a floor; the send throttle
3206
- // is 15s, so anything faster is pointless anyway.
3207
- if (ms < MIN_LOOP_MS) {
3208
- console.error(theme.error(t('sched.loop_too_short', {
3209
- min: formatInterval(MIN_LOOP_MS),
3210
- })));
3211
- continue;
3212
- }
3213
- const job = scheduler.addLoop(ms, taskStr, Date.now());
3214
- startScheduleTicker();
3215
- console.log(theme.system(t('sched.added', {
3216
- id: String(job.id),
3217
- when: formatInterval(ms),
3218
- task: taskStr,
3219
- })));
3220
- // Tell the operator WHEN it will actually run. A scheduled task never
3221
- // interrupts a running turn: it is queued and starts only when the
3222
- // agent is idle (or right after the current task). The bare "added"
3223
- // line made a first /loop look like a no-op, so the operator re-sent the
3224
- // task by hand (the very report this line answers).
3225
- console.log(theme.dim(t('sched.added_hint')));
3226
- continue;
3227
- }
3228
- if (lower === '/cron' || lower.startsWith('/cron ')) {
3229
- const rest = trimmed.slice('/cron'.length).trim();
3230
- // A cron expression is exactly 5 whitespace-separated fields; the task
3231
- // is everything after them.
3232
- const parts = rest.split(/\s+/);
3233
- if (parts.length < 6) {
3234
- console.error(theme.error(t('sched.cron_usage')));
3235
- continue;
3236
- }
3237
- const cronExpr = parts.slice(0, 5).join(' ');
3238
- const taskStr = parts.slice(5).join(' ').trim();
3239
- if (!parseCron(cronExpr) || !taskStr) {
3240
- console.error(theme.error(t('sched.cron_usage')));
3241
- continue;
3242
- }
3243
- const job = scheduler.addCron(cronExpr, taskStr, Date.now());
3244
- if (!job) {
3245
- console.error(theme.error(t('sched.cron_usage')));
3246
- continue;
3247
- }
3248
- startScheduleTicker();
3249
- const nextIn = formatInterval(Math.max(0, job.nextAt - Date.now()));
3250
- console.log(theme.system(t('sched.added', {
3251
- id: String(job.id),
3252
- when: cronExpr + ' (in ~' + nextIn + ')',
3253
- task: taskStr,
3254
- })));
3255
- console.log(theme.dim(t('sched.added_hint')));
3256
- continue;
3257
- }
3258
- if (lower === '/jobs' || lower.startsWith('/jobs ')) {
3259
- const sub = trimmed.slice('/jobs'.length).trim().toLowerCase();
3260
- if (sub === 'clear') {
3261
- const n = scheduler.clear();
3262
- if (scheduleTimer) {
3263
- clearInterval(scheduleTimer);
3264
- scheduleTimer = null;
3265
- }
3266
- console.log(theme.system(t('sched.cleared', { n: String(n) })));
3267
- continue;
3268
- }
3269
- if (sub.startsWith('rm ') || sub.startsWith('remove ')) {
3270
- const id = Number(sub.replace(/^(rm|remove)\s+/, ''));
3271
- if (!Number.isInteger(id) || !scheduler.remove(id)) {
3272
- console.error(theme.error(t('sched.rm_usage')));
3273
- continue;
3274
- }
3275
- if (!scheduler.count() && scheduleTimer) {
3276
- clearInterval(scheduleTimer);
3277
- scheduleTimer = null;
3278
- }
3279
- console.log(theme.system(t('sched.removed', { id: String(id) })));
3280
- continue;
3281
- }
3282
- const jobs = scheduler.list();
3283
- if (!jobs.length) {
3284
- console.log(theme.dim(t('sched.none')));
3285
- continue;
3286
- }
3287
- console.log(theme.system(t('sched.title')));
3288
- for (const j of jobs) {
3289
- console.log(theme.assistant(formatJobLine(j, Date.now())));
3290
- }
3291
- console.log(theme.dim(t('sched.remove_hint')));
3292
- continue;
3293
- }
3294
3938
  if (lower === '/queue' || lower.startsWith('/queue ')) {
3295
3939
  const q = mod.commands.parseQueueCommand(trimmed);
3296
3940
  const view = renderQueueView(q ? q.sub : null, pendingQueue.length, pendingQueue);
@@ -3558,35 +4202,6 @@ async function main() {
3558
4202
  editor.setCommands(buildSlashCommands());
3559
4203
  continue;
3560
4204
  }
3561
- if (lower === '/status') {
3562
- const statusToggles = browser.getToggleStatesSync();
3563
- const statusLines = formatStatus({
3564
- workdir: currentWorkdir,
3565
- reviewMode: reviewMode ? reviewMode.snapName : null,
3566
- origDir: reviewMode ? reviewMode.originalWorkdir : null,
3567
- chatId: currentChatId,
3568
- freshNext: freshChatNext,
3569
- promptNext: sendSystemPromptNext,
3570
- lastChat: loadLastChat(currentWorkdir),
3571
- sessionsDir: sessionsDir(),
3572
- devMode,
3573
- maxIterations: currentMaxIter(),
3574
- headless,
3575
- debug,
3576
- deepThinking: statusToggles.deepThinking,
3577
- webSearch: statusToggles.webSearch,
3578
- undo: config.undo.enabled,
3579
- checkpoint: config.checkpoint.enabled,
3580
- transcriptFile: transcript.file,
3581
- goal: sessionGoal,
3582
- localeName: localeDisplayName(currentLocale),
3583
- mcpToolCount: mcpPool ? mcpPool.status().toolCount : null,
3584
- tokenUsage: browser.getLastTokenUsage(),
3585
- }, t);
3586
- for (const line of statusLines)
3587
- console.log(theme.system(line));
3588
- continue;
3589
- }
3590
4205
  if (lower === '/config' || lower.startsWith('/config ')) {
3591
4206
  await handleConfigCommand(trimmed);
3592
4207
  continue;
@@ -3695,30 +4310,6 @@ async function main() {
3695
4310
  }
3696
4311
  continue;
3697
4312
  }
3698
- if (lower === '/diff' || lower.startsWith('/diff ')) {
3699
- const staged = lower.indexOf('--staged') !== -1;
3700
- const { runGitArgs } = await import('./gitTools.js');
3701
- const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], currentWorkdir, 5000);
3702
- if (probe.trim() !== 'true') {
3703
- console.error(theme.error(t('diff.not_repo')));
3704
- continue;
3705
- }
3706
- const out = await runGitArgs(mod.commands.diffGitArgs(staged), currentWorkdir, 20_000);
3707
- console.log(theme.system(mod.commands.formatDiff(out, { maxLines: 400 }, t)));
3708
- continue;
3709
- }
3710
- if (lower === '/diffstat' || lower.startsWith('/diffstat ')) {
3711
- const staged = lower.indexOf('--staged') !== -1;
3712
- const { runGitArgs } = await import('./gitTools.js');
3713
- const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], currentWorkdir, 5000);
3714
- if (probe.trim() !== 'true') {
3715
- console.error(theme.error(t('diff.not_repo')));
3716
- continue;
3717
- }
3718
- const out = await runGitArgs(mod.commands.diffStatGitArgs(staged), currentWorkdir, 20_000);
3719
- console.log(theme.system(mod.commands.formatDiffStat(out, { maxLines: 60 }, t)));
3720
- continue;
3721
- }
3722
4313
  if (lower === '/limits') {
3723
4314
  const { readSendEvents, summarizeSends, sendLogPath } = await import('./send-telemetry.js');
3724
4315
  const logsDir = config.transcript.dir;
@@ -3821,6 +4412,35 @@ async function main() {
3821
4412
  transcriptOk: !!transcript.file,
3822
4413
  authSaved: authMarkerExists(),
3823
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),
3824
4444
  hasOrigin: gitOk
3825
4445
  ? (await runGitArgs(['remote'], currentWorkdir, 5000)).includes('origin')
3826
4446
  : undefined,
@@ -3857,139 +4477,6 @@ async function main() {
3857
4477
  console.log(theme.system(t('adddir.note', { v: res.path })));
3858
4478
  continue;
3859
4479
  }
3860
- if (lower === '/compact') {
3861
- // Compaction: ask DeepSeek (in the CURRENT chat) to compress the
3862
- // history into a handover summary, then start a NEW chat, resend the
3863
- // system prompt and post the summary as the carried-over context.
3864
- // Shared with the AUTO-compact path via performCompact() so both
3865
- // paths stay identical (same retries, same fallback, same resend).
3866
- const res = await performCompact({
3867
- browser,
3868
- providerLabel: providerLabel(config.provider),
3869
- currentChatId,
3870
- workdir: currentWorkdir,
3871
- locale: currentLocale,
3872
- task: task ?? undefined,
3873
- transcript,
3874
- ui: editor,
3875
- buildSystemPrompt: mod.buildSystemPrompt,
3876
- tools: mod.createTools(currentWorkdir, { undo, todos: todoStore }),
3877
- answerTimeoutMs: providerAnswerTimeoutMs(),
3878
- fallbackLimit: COMPACT_FALLBACK_LIMIT,
3879
- selfImprovement: devMode,
3880
- });
3881
- if (res.ok) {
3882
- currentChatId = res.chatId;
3883
- saveLastChat(currentChatId, currentWorkdir);
3884
- freshChatNext = false;
3885
- // The new chat already carries the system prompt and the context.
3886
- sendSystemPromptNext = false;
3887
- }
3888
- continue;
3889
- }
3890
- if (lower === '/improve' || lower.startsWith('/improve ')) {
3891
- // Backlog-driven self-improvement: pick the next open BACKLOG item (or a
3892
- // specific id) and run the standard task loop on it. The agent reads
3893
- // BACKLOG.md itself for the details, so a FRESH agent needs no external
3894
- // context — this is the reproducible hand-off path.
3895
- const arg = trimmed.slice('/improve'.length).trim();
3896
- let backlogText;
3897
- try {
3898
- backlogText = await fs.readFile(path.join(currentWorkdir, 'BACKLOG.md'), 'utf-8');
3899
- }
3900
- catch {
3901
- console.error(theme.warn(t('improve.no_backlog')));
3902
- continue;
3903
- }
3904
- const items = mod.commands.parseBacklogItems(backlogText);
3905
- const item = mod.commands.nextBacklogItem(items, arg || undefined);
3906
- if (!item) {
3907
- console.log(theme.dim(arg ? t('improve.not_found', { v: arg }) : t('improve.all_done')));
3908
- continue;
3909
- }
3910
- console.log(theme.system(t('improve.start', { id: item.id, title: item.title })));
3911
- const improveBase = mod.createTools(currentWorkdir, {
3912
- undo,
3913
- todos: todoStore,
3914
- readOnly: planMode,
3915
- });
3916
- const improveTools = mergeTools(improveBase, mcpPool?.tools, planMode);
3917
- if (editor)
3918
- editor.busy = true;
3919
- try {
3920
- await mod.runTask(runTaskDeps(), browser, improveTools, mod.commands.buildImprovePrompt(item), currentWorkdir, {
3921
- transcript,
3922
- freshChat: false,
3923
- sendSystemPrompt: false,
3924
- queue: pendingQueue,
3925
- ui: editor || null,
3926
- onChatReady: (chatId) => {
3927
- if (chatId) {
3928
- currentChatId = chatId;
3929
- saveLastChat(chatId, currentWorkdir);
3930
- }
3931
- },
3932
- getTokenUsage: () => browser.getLastTokenUsage(),
3933
- selfImprovement: devMode,
3934
- });
3935
- }
3936
- finally {
3937
- if (editor)
3938
- editor.busy = false;
3939
- }
3940
- // Auto-prune: a finished item keeps its summary line but loses the
3941
- // archived <details> copy of its original text, so BACKLOG.md does not
3942
- // grow on every /improve. Best-effort — a read/write failure must not
3943
- // break the command.
3944
- try {
3945
- const raw = await fs.readFile(path.join(currentWorkdir, 'BACKLOG.md'), 'utf-8');
3946
- const col = mod.collapseBacklog(raw);
3947
- if (col.changed) {
3948
- await fs.writeFile(path.join(currentWorkdir, 'BACKLOG.md'), col.text, 'utf-8');
3949
- console.log(theme.dim(t('improve.collapsed', { n: String(col.collapsed) })));
3950
- }
3951
- }
3952
- catch { }
3953
- continue;
3954
- }
3955
- if (lower === '/backlog' || lower.startsWith('/backlog ')) {
3956
- // Record an improvement idea without implementing it. `/backlog
3957
- // collapse` prunes the archived blocks instead. The LLM only PLACES
3958
- // the item; the pure helpers in src/backlog.ts own the format.
3959
- const note = trimmed.slice('/backlog'.length).trim();
3960
- const backlogFile = path.join(currentWorkdir, 'BACKLOG.md');
3961
- let backlogText;
3962
- try {
3963
- backlogText = await fs.readFile(backlogFile, 'utf-8');
3964
- }
3965
- catch {
3966
- console.error(theme.warn(t('backlog.none')));
3967
- continue;
3968
- }
3969
- if (!note) {
3970
- console.log(theme.dim(t('backlog.usage')));
3971
- continue;
3972
- }
3973
- if (note === 'collapse') {
3974
- const col = mod.collapseBacklog(backlogText);
3975
- if (!col.changed) {
3976
- console.log(theme.dim(t('backlog.empty')));
3977
- }
3978
- else {
3979
- await fs.writeFile(backlogFile, col.text, 'utf-8');
3980
- console.log(theme.system(t('improve.collapsed', { n: String(col.collapsed) })));
3981
- }
3982
- continue;
3983
- }
3984
- // Deterministic append: no model round-trip is needed to record a
3985
- // one-line note, and a pure helper cannot invent a different id than
3986
- // the one the operator is shown.
3987
- const parsed = parseBacklogNote(note);
3988
- const res = mod.appendBacklogItem(backlogText, parsed);
3989
- await fs.writeFile(backlogFile, res.text, 'utf-8');
3990
- console.log(theme.system(t('backlog.adding', { v: res.id })));
3991
- continue;
3992
- }
3993
4480
  if (lower === '/review' || lower.startsWith('/review ')) {
3994
4481
  const rest = trimmed.slice('/review'.length).trim();
3995
4482
  const staged = rest.indexOf('--staged') !== -1;
@@ -4021,128 +4508,6 @@ async function main() {
4021
4508
  }
4022
4509
  continue;
4023
4510
  }
4024
- if (lower === '/undo') {
4025
- const result = await undo.undoLast();
4026
- if (result.ok && result.record) {
4027
- console.log(theme.assistant(t('undo.reverted', { v: result.record.originalPath })) +
4028
- theme.system(result.record.existed ? t('undo.restored') : t('undo.deleted')));
4029
- transcript.log('undo', { path: result.record.originalPath });
4030
- }
4031
- else {
4032
- // undo.ts returns machine codes ('empty'/'disabled') — localize here so
4033
- // an English UI never shows a Russian reason.
4034
- const reasonKey = result.reason === 'empty'
4035
- ? 'undo.reason_empty'
4036
- : result.reason === 'disabled'
4037
- ? 'undo.reason_disabled'
4038
- : null;
4039
- const reason = reasonKey ? t(reasonKey) : String(result.reason ?? '');
4040
- console.error(theme.error(t('undo.failed', { v: reason })));
4041
- }
4042
- continue;
4043
- }
4044
- if (lower === '/undo-list' || lower === '/history') {
4045
- const list = await undo.list(10);
4046
- if (!list.length) {
4047
- console.log(theme.system(t('undo.empty')));
4048
- }
4049
- else {
4050
- for (const r of list) {
4051
- const stamp = undoDateLabel(r.stamp);
4052
- const flag = r.existed ? t('undo.changed') : t('undo.created');
4053
- console.log(theme.system(`${stamp} [${flag}] ${r.originalPath}`));
4054
- }
4055
- }
4056
- continue;
4057
- }
4058
- if (lower === '/rewind-list' || lower === '/checkpoints') {
4059
- const list = await checkpoints.list(10);
4060
- if (!list.length) {
4061
- console.log(theme.system(t('rewind.empty')));
4062
- }
4063
- else {
4064
- console.log(theme.system(t('rewind.list_title')));
4065
- list.forEach((r, i) => {
4066
- const stamp = undoDateLabel(r.stamp);
4067
- const label = r.label ? ' [' + t('rewind.label_backup') + ']' : '';
4068
- console.log(theme.system(` ${i + 1}. ${stamp} ${r.workdir}${label}`));
4069
- });
4070
- }
4071
- continue;
4072
- }
4073
- if (lower === '/rewind' || lower.startsWith('/rewind ')) {
4074
- const selector = trimmed.slice('/rewind'.length).trim() || '1';
4075
- if (editor)
4076
- editor.lock(t('msg.input_locked'));
4077
- try {
4078
- // The DISPLAY window is the newest 10, but a FULL stamp must resolve
4079
- // against the whole index (restore() searches all of it): an older
4080
- // checkpoint picked by its stamp used to report "/rewind failed", and a
4081
- // stamp sharing its first digits with a newer one could resolve to
4082
- // THAT wrong record.
4083
- const list = await checkpoints.list(10);
4084
- const all = isFullStampSelector(selector)
4085
- ? await checkpoints.list(Number.MAX_SAFE_INTEGER)
4086
- : list;
4087
- const target = pickCheckpoint(list, selector, all);
4088
- if (!target) {
4089
- console.error(theme.error(t('rewind.failed', { v: selector })));
4090
- continue;
4091
- }
4092
- // Confirmation: a rewind overwrites the working tree, so it must never
4093
- // happen on a muscle-memory Enter.
4094
- const stamp = new Date(target.stamp).toLocaleString();
4095
- let answer = 'n';
4096
- if (process.stdin.isTTY && process.stdout.isTTY) {
4097
- // promptOnce reads stdin itself. While the LineEditor owns the
4098
- // terminal we must hand it over first (the same pause/resume the
4099
- // /config menu uses), or both readers fight for the same keys.
4100
- if (editor)
4101
- editor.pause();
4102
- try {
4103
- answer = await promptOnce(theme.warn(t('rewind.confirm', { v: stamp })) + ' [y/N] ');
4104
- }
4105
- finally {
4106
- if (editor)
4107
- editor.resume();
4108
- }
4109
- }
4110
- else if (trimmed.indexOf('--yes') !== -1) {
4111
- // Non-TTY (piped): do not guess. Require an explicit --yes.
4112
- answer = 'y';
4113
- }
4114
- if (!/^(y|yes|д|да)$/i.test(answer.trim())) {
4115
- console.log(theme.system(t('rewind.cancelled')));
4116
- continue;
4117
- }
4118
- const res = await checkpoints.restore(selector);
4119
- if (res.ok && res.record) {
4120
- console.log(theme.assistant(t('rewind.reverted', { v: String(res.record.stamp) })));
4121
- if (res.backup) {
4122
- console.log(theme.system(t('rewind.backup', { v: String(res.backup.stamp) })));
4123
- }
4124
- transcript.log('rewind', { stamp: res.record.stamp });
4125
- }
4126
- else {
4127
- const reasonKey = res.reason === 'not_found'
4128
- ? 'rewind.reason_not_found'
4129
- : res.reason === 'archive_missing'
4130
- ? 'rewind.reason_archive_missing'
4131
- : res.reason === 'extract_failed'
4132
- ? 'rewind.reason_extract_failed'
4133
- : res.reason === 'disabled'
4134
- ? 'rewind.reason_disabled'
4135
- : null;
4136
- const reason = reasonKey ? t(reasonKey) : String(res.reason ?? '');
4137
- console.error(theme.error(t('rewind.failed', { v: reason })));
4138
- }
4139
- }
4140
- finally {
4141
- if (editor)
4142
- editor.unlock();
4143
- }
4144
- continue;
4145
- }
4146
4511
  if (lower === '/debug-dom') {
4147
4512
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
4148
4513
  const file = path.join(config.transcript.dir, `dom-${stamp}.html`);
@@ -4354,6 +4719,11 @@ async function main() {
4354
4719
  }
4355
4720
  : null,
4356
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),
4357
4727
  autoCompactPct: config.browser.autoCompactPct,
4358
4728
  contextLimit: effectiveContextLimit(),
4359
4729
  getTokenUsage: () => browser.getLastTokenUsage(),