@bahulam/code 0.1.12 → 0.1.13

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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Pi composition helpers.
3
+ *
4
+ * This is the contract layer for PRD-102 §13.6.1b. It deliberately does
5
+ * not install or execute pi packages yet; it gives manifest/preflight/
6
+ * registry code one normalized shape to test against.
7
+ */
8
+
9
+ export const PI_TOOLS_CACHE = '.bahulam-tools.json';
10
+
11
+ const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
12
+ const NAMESPACE_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
13
+ const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
14
+
15
+ export function parsePiSource(source) {
16
+ const raw = String(source || '').trim();
17
+ if (!raw.startsWith('pi:')) return null;
18
+ const spec = raw.slice(3).trim();
19
+ if (!spec) return null;
20
+
21
+ let packageName = spec;
22
+ let versionRange = '';
23
+ if (spec.startsWith('@')) {
24
+ const slash = spec.indexOf('/');
25
+ const versionAt = slash >= 0 ? spec.indexOf('@', slash + 1) : -1;
26
+ if (versionAt > 0) {
27
+ packageName = spec.slice(0, versionAt);
28
+ versionRange = spec.slice(versionAt + 1);
29
+ }
30
+ } else {
31
+ const versionAt = spec.lastIndexOf('@');
32
+ if (versionAt > 0) {
33
+ packageName = spec.slice(0, versionAt);
34
+ versionRange = spec.slice(versionAt + 1);
35
+ }
36
+ }
37
+
38
+ if (!NPM_NAME_RE.test(packageName)) return null;
39
+ return {
40
+ kind: 'pi',
41
+ source: raw,
42
+ spec,
43
+ package_name: packageName,
44
+ packageName,
45
+ version_range: versionRange || null,
46
+ versionRange: versionRange || null,
47
+ };
48
+ }
49
+
50
+ export function normalizeCompose(composeDef, index = 0) {
51
+ const source = String(composeDef?.source || '').trim();
52
+ const parsed = parsePiSource(source);
53
+ const expose = Array.isArray(composeDef?.expose)
54
+ ? composeDef.expose.map(item => String(item || '').trim()).filter(Boolean)
55
+ : [];
56
+ const namespace = String(composeDef?.as || '').trim();
57
+
58
+ return {
59
+ source,
60
+ as: namespace || '',
61
+ expose,
62
+ verified: composeDef?.verified === true,
63
+ package_name: parsed?.package_name || '',
64
+ packageName: parsed?.packageName || '',
65
+ version_range: parsed?.version_range || null,
66
+ versionRange: parsed?.versionRange || null,
67
+ _index: index,
68
+ _kind: 'pi',
69
+ };
70
+ }
71
+
72
+ export function normalizeComposes(value) {
73
+ if (!Array.isArray(value)) return [];
74
+ return value
75
+ .map((item, index) => normalizeCompose(item, index))
76
+ .filter(item => item.source || item.expose.length || item.as);
77
+ }
78
+
79
+ // Anthropic's tool-name regex (`^[a-zA-Z0-9_-]{1,64}$`) forbids dots, and
80
+ // the backend's client-tool sanitizer enforces the same shape. `__` is the
81
+ // convention Claude Code and MCP both use for namespaced tool names, so
82
+ // stay compatible: `namespace__tool`.
83
+ export const COMPOSED_TOOL_SEPARATOR = '__';
84
+
85
+ export function composedToolName(compose, exposedName) {
86
+ const name = String(exposedName || '').trim();
87
+ return compose?.as ? `${compose.as}${COMPOSED_TOOL_SEPARATOR}${name}` : name;
88
+ }
89
+
90
+ export function validateCompose(compose) {
91
+ const errors = [];
92
+ const warnings = [];
93
+ const label = `Compose #${Number.isInteger(compose?._index) ? compose._index : '?'}`;
94
+
95
+ if (!parsePiSource(compose?.source)) {
96
+ errors.push(`${label}: source must be a pi npm spec, for example pi:@scope/package@^1.0.0`);
97
+ }
98
+ if (compose?.as && !NAMESPACE_RE.test(compose.as)) {
99
+ errors.push(`${label}: as "${compose.as}" must match ${NAMESPACE_RE}`);
100
+ }
101
+ if (!Array.isArray(compose?.expose) || compose.expose.length === 0) {
102
+ errors.push(`${label}: expose must list at least one pi tool`);
103
+ } else {
104
+ const seen = new Set();
105
+ for (const exposed of compose.expose) {
106
+ if (!TOOL_NAME_RE.test(exposed)) {
107
+ errors.push(`${label}: expose "${exposed}" must match ${TOOL_NAME_RE}`);
108
+ }
109
+ if (seen.has(exposed)) {
110
+ errors.push(`${label}: duplicate exposed tool "${exposed}"`);
111
+ }
112
+ seen.add(exposed);
113
+ }
114
+ }
115
+ if (compose?.verified !== true) {
116
+ warnings.push(`${label}: ${compose?.source || 'pi package'} is unverified; hosted Studios require verified pi packages`);
117
+ }
118
+
119
+ return { errors, warnings };
120
+ }
121
+
122
+ export function expandComposedTools(pluginName, pluginDir, composes = []) {
123
+ const tools = [];
124
+ for (const compose of composes || []) {
125
+ for (const exposedName of compose.expose || []) {
126
+ tools.push({
127
+ name: composedToolName(compose, exposedName),
128
+ description: `Composed pi tool ${exposedName} from ${compose.source}`,
129
+ input_schema: { type: 'object', properties: {} },
130
+ tool: '',
131
+ plugin_name: pluginName,
132
+ _plugin_name: pluginName,
133
+ _plugin_dir: pluginDir,
134
+ _composed: {
135
+ kind: 'pi',
136
+ source: compose.source,
137
+ package_name: compose.package_name,
138
+ version_range: compose.version_range,
139
+ namespace: compose.as || null,
140
+ original_name: exposedName,
141
+ verified: compose.verified === true,
142
+ },
143
+ });
144
+ }
145
+ }
146
+ return tools;
147
+ }
@@ -22,6 +22,7 @@ import * as os from 'node:os';
22
22
  import * as path from 'node:path';
