zames_pro 2.82.49 → 2.82.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +159 -26
  3. package/SECURITY.md +50 -0
  4. package/dist/agent-loop.js +35 -5
  5. package/dist/answer-parse.js +1 -1
  6. package/dist/attach-flow.js +7 -3
  7. package/dist/attach-refs.js +30 -6
  8. package/dist/attachments.js +9 -0
  9. package/dist/browser-chats.js +9 -2
  10. package/dist/browser-dom.js +1 -1
  11. package/dist/browser-pure.js +9 -3
  12. package/dist/browser.js +1 -1
  13. package/dist/changelog.js +1 -1
  14. package/dist/checkpoint.js +1 -1
  15. package/dist/cli-args.js +1 -1
  16. package/dist/config-command.js +1 -1
  17. package/dist/config-commands.js +5 -2
  18. package/dist/config-live.js +10 -0
  19. package/dist/config.js +13 -1
  20. package/dist/context-command.js +6 -0
  21. package/dist/context.js +27 -4
  22. package/dist/diff.js +52 -9
  23. package/dist/extraTools.js +7 -0
  24. package/dist/glm-chats.js +18 -1
  25. package/dist/glm-net-capture.js +23 -5
  26. package/dist/goal.js +6 -1
  27. package/dist/i18n/deepseek.js +3 -3
  28. package/dist/i18n/self-review.js +4 -0
  29. package/dist/i18n.js +8 -2
  30. package/dist/index.js +90 -101
  31. package/dist/input/layout.js +33 -8
  32. package/dist/input/suggest.js +1 -1
  33. package/dist/input.js +1 -1
  34. package/dist/limits-command.js +42 -0
  35. package/dist/mcp.js +23 -4
  36. package/dist/model-command.js +21 -0
  37. package/dist/path-token.js +36 -9
  38. package/dist/permissions.js +32 -3
  39. package/dist/provider-command.js +29 -0
  40. package/dist/provider-handoff.js +18 -1
  41. package/dist/queue-command.js +15 -0
  42. package/dist/qwen-chats.js +13 -6
  43. package/dist/qwen-net-capture.js +13 -6
  44. package/dist/resume-command.js +38 -0
  45. package/dist/send-telemetry.js +15 -3
  46. package/dist/send-throttle.js +3 -1
  47. package/dist/status-command.js +68 -0
  48. package/dist/subagent.js +5 -12
  49. package/dist/tool-summary.js +8 -1
  50. package/dist/tools.js +13 -1
  51. package/dist/tui/StatusBar.js +1 -1
  52. package/dist/tui/log-window.js +41 -4
  53. package/dist/undo.js +11 -1
  54. package/dist/web.js +16 -6
  55. package/llms-full.txt +679 -0
  56. package/llms.txt +102 -0
  57. package/package.json +84 -23
package/dist/index.js CHANGED
@@ -47,6 +47,13 @@ import { askOperatorConfirm, } from './run-task.js';
47
47
  import { closeWeb } from './web.js';
48
48
  import { assertCommandInsideRoot, runShell, killAllBackground, } from './shell.js';
49
49
  import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, isInternalChat, } from './sessions.js';
50
+ import { parseModelIntent, emptyModelListMessage } from './model-command.js';
51
+ import { parseResumeId, filterSessions, resumeProviderRefusal, } from './resume-command.js';
52
+ import { formatLimits, limitsEmptyMessage } from './limits-command.js';
53
+ import { renderContextReport } from './context-command.js';
54
+ import { renderQueueView } from './queue-command.js';
55
+ import { parseProviderIntent, providerUsageMessage, } from './provider-command.js';
56
+ import { formatStatus } from './status-command.js';
50
57
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
51
58
  const NL = String.fromCharCode(10);
52
59
  // The last final assistant message, for the /copy command. Module-level
@@ -70,7 +77,7 @@ function authMarkerExists(provider = config.provider) {
70
77
  }
71
78
  }
72
79
  // ---------- CLI parsing ----------
73
- // The pure helpers live in src/cli-args.ts (C3) and are bound to this process's
80
+ // The pure helpers live in src/cli-args.ts and are bound to this process's
74
81
  // argv ONCE here. Call sites keep using getArg/hasFlag/getPositional.
75
82
  const { getArg, hasFlag, getPositional } = createCliArgs(process.argv.slice(2));
