create-claude-workspace 1.1.81 → 1.1.82

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,193 @@
1
+ // ─── Integration tests: agent tag + color in real Claude CLI stream ───
2
+ // Spawns actual `claude` process and verifies agent tags appear in formatted output.
3
+ import { describe, it, expect, beforeEach } from 'vitest';
4
+ import { execSync, spawn } from 'node:child_process';
5
+ import { resetStreamState, setAgentTag, simplifyModelName, colorizeAgent, formatStreamEvent, formatStreamEventTracked, } from './claude-runner.mjs';
6
+ // ─── ANSI helpers ───
7
+ function stripAnsi(s) {
8
+ return s.replace(/\x1b\[\d+m/g, '');
9
+ }
10
+ // ─── Check if claude CLI is available ───
11
+ function claudeAvailable() {
12
+ try {
13
+ execSync('claude --version', { stdio: 'pipe', timeout: 5000 });
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ const hasClaude = claudeAvailable();
21
+ function spawnClaude(prompt, maxTurns, extraFlags = []) {
22
+ const isWin = process.platform === 'win32';
23
+ const flags = [
24
+ '-p',
25
+ '--output-format', 'stream-json',
26
+ '--verbose',
27
+ '--include-partial-messages',
28
+ '--max-turns', String(maxTurns),
29
+ ...extraFlags,
30
+ ];
31
+ const child = isWin
32
+ ? spawn(process.env.ComSpec ?? 'cmd.exe', ['/c', 'claude', ...flags], {
33
+ stdio: ['pipe', 'pipe', 'pipe'],
34
+ windowsHide: true,
35
+ })
36
+ : spawn('claude', [...flags, prompt], {
37
+ stdio: ['ignore', 'pipe', 'pipe'],
38
+ });
39
+ if (isWin && child.stdin) {
40
+ child.stdin.write(prompt);
41
+ child.stdin.end();
42
+ }
43
+ setAgentTag('orchestrator');
44
+ const rawEvents = [];
45
+ const formattedLines = [];
46
+ let detectedModel = '';
47
+ return new Promise((resolve) => {
48
+ let buffer = '';
49
+ child.stdout.on('data', (chunk) => {
50
+ buffer += chunk.toString();
51
+ const lines = buffer.split('\n');
52
+ buffer = lines.pop();
53
+ for (const rawLine of lines) {
54
+ const line = rawLine.replace(/\r/g, '');
55
+ if (!line.trim())
56
+ continue;
57
+ let event;
58
+ try {
59
+ event = JSON.parse(line);
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ rawEvents.push(event);
65
+ if (event.type === 'system' && typeof event.model === 'string') {
66
+ detectedModel = simplifyModelName(event.model);
67
+ setAgentTag('orchestrator', detectedModel);
68
+ }
69
+ const formatted = formatStreamEventTracked(event);
70
+ if (formatted) {
71
+ formattedLines.push(formatted);
72
+ process.stdout.write(formatted);
73
+ }
74
+ }
75
+ });
76
+ child.stderr.on('data', () => { });
77
+ child.on('close', () => resolve({ rawEvents, formattedLines, detectedModel }));
78
+ });
79
+ }
80
+ // ─── Tests ───
81
+ describe('agent tag integration: real Claude CLI', () => {
82
+ beforeEach(() => {
83
+ resetStreamState();
84
+ });
85
+ it.skipIf(!hasClaude)('spawns claude and verifies agent tag in stream output', async () => {
86
+ const result = await spawnClaude('Respond with exactly: ok', 1);
87
+ // System event with model
88
+ const systemEvent = result.rawEvents.find(e => e.type === 'system');
89
+ expect(systemEvent, 'system event should be present').toBeDefined();
90
+ expect(systemEvent.model, 'system event should contain model').toBeDefined();
91
+ console.log(`\nDetected model: ${systemEvent.model} → simplified: ${result.detectedModel}`);
92
+ // Model simplified correctly
93
+ expect(result.detectedModel).toMatch(/^\w+-\d+(\.\d+)?$/);
94
+ // Formatted output contains agent tag with model
95
+ const allPlain = result.formattedLines.map(stripAnsi).join('');
96
+ expect(allPlain).toContain('orchestrator');
97
+ expect(allPlain).toContain(result.detectedModel);
98
+ // At least one line has full tag format
99
+ const hasFullTag = result.formattedLines.some(line => stripAnsi(line).includes(`orchestrator/${result.detectedModel}`));
100
+ expect(hasFullTag, `expected "orchestrator/${result.detectedModel}" in output`).toBe(true);
101
+ console.log(`\n═══ ${result.rawEvents.length} events, ${result.formattedLines.length} formatted lines ═══`);
102
+ console.log(`Event types: ${[...new Set(result.rawEvents.map(e => e.type))].join(', ')}`);
103
+ }, 60_000);
104
+ it.skipIf(!hasClaude)('shows sub-agent name when Agent tool is used', async () => {
105
+ // Use a custom agent defined in .claude/agents/test-dummy.md
106
+ // This mirrors how orchestrator delegates: subagent_type="test-dummy"
107
+ const prompt = [
108
+ 'You MUST use the Agent tool exactly once.',
109
+ 'Call it with: subagent_type "test-dummy", description "find ts files",',
110
+ 'prompt "Use Glob with pattern **/*.mts to find TypeScript files. Return the list."',
111
+ 'After the Agent tool returns, respond with: done',
112
+ ].join(' ');
113
+ const result = await spawnClaude(prompt, 5, ['--dangerously-skip-permissions']);
114
+ // Find assistant events with Agent tool_use
115
+ const agentToolEvents = result.rawEvents.filter(e => {
116
+ if (e.type !== 'assistant')
117
+ return false;
118
+ const msg = e.message;
119
+ if (!msg || typeof msg === 'string')
120
+ return false;
121
+ const content = msg.content;
122
+ return content?.some(b => b.type === 'tool_use' && b.name === 'Agent');
123
+ });
124
+ console.log(`\n═══ Sub-agent test: ${result.rawEvents.length} events, ${agentToolEvents.length} Agent tool calls ═══`);
125
+ // Verify Agent tool was called
126
+ expect(agentToolEvents.length, 'expected at least one Agent tool_use event').toBeGreaterThanOrEqual(1);
127
+ // Verify formatted output contains the robot icon and sub-agent identifier
128
+ const allPlain = result.formattedLines.map(stripAnsi).join('');
129
+ expect(allPlain, 'expected 🤖 icon for Agent tool call').toContain('🤖');
130
+ expect(allPlain, 'expected → arrow for sub-agent delegation').toContain('→');
131
+ // Verify the delegation line shows orchestrator (parent) + agent name
132
+ const agentLine = result.formattedLines.find(l => stripAnsi(l).includes('🤖'));
133
+ expect(agentLine, 'expected a formatted line with 🤖').toBeDefined();
134
+ const agentLinePlain = stripAnsi(agentLine);
135
+ console.log(`Delegation line: ${agentLinePlain.trim()}`);
136
+ // Delegation line should show orchestrator as the caller
137
+ expect(agentLinePlain).toContain(`orchestrator/${result.detectedModel}`);
138
+ // Should contain the agent name "test-dummy"
139
+ expect(agentLinePlain, 'expected agent name "test-dummy" in delegation line')
140
+ .toContain('test-dummy');
141
+ // Verify sub-agent's internal tool calls show sub-agent name (not orchestrator)
142
+ const plainLines = result.formattedLines.map(stripAnsi);
143
+ const delegationIdx = plainLines.findIndex(l => l.includes('🤖'));
144
+ const subAgentLines = plainLines.slice(delegationIdx + 1).filter(l => l.includes('▶') || l.includes('💭'));
145
+ if (subAgentLines.length > 0) {
146
+ console.log(`Sub-agent internal tool calls (${subAgentLines.length}):`);
147
+ for (const line of subAgentLines) {
148
+ console.log(` ${line.trim()}`);
149
+ // Internal tool calls should show "test-dummy", NOT "orchestrator"
150
+ expect(line, 'sub-agent tool call should show test-dummy, not orchestrator')
151
+ .toContain('test-dummy');
152
+ expect(line, 'sub-agent tool call should NOT show orchestrator')
153
+ .not.toContain(`orchestrator/${result.detectedModel}`);
154
+ }
155
+ }
156
+ // After Agent returns, verify orchestrator tag is restored
157
+ const postAgentLines = plainLines.slice(delegationIdx + 1).filter(l => !l.includes('▶') && !l.includes('💭') && !l.includes('✓') && !l.includes('⏳') && l.trim());
158
+ if (postAgentLines.length > 0) {
159
+ const lastLine = postAgentLines[postAgentLines.length - 1];
160
+ console.log(`Post-agent line: ${lastLine.trim()}`);
161
+ expect(lastLine, 'after Agent returns, orchestrator tag should be restored')
162
+ .toContain(`orchestrator/${result.detectedModel}`);
163
+ }
164
+ console.log(`Event types: ${[...new Set(result.rawEvents.map(e => e.type))].join(', ')}`);
165
+ console.log('═══ End ═══\n');
166
+ }, 120_000);
167
+ it.skipIf(!hasClaude)('agent colors are visible in real formatted output', () => {
168
+ const agents = [
169
+ 'orchestrator', 'ui-engineer', 'backend-ts-architect',
170
+ 'senior-code-reviewer', 'test-engineer', 'product-owner',
171
+ 'technical-planner', 'devops-integrator', 'deployment-engineer',
172
+ 'project-initializer',
173
+ ];
174
+ console.log('\n═══ Agent colors (visual check) ═══');
175
+ for (const agent of agents) {
176
+ setAgentTag(agent, 'opus-4.6');
177
+ const line = formatStreamEvent({
178
+ type: 'assistant',
179
+ message: { content: [{ type: 'text', text: `Working on task #42...` }] },
180
+ });
181
+ process.stdout.write(line);
182
+ resetStreamState();
183
+ }
184
+ console.log('═══ End of agent colors ═══\n');
185
+ const orchColor = colorizeAgent('orchestrator').match(/\x1b\[\d+m/)?.[0];
186
+ for (const agent of agents.slice(1)) {
187
+ const color = colorizeAgent(agent).match(/\x1b\[\d+m/)?.[0];
188
+ if (agent !== 'devops-integrator') {
189
+ expect(color, `${agent} should differ from orchestrator`).not.toBe(orchColor);
190
+ }
191
+ }
192
+ });
193
+ });
@@ -18,17 +18,74 @@ const RESULT_SCHEMA = JSON.stringify({
18
18
  const TC = {
19
19
  dim: '\x1b[2m', reset: '\x1b[0m',
20
20
  cyan: '\x1b[36m', yellow: '\x1b[33m', green: '\x1b[32m', red: '\x1b[31m',
21
+ blue: '\x1b[34m', magenta: '\x1b[35m',
22
+ brightCyan: '\x1b[96m', brightYellow: '\x1b[93m',
23
+ brightGreen: '\x1b[92m', brightMagenta: '\x1b[95m', brightBlue: '\x1b[94m',
21
24
  };
22
25
  function ts() {
23
- return `[${new Date().toLocaleTimeString('en-GB', { hour12: false })}] `;
26
+ return `[${new Date().toLocaleTimeString('en-GB', { hour12: false })}] ${_agentTag}`;
24
27
  }
25
28
  const MAX_BUFFER = 1024 * 1024; // 1MB stdout buffer cap
26
29
  const MAX_STDERR = 64 * 1024; // 64KB stderr cap
27
30
  // ─── Stream event formatting ───
28
- // Mutable state for partial message dedup — reset per runClaude call
31
+ // Mutable state for partial message dedup + agent tag — reset per runClaude call
29
32
  let _lastAssistantText = '';
33
+ let _agentTag = '';
34
+ let _agentTagStack = [];
35
+ let _agentReturnDepths = [];
36
+ let _formatToolDepth = 0;
30
37
  export function resetStreamState() {
31
38
  _lastAssistantText = '';
39
+ _agentTag = '';
40
+ _agentTagStack = [];
41
+ _agentReturnDepths = [];
42
+ _formatToolDepth = 0;
43
+ }
44
+ export function setAgentTag(name, model) {
45
+ const colored = colorizeAgent(name);
46
+ _agentTag = model
47
+ ? `${colored}${TC.dim}/${model}${TC.reset} `
48
+ : `${colored} `;
49
+ }
50
+ export function simplifyModelName(raw) {
51
+ // Strip non-alphanumeric suffixes like "[1m]" (context window indicator)
52
+ const model = raw.replace(/\[.*$/, '');
53
+ // "claude-opus-4-6-20250514" → "opus-4.6" (major-minor before date)
54
+ const m = model.match(/claude-(\w+)-(\d{1,2})-(\d{1,2})-\d{8}/);
55
+ if (m)
56
+ return `${m[1]}-${m[2]}.${m[3]}`;
57
+ // "claude-opus-4-6" → "opus-4.6" (major-minor, no date)
58
+ const m1b = model.match(/claude-(\w+)-(\d{1,2})-(\d{1,2})$/);
59
+ if (m1b)
60
+ return `${m1b[1]}-${m1b[2]}.${m1b[3]}`;
61
+ // "claude-sonnet-4-20250514" → "sonnet-4" (major only before date)
62
+ const m2 = model.match(/claude-(\w+)-(\d{1,2})-\d{8}/);
63
+ if (m2)
64
+ return `${m2[1]}-${m2[2]}`;
65
+ // "claude-opus-4" → "opus-4" (no date suffix)
66
+ const m3 = model.match(/claude-(\w+)-(\d+)/);
67
+ if (m3)
68
+ return `${m3[1]}-${m3[2]}`;
69
+ return model;
70
+ }
71
+ // ─── Agent color coding ───
72
+ const AGENT_COLORS = {
73
+ orchestrator: TC.cyan,
74
+ 'ui-engineer': TC.magenta,
75
+ 'backend-ts-architect': TC.blue,
76
+ 'senior-code-reviewer': TC.yellow,
77
+ 'test-engineer': TC.green,
78
+ 'product-owner': TC.brightMagenta,
79
+ 'technical-planner': TC.brightBlue,
80
+ 'devops-integrator': TC.brightCyan,
81
+ 'deployment-engineer': TC.brightYellow,
82
+ 'project-initializer': TC.brightGreen,
83
+ };
84
+ const FALLBACK_COLORS = [TC.cyan, TC.magenta, TC.blue, TC.yellow, TC.green, TC.brightCyan];
85
+ export function colorizeAgent(name) {
86
+ const color = AGENT_COLORS[name]
87
+ ?? FALLBACK_COLORS[Math.abs([...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 0)) % FALLBACK_COLORS.length];
88
+ return `${color}${name}${TC.reset}`;
32
89
  }
33
90
  const SILENT_EVENTS = new Set(['system', 'ping', 'result']);
34
91
  function formatRateLimit(event) {
@@ -47,6 +104,14 @@ function getContentBlocks(event) {
47
104
  }
48
105
  function formatToolUse(block) {
49
106
  const input = block.input ?? {};
107
+ // Special formatting for Agent tool — show sub-agent with color
108
+ if (block.name === 'Agent') {
109
+ const agentType = input.subagent_type ?? '';
110
+ const desc = input.description ?? '';
111
+ const label = agentType || desc || 'sub-agent';
112
+ const colored = colorizeAgent(label);
113
+ return `${ts()}🤖 → ${colored}${desc && agentType ? ` ${TC.dim}${desc}${TC.reset}` : ''}`;
114
+ }
50
115
  const detail = input.command ?? input.file_path ?? input.pattern ?? input.query ?? '';
51
116
  const short = detail.slice(0, 120);
52
117
  return `${ts()}${TC.cyan}▶ ${block.name ?? 'tool'}${TC.reset}${short ? ` ${TC.dim}${short}${TC.reset}` : ''}`;
@@ -115,6 +180,48 @@ export function formatStreamEvent(event) {
115
180
  return null;
116
181
  return EVENT_FORMATTERS[event.type]?.(event) ?? null;
117
182
  }
183
+ /**
184
+ * Wrapper that switches the agent tag around formatting:
185
+ * - user tool_result: restores parent tag BEFORE formatting (so Agent result shows parent)
186
+ * - assistant Agent tool_use: switches to sub-agent AFTER formatting (so delegation line shows parent)
187
+ */
188
+ export function formatStreamEventTracked(event) {
189
+ // Before format: restore parent tag when Agent tool_result arrives
190
+ if (event.type === 'user') {
191
+ const blocks = getContentBlocks(event);
192
+ if (blocks) {
193
+ for (const block of blocks) {
194
+ if (block.type === 'tool_result') {
195
+ if (_agentReturnDepths.length > 0 && _formatToolDepth === _agentReturnDepths[_agentReturnDepths.length - 1]) {
196
+ _agentReturnDepths.pop();
197
+ _agentTag = _agentTagStack.pop() ?? '';
198
+ }
199
+ _formatToolDepth = Math.max(0, _formatToolDepth - 1);
200
+ }
201
+ }
202
+ }
203
+ }
204
+ const result = formatStreamEvent(event);
205
+ // After format: switch to sub-agent tag when Agent tool_use is seen
206
+ if (event.type === 'assistant') {
207
+ const blocks = getContentBlocks(event);
208
+ if (blocks) {
209
+ for (const block of blocks) {
210
+ if (block.type === 'tool_use') {
211
+ _formatToolDepth++;
212
+ if (block.name === 'Agent') {
213
+ _agentTagStack.push(_agentTag);
214
+ _agentReturnDepths.push(_formatToolDepth);
215
+ const input = block.input ?? {};
216
+ const name = input.subagent_type || input.description || 'sub-agent';
217
+ _agentTag = `${colorizeAgent(name)} `;
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ return result;
224
+ }
118
225
  // ─── Schema validation ───
119
226
  const VALID_STATUSES = new Set(['completed', 'needs_input', 'blocked', 'error']);
120
227
  export function validateResult(obj) {
@@ -193,8 +300,9 @@ export function runClaude(opts, log, runOpts = {}) {
193
300
  }
194
301
  if (opts.skipPermissions)
195
302
  flags.push('--dangerously-skip-permissions');
196
- // Reset stream state for this invocation
303
+ // Reset stream state for this invocation and set initial agent tag
197
304
  resetStreamState();
305
+ setAgentTag('orchestrator');
198
306
  // Spawn WITHOUT detached — child stays in the same process group as parent.
199
307
  // When user presses Ctrl+C, BOTH parent and child receive the signal from the OS.
200
308
  // Parent's SIGINT handler kills child (backup) and calls process.exit().
@@ -230,6 +338,7 @@ export function runClaude(opts, log, runOpts = {}) {
230
338
  let isAuthServerError = false;
231
339
  let isUsageLimit = false;
232
340
  let resultReceived = false;
341
+ let pendingToolCalls = 0;
233
342
  let buffer = '';
234
343
  let killed = false;
235
344
  let killReason = null;
@@ -298,6 +407,11 @@ export function runClaude(opts, log, runOpts = {}) {
298
407
  lastOutputTime = Date.now();
299
408
  if (activityTimer)
300
409
  clearTimeout(activityTimer);
410
+ // While a tool is executing (tool_use without tool_result), skip activity timeout entirely.
411
+ // Long-running tools (nx build, test suites) can run 20+ min with no output.
412
+ // The process timeout (2h) is the safety net.
413
+ if (pendingToolCalls > 0)
414
+ return;
301
415
  const timeout = resultReceived ? opts.postResultTimeout : opts.activityTimeout;
302
416
  activityTimer = setTimeout(() => killChild('activity_timeout'), timeout);
303
417
  }
@@ -342,6 +456,12 @@ export function runClaude(opts, log, runOpts = {}) {
342
456
  // Extract session ID
343
457
  if (event.session_id && !sessionId)
344
458
  sessionId = event.session_id;
459
+ // Extract model from system event and update agent tag
460
+ if (event.type === 'system' && event.model) {
461
+ const model = simplifyModelName(event.model);
462
+ setAgentTag('orchestrator', model);
463
+ process.stdout.write(`${ts()}${TC.green}Session started${TC.reset} ${TC.dim}(${event.model})${TC.reset}\n`);
464
+ }
345
465
  // Extract structured result
346
466
  if (event.type === 'result' && event.subtype !== 'tool_result') {
347
467
  resultReceived = true;
@@ -360,19 +480,34 @@ export function runClaude(opts, log, runOpts = {}) {
360
480
  if (event.type === 'rate_limit_event')
361
481
  isRateLimit = true;
362
482
  detectErrorSignals(event);
363
- // Detect error signals in assistant text content (e.g. "You've hit your limit" as text, not error event)
483
+ // Track pending tool calls tool_use in assistant, tool_result in user
364
484
  if (event.type === 'assistant') {
365
485
  const blocks = getContentBlocks(event);
366
486
  if (blocks) {
367
487
  for (const block of blocks) {
488
+ if (block.type === 'tool_use')
489
+ pendingToolCalls++;
490
+ // Detect error signals in assistant text content (e.g. "You've hit your limit" as text, not error event)
368
491
  if (block.type === 'text' && block.text) {
369
492
  detectTextSignals(block.text.toLowerCase());
370
493
  }
371
494
  }
372
495
  }
373
496
  }
374
- // Display
375
- const formatted = formatStreamEvent(event);
497
+ if (event.type === 'user') {
498
+ const blocks = getContentBlocks(event);
499
+ if (blocks) {
500
+ for (const block of blocks) {
501
+ if (block.type === 'tool_result')
502
+ pendingToolCalls = Math.max(0, pendingToolCalls - 1);
503
+ }
504
+ // All tools finished — re-arm activity timer (was suspended while tools ran)
505
+ if (pendingToolCalls <= 0)
506
+ resetActivityTimer();
507
+ }
508
+ }
509
+ // Display (tracked version switches agent tag for sub-agent calls)
510
+ const formatted = formatStreamEventTracked(event);
376
511
  if (formatted)
377
512
  process.stdout.write(formatted);
378
513
  }
@@ -414,7 +549,7 @@ export function runClaude(opts, log, runOpts = {}) {
414
549
  }
415
550
  catch { /* already dead — expected */ }
416
551
  }
417
- // Flush remaining buffer
552
+ // Flush remaining buffer (last line without trailing \n)
418
553
  if (buffer.trim()) {
419
554
  const cleaned = buffer.replace(/\r/g, '');
420
555
  const event = parseStreamEvent(cleaned);
@@ -425,6 +560,11 @@ export function runClaude(opts, log, runOpts = {}) {
425
560
  structuredResult = validateResult(event.structured_output) ?? structuredResult;
426
561
  resultReceived = true;
427
562
  }
563
+ detectErrorSignals(event);
564
+ // Display the flushed event (was silently swallowed before)
565
+ const formatted = formatStreamEventTracked(event);
566
+ if (formatted)
567
+ process.stdout.write(formatted);
428
568
  }
429
569
  else {
430
570
  // Non-JSON buffer (e.g. "You've hit your limit" without trailing newline)
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
- import { resetStreamState, validateResult, formatStreamEvent, killProcessTree } from './claude-runner.mjs';
2
+ import { resetStreamState, setAgentTag, simplifyModelName, colorizeAgent, validateResult, formatStreamEvent, formatStreamEventTracked, killProcessTree } from './claude-runner.mjs';
3
3
  import { spawn, execSync } from 'node:child_process';
4
4
  describe('validateResult', () => {
5
5
  it('returns valid result for complete object', () => {
@@ -237,6 +237,208 @@ describe('resetStreamState', () => {
237
237
  resetStreamState(); // should not throw
238
238
  });
239
239
  });
240
+ // ─── simplifyModelName ───
241
+ describe('simplifyModelName', () => {
242
+ it('simplifies full model ID with minor version', () => {
243
+ expect(simplifyModelName('claude-opus-4-6-20250514')).toBe('opus-4.6');
244
+ });
245
+ it('simplifies model ID without minor version', () => {
246
+ expect(simplifyModelName('claude-sonnet-4-20250514')).toBe('sonnet-4');
247
+ });
248
+ it('simplifies haiku model', () => {
249
+ expect(simplifyModelName('claude-haiku-4-5-20251001')).toBe('haiku-4.5');
250
+ });
251
+ it('strips context window suffix like [1m]', () => {
252
+ expect(simplifyModelName('claude-opus-4-6[1m]')).toBe('opus-4.6');
253
+ expect(simplifyModelName('claude-sonnet-4-6[1m]')).toBe('sonnet-4.6');
254
+ });
255
+ it('handles model ID without date and without suffix', () => {
256
+ expect(simplifyModelName('claude-opus-4-6')).toBe('opus-4.6');
257
+ });
258
+ it('returns original string for unrecognized format', () => {
259
+ expect(simplifyModelName('some-custom-model')).toBe('some-custom-model');
260
+ });
261
+ });
262
+ // ─── colorizeAgent ───
263
+ describe('colorizeAgent', () => {
264
+ it('wraps agent name with ANSI color codes', () => {
265
+ const result = colorizeAgent('orchestrator');
266
+ expect(result).toContain('orchestrator');
267
+ expect(result).toContain('\x1b['); // has ANSI escape
268
+ expect(result).toContain('\x1b[0m'); // has reset
269
+ });
270
+ it('uses distinct colors for different known agents', () => {
271
+ const orch = colorizeAgent('orchestrator');
272
+ const ui = colorizeAgent('ui-engineer');
273
+ // Extract color code (first escape sequence)
274
+ const colorOf = (s) => s.match(/\x1b\[\d+m/)?.[0];
275
+ expect(colorOf(orch)).not.toBe(colorOf(ui));
276
+ });
277
+ it('handles unknown agent names with fallback color', () => {
278
+ const result = colorizeAgent('custom-agent');
279
+ expect(result).toContain('custom-agent');
280
+ expect(result).toContain('\x1b[');
281
+ });
282
+ });
283
+ // ─── setAgentTag + formatted output ───
284
+ describe('setAgentTag', () => {
285
+ beforeEach(() => {
286
+ resetStreamState();
287
+ });
288
+ it('adds agent name to formatted output', () => {
289
+ setAgentTag('orchestrator');
290
+ const result = formatStreamEvent({
291
+ type: 'assistant',
292
+ message: { content: [{ type: 'text', text: 'Hello' }] },
293
+ });
294
+ expect(result).toContain('orchestrator');
295
+ });
296
+ it('adds agent name and model to formatted output', () => {
297
+ setAgentTag('orchestrator', 'opus-4.6');
298
+ const result = formatStreamEvent({
299
+ type: 'assistant',
300
+ message: { content: [{ type: 'text', text: 'Hello' }] },
301
+ });
302
+ expect(result).toContain('orchestrator');
303
+ expect(result).toContain('opus-4.6');
304
+ });
305
+ it('resets tag on resetStreamState', () => {
306
+ setAgentTag('orchestrator', 'opus-4.6');
307
+ resetStreamState();
308
+ const result = formatStreamEvent({
309
+ type: 'assistant',
310
+ message: { content: [{ type: 'text', text: 'Hello' }] },
311
+ });
312
+ expect(result).not.toContain('orchestrator');
313
+ });
314
+ });
315
+ // ─── Agent tool_use formatting ───
316
+ describe('formatStreamEvent with Agent tool', () => {
317
+ beforeEach(() => {
318
+ resetStreamState();
319
+ });
320
+ it('formats Agent tool_use with robot icon and sub-agent type', () => {
321
+ const result = formatStreamEvent({
322
+ type: 'assistant',
323
+ message: {
324
+ content: [{
325
+ type: 'tool_use',
326
+ name: 'Agent',
327
+ input: { subagent_type: 'ui-engineer', description: 'plan frontend' },
328
+ }],
329
+ },
330
+ });
331
+ expect(result).toContain('🤖');
332
+ expect(result).toContain('ui-engineer');
333
+ });
334
+ it('falls back to description when no subagent_type', () => {
335
+ const result = formatStreamEvent({
336
+ type: 'assistant',
337
+ message: {
338
+ content: [{
339
+ type: 'tool_use',
340
+ name: 'Agent',
341
+ input: { description: 'explore codebase' },
342
+ }],
343
+ },
344
+ });
345
+ expect(result).toContain('🤖');
346
+ expect(result).toContain('explore codebase');
347
+ });
348
+ it('shows "sub-agent" when no type or description', () => {
349
+ const result = formatStreamEvent({
350
+ type: 'assistant',
351
+ message: {
352
+ content: [{
353
+ type: 'tool_use',
354
+ name: 'Agent',
355
+ input: {},
356
+ }],
357
+ },
358
+ });
359
+ expect(result).toContain('🤖');
360
+ expect(result).toContain('sub-agent');
361
+ });
362
+ });
363
+ // ─── formatStreamEventTracked: agent tag switching ───
364
+ describe('formatStreamEventTracked', () => {
365
+ function stripAnsi(s) {
366
+ return s.replace(/\x1b\[\d+m/g, '');
367
+ }
368
+ beforeEach(() => {
369
+ resetStreamState();
370
+ setAgentTag('orchestrator', 'opus-4.6');
371
+ });
372
+ it('switches tag to sub-agent after Agent tool_use, restores on tool_result', () => {
373
+ // 1. Agent tool_use — should show orchestrator (parent delegates)
374
+ const delegation = formatStreamEventTracked({
375
+ type: 'assistant',
376
+ message: { content: [{ type: 'tool_use', name: 'Agent', input: { subagent_type: 'ui-engineer', description: 'plan' } }] },
377
+ });
378
+ expect(stripAnsi(delegation)).toContain('orchestrator/opus-4.6');
379
+ expect(stripAnsi(delegation)).toContain('🤖');
380
+ // 2. Sub-agent's internal tool call — should show ui-engineer
381
+ const subTool = formatStreamEventTracked({
382
+ type: 'assistant',
383
+ message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: 'src/main.ts' } }] },
384
+ });
385
+ expect(stripAnsi(subTool)).toContain('ui-engineer');
386
+ expect(stripAnsi(subTool)).not.toContain('orchestrator');
387
+ // 3. Sub-agent's tool result — still sub-agent context
388
+ const subResult = formatStreamEventTracked({
389
+ type: 'user',
390
+ message: { content: [{ type: 'tool_result', content: 'file contents' }] },
391
+ });
392
+ expect(stripAnsi(subResult)).toContain('ui-engineer');
393
+ // 4. Agent tool result — restores orchestrator
394
+ const agentResult = formatStreamEventTracked({
395
+ type: 'user',
396
+ message: { content: [{ type: 'tool_result', content: 'plan completed' }] },
397
+ });
398
+ expect(stripAnsi(agentResult)).toContain('orchestrator/opus-4.6');
399
+ // 5. Next tool call — should be orchestrator again
400
+ const nextTool = formatStreamEventTracked({
401
+ type: 'assistant',
402
+ message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'npm test' } }] },
403
+ });
404
+ expect(stripAnsi(nextTool)).toContain('orchestrator/opus-4.6');
405
+ });
406
+ it('handles nested Agent calls (agent within agent)', () => {
407
+ // orchestrator → backend-ts-architect → (inner tool)
408
+ formatStreamEventTracked({
409
+ type: 'assistant',
410
+ message: { content: [{ type: 'tool_use', name: 'Agent', input: { subagent_type: 'backend-ts-architect' } }] },
411
+ });
412
+ // backend-ts-architect calls Agent (hypothetical nested delegation)
413
+ formatStreamEventTracked({
414
+ type: 'assistant',
415
+ message: { content: [{ type: 'tool_use', name: 'Agent', input: { subagent_type: 'Explore' } }] },
416
+ });
417
+ // Inner agent's tool
418
+ const innerTool = formatStreamEventTracked({
419
+ type: 'assistant',
420
+ message: { content: [{ type: 'tool_use', name: 'Grep', input: { pattern: 'TODO' } }] },
421
+ });
422
+ expect(stripAnsi(innerTool)).toContain('Explore');
423
+ // Inner agent's tool result
424
+ formatStreamEventTracked({
425
+ type: 'user',
426
+ message: { content: [{ type: 'tool_result', content: 'found' }] },
427
+ });
428
+ // Inner Agent result → back to backend-ts-architect
429
+ const innerAgentResult = formatStreamEventTracked({
430
+ type: 'user',
431
+ message: { content: [{ type: 'tool_result', content: 'explore done' }] },
432
+ });
433
+ expect(stripAnsi(innerAgentResult)).toContain('backend-ts-architect');
434
+ // Outer Agent result → back to orchestrator
435
+ const outerAgentResult = formatStreamEventTracked({
436
+ type: 'user',
437
+ message: { content: [{ type: 'tool_result', content: 'architect done' }] },
438
+ });
439
+ expect(stripAnsi(outerAgentResult)).toContain('orchestrator/opus-4.6');
440
+ });
441
+ });
240
442
  // ─── killProcessTree integration tests ───
241
443
  // Verifies that killProcessTree actually kills child process trees immediately
242
444
  // (SIGKILL on Unix, taskkill /T /F on Windows — no 5s SIGTERM grace period).
@@ -1,23 +1,6 @@
1
1
  ---
2
2
  name: deployment-engineer
3
- description: "Use this agent to set up deployment pipelines, CI/CD configuration, preview deployments, production deploys, rollbacks, and smoke tests. Supports: Cloudflare Pages/Workers, Vercel, Netlify, VPS (SSH), Docker. Runs a deployment interview, generates CI/CD config, and adds a Deployment section to CLAUDE.md.
4
-
5
- Examples:
6
-
7
- <example>
8
- user: \"Set up deployment for this project\"
9
- assistant: \"I'll use the deployment-engineer agent to configure CI/CD and deployment pipelines.\"
10
- </example>
11
-
12
- <example>
13
- user: \"Add preview deployments for merge requests\"
14
- assistant: \"Let me use the deployment-engineer agent to set up preview deploys.\"
15
- </example>
16
-
17
- <example>
18
- user: \"Deploy to production\"
19
- assistant: \"I'll use the deployment-engineer agent to run the production deployment.\"
20
- </example>"
3
+ description: "Use this agent to set up deployment pipelines, CI/CD configuration, preview deployments, production deploys, rollbacks, and smoke tests. Supports: Cloudflare Pages/Workers, Vercel, Netlify, VPS (SSH), Docker. Runs a deployment interview, generates CI/CD config, and adds a Deployment section to CLAUDE.md.\n\nExamples:\n\n<example>\nuser: \"Set up deployment for this project\"\nassistant: \"I'll use the deployment-engineer agent to configure CI/CD and deployment pipelines.\"\n</example>\n\n<example>\nuser: \"Add preview deployments for merge requests\"\nassistant: \"Let me use the deployment-engineer agent to set up preview deploys.\"\n</example>\n\n<example>\nuser: \"Deploy to production\"\nassistant: \"I'll use the deployment-engineer agent to run the production deployment.\"\n</example>"
21
4
  model: sonnet
22
5
  ---
23
6
 
@@ -1,23 +1,6 @@
1
1
  ---
2
2
  name: ui-engineer
3
- description: "Use this agent for frontend development tasks: component creation, UI implementation, styling, state management, accessibility, or frontend code reviews. Adapts to the project's frontend framework (Angular, React, Vue, Svelte, etc.) based on `.claude/profiles/frontend.md`.
4
-
5
- Examples:
6
-
7
- <example>
8
- user: \"Create a card component for displaying items\"
9
- assistant: \"I'll use the ui-engineer agent to design and implement this component following our frontend patterns.\"
10
- </example>
11
-
12
- <example>
13
- user: \"Review the dashboard components I just created\"
14
- assistant: \"Let me use the ui-engineer agent to review these components for quality and alignment with our standards.\"
15
- </example>
16
-
17
- <example>
18
- user: \"I need a multi-step form with validation\"
19
- assistant: \"I'll engage the ui-engineer agent to architect and implement this form with proper patterns.\"
20
- </example>"
3
+ description: "Use this agent for frontend development tasks: component creation, UI implementation, styling, state management, accessibility, or frontend code reviews. Adapts to the project's frontend framework (Angular, React, Vue, Svelte, etc.) based on `.claude/profiles/frontend.md`.\n\nExamples:\n\n<example>\nuser: \"Create a card component for displaying items\"\nassistant: \"I'll use the ui-engineer agent to design and implement this component following our frontend patterns.\"\n</example>\n\n<example>\nuser: \"Review the dashboard components I just created\"\nassistant: \"Let me use the ui-engineer agent to review these components for quality and alignment with our standards.\"\n</example>\n\n<example>\nuser: \"I need a multi-step form with validation\"\nassistant: \"I'll engage the ui-engineer agent to architect and implement this form with proper patterns.\"\n</example>"
21
4
  model: opus
22
5
  ---
23
6
 
@@ -29,10 +29,8 @@ RUN GLAB_VER=$(curl -fsSL "https://gitlab.com/api/v4/projects/34675721/releases/
29
29
  -o /tmp/glab.deb && \
30
30
  dpkg -i /tmp/glab.deb && rm /tmp/glab.deb
31
31
 
32
- # Install bun
33
- RUN curl -fsSL https://bun.sh/install | bash && \
34
- ln -s /root/.bun/bin/bun /usr/local/bin/bun && \
35
- ln -s /root/.bun/bin/bunx /usr/local/bin/bunx
32
+ # Install bun (system-wide so non-root claude user can access it)
33
+ RUN BUN_INSTALL=/usr/local curl -fsSL https://bun.sh/install | bash
36
34
 
37
35
  # Install Claude Code natively as the claude user
38
36
  RUN gosu claude bash -c 'curl -fsSL https://claude.ai/install.sh | bash'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "1.1.81",
3
+ "version": "1.1.82",
4
4
  "description": "Scaffold a project with Claude Code agents for autonomous AI-driven development",
5
5
  "type": "module",
6
6
  "bin": {