23
23
  import { pathToFileURL } from 'node:url';
24
24
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
25
+ import { composedToolName, validateCompose } from './pi-compose.mjs';
25
26
 
26
27
  const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
27
28
  const AGENT_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
@@ -86,6 +87,7 @@ export async function preflightPlugin(pluginDir, opts = {}) {
86
87
  const views = manifest.spec?.workspace?.views || [];
87
88
  const mcpServers = manifest.spec?.mcpServers || {};
88
89
  const mcpServerNames = new Set(Object.keys(mcpServers));
90
+ const composes = manifest.spec?.composes || [];
89
91
 
90
92
  // MCP server sanity — every server should have EITHER command (stdio)
91
93
  // OR url (remote). Anything else is meaningless config.
@@ -101,6 +103,26 @@ export async function preflightPlugin(pluginDir, opts = {}) {
101
103
  }
102
104
  }
103
105
 
106
+ // Pi composition sanity. Composed tools become part of the agent-visible
107
+ // tool namespace, but they are not local files and are not imported here.
108
+ const composedToolNames = new Set();
109
+ for (const compose of composes) {
110
+ const validated = validateCompose(compose);
111
+ errors.push(...validated.errors);
112
+ warnings.push(...validated.warnings);
113
+ if (compose.as && mcpServerNames.has(compose.as)) {
114
+ errors.push(`Compose #${compose._index}: namespace "${compose.as}" collides with an MCP server name`);
115
+ }
116
+ for (const exposedName of compose.expose || []) {
117
+ const fullName = composedToolName(compose, exposedName);
118
+ if (composedToolNames.has(fullName)) errors.push(`Composed tool "${fullName}": duplicate name`);
119
+ if (RESERVED_TOOL_NAMES.has(fullName)) {
120
+ errors.push(`Composed tool "${fullName}": shadows a built-in tool`);
121
+ }
122
+ composedToolNames.add(fullName);
123
+ }
124
+ }
125
+
104
126
  // 2 + 3 + 4. Tool checks
105
127
  const toolNames = new Set();
