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