tlc-claude-code 1.4.1 → 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,65 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useState, useEffect } from 'react';
4
+ export default function RouterPane({ apiUrl = '/api/router/status' }) {
5
+ const [status, setStatus] = useState(null);
6
+ const [loading, setLoading] = useState(true);
7
+ const [error, setError] = useState(null);
8
+ useEffect(() => {
9
+ const fetchStatus = async () => {
10
+ try {
11
+ const response = await fetch(apiUrl);
12
+ if (!response.ok) {
13
+ throw new Error('Failed to fetch');
14
+ }
15
+ const data = await response.json();
16
+ setStatus(data);
17
+ setError(null);
18
+ }
19
+ catch (err) {
20
+ setError(err instanceof Error ? err.message : 'Unknown error');
21
+ }
22
+ finally {
23
+ setLoading(false);
24
+ }
25
+ };
26
+ fetchStatus();
27
+ }, [apiUrl]);
28
+ if (loading) {
29
+ return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Model Router" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "Loading router status..." }) })] }));
30
+ }
31
+ if (error) {
32
+ return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Model Router" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })] }));
33
+ }
34
+ if (!status) {
35
+ return null;
36
+ }
37
+ const providers = Object.entries(status.providers);
38
+ const capabilities = Object.entries(status.capabilities || {});
39
+ return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Model Router" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Providers" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [providers.map(([name, provider]) => (_jsx(ProviderRow, { name: name, provider: provider }, name))), providers.length === 0 && (_jsx(Text, { color: "gray", children: "No providers configured" }))] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Devserver" }), _jsx(DevserverRow, { devserver: status.devserver })] }), capabilities.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Routing" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: capabilities.map(([name, cap]) => (_jsx(CapabilityRow, { name: name, providers: cap.providers, allProviders: status.providers }, name))) })] })), status.costEstimate && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Cost Estimates (Monthly)" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: Object.entries(status.costEstimate).map(([name, costs]) => (_jsxs(Box, { children: [_jsx(Text, { children: name.padEnd(12) }), _jsx(Text, { color: "green", children: "local: $0.00" }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "yellow", children: ["devserver: $", costs.devserver.toFixed(2)] })] }, name))) })] }))] }));
40
+ }
41
+ function ProviderRow({ name, provider }) {
42
+ const isLocal = provider.type === 'cli' && provider.detected;
43
+ const healthIndicator = provider.healthy !== false ? '●' : '○';
44
+ const healthColor = provider.healthy !== false ? 'green' : 'red';
45
+ const routingBadge = isLocal ? 'local' : 'devserver';
46
+ const badgeColor = isLocal ? 'green' : 'yellow';
47
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: healthColor, children: [healthIndicator, " "] }), _jsx(Text, { bold: true, children: name.padEnd(10) }), provider.version && _jsxs(Text, { color: "gray", children: [" ", provider.version.padEnd(10)] }), _jsxs(Text, { color: badgeColor, children: ["[", routingBadge, "]"] }), provider.capabilities && provider.capabilities.length > 0 && (_jsxs(Text, { color: "gray", children: [" (", provider.capabilities.join(', '), ")"] }))] }));
48
+ }
49
+ function DevserverRow({ devserver }) {
50
+ if (!devserver.configured) {
51
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Not configured - run " }), _jsx(Text, { color: "cyan", children: "tlc router setup" })] }));
52
+ }
53
+ const statusText = devserver.connected ? 'Connected' : 'Disconnected';
54
+ const statusColor = devserver.connected ? 'green' : 'red';
55
+ const indicator = devserver.connected ? '●' : '○';
56
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: statusColor, children: [indicator, " ", statusText] }) }), devserver.url && (_jsx(Box, { children: _jsx(Text, { color: "gray", children: devserver.url }) }))] }));
57
+ }
58
+ function CapabilityRow({ name, providers, allProviders, }) {
59
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "white", children: name.padEnd(12) }), _jsx(Text, { children: "\u2192 " }), providers.map((p, idx) => {
60
+ const provider = allProviders[p];
61
+ const isLocal = provider?.type === 'cli' && provider?.detected;
62
+ const color = isLocal ? 'green' : 'yellow';
63
+ return (_jsxs(Text, { children: [_jsx(Text, { color: color, children: p }), idx < providers.length - 1 && _jsx(Text, { children: ", " })] }, p));
64
+ })] }));
65
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,176 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { render } from 'ink-testing-library';
4
+ import RouterPane from './RouterPane.js';
5
+ // Mock fetch
6
+ global.fetch = vi.fn();
7
+ describe('RouterPane', () => {
8
+ beforeEach(() => {
9
+ vi.useFakeTimers();
10
+ vi.clearAllMocks();
11
+ });
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ });
15
+ describe('rendering', () => {
16
+ it('renders detected CLIs', async () => {
17
+ global.fetch.mockResolvedValue({
18
+ ok: true,
19
+ json: () => Promise.resolve({
20
+ providers: {
21
+ claude: { detected: true, type: 'cli', version: 'v4.0.0' },
22
+ codex: { detected: true, type: 'cli', version: 'v1.0.0' },
23
+ },
24
+ devserver: { configured: true, connected: true },
25
+ }),
26
+ });
27
+ const { lastFrame } = render(_jsx(RouterPane, {}));
28
+ await vi.runAllTimersAsync();
29
+ const output = lastFrame();
30
+ expect(output).toContain('claude');
31
+ expect(output).toContain('codex');
32
+ });
33
+ it('shows CLI versions', async () => {
34
+ global.fetch.mockResolvedValue({
35
+ ok: true,
36
+ json: () => Promise.resolve({
37
+ providers: {
38
+ claude: { detected: true, type: 'cli', version: 'v4.0.0' },
39
+ },
40
+ devserver: { configured: false },
41
+ }),
42
+ });
43
+ const { lastFrame } = render(_jsx(RouterPane, {}));
44
+ await vi.runAllTimersAsync();
45
+ const output = lastFrame();
46
+ expect(output).toContain('v4.0.0');
47
+ });
48
+ it('shows devserver connected status', async () => {
49
+ global.fetch.mockResolvedValue({
50
+ ok: true,
51
+ json: () => Promise.resolve({
52
+ providers: {},
53
+ devserver: { configured: true, connected: true, url: 'https://dev.example.com' },
54
+ }),
55
+ });
56
+ const { lastFrame } = render(_jsx(RouterPane, {}));
57
+ await vi.runAllTimersAsync();
58
+ const output = lastFrame();
59
+ expect(output).toContain('Connected');
60
+ });
61
+ it('shows devserver disconnected status', async () => {
62
+ global.fetch.mockResolvedValue({
63
+ ok: true,
64
+ json: () => Promise.resolve({
65
+ providers: {},
66
+ devserver: { configured: true, connected: false },
67
+ }),
68
+ });
69
+ const { lastFrame } = render(_jsx(RouterPane, {}));
70
+ await vi.runAllTimersAsync();
71
+ const output = lastFrame();
72
+ expect(output).toContain('Disconnected');
73
+ });
74
+ it('renders routing table', async () => {
75
+ global.fetch.mockResolvedValue({
76
+ ok: true,
77
+ json: () => Promise.resolve({
78
+ providers: {
79
+ claude: { detected: true, type: 'cli', capabilities: ['review'] },
80
+ deepseek: { detected: false, type: 'api', capabilities: ['review'] },
81
+ },
82
+ devserver: { configured: true },
83
+ capabilities: {
84
+ review: { providers: ['claude', 'deepseek'] },
85
+ },
86
+ }),
87
+ });
88
+ const { lastFrame } = render(_jsx(RouterPane, {}));
89
+ await vi.runAllTimersAsync();
90
+ const output = lastFrame();
91
+ expect(output).toContain('review');
92
+ });
93
+ it('shows local vs devserver badges', async () => {
94
+ global.fetch.mockResolvedValue({
95
+ ok: true,
96
+ json: () => Promise.resolve({
97
+ providers: {
98
+ claude: { detected: true, type: 'cli' },
99
+ deepseek: { detected: false, type: 'api' },
100
+ },
101
+ devserver: { configured: true },
102
+ }),
103
+ });
104
+ const { lastFrame } = render(_jsx(RouterPane, {}));
105
+ await vi.runAllTimersAsync();
106
+ const output = lastFrame();
107
+ expect(output).toContain('local');
108
+ });
109
+ it('shows cost estimates', async () => {
110
+ global.fetch.mockResolvedValue({
111
+ ok: true,
112
+ json: () => Promise.resolve({
113
+ providers: {
114
+ deepseek: { detected: false, type: 'api' },
115
+ },
116
+ devserver: { configured: true },
117
+ costEstimate: {
118
+ review: { local: 0, devserver: 1.5 },
119
+ },
120
+ }),
121
+ });
122
+ const { lastFrame } = render(_jsx(RouterPane, {}));
123
+ await vi.runAllTimersAsync();
124
+ const output = lastFrame();
125
+ expect(output).toContain('$1.50');
126
+ });
127
+ it('health indicators show status', async () => {
128
+ global.fetch.mockResolvedValue({
129
+ ok: true,
130
+ json: () => Promise.resolve({
131
+ providers: {
132
+ claude: { detected: true, type: 'cli', healthy: true },
133
+ codex: { detected: false, type: 'cli', healthy: false },
134
+ },
135
+ devserver: { configured: true },
136
+ }),
137
+ });
138
+ const { lastFrame } = render(_jsx(RouterPane, {}));
139
+ await vi.runAllTimersAsync();
140
+ const output = lastFrame();
141
+ // Health indicators should be present (● or similar)
142
+ expect(output).toBeDefined();
143
+ });
144
+ it('shows configure hint', async () => {
145
+ global.fetch.mockResolvedValue({
146
+ ok: true,
147
+ json: () => Promise.resolve({
148
+ providers: {},
149
+ devserver: { configured: false },
150
+ }),
151
+ });
152
+ const { lastFrame } = render(_jsx(RouterPane, {}));
153
+ await vi.runAllTimersAsync();
154
+ const output = lastFrame();
155
+ expect(output).toContain('Router');
156
+ });
157
+ });
158
+ describe('loading state', () => {
159
+ it('handles loading state', () => {
160
+ global.fetch.mockImplementation(() => new Promise(() => { }) // Never resolves
161
+ );
162
+ const { lastFrame } = render(_jsx(RouterPane, {}));
163
+ const output = lastFrame();
164
+ expect(output).toContain('Loading');
165
+ });
166
+ });
167
+ describe('error state', () => {
168
+ it('handles error state', async () => {
169
+ global.fetch.mockRejectedValue(new Error('Network error'));
170
+ const { lastFrame } = render(_jsx(RouterPane, {}));
171
+ await vi.runAllTimersAsync();
172
+ const output = lastFrame();
173
+ expect(output).toContain('Error');
174
+ });
175
+ });
176
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tlc-claude-code",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "TLC - Test Led Coding for Claude Code",
5
5
  "bin": {
6
6
  "tlc": "./bin/tlc.js",
@@ -33,7 +33,9 @@
33
33
  "docs": "node scripts/docs-update.js",
34
34
  "docs:check": "node scripts/docs-update.js --check",
35
35
  "docs:screenshots": "node scripts/generate-screenshots.js",
36
- "docs:capture": "node scripts/capture-screenshots.js"
36
+ "docs:capture": "node scripts/capture-screenshots.js",
37
+ "test:e2e": "npx playwright test",
38
+ "test:e2e:ui": "npx playwright test --ui"
37
39
  },
