@bahulam/code 0.1.11 → 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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/auth/tarang-auth.mjs +313 -0
  3. package/src/commands/agent.mjs +7 -7
  4. package/src/commands/install.mjs +295 -0
  5. package/src/commands/plugin-manage.mjs +280 -88
  6. package/src/config/cli-args.mjs +16 -0
  7. package/src/config/settings-loader.mjs +15 -0
  8. package/src/core/background-tasks.mjs +186 -0
  9. package/src/core/headless.mjs +54 -3
  10. package/src/core/local-agent.mjs +10 -1
  11. package/src/core/risk-tier.mjs +1 -0
  12. package/src/core/stream-client.mjs +95 -15
  13. package/src/core/tool-executor.mjs +266 -15
  14. package/src/local-service/agent-relay.mjs +1 -1
  15. package/src/local-service/server.mjs +116 -14
  16. package/src/orchestration/approval.mjs +30 -0
  17. package/src/orchestration/completion-triggers.mjs +40 -0
  18. package/src/orchestration/dispatch.mjs +118 -0
  19. package/src/orchestration/events.mjs +19 -0
  20. package/src/orchestration/graph.mjs +126 -0
  21. package/src/orchestration/node-runner.mjs +193 -0
  22. package/src/orchestration/runner.mjs +200 -0
  23. package/src/plugins/executor.mjs +2 -2
  24. package/src/plugins/manifest.mjs +30 -27
  25. package/src/plugins/pi-compat/loader-hook.mjs +45 -0
  26. package/src/plugins/pi-compat/probe.mjs +294 -0
  27. package/src/plugins/pi-compat/scaffold.mjs +487 -0
  28. package/src/plugins/pi-compat/shim.mjs +134 -0
  29. package/src/plugins/pi-compose.mjs +147 -0
  30. package/src/plugins/preflight.mjs +35 -10
  31. package/src/plugins/registry.mjs +6 -0
  32. package/src/terminal/agents.mjs +8 -3
  33. package/src/terminal/main.mjs +39 -7
  34. package/src/terminal/paste-input.mjs +23 -0
  35. package/src/terminal/repl-render.mjs +65 -10
  36. package/src/terminal/repl-state.mjs +4 -2
  37. package/src/terminal/repl.mjs +624 -103
  38. package/src/tools/agent.mjs +6 -2
  39. package/src/tools/registry.mjs +107 -4
  40. package/src/ui/input-dock.mjs +5 -2
  41. package/src/ui/slash-commands.mjs +1 -1
  42. package/src/ui/sub-agent.mjs +14 -8
@@ -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,26 +136,29 @@ 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
  }
120
- if (!tool.handler) { errors.push(`Tool "${t}": missing handler path`); continue; }
145
+ if (!tool.tool) { errors.push(`Tool "${t}": missing tool module path (tool: ./tools/<name>.mjs)`); continue; }
121
146
 
122
- const handlerPath = path.resolve(pluginDir, tool.handler);
147
+ const toolModulePath = path.resolve(pluginDir, tool.tool);
123
148
  // Traversal guard
124
- const inside = handlerPath === pluginDir || handlerPath.startsWith(pluginDir + path.sep);
125
- if (!inside) errors.push(`Tool "${t}": handler path escapes the plugin directory`);
126
- else if (!fs.existsSync(handlerPath)) errors.push(`Tool "${t}": handler file not found: ${tool.handler}`);
149
+ const inside = toolModulePath === pluginDir || toolModulePath.startsWith(pluginDir + path.sep);
150
+ if (!inside) errors.push(`Tool "${t}": tool module path escapes the plugin directory`);
151
+ else if (!fs.existsSync(toolModulePath)) errors.push(`Tool "${t}": tool module not found: ${tool.tool}`);
127
152
  else {
128
153
  try {
129
154
  // Cache-bust because a previous install may have imported an older
130
155
  // copy at the same path in this process.
131
- const mod = await import(`${pathToFileURL(handlerPath).href}?preflight=${Date.now()}`);
156
+ const mod = await import(`${pathToFileURL(toolModulePath).href}?preflight=${Date.now()}`);
132
157
  if (typeof mod.call !== 'function') {
133
- errors.push(`Tool "${t}": handler ${tool.handler} does not export an async \`call\` function`);
158
+ errors.push(`Tool "${t}": tool module ${tool.tool} does not export an async \`call\` function`);
134
159
  }
135
160
  } catch (err) {
136
- errors.push(`Tool "${t}": handler ${tool.handler} failed to import: ${err.message}`);
161
+ errors.push(`Tool "${t}": tool module ${tool.tool} failed to import: ${err.message}`);
137
162
  }
138
163
  }
139
164
 
@@ -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
  }
