claudehq 1.0.1 → 1.0.3
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/bin/cli.js +2 -2
- package/lib/core/claude-events.js +308 -0
- package/lib/core/config.js +103 -0
- package/lib/core/event-bus.js +145 -0
- package/lib/core/sse.js +96 -0
- package/lib/core/watchers.js +127 -0
- package/lib/data/conversation.js +195 -0
- package/lib/data/orchestration.js +941 -0
- package/lib/data/plans.js +247 -0
- package/lib/data/tasks.js +263 -0
- package/lib/data/todos.js +205 -0
- package/lib/index.js +481 -0
- package/lib/orchestration/executor.js +635 -0
- package/lib/routes/api.js +1010 -0
- package/lib/sessions/manager.js +870 -0
- package/package.json +10 -4
- package/{lib/server.js → public/index.html} +1984 -2644
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plans Data Module - Plan loading, updating, and session mapping
|
|
3
|
+
*
|
|
4
|
+
* Handles reading and writing plan files from the Claude plans directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const { PLANS_DIR, PROJECTS_DIR } = require('../core/config');
|
|
11
|
+
const { eventBus, EventTypes } = require('../core/event-bus');
|
|
12
|
+
|
|
13
|
+
// Cache for plan slug -> sessionId mapping
|
|
14
|
+
const planSessionCache = new Map();
|
|
15
|
+
let planCacheBuilt = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the plan->session mapping by scanning JSONL files
|
|
19
|
+
*/
|
|
20
|
+
function buildPlanSessionCache() {
|
|
21
|
+
console.log(' Building plan-session mapping cache...');
|
|
22
|
+
const startTime = Date.now();
|
|
23
|
+
planSessionCache.clear();
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(PLANS_DIR) || !fs.existsSync(PROJECTS_DIR)) {
|
|
26
|
+
planCacheBuilt = true;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Get all plan slugs
|
|
31
|
+
const planFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
|
|
32
|
+
const slugs = planFiles.map(f => f.replace('.md', ''));
|
|
33
|
+
|
|
34
|
+
// Search through project directories for slug references
|
|
35
|
+
const projectDirs = fs.readdirSync(PROJECTS_DIR);
|
|
36
|
+
|
|
37
|
+
for (const projectDir of projectDirs) {
|
|
38
|
+
const projectPath = path.join(PROJECTS_DIR, projectDir);
|
|
39
|
+
if (!fs.statSync(projectPath).isDirectory()) continue;
|
|
40
|
+
|
|
41
|
+
// Check main jsonl files
|
|
42
|
+
const jsonlFiles = fs.readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
|
|
43
|
+
for (const jsonlFile of jsonlFiles) {
|
|
44
|
+
try {
|
|
45
|
+
const content = fs.readFileSync(path.join(projectPath, jsonlFile), 'utf-8');
|
|
46
|
+
for (const slug of slugs) {
|
|
47
|
+
if (planSessionCache.has(slug)) continue;
|
|
48
|
+
if (content.includes(`"slug":"${slug}"`)) {
|
|
49
|
+
// Find the sessionId in this file
|
|
50
|
+
const lines = content.split('\n');
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
if (line.includes(`"slug":"${slug}"`)) {
|
|
53
|
+
try {
|
|
54
|
+
const entry = JSON.parse(line);
|
|
55
|
+
if (entry.sessionId) {
|
|
56
|
+
planSessionCache.set(slug, entry.sessionId);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check subagents directory
|
|
68
|
+
const subagentsPath = path.join(projectPath, 'subagents');
|
|
69
|
+
if (fs.existsSync(subagentsPath)) {
|
|
70
|
+
const subagentFiles = fs.readdirSync(subagentsPath).filter(f => f.endsWith('.jsonl'));
|
|
71
|
+
for (const jsonlFile of subagentFiles) {
|
|
72
|
+
try {
|
|
73
|
+
const content = fs.readFileSync(path.join(subagentsPath, jsonlFile), 'utf-8');
|
|
74
|
+
for (const slug of slugs) {
|
|
75
|
+
if (planSessionCache.has(slug)) continue;
|
|
76
|
+
if (content.includes(`"slug":"${slug}"`)) {
|
|
77
|
+
const lines = content.split('\n');
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (line.includes(`"slug":"${slug}"`)) {
|
|
80
|
+
try {
|
|
81
|
+
const entry = JSON.parse(line);
|
|
82
|
+
if (entry.sessionId) {
|
|
83
|
+
planSessionCache.set(slug, entry.sessionId);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (e) {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
planCacheBuilt = true;
|
|
97
|
+
console.log(` Plan cache built: ${planSessionCache.size} plans mapped in ${Date.now() - startTime}ms`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get session ID for a plan slug
|
|
102
|
+
* @param {string} slug - Plan slug
|
|
103
|
+
* @returns {string|null} Session ID or null
|
|
104
|
+
*/
|
|
105
|
+
function getSessionForPlan(slug) {
|
|
106
|
+
if (!planCacheBuilt) buildPlanSessionCache();
|
|
107
|
+
return planSessionCache.get(slug) || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Load all plans with their session mappings
|
|
112
|
+
* @returns {Array} Array of plan objects
|
|
113
|
+
*/
|
|
114
|
+
function loadAllPlans() {
|
|
115
|
+
if (!planCacheBuilt) buildPlanSessionCache();
|
|
116
|
+
|
|
117
|
+
const plans = [];
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(PLANS_DIR)) {
|
|
120
|
+
return plans;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const planFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
|
|
124
|
+
|
|
125
|
+
for (const file of planFiles) {
|
|
126
|
+
try {
|
|
127
|
+
const slug = file.replace('.md', '');
|
|
128
|
+
const filepath = path.join(PLANS_DIR, file);
|
|
129
|
+
const stats = fs.statSync(filepath);
|
|
130
|
+
const content = fs.readFileSync(filepath, 'utf-8');
|
|
131
|
+
|
|
132
|
+
// Extract title from first heading
|
|
133
|
+
const titleMatch = content.match(/^#\s+(.+?)(?:\n|$)/m);
|
|
134
|
+
const title = titleMatch ? titleMatch[1].replace(/^Plan:\s*/i, '') : slug;
|
|
135
|
+
|
|
136
|
+
plans.push({
|
|
137
|
+
slug,
|
|
138
|
+
title,
|
|
139
|
+
sessionId: planSessionCache.get(slug) || null,
|
|
140
|
+
filepath,
|
|
141
|
+
size: stats.size,
|
|
142
|
+
modified: stats.mtime.toISOString(),
|
|
143
|
+
preview: content.substring(0, 200).replace(/\n/g, ' ').trim()
|
|
144
|
+
});
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.error(`Error loading plan ${file}:`, e.message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Sort by modified date, newest first
|
|
151
|
+
plans.sort((a, b) => new Date(b.modified) - new Date(a.modified));
|
|
152
|
+
|
|
153
|
+
return plans;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get plans for a specific session
|
|
158
|
+
* @param {string} sessionId - Session ID
|
|
159
|
+
* @returns {Array} Array of plans for the session
|
|
160
|
+
*/
|
|
161
|
+
function getPlansForSession(sessionId) {
|
|
162
|
+
const allPlans = loadAllPlans();
|
|
163
|
+
return allPlans.filter(p => p.sessionId === sessionId);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get a single plan by slug
|
|
168
|
+
* @param {string} slug - Plan slug
|
|
169
|
+
* @returns {Object} Plan object or error
|
|
170
|
+
*/
|
|
171
|
+
function getPlan(slug) {
|
|
172
|
+
const filepath = path.join(PLANS_DIR, `${slug}.md`);
|
|
173
|
+
|
|
174
|
+
if (!fs.existsSync(filepath)) {
|
|
175
|
+
return { error: 'Plan not found' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const content = fs.readFileSync(filepath, 'utf-8');
|
|
180
|
+
const stats = fs.statSync(filepath);
|
|
181
|
+
const titleMatch = content.match(/^#\s+(.+?)(?:\n|$)/m);
|
|
182
|
+
const title = titleMatch ? titleMatch[1].replace(/^Plan:\s*/i, '') : slug;
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
slug,
|
|
186
|
+
title,
|
|
187
|
+
sessionId: getSessionForPlan(slug),
|
|
188
|
+
content,
|
|
189
|
+
modified: stats.mtime.toISOString()
|
|
190
|
+
};
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return { error: e.message };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Update a plan's content
|
|
198
|
+
* @param {string} slug - Plan slug
|
|
199
|
+
* @param {string} content - New content
|
|
200
|
+
* @returns {Object} Result with success flag or error
|
|
201
|
+
*/
|
|
202
|
+
function updatePlan(slug, content) {
|
|
203
|
+
const filepath = path.join(PLANS_DIR, `${slug}.md`);
|
|
204
|
+
|
|
205
|
+
if (!fs.existsSync(filepath)) {
|
|
206
|
+
return { error: 'Plan not found' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
fs.writeFileSync(filepath, content);
|
|
211
|
+
const sessionId = getSessionForPlan(slug);
|
|
212
|
+
|
|
213
|
+
eventBus.emit(EventTypes.PLAN_UPDATED, { slug, sessionId });
|
|
214
|
+
|
|
215
|
+
return { success: true, slug };
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return { error: e.message };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Check if plan session cache has been built
|
|
223
|
+
* @returns {boolean}
|
|
224
|
+
*/
|
|
225
|
+
function isCacheBuilt() {
|
|
226
|
+
return planCacheBuilt;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if a plan slug is in the cache
|
|
231
|
+
* @param {string} slug - Plan slug
|
|
232
|
+
* @returns {boolean}
|
|
233
|
+
*/
|
|
234
|
+
function hasPlanInCache(slug) {
|
|
235
|
+
return planSessionCache.has(slug);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
buildPlanSessionCache,
|
|
240
|
+
getSessionForPlan,
|
|
241
|
+
loadAllPlans,
|
|
242
|
+
getPlansForSession,
|
|
243
|
+
getPlan,
|
|
244
|
+
updatePlan,
|
|
245
|
+
isCacheBuilt,
|
|
246
|
+
hasPlanInCache
|
|
247
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tasks Data Module - Task loading, updating, and creation
|
|
3
|
+
*
|
|
4
|
+
* Handles reading and writing task files from the Claude tasks directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const { TASKS_DIR } = require('../core/config');
|
|
11
|
+
const { eventBus, EventTypes } = require('../core/event-bus');
|
|
12
|
+
|
|
13
|
+
// Callbacks for enrichment (set by other modules)
|
|
14
|
+
let loadCustomNamesCallback = null;
|
|
15
|
+
let getSessionMetadataCallback = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Set callback for loading custom names
|
|
19
|
+
* @param {Function} callback
|
|
20
|
+
*/
|
|
21
|
+
function setCustomNamesCallback(callback) {
|
|
22
|
+
loadCustomNamesCallback = callback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set callback for getting session metadata
|
|
27
|
+
* @param {Function} callback
|
|
28
|
+
*/
|
|
29
|
+
function setMetadataCallback(callback) {
|
|
30
|
+
getSessionMetadataCallback = callback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load all tasks from all sessions
|
|
35
|
+
* @returns {Array} Array of session objects with tasks
|
|
36
|
+
*/
|
|
37
|
+
function loadAllTasks() {
|
|
38
|
+
const sessions = [];
|
|
39
|
+
|
|
40
|
+
if (!fs.existsSync(TASKS_DIR)) {
|
|
41
|
+
return sessions;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const customNames = loadCustomNamesCallback ? loadCustomNamesCallback() : {};
|
|
45
|
+
|
|
46
|
+
const sessionDirs = fs.readdirSync(TASKS_DIR).filter(f => {
|
|
47
|
+
const fullPath = path.join(TASKS_DIR, f);
|
|
48
|
+
return fs.statSync(fullPath).isDirectory();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
for (const sessionId of sessionDirs) {
|
|
52
|
+
const sessionPath = path.join(TASKS_DIR, sessionId);
|
|
53
|
+
const tasks = [];
|
|
54
|
+
|
|
55
|
+
const files = fs.readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
try {
|
|
58
|
+
const content = fs.readFileSync(path.join(sessionPath, file), 'utf-8');
|
|
59
|
+
const task = JSON.parse(content);
|
|
60
|
+
task.sessionId = sessionId;
|
|
61
|
+
tasks.push(task);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error(`Error reading ${file}:`, e.message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
68
|
+
|
|
69
|
+
const metadata = getSessionMetadataCallback ? getSessionMetadataCallback(sessionId) : { repoName: null, firstPrompt: null };
|
|
70
|
+
const hasInProgress = tasks.some(t => t.status === 'in_progress');
|
|
71
|
+
const sessionStatus = hasInProgress ? 'working' : 'idle';
|
|
72
|
+
|
|
73
|
+
sessions.push({
|
|
74
|
+
sessionId,
|
|
75
|
+
tasks,
|
|
76
|
+
customName: customNames[sessionId] || null,
|
|
77
|
+
repoName: metadata.repoName,
|
|
78
|
+
firstPrompt: metadata.firstPrompt,
|
|
79
|
+
status: sessionStatus,
|
|
80
|
+
stats: {
|
|
81
|
+
total: tasks.length,
|
|
82
|
+
pending: tasks.filter(t => t.status === 'pending').length,
|
|
83
|
+
in_progress: tasks.filter(t => t.status === 'in_progress').length,
|
|
84
|
+
completed: tasks.filter(t => t.status === 'completed').length
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
sessions.sort((a, b) => b.tasks.length - a.tasks.length);
|
|
90
|
+
return sessions;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get tasks for a specific session
|
|
95
|
+
* @param {string} sessionId - Session ID
|
|
96
|
+
* @returns {Object} Session object with tasks
|
|
97
|
+
*/
|
|
98
|
+
function getTasksForSession(sessionId) {
|
|
99
|
+
const sessionPath = path.join(TASKS_DIR, sessionId);
|
|
100
|
+
|
|
101
|
+
if (!fs.existsSync(sessionPath)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const tasks = [];
|
|
106
|
+
const files = fs.readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
107
|
+
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
try {
|
|
110
|
+
const content = fs.readFileSync(path.join(sessionPath, file), 'utf-8');
|
|
111
|
+
const task = JSON.parse(content);
|
|
112
|
+
task.sessionId = sessionId;
|
|
113
|
+
tasks.push(task);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error(`Error reading ${file}:`, e.message);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
sessionId,
|
|
123
|
+
tasks,
|
|
124
|
+
stats: {
|
|
125
|
+
total: tasks.length,
|
|
126
|
+
pending: tasks.filter(t => t.status === 'pending').length,
|
|
127
|
+
in_progress: tasks.filter(t => t.status === 'in_progress').length,
|
|
128
|
+
completed: tasks.filter(t => t.status === 'completed').length
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Update a task
|
|
135
|
+
* @param {string} sessionId - Session ID
|
|
136
|
+
* @param {string} taskId - Task ID
|
|
137
|
+
* @param {Object} updates - Updates to apply
|
|
138
|
+
* @returns {Object} Result with success flag or error
|
|
139
|
+
*/
|
|
140
|
+
function updateTask(sessionId, taskId, updates) {
|
|
141
|
+
const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
|
|
142
|
+
|
|
143
|
+
if (!fs.existsSync(taskPath)) {
|
|
144
|
+
return { error: 'Task not found' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const content = fs.readFileSync(taskPath, 'utf-8');
|
|
149
|
+
const task = JSON.parse(content);
|
|
150
|
+
const previousStatus = task.status;
|
|
151
|
+
|
|
152
|
+
if (updates.status) task.status = updates.status;
|
|
153
|
+
if (updates.subject !== undefined) task.subject = updates.subject;
|
|
154
|
+
if (updates.description !== undefined) task.description = updates.description;
|
|
155
|
+
if (updates.activeForm !== undefined) task.activeForm = updates.activeForm;
|
|
156
|
+
if (updates.blocks !== undefined) task.blocks = updates.blocks;
|
|
157
|
+
if (updates.blockedBy !== undefined) task.blockedBy = updates.blockedBy;
|
|
158
|
+
|
|
159
|
+
fs.writeFileSync(taskPath, JSON.stringify(task, null, 2));
|
|
160
|
+
|
|
161
|
+
eventBus.emit(EventTypes.TASK_UPDATED, { sessionId, taskId, task, updates });
|
|
162
|
+
|
|
163
|
+
if (updates.status && updates.status !== previousStatus) {
|
|
164
|
+
eventBus.emit(EventTypes.TASK_STATUS_CHANGED, {
|
|
165
|
+
sessionId, taskId, status: updates.status, previousStatus, task
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { success: true, task };
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { error: e.message };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Bulk update multiple tasks
|
|
177
|
+
* @param {string} sessionId - Session ID
|
|
178
|
+
* @param {Array} taskIds - Array of task IDs
|
|
179
|
+
* @param {Object} updates - Updates to apply
|
|
180
|
+
* @returns {Object} Result with count of updated tasks
|
|
181
|
+
*/
|
|
182
|
+
function bulkUpdateTasks(sessionId, taskIds, updates) {
|
|
183
|
+
let updated = 0;
|
|
184
|
+
|
|
185
|
+
for (const taskId of taskIds) {
|
|
186
|
+
const result = updateTask(sessionId, taskId, updates);
|
|
187
|
+
if (result.success) {
|
|
188
|
+
updated++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { ok: true, updated };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Create a new task
|
|
197
|
+
* @param {string} sessionId - Session ID
|
|
198
|
+
* @param {Object} taskData - Task data
|
|
199
|
+
* @returns {Object} Result with success flag and task
|
|
200
|
+
*/
|
|
201
|
+
function createTask(sessionId, taskData) {
|
|
202
|
+
const sessionPath = path.join(TASKS_DIR, sessionId);
|
|
203
|
+
|
|
204
|
+
if (!fs.existsSync(sessionPath)) {
|
|
205
|
+
return { error: 'Session not found' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const files = fs.readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
210
|
+
const ids = files.map(f => parseInt(f.replace('.json', ''))).filter(n => !isNaN(n));
|
|
211
|
+
const nextId = ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
212
|
+
|
|
213
|
+
const task = {
|
|
214
|
+
id: String(nextId),
|
|
215
|
+
subject: taskData.subject || 'New Task',
|
|
216
|
+
description: taskData.description || '',
|
|
217
|
+
activeForm: taskData.activeForm || '',
|
|
218
|
+
status: taskData.status || 'pending',
|
|
219
|
+
blocks: taskData.blocks || [],
|
|
220
|
+
blockedBy: taskData.blockedBy || []
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
fs.writeFileSync(path.join(sessionPath, `${nextId}.json`), JSON.stringify(task, null, 2));
|
|
224
|
+
|
|
225
|
+
eventBus.emit(EventTypes.TASK_CREATED, { sessionId, task });
|
|
226
|
+
|
|
227
|
+
return { success: true, task };
|
|
228
|
+
} catch (e) {
|
|
229
|
+
return { error: e.message };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Delete a task
|
|
235
|
+
* @param {string} sessionId - Session ID
|
|
236
|
+
* @param {string} taskId - Task ID
|
|
237
|
+
* @returns {Object} Result with success flag
|
|
238
|
+
*/
|
|
239
|
+
function deleteTask(sessionId, taskId) {
|
|
240
|
+
const taskPath = path.join(TASKS_DIR, sessionId, `${taskId}.json`);
|
|
241
|
+
|
|
242
|
+
if (!fs.existsSync(taskPath)) {
|
|
243
|
+
return { error: 'Task not found' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
fs.unlinkSync(taskPath);
|
|
248
|
+
return { success: true };
|
|
249
|
+
} catch (e) {
|
|
250
|
+
return { error: e.message };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
loadAllTasks,
|
|
256
|
+
getTasksForSession,
|
|
257
|
+
updateTask,
|
|
258
|
+
bulkUpdateTasks,
|
|
259
|
+
createTask,
|
|
260
|
+
deleteTask,
|
|
261
|
+
setCustomNamesCallback,
|
|
262
|
+
setMetadataCallback
|
|
263
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todos Data Module - Todo loading, updating, and creation
|
|
3
|
+
*
|
|
4
|
+
* Handles reading and writing todo files from the Claude todos directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const { TODOS_DIR } = require('../core/config');
|
|
11
|
+
const { eventBus, EventTypes } = require('../core/event-bus');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load all todos from all sessions
|
|
15
|
+
* @returns {Object} Object mapping sessionId to todo data
|
|
16
|
+
*/
|
|
17
|
+
function loadAllTodos() {
|
|
18
|
+
const todosBySession = {};
|
|
19
|
+
|
|
20
|
+
if (!fs.existsSync(TODOS_DIR)) {
|
|
21
|
+
return todosBySession;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const files = fs.readdirSync(TODOS_DIR).filter(f => f.endsWith('.json'));
|
|
26
|
+
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
try {
|
|
29
|
+
// Extract session ID from filename (format: {sessionId}-agent-{sessionId}.json)
|
|
30
|
+
const match = file.match(/^([a-f0-9-]+)-agent-/);
|
|
31
|
+
if (!match) continue;
|
|
32
|
+
|
|
33
|
+
const sessionId = match[1];
|
|
34
|
+
const content = fs.readFileSync(path.join(TODOS_DIR, file), 'utf-8');
|
|
35
|
+
const todos = JSON.parse(content);
|
|
36
|
+
|
|
37
|
+
// Skip empty arrays
|
|
38
|
+
if (!Array.isArray(todos) || todos.length === 0) continue;
|
|
39
|
+
|
|
40
|
+
// Add index-based IDs if not present
|
|
41
|
+
const todosWithIds = todos.map((todo, idx) => ({
|
|
42
|
+
...todo,
|
|
43
|
+
id: todo.id || String(idx + 1)
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
todosBySession[sessionId] = {
|
|
47
|
+
sessionId,
|
|
48
|
+
todos: todosWithIds,
|
|
49
|
+
stats: {
|
|
50
|
+
total: todosWithIds.length,
|
|
51
|
+
pending: todosWithIds.filter(t => t.status === 'pending').length,
|
|
52
|
+
in_progress: todosWithIds.filter(t => t.status === 'in_progress').length,
|
|
53
|
+
completed: todosWithIds.filter(t => t.status === 'completed').length
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// Skip invalid files
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.error('Error loading todos:', e.message);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return todosBySession;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get todos for a specific session
|
|
69
|
+
* @param {string} sessionId - Session ID
|
|
70
|
+
* @returns {Object} Session object with todos
|
|
71
|
+
*/
|
|
72
|
+
function getTodosForSession(sessionId) {
|
|
73
|
+
const emptyResult = { sessionId, todos: [], stats: { total: 0, pending: 0, in_progress: 0, completed: 0 } };
|
|
74
|
+
|
|
75
|
+
if (!fs.existsSync(TODOS_DIR)) {
|
|
76
|
+
return emptyResult;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Look for file matching the session ID pattern
|
|
80
|
+
const filename = `${sessionId}-agent-${sessionId}.json`;
|
|
81
|
+
const filepath = path.join(TODOS_DIR, filename);
|
|
82
|
+
|
|
83
|
+
if (!fs.existsSync(filepath)) {
|
|
84
|
+
return emptyResult;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const content = fs.readFileSync(filepath, 'utf-8');
|
|
89
|
+
const todos = JSON.parse(content);
|
|
90
|
+
|
|
91
|
+
if (!Array.isArray(todos)) {
|
|
92
|
+
return emptyResult;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const todosWithIds = todos.map((todo, idx) => ({
|
|
96
|
+
...todo,
|
|
97
|
+
id: todo.id || String(idx + 1)
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
sessionId,
|
|
102
|
+
todos: todosWithIds,
|
|
103
|
+
stats: {
|
|
104
|
+
total: todosWithIds.length,
|
|
105
|
+
pending: todosWithIds.filter(t => t.status === 'pending').length,
|
|
106
|
+
in_progress: todosWithIds.filter(t => t.status === 'in_progress').length,
|
|
107
|
+
completed: todosWithIds.filter(t => t.status === 'completed').length
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return emptyResult;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Update a todo
|
|
117
|
+
* @param {string} sessionId - Session ID
|
|
118
|
+
* @param {number} todoIndex - Index of the todo
|
|
119
|
+
* @param {Object} updates - Updates to apply
|
|
120
|
+
* @returns {Object} Result with success flag or error
|
|
121
|
+
*/
|
|
122
|
+
function updateTodo(sessionId, todoIndex, updates) {
|
|
123
|
+
const filename = `${sessionId}-agent-${sessionId}.json`;
|
|
124
|
+
const filepath = path.join(TODOS_DIR, filename);
|
|
125
|
+
|
|
126
|
+
if (!fs.existsSync(filepath)) {
|
|
127
|
+
return { error: 'Todo file not found' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const content = fs.readFileSync(filepath, 'utf-8');
|
|
132
|
+
const todos = JSON.parse(content);
|
|
133
|
+
|
|
134
|
+
if (!Array.isArray(todos) || todoIndex >= todos.length) {
|
|
135
|
+
return { error: 'Todo not found' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const previousStatus = todos[todoIndex].status;
|
|
139
|
+
|
|
140
|
+
if (updates.status) todos[todoIndex].status = updates.status;
|
|
141
|
+
if (updates.content !== undefined) todos[todoIndex].content = updates.content;
|
|
142
|
+
if (updates.activeForm !== undefined) todos[todoIndex].activeForm = updates.activeForm;
|
|
143
|
+
|
|
144
|
+
fs.writeFileSync(filepath, JSON.stringify(todos, null, 2));
|
|
145
|
+
|
|
146
|
+
const todo = { ...todos[todoIndex], id: todos[todoIndex].id || String(todoIndex + 1) };
|
|
147
|
+
|
|
148
|
+
// Emit events
|
|
149
|
+
eventBus.emit(EventTypes.TODO_UPDATED, { sessionId, todoIndex, todo, updates });
|
|
150
|
+
|
|
151
|
+
if (updates.status && updates.status !== previousStatus) {
|
|
152
|
+
eventBus.emit(EventTypes.TODO_STATUS_CHANGED, {
|
|
153
|
+
sessionId, todoIndex, status: updates.status, previousStatus, todo
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { success: true, todo };
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return { error: e.message };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Create a new todo
|
|
165
|
+
* @param {string} sessionId - Session ID
|
|
166
|
+
* @param {Object} todoData - Todo data
|
|
167
|
+
* @returns {Object} Result with success flag and todo
|
|
168
|
+
*/
|
|
169
|
+
function createTodo(sessionId, todoData) {
|
|
170
|
+
const filename = `${sessionId}-agent-${sessionId}.json`;
|
|
171
|
+
const filepath = path.join(TODOS_DIR, filename);
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
let todos = [];
|
|
175
|
+
if (fs.existsSync(filepath)) {
|
|
176
|
+
const content = fs.readFileSync(filepath, 'utf-8');
|
|
177
|
+
todos = JSON.parse(content);
|
|
178
|
+
if (!Array.isArray(todos)) todos = [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const todo = {
|
|
182
|
+
content: todoData.content || 'New todo',
|
|
183
|
+
status: todoData.status || 'pending',
|
|
184
|
+
activeForm: todoData.activeForm || ''
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
todos.push(todo);
|
|
188
|
+
fs.writeFileSync(filepath, JSON.stringify(todos, null, 2));
|
|
189
|
+
|
|
190
|
+
const todoWithId = { ...todo, id: String(todos.length) };
|
|
191
|
+
|
|
192
|
+
eventBus.emit(EventTypes.TODO_CREATED, { sessionId, todo: todoWithId });
|
|
193
|
+
|
|
194
|
+
return { success: true, todo: todoWithId };
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return { error: e.message };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
loadAllTodos,
|
|
202
|
+
getTodosForSession,
|
|
203
|
+
updateTodo,
|
|
204
|
+
createTodo
|
|
205
|
+
};
|