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.
- package/dist/scripts/lib/agent-tag.integration.spec.js +193 -0
- package/dist/scripts/lib/claude-runner.mjs +157 -8
- package/dist/scripts/lib/claude-runner.spec.js +203 -1
- package/dist/scripts/lib/utils.mjs +46 -11
- package/dist/scripts/lib/utils.spec.js +16 -0
- 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;
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
366
|
-
|
|
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
|
|
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
|
-
*
|
|
12
|
+
* Supports IANA timezones, UTC, and bare times (assumes UTC). */
|
|
13
13
|
export function parseUsageLimitResetMs(text) {
|
|
14
|
-
// Match
|
|
15
|
-
const match = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
reset
|
|
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 =
|
|
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'
|