@@ -113,7 +113,7 @@ const TOOL_ALIASES = new Map([
113
113
  ['grep', 'search_code'],
114
114
  ]);
115
115
 
116
- function canonicalToolName(value) {
116
+ export function canonicalToolName(value) {
117
117
  const key = String(value || '').trim().toLowerCase();
118
118
  return TOOL_ALIASES.get(key) || key;
119
119
  }
@@ -133,7 +133,7 @@ function normalizeScopedArgs(toolName, args = {}, { projectRoot = null } = {}) {
133
133
  return next;
134
134
  }
135
135
 
136
- function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
136
+ export function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
137
137
  const tools = Array.isArray(agent.tools) ? agent.tools : [];
138
138
  const allowed = new Set(tools.map(canonicalToolName).filter(Boolean));
139
139
  if (!allowed.size) return baseExecutor;
@@ -152,7 +152,11 @@ function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } =
152
152
  baseExecutor,
153
153
  toolName,
154
154
  normalizeScopedArgs(toolName, args, { projectRoot }),
155
- options,
155
+ {
156
+ ...options,
157
+ internal: true,
158
+ subAgent: agent.slug || agent.command || agent.name || true,
159
+ },
156
160
  );
157
161
  },
158
162
  };
@@ -317,6 +321,7 @@ export async function runAgentDefinition(agentDefinition, instruction, ctx, sess
317
321
  token: creds.token,
318
322
  toolExecutor,
319
323
  approvalManager: agentApproval,
324
+ pluginRegistry: options.pluginRegistry || ctx.pluginRegistry || null,
320
325
  });
321
326
 
322
327
  session.turns++;