76
83
  // /compact asks the OLD chat for a summary. If browser.ask() exhausts its own
@@ -1267,7 +1274,7 @@ async function main() {
1267
1274
  // Write the checklist whenever the agent changes it, so a restart/resume
1268
1275
  // brings the tasks back (they used to be in-memory only).
1269
1276
  todoStore.onChange = () => persistTodos();
1270
- // Subagents (BACKLOG N36): the `Task` tool delegates a self-contained
1277
+ // Subagents: the `Task` tool delegates a self-contained
1271
1278
  // sub-task to a nested agent loop in its OWN chat (context isolation — in
1272
1279
  // zames "context" IS the chat), then restores this chat and returns only
1273
1280
  // the report. Sequential by design (one browser, one send slot). On by
@@ -1735,19 +1742,18 @@ async function main() {
1735
1742
  if (ed.busy) {
1736
1743
  const q = mod.commands.parseQueueCommand(text);
1737
1744
  if (q) {
1738
- if (q.sub === 'clear') {
1739
- const n = pendingQueue.length;
1745
+ const view = renderQueueView(q.sub, pendingQueue.length, pendingQueue);
1746
+ if (view.kind === 'cleared') {
1740
1747
  pendingQueue.length = 0;
1741
- ed.printAbove(theme.system(t('msg.queue_cleared') + ' (' + n + ')'));
1748
+ ed.printAbove(theme.system(t('msg.queue_cleared') + ' (' + view.n + ')'));
1742
1749
  }
1743
- else if (!pendingQueue.length) {
1750
+ else if (view.kind === 'empty') {
1744
1751
  ed.printAbove(theme.dim(t('msg.queue_empty')));
1745
1752
  }
1746
- else {
1753
+ else if (view.kind === 'list') {
1747
1754
  ed.printAbove(theme.system(t('msg.queue_title')));
1748
- for (const line of mod.commands.formatQueueList(pendingQueue)) {
1755
+ for (const line of view.lines)
1749
1756
  ed.printAbove(theme.assistant(line));
1750
- }
1751
1757
  ed.printAbove(theme.dim(t('msg.queue_cleared_hint')));
1752
1758
  }
1753
1759
  return;
@@ -2616,8 +2622,8 @@ async function main() {
2616
2622
  continue;
2617
2623
  }
2618
2624
  if (lower === '/provider' || lower.startsWith('/provider ')) {
2619
- const arg = trimmed.slice('/provider'.length).trim().toLowerCase();
2620
- if (!arg || arg === 'status') {
2625
+ const intent = parseProviderIntent(trimmed.slice('/provider'.length), config.provider);
2626
+ if (intent.kind === 'status') {
2621
2627
  const model = providerModelOf(config.provider) || '(default)';
2622
2628
  console.log(theme.system(t('provider.status', {
2623
2629
  v: config.provider,
@@ -2626,7 +2632,7 @@ async function main() {
2626
2632
  })));
2627
2633
  continue;
2628
2634
  }
2629
- if (arg === 'login') {
2635
+ if (intent.kind === 'login') {
2630
2636
  try {
2631
2637
  await browser.waitForLogin();
2632
2638
  console.log(theme.system(t('provider.login_done')));
@@ -2636,9 +2642,10 @@ async function main() {
2636
2642
  }
2637
2643
  continue;
2638
2644
  }
2639
- if (isProviderId(arg)) {
2645
+ if (intent.kind === 'switch') {
2646
+ const arg = intent.provider;
2640
2647
  const from = config.provider;
2641
- if (arg === from) {
2648
+ if (intent.same) {
2642
2649
  console.log(theme.system(t('provider.switched', { v: arg })));
2643
2650
  continue;
2644
2651
  }
@@ -2780,7 +2787,7 @@ async function main() {
2780
2787
  }
2781
2788
  continue;
2782
2789
  }
2783
- console.error(theme.warn(t('provider.usage', { providers: PROVIDER_IDS.join('|') })));
2790
+ console.error(theme.warn(providerUsageMessage(t)));
2784
2791
  continue;
2785
2792
  }
2786
2793
  if (lower === '/model' || lower.startsWith('/model ')) {
@@ -2788,10 +2795,9 @@ async function main() {
2788
2795
  // `/model` is available to ANY provider that exposes a model picker
2789
2796
  // (Qwen, GLM). DeepSeek has none, so the command is hidden from the
2790
2797
  // hints there and only explains itself when typed by hand.
2791
- if (!providerHasModelPicker(config.provider)) {
2792
- console.log(theme.system(t('model.unsupported', {
2793
- provider: providerLabel(config.provider),
2794
- })));
2798
+ const intent = parseModelIntent(config.provider, arg, t);
2799
+ if (intent.kind === 'unsupported') {
2800
+ console.log(theme.system(intent.message));
2795
2801
  continue;
2796
2802
  }
2797
2803
  const section = providerConfigSection(config.provider);
@@ -2800,10 +2806,10 @@ async function main() {
2800
2806
  continue;
2801
2807
  const curModel = String(sectionObj.model || '');
2802
2808
  const qb = browser;
2803
- if (!arg) {
2809
+ if (intent.kind === 'list') {
2804
2810
  const models = qb.listModels ? await qb.listModels() : [];
2805
2811
  if (!models.length) {
2806
- console.log(theme.system(t('model.list', { v: '(none)' })));
2812
+ console.log(theme.system(emptyModelListMessage(t)));
2807
2813
  continue;
2808
2814
  }
2809
2815
  // In the TUI, open a proper SELECT LIST instead of dumping text.
@@ -2862,10 +2868,10 @@ async function main() {
2862
2868
  console.log(theme.system(t('model.list', { v: lines })));
2863
2869
  continue;
2864
2870
  }
2865
- sectionObj.model = arg;
2871
+ sectionObj.model = intent.model;
2866
2872
  if (typeof qb.model === 'string')
2867
- qb.model = arg;
2868
- applyModelContextLimit(arg);
2873
+ qb.model = intent.model;
2874
+ applyModelContextLimit(intent.model);
2869
2875
  if (editor) {
2870
2876
  editor.setContextLimit(effectiveContextLimit());
2871
2877
  editor.refreshStatus();
@@ -2874,7 +2880,10 @@ async function main() {
2874
2880
  writeConfigValue('home', section + '.model', arg);
2875
2881
  }
2876
2882
  catch { }
2877
- console.log(theme.system(t('model.set', { provider: providerLabel(config.provider), v: arg })));
2883
+ console.log(theme.system(t('model.set', {
2884
+ provider: providerLabel(config.provider),
2885
+ v: intent.model,
2886
+ })));
2878
2887
  continue;
2879
2888
  }
2880
2889
  if (lower === '/chat') {
@@ -2898,9 +2907,7 @@ async function main() {
2898
2907
  .toLowerCase();
2899
2908
  // /sessions <query> filters by title or workdir (substring). Sorted by
2900
2909
  // recency already (listSessions).
2901
- const all = listSessions().filter((s) => !filter ||
2902
- (s.title || '').toLowerCase().includes(filter) ||
2903
- (s.workdir || '').toLowerCase().includes(filter));
2910
+ const all = filterSessions(listSessions(), filter);
2904
2911
  console.log(theme.system(t('sessions.dir', { v: sessionsDir() })));
2905
2912
  if (!all.length) {
2906
2913
  console.log(theme.system(t('sessions.none')));
@@ -2961,11 +2968,19 @@ async function main() {
2961
2968
  continue;
2962
2969
  }
2963
2970
  if (lower.startsWith('/resume-id ')) {
2964
- const id = trimmed.slice('/resume-id'.length).trim();
2971
+ const id = parseResumeId(trimmed.slice('/resume-id'.length));
2965
2972
  if (!id) {
2966
2973
  console.error(theme.error(t('sessions.resume_id_usage')));
2967
2974
  continue;
2968
2975
  }
2976
+ // A chat id only exists on the backend that created it, so opening a
2977
+ // session saved by ANOTHER provider fails deep inside the browser with a
2978
+ // confusing error. Refuse up front and name the right /provider command.
2979
+ const refusal = resumeProviderRefusal(readSession(id), config.provider, t);
2980
+ if (refusal) {
2981
+ console.error(theme.error(refusal));
2982
+ continue;
2983
+ }
2969
2984
  if (editor)
2970
2985
  editor.lock(t('msg.input_locked'));
2971
2986
  try {
@@ -3121,24 +3136,23 @@ async function main() {
3121
3136
  }
3122
3137
  if (lower === '/queue' || lower.startsWith('/queue ')) {
3123
3138
  const q = mod.commands.parseQueueCommand(trimmed);
3124
- if (!q) {
3139
+ const view = renderQueueView(q ? q.sub : null, pendingQueue.length, pendingQueue);
3140
+ if (view.kind === 'usage') {
3125
3141
  console.error(theme.error(t('msg.queue_usage')));
3126
3142
  continue;
3127
3143
  }
3128
- if (q.sub === 'clear') {
3129
- const n = pendingQueue.length;
3144
+ if (view.kind === 'cleared') {
3130
3145
  pendingQueue.length = 0;
3131
- console.log(theme.system(t('msg.queue_cleared') + ' (' + n + ')'));
3146
+ console.log(theme.system(t('msg.queue_cleared') + ' (' + view.n + ')'));
3132
3147
  continue;
3133
3148
  }
3134
- if (!pendingQueue.length) {
3149
+ if (view.kind === 'empty') {
3135
3150
  console.log(theme.dim(t('msg.queue_empty')));
3136
3151
  continue;
3137
3152
  }
3138
3153
  console.log(theme.system(t('msg.queue_title')));
3139
- for (const line of mod.commands.formatQueueList(pendingQueue)) {
3154
+ for (const line of view.lines)
3140
3155
  console.log(theme.assistant(line));
3141
- }
3142
3156
  console.log(theme.dim(t('msg.queue_cleared_hint')));
3143
3157
  continue;
3144
3158
  }
@@ -3388,51 +3402,32 @@ async function main() {
3388
3402
  continue;
3389
3403
  }
3390
3404
  if (lower === '/status') {
3391
- const yes = t('common.yes');
3392
- const no = t('common.no');
3393
- console.log(theme.system(t('status.workdir', { v: currentWorkdir })));
3394
- console.log(theme.system(t('status.review_mode', {
3395
- v: reviewMode ? reviewMode.snapName : t('status.review_none'),
3396
- })));
3397
- if (reviewMode) {
3398
- console.log(theme.system(t('status.orig_dir', { v: reviewMode.originalWorkdir })));
3399
- }
3400
- console.log(theme.system(t('status.chat', { v: currentChatId || t('common.none') })));
3401
- console.log(theme.system(t('status.fresh_next', { v: freshChatNext ? yes : no })));
3402
- console.log(theme.system(t('status.prompt_next', { v: sendSystemPromptNext ? yes : no })));
3403
- console.log(theme.system(t('status.last_chat', {
3404
- v: loadLastChat(currentWorkdir) || t('common.none'),
3405
- })));
3406
- console.log(theme.system(t('status.sessions', { v: sessionsDir() })));
3407
- console.log(theme.system(t('status.dev', {
3408
- v: devMode ? t('common.on') : t('common.off'),
3409
- })));
3410
- console.log(theme.system(t('status.max_iter', { v: currentMaxIter() })));
3411
- console.log(theme.system(t('status.headless', { v: headless ? yes : no })));
3412
- console.log(theme.system(t('status.debug', { v: debug ? yes : no })));
3413
3405
  const statusToggles = browser.getToggleStatesSync();
3414
- console.log(theme.system(t('status.toggles', {
3415
- think: statusToggles.deepThinking ? yes : no,
3416
- search: statusToggles.webSearch ? yes : no,
3417
- })));
3418
- console.log(theme.system(t('status.undo', {
3419
- v: config.undo.enabled ? t('common.on') : t('common.off'),
3420
- })));
3421
- console.log(theme.system(t('status.checkpoint', {
3422
- v: config.checkpoint.enabled ? t('common.on') : t('common.off'),
3423
- })));
3424
- console.log(theme.system(t('status.transcript', {
3425
- v: transcript.file || t('common.off'),
3426
- })));
3427
- console.log(theme.system(t('status.goal', { v: sessionGoal || t('common.none') })));
3428
- console.log(theme.system(t('status.locale', { v: localeDisplayName(currentLocale) })));
3429
- console.log(theme.system(t('status.mcp', {
3430
- v: mcpPool ? String(mcpPool.status().toolCount) : t('common.none'),
3431
- })));
3432
- const tokens = browser.getLastTokenUsage();
3433
- console.log(theme.system(t('status.tokens', {
3434
- v: tokens === null ? t('common.unknown') : String(tokens),
3435
- })));
3406
+ const statusLines = formatStatus({
3407
+ workdir: currentWorkdir,
3408
+ reviewMode: reviewMode ? reviewMode.snapName : null,
3409
+ origDir: reviewMode ? reviewMode.originalWorkdir : null,
3410
+ chatId: currentChatId,
3411
+ freshNext: freshChatNext,
3412
+ promptNext: sendSystemPromptNext,
3413
+ lastChat: loadLastChat(currentWorkdir),
3414
+ sessionsDir: sessionsDir(),
3415
+ devMode,
3416
+ maxIterations: currentMaxIter(),
3417
+ headless,
3418
+ debug,
3419
+ deepThinking: statusToggles.deepThinking,
3420
+ webSearch: statusToggles.webSearch,
3421
+ undo: config.undo.enabled,
3422
+ checkpoint: config.checkpoint.enabled,
3423
+ transcriptFile: transcript.file,
3424
+ goal: sessionGoal,
3425
+ localeName: localeDisplayName(currentLocale),
3426
+ mcpToolCount: mcpPool ? mcpPool.status().toolCount : null,
3427
+ tokenUsage: browser.getLastTokenUsage(),
3428
+ }, t);
3429
+ for (const line of statusLines)
3430
+ console.log(theme.system(line));
3436
3431
  continue;
3437
3432
  }
3438
3433
  if (lower === '/config' || lower.startsWith('/config ')) {
@@ -3456,7 +3451,12 @@ async function main() {
3456
3451
  locale: currentLocale,
3457
3452
  context: ctx,
3458
3453
  }).length;
3459
- console.log(theme.system(mod.commands.formatContextSources(ctx.agents, ctx.memory, ctx.skills, ctx.commands, { systemPromptChars: sysLen }, t)));
3454
+ console.log(theme.system(renderContextReport({
3455
+ agents: ctx.agents,
3456
+ memory: ctx.memory,
3457
+ skills: ctx.skills,
3458
+ commands: ctx.commands,
3459
+ }, { systemPromptChars: sysLen }, t)));
3460
3460
  }
3461
3461
  catch (e) {
3462
3462
  console.error(theme.error(e.message));
@@ -3563,29 +3563,18 @@ async function main() {
3563
3563
  const logsDir = config.transcript.dir;
3564
3564
  const events = readSendEvents(logsDir, config.provider);
3565
3565
  if (!events.length) {
3566
- console.log(theme.system(t('limits.none', { path: sendLogPath(logsDir, config.provider) })));
3566
+ console.log(theme.system(limitsEmptyMessage(sendLogPath(logsDir, config.provider), t)));
3567
3567
  }
3568
3568
  else {
3569
3569
  const st = summarizeSends(events);
3570
- console.log(theme.system(t('limits.title', { provider: providerLabel(config.provider) })));
3571
- for (const [label, n] of [
3572
- ['10m', st.last10m],
3573
- ['30m', st.last30m],
3574
- ['60m', st.last60m],
3575
- ]) {
3576
- console.log(' ' + t('limits.window', { label, n: String(n) }));
3570
+ for (const line of formatLimits(st, {
3571
+ providerLabel: providerLabel(config.provider),
3572
+ intervalMs: browser.sendIntervalMs(),
3573
+ adaptive: providerAdaptiveThrottle(config.provider),
3574
+ }, t)) {
3575
+ // The title is printed bare; the counters are indented under it.
3576
+ console.log(line.kind === 'title' ? theme.system(line.text) : ' ' + line.text);
3577
3577
  }
3578
- console.log(' ' +
3579
- t('limits.rate', {
3580
- n: String(st.rateLimits),
3581
- sec: String(Math.round(st.avgBlockMs / 1000)),
3582
- }));
3583
- const adaptive = providerAdaptiveThrottle(config.provider);
3584
- console.log(' ' +
3585
- t('limits.interval', {
3586
- sec: String(Math.round(browser.sendIntervalMs() / 1000)),
3587
- adaptive: adaptive ? t('limits.adaptive_on') : '',
3588
- }));
3589
3578
  }
3590
3579
  continue;
3591
3580
  }
@@ -4120,7 +4109,7 @@ async function main() {
4120
4109
  task: finalTaskText,
4121
4110
  workdir: currentWorkdir,
4122
4111
  });
4123
- // BACKLOG B3: snapshot the working tree BEFORE the task so /rewind can
4112
+ // Snapshot the working tree BEFORE the task so /rewind can
4124
4113
  // undo the whole task in one step. Best-effort: create() never throws, so
4125
4114
  // a missing tar cannot abort the task it was meant to protect. Skipped
4126
4115
  // while reviewing a snapshot (workdir is a throwaway copy).
@@ -2,7 +2,7 @@
2
2
  // LineEditor (src/input.ts). No terminal access, no state: given a prompt, a
3
3
  // buffer, a cursor and a width these compute the visual rows, the cursor
4
4
  // position, the visible width (wide chars count 2) and the status formatting.
5
- // Split out (C3 step 3) so the geometry can be unit-tested without driving the
5
+ // Split out so the geometry can be unit-tested without driving the
6
6
  // real editor, and so the editor class keeps only render/key handling.
7
7
  // All control characters are assembled from codes so the file has no
8
8
  // "raw" ESC/CR/LF in string literals (see AGENTS.md).
@@ -37,7 +37,12 @@ export function countPasteLines(raw) {
37
37
  .join(NL)
38
38
  .split(CR)
39
39
  .join(NL);
40
- const trimmed = normalized.replace(/\n+$/, '');
40
+ // Only the SINGLE last newline is a terminator (it does not open a new
41
+ // line); any newline before it ends a real - possibly BLANK - line. The
42
+ // old /\n+$/ stripped ALL trailing newlines, so a paste of "a\n\n\n"
43
+ // counted as ONE line: it was inserted verbatim instead of collapsing into
44
+ // a `[Pasted lines#N]` marker and flooded the input line.
45
+ const trimmed = normalized.endsWith(NL) ? normalized.slice(0, -1) : normalized;
41
46
  if (trimmed === '')
42
47
  return 1;
43
48
  return trimmed.split(NL).length;
@@ -197,15 +202,23 @@ export function wrapToWidth(s, maxCols) {
197
202
  // separator, so `'aaa bbb'` at width 7 became `'aaabbb'` and an indented
198
203
  // warning lost its indent.
199
204
  const words = raw.split(/ +/).filter((w) => w !== '');
200
- const indent = raw.slice(0, raw.length - raw.trimStart().length);
205
+ // An INDENT deeper than the whole width can never be reproduced within
206
+ // the margin: keeping it on the first line glued `width` MORE columns to
207
+ // it (a pasted stack-trace line pushed the terminal into horizontal
208
+ // scroll). It is clipped so every produced line fits.
209
+ let indent = raw.slice(0, raw.length - raw.trimStart().length);
210
+ if (visLen(indent) > width)
211
+ indent = truncateToWidth(indent, width - 1);
212
+ const avail = Math.max(1, width - visLen(indent));
201
213
  let line = indent;
202
214
  let lineW = visLen(indent);
203
215
  let first = true;
204
216
  for (let i = 0; i < words.length; i++) {
205
217
  const word = words[i];
206
218
  const w = visLen(word);
207
- // Hard-split a word longer than a whole line.
208
- if (w > width) {
219
+ // Hard-split a word that does not fit the REMAINING columns on this
220
+ // line (the indent, plus any earlier word, consumes part of it).
221
+ if (w > avail) {
209
222
  if (!first) {
210
223
  out.push(line);
211
224
  line = '';
@@ -213,12 +226,14 @@ export function wrapToWidth(s, maxCols) {
213
226
  }
214
227
  let piece = '';
215
228
  let pieceW = 0;
229
+ let room = width - lineW;
216
230
  for (const ch of word) {
217
231
  const cw = charWidth(ch.codePointAt(0));
218
- if (pieceW + cw > width) {
232
+ if (pieceW + cw > room) {
219
233
  out.push(line + piece);
220
234
  line = '';
221
235
  lineW = 0;
236
+ room = width;
222
237
  piece = '';
223
238
  pieceW = 0;
224
239
  }
@@ -382,11 +397,21 @@ export const CONTEXT_YELLOW_PCT = 50;
382
397
  export const CONTEXT_RED_PCT = 80;
383
398
  // The color role for a context fill level, as a stable string so it can be
384
399
  // unit-tested without a terminal: 'ok' | 'warn' | 'high'. Null tokens -> null.
400
+ // A usable context window: a finite POSITIVE number. The denominator comes
401
+ // from config (`ui.contextLimit` / a provider section), so a 0 / NaN /
402
+ // negative / Infinity value reaches these functions; dividing by it rendered
403
+ // `Infinity%` / `NaN%` (or a fake 0.0% and even 'high') on a real token
404
+ // count. An unusable window falls back to the default one.
405
+ function usableLimit(limit) {
406
+ return typeof limit === 'number' && Number.isFinite(limit) && limit > 0
407
+ ? limit
408
+ : CONTEXT_LIMIT;
409
+ }
385
410
  export function tokenStatusLevel(tokens, limit = CONTEXT_LIMIT) {
386
411
  if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
387
412
  return null;
388
413
  }
389
- const pct = (tokens / limit) * 100;
414
+ const pct = (tokens / usableLimit(limit)) * 100;
390
415
  if (pct >= CONTEXT_RED_PCT)
391
416
  return 'high';
392
417
  if (pct >= CONTEXT_YELLOW_PCT)
@@ -399,7 +424,7 @@ export function formatTokenStatus(tokens, limit = CONTEXT_LIMIT) {
399
424
  }
400
425
  const n = Math.round(tokens);
401
426
  const compact = formatCompactTokens(n);
402
- const pct = Math.max(0, (n / limit) * 100);
427
+ const pct = Math.max(0, (n / usableLimit(limit)) * 100);
403
428
  const pctStr = pct >= 10 ? String(Math.round(pct)) : pct.toFixed(1);
404
429
  // "ctx:" marks this as the USED context size, so the operator does not have
405
430
  // to guess what "302k · 30%" means.
@@ -2,7 +2,7 @@
2
2
  // of the LineEditor's «/» hint list (src/input.ts). No terminal access and no
3
3
  // editor state: given the buffer text and the command list these decide which
4
4
  // commands to show, the visible window, and the Tab completion. Split out
5
- // (C3) so the fuzzy match can be unit-tested without driving the editor.
5
+ // so the fuzzy match can be unit-tested without driving the editor.
6
6
  import { SUGGEST_PAGE, isSubsequence, visLen, } from './layout.js';
7
7
  /**
8
8
  * The commands matching the current buffer. Shown only when the line starts
package/dist/input.js CHANGED
@@ -9,7 +9,7 @@ import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, pasteReplacement, ex
9
9
  // The pure layout/formatting helpers (visual rows, cursor position, visible
10
10
  // width, paste markers, token formatting) live in ./input/layout.js. They are
11
11
  // re-exported here so every existing import from './input.js' keeps working,
12
- // and so tests can import them from either module (C3 step 3).
12
+ // and so tests can import them from either module.
13
13
  export { PASTE_MIN_LINES, SUGGEST_PAGE, isSubsequence, countPasteLines, formatPasteMarker, pasteReplacement, expandPastes, visRows, visLen, charWidth, layoutInput, tokenStatusLevel, formatTokenStatus, formatCompactTokens, splitPasteTail, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, CONTEXT_YELLOW_PCT, CONTEXT_RED_PCT, } from './input/layout.js';
14
14
  // PURE slash-command suggestion logic (match / paging / completion), split out
15
15
  // of the class so the fuzzy match is unit-tested without driving the editor.
@@ -0,0 +1,42 @@
1
+ import { translate } from './i18n.js';
2
+ /**
3
+ * The lines of `/limits` for an aggregate. `intervalMs` is the ACTIVE browser's
4
+ * send interval and `adaptive` whether the pause is derived adaptively.
5
+ */
6
+ export function formatLimits(stats, opts, t = translate('en')) {
7
+ const out = [];
8
+ out.push({
9
+ kind: 'title',
10
+ text: t('limits.title', { provider: opts.providerLabel }),
11
+ });
12
+ const windows = [
13
+ ['10m', stats.last10m],
14
+ ['30m', stats.last30m],
15
+ ['60m', stats.last60m],
16
+ ];
17
+ for (const [label, n] of windows) {
18
+ out.push({
19
+ kind: 'window',
20
+ text: t('limits.window', { label, n: String(n) }),
21
+ });
22
+ }
23
+ out.push({
24
+ kind: 'rate',
25
+ text: t('limits.rate', {
26
+ n: String(stats.rateLimits),
27
+ sec: String(Math.round(stats.avgBlockMs / 1000)),
28
+ }),
29
+ });
30
+ out.push({
31
+ kind: 'interval',
32
+ text: t('limits.interval', {
33
+ sec: String(Math.round(opts.intervalMs / 1000)),
34
+ adaptive: opts.adaptive ? t('limits.adaptive_on') : '',
35
+ }),
36
+ });
37
+ return out;
38
+ }
39
+ /** The text for an EMPTY telemetry file (nothing recorded yet). */
40
+ export function limitsEmptyMessage(path, t = translate('en')) {
41
+ return t('limits.none', { path });
42
+ }
package/dist/mcp.js CHANGED
@@ -44,7 +44,14 @@ export function hardenPlaywrightArgs(command, args) {
44
44
  const hay = (String(command || '') + ' ' + args.join(' ')).toLowerCase();
45
45
  if (!hay.includes('playwright') || !hay.includes('mcp'))
46
46
  return args;
47
- if (args.some((a) =>
47
+ // The profile flag may sit in the COMMAND itself: a config like
48
+ // `{command: "npx --user-data-dir=/tmp/x", args: ["@playwright/mcp"]}` is
49
+ // legitimate, and checking only `args` appended `--isolated` next to the
50
+ // profile — the server then has BOTH an on-disk profile and an in-memory
51
+ // one and picks unpredictably (the live agent's profile is the very thing
52
+ // this guard exists to protect).
53
+ const all = [String(command || ''), ...args];
54
+ if (all.some((a) =>
48
55
  // A PREFIX match treated `--isolated=false` as the isolated flag, so
49
56
  // the hardener added nothing and the MCP browser opened the agent's
50
57
  // live profile anyway. Only a flag EXPLICITLY ON, or a value-taking
@@ -53,10 +60,16 @@ export function hardenPlaywrightArgs(command, args) {
53
60
  /^--isolated=(?!false$)/.test(a) ||
54
61
  a === '--user-data-dir' ||
55
62
  a.startsWith('--user-data-dir=') ||
63
+ a.endsWith('--user-data-dir') ||
64
+ a.includes('--user-data-dir=') ||
56
65
  a === '--extension' ||
57
66
  a.startsWith('--extension=') ||
67
+ a.endsWith('--extension') ||
68
+ a.includes('--extension=') ||
58
69
  a === '--cdp-endpoint' ||
59
- a.startsWith('--cdp-endpoint='))) {
70
+ a.startsWith('--cdp-endpoint=') ||
71
+ a.endsWith('--cdp-endpoint') ||
72
+ a.includes('--cdp-endpoint='))) {
60
73
  return args;
61
74
  }
62
75
  return [...args, '--isolated'];
@@ -167,8 +180,14 @@ export function jsonSchemaToParams(schema) {
167
180
  // descriptions and enums. The flat map alone cannot convey them, and MCP
168
181
  // tools (browser_snapshot, browser_click, ...) rely on them heavily.
169
182
  export function describeParamsExternally(description, schema) {
170
- const type = schema?.type;
171
- if (!schema || type !== 'object')
183
+ // A JSON Schema may spell the object type as a LIST (`type: ['object']`, a
184
+ // zod-derived or nullable wrapper). Rejecting anything but the exact string
185
+ // 'object' silently dropped the schema — field descriptions and enums — from
186
+ // the model-facing tool description while the flat param map still listed
187
+ // the fields.
188
+ const raw = schema?.type;
189
+ const isObject = raw === 'object' || (Array.isArray(raw) && raw.includes('object'));
190
+ if (!schema || !isObject)
172
191
  return description;
173
192
  try {
174
193
  const json = JSON.stringify(schema);
@@ -0,0 +1,21 @@
1
+ import { providerHasModelPicker } from './providers.js';
2
+ import { translate } from './i18n.js';
3
+ /**
4
+ * Decide what /model should do from the command argument and the provider.
5
+ * The argument is the text after "/model" (trimmed here again); an empty
6
+ * argument means "list".
7
+ */
8
+ export function parseModelIntent(provider, arg, t = translate('en')) {
9
+ if (!providerHasModelPicker(provider)) {
10
+ return {
11
+ kind: 'unsupported',
12
+ message: t('model.unsupported', { provider }),
13
+ };
14
+ }
15
+ const model = String(arg ?? '').trim();
16
+ return model ? { kind: 'set', model } : { kind: 'list' };
17
+ }
18
+ /** The text for an EMPTY model list (a provider that answered with nothing). */
19
+ export function emptyModelListMessage(t = translate('en')) {
20
+ return t('model.list', { v: '(none)' });
21
+ }