106
128
  for (const [i, tool] of tools.entries()) {
@@ -114,6 +136,9 @@ export async function preflightPlugin(pluginDir, opts = {}) {
114
136
  if (RESERVED_TOOL_NAMES.has(tool.name)) {
115
137
  errors.push(`Tool "${t}": shadows a built-in tool — pick a different name (built-ins always win)`);
116
138
  }
139
+ if (composedToolNames.has(tool.name)) {
140
+ errors.push(`Tool "${t}": collides with a composed pi tool`);
141
+ }
117
142
  if (!tool.description || tool.description.length < 8) {
118
143
  warnings.push(`Tool "${t}": description is missing or very short (<8 chars) — the model uses this to decide when to call it`);
119
144
  }
@@ -166,12 +191,12 @@ export async function preflightPlugin(pluginDir, opts = {}) {
166
191
  // plugin's mcpServers. The <tool> half is discovered live.
167
192
  if (toolRef.includes('.')) {
168
193
  const serverName = toolRef.split('.', 1)[0];
169
- if (!mcpServerNames.has(serverName)) {
194
+ if (!mcpServerNames.has(serverName) && !composedToolNames.has(toolRef)) {
170
195
  errors.push(`Agent "${slug}": tool "${toolRef}" references MCP server "${serverName}" which is not declared in mcpServers`);
171
196
  }
172
197
  continue;
173
198
  }
174
- if (!toolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
199
+ if (!toolNames.has(toolRef) && !composedToolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
175
200
  errors.push(`Agent "${slug}": tool "${toolRef}" is not defined by this plugin and is not a built-in`);
176
201
  }
177
202
  }
@@ -9,6 +9,7 @@ import fs from 'fs';
9
9
  import path from 'path';
10
10
  import os from 'os';
11
11
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
12
+ import { expandComposedTools } from './pi-compose.mjs';
12
13
 
13
14
  const DEFAULT_PLUGIN_DIRS = () => [
14
15
  path.join(process.cwd(), '.bahulam', 'plugins'),
@@ -159,6 +160,11 @@ export class PluginRegistry {
159
160
  _plugin_dir: plugin._dir,
160
161
  });
161
162
  }
163
+ tools.push(...expandComposedTools(
164
+ plugin.metadata?.name || '',
165
+ plugin._dir,
166
+ plugin.spec?.composes || [],
167
+ ));
162
168
  }
163
169
  return tools;
164
170
  }
@@ -24,7 +24,7 @@ const subcommand = process.argv[2];
24
24
  const subcommandArgs = process.argv.slice(3);
25
25
 
26
26
  const PLUGIN_MANAGEMENT_COMMANDS = new Set([
27
- 'install', 'validate', 'check', 'lint',
27
+ 'validate', 'check', 'lint',
28
28
  'list', 'ls', 'remove', 'rm', 'uninstall',
29
29
  'enable', 'disable', 'info', 'update', 'upgrade',
30
30
  ]);
@@ -63,8 +63,7 @@ function parsePluginArgs(argv) {
63
63
  }
64
64
  if (positional.length && PLUGIN_MANAGEMENT_COMMANDS.has(positional[0].toLowerCase())) {
65
65
  parsed.action = positional.shift().toLowerCase();
66
- if (parsed.action === 'install') parsed.source = positional.shift() || null;
67
- else if (['validate', 'check', 'lint'].includes(parsed.action)) {
66
+ if (['validate', 'check', 'lint'].includes(parsed.action)) {
68
67
  // Accepts either a directory path or an installed plugin name.
69
68
  const arg = positional.shift() || null;
70
69
  if (arg && (arg.includes('/') || fs.existsSync?.(arg))) parsed.source = arg;
@@ -279,7 +278,28 @@ async function main() {
279
278
  return;
280
279
  }
281
280
 
281
+ if (subcommand === 'pull') {
282
+ const { handlePullCommand } = await import('../commands/install.mjs');
283
+ await handlePullCommand(subcommandArgs, { cwd: process.cwd() });
284
+ return;
285
+ }
286
+
287
+ if (subcommand === 'install') {
288
+ const { handleInstallCommand } = await import('../commands/install.mjs');
289
+ await handleInstallCommand(subcommandArgs, { cwd: process.cwd() });
290
+ return;
291
+ }
292
+
282
293
  if (subcommand === 'plugin' || subcommand === 'plugins') {
294
+ // `install`/`pull` moved to top-level. Detect the old form and redirect.
295
+ if (subcommandArgs[0] === 'install' || subcommandArgs[0] === 'pull') {
296
+ const verb = subcommandArgs[0];
297
+ const rest = subcommandArgs.slice(1);
298
+ process.stderr.write(`\x1b[33m!\x1b[0m \`bahulam plugin ${verb}\` moved to top-level. Use:\n`);
299
+ process.stderr.write(` \x1b[36mbahulam install ${rest.join(' ')}\x1b[0m (pack — scaffolds around pi:, installs git/tarball/local)\n`);
300
+ process.stderr.write(` \x1b[36mbahulam pull ${rest.join(' ')}\x1b[0m (ingredient only — pi: sources)\n`);
301
+ process.exit(2);
302
+ }
283
303
  const args = parsePluginArgs(subcommandArgs);
284
304
  if (args.action && args.action !== 'open') {
285
305
  const { handlePluginManagementCommand } = await import('../commands/plugin-manage.mjs');
@@ -332,8 +352,14 @@ async function main() {
332
352
  bahulam workspace list List recent local workspace sessions
333
353
  bahulam local open [path] Alias for workspace open
334
354
 
335
- \x1b[1mPlugins:\x1b[0m
336
- bahulam plugin <name> [path] Open a workspace with a named plugin
355
+ \x1b[1mPacks & ingredients:\x1b[0m
356
+ bahulam pull pi:<name> Pull a pi ingredient (composable, not runnable on its own)
357
+ bahulam install pi:<name> Pull ingredient + scaffold a full Bahulam pack around it
358
+ bahulam install <git-url> Install a hand-authored pack from git
359
+ bahulam install <local-path> Install a hand-authored pack from disk
360
+ bahulam plugin list List installed packs and pi ingredients
361
+ bahulam plugin remove <name> Remove an installed pack
362
+ bahulam plugin <name> [path] Open a workspace with an installed pack
337
363
 
338
364
  \x1b[1mAnalytics:\x1b[0m
339
365
  bahulam sessions List recent local sessions
@@ -0,0 +1,23 @@
1
+ export function isRawMultilinePasteChunk(text) {
2
+ const value = String(text || '');
3
+ if (!value) return false;
4
+ if (!/[\r\n]/.test(value)) return false;
5
+
6
+ const withoutLineBreaks = value.replace(/[\r\n]/g, '');
7
+ if (!withoutLineBreaks.length) return false;
8
+
9
+ // A single printable char followed by Enter can be delivered in one chunk
10
+ // by some terminals; keep that as normal line submission.
11
+ return withoutLineBreaks.length > 1 || value.split(/\r?\n|\r/).length > 2;
12
+ }
13
+
14
+ export function normalizePastedText(text) {
15
+ return String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
16
+ }
17
+
18
+ export function pastedTextLabel(text) {
19
+ const value = normalizePastedText(text);
20
+ const lines = value ? value.split('\n').length : 0;
21
+ if (lines > 1) return `[text copied · ${lines} lines]`;
22
+ return '[text copied]';
23
+ }
@@ -71,6 +71,7 @@ import { PluginRegistry } from '../plugins/registry.mjs';
71
71
  import { SessionManager } from '../core/session-manager.mjs';
72
72
  import { parseArgs } from '../config/cli-args.mjs';
73
73
  import { pickModelOverridesForm } from './repl-model-form.mjs';
74
+ import { isRawMultilinePasteChunk, normalizePastedText, pastedTextLabel } from './paste-input.mjs';
74
75
  import {
75
76
  MODEL_CATEGORY_ORDER,
76
77
  formatCategoryBadge,
@@ -4857,6 +4858,9 @@ export async function startTerminalRepl() {
4857
4858
  let _bracketedPasteStartLine = '';
4858
4859
  let _bracketedPasteStartCursor = 0;
4859
4860
  let _promptHasInsertedPaste = false;
4861
+ let _suppressRawPasteLines = false;
4862
+ let _pastedInputValue = '';
4863
+ let _pastedInputLabel = '';
4860
4864
  const _pasteEndListeners = new Set();
4861
4865
  function onBracketedPasteEnd(cb) { _pasteEndListeners.add(cb); return () => _pasteEndListeners.delete(cb); }
4862
4866
  function isInBracketedPaste() { return _inBracketedPaste; }
@@ -4876,7 +4880,25 @@ export async function startTerminalRepl() {
4876
4880
  while (i < s.length) {
4877
4881
  if (!_inBracketedPaste) {
4878
4882
  const start = s.indexOf(PASTE_BEGIN, i);
4879
- if (start === -1) return;
4883
+ if (start === -1) {
4884
+ if (isRawMultilinePasteChunk(s)) {
4885
+ _suppressRawPasteLines = true;
4886
+ const baseLine = String(rl?.line || '');
4887
+ const baseCursor = typeof rl?.cursor === 'number' ? rl.cursor : baseLine.length;
4888
+ setImmediate(() => {
4889
+ try {
4890
+ insertPromptText(normalizePastedText(s), {
4891
+ baseLine,
4892
+ baseCursor,
4893
+ fromPaste: true,
4894
+ });
4895
+ } finally {
4896
+ _suppressRawPasteLines = false;
4897
+ }
4898
+ });
4899
+ }
4900
+ return;
4901
+ }
4880
4902
  _inBracketedPaste = true;
4881
4903
  _bracketedPasteBuffer = '';
4882
4904
  _suppressBracketedPasteLines = true;
@@ -5138,7 +5160,11 @@ export async function startTerminalRepl() {
5138
5160
  const line = String(baseLine || '');
5139
5161
  const cursor = typeof baseCursor === 'number' ? Math.max(0, Math.min(line.length, baseCursor)) : line.length;
5140
5162
  const next = `${line.slice(0, cursor)}${payload}${line.slice(cursor)}`;
5141
- if (fromPaste) _promptHasInsertedPaste = true;
5163
+ if (fromPaste) {
5164
+ _promptHasInsertedPaste = true;
5165
+ _pastedInputValue = next;
5166
+ _pastedInputLabel = pastedTextLabel(payload);
5167
+ }
5142
5168
  replaceReadlineLine(next, cursor + payload.length);
5143
5169
  renderIdleDockInput();
5144
5170
  }
@@ -5178,15 +5204,28 @@ export async function startTerminalRepl() {
5178
5204
 
5179
5205
  function renderIdleDockInput() {
5180
5206
  if (!isInputDockMounted()) return false;
5207
+ const line = rl.line || '';
5208
+ let displayLine = line;
5209
+ let displayCursor = typeof rl.cursor === 'number' ? rl.cursor : null;
5210
+ let fixedRows = null;
5211
+ if (_pastedInputValue && line === _pastedInputValue) {
5212
+ displayLine = _pastedInputLabel;
5213
+ displayCursor = _pastedInputLabel.length;
5214
+ fixedRows = 1;
5215
+ } else if (_pastedInputValue) {
5216
+ _pastedInputValue = '';
5217
+ _pastedInputLabel = '';
5218
+ }
5181
5219
  // rl.cursor is readline's byte offset within rl.line. Threading it
5182
5220
  // through to focusDockInput makes arrow-key navigation visually move
5183
5221
  // the terminal cursor within the buffer instead of always landing at
5184
5222
  // the end of the string.
5185
- return renderDockInput(userPrompt(), rl.line || '', {
5223
+ return renderDockInput(userPrompt(), displayLine, {
5186
5224
  context: buildContextStrip(),
5187
5225
  meta: buildDockMeta(),
5188
5226
  tips: idleInputTips(),
5189
- cursor: typeof rl.cursor === 'number' ? rl.cursor : null,
5227
+ cursor: displayCursor,
5228
+ fixedRows,
5190
5229
  });
5191
5230
  }
5192
5231
 
@@ -5232,7 +5271,7 @@ export async function startTerminalRepl() {
5232
5271
  readline.emitKeypressEvents(process.stdin, rl);
5233
5272
  process.stdin.on('keypress', (_str, key = {}) => {
5234
5273
  if (!inputActive) return;
5235
- if (_inBracketedPaste || _suppressBracketedPasteLines) return;
5274
+ if (_inBracketedPaste || _suppressBracketedPasteLines || _suppressRawPasteLines) return;
5236
5275
  if (key.name === 'return' || key.name === 'enter') return;
5237
5276
  if (key.name === 'f2') {
5238
5277
  clearSlashHint();
@@ -5243,7 +5282,7 @@ export async function startTerminalRepl() {
5243
5282
  }
5244
5283
  setImmediate(() => {
5245
5284
  if (!inputActive) return;
5246
- if (_inBracketedPaste || _suppressBracketedPasteLines) return;
5285
+ if (_inBracketedPaste || _suppressBracketedPasteLines || _suppressRawPasteLines) return;
5247
5286
  if (slashHintVisible && key.name === 'tab' && acceptSlashHint()) return;
5248
5287
  if (slashHintVisible && key.name === 'down' && moveSlashHintSelection(1)) return;
5249
5288
  if (slashHintVisible && key.name === 'up' && moveSlashHintSelection(-1)) return;
@@ -5305,12 +5344,16 @@ export async function startTerminalRepl() {
5305
5344
  if (pastedLines.length > 1 || trailing) {
5306
5345
  const text = [...pastedLines, trailing].join('\n');
5307
5346
  _promptHasInsertedPaste = true;
5347
+ _pastedInputValue = text;
5348
+ _pastedInputLabel = pastedTextLabel(text);
5308
5349
  replaceReadlineLine(text);
5309
5350
  renderIdleDockInput();
5310
5351
  return;
5311
5352
  }
5312
5353
  const line = pastedLines.join('\n');
5313
5354
  _promptHasInsertedPaste = false;
5355
+ _pastedInputValue = '';
5356
+ _pastedInputLabel = '';
5314
5357
  queueOrRunLine(line);
5315
5358
  }
5316
5359
 
@@ -5320,7 +5363,7 @@ export async function startTerminalRepl() {
5320
5363
  // or the user pressed Enter normally), the debounce falls back to old
5321
5364
  // behavior — a single Enter flushes almost instantly.
5322
5365
  rl.on('line', async (line) => {
5323
- if (_suppressBracketedPasteLines) {
5366
+ if (_suppressBracketedPasteLines || _suppressRawPasteLines) {
5324
5367
  _pasteLines = [];
5325
5368
  if (_pasteFlushTimer) {
5326
5369
  clearTimeout(_pasteFlushTimer);
@@ -5369,6 +5412,8 @@ export async function startTerminalRepl() {
5369
5412
  let input = line.trim();
5370
5413
  const selectedSlashCommand = selectedSlashCommandFor(input);
5371
5414
  inputActive = false;
5415
+ _pastedInputValue = '';
5416
+ _pastedInputLabel = '';
5372
5417
  clearSlashHint();
5373
5418
  if (selectedSlashCommand) input = selectedSlashCommand;
5374
5419
  if (!input) {
@@ -122,6 +122,25 @@ export function createToolRegistry({
122
122
  const name = String(toolDef.name || '').trim();
123
123
  if (!name || tools.has(name)) continue;
124
124
  const pluginName = toolDef._plugin_name || toolDef.plugin_name || null;
125
+ if (toolDef._composed?.kind === 'pi') {
126
+ tools.set(name, {
127
+ name,
128
+ description: toolDef.description || '',
129
+ inputSchema: toolDef.input_schema || toolDef.parameters || { type: 'object', properties: {} },
130
+ validateInput() { return []; },
131
+ async call() {
132
+ return {
133
+ success: false,
134
+ output: `Composed pi tool '${name}' is registered but pi runtime execution is not wired yet`,
135
+ _tool: name,
136
+ _plugin: pluginName,
137
+ _composed: toolDef._composed,
138
+ _blocked: true,
139
+ };
140
+ },
141
+ });
142
+ continue;
143
+ }
125
144
  tools.set(name, {
126
145
  name,
127
146
  description: toolDef.description || '',
@@ -672,10 +672,13 @@ export function clearInputPrompt() {
672
672
  return true;
673
673
  }
674
674
 
675
- export function renderDockInput(prefix, value, { context = '', tips = '', meta = '', cursor = null } = {}) {
675
+ export function renderDockInput(prefix, value, { context = '', tips = '', meta = '', cursor = null, fixedRows = null } = {}) {
676
676
  if (!mounted) return false;
677
677
  contentTrackingActive = false;
678
- setInputRowsTo(computeInputRowsForBuffer(prefix, value));
678
+ const requestedRows = fixedRows == null
679
+ ? computeInputRowsForBuffer(prefix, value)
680
+ : Math.max(MIN_INPUT_ROWS, Math.min(inputRowsMax, Math.floor(Number(fixedRows) || MIN_INPUT_ROWS)));
681
+ setInputRowsTo(requestedRows);
679
682
  renderFrame({ context, tips, meta, prefix, value, cursor, overlayLines: null });
680
683
  const layout = layoutInput(prefix, value);
681
684
  drawInputLines(layout.lines);