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.
- package/dist/scripts/lib/agent-tag.integration.spec.js +193 -0
- package/dist/scripts/lib/claude-runner.mjs +147 -7
- package/dist/scripts/lib/claude-runner.spec.js +203 -1
- package/dist/template/.claude/agents/deployment-engineer.md +1 -18
- package/dist/template/.claude/agents/ui-engineer.md +1 -18
- package/dist/template/.claude/docker/Dockerfile +2 -4
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
-
|
|
375
|
-
|
|
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'
|