create-claude-workspace 1.1.80 → 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;
@@ -249,9 +358,18 @@ export function runClaude(opts, log, runOpts = {}) {
249
358
  if (AUTH_SERVER_RE.test(msg) && /auth/i.test(msg))
250
359
  isAuthServerError = true;
251
360
  }
361
+ let usageLimitKilled = false;
252
362
  function detectTextSignals(lower) {
253
- if (USAGE_LIMIT_RE.test(lower))
363
+ if (USAGE_LIMIT_RE.test(lower)) {
254
364
  isUsageLimit = true;
365
+ // Kill immediately on usage limit — Claude CLI retries internally and spams
366
+ // the message dozens of times. No point waiting for it to exit on its own.
367
+ if (!usageLimitKilled && !killed) {
368
+ usageLimitKilled = true;
369
+ log.warn('Usage limit detected — killing process to prevent spam.');
370
+ killChild('process_timeout');
371
+ }
372
+ }
255
373
  if (RATE_LIMIT_RE.test(lower))
256
374
  isRateLimit = true;
257
375
  if (AUTH_ERROR_RE.test(lower))
@@ -289,6 +407,11 @@ export function runClaude(opts, log, runOpts = {}) {
289
407
  lastOutputTime = Date.now();
290
408
  if (activityTimer)
291
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;
292
415
  const timeout = resultReceived ? opts.postResultTimeout : opts.activityTimeout;
293
416
  activityTimer = setTimeout(() => killChild('activity_timeout'), timeout);
294
417
  }
@@ -333,6 +456,12 @@ export function runClaude(opts, log, runOpts = {}) {
333
456
  // Extract session ID
334
457
  if (event.session_id && !sessionId)
335
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
+ }
336
465
  // Extract structured result
337
466
  if (event.type === 'result' && event.subtype !== 'tool_result') {
338
467
  resultReceived = true;
@@ -351,19 +480,34 @@ export function runClaude(opts, log, runOpts = {}) {
351
480
  if (event.type === 'rate_limit_event')
352
481
  isRateLimit = true;
353
482
  detectErrorSignals(event);
354
- // 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
355
484
  if (event.type === 'assistant') {
356
485
  const blocks = getContentBlocks(event);
357
486
  if (blocks) {
358
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)
359
491
  if (block.type === 'text' && block.text) {
360
492
  detectTextSignals(block.text.toLowerCase());
361
493
  }
362
494
  }
363
495
  }
364
496
  }
365
- // Display
366
- 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);
367
511
  if (formatted)
368
512
  process.stdout.write(formatted);
369
513
  }
@@ -405,7 +549,7 @@ export function runClaude(opts, log, runOpts = {}) {
405
549
  }
406
550
  catch { /* already dead — expected */ }
407
551
  }
408
- // Flush remaining buffer
552
+ // Flush remaining buffer (last line without trailing \n)
409
553
  if (buffer.trim()) {
410
554
  const cleaned = buffer.replace(/\r/g, '');
411
555
  const event = parseStreamEvent(cleaned);
@@ -416,6 +560,11 @@ export function runClaude(opts, log, runOpts = {}) {
416
560
  structuredResult = validateResult(event.structured_output) ?? structuredResult;
417
561
  resultReceived = true;
418
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);
419
568
  }
420
569
  else {
421
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).
@@ -7,17 +7,18 @@ import { createInterface } from 'node:readline';
7
7
  // Node.js runtime accepts `true` for shell but @types expects string
8
8
  const SHELL = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
9
9
  // ─── Usage limit reset time parser ───
10
- /** Parse "resets Xpm (UTC)" / "resets X:XX AM" / "resets 3am" from stderr text.
10
+ /** Parse "resets Xpm (UTC)" / "resets 12pm (Europe/Prague)" / "resets 3am" from stderr text.
11
11
  * Returns milliseconds to wait, or null if unparseable.
12
- * Accepts with or without (UTC) assumes UTC when timezone not specified. */
12
+ * Supports IANA timezones, UTC, and bare times (assumes UTC). */
13
13
  export function parseUsageLimitResetMs(text) {
14
- // Match patterns: "resets 6pm (UTC)", "resets 6:30pm", "resets 18:00 (UTC)", "resets 6 PM", "resets 3am"
15
- const match = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(UTC\))?/i);
14
+ // Match: "resets 6pm (UTC)", "resets 12pm (Europe/Prague)", "resets 6:30pm", "resets 18:00 (UTC)", "resets 3am"
15
+ const match = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(([^)]+)\))?/i);
16
16
  if (!match)
17
17
  return null;
18
18
  let hours = parseInt(match[1], 10);
19
19
  const minutes = match[2] ? parseInt(match[2], 10) : 0;
20
20
  const ampm = match[3]?.toLowerCase();
21
+ const tz = match[4]?.trim() || 'UTC';
21
22
  if (ampm === 'pm' && hours < 12)
22
23
  hours += 12;
