tlc-claude-code 1.4.0 → 1.4.2

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.
Files changed (46) hide show
  1. package/dashboard/dist/App.js +229 -35
  2. package/dashboard/dist/components/AgentRegistryPane.d.ts +35 -0
  3. package/dashboard/dist/components/AgentRegistryPane.js +89 -0
  4. package/dashboard/dist/components/AgentRegistryPane.test.d.ts +1 -0
  5. package/dashboard/dist/components/AgentRegistryPane.test.js +200 -0
  6. package/dashboard/dist/components/RouterPane.d.ts +5 -0
  7. package/dashboard/dist/components/RouterPane.js +65 -0
  8. package/dashboard/dist/components/RouterPane.test.d.ts +1 -0
  9. package/dashboard/dist/components/RouterPane.test.js +176 -0
  10. package/package.json +5 -2
  11. package/server/index.js +178 -0
  12. package/server/lib/agent-cleanup.js +177 -0
  13. package/server/lib/agent-cleanup.test.js +359 -0
  14. package/server/lib/agent-hooks.js +126 -0
  15. package/server/lib/agent-hooks.test.js +303 -0
  16. package/server/lib/agent-metadata.js +179 -0
  17. package/server/lib/agent-metadata.test.js +383 -0
  18. package/server/lib/agent-persistence.js +191 -0
  19. package/server/lib/agent-persistence.test.js +475 -0
  20. package/server/lib/agent-registry-command.js +340 -0
  21. package/server/lib/agent-registry-command.test.js +334 -0
  22. package/server/lib/agent-registry.js +155 -0
  23. package/server/lib/agent-registry.test.js +239 -0
  24. package/server/lib/agent-state.js +236 -0
  25. package/server/lib/agent-state.test.js +375 -0
  26. package/server/lib/api-provider.js +186 -0
  27. package/server/lib/api-provider.test.js +336 -0
  28. package/server/lib/cli-detector.js +166 -0
  29. package/server/lib/cli-detector.test.js +269 -0
  30. package/server/lib/cli-provider.js +212 -0
  31. package/server/lib/cli-provider.test.js +349 -0
  32. package/server/lib/debug.test.js +62 -0
  33. package/server/lib/devserver-router-api.js +249 -0
  34. package/server/lib/devserver-router-api.test.js +426 -0
  35. package/server/lib/model-router.js +245 -0
  36. package/server/lib/model-router.test.js +313 -0
  37. package/server/lib/output-schemas.js +269 -0
  38. package/server/lib/output-schemas.test.js +307 -0
  39. package/server/lib/provider-interface.js +153 -0
  40. package/server/lib/provider-interface.test.js +394 -0
  41. package/server/lib/provider-queue.js +158 -0
  42. package/server/lib/provider-queue.test.js +315 -0
  43. package/server/lib/router-config.js +221 -0
  44. package/server/lib/router-config.test.js +237 -0
  45. package/server/lib/router-setup-command.js +419 -0
  46. package/server/lib/router-setup-command.test.js +375 -0