38
40
  "repository": {
39
41
  "type": "git",
@@ -50,6 +52,7 @@
50
52
  "author": "Jurgen Calleja",
51
53
  "license": "MIT",
52
54
  "devDependencies": {
55
+ "@playwright/test": "^1.58.1",
53
56
  "playwright": "^1.58.1",
54
57
  "text-to-image": "^8.0.1"
55
58
  }
package/server/index.js CHANGED
@@ -471,6 +471,184 @@ app.post('/api/test', (req, res) => {
471
471
  res.json({ success: true });
472
472
  });
473
473
 
474
+ // ============================================
475
+ // Agent Registry API (Phase 32)
476
+ // ============================================
477
+ const { getAgentRegistry } = require('./lib/agent-registry');
478
+ const { createAgentState } = require('./lib/agent-state');
479
+ const { createMetadata } = require('./lib/agent-metadata');
480
+
481
+ // Helper to format agent for API response
482
+ function formatAgent(agent) {
483
+ return {
484
+ id: agent.id,
485
+ name: agent.name,
486
+ state: {
487
+ current: agent.stateMachine ? agent.stateMachine.getState() : 'pending',
488
+ history: agent.stateMachine ? agent.stateMachine.getHistory() : [],
489
+ },
490
+ metadata: {
491
+ model: agent.model,
492
+ taskType: agent.taskType || 'unknown',
493
+ tokens: agent.metadataObj ? {
494
+ input: agent.metadataObj.inputTokens,
495
+ output: agent.metadataObj.outputTokens,
496
+ total: agent.metadataObj.totalTokens,
497
+ } : { input: 0, output: 0, total: 0 },
498
+ },
499
+ createdAt: agent.createdAt || agent.registeredAt,
500
+ };
501
+ }
502
+
503
+ // List agents
504
+ app.get('/api/agents', (req, res) => {
505
+ try {
506
+ const registry = getAgentRegistry();
507
+ let agents = registry.listAgents();
508
+
509
+ // Filter by status (state.current)
510
+ if (req.query.status) {
511
+ agents = agents.filter(a =>
512
+ a.stateMachine ? a.stateMachine.getState() === req.query.status : false
513
+ );
514
+ }
515
+ // Filter by model
516
+ if (req.query.model) {
517
+ agents = agents.filter(a => a.model === req.query.model);
518
+ }
519
+ // Filter by type
520
+ if (req.query.type) {
521
+ agents = agents.filter(a => a.taskType === req.query.type);
522
+ }
523
+
524
+ res.json({ success: true, agents: agents.map(formatAgent) });
525
+ } catch (err) {
526
+ res.status(500).json({ success: false, error: err.message });
527
+ }
528
+ });
529
+
530
+ // Get single agent
531
+ app.get('/api/agents/:id', (req, res) => {
532
+ try {
533
+ const registry = getAgentRegistry();
534
+ const agent = registry.getAgent(req.params.id);
535
+ if (!agent) {
536
+ return res.status(404).json({ success: false, error: 'Agent not found' });
537
+ }
538
+ res.json({ success: true, agent: formatAgent(agent) });
539
+ } catch (err) {
540
+ res.status(500).json({ success: false, error: err.message });
541
+ }
542
+ });
543
+
544
+ // Register new agent
545
+ app.post('/api/agents', (req, res) => {
546
+ try {
547
+ const registry = getAgentRegistry();
548
+ const { id, name, model, taskType } = req.body;
549
+
550
+ if (!name) {
551
+ return res.status(400).json({ success: false, error: 'name is required' });
552
+ }
553
+
554
+ // Create state machine and metadata
555
+ const stateMachine = createAgentState({ agentId: id });
556
+ const metadataObj = createMetadata({
557
+ model: model || 'unknown',
558
+ taskType: taskType || 'default',
559
+ });
560
+
561
+ // Register with all components
562
+ const agentId = registry.registerAgent({
563
+ id,
564
+ name,
565
+ model: model || 'unknown',
566
+ taskType: taskType || 'default',
567
+ stateMachine,
568
+ metadataObj,
569
+ createdAt: Date.now(),
570
+ });
571
+
572
+ const agent = registry.getAgent(agentId);
573
+ res.status(201).json({ success: true, agent: formatAgent(agent) });
574
+ } catch (err) {
575
+ res.status(500).json({ success: false, error: err.message });
576
+ }
577
+ });
578
+
579
+ // Update agent (state transitions, token updates)
580
+ app.patch('/api/agents/:id', (req, res) => {
581
+ try {
582
+ const registry = getAgentRegistry();
583
+ const agent = registry.getAgent(req.params.id);
584
+ if (!agent) {
585
+ return res.status(404).json({ success: false, error: 'Agent not found' });
586
+ }
587
+
588
+ // Handle state transition
589
+ if (req.body.state) {
590
+ if (!agent.stateMachine) {
591
+ return res.status(400).json({ success: false, error: 'Agent has no state machine' });
592
+ }
593
+ const result = agent.stateMachine.transition(req.body.state, { reason: req.body.reason });
594
+ if (!result.success) {
595
+ return res.status(400).json({ success: false, error: result.error });
596
+ }
597
+ }
598
+
599
+ // Handle token updates
600
+ if (req.body.tokens && agent.metadataObj) {
601
+ agent.metadataObj.updateTokens({
602
+ input: req.body.tokens.input || 0,
603
+ output: req.body.tokens.output || 0,
604
+ });
605
+ }
606
+
607
+ res.json({ success: true, agent: formatAgent(agent) });
608
+ } catch (err) {
609
+ res.status(500).json({ success: false, error: err.message });
610
+ }
611
+ });
612
+
613
+ // Delete agent
614
+ app.delete('/api/agents/:id', (req, res) => {
615
+ try {
616
+ const registry = getAgentRegistry();
617
+ const removed = registry.removeAgent(req.params.id);
618
+ if (!removed) {
619
+ return res.status(404).json({ success: false, error: 'Agent not found' });
620
+ }
621
+ res.json({ success: true });
622
+ } catch (err) {
623
+ res.status(500).json({ success: false, error: err.message });
624
+ }
625
+ });
626
+
627
+ // Get registry stats
628
+ app.get('/api/agents-stats', (req, res) => {
629
+ try {
630
+ const registry = getAgentRegistry();
631
+ const agents = registry.listAgents();
632
+
633
+ const stats = {
634
+ total: agents.length,
635
+ byStatus: {},
636
+ byModel: {}
637
+ };
638
+
639
+ agents.forEach(agent => {
640
+ const status = agent.stateMachine ? agent.stateMachine.getState() : 'pending';
641
+ const model = agent.model || 'unknown';
642
+ stats.byStatus[status] = (stats.byStatus[status] || 0) + 1;
643
+ stats.byModel[model] = (stats.byModel[model] || 0) + 1;
644
+ });
645
+
646
+ res.json({ success: true, stats });
647
+ } catch (err) {
648
+ res.status(500).json({ success: false, error: err.message });
649
+ }
650
+ });
651
+
474
652
  app.post('/api/restart', (req, res) => {
475
653
  addLog('app', '--- Restarting app ---', 'warn');
476
654
  broadcast('app-restart', {});
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Agent Cleanup - Handles timeouts and orphaned agents
3
+ *
4
+ * Detects stuck agents (running state but no activity for timeout period)
5
+ * and transitions them to cancelled state.
6
+ * Supports periodic cleanup via setInterval with configurable interval.
7
+ */
8
+
9
+ import { getAgentRegistry } from './agent-registry.js';
10
+ import { getAgentHooks } from './agent-hooks.js';
11
+ import { STATES } from './agent-state.js';
12
+
13
+ /**
14
+ * Default timeout for detecting orphaned agents (30 minutes)
15
+ */
16
+ const DEFAULT_TIMEOUT = 30 * 60 * 1000;
17
+
18
+ /**
19
+ * Default interval for periodic cleanup (5 minutes)
20
+ */
21
+ const DEFAULT_INTERVAL = 5 * 60 * 1000;
22
+
23
+ /**
24
+ * Internal state for cleanup scheduling and stats
25
+ */
26
+ let cleanupIntervalId = null;
27
+ let cleanupStats = {
28
+ totalCleaned: 0,
29
+ cleanupRuns: 0,
30
+ lastCleanupAt: null,
31
+ };
32
+
33
+ /**
34
+ * Find agents that are orphaned (stuck in running state without activity)
35
+ * @param {Object} [options] - Options
36
+ * @param {number} [options.timeout] - Timeout in ms (default: 30 minutes)
37
+ * @returns {Array} Array of orphaned agent objects
38
+ */
39
+ function findOrphanedAgents(options = {}) {
40
+ const timeout = options.timeout || DEFAULT_TIMEOUT;
41
+ const registry = getAgentRegistry();
42
+ const now = Date.now();
43
+
44
+ // Get all running agents
45
+ const runningAgents = registry.listAgents({ status: STATES.RUNNING });
46
+
47
+ // Filter to those with no activity for longer than timeout
48
+ const orphans = runningAgents.filter(agent => {
49
+ const lastActivity = agent.lastActivity || agent.registeredAt;
50
+ const inactiveTime = now - lastActivity;
51
+
52
+ // Check if agent has a custom grace period
53
+ if (agent.gracePeriod) {
54
+ return inactiveTime > agent.gracePeriod;
55
+ }
56
+
57
+ return inactiveTime > timeout;
58
+ });
59
+
60
+ return orphans;
61
+ }
62
+
63
+ /**
64
+ * Clean up orphaned agents by transitioning them to cancelled state
65
+ * @param {Object} [options] - Options
66
+ * @param {number} [options.timeout] - Timeout for finding orphans (default: 30 minutes)
67
+ * @returns {Promise<Object>} Result with cleaned agents and any errors
68
+ */
69
+ async function cleanupOrphans(options = {}) {
70
+ const orphans = findOrphanedAgents(options);
71
+ const registry = getAgentRegistry();
72
+ const hooks = getAgentHooks();
73
+
74
+ const cleaned = [];
75
+ const errors = [];
76
+
77
+ for (const agent of orphans) {
78
+ try {
79
+ // Update agent status to cancelled
80
+ registry.updateAgent(agent.id, {
81
+ status: STATES.CANCELLED,
82
+ cancelledAt: Date.now(),
83
+ cancelReason: 'orphaned',
84
+ });
85
+
86
+ // Trigger onCancel hook
87
+ try {
88
+ await hooks.triggerHook('onCancel', {
89
+ agentId: agent.id,
90
+ agent: agent,
91
+ reason: 'orphaned',
92
+ timeout: options.timeout || DEFAULT_TIMEOUT,
93
+ });
94
+ } catch (hookError) {
95
+ // Hook errors are logged but don't stop cleanup
96
+ errors.push({ agentId: agent.id, error: hookError.message, type: 'hook' });
97
+ }
98
+
99
+ cleaned.push(agent);
100
+
101
+ // Log cleanup action
102
+ console.log(`[agent-cleanup] Cleaned orphaned agent: ${agent.id} (${agent.name})`);
103
+ } catch (err) {
104
+ errors.push({ agentId: agent.id, error: err.message, type: 'cleanup' });
105
+ }
106
+ }
107
+
108
+ // Update stats
109
+ cleanupStats.totalCleaned += cleaned.length;
110
+ cleanupStats.cleanupRuns += 1;
111
+ cleanupStats.lastCleanupAt = Date.now();
112
+
113
+ return { cleaned, errors };
114
+ }
115
+
116
+ /**
117
+ * Schedule periodic cleanup
118
+ * @param {Object} [options] - Options
119
+ * @param {number} [options.interval] - Interval in ms (default: 5 minutes)
120
+ * @param {number} [options.timeout] - Timeout for finding orphans (default: 30 minutes)
121
+ */
122
+ function scheduleCleanup(options = {}) {
123
+ const interval = options.interval || DEFAULT_INTERVAL;
124
+
125
+ // Stop any existing schedule
126
+ stopCleanup();
127
+
128
+ // Schedule periodic cleanup
129
+ cleanupIntervalId = setInterval(async () => {
130
+ try {
131
+ await cleanupOrphans(options);
132
+ } catch (err) {
133
+ console.error('[agent-cleanup] Error during scheduled cleanup:', err);
134
+ }
135
+ }, interval);
136
+ }
137
+
138
+ /**
139
+ * Stop the scheduled cleanup
140
+ */
141
+ function stopCleanup() {
142
+ if (cleanupIntervalId) {
143
+ clearInterval(cleanupIntervalId);
144
+ cleanupIntervalId = null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Get cleanup statistics
150
+ * @returns {Object} Stats including totalCleaned, cleanupRuns, lastCleanupAt
151
+ */
152
+ function getCleanupStats() {
153
+ return { ...cleanupStats };
154
+ }
155
+
156
+ /**
157
+ * Reset cleanup state (for testing)
158
+ */
159
+ function resetCleanup() {
160
+ stopCleanup();
161
+ cleanupStats = {
162
+ totalCleaned: 0,
163
+ cleanupRuns: 0,
164
+ lastCleanupAt: null,
165
+ };
166
+ }
167
+
168
+ export {
169
+ findOrphanedAgents,
170
+ cleanupOrphans,
171
+ scheduleCleanup,
172
+ stopCleanup,
173
+ getCleanupStats,
174
+ resetCleanup,
175
+ DEFAULT_TIMEOUT,
176
+ DEFAULT_INTERVAL,
177
+ };