zames_pro 2.66.1 → 2.67.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/dist/gitTools.js CHANGED
@@ -1,4 +1,4 @@
1
- import { exec } from 'child_process';
1
+ import { exec, execFile } from 'child_process';
2
2
  const BS = String.fromCharCode(92);
3
3
  export function runGit(cmd, cwd, timeout = 15_000) {
4
4
  return new Promise((resolve) => {
@@ -36,15 +36,44 @@ export function runGit(cmd, cwd, timeout = 15_000) {
36
36
  });
37
37
  });
38
38
  }
39
- // Safely quotes paths/arguments for passing to the shell.
40
- // String or array -> a string with double quotes and escaping.
41
- function quoteArgs(input) {
42
- const list = Array.isArray(input) ? input : [input];
43
- return list
44
- .map((v) => String(v).trim())
45
- .filter(Boolean)
46
- .map((v) => JSON.stringify(v))
47
- .join(' ');
39
+ // Run git with an argument ARRAY (no shell). Use this whenever an argument
40
+ // comes from the model: the shell never sees it, so $(...), backticks and a
41
+ // leading `-` (option injection) cannot turn a read-only call into a command.
42
+ export function runGitArgs(args, cwd, timeout = 15_000) {
43
+ return new Promise((resolve) => {
44
+ execFile('git', args, {
45
+ cwd,
46
+ timeout,
47
+ windowsHide: true,
48
+ maxBuffer: 1024 * 1024 * 8,
49
+ env: {
50
+ ...process.env,
51
+ GIT_TERMINAL_PROMPT: '0',
52
+ GIT_PAGER: 'cat',
53
+ PAGER: 'cat',
54
+ },
55
+ }, (err, stdout, stderr) => {
56
+ const out = (stdout || '').toString();
57
+ const errStr = (stderr || '').toString();
58
+ if (!err) {
59
+ const combined = (out + errStr).trim();
60
+ resolve(combined || '(command produced no output)');
61
+ return;
62
+ }
63
+ const parts = [];
64
+ if (err.killed)
65
+ parts.push(`⏱ Timeout ${timeout}ms`);
66
+ else if (err.code !== undefined && err.code !== null)
67
+ parts.push(`Exit code: ${err.code}`);
68
+ else
69
+ parts.push(`Error: ${err.message}`);
70
+ if (out.trim())
71
+ parts.push(out.trim());
72
+ if (errStr.trim())
73
+ parts.push(errStr.trim());
74
+ resolve(parts.join(String.fromCharCode(10)));
75
+ });
76
+ });
48
77
  }
49
78
  export async function getGitContext(workdir) {
50
79
  const probe = await runGit('git rev-parse --is-inside-work-tree', workdir, 5000);
@@ -93,7 +122,12 @@ export function formatGitContext(ctx) {
93
122
  if (ctx.changedFiles > 0) {
94
123
  s += `\n- Uncommitted changes: ${ctx.changedFiles} file(s)
95
124
  `;
96
- s += '```\n' + ctx.statusPreview + '\n```';
125
+ // Choose a fence longer than any backtick run in the preview: a filename
126
+ // containing ``` would otherwise close the block early and corrupt the
127
+ // system-prompt markdown.
128
+ const longest = (ctx.statusPreview.match(/`+/g) || []).reduce((a, b) => Math.max(a, b.length), 0);
129
+ const fence = '`'.repeat(Math.max(3, longest + 1));
130
+ s += fence + '\n' + ctx.statusPreview + '\n' + fence;
97
131
  }
98
132
  else {
99
133
  s += '\n- Working tree: clean';
@@ -130,12 +164,17 @@ export function createGitTools(workdir) {
130
164
  const err = await ensureRepo();
131
165
  if (err)
132
166
  return err;
133
- const parts = ['git diff'];
167
+ const args = ['diff'];
134
168
  if (staged)
135
- parts.push('--staged');
136
- if (p)
137
- parts.push('--', JSON.stringify(p));
138
- return runGit(parts.join(' '), workdir, 20_000);
169
+ args.push('--staged');
170
+ if (p) {
171
+ const rel = String(p);
172
+ if (rel.startsWith('-')) {
173
+ return `Error: invalid path: ${rel}`;
174
+ }
175
+ args.push('--', rel);
176
+ }
177
+ return runGitArgs(args, workdir, 20_000);
139
178
  },
140
179
  },
141
180
  {
@@ -159,9 +198,17 @@ export function createGitTools(workdir) {
159
198
  if (err)
160
199
  return err;
161
200
  const r = ref && String(ref).trim() ? String(ref).trim() : 'HEAD';
162
- // `stat` gives the cheap file list; the default dumps the full diff.
163
- const flag = stat ? '--stat ' : '';
164
- return runGit(`git show ${flag}${JSON.stringify(r)}`, workdir, 20_000);
201
+ // Validate the ref so a value like `--output=/tmp/x` or `HEAD$(...)`
202
+ // cannot reach git as an option/substitution. `git show` treats `--`
203
+ // as a paths separator, not a ref separator, so it is NOT used here.
204
+ if (r.startsWith('-') || !/^[A-Za-z0-9._/~^-]+$/.test(r)) {
205
+ return `Error: invalid ref: ${r}`;
206
+ }
207
+ const args = ['show'];
208
+ if (stat)
209
+ args.push('--stat');
210
+ args.push(r);
211
+ return runGitArgs(args, workdir, 20_000);
165
212
  },
166
213
  },
167
214
  {
@@ -187,9 +234,17 @@ export function createGitTools(workdir) {
187
234
  const err = await ensureRepo();
188
235
  if (err)
189
236
  return err;
190
- return runGit(paths && String(paths).trim()
191
- ? 'git add -- ' + quoteArgs(paths)
192
- : 'git add -A', workdir, 20_000);
237
+ if (paths && String(paths).trim()) {
238
+ const list = (Array.isArray(paths) ? paths : [paths])
239
+ .map((v) => String(v).trim())
240
+ .filter(Boolean);
241
+ for (const p of list) {
242
+ if (p.startsWith('-'))
243
+ return `Error: invalid path: ${p}`;
244
+ }
245
+ return runGitArgs(['add', '--', ...list], workdir, 20_000);
246
+ }
247
+ return runGit('git add -A', workdir, 20_000);
193
248
  },
194
249
  },
195
250
  {
@@ -218,7 +273,7 @@ export function createGitTools(workdir) {
218
273
  const tmp = path.join(os.tmpdir(), `dsa-commit-${Date.now()}.txt`);
219
274
  await fs.writeFile(tmp, msg, 'utf-8');
220
275
  try {
221
- const result = await runGit(`git commit -F ${JSON.stringify(tmp)}`, workdir, 30_000);
276
+ const result = await runGitArgs(['commit', '-F', tmp], workdir, 30_000);
222
277
  return `add: ${addResult}\ncommit: ${result}`;
223
278
  }
224
279
  finally {
@@ -246,9 +301,11 @@ export function createGitTools(workdir) {
246
301
  if (!/^[A-Za-z0-9._/-]+$/.test(b) || b.startsWith('-')) {
247
302
  return `Error: invalid branch name: ${b}`;
248
303
  }
249
- const flag = setUpstream ? '-u ' : '';
250
- const target = quoteArgs(b);
251
- return runGit(`git push ${flag}origin ${target}`, workdir, 120_000);
304
+ const args = ['push'];
305
+ if (setUpstream)
306
+ args.push('-u');
307
+ args.push('origin', b);
308
+ return runGitArgs(args, workdir, 120_000);
252
309
  },
253
310
  },
254
311
  ];
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { existsSync, statSync } from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { theme } from './theme.js';
8
8
  import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
9
- import { createTools } from './tools.js';
9
+ import { createTools, mergeTools } from './tools.js';
10
10
  import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from './extraTools.js';
11
11
  import { runAgentLoop } from './agent-loop.js';
12
12
  import { createSpinner } from './spinner.js';
@@ -994,17 +994,16 @@ async function main() {
994
994
  if (debug)
995
995
  console.error(e.stack);
996
996
  await browser.close().catch(() => { });
997
- transcript.close();
997
+ await transcript.close();
998
998
  process.exit(1);
999
999
  }
1000
1000
  // One-shot mode
1001
1001
  if (task) {
1002
- const tools = mod.createTools(currentWorkdir, {
1002
+ const baseTools = mod.createTools(currentWorkdir, {
1003
1003
  undo,
1004
1004
  readOnly: planMode,
1005
1005
  });
1006
- if (mcpPool)
1007
- tools.push(...mcpPool.tools);
1006
+ const tools = mergeTools(baseTools, mcpPool?.tools, planMode);
1008
1007
  let freshChat = true;
1009
1008
  let sendSystemPrompt = true;
1010
1009
  // By default we start a new chat. To continue a previous session —
@@ -1031,7 +1030,7 @@ async function main() {
1031
1030
  }
1032
1031
  }
1033
1032
  await autoReload();
1034
- await mod.runTask(runTaskDeps(), browser, tools, task, currentWorkdir, {
1033
+ const result = await mod.runTask(runTaskDeps(), browser, tools, task, currentWorkdir, {
1035
1034
  transcript,
1036
1035
  freshChat,
1037
1036
  sendSystemPrompt,
@@ -1041,11 +1040,15 @@ async function main() {
1041
1040
  saveLastChat(chatId, currentWorkdir);
1042
1041
  },
1043
1042
  });
1043
+ // One-shot mode (--task) is documented for scripts/CI. A failed agent run
1044
+ // used to exit 0, so the CI job stayed green; surface it as exit code 1.
1045
+ if (!result.ok)
1046
+ process.exitCode = 1;
1044
1047
  await browser.close();
1045
1048
  await mod.closeWeb().catch(() => { });
1046
1049
  if (mcpPool)
1047
1050
  await mcpPool.close().catch(() => { });
1048
- transcript.close();
1051
+ await transcript.close();
1049
1052
  return;
1050
1053
  }
1051
1054
  console.log(theme.system(t('msg.interactive')));
@@ -1203,7 +1206,7 @@ async function main() {
1203
1206
  await mod.closeWeb().catch(() => { });
1204
1207
  if (mcpPool)
1205
1208
  await mcpPool.close().catch(() => { });
1206
- transcript.close();
1209
+ await transcript.close();
1207
1210
  console.log(theme.dim(t('msg.exit_summary', {
1208
1211
  transcript: transcript.file || t('common.off'),
1209
1212
  chat: currentChatId || t('chats.not_created'),
@@ -2817,13 +2820,12 @@ async function main() {
2817
2820
  console.log(theme.system(t('retry.running')));
2818
2821
  // Re-send the last task into the SAME chat (no new chat, no system
2819
2822
  // prompt), so a truncated/empty answer can be retried cheaply.
2820
- const tools = mod.createTools(currentWorkdir, {
2823
+ const baseTools = mod.createTools(currentWorkdir, {
2821
2824
  undo,
2822
2825
  todos: todoStore,
2823
2826
  readOnly: planMode,
2824
2827
  });
2825
- if (mcpPool)
2826
- tools.push(...mcpPool.tools);
2828
+ const tools = mergeTools(baseTools, mcpPool?.tools, planMode);
2827
2829
  if (editor)
2828
2830
  editor.busy = true;
2829
2831
  try {
@@ -3027,13 +3029,12 @@ async function main() {
3027
3029
  continue;
3028
3030
  }
3029
3031
  console.log(theme.system(t('improve.start', { id: item.id, title: item.title })));
3030
- const improveTools = mod.createTools(currentWorkdir, {
3032
+ const improveBase = mod.createTools(currentWorkdir, {
3031
3033
  undo,
3032
3034
  todos: todoStore,
3033
3035
  readOnly: planMode,
3034
3036
  });
3035
- if (mcpPool)
3036
- improveTools.push(...mcpPool.tools);
3037
+ const improveTools = mergeTools(improveBase, mcpPool?.tools, planMode);
3037
3038
  if (editor)
3038
3039
  editor.busy = true;
3039
3040
  try {
@@ -3367,13 +3368,12 @@ async function main() {
3367
3368
  if (cp)
3368
3369
  transcript.log('checkpoint', { stamp: cp.stamp });
3369
3370
  }
3370
- const tools = mod.createTools(currentWorkdir, {
3371
+ const baseTools = mod.createTools(currentWorkdir, {
3371
3372
  undo,
3372
3373
  todos: todoStore,
3373
3374
  readOnly: planMode,
3374
3375
  });
3375
- if (mcpPool)
3376
- tools.push(...mcpPool.tools);
3376
+ const tools = mergeTools(baseTools, mcpPool?.tools, planMode);
3377
3377
  if (editor)
3378
3378
  editor.busy = true;
3379
3379
  try {
@@ -3450,7 +3450,7 @@ async function main() {
3450
3450
  await mod.closeWeb().catch(() => { });
3451
3451
  if (mcpPool)
3452
3452
  await mcpPool.close().catch(() => { });
3453
- transcript.close();
3453
+ await transcript.close();
3454
3454
  // One dim line so the operator can find the log and the session after
3455
3455
  // the run (previously the path was only available via /transcript).
3456
3456
  console.log(theme.dim(t('msg.exit_summary', {
package/dist/input.js CHANGED
@@ -3,12 +3,16 @@ import { renderMarkdown } from './markdown.js';
3
3
  import { randomThinkingPhrase, stripEllipsis, DOTS, renderDots, } from './spinner.js';
4
4
  import { translate } from './i18n.js';
5
5
  import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachments.js';
6
- import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, isSubsequence, pasteReplacement, expandPastes, safeJson, visRows, visLen, layoutInput, formatTokenStatus, tokenStatusLevel, truncateToWidth, CONTEXT_LIMIT, } from './input/layout.js';
6
+ import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, isSubsequence, pasteReplacement, expandPastes, safeJson, visRows, visLen, charWidth, layoutInput, formatTokenStatus, tokenStatusLevel, truncateToWidth, CONTEXT_LIMIT, } from './input/layout.js';
7
7
  // The pure layout/formatting helpers (visual rows, cursor position, visible
8
8
  // width, paste markers, token formatting) live in ./input/layout.js. They are
9
9
  // re-exported here so every existing import from './input.js' keeps working,
10
10
  // and so tests can import them from either module (C3 step 3).
11
11
  export { PASTE_MIN_LINES, SUGGEST_PAGE, isSubsequence, countPasteLines, formatPasteMarker, pasteReplacement, expandPastes, visRows, visLen, charWidth, layoutInput, tokenStatusLevel, formatTokenStatus, formatCompactTokens, truncateToWidth, CONTEXT_LIMIT, CONTEXT_YELLOW_PCT, CONTEXT_RED_PCT, } from './input/layout.js';
12
+ // How long a lone ESC is held before it is treated as the Escape key (and not
13
+ // as the head of a control sequence split across two reads). 40ms is below a
14
+ // human "two Escapes" interval but above typical chunk-split latency.
15
+ export const ESC_DISAMBIGUATE_MS = 40;
12
16
  // A permanent input line at the bottom of the terminal + a status/output area above it.
13
17
  //
14
18
  // Why a custom editor:
@@ -87,6 +91,11 @@ export class LineEditor {
87
91
  // is stored so it can be removed in dispose().
88
92
  _onResize;
89
93
  _resizeTimer;
94
+ // ESC disambiguation (N29): a read may end on a bare ESC that is really the
95
+ // head of a control sequence split across two reads. We hold it briefly and
96
+ // fire onEscape only if no tail arrives.
97
+ _escPending;
98
+ _escTimer;
90
99
  // History of sent messages (for the up/down arrows).
91
100
  history;
92
101
  _histIndex;
@@ -195,6 +204,8 @@ export class LineEditor {
195
204
  this._wasRaw = false;
196
205
  this._onData = (b) => this._handle(b);
197
206
  this._resizeTimer = null;
207
+ this._escPending = false;
208
+ this._escTimer = null;
198
209
  this._onResize = () => {
199
210
  // Debounce: a window drag fires a burst of SIGWINCH events. We wait a
200
211
  // short moment, then redraw the block at the NEW width.
@@ -462,6 +473,19 @@ export class LineEditor {
462
473
  }
463
474
  this._renderInputOnly();
464
475
  }
476
+ // PageUp/PageDown: page the suggestion window by a full SUGGEST_PAGE. When
477
+ // no list is open it is a no-op (PageUp/Down used to be swallowed entirely).
478
+ _suggestPage(delta) {
479
+ const sugg = this._suggestions();
480
+ if (!sugg.length)
481
+ return;
482
+ const max = sugg.length - 1;
483
+ const base = this._suggestSelected < 0 ? 0 : this._suggestSelected;
484
+ const next = base + delta * SUGGEST_PAGE;
485
+ this._suggestSelected = Math.max(0, Math.min(max, next));
486
+ this._suggestOffset = Math.max(0, Math.min(this._suggestSelected, sugg.length - SUGGEST_PAGE));
487
+ this._renderInputOnly();
488
+ }
465
489
  // Tab: complete the command up to the common prefix; if there is a single
466
490
  // match — insert it whole and add a space.
467
491
  _completeCommand() {
@@ -526,6 +550,8 @@ export class LineEditor {
526
550
  process.stdout.removeListener('resize', this._onResize);
527
551
  if (this._resizeTimer)
528
552
  clearTimeout(this._resizeTimer);
553
+ if (this._escTimer)
554
+ clearTimeout(this._escTimer);
529
555
  process.stdout.write(ESC + '[?2004l');
530
556
  this._stopDots();
531
557
  if (this.rendered)
@@ -1314,36 +1340,54 @@ export class LineEditor {
1314
1340
  const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
1315
1341
  return lay.cursorRow === lay.rows.length - 1;
1316
1342
  }
1343
+ // Bare ESC: wait a moment for a possible control-sequence tail before firing
1344
+ // onEscape. Without this a sequence split across two reads (ESC, then "[A")
1345
+ // looked like a lone Escape and aborted the current generation (N29).
1346
+ _scheduleEscape() {
1347
+ this._escPending = true;
1348
+ if (this._escTimer)
1349
+ clearTimeout(this._escTimer);
1350
+ this._escTimer = setTimeout(() => {
1351
+ this._escTimer = null;
1352
+ if (!this._escPending)
1353
+ return;
1354
+ this._escPending = false;
1355
+ if (this.onEscape)
1356
+ this.onEscape();
1357
+ }, ESC_DISAMBIGUATE_MS);
1358
+ if (this._escTimer.unref)
1359
+ this._escTimer.unref();
1360
+ }
1361
+ // Move the cursor `delta` visual rows (not logical lines) and land in the
1362
+ // column closest to the current one. A long line wraps over several visual
1363
+ // rows; the old _up/_down moved by LOGICAL lines only, so on a wrapped row
1364
+ // Up did nothing and Down skipped a whole logical line. The column is
1365
+ // measured in COLUMNS (layoutInput's cursorCol), so wide chars/emoji land on
1366
+ // the right cell. Returns false when the target row is off the buffer.
1367
+ _visualMoveRows(delta) {
1368
+ const cols = process.stdout.columns || 80;
1369
+ const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
1370
+ const target = lay.cursorRow + delta;
1371
+ if (target < 0 || target >= lay.rows.length)
1372
+ return false;
1373
+ const row = lay.rows[target];
1374
+ const rowChars = Array.from(row.text);
1375
+ const promptW = visLen(row.prefix);
1376
+ const want = Math.max(0, lay.cursorCol - promptW);
1377
+ let idx = row.start;
1378
+ let w = 0;
1379
+ while (idx < row.start + rowChars.length && w < want) {
1380
+ w += charWidth(rowChars[idx - row.start].codePointAt(0));
1381
+ idx++;
1382
+ }
1383
+ this.cursor = idx;
1384
+ return true;
1385
+ }
1317
1386
  _up() {
1318
- const chars = Array.from(this.buf);
1319
- let start = this.cursor;
1320
- while (start > 0 && chars[start - 1] !== NL)
1321
- start--;
1322
- if (start === 0)
1323
- return;
1324
- const col = this.cursor - start;
1325
- const prevEnd = start - 1;
1326
- let prevStart = prevEnd;
1327
- while (prevStart > 0 && chars[prevStart - 1] !== NL)
1328
- prevStart--;
1329
- this.cursor = prevStart + Math.min(col, prevEnd - prevStart);
1387
+ this._visualMoveRows(-1);
1330
1388
  }
1331
1389
  _down() {
1332
- const chars = Array.from(this.buf);
1333
- let end = this.cursor;
1334
- while (end < chars.length && chars[end] !== NL)
1335
- end++;
1336
- if (end >= chars.length)
1337
- return;
1338
- let lineStart = this.cursor;
1339
- while (lineStart > 0 && chars[lineStart - 1] !== NL)
1340
- lineStart--;
1341
- const col = this.cursor - lineStart;
1342
- const nextStart = end + 1;
1343
- let nextEnd = nextStart;
1344
- while (nextEnd < chars.length && chars[nextEnd] !== NL)
1345
- nextEnd++;
1346
- this.cursor = nextStart + Math.min(col, nextEnd - nextStart);
1390
+ this._visualMoveRows(1);
1347
1391
  }
1348
1392
  // Move one word back (Ctrl+Left / Alt+B): skip spaces
1349
1393
  // on the left, then go to the start of the word.
@@ -1524,9 +1568,22 @@ export class LineEditor {
1524
1568
  }
1525
1569
  return;
1526
1570
  }
1571
+ // A previous read ended on a bare ESC. That is ambiguous: the Escape key
1572
+ // or the head of a control sequence split across reads (arrows, Ctrl+Del).
1573
+ // Reattach the ESC and parse the whole sequence, so a split arrow does not
1574
+ // abort the generation (N29).
1575
+ if (this._escPending) {
1576
+ this._escPending = false;
1577
+ if (this._escTimer) {
1578
+ clearTimeout(this._escTimer);
1579
+ this._escTimer = null;
1580
+ }
1581
+ s = ESC + s;
1582
+ }
1527
1583
  if (!this._inPaste && s === ESC && !this._searchMode) {
1528
- if (this.onEscape)
1529
- this.onEscape();
1584
+ // Bare ESC in its own chunk: wait a moment for the possible tail of a
1585
+ // control sequence before treating it as the Escape key.
1586
+ this._scheduleEscape();
1530
1587
  return;
1531
1588
  }
1532
1589
  // While the reverse search (Ctrl+R) is active, the editor is in a modal
@@ -1728,6 +1785,12 @@ export class LineEditor {
1728
1785
  this._renderInputOnly();
1729
1786
  continue;
1730
1787
  }
1788
+ if (code === 12) {
1789
+ // Ctrl+L — clear the screen and repaint the input block (the standard
1790
+ // shell binding; it was silently swallowed before).
1791
+ this.clearScreen();
1792
+ continue;
1793
+ }
1731
1794
  if (code === 127 || code === 8) {
1732
1795
  this._pushUndo(true);
1733
1796
  this._backspace();
@@ -1780,6 +1843,24 @@ export class LineEditor {
1780
1843
  this._renderInputOnly();
1781
1844
  continue;
1782
1845
  }
1846
+ // Same CSI-u protocol for Delete: Ctrl+Delete is CSI 3;5u (Alt 3;3u),
1847
+ // plain Delete is CSI 3u — without the tilde. Missing these made the
1848
+ // key silently dead on kitty/foot/WezTerm, while the tilde forms above
1849
+ // worked (N27).
1850
+ if (s.startsWith('[3;5u') || s.startsWith('[3;3u')) {
1851
+ this._pushUndo();
1852
+ this._deleteWordRight();
1853
+ s = s.slice(5);
1854
+ this._renderInputOnly();
1855
+ continue;
1856
+ }
1857
+ if (s.startsWith('[3u')) {
1858
+ this._pushUndo(true);
1859
+ this._delete();
1860
+ s = s.slice(3);
1861
+ this._renderInputOnly();
1862
+ continue;
1863
+ }
1783
1864
  if (s.startsWith('[D')) {
1784
1865
  this._left();
1785
1866
  s = s.slice(2);
@@ -1823,6 +1904,19 @@ export class LineEditor {
1823
1904
  this._renderInputOnly();
1824
1905
  continue;
1825
1906
  }
1907
+ // PageUp / PageDown (ESC[5~ / ESC[6~) — page the slash-command
1908
+ // suggestion list, which is otherwise only reachable with Ctrl+N/P.
1909
+ // Without this the sequence fell through to the generic skip.
1910
+ if (s.startsWith('[5~')) {
1911
+ this._suggestPage(-1);
1912
+ s = s.slice(3);
1913
+ continue;
1914
+ }
1915
+ if (s.startsWith('[6~')) {
1916
+ this._suggestPage(1);
1917
+ s = s.slice(3);
1918
+ continue;
1919
+ }
1826
1920
  if (s.startsWith('[3~')) {
1827
1921
  this._pushUndo(true);
1828
1922
  this._delete();
@@ -1846,6 +1940,16 @@ export class LineEditor {
1846
1940
  let j = 0;
1847
1941
  while (j < s.length && !/[A-Za-z~]/.test(s[j]))
1848
1942
  j++;
1943
+ const skipped = s.slice(0, j + 1);
1944
+ // Surface a swallowed sequence in debug mode: an unrecognized control
1945
+ // sequence used to be skipped SILENTLY, which is how a dead key looked
1946
+ // like a frozen editor with no way to tell why.
1947
+ if (process.env.ZAMES_DEBUG_KEYS && skipped.length > 1) {
1948
+ process.stderr.write(String.fromCharCode(10) +
1949
+ '[keys] unhandled: ' +
1950
+ JSON.stringify(skipped) +
1951
+ String.fromCharCode(10));
1952
+ }
1849
1953
  s = s.slice(j + 1);
1850
1954
  continue;
1851
1955
  }
package/dist/mcp.js CHANGED
@@ -34,13 +34,6 @@ export function floatingVersionWarnings(servers) {
34
34
  }
35
35
  return out;
36
36
  }
37
- /** Names of the servers found in a config, before any connection. */
38
- export async function listConfiguredServers(workdir) {
39
- const files = await discoverConfigFiles(workdir);
40
- const config = await loadConfigFiles(files);
41
- const servers = config.mcpServers || {};
42
- return Object.keys(servers).filter((n) => !servers[n].disabled);
43
- }
44
37
  // @playwright/mcp defaults its --user-data-dir to the SAME profile zames uses
45
38
  // (~/.zames/profile). Two chromium instances on one profile break each other:
46
39
  // the chat page shows "Something went wrong when opening your profile" and
@@ -179,14 +172,32 @@ export function describeParamsExternally(description, schema) {
179
172
  }
180
173
  }
181
174
  /* ---------- result rendering ---------- */
175
+ // Cap on the MCP result string handed to the model. MCP results (e.g.
176
+ // browser_snapshot) can be hundreds of KB; without a cap a single call floods
177
+ // the context and speeds up rate-limiting/truncation. Chosen to match the
178
+ // magnitude of the built-in tools' truncation.
179
+ export const MCP_RESULT_LIMIT = 12_000;
180
+ // Truncate a tool-result string with an explicit marker (the model must be able
181
+ // to tell it is looking at a partial output). Exported for tests.
182
+ export function truncateMcpResult(s, limit = MCP_RESULT_LIMIT) {
183
+ const str = String(s);
184
+ if (str.length <= limit)
185
+ return str;
186
+ const head = str.slice(0, limit);
187
+ // Prefer a line boundary so a cut does not split a line in half.
188
+ const nl = head.lastIndexOf(String.fromCharCode(10));
189
+ const cut = nl > limit * 0.5 ? head.slice(0, nl + 1) : head;
190
+ const omitted = str.length - cut.length;
191
+ return cut + `\n[...truncated ${omitted} chars]`;
192
+ }
182
193
  // MCP tool results are a list of content blocks (text, image, resource, ...)
183
194
  // and an isError flag. The agent needs a single string for the tool result
184
195
  // message. Images are summarized (the model cannot see them through
185
196
  // DeepSeek web anyway), text is joined.
186
- export function renderMcpResult(result) {
197
+ export function renderMcpResult(result, limit = MCP_RESULT_LIMIT) {
187
198
  const r = (result || {});
188
199
  if (r.toolResult !== undefined && !r.content) {
189
- return formatResultString(r.toolResult, r.isError);
200
+ return truncateMcpResult(formatResultString(r.toolResult, r.isError), limit);
190
201
  }
191
202
  const parts = [];
192
203
  for (const block of r.content || []) {
@@ -236,7 +247,7 @@ export function renderMcpResult(result) {
236
247
  .filter((p) => p !== '')
237
248
  .join(String.fromCharCode(10) + String.fromCharCode(10))
238
249
  .trim();
239
- return formatResultString(text || '(MCP tool returned no content)', r.isError);
250
+ return truncateMcpResult(formatResultString(text || '(MCP tool returned no content)', r.isError), limit);
240
251
  }
241
252
  function formatResultString(result, isError) {
242
253
  const text = typeof result === 'string' ? result : safeJson(result);
@@ -258,6 +269,27 @@ function safeJson(v) {
258
269
  export function qualifyToolName(server, tool) {
259
270
  return server + '__' + tool.replace(/[^A-Za-z0-9_]/g, '_');
260
271
  }
272
+ // Make every tool name unique IN PLACE. agent-loop resolves a call by name and
273
+ // takes the first match, so a duplicate silently shadows the later tool. The
274
+ // qualifier is not injective (see the call site), hence this guard. Pure;
275
+ // exported for tests.
276
+ export function dedupeToolNames(tools) {
277
+ const seen = new Set();
278
+ for (const t of tools) {
279
+ if (!seen.has(t.name)) {
280
+ seen.add(t.name);
281
+ continue;
282
+ }
283
+ let n = 2;
284
+ let candidate = `${t.name}_${n}`;
285
+ while (seen.has(candidate)) {
286
+ n++;
287
+ candidate = `${t.name}_${n}`;
288
+ }
289
+ t.name = candidate;
290
+ seen.add(candidate);
291
+ }
292
+ }
261
293
  // Connect to ONE server and return a ready connection (or an error).
262
294
  async function connectServer(name, cfg, debug) {
263
295
  try {
@@ -352,6 +384,13 @@ export async function createMcpPool(opts) {
352
384
  });
353
385
  }
354
386
  }
387
+ // Distinct servers can produce the SAME qualified name (server 'a' + tool
388
+ // 'b__c' vs server 'a__b' + tool 'c'), and agent-loop resolves a call with
389
+ // tools.find(name) — it takes the FIRST. A duplicate would make the second
390
+ // tool silently unreachable (the model calls "the right name", a DIFFERENT
391
+ // tool runs). Rename the later one with a numeric suffix so every name is
392
+ // unique.
393
+ dedupeToolNames(tools);
355
394
  let closed = false;
356
395
  return {
357
396
  tools,