23
24
  if (ampm === 'am' && hours === 12)
@@ -25,14 +26,48 @@ export function parseUsageLimitResetMs(text) {
25
26
  if (hours > 23 || minutes > 59)
26
27
  return null;
27
28
  const now = new Date();
28
- const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hours, minutes, 0, 0));
29
- // If reset time is in the past, it's tomorrow
30
- if (reset.getTime() <= now.getTime()) {
31
- reset.setUTCDate(reset.getUTCDate() + 1);
29
+ let resetMs;
30
+ try {
31
+ // Resolve timezone offset using Intl (works for UTC, IANA like "Europe/Prague", etc.)
32
+ // Strategy: find the UTC offset for the given timezone at "now", then compute reset time
33
+ const tzName = tz === 'UTC' ? 'UTC' : tz;
34
+ // Get current time in target timezone to determine today's date there
35
+ const dtf = new Intl.DateTimeFormat('en-CA', {
36
+ timeZone: tzName, year: 'numeric', month: '2-digit', day: '2-digit',
37
+ hour: '2-digit', minute: '2-digit', hour12: false,
38
+ });
39
+ const parts = dtf.formatToParts(now);
40
+ const pv = (t) => parts.find(p => p.type === t).value;
41
+ const tzDate = `${pv('year')}-${pv('month')}-${pv('day')}`;
42
+ // Construct the reset datetime as if it were UTC, then find the real offset
43
+ const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
44
+ const resetAsUtc = new Date(`${tzDate}T${timeStr}Z`);
45
+ // Find what hour resetAsUtc maps to in the target TZ
46
+ const inTz = new Intl.DateTimeFormat('en-CA', {
47
+ timeZone: tzName, hour: '2-digit', minute: '2-digit', hour12: false,
48
+ }).format(resetAsUtc);
49
+ const [tzH, tzM] = inTz.split(':').map(Number);
50
+ const offsetMin = (tzH * 60 + tzM) - (resetAsUtc.getUTCHours() * 60 + resetAsUtc.getUTCMinutes());
51
+ // Handle day wrap (e.g. tzH=23 but utcH=1 → offset should be negative, not +1320)
52
+ const correctedOffset = offsetMin > 720 ? offsetMin - 1440 : offsetMin < -720 ? offsetMin + 1440 : offsetMin;
53
+ // Reset time in UTC = local time - offset
54
+ const resetUtc = new Date(resetAsUtc.getTime() - correctedOffset * 60_000);
55
+ if (resetUtc.getTime() <= now.getTime()) {
56
+ resetUtc.setUTCDate(resetUtc.getUTCDate() + 1);
57
+ }
58
+ resetMs = resetUtc.getTime();
59
+ }
60
+ catch {
61
+ // Invalid timezone — fall back to treating as UTC
62
+ const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hours, minutes, 0, 0));
63
+ if (reset.getTime() <= now.getTime()) {
64
+ reset.setUTCDate(reset.getUTCDate() + 1);
65
+ }
66
+ resetMs = reset.getTime();
32
67
  }
33
- const waitMs = reset.getTime() - now.getTime();
34
- // Sanity: max 24 hours
35
- if (waitMs > 24 * 60 * 60_000)
68
+ const waitMs = resetMs - now.getTime();
69
+ // Sanity: max 24 hours, min 0
70
+ if (waitMs > 24 * 60 * 60_000 || waitMs < 0)
36
71
  return null;
37
72
  return waitMs;
38
73
  }
@@ -139,6 +139,22 @@ describe('parseUsageLimitResetMs', () => {
139
139
  expect(ms).toBeTypeOf('number');
140
140
  expect(ms).toBeGreaterThan(0);
141
141
  });
142
+ it('parses "resets 12pm (Europe/Prague)" IANA timezone', () => {
143
+ const ms = parseUsageLimitResetMs("You've hit your limit · resets 12pm (Europe/Prague)");
144
+ expect(ms).toBeTypeOf('number');
145
+ expect(ms).toBeGreaterThan(0);
146
+ expect(ms).toBeLessThanOrEqual(24 * 60 * 60_000);
147
+ });
148
+ it('parses "resets 3am (America/New_York)" IANA timezone', () => {
149
+ const ms = parseUsageLimitResetMs("resets 3am (America/New_York)");
150
+ expect(ms).toBeTypeOf('number');
151
+ expect(ms).toBeGreaterThan(0);
152
+ });
153
+ it('falls back to UTC for invalid timezone', () => {
154
+ const ms = parseUsageLimitResetMs("resets 6pm (Invalid/Zone)");
155
+ expect(ms).toBeTypeOf('number');
156
+ expect(ms).toBeGreaterThan(0);
157
+ });
142
158
  it('result is always positive (wraps to next day if time passed)', () => {
143
159
  // Use a time that's definitely in the past today (1am UTC if test runs after 1am)
144
160
  const ms = parseUsageLimitResetMs('resets 1am (UTC)');
@@ -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.80",
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": {