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.
- package/CHANGELOG.md +53 -0
- package/README.md +32 -585
- package/dist/agent-loop.js +47 -4
- package/dist/ask-retry.js +49 -0
- package/dist/ask-state.js +80 -0
- package/dist/browser-base.js +69 -0
- package/dist/browser.js +170 -41
- package/dist/command-registry.js +58 -0
- package/dist/commands.js +25 -2
- package/dist/completion-check.js +77 -0
- package/dist/config-schema.js +8 -0
- package/dist/config.js +14 -0
- package/dist/dev-commands.js +27 -0
- package/dist/dev-watch.js +96 -0
- package/dist/first-run.js +80 -0
- package/dist/glm-browser.js +133 -32
- package/dist/i18n/common.js +6 -3
- package/dist/i18n/config.js +16 -0
- package/dist/i18n/wizard.js +25 -0
- package/dist/i18n.js +6 -1
- package/dist/index.js +1289 -919
- package/dist/info-commands.js +61 -0
- package/dist/input/layout.js +38 -1
- package/dist/input.js +2 -2
- package/dist/password-store.js +48 -0
- package/dist/prompt-budget.js +23 -0
- package/dist/provider-errors.js +53 -0
- package/dist/provider-fallback.js +46 -0
- package/dist/qwen-browser.js +155 -39
- package/dist/run-task.js +2 -1
- package/dist/scheduler-commands.js +41 -0
- package/dist/send-slot.js +36 -0
- package/dist/session-commands.js +42 -0
- package/dist/spinner.js +3 -3
- package/dist/swallow.js +45 -0
- package/dist/system-prompt.js +2 -2
- package/dist/tree-commands.js +36 -0
- package/dist/tui/FullScreenApp.js +13 -4
- package/dist/tui/InputLine.js +20 -3
- package/dist/tui/LogView.js +11 -5
- package/dist/tui/StatusBar.js +4 -1
- package/dist/tui/TuiApp.js +11 -2
- package/dist/tui/TuiEditor.js +21 -4
- package/dist/tui/layout.js +14 -1
- package/dist/tui/store.js +2 -1
- package/dist/tui/ui-colors.js +2 -0
- package/dist/width.js +10 -5
- package/llms-full.txt +16 -292
- 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
|
|
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
|
-
:
|
|
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
|
-
|
|
403
|
+
warnIfNonReloadableChanged();
|
|
392
404
|
}
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
// and print
|
|
399
|
-
|
|
400
|
-
let
|
|
401
|
-
function
|
|
402
|
-
// In dev the running file is src/index.ts; in the built dist
|
|
403
|
-
// with the sources next to it (../src).
|
|
404
|
-
|
|
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
|
|
410
|
-
if (!devMode)
|
|
420
|
+
function warnIfNonReloadableChanged() {
|
|
421
|
+
if (!devMode || nonReloadableWarned)
|
|
411
422
|
return;
|
|
412
423
|
try {
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
if (
|
|
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 (
|
|
420
|
-
|
|
421
|
-
console.error(theme.warn(t('reload.
|
|
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
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
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
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
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
|
-
|
|
2443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
const
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
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
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
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
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
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
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
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
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
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
|
-
|
|
2666
|
-
|
|
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
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
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
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
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
|
-
|
|
2689
|
-
|
|
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
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
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
|
-
|
|
3601
|
+
assertCommandInsideRoot(currentWorkdir, cmd);
|
|
2701
3602
|
}
|
|
2702
3603
|
catch (e) {
|
|
2703
|
-
console.error(theme.error(
|
|
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
|
|
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
|
-
|
|
2717
|
-
|
|
3615
|
+
finally {
|
|
3616
|
+
if (editor)
|
|
3617
|
+
editor.unlock();
|
|
2718
3618
|
}
|
|
2719
3619
|
continue;
|
|
2720
3620
|
}
|
|
2721
|
-
if (lower === '/
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
//
|
|
2731
|
-
//
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
.
|
|
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
|
-
|
|
2752
|
-
|
|
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
|
-
|
|
3173
|
-
//
|
|
3174
|
-
|
|
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(),
|