@@ -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');
@@ -306,6 +326,8 @@ async function main() {
306
326
  \x1b[1mUsage:\x1b[0m
307
327
  bahulam Start interactive REPL
308
328
  bahulam "instruction" Run a single instruction
329
+ bahulam --agent <slug> -p "x" Run a named agent (local deterministic graph)
330
+ bahulam --workflow <name> -p Run a named workflow (local deterministic graph)
309
331
  bahulam --headless -p "x" Non-interactive: auto-approve, JSONL output
310
332
  bahulam --headless -p "x" --vision screenshot.png
311
333
  Attach an image via the vision analysis pipeline
@@ -330,8 +352,14 @@ async function main() {
330
352
  bahulam workspace list List recent local workspace sessions
331
353
  bahulam local open [path] Alias for workspace open
332
354
 
333
- \x1b[1mPlugins:\x1b[0m
334
- 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
335
363
 
336
364
  \x1b[1mAnalytics:\x1b[0m
337
365
  bahulam sessions List recent local sessions
@@ -360,7 +388,7 @@ async function main() {
360
388
  /architect <query> Spawn architecture planning agent
361
389
  /agents create <name> Create project-local user-defined agent YAML
362
390
  /agents edit <name> Open a local agent YAML in your editor
363
- /agents sync [name] Sync all or one local agent to Supabase
391
+ /agents sync [name] Optionally publish local agents to backend/account
364
392
  /attach <image-path> Attach an image to next prompt
365
393
  /attach clipboard Attach image copied to macOS/Windows clipboard
366
394
  /exit Exit the REPL
@@ -480,7 +508,9 @@ async function main() {
480
508
  const daemonSpawned = process.env.BAHULAM_DAEMON_SPAWNED === '1';
481
509
  const daemonPrompt = daemonSpawned ? (process.env.BAHULAM_DAEMON_INITIAL_PROMPT || '').trim() : '';
482
510
  const effectivePrompt = args.prompt || (daemonSpawned && daemonPrompt) || '';
483
- if (effectivePrompt && (daemonSpawned || process.argv.includes('--headless') || !process.stdin.isTTY)) {
511
+ const hasGraphTarget = Boolean(args.agent || args.workflow);
512
+ if ((effectivePrompt || hasGraphTarget)
513
+ && (daemonSpawned || process.argv.includes('--headless') || !process.stdin.isTTY || hasGraphTarget)) {
484
514
  const { runHeadless } = await import('../core/headless.mjs');
485
515
  await runHeadless({
486
516
  instruction: effectivePrompt,
@@ -490,6 +520,8 @@ async function main() {
490
520
  cacheReport: args.cacheReport,
491
521
  local: args.local,
492
522
  vision: args.vision,
523
+ agent: args.agent,
524
+ workflow: args.workflow,
493
525
  });
494
526
  return;
495
527
  }
@@ -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
+ }
@@ -34,8 +34,8 @@ import * as queue from '../ui/render-queue.mjs';
34
34
  // queue active) → coalesced last-wins status that can never interleave
35
35
  // with content. Legacy dock path and bare-TTY inPlace stay as fallbacks
36
36
  // until their write-sites migrate onto the queue too.
37
- // Max inner-tool lines shown under the spinner during a sub-agent run.
38
- const SUB_AGENT_WINDOW_ROWS = 7;
37
+ // Max live-window lines under the spinner during a sub-agent run.
38
+ const SUB_AGENT_WINDOW_ROWS = 8;
39
39
 
40
40
  function statusWidth() {
41
41
  return Math.max(8, (process.stderr.columns || process.stdout.columns || 120) - 1);
@@ -49,6 +49,18 @@ function fitStatusLines(lines) {
49
49
  return (Array.isArray(lines) ? lines : [lines]).map(fitStatusLine);
50
50
  }
51
51
 
52
+ function subAgentWindowLines(win) {
53
+ if (win?.groups instanceof Map && win.groups.size) {
54
+ const lines = [];
55
+ for (const group of win.groups.values()) {
56
+ if (group?.header) lines.push(group.header);
57
+ for (const line of group?.lines || []) lines.push(line);
58
+ }
59
+ return lines;
60
+ }
61
+ return (win?.lines || []).slice(-SUB_AGENT_WINDOW_ROWS);
62
+ }
63
+
52
64
  function presentStatus(rendered) {
53
65
  // Watch panel override: when active, render the watch panel entries as a
54
66
  // multi-line status block instead of the normal spinner/status line.
@@ -69,10 +81,11 @@ function presentStatus(rendered) {
69
81
  const fitted = fitStatusLine(rendered);
70
82
  if (queue.isActive()) {
71
83
  const win = runtime.subAgentWindow;
72
- if (win?.active && win.lines.length) {
84
+ const subAgentLines = subAgentWindowLines(win);
85
+ if (win?.active && subAgentLines.length) {
73
86
  queue.statusBlock([
74
87
  fitted,
75
- ...fitStatusLines(win.lines.slice(-SUB_AGENT_WINDOW_ROWS).map(l => ` ${c.dim(l)}`)),
88
+ ...fitStatusLines(subAgentLines.map(l => ` ${c.dim(l)}`)),
76
89
  ]);
77
90
  return;
78
91
  }
@@ -95,7 +108,7 @@ export function pushSubAgentWindowLine(line) {
95
108
  }
96
109
 
97
110
  export function setSubAgentWindowActive(active) {
98
- runtime.subAgentWindow = { active: Boolean(active), lines: [] };
111
+ runtime.subAgentWindow = { active: Boolean(active), lines: [], groups: new Map() };
99
112
  }
100
113
 
101
114
  /**
@@ -109,6 +122,25 @@ export function rebuildSubAgentWindow(lines) {
109
122
  const win = runtime.subAgentWindow;
110
123
  if (!win?.active) return;
111
124
  win.lines = Array.isArray(lines) ? lines.slice() : [];
125
+ win.groups = new Map();
126
+ repaintSpinnerStatus();
127
+ }
128
+
129
+ export function rebuildSubAgentWindowGroups(groups) {
130
+ const win = runtime.subAgentWindow;
131
+ if (!win?.active) return;
132
+ win.groups = new Map();
133
+ win.lines = [];
134
+ for (const group of Array.isArray(groups) ? groups : []) {
135
+ const key = group?.runId || group?.key || group?.label;
136
+ if (!key) continue;
137
+ win.groups.set(key, {
138
+ runId: group.runId || null,
139
+ label: group.label || 'sub-agent',
140
+ header: group.header || group.label || 'sub-agent',
141
+ lines: Array.isArray(group.lines) ? group.lines.slice() : [],
142
+ });
143
+ }
112
144
  repaintSpinnerStatus();
113
145
  }
114
146
 
@@ -308,11 +340,27 @@ export function renderToolCall(data) {
308
340
  const args = data?.args || {};
309
341
  const indent = subAgentIndent();
310
342
  const callId = data?.call_id || data?._callId || `${tool}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
343
+ const fromSubAgent = Boolean(data?.internal || data?.sub_agent || data?.sub_agent_label || data?.sub_agent_run_id);
311
344
 
312
345
  // If a previous head is still pending (no result yet), flush it as a
313
346
  // regular two-line shape before starting the next one.
314
347
  flushPendingHead();
315
348
 
349
+ // Sub-agent live window (queue mode): inner tool calls stream into the
350
+ // fixed-height status block instead of appending transcript lines. Keep
351
+ // this ahead of explore-collapse so parallel explores do not merge into
352
+ // one global read/search spinner.
353
+ if (fromSubAgent && queue.isActive() && runtime.subAgentWindow?.active) {
354
+ recordCard({ id: callId, tool, args, startedAt: Date.now() });
355
+ session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
356
+ const label = readToolLabel(tool, { args });
357
+ const runId = data?.run_id || data?.sub_agent_run_id || '';
358
+ const runSuffix = runId ? ` run ${String(runId).slice(0, 8)}` : '';
359
+ const lane = data?.sub_agent_label ? `[${data.sub_agent_label}${runSuffix}] ` : '';
360
+ pushSubAgentWindowLine(label ? `${lane}→ ${tool} · ${label}` : `${lane}→ ${tool}`);
361
+ return; // the spinner tick paints the window block
362
+ }
363
+
316
364
  // ── Explore-run collapse ────────────────────────────────────────────────
317
365
  // For list/read/search/index tools, skip the per-call head entirely and
318
366
  // update a single animated summary spinner. The transcript stays clean;
@@ -328,9 +376,8 @@ export function renderToolCall(data) {
328
376
  return;
329
377
  }
330
378
 
331
- // Sub-agent live window (queue mode): inner tool calls stream into the
332
- // fixed-height status block instead of appending transcript lines. The
333
- // card is still recorded so /expand, /last, and `d` show full detail.
379
+ // Legacy sub-agent live window fallback for events without explicit
380
+ // sub-agent metadata.
334
381
  if (queue.isActive() && runtime.subAgentWindow?.active && inSubAgentBlock()) {
335
382
  recordCard({ id: callId, tool, args, startedAt: Date.now() });
336
383
  session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
@@ -410,6 +457,14 @@ export function renderToolResult(data, eventType = 'tool_result') {
410
457
  indent: gutter,
411
458
  columns: process.stderr.columns || 120,
412
459
  });
460
+ const fromSubAgent = Boolean(data?.internal || data?.sub_agent || data?.sub_agent_label || data?.sub_agent_run_id);
461
+
462
+ // Sub-agent live window: the call line is already streaming in the
463
+ // status block; the result stays card-only (close card summarizes). This
464
+ // must run before explore-collapse for read/search tools used by explores.
465
+ if (fromSubAgent && queue.isActive() && runtime.subAgentWindow?.active) {
466
+ return;
467
+ }
413
468
 
414
469
  // Explore tools: the call already updated the summary spinner. Refresh
415
470
  // the "latest" hint with the result's file if we have one, and skip the
@@ -421,8 +476,8 @@ export function renderToolResult(data, eventType = 'tool_result') {
421
476
  return;
422
477
  }
423
478
 
424
- // Sub-agent live window: the call line is already streaming in the
425
- // status block; the result stays card-only (close card summarizes).
479
+ // Legacy sub-agent live window fallback for events without explicit
480
+ // sub-agent metadata.
426
481
  if (queue.isActive() && runtime.subAgentWindow?.active && inSubAgentBlock()) {
427
482
  return;
428
483
  }
@@ -43,7 +43,8 @@ export const runtime = {
43
43
 
44
44
  // Explore-run collapse (read/list/search/index bursts as concise progress).
45
45
  exploreRun: { counts: {}, recent: [], lineActive: false, lastPrintedSummary: '', lastPrintedTotal: 0, lastPrintedAt: 0 },
46
- foldedSubAgentTools: null, // { agentType, entries, startedAt } for default/quiet folded sub-agent tools
46
+ foldedSubAgentTools: null, // current folded sub-agent tool bucket
47
+ foldedSubAgentToolMap: new Map(), // run-aware buckets for parallel sub-agents
47
48
 
48
49
  // Animated spinner state (single shared interval; text/frame drive inPlace).
49
50
  spinInterval: null,
@@ -61,7 +62,7 @@ export const runtime = {
61
62
  // its inner tool calls stream into a fixed-height status block (last
62
63
  // N lines under the spinner) instead of appending to the transcript.
63
64
  // Full detail stays on the recorded cards (/expand, /last, `d`).
64
- subAgentWindow: { active: false, lines: [] },
65
+ subAgentWindow: { active: false, lines: [], groups: new Map() },
65
66
 
66
67
  // PRD-092: Watch panel — toggled by /watch. When active, the spinner
67
68
  // area renders a compact agent-activity summary instead of the spinner.
@@ -106,6 +107,7 @@ export const session = {
106
107
  lastTurnDuration: 0,
107
108
  toolCounts: {}, // per-tool histogram (mission report)
108
109
  subAgentCounts: {}, // per-sub-agent histogram (mission report)
110
+ activeSubAgentRuns: new Map(), // run_id -> active sub-agent lane
109
111
  savedUsd: 0, // total sub-agent cost (for "saved by routing")
110
112
  lastTask: '', // most recent user prompt (mission report title)
111
113
  lastReasoning: '', // captured from agent for /why