@@ -0,0 +1,383 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import {
3
+ AgentMetadata,
4
+ createMetadata,
5
+ MODEL_PRICING,
6
+ } from './agent-metadata.js';
7
+
8
+ describe('AgentMetadata', () => {
9
+ let metadata;
10
+
11
+ beforeEach(() => {
12
+ vi.useFakeTimers();
13
+ vi.setSystemTime(new Date('2026-02-02T12:00:00Z'));
14
+ });
15
+
16
+ describe('createMetadata', () => {
17
+ it('initializes with model and task', () => {
18
+ metadata = createMetadata({
19
+ model: 'claude-3-opus',
20
+ taskType: 'code-review',
21
+ parameters: { files: ['test.js'] },
22
+ });
23
+
24
+ expect(metadata.model).toBe('claude-3-opus');
25
+ expect(metadata.taskType).toBe('code-review');
26
+ expect(metadata.parameters).toEqual({ files: ['test.js'] });
27
+ });
28
+
29
+ it('sets initial token counts to zero', () => {
30
+ metadata = createMetadata({
31
+ model: 'claude-3-opus',
32
+ taskType: 'analysis',
33
+ });
34
+
35
+ expect(metadata.inputTokens).toBe(0);
36
+ expect(metadata.outputTokens).toBe(0);
37
+ expect(metadata.totalTokens).toBe(0);
38
+ });
39
+
40
+ it('records start timestamp', () => {
41
+ metadata = createMetadata({
42
+ model: 'claude-3-opus',
43
+ taskType: 'generation',
44
+ });
45
+
46
+ expect(metadata.startedAt).toBe(new Date('2026-02-02T12:00:00Z').getTime());
47
+ });
48
+
49
+ it('initializes cost to zero', () => {
50
+ metadata = createMetadata({
51
+ model: 'claude-3-opus',
52
+ taskType: 'analysis',
53
+ });
54
+
55
+ expect(metadata.cost).toBe(0);
56
+ });
57
+
58
+ it('sets frozen to false initially', () => {
59
+ metadata = createMetadata({
60
+ model: 'claude-3-opus',
61
+ taskType: 'generation',
62
+ });
63
+
64
+ expect(metadata.frozen).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe('updateTokens', () => {
69
+ beforeEach(() => {
70
+ metadata = createMetadata({
71
+ model: 'claude-3-opus',
72
+ taskType: 'analysis',
73
+ });
74
+ });
75
+
76
+ it('adds input tokens', () => {
77
+ metadata.updateTokens({ input: 100 });
78
+
79
+ expect(metadata.inputTokens).toBe(100);
80
+ });
81
+
82
+ it('adds output tokens', () => {
83
+ metadata.updateTokens({ output: 50 });
84
+
85
+ expect(metadata.outputTokens).toBe(50);
86
+ });
87
+
88
+ it('calculates total tokens', () => {
89
+ metadata.updateTokens({ input: 100, output: 50 });
90
+
91
+ expect(metadata.totalTokens).toBe(150);
92
+ });
93
+
94
+ it('accumulates across multiple updates', () => {
95
+ metadata.updateTokens({ input: 100, output: 50 });
96
+ metadata.updateTokens({ input: 200, output: 75 });
97
+
98
+ expect(metadata.inputTokens).toBe(300);
99
+ expect(metadata.outputTokens).toBe(125);
100
+ expect(metadata.totalTokens).toBe(425);
101
+ });
102
+
103
+ it('returns self for chaining', () => {
104
+ const result = metadata.updateTokens({ input: 100 });
105
+
106
+ expect(result).toBe(metadata);
107
+ });
108
+ });
109
+
110
+ describe('calculateCost', () => {
111
+ it('uses model pricing for claude-3-opus', () => {
112
+ metadata = createMetadata({
113
+ model: 'claude-3-opus',
114
+ taskType: 'analysis',
115
+ });
116
+ metadata.updateTokens({ input: 1000, output: 500 });
117
+
118
+ const cost = metadata.calculateCost();
119
+
120
+ // claude-3-opus: $15/1M input, $75/1M output
121
+ const expectedCost = (1000 * 15 / 1_000_000) + (500 * 75 / 1_000_000);
122
+ expect(cost).toBeCloseTo(expectedCost, 6);
123
+ expect(metadata.cost).toBeCloseTo(expectedCost, 6);
124
+ });
125
+
126
+ it('uses model pricing for claude-3-sonnet', () => {
127
+ metadata = createMetadata({
128
+ model: 'claude-3-sonnet',
129
+ taskType: 'analysis',
130
+ });
131
+ metadata.updateTokens({ input: 1000, output: 500 });
132
+
133
+ const cost = metadata.calculateCost();
134
+
135
+ // claude-3-sonnet: $3/1M input, $15/1M output
136
+ const expectedCost = (1000 * 3 / 1_000_000) + (500 * 15 / 1_000_000);
137
+ expect(cost).toBeCloseTo(expectedCost, 6);
138
+ });
139
+
140
+ it('uses model pricing for claude-3-haiku', () => {
141
+ metadata = createMetadata({
142
+ model: 'claude-3-haiku',
143
+ taskType: 'analysis',
144
+ });
145
+ metadata.updateTokens({ input: 1000, output: 500 });
146
+
147
+ const cost = metadata.calculateCost();
148
+
149
+ // claude-3-haiku: $0.25/1M input, $1.25/1M output
150
+ const expectedCost = (1000 * 0.25 / 1_000_000) + (500 * 1.25 / 1_000_000);
151
+ expect(cost).toBeCloseTo(expectedCost, 6);
152
+ });
153
+
154
+ it('handles unknown models with default pricing', () => {
155
+ metadata = createMetadata({
156
+ model: 'unknown-model',
157
+ taskType: 'analysis',
158
+ });
159
+ metadata.updateTokens({ input: 1000, output: 500 });
160
+
161
+ const cost = metadata.calculateCost();
162
+
163
+ // default: $1/1M input, $3/1M output
164
+ const expectedCost = (1000 * 1 / 1_000_000) + (500 * 3 / 1_000_000);
165
+ expect(cost).toBeCloseTo(expectedCost, 6);
166
+ });
167
+
168
+ it('returns zero cost when no tokens', () => {
169
+ metadata = createMetadata({
170
+ model: 'claude-3-opus',
171
+ taskType: 'analysis',
172
+ });
173
+
174
+ const cost = metadata.calculateCost();
175
+
176
+ expect(cost).toBe(0);
177
+ });
178
+ });
179
+
180
+ describe('setDuration', () => {
181
+ beforeEach(() => {
182
+ metadata = createMetadata({
183
+ model: 'claude-3-opus',
184
+ taskType: 'analysis',
185
+ });
186
+ });
187
+
188
+ it('records elapsed time in milliseconds', () => {
189
+ // Advance time by 5 seconds
190
+ vi.advanceTimersByTime(5000);
191
+
192
+ metadata.setDuration();
193
+
194
+ expect(metadata.duration).toBe(5000);
195
+ });
196
+
197
+ it('records completion timestamp', () => {
198
+ vi.advanceTimersByTime(5000);
199
+
200
+ metadata.setDuration();
201
+
202
+ expect(metadata.completedAt).toBe(new Date('2026-02-02T12:00:05Z').getTime());
203
+ });
204
+
205
+ it('returns self for chaining', () => {
206
+ const result = metadata.setDuration();
207
+
208
+ expect(result).toBe(metadata);
209
+ });
210
+ });
211
+
212
+ describe('freeze', () => {
213
+ beforeEach(() => {
214
+ metadata = createMetadata({
215
+ model: 'claude-3-opus',
216
+ taskType: 'analysis',
217
+ });
218
+ metadata.updateTokens({ input: 100, output: 50 });
219
+ });
220
+
221
+ it('prevents further token updates', () => {
222
+ metadata.freeze();
223
+
224
+ expect(() => metadata.updateTokens({ input: 100 })).toThrow();
225
+ expect(metadata.inputTokens).toBe(100);
226
+ });
227
+
228
+ it('prevents setting duration again', () => {
229
+ metadata.setDuration();
230
+ metadata.freeze();
231
+
232
+ expect(() => metadata.setDuration()).toThrow();
233
+ });
234
+
235
+ it('sets frozen flag to true', () => {
236
+ metadata.freeze();
237
+
238
+ expect(metadata.frozen).toBe(true);
239
+ });
240
+
241
+ it('automatically calculates final cost', () => {
242
+ metadata.freeze();
243
+
244
+ expect(metadata.cost).toBeGreaterThan(0);
245
+ });
246
+
247
+ it('returns self for chaining', () => {
248
+ const result = metadata.freeze();
249
+
250
+ expect(result).toBe(metadata);
251
+ });
252
+ });
253
+
254
+ describe('toJSON', () => {
255
+ it('serializes all fields', () => {
256
+ metadata = createMetadata({
257
+ model: 'claude-3-opus',
258
+ taskType: 'code-review',
259
+ parameters: { files: ['test.js'] },
260
+ });
261
+ metadata.updateTokens({ input: 1000, output: 500 });
262
+ vi.advanceTimersByTime(5000);
263
+ metadata.setDuration();
264
+ metadata.freeze();
265
+
266
+ const json = metadata.toJSON();
267
+
268
+ expect(json).toMatchObject({
269
+ model: 'claude-3-opus',
270
+ taskType: 'code-review',
271
+ parameters: { files: ['test.js'] },
272
+ inputTokens: 1000,
273
+ outputTokens: 500,
274
+ totalTokens: 1500,
275
+ duration: 5000,
276
+ frozen: true,
277
+ });
278
+ expect(json.startedAt).toBeDefined();
279
+ expect(json.completedAt).toBeDefined();
280
+ expect(json.cost).toBeGreaterThan(0);
281
+ });
282
+
283
+ it('returns plain object, not instance', () => {
284
+ metadata = createMetadata({
285
+ model: 'claude-3-opus',
286
+ taskType: 'analysis',
287
+ });
288
+
289
+ const json = metadata.toJSON();
290
+
291
+ expect(json).not.toBeInstanceOf(AgentMetadata);
292
+ expect(typeof json.updateTokens).toBe('undefined');
293
+ });
294
+ });
295
+
296
+ describe('fromJSON', () => {
297
+ it('deserializes correctly', () => {
298
+ const original = createMetadata({
299
+ model: 'claude-3-opus',
300
+ taskType: 'code-review',
301
+ parameters: { files: ['test.js'] },
302
+ });
303
+ original.updateTokens({ input: 1000, output: 500 });
304
+ vi.advanceTimersByTime(5000);
305
+ original.setDuration();
306
+ original.freeze();
307
+
308
+ const json = original.toJSON();
309
+ const restored = AgentMetadata.fromJSON(json);
310
+
311
+ expect(restored.model).toBe('claude-3-opus');
312
+ expect(restored.taskType).toBe('code-review');
313
+ expect(restored.parameters).toEqual({ files: ['test.js'] });
314
+ expect(restored.inputTokens).toBe(1000);
315
+ expect(restored.outputTokens).toBe(500);
316
+ expect(restored.totalTokens).toBe(1500);
317
+ expect(restored.duration).toBe(5000);
318
+ expect(restored.frozen).toBe(true);
319
+ });
320
+
321
+ it('restored metadata is immutable if original was frozen', () => {
322
+ const original = createMetadata({
323
+ model: 'claude-3-opus',
324
+ taskType: 'analysis',
325
+ });
326
+ original.freeze();
327
+
328
+ const restored = AgentMetadata.fromJSON(original.toJSON());
329
+
330
+ expect(() => restored.updateTokens({ input: 100 })).toThrow();
331
+ });
332
+
333
+ it('restored metadata is mutable if original was not frozen', () => {
334
+ const original = createMetadata({
335
+ model: 'claude-3-opus',
336
+ taskType: 'analysis',
337
+ });
338
+
339
+ const restored = AgentMetadata.fromJSON(original.toJSON());
340
+ restored.updateTokens({ input: 100 });
341
+
342
+ expect(restored.inputTokens).toBe(100);
343
+ });
344
+ });
345
+
346
+ describe('validates required fields', () => {
347
+ it('throws if model is missing', () => {
348
+ expect(() => createMetadata({
349
+ taskType: 'analysis',
350
+ })).toThrow('model is required');
351
+ });
352
+
353
+ it('throws if taskType is missing', () => {
354
+ expect(() => createMetadata({
355
+ model: 'claude-3-opus',
356
+ })).toThrow('taskType is required');
357
+ });
358
+
359
+ it('accepts valid minimal config', () => {
360
+ expect(() => createMetadata({
361
+ model: 'claude-3-opus',
362
+ taskType: 'analysis',
363
+ })).not.toThrow();
364
+ });
365
+ });
366
+
367
+ describe('MODEL_PRICING', () => {
368
+ it('exports pricing constants', () => {
369
+ expect(MODEL_PRICING).toBeDefined();
370
+ expect(MODEL_PRICING['claude-3-opus']).toBeDefined();
371
+ expect(MODEL_PRICING['claude-3-sonnet']).toBeDefined();
372
+ expect(MODEL_PRICING['claude-3-haiku']).toBeDefined();
373
+ expect(MODEL_PRICING['default']).toBeDefined();
374
+ });
375
+
376
+ it('has input and output rates for each model', () => {
377
+ for (const model of Object.keys(MODEL_PRICING)) {
378
+ expect(MODEL_PRICING[model].input).toBeTypeOf('number');
379
+ expect(MODEL_PRICING[model].output).toBeTypeOf('number');
380
+ }
381
+ });
382
+ });
383
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Agent Persistence - Save and restore agent state across sessions
3
+ *
4
+ * Provides file-based persistence for agent data in the .tlc/agents/ directory.
5
+ * Uses atomic writes (write to temp file, then rename) to prevent corruption.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ /**
12
+ * Default directory for storing agent files
13
+ */
14
+ const AGENTS_DIR = '.tlc/agents';
15
+
16
+ /**
17
+ * Default max age for cleanup (7 days in milliseconds)
18
+ */
19
+ const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
20
+
21
+ /**
22
+ * Get the storage path for an agent
23
+ * @param {string|null} projectRoot - Project root directory (uses TLC_PROJECT_ROOT env if null)
24
+ * @param {string} [agentId] - Optional agent ID for specific file path
25
+ * @returns {string} Full path to agent file or agents directory
26
+ */
27
+ function getStoragePath(projectRoot, agentId) {
28
+ const root = projectRoot || process.env.TLC_PROJECT_ROOT || process.cwd();
29
+ const agentsDir = path.join(root, AGENTS_DIR);
30
+
31
+ if (agentId) {
32
+ return path.join(agentsDir, `${agentId}.json`);
33
+ }
34
+
35
+ return agentsDir;
36
+ }
37
+
38
+ /**
39
+ * Ensure the agents directory exists
40
+ * @param {string} projectRoot - Project root directory
41
+ */
42
+ function ensureAgentsDir(projectRoot) {
43
+ const agentsDir = getStoragePath(projectRoot);
44
+ if (!fs.existsSync(agentsDir)) {
45
+ fs.mkdirSync(agentsDir, { recursive: true });
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Save agent data to file using atomic write
51
+ * @param {string} projectRoot - Project root directory
52
+ * @param {Object} agentData - Agent data to save
53
+ * @param {string} agentData.id - Agent ID (required)
54
+ * @param {string} agentData.state - Agent state
55
+ * @param {Object} agentData.metadata - Agent metadata
56
+ * @param {number} agentData.createdAt - Creation timestamp
57
+ * @param {number} agentData.updatedAt - Last update timestamp
58
+ * @returns {Promise<void>}
59
+ * @throws {Error} If agent ID is missing
60
+ */
61
+ async function saveAgent(projectRoot, agentData) {
62
+ if (!agentData || !agentData.id) {
63
+ throw new Error('Agent ID is required');
64
+ }
65
+
66
+ ensureAgentsDir(projectRoot);
67
+
68
+ const filePath = getStoragePath(projectRoot, agentData.id);
69
+ const tempPath = `${filePath}.tmp`;
70
+
71
+ // Write to temp file first
72
+ const jsonData = JSON.stringify(agentData, null, 2);
73
+ fs.writeFileSync(tempPath, jsonData, 'utf8');
74
+
75
+ // Atomic rename
76
+ fs.renameSync(tempPath, filePath);
77
+ }
78
+
79
+ /**
80
+ * Load agent data from file
81
+ * @param {string} projectRoot - Project root directory
82
+ * @param {string} agentId - Agent ID to load
83
+ * @returns {Promise<Object|null>} Agent data or null if not found/corrupted
84
+ */
85
+ async function loadAgent(projectRoot, agentId) {
86
+ const filePath = getStoragePath(projectRoot, agentId);
87
+
88
+ if (!fs.existsSync(filePath)) {
89
+ return null;
90
+ }
91
+
92
+ try {
93
+ const content = fs.readFileSync(filePath, 'utf8');
94
+ if (!content || content.trim() === '') {
95
+ return null;
96
+ }
97
+ return JSON.parse(content);
98
+ } catch (err) {
99
+ // Handle corrupted files gracefully
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Load all saved agents
106
+ * @param {string} projectRoot - Project root directory
107
+ * @returns {Promise<Array>} Array of agent data objects
108
+ */
109
+ async function loadAllAgents(projectRoot) {
110
+ const agentsDir = getStoragePath(projectRoot);
111
+
112
+ if (!fs.existsSync(agentsDir)) {
113
+ return [];
114
+ }
115
+
116
+ const files = fs.readdirSync(agentsDir);
117
+ const agents = [];
118
+
119
+ for (const file of files) {
120
+ if (!file.endsWith('.json')) {
121
+ continue;
122
+ }
123
+
124
+ const agentId = path.basename(file, '.json');
125
+ const agent = await loadAgent(projectRoot, agentId);
126
+
127
+ if (agent) {
128
+ agents.push(agent);
129
+ }
130
+ }
131
+
132
+ return agents;
133
+ }
134
+
135
+ /**
136
+ * Delete an agent file
137
+ * @param {string} projectRoot - Project root directory
138
+ * @param {string} agentId - Agent ID to delete
139
+ * @returns {Promise<boolean>} True if deleted, false if not found
140
+ */
141
+ async function deleteAgent(projectRoot, agentId) {
142
+ const filePath = getStoragePath(projectRoot, agentId);
143
+
144
+ if (!fs.existsSync(filePath)) {
145
+ return false;
146
+ }
147
+
148
+ fs.unlinkSync(filePath);
149
+ return true;
150
+ }
151
+
152
+ /**
153
+ * Clean up old agent files
154
+ * @param {string} projectRoot - Project root directory
155
+ * @param {number} [maxAgeMs] - Maximum age in milliseconds (default: 7 days)
156
+ * @returns {Promise<number>} Number of agents cleaned up
157
+ */
158
+ async function cleanupOldAgents(projectRoot, maxAgeMs = DEFAULT_MAX_AGE_MS) {
159
+ const agents = await loadAllAgents(projectRoot);
160
+ const now = Date.now();
161
+ let cleaned = 0;
162
+
163
+ for (const agent of agents) {
164
+ // Skip running agents - they might still be active
165
+ if (agent.state === 'running' || agent.state === 'pending') {
166
+ continue;
167
+ }
168
+
169
+ // Check age based on updatedAt (or createdAt if updatedAt is missing)
170
+ const agentTime = agent.updatedAt || agent.createdAt;
171
+ const age = now - agentTime;
172
+
173
+ if (age > maxAgeMs) {
174
+ await deleteAgent(projectRoot, agent.id);
175
+ cleaned++;
176
+ }
177
+ }
178
+
179
+ return cleaned;
180
+ }
181
+
182
+ module.exports = {
183
+ saveAgent,
184
+ loadAgent,
185
+ loadAllAgents,
186
+ deleteAgent,
187
+ getStoragePath,
188
+ cleanupOldAgents,
189
+ AGENTS_DIR,
190
+ DEFAULT_MAX_AGE_MS,
191
+ };