create-claude-workspace 1.1.120 → 1.1.122
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.
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// ─── Autonomous development loop ───
|
|
3
3
|
// Headless runner for Claude Code orchestrator using Agent SDK.
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { execSync } from 'node:child_process';
|
|
7
7
|
import { DEFAULTS } from './lib/types.mjs';
|
|
8
8
|
import { emptyCheckpoint, readCheckpoint, writeCheckpoint } from './lib/state.mjs';
|
|
9
9
|
import { pollForNewWork } from './lib/idle-poll.mjs';
|
|
10
|
+
import { Formatter } from './lib/formatter.mjs';
|
|
10
11
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
11
12
|
import { config as dotenvConfig } from '@dotenvx/dotenvx';
|
|
12
13
|
// ─── Args ───
|
|
@@ -164,16 +165,6 @@ Options:
|
|
|
164
165
|
`);
|
|
165
166
|
}
|
|
166
167
|
// ─── Helpers ───
|
|
167
|
-
let logPath = '';
|
|
168
|
-
function log(msg) {
|
|
169
|
-
const line = `[${new Date().toISOString()}] ${msg}`;
|
|
170
|
-
console.log(line);
|
|
171
|
-
if (logPath)
|
|
172
|
-
try {
|
|
173
|
-
appendFileSync(logPath, line + '\n');
|
|
174
|
-
}
|
|
175
|
-
catch { /* */ }
|
|
176
|
-
}
|
|
177
168
|
function sleep(ms, ref) {
|
|
178
169
|
return new Promise(r => {
|
|
179
170
|
const t = setTimeout(r, ms);
|
|
@@ -200,7 +191,6 @@ function notify(cmd, type, message, iteration) {
|
|
|
200
191
|
catch { /* */ }
|
|
201
192
|
}
|
|
202
193
|
function checkAuth() {
|
|
203
|
-
// Check if Claude Code has credentials (API key or OAuth session)
|
|
204
194
|
if (process.env.ANTHROPIC_API_KEY)
|
|
205
195
|
return true;
|
|
206
196
|
try {
|
|
@@ -215,10 +205,10 @@ function checkAuth() {
|
|
|
215
205
|
catch { /* */ }
|
|
216
206
|
return false;
|
|
217
207
|
}
|
|
218
|
-
function acquireLock(dir) {
|
|
208
|
+
function acquireLock(dir, fmt) {
|
|
219
209
|
const lockFile = resolve(dir, '.claude/autonomous.lock');
|
|
220
210
|
if (existsSync(lockFile)) {
|
|
221
|
-
|
|
211
|
+
fmt.warn('Lock file exists. Another instance running?');
|
|
222
212
|
return false;
|
|
223
213
|
}
|
|
224
214
|
try {
|
|
@@ -275,27 +265,33 @@ async function main() {
|
|
|
275
265
|
process.exit(0);
|
|
276
266
|
}
|
|
277
267
|
dotenvConfig({ path: resolve(opts.projectDir, '.env'), override: false, quiet: true });
|
|
278
|
-
logPath = resolve(opts.projectDir, opts.logFile);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
268
|
+
const logPath = resolve(opts.projectDir, opts.logFile);
|
|
269
|
+
const fmt = new Formatter(logPath);
|
|
270
|
+
fmt.banner();
|
|
271
|
+
fmt.info(`Project: ${opts.projectDir}`);
|
|
272
|
+
fmt.info(`Max iterations: ${opts.maxIterations} │ Max turns: ${opts.maxTurns}`);
|
|
282
273
|
// Auth check
|
|
283
274
|
if (!checkAuth()) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
275
|
+
fmt.error('Not authenticated. Either:');
|
|
276
|
+
fmt.error(' 1. Set ANTHROPIC_API_KEY env var (API billing)');
|
|
277
|
+
fmt.error(' 2. Run `claude login` to authenticate with Claude Max plan');
|
|
278
|
+
fmt.error(' For Docker: mount ~/.claude/.credentials.json into the container');
|
|
288
279
|
process.exit(1);
|
|
289
280
|
}
|
|
281
|
+
fmt.success('Authentication verified');
|
|
290
282
|
// Lock
|
|
291
|
-
if (!opts.noLock && !acquireLock(opts.projectDir)) {
|
|
283
|
+
if (!opts.noLock && !acquireLock(opts.projectDir, fmt)) {
|
|
292
284
|
process.exit(1);
|
|
293
285
|
}
|
|
294
286
|
// Git pull
|
|
295
|
-
if (!opts.noPull)
|
|
296
|
-
gitPull(opts.projectDir)
|
|
287
|
+
if (!opts.noPull) {
|
|
288
|
+
if (gitPull(opts.projectDir))
|
|
289
|
+
fmt.info('Git: pulled latest changes');
|
|
290
|
+
else
|
|
291
|
+
fmt.warn('Git: pull failed or not a git repo');
|
|
292
|
+
}
|
|
297
293
|
if (opts.dryRun) {
|
|
298
|
-
|
|
294
|
+
fmt.success('Dry run complete. All checks passed.');
|
|
299
295
|
if (!opts.noLock)
|
|
300
296
|
releaseLock(opts.projectDir);
|
|
301
297
|
process.exit(0);
|
|
@@ -305,7 +301,7 @@ async function main() {
|
|
|
305
301
|
const existing = readCheckpoint(opts.projectDir);
|
|
306
302
|
if (opts.resume && existing) {
|
|
307
303
|
checkpoint = existing;
|
|
308
|
-
|
|
304
|
+
fmt.info(`Resuming from iteration ${checkpoint.iteration}`);
|
|
309
305
|
}
|
|
310
306
|
else {
|
|
311
307
|
checkpoint = emptyCheckpoint();
|
|
@@ -317,12 +313,11 @@ async function main() {
|
|
|
317
313
|
if (!opts.noLock)
|
|
318
314
|
releaseLock(opts.projectDir);
|
|
319
315
|
writeCheckpoint(opts.projectDir, checkpoint);
|
|
320
|
-
|
|
316
|
+
fmt.warn('Interrupted. State saved.');
|
|
321
317
|
process.exit(0);
|
|
322
318
|
};
|
|
323
319
|
process.on('SIGINT', cleanup);
|
|
324
320
|
process.on('SIGTERM', cleanup);
|
|
325
|
-
log('---');
|
|
326
321
|
// ─── Loop ───
|
|
327
322
|
for (let i = checkpoint.iteration + 1; i <= opts.maxIterations && !stopping; i++) {
|
|
328
323
|
// Check completion → idle polling
|
|
@@ -331,23 +326,22 @@ async function main() {
|
|
|
331
326
|
if (!checkpoint.completionVerified) {
|
|
332
327
|
checkpoint.completionVerified = true;
|
|
333
328
|
writeCheckpoint(opts.projectDir, checkpoint);
|
|
334
|
-
// Fall through to run one more iteration (orchestrator verifies)
|
|
335
329
|
}
|
|
336
330
|
else {
|
|
337
|
-
|
|
331
|
+
fmt.info('Project complete. Entering idle polling...');
|
|
338
332
|
let foundWork = false;
|
|
339
333
|
const idleStart = Date.now();
|
|
340
334
|
while (!stopping) {
|
|
341
|
-
const poll = await pollForNewWork(opts.projectDir, { info:
|
|
335
|
+
const poll = await pollForNewWork(opts.projectDir, { info: (m) => fmt.info(m), warn: (m) => fmt.warn(m), error: (m) => fmt.error(m), debug: () => { } });
|
|
342
336
|
if (poll.hasWork) {
|
|
343
|
-
|
|
337
|
+
fmt.success(`New work detected (${poll.source}). Resuming...`);
|
|
344
338
|
checkpoint.completionVerified = false;
|
|
345
339
|
writeCheckpoint(opts.projectDir, checkpoint);
|
|
346
340
|
foundWork = true;
|
|
347
341
|
break;
|
|
348
342
|
}
|
|
349
343
|
if (opts.maxIdleTime > 0 && Date.now() - idleStart >= opts.maxIdleTime) {
|
|
350
|
-
|
|
344
|
+
fmt.info('Max idle time reached. Exiting.');
|
|
351
345
|
break;
|
|
352
346
|
}
|
|
353
347
|
await sleep(opts.idlePollInterval, stoppingRef);
|
|
@@ -357,13 +351,14 @@ async function main() {
|
|
|
357
351
|
continue;
|
|
358
352
|
}
|
|
359
353
|
}
|
|
360
|
-
|
|
354
|
+
fmt.iterationStart(i, opts.maxIterations);
|
|
355
|
+
fmt.resetAgentStack();
|
|
361
356
|
// Run Claude via Agent SDK
|
|
362
357
|
try {
|
|
363
358
|
const resumeOpts = {};
|
|
364
359
|
if (opts.resumeSession) {
|
|
365
360
|
resumeOpts.resume = opts.resumeSession;
|
|
366
|
-
opts.resumeSession = null;
|
|
361
|
+
opts.resumeSession = null;
|
|
367
362
|
}
|
|
368
363
|
else if (opts.resume && checkpoint.lastSessionId) {
|
|
369
364
|
resumeOpts.continue = true;
|
|
@@ -380,17 +375,17 @@ async function main() {
|
|
|
380
375
|
...resumeOpts,
|
|
381
376
|
},
|
|
382
377
|
})) {
|
|
378
|
+
fmt.handleMessage(message);
|
|
383
379
|
if (message.type === 'result') {
|
|
384
|
-
|
|
385
|
-
checkpoint.lastSessionId = result.session_id ?? null;
|
|
386
|
-
log(`Result: ${JSON.stringify(result).slice(0, 300)}`);
|
|
380
|
+
checkpoint.lastSessionId = fmt.getSessionId(message);
|
|
387
381
|
}
|
|
388
382
|
}
|
|
389
383
|
checkpoint.stats.iterations++;
|
|
384
|
+
fmt.iterationEnd();
|
|
390
385
|
}
|
|
391
386
|
catch (err) {
|
|
392
|
-
|
|
393
|
-
|
|
387
|
+
fmt.error(`${err.message}`);
|
|
388
|
+
fmt.warn(`Cooling down ${formatDuration(opts.cooldown)}...`);
|
|
394
389
|
await sleep(opts.cooldown, stoppingRef);
|
|
395
390
|
if (stopping)
|
|
396
391
|
break;
|
|
@@ -400,7 +395,7 @@ async function main() {
|
|
|
400
395
|
checkpoint.iteration = i;
|
|
401
396
|
writeCheckpoint(opts.projectDir, checkpoint);
|
|
402
397
|
if (i < opts.maxIterations && !stopping) {
|
|
403
|
-
|
|
398
|
+
fmt.info(`Next iteration in ${formatDuration(opts.delay)}...`);
|
|
404
399
|
await sleep(opts.delay, stoppingRef);
|
|
405
400
|
}
|
|
406
401
|
}
|
|
@@ -408,8 +403,7 @@ async function main() {
|
|
|
408
403
|
if (!opts.noLock)
|
|
409
404
|
releaseLock(opts.projectDir);
|
|
410
405
|
writeCheckpoint(opts.projectDir, checkpoint);
|
|
411
|
-
|
|
412
|
-
log('Autonomous loop ended.');
|
|
406
|
+
fmt.success(`Loop ended after ${checkpoint.stats.iterations} iterations.`);
|
|
413
407
|
notify(opts.notifyCommand, 'completed', 'Loop ended', checkpoint.iteration);
|
|
414
408
|
}
|
|
415
409
|
// ─── Export for CLI integration ───
|
|
@@ -329,7 +329,9 @@ async function main() {
|
|
|
329
329
|
}
|
|
330
330
|
// 2. Build
|
|
331
331
|
step('2/4 Building container image...');
|
|
332
|
-
|
|
332
|
+
// Always use --pull to pick up base image updates; --no-cache only on explicit --rebuild
|
|
333
|
+
const buildArgs = opts.rebuild ? ['build', '--no-cache', '--pull'] : ['build', '--pull', '-q'];
|
|
334
|
+
const buildResult = compose(buildArgs);
|
|
333
335
|
if (buildResult.status !== 0) {
|
|
334
336
|
error('Docker image build failed. Fix the errors above and re-run.');
|
|
335
337
|
process.exit(1);
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// ─── Beautiful terminal output for autonomous loop ───
|
|
2
|
+
import { appendFileSync } from 'node:fs';
|
|
3
|
+
// ─── ANSI colors ───
|
|
4
|
+
const c = {
|
|
5
|
+
reset: '\x1b[0m',
|
|
6
|
+
bold: '\x1b[1m',
|
|
7
|
+
dim: '\x1b[2m',
|
|
8
|
+
italic: '\x1b[3m',
|
|
9
|
+
// Foreground
|
|
10
|
+
black: '\x1b[30m',
|
|
11
|
+
red: '\x1b[31m',
|
|
12
|
+
green: '\x1b[32m',
|
|
13
|
+
yellow: '\x1b[33m',
|
|
14
|
+
blue: '\x1b[34m',
|
|
15
|
+
magenta: '\x1b[35m',
|
|
16
|
+
cyan: '\x1b[36m',
|
|
17
|
+
white: '\x1b[37m',
|
|
18
|
+
// Bright foreground
|
|
19
|
+
gray: '\x1b[90m',
|
|
20
|
+
brightRed: '\x1b[91m',
|
|
21
|
+
brightGreen: '\x1b[92m',
|
|
22
|
+
brightYellow: '\x1b[93m',
|
|
23
|
+
brightBlue: '\x1b[94m',
|
|
24
|
+
brightMagenta: '\x1b[95m',
|
|
25
|
+
brightCyan: '\x1b[96m',
|
|
26
|
+
// Background
|
|
27
|
+
bgBlue: '\x1b[44m',
|
|
28
|
+
bgMagenta: '\x1b[45m',
|
|
29
|
+
bgCyan: '\x1b[46m',
|
|
30
|
+
bgGray: '\x1b[100m',
|
|
31
|
+
};
|
|
32
|
+
// ─── Agent colors (rotating) ───
|
|
33
|
+
const AGENT_COLORS = [c.brightCyan, c.brightMagenta, c.brightGreen, c.brightYellow, c.brightBlue, c.brightRed];
|
|
34
|
+
const agentColorMap = new Map();
|
|
35
|
+
let colorIndex = 0;
|
|
36
|
+
function agentColor(name) {
|
|
37
|
+
if (!agentColorMap.has(name)) {
|
|
38
|
+
agentColorMap.set(name, AGENT_COLORS[colorIndex % AGENT_COLORS.length]);
|
|
39
|
+
colorIndex++;
|
|
40
|
+
}
|
|
41
|
+
return agentColorMap.get(name);
|
|
42
|
+
}
|
|
43
|
+
// ─── Tool icons ───
|
|
44
|
+
const TOOL_ICONS = {
|
|
45
|
+
Bash: '⚡',
|
|
46
|
+
Read: '📖',
|
|
47
|
+
Write: '✏️',
|
|
48
|
+
Edit: '🔧',
|
|
49
|
+
Glob: '🔍',
|
|
50
|
+
Grep: '🔎',
|
|
51
|
+
Agent: '🤖',
|
|
52
|
+
TodoRead: '📋',
|
|
53
|
+
TodoWrite: '📝',
|
|
54
|
+
WebSearch: '🌐',
|
|
55
|
+
WebFetch: '🌐',
|
|
56
|
+
AskUserQuestion: '❓',
|
|
57
|
+
};
|
|
58
|
+
function toolIcon(name) {
|
|
59
|
+
return TOOL_ICONS[name] || '⚙️';
|
|
60
|
+
}
|
|
61
|
+
// ─── Time formatting ───
|
|
62
|
+
function ts() {
|
|
63
|
+
return new Date().toLocaleTimeString('en-GB', { hour12: false });
|
|
64
|
+
}
|
|
65
|
+
function duration(ms) {
|
|
66
|
+
if (ms < 1000)
|
|
67
|
+
return `${ms}ms`;
|
|
68
|
+
if (ms < 60_000)
|
|
69
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
70
|
+
if (ms < 3600_000)
|
|
71
|
+
return `${(ms / 60_000).toFixed(1)}m`;
|
|
72
|
+
return `${(ms / 3600_000).toFixed(1)}h`;
|
|
73
|
+
}
|
|
74
|
+
// ─── Formatting helpers ───
|
|
75
|
+
function truncate(s, max) {
|
|
76
|
+
const clean = s.replace(/\n/g, ' ').trim();
|
|
77
|
+
return clean.length > max ? clean.slice(0, max) + '…' : clean;
|
|
78
|
+
}
|
|
79
|
+
function formatPath(path) {
|
|
80
|
+
// Shorten long paths: /project/libs/shared/ui/src/lib/component.ts → .../ui/src/lib/component.ts
|
|
81
|
+
const short = path.replace(/^\/project\//, '');
|
|
82
|
+
return `${c.cyan}${short}${c.reset}`;
|
|
83
|
+
}
|
|
84
|
+
function formatCommand(cmd) {
|
|
85
|
+
const short = truncate(cmd, 80);
|
|
86
|
+
return `${c.yellow}${short}${c.reset}`;
|
|
87
|
+
}
|
|
88
|
+
// ─── Main formatter class ───
|
|
89
|
+
export class Formatter {
|
|
90
|
+
logFile;
|
|
91
|
+
iterStartTime = 0;
|
|
92
|
+
totalTokens = { input: 0, output: 0 };
|
|
93
|
+
toolCalls = 0;
|
|
94
|
+
agentStack = [];
|
|
95
|
+
constructor(logFile) {
|
|
96
|
+
this.logFile = logFile;
|
|
97
|
+
}
|
|
98
|
+
// ── Raw log (file only, no formatting) ──
|
|
99
|
+
fileLog(msg) {
|
|
100
|
+
if (!this.logFile)
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
appendFileSync(this.logFile, `[${new Date().toISOString()}] ${msg}\n`);
|
|
104
|
+
}
|
|
105
|
+
catch { /* */ }
|
|
106
|
+
}
|
|
107
|
+
// ── Print to console + file ──
|
|
108
|
+
print(formatted, raw) {
|
|
109
|
+
console.log(formatted);
|
|
110
|
+
this.fileLog(raw || formatted.replace(/\x1b\[[0-9;]*m/g, ''));
|
|
111
|
+
}
|
|
112
|
+
// ─── Public API ───
|
|
113
|
+
banner() {
|
|
114
|
+
this.print('');
|
|
115
|
+
this.print(`${c.bold}${c.cyan} ╔══════════════════════════════════════════════╗${c.reset}`);
|
|
116
|
+
this.print(`${c.bold}${c.cyan} ║ ${c.white}Claude Starter Kit — Autonomous Loop${c.cyan} ║${c.reset}`);
|
|
117
|
+
this.print(`${c.bold}${c.cyan} ╚══════════════════════════════════════════════╝${c.reset}`);
|
|
118
|
+
this.print('');
|
|
119
|
+
}
|
|
120
|
+
info(msg) {
|
|
121
|
+
this.print(` ${c.gray}${ts()}${c.reset} ${c.blue}ℹ${c.reset} ${msg}`);
|
|
122
|
+
}
|
|
123
|
+
warn(msg) {
|
|
124
|
+
this.print(` ${c.gray}${ts()}${c.reset} ${c.yellow}⚠${c.reset} ${c.yellow}${msg}${c.reset}`);
|
|
125
|
+
}
|
|
126
|
+
error(msg) {
|
|
127
|
+
this.print(` ${c.gray}${ts()}${c.reset} ${c.red}✗${c.reset} ${c.red}${msg}${c.reset}`);
|
|
128
|
+
}
|
|
129
|
+
success(msg) {
|
|
130
|
+
this.print(` ${c.gray}${ts()}${c.reset} ${c.green}✓${c.reset} ${c.green}${msg}${c.reset}`);
|
|
131
|
+
}
|
|
132
|
+
iterationStart(i, max) {
|
|
133
|
+
this.iterStartTime = Date.now();
|
|
134
|
+
this.toolCalls = 0;
|
|
135
|
+
const pct = Math.round((i / max) * 100);
|
|
136
|
+
const bar = progressBar(pct, 20);
|
|
137
|
+
this.print('');
|
|
138
|
+
this.print(` ${c.bold}${c.white}─── Iteration ${i}/${max} ${c.gray}${bar} ${pct}%${c.reset}`);
|
|
139
|
+
this.print('');
|
|
140
|
+
}
|
|
141
|
+
iterationEnd() {
|
|
142
|
+
const elapsed = Date.now() - this.iterStartTime;
|
|
143
|
+
const tokens = this.totalTokens.input + this.totalTokens.output;
|
|
144
|
+
this.print('');
|
|
145
|
+
this.print(` ${c.gray}──── ${duration(elapsed)} │ ${this.toolCalls} tools │ ${formatTokens(tokens)} tokens ────${c.reset}`);
|
|
146
|
+
}
|
|
147
|
+
// ─── SDK Message handling ───
|
|
148
|
+
handleMessage(message) {
|
|
149
|
+
const msg = message;
|
|
150
|
+
switch (message.type) {
|
|
151
|
+
case 'assistant':
|
|
152
|
+
this.handleAssistant(msg);
|
|
153
|
+
break;
|
|
154
|
+
case 'user':
|
|
155
|
+
this.handleToolResult(msg);
|
|
156
|
+
break;
|
|
157
|
+
case 'system':
|
|
158
|
+
this.handleSystem(msg);
|
|
159
|
+
break;
|
|
160
|
+
case 'result':
|
|
161
|
+
this.handleResult(msg);
|
|
162
|
+
break;
|
|
163
|
+
default:
|
|
164
|
+
// Silently skip unknown types (stream_event, etc.)
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
handleAssistant(msg) {
|
|
169
|
+
const content = msg.message?.content;
|
|
170
|
+
if (!Array.isArray(content))
|
|
171
|
+
return;
|
|
172
|
+
for (const block of content) {
|
|
173
|
+
if (block.type === 'text' && block.text?.trim()) {
|
|
174
|
+
const text = truncate(block.text, 300);
|
|
175
|
+
const indent = this.indent();
|
|
176
|
+
this.print(`${indent}${c.white}${text}${c.reset}`, `TEXT: ${text}`);
|
|
177
|
+
}
|
|
178
|
+
if (block.type === 'tool_use') {
|
|
179
|
+
this.handleToolUse(block);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Track tokens from usage
|
|
183
|
+
if (msg.message?.usage) {
|
|
184
|
+
this.totalTokens.input += msg.message.usage.input_tokens || 0;
|
|
185
|
+
this.totalTokens.output += msg.message.usage.output_tokens || 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
handleToolUse(block) {
|
|
189
|
+
this.toolCalls++;
|
|
190
|
+
const name = block.name || 'unknown';
|
|
191
|
+
const icon = toolIcon(name);
|
|
192
|
+
const input = block.input || {};
|
|
193
|
+
const indent = this.indent();
|
|
194
|
+
if (name === 'Agent') {
|
|
195
|
+
// Agent delegation — show agent name and description
|
|
196
|
+
const agentType = input.subagent_type || input.type || 'agent';
|
|
197
|
+
const desc = truncate(input.description || input.prompt || '', 60);
|
|
198
|
+
const col = agentColor(agentType);
|
|
199
|
+
this.agentStack.push(agentType);
|
|
200
|
+
this.print(`${indent}${icon} ${col}${c.bold}${agentType}${c.reset} ${c.gray}${desc}${c.reset}`, `AGENT: ${agentType} — ${desc}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Format based on tool type
|
|
204
|
+
let detail = '';
|
|
205
|
+
if (name === 'Bash') {
|
|
206
|
+
detail = formatCommand(input.command || '');
|
|
207
|
+
}
|
|
208
|
+
else if (name === 'Read') {
|
|
209
|
+
detail = formatPath(input.file_path || '');
|
|
210
|
+
}
|
|
211
|
+
else if (name === 'Write' || name === 'Edit') {
|
|
212
|
+
detail = formatPath(input.file_path || '');
|
|
213
|
+
}
|
|
214
|
+
else if (name === 'Glob') {
|
|
215
|
+
detail = `${c.cyan}${input.pattern || ''}${c.reset}`;
|
|
216
|
+
}
|
|
217
|
+
else if (name === 'Grep') {
|
|
218
|
+
detail = `${c.cyan}/${input.pattern || ''}/`;
|
|
219
|
+
if (input.path)
|
|
220
|
+
detail += ` ${c.gray}in ${input.path}`;
|
|
221
|
+
detail += c.reset;
|
|
222
|
+
}
|
|
223
|
+
else if (name === 'TodoWrite') {
|
|
224
|
+
detail = `${c.gray}updating task list${c.reset}`;
|
|
225
|
+
}
|
|
226
|
+
else if (name === 'TodoRead') {
|
|
227
|
+
detail = `${c.gray}reading task list${c.reset}`;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const summary = truncate(JSON.stringify(input), 80);
|
|
231
|
+
detail = `${c.gray}${summary}${c.reset}`;
|
|
232
|
+
}
|
|
233
|
+
this.print(`${indent}${c.gray}${ts()}${c.reset} ${icon} ${c.bold}${name}${c.reset} ${detail}`, `TOOL: ${name} ${JSON.stringify(input).slice(0, 200)}`);
|
|
234
|
+
}
|
|
235
|
+
handleToolResult(msg) {
|
|
236
|
+
const content = msg.message?.content;
|
|
237
|
+
if (!Array.isArray(content))
|
|
238
|
+
return;
|
|
239
|
+
for (const block of content) {
|
|
240
|
+
if (block.type !== 'tool_result')
|
|
241
|
+
continue;
|
|
242
|
+
const output = String(block.content || '').trim();
|
|
243
|
+
const isError = block.is_error === true;
|
|
244
|
+
const indent = this.indent();
|
|
245
|
+
if (isError) {
|
|
246
|
+
const short = truncate(output, 120);
|
|
247
|
+
this.print(`${indent} ${c.red}✗ ${short}${c.reset}`, `ERROR: ${short}`);
|
|
248
|
+
}
|
|
249
|
+
else if (output && output.length > 0) {
|
|
250
|
+
// Show short results inline, skip very long ones
|
|
251
|
+
if (output.length < 200) {
|
|
252
|
+
const short = truncate(output, 120);
|
|
253
|
+
this.print(`${indent} ${c.green}✓${c.reset} ${c.gray}${short}${c.reset}`, `RESULT: ${short}`);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
const lines = output.split('\n').length;
|
|
257
|
+
this.print(`${indent} ${c.green}✓${c.reset} ${c.gray}${lines} lines${c.reset}`, `RESULT: (${lines} lines)`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
handleSystem(msg) {
|
|
263
|
+
if (msg.subtype === 'task_progress') {
|
|
264
|
+
const desc = msg.description || '';
|
|
265
|
+
if (desc) {
|
|
266
|
+
const indent = this.indent();
|
|
267
|
+
this.print(`${indent} ${c.dim}${truncate(desc, 80)}${c.reset}`, `PROGRESS: ${desc}`);
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Other system messages
|
|
272
|
+
const text = msg.message || JSON.stringify(msg).slice(0, 150);
|
|
273
|
+
this.info(`${c.dim}${truncate(String(text), 120)}${c.reset}`);
|
|
274
|
+
}
|
|
275
|
+
handleResult(msg) {
|
|
276
|
+
const sessionId = msg.session_id;
|
|
277
|
+
if (sessionId) {
|
|
278
|
+
this.fileLog(`SESSION: ${sessionId}`);
|
|
279
|
+
}
|
|
280
|
+
this.success('Iteration complete');
|
|
281
|
+
}
|
|
282
|
+
// ─── Helpers ───
|
|
283
|
+
indent() {
|
|
284
|
+
const depth = this.agentStack.length;
|
|
285
|
+
if (depth === 0)
|
|
286
|
+
return ' ';
|
|
287
|
+
return ' ' + '│ '.repeat(depth);
|
|
288
|
+
}
|
|
289
|
+
// Reset agent stack between iterations
|
|
290
|
+
resetAgentStack() {
|
|
291
|
+
this.agentStack = [];
|
|
292
|
+
}
|
|
293
|
+
getSessionId(msg) {
|
|
294
|
+
return msg.session_id ?? null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// ─── Utilities ───
|
|
298
|
+
function progressBar(pct, width) {
|
|
299
|
+
const filled = Math.round((pct / 100) * width);
|
|
300
|
+
const empty = width - filled;
|
|
301
|
+
return `${c.green}${'█'.repeat(filled)}${c.gray}${'░'.repeat(empty)}${c.reset}`;
|
|
302
|
+
}
|
|
303
|
+
function formatTokens(n) {
|
|
304
|
+
if (n < 1000)
|
|
305
|
+
return String(n);
|
|
306
|
+
if (n < 1_000_000)
|
|
307
|
+
return `${(n / 1000).toFixed(1)}K`;
|
|
308
|
+
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
309
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, rmSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { Formatter } from './formatter.mjs';
|
|
6
|
+
const TEST_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '__fmt_test_tmp__');
|
|
7
|
+
const LOG_FILE = resolve(TEST_DIR, 'test.log');
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
10
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
function readLog() {
|
|
17
|
+
try {
|
|
18
|
+
return readFileSync(LOG_FILE, 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
describe('Formatter', () => {
|
|
25
|
+
it('creates instance with log file', () => {
|
|
26
|
+
const fmt = new Formatter(LOG_FILE);
|
|
27
|
+
expect(fmt).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
it('banner outputs to console', () => {
|
|
30
|
+
const fmt = new Formatter(LOG_FILE);
|
|
31
|
+
fmt.banner();
|
|
32
|
+
expect(console.log).toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
it('info writes to console and file', () => {
|
|
35
|
+
const fmt = new Formatter(LOG_FILE);
|
|
36
|
+
fmt.info('test message');
|
|
37
|
+
expect(console.log).toHaveBeenCalled();
|
|
38
|
+
const log = readLog();
|
|
39
|
+
expect(log).toContain('test message');
|
|
40
|
+
});
|
|
41
|
+
it('warn writes with warning indicator', () => {
|
|
42
|
+
const fmt = new Formatter(LOG_FILE);
|
|
43
|
+
fmt.warn('warning msg');
|
|
44
|
+
const log = readLog();
|
|
45
|
+
expect(log).toContain('warning msg');
|
|
46
|
+
});
|
|
47
|
+
it('error writes with error indicator', () => {
|
|
48
|
+
const fmt = new Formatter(LOG_FILE);
|
|
49
|
+
fmt.error('error msg');
|
|
50
|
+
const log = readLog();
|
|
51
|
+
expect(log).toContain('error msg');
|
|
52
|
+
});
|
|
53
|
+
it('success writes with success indicator', () => {
|
|
54
|
+
const fmt = new Formatter(LOG_FILE);
|
|
55
|
+
fmt.success('done');
|
|
56
|
+
const log = readLog();
|
|
57
|
+
expect(log).toContain('done');
|
|
58
|
+
});
|
|
59
|
+
it('iterationStart writes iteration header', () => {
|
|
60
|
+
const fmt = new Formatter(LOG_FILE);
|
|
61
|
+
fmt.iterationStart(3, 50);
|
|
62
|
+
const calls = console.log.mock.calls.map((c) => c[0]);
|
|
63
|
+
const joined = calls.join('\n');
|
|
64
|
+
expect(joined).toContain('3');
|
|
65
|
+
expect(joined).toContain('50');
|
|
66
|
+
});
|
|
67
|
+
it('iterationEnd writes summary', () => {
|
|
68
|
+
const fmt = new Formatter(LOG_FILE);
|
|
69
|
+
fmt.iterationStart(1, 10);
|
|
70
|
+
fmt.iterationEnd();
|
|
71
|
+
const calls = console.log.mock.calls.map((c) => c[0]);
|
|
72
|
+
const joined = calls.join('\n');
|
|
73
|
+
expect(joined).toContain('tools');
|
|
74
|
+
expect(joined).toContain('tokens');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('Formatter.handleMessage', () => {
|
|
78
|
+
it('handles assistant text messages', () => {
|
|
79
|
+
const fmt = new Formatter(LOG_FILE);
|
|
80
|
+
fmt.handleMessage({
|
|
81
|
+
type: 'assistant',
|
|
82
|
+
message: { content: [{ type: 'text', text: 'Hello from Claude' }] },
|
|
83
|
+
});
|
|
84
|
+
const log = readLog();
|
|
85
|
+
expect(log).toContain('Hello from Claude');
|
|
86
|
+
});
|
|
87
|
+
it('handles tool_use Bash', () => {
|
|
88
|
+
const fmt = new Formatter(LOG_FILE);
|
|
89
|
+
fmt.handleMessage({
|
|
90
|
+
type: 'assistant',
|
|
91
|
+
message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls -la' } }] },
|
|
92
|
+
});
|
|
93
|
+
const log = readLog();
|
|
94
|
+
expect(log).toContain('Bash');
|
|
95
|
+
expect(log).toContain('ls -la');
|
|
96
|
+
});
|
|
97
|
+
it('handles tool_use Read', () => {
|
|
98
|
+
const fmt = new Formatter(LOG_FILE);
|
|
99
|
+
fmt.handleMessage({
|
|
100
|
+
type: 'assistant',
|
|
101
|
+
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/project/src/index.ts' } }] },
|
|
102
|
+
});
|
|
103
|
+
const log = readLog();
|
|
104
|
+
expect(log).toContain('Read');
|
|
105
|
+
expect(log).toContain('index.ts');
|
|
106
|
+
});
|
|
107
|
+
it('handles tool_use Write', () => {
|
|
108
|
+
const fmt = new Formatter(LOG_FILE);
|
|
109
|
+
fmt.handleMessage({
|
|
110
|
+
type: 'assistant',
|
|
111
|
+
message: { content: [{ type: 'tool_use', name: 'Write', input: { file_path: '/project/new-file.ts' } }] },
|
|
112
|
+
});
|
|
113
|
+
const log = readLog();
|
|
114
|
+
expect(log).toContain('Write');
|
|
115
|
+
expect(log).toContain('new-file.ts');
|
|
116
|
+
});
|
|
117
|
+
it('handles tool_use Edit', () => {
|
|
118
|
+
const fmt = new Formatter(LOG_FILE);
|
|
119
|
+
fmt.handleMessage({
|
|
120
|
+
type: 'assistant',
|
|
121
|
+
message: { content: [{ type: 'tool_use', name: 'Edit', input: { file_path: '/project/file.ts' } }] },
|
|
122
|
+
});
|
|
123
|
+
const log = readLog();
|
|
124
|
+
expect(log).toContain('Edit');
|
|
125
|
+
});
|
|
126
|
+
it('handles tool_use Glob', () => {
|
|
127
|
+
const fmt = new Formatter(LOG_FILE);
|
|
128
|
+
fmt.handleMessage({
|
|
129
|
+
type: 'assistant',
|
|
130
|
+
message: { content: [{ type: 'tool_use', name: 'Glob', input: { pattern: '**/*.ts' } }] },
|
|
131
|
+
});
|
|
132
|
+
const log = readLog();
|
|
133
|
+
expect(log).toContain('Glob');
|
|
134
|
+
expect(log).toContain('**/*.ts');
|
|
135
|
+
});
|
|
136
|
+
it('handles tool_use Grep', () => {
|
|
137
|
+
const fmt = new Formatter(LOG_FILE);
|
|
138
|
+
fmt.handleMessage({
|
|
139
|
+
type: 'assistant',
|
|
140
|
+
message: { content: [{ type: 'tool_use', name: 'Grep', input: { pattern: 'TODO', path: 'src/' } }] },
|
|
141
|
+
});
|
|
142
|
+
const log = readLog();
|
|
143
|
+
expect(log).toContain('Grep');
|
|
144
|
+
expect(log).toContain('TODO');
|
|
145
|
+
});
|
|
146
|
+
it('handles Agent delegation', () => {
|
|
147
|
+
const fmt = new Formatter(LOG_FILE);
|
|
148
|
+
fmt.handleMessage({
|
|
149
|
+
type: 'assistant',
|
|
150
|
+
message: { content: [{ type: 'tool_use', name: 'Agent', input: { subagent_type: 'backend-ts-architect', description: 'Plan API design' } }] },
|
|
151
|
+
});
|
|
152
|
+
const log = readLog();
|
|
153
|
+
expect(log).toContain('backend-ts-architect');
|
|
154
|
+
expect(log).toContain('Plan API design');
|
|
155
|
+
});
|
|
156
|
+
it('handles tool results (short)', () => {
|
|
157
|
+
const fmt = new Formatter(LOG_FILE);
|
|
158
|
+
fmt.handleMessage({
|
|
159
|
+
type: 'user',
|
|
160
|
+
message: { content: [{ type: 'tool_result', content: 'file1.ts\nfile2.ts', is_error: false }] },
|
|
161
|
+
});
|
|
162
|
+
const log = readLog();
|
|
163
|
+
expect(log).toContain('file1.ts');
|
|
164
|
+
});
|
|
165
|
+
it('handles tool results (long → line count)', () => {
|
|
166
|
+
const fmt = new Formatter(LOG_FILE);
|
|
167
|
+
const longOutput = Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n');
|
|
168
|
+
fmt.handleMessage({
|
|
169
|
+
type: 'user',
|
|
170
|
+
message: { content: [{ type: 'tool_result', content: longOutput, is_error: false }] },
|
|
171
|
+
});
|
|
172
|
+
const log = readLog();
|
|
173
|
+
expect(log).toContain('50 lines');
|
|
174
|
+
});
|
|
175
|
+
it('handles tool error results', () => {
|
|
176
|
+
const fmt = new Formatter(LOG_FILE);
|
|
177
|
+
fmt.handleMessage({
|
|
178
|
+
type: 'user',
|
|
179
|
+
message: { content: [{ type: 'tool_result', content: 'Permission denied', is_error: true }] },
|
|
180
|
+
});
|
|
181
|
+
const log = readLog();
|
|
182
|
+
expect(log).toContain('Permission denied');
|
|
183
|
+
});
|
|
184
|
+
it('handles system task_progress', () => {
|
|
185
|
+
const fmt = new Formatter(LOG_FILE);
|
|
186
|
+
fmt.handleMessage({
|
|
187
|
+
type: 'system',
|
|
188
|
+
subtype: 'task_progress',
|
|
189
|
+
description: 'Reading package.json',
|
|
190
|
+
});
|
|
191
|
+
const log = readLog();
|
|
192
|
+
expect(log).toContain('Reading package.json');
|
|
193
|
+
});
|
|
194
|
+
it('handles result message', () => {
|
|
195
|
+
const fmt = new Formatter(LOG_FILE);
|
|
196
|
+
fmt.handleMessage({
|
|
197
|
+
type: 'result',
|
|
198
|
+
session_id: 'abc-123',
|
|
199
|
+
});
|
|
200
|
+
const log = readLog();
|
|
201
|
+
expect(log).toContain('SESSION: abc-123');
|
|
202
|
+
});
|
|
203
|
+
it('getSessionId extracts session_id', () => {
|
|
204
|
+
const fmt = new Formatter(LOG_FILE);
|
|
205
|
+
expect(fmt.getSessionId({ session_id: 'test-id' })).toBe('test-id');
|
|
206
|
+
expect(fmt.getSessionId({})).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
it('ignores unknown message types silently', () => {
|
|
209
|
+
const fmt = new Formatter(LOG_FILE);
|
|
210
|
+
fmt.handleMessage({ type: 'stream_event', data: 'something' });
|
|
211
|
+
const log = readLog();
|
|
212
|
+
expect(log).toBe('');
|
|
213
|
+
});
|
|
214
|
+
it('handles multiple content blocks in one message', () => {
|
|
215
|
+
const fmt = new Formatter(LOG_FILE);
|
|
216
|
+
fmt.handleMessage({
|
|
217
|
+
type: 'assistant',
|
|
218
|
+
message: {
|
|
219
|
+
content: [
|
|
220
|
+
{ type: 'text', text: 'Planning the implementation' },
|
|
221
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: '/project/src/main.ts' } },
|
|
222
|
+
{ type: 'tool_use', name: 'Bash', input: { command: 'npm test' } },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
const log = readLog();
|
|
227
|
+
expect(log).toContain('Planning the implementation');
|
|
228
|
+
expect(log).toContain('Read');
|
|
229
|
+
expect(log).toContain('Bash');
|
|
230
|
+
expect(log).toContain('npm test');
|
|
231
|
+
});
|
|
232
|
+
it('tracks tokens from usage', () => {
|
|
233
|
+
const fmt = new Formatter(LOG_FILE);
|
|
234
|
+
fmt.iterationStart(1, 10);
|
|
235
|
+
fmt.handleMessage({
|
|
236
|
+
type: 'assistant',
|
|
237
|
+
message: {
|
|
238
|
+
content: [{ type: 'text', text: 'hi' }],
|
|
239
|
+
usage: { input_tokens: 1000, output_tokens: 500 },
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
fmt.iterationEnd();
|
|
243
|
+
const calls = console.log.mock.calls.map((c) => c[0]);
|
|
244
|
+
const summary = calls.join('\n');
|
|
245
|
+
expect(summary).toContain('1.5K');
|
|
246
|
+
});
|
|
247
|
+
it('increments tool call counter', () => {
|
|
248
|
+
const fmt = new Formatter(LOG_FILE);
|
|
249
|
+
fmt.iterationStart(1, 10);
|
|
250
|
+
fmt.handleMessage({
|
|
251
|
+
type: 'assistant',
|
|
252
|
+
message: { content: [
|
|
253
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: 'a.ts' } },
|
|
254
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: 'b.ts' } },
|
|
255
|
+
] },
|
|
256
|
+
});
|
|
257
|
+
fmt.iterationEnd();
|
|
258
|
+
const calls = console.log.mock.calls.map((c) => c[0]);
|
|
259
|
+
const summary = calls.join('\n');
|
|
260
|
+
expect(summary).toContain('2 tools');
|
|
261
|
+
});
|
|
262
|
+
});
|