create-walle 0.1.0
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/create-walle.js +134 -0
- package/package.json +18 -0
- package/template/.env.example +40 -0
- package/template/CLAUDE.md +12 -0
- package/template/LICENSE +21 -0
- package/template/README.md +167 -0
- package/template/bin/setup.js +100 -0
- package/template/claude-code-skill.md +60 -0
- package/template/claude-task-manager/api-prompts.js +1841 -0
- package/template/claude-task-manager/api-reviews.js +275 -0
- package/template/claude-task-manager/approval-agent.js +454 -0
- package/template/claude-task-manager/bin/restart-ctm.sh +16 -0
- package/template/claude-task-manager/db.js +1721 -0
- package/template/claude-task-manager/docs/PROMPT-MANAGEMENT-DESIGN.md +631 -0
- package/template/claude-task-manager/git-utils.js +214 -0
- package/template/claude-task-manager/package-lock.json +1607 -0
- package/template/claude-task-manager/package.json +31 -0
- package/template/claude-task-manager/prompt-harvest.js +1148 -0
- package/template/claude-task-manager/public/css/prompts.css +880 -0
- package/template/claude-task-manager/public/css/reviews.css +430 -0
- package/template/claude-task-manager/public/css/walle.css +732 -0
- package/template/claude-task-manager/public/favicon.ico +0 -0
- package/template/claude-task-manager/public/icon.svg +37 -0
- package/template/claude-task-manager/public/index.html +8346 -0
- package/template/claude-task-manager/public/js/prompts.js +3159 -0
- package/template/claude-task-manager/public/js/reviews.js +1292 -0
- package/template/claude-task-manager/public/js/walle.js +3081 -0
- package/template/claude-task-manager/public/manifest.json +13 -0
- package/template/claude-task-manager/public/prompts.html +4353 -0
- package/template/claude-task-manager/public/setup.html +216 -0
- package/template/claude-task-manager/queue-engine.js +404 -0
- package/template/claude-task-manager/server-state.js +5 -0
- package/template/claude-task-manager/server.js +2254 -0
- package/template/claude-task-manager/session-utils.js +124 -0
- package/template/claude-task-manager/start.sh +17 -0
- package/template/claude-task-manager/tests/test-ai-search.js +61 -0
- package/template/claude-task-manager/tests/test-editor-ux.js +76 -0
- package/template/claude-task-manager/tests/test-editor-ux2.js +51 -0
- package/template/claude-task-manager/tests/test-features-v2.js +127 -0
- package/template/claude-task-manager/tests/test-insights-cached.js +78 -0
- package/template/claude-task-manager/tests/test-insights.js +124 -0
- package/template/claude-task-manager/tests/test-permissions-v2.js +127 -0
- package/template/claude-task-manager/tests/test-permissions.js +122 -0
- package/template/claude-task-manager/tests/test-pin.js +51 -0
- package/template/claude-task-manager/tests/test-prompts.js +164 -0
- package/template/claude-task-manager/tests/test-recent-sessions.js +96 -0
- package/template/claude-task-manager/tests/test-review.js +104 -0
- package/template/claude-task-manager/tests/test-send-dropdown.js +76 -0
- package/template/claude-task-manager/tests/test-send-final.js +30 -0
- package/template/claude-task-manager/tests/test-send-fixes.js +76 -0
- package/template/claude-task-manager/tests/test-send-integration.js +107 -0
- package/template/claude-task-manager/tests/test-send-visual.js +34 -0
- package/template/claude-task-manager/tests/test-session-create.js +147 -0
- package/template/claude-task-manager/tests/test-sidebar-ux.js +83 -0
- package/template/claude-task-manager/tests/test-url-hash.js +68 -0
- package/template/claude-task-manager/tests/test-ux-crop.js +34 -0
- package/template/claude-task-manager/tests/test-ux-review.js +130 -0
- package/template/claude-task-manager/tests/test-zoom-card.js +76 -0
- package/template/claude-task-manager/tests/test-zoom.js +92 -0
- package/template/claude-task-manager/tests/test-zoom2.js +67 -0
- package/template/docs/site/api/README.md +187 -0
- package/template/docs/site/guides/claude-code.md +58 -0
- package/template/docs/site/guides/configuration.md +96 -0
- package/template/docs/site/guides/quickstart.md +158 -0
- package/template/docs/site/index.md +14 -0
- package/template/docs/site/skills/README.md +135 -0
- package/template/wall-e/.dockerignore +11 -0
- package/template/wall-e/Dockerfile +25 -0
- package/template/wall-e/adapters/adapter-base.js +37 -0
- package/template/wall-e/adapters/ctm.js +193 -0
- package/template/wall-e/adapters/slack.js +56 -0
- package/template/wall-e/agent.js +319 -0
- package/template/wall-e/api-walle.js +1073 -0
- package/template/wall-e/brain.js +1235 -0
- package/template/wall-e/channels/agent-api.js +172 -0
- package/template/wall-e/channels/channel-base.js +14 -0
- package/template/wall-e/channels/imessage-channel.js +113 -0
- package/template/wall-e/channels/slack-channel.js +118 -0
- package/template/wall-e/chat.js +778 -0
- package/template/wall-e/decision/confidence.js +93 -0
- package/template/wall-e/deploy.sh +35 -0
- package/template/wall-e/docs/specs/2026-04-01-publish-plan.md +112 -0
- package/template/wall-e/docs/specs/SKILL-FORMAT.md +326 -0
- package/template/wall-e/extraction/contradiction.js +168 -0
- package/template/wall-e/extraction/knowledge-extractor.js +190 -0
- package/template/wall-e/fly.toml +24 -0
- package/template/wall-e/loops/ingest.js +34 -0
- package/template/wall-e/loops/reflect.js +63 -0
- package/template/wall-e/loops/tasks.js +487 -0
- package/template/wall-e/loops/think.js +125 -0
- package/template/wall-e/package-lock.json +533 -0
- package/template/wall-e/package.json +18 -0
- package/template/wall-e/scripts/ingest-slack-search.js +85 -0
- package/template/wall-e/scripts/pull-slack-via-claude.js +98 -0
- package/template/wall-e/scripts/slack-backfill.js +295 -0
- package/template/wall-e/scripts/slack-channel-history.js +454 -0
- package/template/wall-e/server.js +93 -0
- package/template/wall-e/skills/_bundled/email-digest/SKILL.md +95 -0
- package/template/wall-e/skills/_bundled/email-sync/SKILL.md +65 -0
- package/template/wall-e/skills/_bundled/email-sync/mail-reader.jxa +104 -0
- package/template/wall-e/skills/_bundled/email-sync/run.js +213 -0
- package/template/wall-e/skills/_bundled/google-calendar/SKILL.md +73 -0
- package/template/wall-e/skills/_bundled/google-calendar/cal-reader.swift +81 -0
- package/template/wall-e/skills/_bundled/google-calendar/run.js +181 -0
- package/template/wall-e/skills/_bundled/memory-search/SKILL.md +92 -0
- package/template/wall-e/skills/_bundled/morning-briefing/SKILL.md +131 -0
- package/template/wall-e/skills/_bundled/morning-briefing/run.js +264 -0
- package/template/wall-e/skills/_bundled/slack-backfill/SKILL.md +60 -0
- package/template/wall-e/skills/_bundled/slack-sync/SKILL.md +55 -0
- package/template/wall-e/skills/claude-code-reader.js +144 -0
- package/template/wall-e/skills/mcp-client.js +407 -0
- package/template/wall-e/skills/skill-executor.js +163 -0
- package/template/wall-e/skills/skill-loader.js +410 -0
- package/template/wall-e/skills/skill-planner.js +88 -0
- package/template/wall-e/skills/slack-ingest.js +329 -0
- package/template/wall-e/skills/slack-pull-live.js +270 -0
- package/template/wall-e/skills/tool-executor.js +188 -0
- package/template/wall-e/tests/adapter-base.test.js +20 -0
- package/template/wall-e/tests/adapter-ctm.test.js +122 -0
- package/template/wall-e/tests/adapter-slack.test.js +98 -0
- package/template/wall-e/tests/agent-api.test.js +256 -0
- package/template/wall-e/tests/api-walle.test.js +222 -0
- package/template/wall-e/tests/brain.test.js +602 -0
- package/template/wall-e/tests/channels.test.js +104 -0
- package/template/wall-e/tests/chat.test.js +103 -0
- package/template/wall-e/tests/confidence.test.js +134 -0
- package/template/wall-e/tests/contradiction.test.js +217 -0
- package/template/wall-e/tests/ingest.test.js +113 -0
- package/template/wall-e/tests/mcp-client.test.js +71 -0
- package/template/wall-e/tests/reflect.test.js +103 -0
- package/template/wall-e/tests/server.test.js +111 -0
- package/template/wall-e/tests/skills.test.js +198 -0
- package/template/wall-e/tests/slack-ingest.test.js +103 -0
- package/template/wall-e/tests/think.test.js +435 -0
- package/template/wall-e/tools/local-tools.js +697 -0
- package/template/wall-e/tools/slack-mcp.js +290 -0
|
@@ -0,0 +1,2254 @@
|
|
|
1
|
+
// Load .env file from project root (no dependencies)
|
|
2
|
+
try {
|
|
3
|
+
const envPath = require('path').resolve(__dirname, '..', '.env');
|
|
4
|
+
require('fs').readFileSync(envPath, 'utf8').split('\n').forEach(line => {
|
|
5
|
+
const match = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
|
|
6
|
+
if (match && !process.env[match[1]]) process.env[match[1]] = match[2];
|
|
7
|
+
});
|
|
8
|
+
} catch {}
|
|
9
|
+
|
|
10
|
+
const http = require('http');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const { WebSocketServer } = require('ws');
|
|
16
|
+
const pty = require('node-pty');
|
|
17
|
+
const dbModule = require('./db');
|
|
18
|
+
const { handlePromptApi, queueEngine, importPermissionsToDb } = require('./api-prompts');
|
|
19
|
+
const harvest = require('./prompt-harvest');
|
|
20
|
+
const approvalAgent = require('./approval-agent');
|
|
21
|
+
const { handleReviewApi, checkForChanges } = require('./api-reviews');
|
|
22
|
+
const { sessions } = require('./server-state');
|
|
23
|
+
|
|
24
|
+
// WALL-E API now served directly by the WALL-E process (port 3457)
|
|
25
|
+
// Frontend connects to it via CORS. Keep proxy as fallback for environments where WALL-E isn't running separately.
|
|
26
|
+
let handleWalleApi;
|
|
27
|
+
try { handleWalleApi = require('../wall-e/api-walle').handleWalleApi; } catch {}
|
|
28
|
+
|
|
29
|
+
// --- Config ---
|
|
30
|
+
const CONFIG_DIR = process.env.CTM_DATA_DIR || path.join(process.env.HOME, '.walle', 'data');
|
|
31
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
32
|
+
const PORT = parseInt(process.env.CTM_PORT || '3456', 10);
|
|
33
|
+
const HOST = process.env.CTM_HOST || '127.0.0.1';
|
|
34
|
+
|
|
35
|
+
function loadConfig() {
|
|
36
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
37
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
38
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
const config = { token: crypto.randomBytes(32).toString('hex') };
|
|
41
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
42
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const config = loadConfig();
|
|
47
|
+
|
|
48
|
+
// --- Initialize SQLite Database ---
|
|
49
|
+
dbModule.initDb();
|
|
50
|
+
dbModule.startDailyBackup();
|
|
51
|
+
queueEngine.init();
|
|
52
|
+
importPermissionsToDb();
|
|
53
|
+
migrateAnalysisCacheToDb();
|
|
54
|
+
|
|
55
|
+
// Lifecycle: incremental harvest + refresh on startup (async, non-blocking)
|
|
56
|
+
setTimeout(() => harvest.runIncrementalLifecycleRefresh(), 3000);
|
|
57
|
+
// Lifecycle: refresh every 10 minutes
|
|
58
|
+
setInterval(() => harvest.runIncrementalLifecycleRefresh(), 10 * 60 * 1000);
|
|
59
|
+
// Permissions: scrub invalid rules every 5 minutes (Claude Code can write bad patterns)
|
|
60
|
+
setInterval(() => { try { importPermissionsToDb(); } catch {} }, 5 * 60 * 1000);
|
|
61
|
+
|
|
62
|
+
// --- HTTP Server ---
|
|
63
|
+
const MIME_TYPES = {
|
|
64
|
+
'.html': 'text/html',
|
|
65
|
+
'.js': 'text/javascript',
|
|
66
|
+
'.css': 'text/css',
|
|
67
|
+
'.json': 'application/json',
|
|
68
|
+
'.png': 'image/png',
|
|
69
|
+
'.svg': 'image/svg+xml',
|
|
70
|
+
'.ico': 'image/x-icon',
|
|
71
|
+
'.webmanifest': 'application/manifest+json',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function getTokenFromCookie(req) {
|
|
75
|
+
const cookie = req.headers.cookie || '';
|
|
76
|
+
const match = cookie.match(/(?:^|;\s*)ctm_token=([^;]+)/);
|
|
77
|
+
return match ? match[1] : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getAuthToken(req, url) {
|
|
81
|
+
return url.searchParams.get('token')
|
|
82
|
+
|| req.headers.authorization?.replace('Bearer ', '')
|
|
83
|
+
|| getTokenFromCookie(req);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isLocalhost(req) {
|
|
87
|
+
const addr = req.socket?.remoteAddress;
|
|
88
|
+
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const server = http.createServer((req, res) => {
|
|
92
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
93
|
+
|
|
94
|
+
// If token is in URL query param for a page request, set cookie and redirect to clean URL
|
|
95
|
+
if (!url.pathname.startsWith('/api/') && url.searchParams.get('token')) {
|
|
96
|
+
const token = url.searchParams.get('token');
|
|
97
|
+
if (token === config.token) {
|
|
98
|
+
url.searchParams.delete('token');
|
|
99
|
+
const cleanUrl = url.pathname + (url.search || '') + (url.hash || '');
|
|
100
|
+
res.writeHead(302, {
|
|
101
|
+
'Location': cleanUrl,
|
|
102
|
+
'Set-Cookie': `ctm_token=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`,
|
|
103
|
+
});
|
|
104
|
+
res.end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// API routes
|
|
110
|
+
if (url.pathname.startsWith('/api/')) {
|
|
111
|
+
if (!isLocalhost(req)) {
|
|
112
|
+
const authToken = getAuthToken(req, url);
|
|
113
|
+
if (authToken !== config.token) {
|
|
114
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Wrap response to broadcast data-changed on successful mutations
|
|
120
|
+
if (['POST', 'PUT', 'DELETE'].includes(req.method)) {
|
|
121
|
+
const origEnd = res.end.bind(res);
|
|
122
|
+
res.end = function(data) {
|
|
123
|
+
origEnd(data);
|
|
124
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
125
|
+
// Extract resource type from URL path
|
|
126
|
+
const resource = url.pathname.replace(/^\/api\//, '').split('/')[0];
|
|
127
|
+
broadcastDataChanged(resource, req.method);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Try prompt editor API routes first
|
|
132
|
+
const handled = handlePromptApi(req, res, url);
|
|
133
|
+
if (handled !== false) return;
|
|
134
|
+
// Try review API routes
|
|
135
|
+
const reviewHandled = handleReviewApi(req, res, url);
|
|
136
|
+
if (reviewHandled !== false) return;
|
|
137
|
+
// Try WALL-E API routes
|
|
138
|
+
if (handleWalleApi) {
|
|
139
|
+
const walleHandled = handleWalleApi(req, res, url);
|
|
140
|
+
if (walleHandled !== false) return;
|
|
141
|
+
}
|
|
142
|
+
handleApi(req, res, url);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Redirect to setup page on first run (no API key configured)
|
|
147
|
+
if (url.pathname === '/' && setup.needsSetup()) {
|
|
148
|
+
res.writeHead(302, { 'Location': '/setup.html' });
|
|
149
|
+
res.end();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Static files
|
|
154
|
+
let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
155
|
+
const publicDir = path.join(__dirname, 'public');
|
|
156
|
+
filePath = path.resolve(publicDir, '.' + filePath);
|
|
157
|
+
|
|
158
|
+
// Prevent path traversal
|
|
159
|
+
if (!filePath.startsWith(publicDir)) {
|
|
160
|
+
res.writeHead(403);
|
|
161
|
+
res.end('Forbidden');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ext = path.extname(filePath);
|
|
166
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
167
|
+
|
|
168
|
+
fs.readFile(filePath, (err, data) => {
|
|
169
|
+
if (err) {
|
|
170
|
+
res.writeHead(404);
|
|
171
|
+
res.end('Not Found');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
res.writeHead(200, {
|
|
175
|
+
'Content-Type': contentType,
|
|
176
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
177
|
+
'Pragma': 'no-cache',
|
|
178
|
+
'Expires': '0',
|
|
179
|
+
});
|
|
180
|
+
res.end(data);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// --- API Handlers ---
|
|
185
|
+
function handleApi(req, res, url) {
|
|
186
|
+
// --- Setup API ---
|
|
187
|
+
if (url.pathname === '/api/setup/status' && req.method === 'GET') {
|
|
188
|
+
const envPath = path.resolve(__dirname, '..', '.env');
|
|
189
|
+
let hasApiKey = !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_BASE_URL);
|
|
190
|
+
let ownerName = process.env.WALLE_OWNER_NAME || '';
|
|
191
|
+
if (!hasApiKey) {
|
|
192
|
+
try {
|
|
193
|
+
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
194
|
+
hasApiKey = /^ANTHROPIC_API_KEY=\S+/m.test(envContent);
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
let slackConnected = false;
|
|
198
|
+
try {
|
|
199
|
+
const tokPath = path.join(process.env.HOME, '.walle', 'data', 'oauth-tokens', 'slack.json');
|
|
200
|
+
slackConnected = fs.existsSync(tokPath);
|
|
201
|
+
} catch {}
|
|
202
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
203
|
+
res.end(JSON.stringify({ owner_name: ownerName, has_api_key: hasApiKey, slack_connected: slackConnected, needs_setup: setup.needsSetup() }));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (url.pathname === '/api/setup/save' && req.method === 'POST') {
|
|
207
|
+
let body = '';
|
|
208
|
+
let bodyLen = 0;
|
|
209
|
+
req.on('data', c => {
|
|
210
|
+
bodyLen += c.length;
|
|
211
|
+
if (bodyLen > 8192) { req.destroy(); return; } // 8KB limit
|
|
212
|
+
body += c;
|
|
213
|
+
});
|
|
214
|
+
req.on('end', () => {
|
|
215
|
+
try {
|
|
216
|
+
const data = JSON.parse(body);
|
|
217
|
+
// Validate and sanitize inputs
|
|
218
|
+
const ownerName = typeof data.owner_name === 'string'
|
|
219
|
+
? data.owner_name.replace(/[\r\n=]/g, '').trim().slice(0, 200)
|
|
220
|
+
: '';
|
|
221
|
+
const apiKey = typeof data.api_key === 'string'
|
|
222
|
+
? data.api_key.replace(/[\r\n\s]/g, '').slice(0, 200)
|
|
223
|
+
: '';
|
|
224
|
+
if (apiKey && !/^sk-ant-/.test(apiKey)) {
|
|
225
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
226
|
+
res.end(JSON.stringify({ error: 'API key must start with sk-ant-' }));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const envPath = path.resolve(__dirname, '..', '.env');
|
|
230
|
+
const lines = [];
|
|
231
|
+
// Read existing .env or start fresh
|
|
232
|
+
try {
|
|
233
|
+
const existing = fs.readFileSync(envPath, 'utf8');
|
|
234
|
+
for (const line of existing.split('\n')) {
|
|
235
|
+
if (line.match(/^#?\s*ANTHROPIC_API_KEY=/) && apiKey) continue;
|
|
236
|
+
if (line.match(/^#?\s*WALLE_OWNER_NAME=/) && ownerName) continue;
|
|
237
|
+
lines.push(line);
|
|
238
|
+
}
|
|
239
|
+
} catch { lines.push('# Wall-E configuration'); lines.push(''); }
|
|
240
|
+
// Add values after the header comment
|
|
241
|
+
if (ownerName) {
|
|
242
|
+
const insertIdx = lines.findIndex(l => !l.startsWith('#') && l.trim() !== '') || lines.length;
|
|
243
|
+
lines.splice(insertIdx, 0, `WALLE_OWNER_NAME=${ownerName}`);
|
|
244
|
+
process.env.WALLE_OWNER_NAME = ownerName;
|
|
245
|
+
}
|
|
246
|
+
if (apiKey) {
|
|
247
|
+
lines.push(`ANTHROPIC_API_KEY=${apiKey}`);
|
|
248
|
+
process.env.ANTHROPIC_API_KEY = apiKey;
|
|
249
|
+
}
|
|
250
|
+
fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 });
|
|
251
|
+
setup.clearSetupCache(); // so next / request goes to dashboard
|
|
252
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
253
|
+
res.end(JSON.stringify({ ok: true }));
|
|
254
|
+
} catch (e) {
|
|
255
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
256
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (url.pathname === '/api/projects' && req.method === 'GET') {
|
|
263
|
+
return apiGetProjects(req, res);
|
|
264
|
+
}
|
|
265
|
+
if (url.pathname === '/api/rules' && req.method === 'GET') {
|
|
266
|
+
return apiGetRules(req, res, url);
|
|
267
|
+
}
|
|
268
|
+
if (url.pathname === '/api/rules' && req.method === 'PUT') {
|
|
269
|
+
return apiPutRules(req, res);
|
|
270
|
+
}
|
|
271
|
+
if (url.pathname === '/api/rules/list' && req.method === 'GET') {
|
|
272
|
+
return apiListRules(req, res);
|
|
273
|
+
}
|
|
274
|
+
if (url.pathname === '/api/recent-sessions' && req.method === 'GET') {
|
|
275
|
+
return apiRecentSessions(req, res, url);
|
|
276
|
+
}
|
|
277
|
+
if (url.pathname === '/api/session/messages' && req.method === 'GET') {
|
|
278
|
+
return apiSessionMessages(req, res, url);
|
|
279
|
+
}
|
|
280
|
+
if (url.pathname === '/api/session' && req.method === 'DELETE') {
|
|
281
|
+
return apiDeleteSession(req, res, url);
|
|
282
|
+
}
|
|
283
|
+
if (url.pathname === '/api/session/truncate' && req.method === 'POST') {
|
|
284
|
+
return apiTruncateSession(req, res, url);
|
|
285
|
+
}
|
|
286
|
+
if (url.pathname === '/api/sessions/clean-empty' && req.method === 'POST') {
|
|
287
|
+
return apiCleanEmptySessions(req, res);
|
|
288
|
+
}
|
|
289
|
+
if (url.pathname === '/api/sessions/ai-search' && req.method === 'POST') {
|
|
290
|
+
return apiAiSearch(req, res);
|
|
291
|
+
}
|
|
292
|
+
if (url.pathname === '/api/sessions/analyze' && req.method === 'POST') {
|
|
293
|
+
return apiAnalyzeSessions(req, res);
|
|
294
|
+
}
|
|
295
|
+
if (url.pathname === '/api/sessions/analysis' && req.method === 'GET') {
|
|
296
|
+
return apiGetAnalysis(req, res);
|
|
297
|
+
}
|
|
298
|
+
if (url.pathname === '/api/sessions/generate-titles' && req.method === 'POST') {
|
|
299
|
+
return apiGenerateTitles(req, res);
|
|
300
|
+
}
|
|
301
|
+
if (url.pathname === '/api/sessions/rename' && req.method === 'POST') {
|
|
302
|
+
return apiRenameSession(req, res);
|
|
303
|
+
}
|
|
304
|
+
// --- Service control endpoints ---
|
|
305
|
+
if (url.pathname === '/api/services/status' && req.method === 'GET') {
|
|
306
|
+
return apiServicesStatus(req, res);
|
|
307
|
+
}
|
|
308
|
+
if (url.pathname === '/api/restart/ctm' && req.method === 'POST') {
|
|
309
|
+
return apiRestartCtm(req, res);
|
|
310
|
+
}
|
|
311
|
+
if (url.pathname === '/api/restart/walle' && req.method === 'POST') {
|
|
312
|
+
return apiRestartWalle(req, res);
|
|
313
|
+
}
|
|
314
|
+
if (url.pathname === '/api/stop/walle' && req.method === 'POST') {
|
|
315
|
+
return apiStopWalle(req, res);
|
|
316
|
+
}
|
|
317
|
+
if (url.pathname === '/api/start/walle' && req.method === 'POST') {
|
|
318
|
+
return apiStartWalle(req, res);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
322
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function apiGetProjects(req, res) {
|
|
326
|
+
const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
|
|
327
|
+
const projects = [];
|
|
328
|
+
|
|
329
|
+
if (fs.existsSync(claudeProjectsDir)) {
|
|
330
|
+
for (const entry of fs.readdirSync(claudeProjectsDir)) {
|
|
331
|
+
// Claude encodes paths by replacing / with -
|
|
332
|
+
const projectPath = entry.startsWith('-') ? entry.replace(/-/g, '/') : entry;
|
|
333
|
+
projects.push({
|
|
334
|
+
id: entry,
|
|
335
|
+
path: projectPath,
|
|
336
|
+
exists: fs.existsSync(projectPath),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
342
|
+
res.end(JSON.stringify(projects));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function decodeProjectEntry(entry) {
|
|
346
|
+
// Claude encodes paths like /Users/alice/my-project as -Users-alice-my-project
|
|
347
|
+
// Naive replace(/-/g, '/') breaks paths with real hyphens (octo-cms -> octo/cms)
|
|
348
|
+
// Use backtracking: try '/' first, fall back to '-' when the final path doesn't exist
|
|
349
|
+
if (!entry.startsWith('-')) return entry;
|
|
350
|
+
const parts = entry.slice(1).split('-');
|
|
351
|
+
if (parts.length > 12) return '/' + parts.join('/'); // cap depth to avoid exponential backtracking
|
|
352
|
+
const memo = new Map();
|
|
353
|
+
function solve(idx, current) {
|
|
354
|
+
const key = `${idx}:${current}`;
|
|
355
|
+
if (memo.has(key)) return memo.get(key);
|
|
356
|
+
let result;
|
|
357
|
+
if (idx >= parts.length) { result = fs.existsSync(current) ? current : null; }
|
|
358
|
+
else {
|
|
359
|
+
result = solve(idx + 1, current + '/' + parts[idx]);
|
|
360
|
+
if (!result) result = solve(idx + 1, current + '-' + parts[idx]);
|
|
361
|
+
}
|
|
362
|
+
memo.set(key, result);
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
return solve(1, '/' + parts[0]) || '/' + parts.join('/'); // fallback to naive
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function apiListRules(req, res) {
|
|
369
|
+
const rules = [];
|
|
370
|
+
const home = process.env.HOME;
|
|
371
|
+
const globalRules = path.join(home, '.claude', 'CLAUDE.md');
|
|
372
|
+
rules.push({
|
|
373
|
+
type: 'global', path: globalRules,
|
|
374
|
+
label: '~/.claude/CLAUDE.md',
|
|
375
|
+
exists: fs.existsSync(globalRules),
|
|
376
|
+
project: 'Global',
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Scan projects for CLAUDE.md files
|
|
380
|
+
const claudeProjectsDir = path.join(home, '.claude', 'projects');
|
|
381
|
+
if (fs.existsSync(claudeProjectsDir)) {
|
|
382
|
+
for (const entry of fs.readdirSync(claudeProjectsDir)) {
|
|
383
|
+
const entryDir = path.join(claudeProjectsDir, entry);
|
|
384
|
+
if (!fs.statSync(entryDir).isDirectory()) continue;
|
|
385
|
+
const projectPath = decodeProjectEntry(entry);
|
|
386
|
+
const projectExists = fs.existsSync(projectPath);
|
|
387
|
+
const shortPath = projectPath.replace(home, '~');
|
|
388
|
+
const projectName = path.basename(projectPath);
|
|
389
|
+
|
|
390
|
+
// Skip orphaned projects where the decoded path doesn't exist on disk
|
|
391
|
+
if (!projectExists) continue;
|
|
392
|
+
|
|
393
|
+
// User rules: ~/.claude/projects/<entry>/CLAUDE.md
|
|
394
|
+
const userRules = path.join(entryDir, 'CLAUDE.md');
|
|
395
|
+
rules.push({
|
|
396
|
+
type: 'project-user', path: userRules,
|
|
397
|
+
label: `.claude/projects/.../CLAUDE.md`,
|
|
398
|
+
exists: fs.existsSync(userRules),
|
|
399
|
+
project: projectName, projectPath: shortPath,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Project root: <projectPath>/CLAUDE.md
|
|
403
|
+
const rootRules = path.join(projectPath, 'CLAUDE.md');
|
|
404
|
+
if (fs.existsSync(rootRules)) {
|
|
405
|
+
rules.push({
|
|
406
|
+
type: 'project-root', path: rootRules,
|
|
407
|
+
label: `CLAUDE.md`,
|
|
408
|
+
exists: true,
|
|
409
|
+
project: projectName, projectPath: shortPath,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Project .claude: <projectPath>/.claude/CLAUDE.md
|
|
414
|
+
const dotRules = path.join(projectPath, '.claude', 'CLAUDE.md');
|
|
415
|
+
if (fs.existsSync(dotRules)) {
|
|
416
|
+
rules.push({
|
|
417
|
+
type: 'project-dot', path: dotRules,
|
|
418
|
+
label: `.claude/CLAUDE.md`,
|
|
419
|
+
exists: true,
|
|
420
|
+
project: projectName, projectPath: shortPath,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
427
|
+
res.end(JSON.stringify(rules));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function isValidRulesPath(filePath) {
|
|
431
|
+
if (!filePath || path.basename(filePath) !== 'CLAUDE.md') return false;
|
|
432
|
+
const resolved = path.resolve(filePath);
|
|
433
|
+
const home = process.env.HOME;
|
|
434
|
+
// Only allow paths under home directory
|
|
435
|
+
if (!resolved.startsWith(home + '/') && resolved !== home) return false;
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function apiGetRules(req, res, url) {
|
|
440
|
+
const filePath = url.searchParams.get('path');
|
|
441
|
+
if (!isValidRulesPath(filePath)) {
|
|
442
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
443
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
let content = '';
|
|
447
|
+
if (fs.existsSync(filePath)) {
|
|
448
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
449
|
+
}
|
|
450
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
451
|
+
res.end(JSON.stringify({ path: filePath, content }));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function apiPutRules(req, res) {
|
|
455
|
+
let body = '';
|
|
456
|
+
req.on('data', chunk => { body += chunk; if (body.length > 1024 * 1024) { req.destroy(); return; } });
|
|
457
|
+
req.on('end', () => {
|
|
458
|
+
try {
|
|
459
|
+
const { path: filePath, content } = JSON.parse(body);
|
|
460
|
+
if (!isValidRulesPath(filePath)) {
|
|
461
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
462
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const dir = path.dirname(filePath);
|
|
466
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
467
|
+
fs.writeFileSync(filePath, content);
|
|
468
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
469
|
+
res.end(JSON.stringify({ ok: true }));
|
|
470
|
+
} catch (e) {
|
|
471
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
472
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Helper: detect sessions spawned by CTM itself (title generation, analysis, etc.)
|
|
478
|
+
const CTM_PROMPT_PATTERNS = [
|
|
479
|
+
/^Generate short, descriptive titles/,
|
|
480
|
+
/^Analyze these Claude Code sessions/,
|
|
481
|
+
/^Analyze this Claude Code session history/,
|
|
482
|
+
/^You are summarizing a group of prompts/,
|
|
483
|
+
/^Session categorization with JSON output/,
|
|
484
|
+
/^Batch (?:generate |session )?title/i,
|
|
485
|
+
/^Return ONLY a JSON/,
|
|
486
|
+
];
|
|
487
|
+
|
|
488
|
+
function isCtmInternalSession(firstMessage) {
|
|
489
|
+
if (!firstMessage) return false;
|
|
490
|
+
return CTM_PROMPT_PATTERNS.some(re => re.test(firstMessage));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Helper: parse a session JSONL file for metadata
|
|
494
|
+
function parseSessionFile(filePath, projectPath, projectEntry) {
|
|
495
|
+
const fileStat = fs.statSync(filePath);
|
|
496
|
+
const modifiedAt = fileStat.mtime.toISOString();
|
|
497
|
+
const sessionId = path.basename(filePath).replace(/\.jsonl(\.bak)?$/, '');
|
|
498
|
+
|
|
499
|
+
// Read first 128KB to get session info (file-history-snapshots can be huge)
|
|
500
|
+
// Also read last 32KB for more recent messages
|
|
501
|
+
const fd = fs.openSync(filePath, 'r');
|
|
502
|
+
const headSize = Math.min(fileStat.size, 131072);
|
|
503
|
+
const headBuf = Buffer.alloc(headSize);
|
|
504
|
+
fs.readSync(fd, headBuf, 0, headSize, 0);
|
|
505
|
+
let chunk = headBuf.toString('utf8');
|
|
506
|
+
|
|
507
|
+
// Also read tail if file is larger than head buffer
|
|
508
|
+
if (fileStat.size > headSize) {
|
|
509
|
+
const tailSize = Math.min(32768, fileStat.size - headSize);
|
|
510
|
+
const tailBuf = Buffer.alloc(tailSize);
|
|
511
|
+
fs.readSync(fd, tailBuf, 0, tailSize, fileStat.size - tailSize);
|
|
512
|
+
chunk += '\n' + tailBuf.toString('utf8');
|
|
513
|
+
}
|
|
514
|
+
fs.closeSync(fd);
|
|
515
|
+
|
|
516
|
+
const lines = chunk.split('\n').filter(Boolean);
|
|
517
|
+
|
|
518
|
+
let firstUserMessage = '';
|
|
519
|
+
let sessionCwd = projectPath;
|
|
520
|
+
let timestamp = modifiedAt;
|
|
521
|
+
let version = '';
|
|
522
|
+
let gitBranch = '';
|
|
523
|
+
let userMsgCount = 0;
|
|
524
|
+
let allUserMessages = [];
|
|
525
|
+
|
|
526
|
+
for (const line of lines) {
|
|
527
|
+
try {
|
|
528
|
+
const entry = JSON.parse(line);
|
|
529
|
+
if (entry.type === 'user' && entry.message?.role === 'user') {
|
|
530
|
+
const content = entry.message.content;
|
|
531
|
+
const text = typeof content === 'string'
|
|
532
|
+
? content
|
|
533
|
+
: Array.isArray(content)
|
|
534
|
+
? (content.find(c => c.type === 'text')?.text || '')
|
|
535
|
+
: '';
|
|
536
|
+
userMsgCount++;
|
|
537
|
+
const isArtifact = /^\[(?:Request interrupted|Tool use|Error|Retrying)/.test(text);
|
|
538
|
+
if (text && !isArtifact) allUserMessages.push(text.slice(0, 200));
|
|
539
|
+
if (!firstUserMessage && text && !isArtifact) {
|
|
540
|
+
firstUserMessage = text.slice(0, 200);
|
|
541
|
+
sessionCwd = entry.cwd || sessionCwd;
|
|
542
|
+
timestamp = entry.timestamp || timestamp;
|
|
543
|
+
version = entry.version || version;
|
|
544
|
+
gitBranch = entry.gitBranch || gitBranch;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} catch { /* skip */ }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Generate a title from the first message
|
|
551
|
+
let title = '';
|
|
552
|
+
if (firstUserMessage) {
|
|
553
|
+
// Take first line, strip markdown, truncate
|
|
554
|
+
title = firstUserMessage.split('\n')[0].replace(/^#+\s*/, '').replace(/[*_`]/g, '').trim();
|
|
555
|
+
if (title.length > 80) title = title.slice(0, 77) + '...';
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Estimate if "empty" — no user messages found
|
|
559
|
+
const isEmpty = userMsgCount === 0;
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
sessionId,
|
|
563
|
+
project: projectPath,
|
|
564
|
+
projectEntry,
|
|
565
|
+
cwd: sessionCwd,
|
|
566
|
+
firstMessage: firstUserMessage,
|
|
567
|
+
title,
|
|
568
|
+
isEmpty,
|
|
569
|
+
userMsgCount,
|
|
570
|
+
modifiedAt,
|
|
571
|
+
timestamp,
|
|
572
|
+
version,
|
|
573
|
+
gitBranch,
|
|
574
|
+
fileSize: fileStat.size,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Helper: iterate all session files
|
|
579
|
+
function getAllSessionFiles() {
|
|
580
|
+
const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
|
|
581
|
+
const results = [];
|
|
582
|
+
|
|
583
|
+
if (!fs.existsSync(claudeProjectsDir)) return results;
|
|
584
|
+
|
|
585
|
+
for (const projectEntry of fs.readdirSync(claudeProjectsDir)) {
|
|
586
|
+
const projectDir = path.join(claudeProjectsDir, projectEntry);
|
|
587
|
+
let stat;
|
|
588
|
+
try { stat = fs.statSync(projectDir); } catch { continue; }
|
|
589
|
+
if (!stat.isDirectory()) continue;
|
|
590
|
+
|
|
591
|
+
const projectPath = decodeProjectEntry(projectEntry);
|
|
592
|
+
let files;
|
|
593
|
+
try { files = fs.readdirSync(projectDir); } catch { continue; }
|
|
594
|
+
|
|
595
|
+
const fileSet = new Set(files);
|
|
596
|
+
for (const file of files) {
|
|
597
|
+
if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.bak')) continue;
|
|
598
|
+
// Skip .jsonl.bak when the .jsonl version exists (Claude Code creates
|
|
599
|
+
// .bak on session migration/compaction — showing both causes duplicates)
|
|
600
|
+
if (file.endsWith('.jsonl.bak') && fileSet.has(file.replace(/\.bak$/, ''))) continue;
|
|
601
|
+
const filePath = path.join(projectDir, file);
|
|
602
|
+
// Extract session ID: strip .jsonl or .jsonl.bak
|
|
603
|
+
const sessionId = file.replace(/\.jsonl(\.bak)?$/, '');
|
|
604
|
+
results.push({ filePath, projectPath, projectEntry, sessionId });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return results;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function apiRecentSessions(req, res, url) {
|
|
611
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
612
|
+
const recentSessions = [];
|
|
613
|
+
|
|
614
|
+
for (const { filePath, projectPath, projectEntry } of getAllSessionFiles()) {
|
|
615
|
+
try {
|
|
616
|
+
recentSessions.push(parseSessionFile(filePath, projectPath, projectEntry));
|
|
617
|
+
} catch { /* skip */ }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Merge cached titles (AI or user-renamed)
|
|
621
|
+
const allTitles = dbModule.getAllSessionTitles();
|
|
622
|
+
for (const s of recentSessions) {
|
|
623
|
+
if (allTitles[s.sessionId]) {
|
|
624
|
+
s.aiTitle = allTitles[s.sessionId].title;
|
|
625
|
+
s.userRenamed = allTitles[s.sessionId].userRenamed;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
recentSessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
|
|
630
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
631
|
+
res.end(JSON.stringify(recentSessions.slice(0, limit)));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Session IDs are UUIDs; project entries are encoded paths (no slashes or ..)
|
|
635
|
+
const SESSION_ID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
636
|
+
const PROJECT_ENTRY_RE = /^[a-zA-Z0-9._-]+$/;
|
|
637
|
+
|
|
638
|
+
function apiSessionMessages(req, res, url) {
|
|
639
|
+
const sessionId = url.searchParams.get('id');
|
|
640
|
+
const projectEntry = url.searchParams.get('project');
|
|
641
|
+
if (!sessionId || !projectEntry) {
|
|
642
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
643
|
+
res.end(JSON.stringify({ error: 'Missing id or project' }));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (!SESSION_ID_RE.test(sessionId) || !PROJECT_ENTRY_RE.test(projectEntry)) {
|
|
647
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
648
|
+
res.end(JSON.stringify({ error: 'Invalid id or project format' }));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
|
|
653
|
+
const basePath = path.resolve(claudeProjectsDir, projectEntry, `${sessionId}.jsonl`);
|
|
654
|
+
if (!basePath.startsWith(claudeProjectsDir + '/')) {
|
|
655
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
656
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Try .jsonl first, then fall back to .jsonl.bak (original file when Claude converts session to directory format)
|
|
661
|
+
const filePath = fs.existsSync(basePath) ? basePath
|
|
662
|
+
: fs.existsSync(basePath + '.bak') ? basePath + '.bak'
|
|
663
|
+
: null;
|
|
664
|
+
if (!filePath) {
|
|
665
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
666
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
671
|
+
const lines = content.split('\n').filter(Boolean);
|
|
672
|
+
const messages = [];
|
|
673
|
+
|
|
674
|
+
for (const line of lines) {
|
|
675
|
+
try {
|
|
676
|
+
const entry = JSON.parse(line);
|
|
677
|
+
if (entry.type === 'user' && entry.message?.role === 'user') {
|
|
678
|
+
const c = entry.message.content;
|
|
679
|
+
let text = typeof c === 'string' ? c
|
|
680
|
+
: Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
|
|
681
|
+
if (!text) continue;
|
|
682
|
+
// Detect system/tool messages masquerading as user messages
|
|
683
|
+
const isToolResult = Array.isArray(c) && c.some(b => b.type === 'tool_result');
|
|
684
|
+
const isTaskNotification = text.includes('<task-notification>') || (text.includes('toolu_') && text.includes('.output'));
|
|
685
|
+
const isSystemReminder = text.includes('<system-reminder>') && !text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
686
|
+
if (isToolResult || isTaskNotification || isSystemReminder) {
|
|
687
|
+
messages.push({ role: 'system', text, timestamp: entry.timestamp });
|
|
688
|
+
} else {
|
|
689
|
+
// Strip system-reminder tags from real user messages
|
|
690
|
+
const cleaned = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
691
|
+
messages.push({ role: 'user', text: cleaned || text, timestamp: entry.timestamp });
|
|
692
|
+
}
|
|
693
|
+
} else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
|
|
694
|
+
const c = entry.message.content;
|
|
695
|
+
if (!Array.isArray(c)) continue;
|
|
696
|
+
// Collect text and tool_use blocks
|
|
697
|
+
const parts = [];
|
|
698
|
+
for (const block of c) {
|
|
699
|
+
if (block.type === 'text' && block.text) {
|
|
700
|
+
parts.push(block.text);
|
|
701
|
+
} else if (block.type === 'tool_use') {
|
|
702
|
+
parts.push(`[Tool: ${block.name}]`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (parts.length > 0) {
|
|
706
|
+
// Deduplicate: assistant messages arrive incrementally, keep only the last one with same parentUuid
|
|
707
|
+
const lastMsg = messages[messages.length - 1];
|
|
708
|
+
if (lastMsg && lastMsg.role === 'assistant' && lastMsg._parent === entry.parentUuid) {
|
|
709
|
+
lastMsg.text = parts.join('\n');
|
|
710
|
+
} else {
|
|
711
|
+
messages.push({ role: 'assistant', text: parts.join('\n'), timestamp: entry.timestamp, _parent: entry.parentUuid });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
} else if (entry.type === 'tool_result') {
|
|
715
|
+
// Skip tool results in the viewer for now — too noisy
|
|
716
|
+
}
|
|
717
|
+
} catch { /* skip */ }
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Clean internal fields
|
|
721
|
+
messages.forEach(m => delete m._parent);
|
|
722
|
+
|
|
723
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
724
|
+
res.end(JSON.stringify(messages));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function apiTruncateSession(req, res, url) {
|
|
728
|
+
let body = '';
|
|
729
|
+
req.on('data', chunk => { body += chunk; });
|
|
730
|
+
req.on('end', () => {
|
|
731
|
+
try {
|
|
732
|
+
const parsed = JSON.parse(body);
|
|
733
|
+
const { id, project } = parsed;
|
|
734
|
+
// Support both old afterMsgIndex (keep this msg + response) and new cutFromMsgIndex (remove this msg and after)
|
|
735
|
+
const cutFromMsgIndex = parsed.cutFromMsgIndex != null ? parsed.cutFromMsgIndex : null;
|
|
736
|
+
const afterMsgIndex = parsed.afterMsgIndex != null ? parsed.afterMsgIndex : null;
|
|
737
|
+
if (!id || !project || (cutFromMsgIndex == null && afterMsgIndex == null)) {
|
|
738
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
739
|
+
res.end(JSON.stringify({ error: 'Missing id, project, or cutFromMsgIndex/afterMsgIndex' }));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (!SESSION_ID_RE.test(id) || !PROJECT_ENTRY_RE.test(project)) {
|
|
743
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
744
|
+
res.end(JSON.stringify({ error: 'Invalid id or project format' }));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
|
|
749
|
+
const basePath = path.resolve(claudeProjectsDir, project, `${id}.jsonl`);
|
|
750
|
+
if (!basePath.startsWith(claudeProjectsDir + '/')) {
|
|
751
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
752
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const filePath = fs.existsSync(basePath) ? basePath
|
|
757
|
+
: fs.existsSync(basePath + '.bak') ? basePath + '.bak'
|
|
758
|
+
: null;
|
|
759
|
+
if (!filePath) {
|
|
760
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
761
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
766
|
+
const rawLines = content.split('\n');
|
|
767
|
+
const nonEmptyLines = []; // { lineIdx, entry }
|
|
768
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
769
|
+
if (!rawLines[i].trim()) continue;
|
|
770
|
+
try {
|
|
771
|
+
nonEmptyLines.push({ lineIdx: i, entry: JSON.parse(rawLines[i]) });
|
|
772
|
+
} catch { nonEmptyLines.push({ lineIdx: i, entry: null }); }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Walk through the same message-building logic as apiSessionMessages
|
|
776
|
+
// to find the rendered message index -> JSONL line mapping.
|
|
777
|
+
// IMPORTANT: deduplication must match frontend exactly — only check the
|
|
778
|
+
// LAST counted message's parentUuid, not all previous assistant messages.
|
|
779
|
+
const targetMsgIndex = cutFromMsgIndex != null ? cutFromMsgIndex : afterMsgIndex;
|
|
780
|
+
let msgIdx = -1;
|
|
781
|
+
let targetLineIdx = -1; // JSONL line index of the target message
|
|
782
|
+
let cutAfterLineIdx = -1; // We'll cut after this JSONL line
|
|
783
|
+
let lastAssistantParent = null; // Track last assistant parentUuid for dedup (must match frontend)
|
|
784
|
+
|
|
785
|
+
for (let j = 0; j < nonEmptyLines.length; j++) {
|
|
786
|
+
const { lineIdx, entry } = nonEmptyLines[j];
|
|
787
|
+
if (!entry) continue;
|
|
788
|
+
|
|
789
|
+
if (entry.type === 'user' && entry.message?.role === 'user') {
|
|
790
|
+
const c = entry.message.content;
|
|
791
|
+
let text = typeof c === 'string' ? c
|
|
792
|
+
: Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
|
|
793
|
+
if (!text) continue;
|
|
794
|
+
lastAssistantParent = null; // Reset — user message breaks assistant dedup chain
|
|
795
|
+
msgIdx++;
|
|
796
|
+
if (msgIdx === targetMsgIndex) {
|
|
797
|
+
targetLineIdx = lineIdx;
|
|
798
|
+
}
|
|
799
|
+
} else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
|
|
800
|
+
const c = entry.message.content;
|
|
801
|
+
if (!Array.isArray(c)) continue;
|
|
802
|
+
const parts = [];
|
|
803
|
+
for (const block of c) {
|
|
804
|
+
if (block.type === 'text' && block.text) parts.push(block.text);
|
|
805
|
+
else if (block.type === 'tool_use') parts.push(`[Tool: ${block.name}]`);
|
|
806
|
+
}
|
|
807
|
+
if (parts.length === 0) continue;
|
|
808
|
+
// Match frontend dedup: only check if LAST counted message was same parentUuid
|
|
809
|
+
const isDuplicate = lastAssistantParent != null && lastAssistantParent === entry.parentUuid;
|
|
810
|
+
if (!isDuplicate) {
|
|
811
|
+
msgIdx++;
|
|
812
|
+
}
|
|
813
|
+
lastAssistantParent = entry.parentUuid;
|
|
814
|
+
if (msgIdx === targetMsgIndex) {
|
|
815
|
+
targetLineIdx = lineIdx;
|
|
816
|
+
}
|
|
817
|
+
} else if (entry.type === 'tool_result') {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (targetLineIdx < 0) {
|
|
823
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
824
|
+
res.end(JSON.stringify({ error: 'Message index not found' }));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (cutFromMsgIndex != null) {
|
|
829
|
+
// cutFromMsgIndex: remove this message and everything after it.
|
|
830
|
+
// Cut right before the target message's JSONL line.
|
|
831
|
+
cutAfterLineIdx = targetLineIdx - 1;
|
|
832
|
+
} else {
|
|
833
|
+
// afterMsgIndex (legacy): keep this message + its response, remove the rest.
|
|
834
|
+
// Find the next user message after targetLineIdx and cut before it.
|
|
835
|
+
cutAfterLineIdx = rawLines.length - 1;
|
|
836
|
+
for (let j = 0; j < nonEmptyLines.length; j++) {
|
|
837
|
+
const { lineIdx, entry } = nonEmptyLines[j];
|
|
838
|
+
if (lineIdx <= targetLineIdx) continue;
|
|
839
|
+
if (!entry) continue;
|
|
840
|
+
if (entry.type === 'user' && entry.message?.role === 'user') {
|
|
841
|
+
cutAfterLineIdx = lineIdx - 1;
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Keep lines 0..cutAfterLineIdx
|
|
848
|
+
const keptLines = rawLines.slice(0, cutAfterLineIdx + 1);
|
|
849
|
+
const removedCount = rawLines.filter(l => l.trim()).length - keptLines.filter(l => l.trim()).length;
|
|
850
|
+
|
|
851
|
+
// Backup original file
|
|
852
|
+
const backupPath = filePath + '.truncate-backup.' + Date.now();
|
|
853
|
+
fs.copyFileSync(filePath, backupPath);
|
|
854
|
+
|
|
855
|
+
// Write truncated content
|
|
856
|
+
fs.writeFileSync(filePath, keptLines.join('\n') + '\n');
|
|
857
|
+
|
|
858
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
859
|
+
res.end(JSON.stringify({
|
|
860
|
+
ok: true,
|
|
861
|
+
keptLines: keptLines.filter(l => l.trim()).length,
|
|
862
|
+
removedLines: removedCount,
|
|
863
|
+
backup: backupPath
|
|
864
|
+
}));
|
|
865
|
+
} catch (e) {
|
|
866
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
867
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function apiDeleteSession(req, res, url) {
|
|
873
|
+
const sessionId = url.searchParams.get('id');
|
|
874
|
+
const projectEntry = url.searchParams.get('project');
|
|
875
|
+
if (!sessionId || !projectEntry) {
|
|
876
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
877
|
+
res.end(JSON.stringify({ error: 'Missing id or project' }));
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (!SESSION_ID_RE.test(sessionId) || !PROJECT_ENTRY_RE.test(projectEntry)) {
|
|
881
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
882
|
+
res.end(JSON.stringify({ error: 'Invalid id or project format' }));
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
|
|
887
|
+
const sessionFile = path.resolve(claudeProjectsDir, projectEntry, `${sessionId}.jsonl`);
|
|
888
|
+
const sessionDir = path.resolve(claudeProjectsDir, projectEntry, sessionId);
|
|
889
|
+
if (!sessionFile.startsWith(claudeProjectsDir + '/') || !sessionDir.startsWith(claudeProjectsDir + '/')) {
|
|
890
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
891
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
let deleted = false;
|
|
896
|
+
if (fs.existsSync(sessionFile)) {
|
|
897
|
+
fs.unlinkSync(sessionFile);
|
|
898
|
+
deleted = true;
|
|
899
|
+
}
|
|
900
|
+
// Also remove companion directory if it exists
|
|
901
|
+
if (fs.existsSync(sessionDir)) {
|
|
902
|
+
fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
906
|
+
res.end(JSON.stringify({ ok: true, deleted }));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function apiCleanEmptySessions(req, res) {
|
|
910
|
+
const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
|
|
911
|
+
let cleaned = 0;
|
|
912
|
+
|
|
913
|
+
for (const { filePath, projectPath, projectEntry } of getAllSessionFiles()) {
|
|
914
|
+
try {
|
|
915
|
+
const session = parseSessionFile(filePath, projectPath, projectEntry);
|
|
916
|
+
if (session.isEmpty) {
|
|
917
|
+
fs.unlinkSync(filePath);
|
|
918
|
+
// Remove companion directory
|
|
919
|
+
const dirPath = filePath.replace('.jsonl', '');
|
|
920
|
+
if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
|
|
921
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
922
|
+
}
|
|
923
|
+
cleaned++;
|
|
924
|
+
}
|
|
925
|
+
} catch { /* skip */ }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
929
|
+
res.end(JSON.stringify({ ok: true, cleaned }));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function apiAiSearch(req, res) {
|
|
933
|
+
let body = '';
|
|
934
|
+
req.on('data', chunk => { body += chunk; if (body.length > 1024 * 1024) { req.destroy(); return; } });
|
|
935
|
+
req.on('end', () => {
|
|
936
|
+
try {
|
|
937
|
+
const { query } = JSON.parse(body);
|
|
938
|
+
if (!query) {
|
|
939
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
940
|
+
res.end(JSON.stringify({ error: 'Missing query' }));
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Collect all session summaries for Claude to search
|
|
945
|
+
const allSessions = [];
|
|
946
|
+
for (const { filePath, projectPath, projectEntry } of getAllSessionFiles()) {
|
|
947
|
+
try {
|
|
948
|
+
allSessions.push(parseSessionFile(filePath, projectPath, projectEntry));
|
|
949
|
+
} catch { /* skip */ }
|
|
950
|
+
}
|
|
951
|
+
allSessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
|
|
952
|
+
|
|
953
|
+
// Build a summary for Claude to search through
|
|
954
|
+
const summaryText = allSessions.slice(0, 200).map((s, i) =>
|
|
955
|
+
`[${i}] ${s.sessionId} | ${s.project} | ${s.gitBranch || '-'} | ${s.modifiedAt} | ${s.title || s.firstMessage || '(empty)'}`
|
|
956
|
+
).join('\n');
|
|
957
|
+
|
|
958
|
+
const { execSync } = require('child_process');
|
|
959
|
+
const prompt = `You are searching through Claude Code session history. Given the user's search query and the list of sessions below, return ONLY a JSON array of session indices (numbers) that match the query, ranked by relevance. Return [] if nothing matches. No explanation, just the JSON array.
|
|
960
|
+
|
|
961
|
+
Query: "${query}"
|
|
962
|
+
|
|
963
|
+
Sessions:
|
|
964
|
+
${summaryText}`;
|
|
965
|
+
|
|
966
|
+
const env = { ...process.env };
|
|
967
|
+
delete env.CLAUDECODE;
|
|
968
|
+
let result;
|
|
969
|
+
result = require('child_process').spawnSync('claude', ['-p', prompt], {
|
|
970
|
+
encoding: 'utf8',
|
|
971
|
+
timeout: 30000,
|
|
972
|
+
env,
|
|
973
|
+
maxBuffer: 1024 * 1024,
|
|
974
|
+
});
|
|
975
|
+
if (result.error) throw result.error;
|
|
976
|
+
result = (result.stdout || '').trim();
|
|
977
|
+
|
|
978
|
+
// Parse the indices
|
|
979
|
+
const match = result.match(/\[[\d,\s]*\]/);
|
|
980
|
+
const indices = match ? JSON.parse(match[0]) : [];
|
|
981
|
+
const matched = indices.map(i => allSessions[i]).filter(Boolean);
|
|
982
|
+
|
|
983
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
984
|
+
res.end(JSON.stringify(matched));
|
|
985
|
+
} catch (e) {
|
|
986
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
987
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// --- Migrate analysis cache from JSON → SQLite (one-time) ---
|
|
993
|
+
function migrateAnalysisCacheToDb() {
|
|
994
|
+
const ANALYSIS_CACHE_FILE = path.join(CONFIG_DIR, 'analysis-cache.json');
|
|
995
|
+
if (!fs.existsSync(ANALYSIS_CACHE_FILE)) return;
|
|
996
|
+
// Only migrate if DB is empty
|
|
997
|
+
if (dbModule.getSessionAnalysisCount() > 0) return;
|
|
998
|
+
try {
|
|
999
|
+
const cache = JSON.parse(fs.readFileSync(ANALYSIS_CACHE_FILE, 'utf8'));
|
|
1000
|
+
let count = 0;
|
|
1001
|
+
for (const [id, s] of Object.entries(cache.sessions || {})) {
|
|
1002
|
+
dbModule.upsertSessionAnalysis({
|
|
1003
|
+
session_id: id,
|
|
1004
|
+
project: s.project || '',
|
|
1005
|
+
title: s.title || '',
|
|
1006
|
+
category: s.category || 'other',
|
|
1007
|
+
topics: s.topics || [],
|
|
1008
|
+
skills_used: s.skills_used || [],
|
|
1009
|
+
complexity: s.complexity || '',
|
|
1010
|
+
summary: s.summary || '',
|
|
1011
|
+
pattern: s.pattern || '',
|
|
1012
|
+
first_message: s.firstMessage || '',
|
|
1013
|
+
session_modified_at: s.modifiedAt || '',
|
|
1014
|
+
message_count: 0,
|
|
1015
|
+
});
|
|
1016
|
+
count++;
|
|
1017
|
+
}
|
|
1018
|
+
// Migrate grouping
|
|
1019
|
+
if (cache.grouping) {
|
|
1020
|
+
const g = cache.grouping;
|
|
1021
|
+
if (g.groups) dbModule.replaceInsightGroups(g.groups.map(grp => ({
|
|
1022
|
+
...grp, is_internal: /session.?tit|session.?class|ctm|task.?manager/i.test(grp.name),
|
|
1023
|
+
})));
|
|
1024
|
+
if (g.skill_suggestions) dbModule.replaceInsightSkills(g.skill_suggestions.map(sk => ({
|
|
1025
|
+
...sk, is_internal: /session.?tit|session.?class|ctm|task.?manager/i.test(sk.name + ' ' + sk.title),
|
|
1026
|
+
})));
|
|
1027
|
+
}
|
|
1028
|
+
// Rename old file so it's not re-migrated
|
|
1029
|
+
fs.renameSync(ANALYSIS_CACHE_FILE, ANALYSIS_CACHE_FILE + '.migrated');
|
|
1030
|
+
console.log(` Migrated ${count} session analyses from JSON cache → SQLite`);
|
|
1031
|
+
} catch (e) {
|
|
1032
|
+
console.error(' Analysis cache migration error:', e.message);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// --- Session Analysis & Grouping (SQLite-backed) ---
|
|
1037
|
+
|
|
1038
|
+
function callClaude(prompt) {
|
|
1039
|
+
const { spawnSync } = require('child_process');
|
|
1040
|
+
const env = { ...process.env };
|
|
1041
|
+
delete env.CLAUDECODE;
|
|
1042
|
+
delete env.CLAUDE_CODE;
|
|
1043
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
1044
|
+
delete env.CLAUDE_CODE_ENABLE_TELEMETRY;
|
|
1045
|
+
const result = spawnSync('claude', ['-p', prompt], {
|
|
1046
|
+
encoding: 'utf8', timeout: 60000, env, maxBuffer: 1024 * 1024,
|
|
1047
|
+
});
|
|
1048
|
+
if (result.error) throw result.error;
|
|
1049
|
+
return (result.stdout || '').trim();
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function callClaudeAsync(prompt) {
|
|
1053
|
+
const { spawn } = require('child_process');
|
|
1054
|
+
const env = { ...process.env };
|
|
1055
|
+
// Remove all Claude Code env vars to avoid nested session detection
|
|
1056
|
+
for (const key of Object.keys(env)) {
|
|
1057
|
+
if (key.toLowerCase().includes('claude') || key.toLowerCase().includes('portkey')) delete env[key];
|
|
1058
|
+
}
|
|
1059
|
+
return new Promise((resolve, reject) => {
|
|
1060
|
+
let settled = false;
|
|
1061
|
+
const child = spawn('claude', ['-p', prompt], {
|
|
1062
|
+
env, stdio: ['ignore', 'pipe', 'pipe'],
|
|
1063
|
+
});
|
|
1064
|
+
let stdout = '';
|
|
1065
|
+
let stderr = '';
|
|
1066
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
1067
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
1068
|
+
const timer = setTimeout(() => {
|
|
1069
|
+
if (settled) return;
|
|
1070
|
+
settled = true;
|
|
1071
|
+
child.kill('SIGKILL');
|
|
1072
|
+
reject(new Error('Claude CLI timeout'));
|
|
1073
|
+
}, 120000);
|
|
1074
|
+
child.stdout.on('end', () => {
|
|
1075
|
+
if (settled) return;
|
|
1076
|
+
settled = true;
|
|
1077
|
+
clearTimeout(timer);
|
|
1078
|
+
if (stderr && !stdout) reject(new Error(stderr.trim()));
|
|
1079
|
+
else resolve(stdout.trim());
|
|
1080
|
+
});
|
|
1081
|
+
child.on('error', err => {
|
|
1082
|
+
if (settled) return;
|
|
1083
|
+
settled = true;
|
|
1084
|
+
clearTimeout(timer);
|
|
1085
|
+
reject(err);
|
|
1086
|
+
});
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
function apiRenameSession(req, res) {
|
|
1092
|
+
let body = '';
|
|
1093
|
+
req.on('data', chunk => { body += chunk; if (body.length > 1024 * 1024) { req.destroy(); return; } });
|
|
1094
|
+
req.on('end', () => {
|
|
1095
|
+
try {
|
|
1096
|
+
const { sessionId, title } = JSON.parse(body);
|
|
1097
|
+
if (!sessionId || !title) {
|
|
1098
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1099
|
+
res.end(JSON.stringify({ error: 'sessionId and title required' }));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
const trimmed = title.trim().slice(0, 120);
|
|
1103
|
+
dbModule.setSessionTitle(sessionId, trimmed, true);
|
|
1104
|
+
|
|
1105
|
+
// Update in-memory session if active
|
|
1106
|
+
const session = sessions.get(sessionId);
|
|
1107
|
+
if (session) {
|
|
1108
|
+
session.label = trimmed;
|
|
1109
|
+
broadcastSessionList();
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1113
|
+
res.end(JSON.stringify({ ok: true, title: trimmed }));
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1116
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function apiGenerateTitles(req, res) {
|
|
1122
|
+
let body = '';
|
|
1123
|
+
req.on('data', chunk => { body += chunk; if (body.length > 1024 * 1024) { req.destroy(); return; } });
|
|
1124
|
+
req.on('end', async () => {
|
|
1125
|
+
try {
|
|
1126
|
+
const { sessions } = JSON.parse(body);
|
|
1127
|
+
if (!Array.isArray(sessions) || sessions.length === 0) {
|
|
1128
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1129
|
+
res.end(JSON.stringify({ error: 'sessions array required' }));
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Filter to sessions that don't already have titles (or user-renamed), skip CTM-internal sessions
|
|
1134
|
+
const existingTitles = dbModule.getAllSessionTitles();
|
|
1135
|
+
const needTitles = sessions.filter(s => {
|
|
1136
|
+
const existing = existingTitles[s.sessionId];
|
|
1137
|
+
if (existing && (existing.userRenamed || existing.title)) return false;
|
|
1138
|
+
return s.firstMessage && !isCtmInternalSession(s.firstMessage);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
if (needTitles.length === 0) {
|
|
1142
|
+
const flatTitles = {};
|
|
1143
|
+
for (const [id, v] of Object.entries(existingTitles)) flatTitles[id] = v.title;
|
|
1144
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1145
|
+
res.end(JSON.stringify({ titles: flatTitles, generated: 0 }));
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Batch up to 20 sessions at a time
|
|
1150
|
+
const batch = needTitles.slice(0, 20);
|
|
1151
|
+
|
|
1152
|
+
// Strip resume/continuation boilerplate from first messages
|
|
1153
|
+
function cleanFirstMessage(msg) {
|
|
1154
|
+
if (!msg) return '';
|
|
1155
|
+
return msg
|
|
1156
|
+
.replace(/^This session is being continued from a previous conversation[^\n]*\n*/i, '')
|
|
1157
|
+
.replace(/^Continue the conversation[^\n]*\n*/i, '')
|
|
1158
|
+
.replace(/^Resume:?\s*/i, '')
|
|
1159
|
+
.replace(/^Summary:?\s*\n?/i, '')
|
|
1160
|
+
.replace(/^The summary below covers[^\n]*\n*/i, '')
|
|
1161
|
+
.replace(/^\[(?:Request interrupted|Tool use|Error|Retrying)[^\]]*\]\s*/g, '')
|
|
1162
|
+
.trim();
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const prompt = `Generate short, descriptive titles (max 60 chars) for these Claude Code sessions.
|
|
1166
|
+
Each session has an ID and the user's first message. Create a title that summarizes what the user was working on.
|
|
1167
|
+
|
|
1168
|
+
IMPORTANT: Do NOT prefix titles with "Resume:", "Continue:", "Fix:", or similar action verbs. Just describe the topic/task directly.
|
|
1169
|
+
Good: "Shopping cart checkout flow redesign"
|
|
1170
|
+
Bad: "Resume: Update checkout page logic"
|
|
1171
|
+
|
|
1172
|
+
Sessions:
|
|
1173
|
+
${batch.map((s, i) => `${i + 1}. [${s.sessionId}] ${cleanFirstMessage(s.firstMessage).slice(0, 300)}`).join('\n')}
|
|
1174
|
+
|
|
1175
|
+
Return ONLY a JSON array of objects with "id" and "title" fields. No markdown fences.`;
|
|
1176
|
+
|
|
1177
|
+
const result = await callClaudeAsync(prompt);
|
|
1178
|
+
const match = result.match(/\[[\s\S]*\]/);
|
|
1179
|
+
if (match) {
|
|
1180
|
+
const titles = JSON.parse(match[0]);
|
|
1181
|
+
for (const t of titles) {
|
|
1182
|
+
if (t.id && t.title) {
|
|
1183
|
+
dbModule.setSessionTitle(t.id, t.title, false);
|
|
1184
|
+
existingTitles[t.id] = { title: t.title, userRenamed: false };
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Flatten to simple {id: title} for response
|
|
1190
|
+
const flatTitles = {};
|
|
1191
|
+
for (const [id, v] of Object.entries(existingTitles)) flatTitles[id] = v.title;
|
|
1192
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1193
|
+
res.end(JSON.stringify({ titles: flatTitles, generated: batch.length }));
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1196
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function apiGetAnalysis(req, res) {
|
|
1202
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1203
|
+
const includeInternal = url.searchParams.get('includeInternal') === '1';
|
|
1204
|
+
const data = dbModule.getInsightsData(includeInternal);
|
|
1205
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1206
|
+
res.end(JSON.stringify(data));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Analysis state — runs in background, broadcasts progress via WebSocket
|
|
1210
|
+
let analysisRunning = false;
|
|
1211
|
+
let analysisProgress = [];
|
|
1212
|
+
|
|
1213
|
+
function broadcastAnalysisProgress(msg) {
|
|
1214
|
+
analysisProgress.push(msg);
|
|
1215
|
+
const payload = JSON.stringify({ type: 'analysis-progress', message: msg });
|
|
1216
|
+
for (const client of wss.clients) {
|
|
1217
|
+
if (client.readyState === 1) client.send(payload);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function broadcastAnalysisDone(data) {
|
|
1222
|
+
const payload = JSON.stringify({ type: 'analysis-done', ...data });
|
|
1223
|
+
for (const client of wss.clients) {
|
|
1224
|
+
if (client.readyState === 1) client.send(payload);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function apiAnalyzeSessions(req, res) {
|
|
1229
|
+
if (analysisRunning) {
|
|
1230
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1231
|
+
res.end(JSON.stringify({ ok: true, status: 'already-running', progress: analysisProgress }));
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
analysisRunning = true;
|
|
1236
|
+
analysisProgress = [];
|
|
1237
|
+
|
|
1238
|
+
// Respond immediately
|
|
1239
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1240
|
+
res.end(JSON.stringify({ ok: true, status: 'started' }));
|
|
1241
|
+
|
|
1242
|
+
// Run in background
|
|
1243
|
+
setImmediate(() => runAnalysisBackground());
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
async function runAnalysisBackground() {
|
|
1247
|
+
const runId = dbModule.startAnalysisRun();
|
|
1248
|
+
let analyzedCount = 0;
|
|
1249
|
+
let skippedCount = 0;
|
|
1250
|
+
|
|
1251
|
+
try {
|
|
1252
|
+
const allFiles = getAllSessionFiles();
|
|
1253
|
+
|
|
1254
|
+
const sessionsToAnalyze = [];
|
|
1255
|
+
const currentSessionIds = new Set();
|
|
1256
|
+
|
|
1257
|
+
for (const { filePath, projectPath, projectEntry } of allFiles) {
|
|
1258
|
+
try {
|
|
1259
|
+
const parsed = parseSessionFile(filePath, projectPath, projectEntry);
|
|
1260
|
+
if (parsed.isEmpty) continue;
|
|
1261
|
+
|
|
1262
|
+
currentSessionIds.add(parsed.sessionId);
|
|
1263
|
+
const existing = dbModule.getSessionAnalysis(parsed.sessionId);
|
|
1264
|
+
|
|
1265
|
+
if (existing && existing.session_modified_at === parsed.modifiedAt) {
|
|
1266
|
+
skippedCount++;
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
sessionsToAnalyze.push(parsed);
|
|
1270
|
+
} catch { /* skip */ }
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Remove stale analyses for sessions that no longer exist
|
|
1274
|
+
const removed = dbModule.deleteStaleAnalyses([...currentSessionIds]);
|
|
1275
|
+
if (removed > 0) broadcastAnalysisProgress(`Removed ${removed} stale session analyses.`);
|
|
1276
|
+
|
|
1277
|
+
broadcastAnalysisProgress(`Found ${sessionsToAnalyze.length} new/changed sessions to analyze (${currentSessionIds.size} total, ${skippedCount} cached)`);
|
|
1278
|
+
|
|
1279
|
+
// Analyze in batches
|
|
1280
|
+
const BATCH_SIZE = 10;
|
|
1281
|
+
for (let i = 0; i < sessionsToAnalyze.length; i += BATCH_SIZE) {
|
|
1282
|
+
const batch = sessionsToAnalyze.slice(i, i + BATCH_SIZE);
|
|
1283
|
+
broadcastAnalysisProgress(`Analyzing sessions ${i + 1}-${Math.min(i + BATCH_SIZE, sessionsToAnalyze.length)} of ${sessionsToAnalyze.length}...`);
|
|
1284
|
+
|
|
1285
|
+
const batchPrompt = `Analyze these Claude Code sessions. For each, provide a JSON object with:
|
|
1286
|
+
- "id": the session ID
|
|
1287
|
+
- "category": one of [coding, debugging, devops, data, design, research, config, communication, ctm-internal, other]
|
|
1288
|
+
- "topics": array of 2-4 specific topic tags
|
|
1289
|
+
- "skills_used": array of skills/tools used
|
|
1290
|
+
- "complexity": "simple" | "moderate" | "complex"
|
|
1291
|
+
- "summary": 1-sentence summary
|
|
1292
|
+
- "pattern": workflow pattern description
|
|
1293
|
+
- "is_internal": true if this session is about the Claude Task Manager tool itself (session titling, analysis, classification, CTM UI development), false otherwise
|
|
1294
|
+
|
|
1295
|
+
Return ONLY a JSON array. No markdown, no explanation.
|
|
1296
|
+
|
|
1297
|
+
Sessions:
|
|
1298
|
+
${batch.map(s => `---
|
|
1299
|
+
ID: ${s.sessionId}
|
|
1300
|
+
Project: ${s.project}
|
|
1301
|
+
Branch: ${s.gitBranch || '-'}
|
|
1302
|
+
First message: ${s.firstMessage || '(none)'}
|
|
1303
|
+
Messages: ${s.userMsgCount}
|
|
1304
|
+
Modified: ${s.modifiedAt}
|
|
1305
|
+
`).join('\n')}`;
|
|
1306
|
+
|
|
1307
|
+
try {
|
|
1308
|
+
const result = await callClaudeAsync(batchPrompt);
|
|
1309
|
+
const match = result.match(/\[[\s\S]*\]/);
|
|
1310
|
+
if (match) {
|
|
1311
|
+
const analyses = JSON.parse(match[0]);
|
|
1312
|
+
for (const analysis of analyses) {
|
|
1313
|
+
const orig = batch.find(s => s.sessionId === analysis.id);
|
|
1314
|
+
if (orig) {
|
|
1315
|
+
dbModule.upsertSessionAnalysis({
|
|
1316
|
+
session_id: analysis.id,
|
|
1317
|
+
project: orig.project,
|
|
1318
|
+
title: orig.title || '',
|
|
1319
|
+
category: analysis.is_internal ? 'ctm-internal' : (analysis.category || 'other'),
|
|
1320
|
+
topics: analysis.topics,
|
|
1321
|
+
skills_used: analysis.skills_used,
|
|
1322
|
+
complexity: analysis.complexity,
|
|
1323
|
+
summary: analysis.summary,
|
|
1324
|
+
pattern: analysis.pattern,
|
|
1325
|
+
first_message: orig.firstMessage || '',
|
|
1326
|
+
session_modified_at: orig.modifiedAt,
|
|
1327
|
+
message_count: orig.userMsgCount || 0,
|
|
1328
|
+
});
|
|
1329
|
+
analyzedCount++;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
} catch (e) {
|
|
1334
|
+
broadcastAnalysisProgress(`Warning: batch failed: ${e.message}`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Only regenerate grouping/skills/recommendations if new sessions were analyzed
|
|
1339
|
+
if (analyzedCount === 0) {
|
|
1340
|
+
broadcastAnalysisProgress(`No new sessions to analyze — skipping group/skill/recommendation regeneration.`);
|
|
1341
|
+
dbModule.completeAnalysisRun(runId, { sessions_analyzed: analyzedCount, sessions_skipped: skippedCount, status: 'completed' });
|
|
1342
|
+
broadcastAnalysisProgress('Analysis complete! (no changes)');
|
|
1343
|
+
broadcastAnalysisDone(dbModule.getInsightsData(false));
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Generate grouping, skills, and recommendations
|
|
1348
|
+
const allAnalyses = dbModule.getAllSessionAnalyses();
|
|
1349
|
+
broadcastAnalysisProgress(`Generating groups, skills, and recommendations from ${allAnalyses.length} sessions...`);
|
|
1350
|
+
|
|
1351
|
+
const groupPrompt = `Analyze this Claude Code session history. Identify patterns, suggest reusable skills, and give recommendations.
|
|
1352
|
+
|
|
1353
|
+
Sessions:
|
|
1354
|
+
${allAnalyses.map(s => {
|
|
1355
|
+
const topics = JSON.parse(s.topics || '[]');
|
|
1356
|
+
return `- [${s.category}] ${s.summary || s.title || '?'} (topics: ${topics.join(', ')}; pattern: ${s.pattern || '?'}; project: ${s.project}; msgs: ${s.message_count || '?'})`;
|
|
1357
|
+
}).join('\n')}
|
|
1358
|
+
|
|
1359
|
+
Return a JSON object with:
|
|
1360
|
+
1. "groups": [{name, description, session_ids (array of session IDs), count, category, is_internal (true if group is about CTM tool itself like session-titling/classification/analysis)}]
|
|
1361
|
+
2. "skill_suggestions": [{name (kebab-case), title, description, trigger, based_on, priority ("high"|"medium"|"low"), category, is_internal (true if skill is for CTM tool itself)}]
|
|
1362
|
+
3. "recommendations": [{type ("prompt-effectiveness"|"cost-saving"|"workflow"|"skill-gap"), title, description, evidence (object with supporting data), impact ("high"|"medium"|"low"), actionable (concrete suggestion string), category}]
|
|
1363
|
+
|
|
1364
|
+
For recommendations, analyze:
|
|
1365
|
+
- Prompt effectiveness: sessions with high iteration counts vs low - what makes prompts succeed faster?
|
|
1366
|
+
- Cost saving: patterns that waste tokens (excessive exploration, vague initial prompts, repeated failures)
|
|
1367
|
+
- Workflow: repetitive manual tasks that could be automated with skills
|
|
1368
|
+
- Skill gaps: areas where skills/templates would save time
|
|
1369
|
+
|
|
1370
|
+
Return ONLY JSON. No markdown fences.`;
|
|
1371
|
+
|
|
1372
|
+
try {
|
|
1373
|
+
const result = await callClaudeAsync(groupPrompt);
|
|
1374
|
+
const match = result.match(/\{[\s\S]*\}/);
|
|
1375
|
+
if (match) {
|
|
1376
|
+
const parsed = JSON.parse(match[0]);
|
|
1377
|
+
|
|
1378
|
+
if (parsed.groups) dbModule.replaceInsightGroups(parsed.groups);
|
|
1379
|
+
if (parsed.skill_suggestions) dbModule.replaceInsightSkills(parsed.skill_suggestions);
|
|
1380
|
+
if (parsed.recommendations) dbModule.replaceInsightRecommendations(parsed.recommendations);
|
|
1381
|
+
}
|
|
1382
|
+
} catch (e) {
|
|
1383
|
+
broadcastAnalysisProgress(`Warning: grouping failed: ${e.message}`);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
dbModule.completeAnalysisRun(runId, { sessions_analyzed: analyzedCount, sessions_skipped: skippedCount, status: 'completed' });
|
|
1387
|
+
broadcastAnalysisProgress('Analysis complete!');
|
|
1388
|
+
broadcastAnalysisDone(dbModule.getInsightsData(false));
|
|
1389
|
+
} catch (e) {
|
|
1390
|
+
broadcastAnalysisProgress(`Error: ${e.message}`);
|
|
1391
|
+
dbModule.completeAnalysisRun(runId, { sessions_analyzed: analyzedCount, sessions_skipped: skippedCount, status: 'failed' });
|
|
1392
|
+
} finally {
|
|
1393
|
+
analysisRunning = false;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// --- WebSocket Server (Terminal) ---
|
|
1398
|
+
const wss = new WebSocketServer({ server });
|
|
1399
|
+
// sessions Map is imported from server-state.js (shared with api-prompts.js)
|
|
1400
|
+
|
|
1401
|
+
wss.on('connection', (ws, req) => {
|
|
1402
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1403
|
+
const token = getAuthToken(req, url);
|
|
1404
|
+
|
|
1405
|
+
if (!isLocalhost(req) && token !== config.token) {
|
|
1406
|
+
ws.close(4001, 'Unauthorized');
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
ws.isAlive = true;
|
|
1411
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
1412
|
+
|
|
1413
|
+
ws.on('message', (raw) => {
|
|
1414
|
+
let msg;
|
|
1415
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
1416
|
+
|
|
1417
|
+
switch (msg.type) {
|
|
1418
|
+
case 'create': return handleCreate(ws, msg);
|
|
1419
|
+
case 'attach': return handleAttach(ws, msg);
|
|
1420
|
+
case 'input': return handleInput(ws, msg);
|
|
1421
|
+
case 'resize': return handleResize(ws, msg);
|
|
1422
|
+
case 'kill': return handleKill(ws, msg);
|
|
1423
|
+
case 'rename': return handleRename(ws, msg);
|
|
1424
|
+
case 'list': return handleList(ws);
|
|
1425
|
+
case 'detach': return handleDetach(ws, msg);
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
ws.on('close', () => {
|
|
1430
|
+
// Detach from all sessions
|
|
1431
|
+
for (const [id, session] of sessions) {
|
|
1432
|
+
session.clients = session.clients.filter(c => c !== ws);
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
// --- Queue Engine Integration ---
|
|
1438
|
+
function setupQueueForSession(sessionId) {
|
|
1439
|
+
// Set the send function so queue engine can write to PTY
|
|
1440
|
+
queueEngine.setSendFn(sessionId, (data) => {
|
|
1441
|
+
const session = sessions.get(sessionId);
|
|
1442
|
+
if (session) session.ptyProcess.write(data);
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
// Set state change callback to broadcast via WebSocket
|
|
1446
|
+
queueEngine.setOnStateChange(sessionId, (state) => {
|
|
1447
|
+
broadcastQueueState(sessionId, state);
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function broadcastQueueState(sessionId, state) {
|
|
1452
|
+
const payload = JSON.stringify({ type: 'queue-state', sessionId, ...state });
|
|
1453
|
+
for (const client of wss.clients) {
|
|
1454
|
+
if (client.readyState === 1) client.send(payload);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// --- Shadow Approver Engine ---
|
|
1459
|
+
// Monitors terminal output for "Do you want to proceed?" prompts.
|
|
1460
|
+
// Checks learned rules first, then uses AI to auto-approve or escalate.
|
|
1461
|
+
const autoApprovalBuffers = new Map(); // sessionId -> { text, lastCheck, cooldown, reviewing }
|
|
1462
|
+
|
|
1463
|
+
function broadcastToSession(sessionId, session, data) {
|
|
1464
|
+
const payload = JSON.stringify(data);
|
|
1465
|
+
for (const client of session.clients) {
|
|
1466
|
+
if (client.readyState === 1) client.send(payload);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Lightweight virtual terminal screen buffer for parsing PTY output.
|
|
1471
|
+
// Claude Code uses Ink (React TUI) which renders via cursor positioning,
|
|
1472
|
+
// so raw text accumulation doesn't work — we need to interpret cursor moves.
|
|
1473
|
+
class VTermScreen {
|
|
1474
|
+
constructor(rows = 60, cols = 220) {
|
|
1475
|
+
this.rows = rows;
|
|
1476
|
+
this.cols = cols;
|
|
1477
|
+
this.screen = [];
|
|
1478
|
+
this.cursorRow = 0;
|
|
1479
|
+
this.cursorCol = 0;
|
|
1480
|
+
this._pending = ''; // Buffer for incomplete escape sequences across chunks
|
|
1481
|
+
for (let r = 0; r < rows; r++) this.screen[r] = new Array(cols).fill(' ');
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
feed(chunk) {
|
|
1485
|
+
// Prepend any leftover from a split escape sequence
|
|
1486
|
+
const data = this._pending + chunk;
|
|
1487
|
+
this._pending = '';
|
|
1488
|
+
let i = 0;
|
|
1489
|
+
while (i < data.length) {
|
|
1490
|
+
const ch = data[i];
|
|
1491
|
+
if (ch === '\x1b') {
|
|
1492
|
+
// Check if we have enough data to parse the escape sequence
|
|
1493
|
+
if (i + 1 >= data.length) {
|
|
1494
|
+
// ESC at end of chunk — save for next feed()
|
|
1495
|
+
this._pending = data.slice(i);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
if (data[i + 1] === '[') {
|
|
1499
|
+
// CSI sequence: ESC [ params letter
|
|
1500
|
+
let j = i + 2;
|
|
1501
|
+
while (j < data.length && /[0-9;?]/.test(data[j])) j++;
|
|
1502
|
+
if (j >= data.length) {
|
|
1503
|
+
// Incomplete CSI — save for next feed()
|
|
1504
|
+
this._pending = data.slice(i);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
const params = data.slice(i + 2, j);
|
|
1508
|
+
this._handleCSI(params, data[j]);
|
|
1509
|
+
i = j + 1;
|
|
1510
|
+
} else if (data[i + 1] === ']') {
|
|
1511
|
+
// OSC sequence: skip to BEL or ST
|
|
1512
|
+
let j = i + 2;
|
|
1513
|
+
while (j < data.length && data[j] !== '\x07' && !(data[j] === '\x1b' && data[j + 1] === '\\')) j++;
|
|
1514
|
+
if (j >= data.length) {
|
|
1515
|
+
// Incomplete OSC — save for next feed()
|
|
1516
|
+
this._pending = data.slice(i);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
i = (data[j] === '\x07') ? j + 1 : j + 2;
|
|
1520
|
+
} else {
|
|
1521
|
+
// Other ESC sequences (charset, etc.) — skip 2 chars
|
|
1522
|
+
i += 2;
|
|
1523
|
+
}
|
|
1524
|
+
} else if (ch === '\n') {
|
|
1525
|
+
this.cursorRow++;
|
|
1526
|
+
if (this.cursorRow >= this.rows) { this._scrollUp(); this.cursorRow = this.rows - 1; }
|
|
1527
|
+
i++;
|
|
1528
|
+
} else if (ch === '\r') {
|
|
1529
|
+
this.cursorCol = 0;
|
|
1530
|
+
i++;
|
|
1531
|
+
} else if (ch === '\t') {
|
|
1532
|
+
this.cursorCol = Math.min(this.cols - 1, (Math.floor(this.cursorCol / 8) + 1) * 8);
|
|
1533
|
+
i++;
|
|
1534
|
+
} else if (ch.charCodeAt(0) < 32 || ch === '\x7f') {
|
|
1535
|
+
i++; // skip control chars
|
|
1536
|
+
} else {
|
|
1537
|
+
// Printable character
|
|
1538
|
+
if (this.cursorCol < this.cols && this.cursorRow < this.rows && this.cursorRow >= 0) {
|
|
1539
|
+
this.screen[this.cursorRow][this.cursorCol] = ch;
|
|
1540
|
+
this.cursorCol++;
|
|
1541
|
+
if (this.cursorCol >= this.cols) {
|
|
1542
|
+
this.cursorCol = 0;
|
|
1543
|
+
this.cursorRow++;
|
|
1544
|
+
if (this.cursorRow >= this.rows) { this._scrollUp(); this.cursorRow = this.rows - 1; }
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
i++;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
_handleCSI(params, cmd) {
|
|
1553
|
+
const parts = params.replace(/\?/g, '').split(';').map(Number);
|
|
1554
|
+
switch (cmd) {
|
|
1555
|
+
case 'H': case 'f': // Cursor position
|
|
1556
|
+
this.cursorRow = Math.max(0, (parts[0] || 1) - 1);
|
|
1557
|
+
this.cursorCol = Math.max(0, (parts[1] || 1) - 1);
|
|
1558
|
+
break;
|
|
1559
|
+
case 'A': // Cursor up
|
|
1560
|
+
this.cursorRow = Math.max(0, this.cursorRow - (parts[0] || 1));
|
|
1561
|
+
break;
|
|
1562
|
+
case 'B': // Cursor down
|
|
1563
|
+
this.cursorRow = Math.min(this.rows - 1, this.cursorRow + (parts[0] || 1));
|
|
1564
|
+
break;
|
|
1565
|
+
case 'C': // Cursor forward
|
|
1566
|
+
this.cursorCol = Math.min(this.cols - 1, this.cursorCol + (parts[0] || 1));
|
|
1567
|
+
break;
|
|
1568
|
+
case 'D': // Cursor back
|
|
1569
|
+
this.cursorCol = Math.max(0, this.cursorCol - (parts[0] || 1));
|
|
1570
|
+
break;
|
|
1571
|
+
case 'J': // Erase in display
|
|
1572
|
+
if ((parts[0] || 0) === 2) {
|
|
1573
|
+
for (let r = 0; r < this.rows; r++) this.screen[r].fill(' ');
|
|
1574
|
+
} else if ((parts[0] || 0) === 0) {
|
|
1575
|
+
// Clear from cursor to end
|
|
1576
|
+
this.screen[this.cursorRow].fill(' ', this.cursorCol);
|
|
1577
|
+
for (let r = this.cursorRow + 1; r < this.rows; r++) this.screen[r].fill(' ');
|
|
1578
|
+
}
|
|
1579
|
+
break;
|
|
1580
|
+
case 'K': // Erase in line
|
|
1581
|
+
if ((parts[0] || 0) === 0) {
|
|
1582
|
+
this.screen[this.cursorRow].fill(' ', this.cursorCol);
|
|
1583
|
+
} else if ((parts[0] || 0) === 2) {
|
|
1584
|
+
this.screen[this.cursorRow].fill(' ');
|
|
1585
|
+
}
|
|
1586
|
+
break;
|
|
1587
|
+
case 'G': // Cursor horizontal absolute
|
|
1588
|
+
this.cursorCol = Math.max(0, (parts[0] || 1) - 1);
|
|
1589
|
+
break;
|
|
1590
|
+
case 'd': // Cursor vertical absolute
|
|
1591
|
+
this.cursorRow = Math.max(0, (parts[0] || 1) - 1);
|
|
1592
|
+
break;
|
|
1593
|
+
// m (SGR/color), h/l (mode set/reset), etc. — ignored (visual only)
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
_scrollUp() {
|
|
1598
|
+
this.screen.shift();
|
|
1599
|
+
this.screen.push(new Array(this.cols).fill(' '));
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
getText() {
|
|
1603
|
+
return this.screen.map(row => row.join('').trimEnd()).join('\n');
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function checkAutoApproval(sessionId, session, data) {
|
|
1608
|
+
// Accumulate output using virtual terminal screen
|
|
1609
|
+
let buf = autoApprovalBuffers.get(sessionId);
|
|
1610
|
+
if (!buf) {
|
|
1611
|
+
buf = { vterm: new VTermScreen(), lastCheck: 0, cooldown: 0, reviewing: false };
|
|
1612
|
+
autoApprovalBuffers.set(sessionId, buf);
|
|
1613
|
+
}
|
|
1614
|
+
buf.vterm.feed(data);
|
|
1615
|
+
|
|
1616
|
+
// Throttle checks
|
|
1617
|
+
const now = Date.now();
|
|
1618
|
+
if (now - buf.lastCheck < 500) return;
|
|
1619
|
+
if (now < buf.cooldown) return;
|
|
1620
|
+
if (buf.reviewing) return; // AI review in progress
|
|
1621
|
+
buf.lastCheck = now;
|
|
1622
|
+
|
|
1623
|
+
// Read the virtual screen as text
|
|
1624
|
+
const clean = buf.vterm.getText();
|
|
1625
|
+
|
|
1626
|
+
// Debug: log buffer content periodically (every 30s per session)
|
|
1627
|
+
if (!buf._lastDebug || now - buf._lastDebug > 30000) {
|
|
1628
|
+
buf._lastDebug = now;
|
|
1629
|
+
const nonEmpty = clean.split('\n').filter(l => l.trim()).slice(-3);
|
|
1630
|
+
if (nonEmpty.length > 0) {
|
|
1631
|
+
console.log(`[shadow-approver:debug] session=${sessionId.slice(0,8)} screen=` +
|
|
1632
|
+
JSON.stringify(nonEmpty.map(l => l.trim().slice(0, 80))));
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Check for approval prompt patterns (proceed, edit, create file)
|
|
1637
|
+
if (!/Do you want to (proceed|make this edit to .+|create .+)\??/.test(clean)) return;
|
|
1638
|
+
if (!/1\.\s*Yes/.test(clean)) return;
|
|
1639
|
+
|
|
1640
|
+
console.log(`[shadow-approver] Detected approval prompt in session ${sessionId.slice(0, 8)}`);
|
|
1641
|
+
|
|
1642
|
+
// Mark as reviewing to prevent duplicate checks
|
|
1643
|
+
buf.reviewing = true;
|
|
1644
|
+
|
|
1645
|
+
// Run the AI-powered approval agent
|
|
1646
|
+
approvalAgent.handleApprovalCheck(sessionId, session, clean, broadcastToSession)
|
|
1647
|
+
.then(handled => {
|
|
1648
|
+
console.log(`[shadow-approver] handleApprovalCheck result: handled=${handled}`);
|
|
1649
|
+
if (handled) {
|
|
1650
|
+
buf.vterm = new VTermScreen();
|
|
1651
|
+
buf.cooldown = Date.now() + 3000;
|
|
1652
|
+
}
|
|
1653
|
+
})
|
|
1654
|
+
.catch(err => {
|
|
1655
|
+
console.error('[shadow-approver] Error:', err.message);
|
|
1656
|
+
})
|
|
1657
|
+
.finally(() => {
|
|
1658
|
+
buf.reviewing = false;
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// Clean up buffers when sessions exit
|
|
1663
|
+
function cleanAutoApprovalBuffer(sessionId) {
|
|
1664
|
+
autoApprovalBuffers.delete(sessionId);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// --- Input-Waiting Detection & Notification ---
|
|
1668
|
+
// Detects when Claude Code is idle and waiting for user input, then notifies clients.
|
|
1669
|
+
const idleNotifyState = new Map(); // sessionId -> { timer, notified, lastOutput, buf }
|
|
1670
|
+
|
|
1671
|
+
// Strip ALL ANSI/xterm escape sequences comprehensively
|
|
1672
|
+
function stripAnsi(str) {
|
|
1673
|
+
return str
|
|
1674
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // CSI sequences: ESC [ ... letter
|
|
1675
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences: ESC ] ... BEL/ST
|
|
1676
|
+
.replace(/\x1b[()][A-Z0-9]/g, '') // Character set: ESC ( B, etc.
|
|
1677
|
+
.replace(/\x1b>[0-9]*[a-zA-Z]/g, '') // Private mode: ESC > ...
|
|
1678
|
+
.replace(/\x1b<[a-zA-Z]/g, '') // ESC < ...
|
|
1679
|
+
.replace(/\x1b=[0-9]*/g, '') // ESC = ...
|
|
1680
|
+
.replace(/\x1b\s/g, '') // ESC space
|
|
1681
|
+
.replace(/\r/g, '') // Carriage returns
|
|
1682
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ''); // Other control chars
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const IDLE_PROMPT_PATTERNS = [
|
|
1686
|
+
// Claude Code TUI indicators (these appear when Claude is waiting for input)
|
|
1687
|
+
{ pattern: /❯/, reason: 'input' }, // Claude Code prompt marker (anywhere in buffer)
|
|
1688
|
+
{ pattern: /ctrl\+[a-z].*to edit/i, reason: 'input' }, // Claude Code hint text
|
|
1689
|
+
// Approval / choice prompts
|
|
1690
|
+
{ pattern: /Do you want to (proceed|make this edit|create )\S/, reason: 'approval' }, // Approval prompt
|
|
1691
|
+
{ pattern: /\d+\.\s*Yes.*\d+\.\s*No/s, reason: 'choice' }, // Numbered choice menu
|
|
1692
|
+
{ pattern: /\(Y\/n\)\s*$/i, reason: 'approval' }, // Y/n prompt
|
|
1693
|
+
{ pattern: /Enter to confirm/, reason: 'choice' }, // Claude Code confirm
|
|
1694
|
+
// Shell prompts (for non-Claude sessions)
|
|
1695
|
+
{ pattern: /[→▶]\s*$/, reason: 'input' }, // Arrow-style prompts (zsh themes)
|
|
1696
|
+
{ pattern: /[\$#%]\s*$/, reason: 'input' }, // Shell prompts: $, #, %
|
|
1697
|
+
];
|
|
1698
|
+
const IDLE_NOTIFY_DELAY_MS = 3000; // Wait 3s of silence after prompt marker
|
|
1699
|
+
|
|
1700
|
+
function checkIdleNotify(sessionId, session, data) {
|
|
1701
|
+
let st = idleNotifyState.get(sessionId);
|
|
1702
|
+
if (!st) {
|
|
1703
|
+
st = { timer: null, notified: false, lastOutput: Date.now(), buf: '' };
|
|
1704
|
+
idleNotifyState.set(sessionId, st);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Accumulate output in own buffer (independent of auto-approval buffer)
|
|
1708
|
+
st.buf += data;
|
|
1709
|
+
if (st.buf.length > 4096) st.buf = st.buf.slice(-3000);
|
|
1710
|
+
|
|
1711
|
+
st.lastOutput = Date.now();
|
|
1712
|
+
|
|
1713
|
+
// Only reset notified flag if this is substantial output (not just cursor/control sequences)
|
|
1714
|
+
const stripped = data.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/[\x00-\x1f\x7f]/g, '').trim();
|
|
1715
|
+
if (stripped.length > 2) {
|
|
1716
|
+
st.notified = false; // Significant new output means Claude is active again
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// Clear any pending notification timer
|
|
1720
|
+
if (st.timer) { clearTimeout(st.timer); st.timer = null; }
|
|
1721
|
+
|
|
1722
|
+
// After a delay, check if output stopped at a prompt marker
|
|
1723
|
+
st.timer = setTimeout(() => {
|
|
1724
|
+
const clean = stripAnsi(st.buf);
|
|
1725
|
+
// Get last non-empty lines
|
|
1726
|
+
const lines = clean.split('\n').filter(l => l.trim().length > 0);
|
|
1727
|
+
const lastLines = lines.slice(-8).join('\n');
|
|
1728
|
+
|
|
1729
|
+
for (const { pattern, reason } of IDLE_PROMPT_PATTERNS) {
|
|
1730
|
+
if (pattern.test(lastLines)) {
|
|
1731
|
+
if (!st.notified) {
|
|
1732
|
+
st.notified = true;
|
|
1733
|
+
broadcastToAll({
|
|
1734
|
+
type: 'waiting-for-input',
|
|
1735
|
+
id: sessionId,
|
|
1736
|
+
reason,
|
|
1737
|
+
label: session.label || '',
|
|
1738
|
+
snippet: lastLines.trim().split('\n').slice(-3).join('\n').slice(0, 200),
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}, IDLE_NOTIFY_DELAY_MS);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function cleanIdleNotify(sessionId) {
|
|
1748
|
+
const st = idleNotifyState.get(sessionId);
|
|
1749
|
+
if (st?.timer) clearTimeout(st.timer);
|
|
1750
|
+
idleNotifyState.delete(sessionId);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
function broadcastToAll(data) {
|
|
1754
|
+
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
|
1755
|
+
for (const client of wss.clients) {
|
|
1756
|
+
if (client.readyState === 1) client.send(payload);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function handleCreate(ws, msg) {
|
|
1761
|
+
const id = msg.id || crypto.randomUUID();
|
|
1762
|
+
|
|
1763
|
+
// If a session with this ID is still alive (e.g. resume of a stale entry), attach instead
|
|
1764
|
+
const existing = sessions.get(id);
|
|
1765
|
+
if (existing && existing.ptyProcess) {
|
|
1766
|
+
return handleAttach(ws, { id });
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const cwd = (msg.cwd || process.env.HOME).replace(/^~/, process.env.HOME);
|
|
1770
|
+
const shell = msg.shell || process.env.SHELL || '/bin/zsh';
|
|
1771
|
+
const cmd = msg.cmd || shell;
|
|
1772
|
+
const args = msg.args || [];
|
|
1773
|
+
|
|
1774
|
+
// If resuming a Claude session, restore .jsonl from .bak if needed
|
|
1775
|
+
// (Claude Code migrates sessions to directory format, renaming .jsonl to .jsonl.bak)
|
|
1776
|
+
if (args.includes('--resume') && id) {
|
|
1777
|
+
const claudeProjectsDir = path.join(process.env.HOME, '.claude', 'projects');
|
|
1778
|
+
try {
|
|
1779
|
+
for (const entry of fs.readdirSync(claudeProjectsDir)) {
|
|
1780
|
+
const jsonl = path.join(claudeProjectsDir, entry, `${id}.jsonl`);
|
|
1781
|
+
const bak = jsonl + '.bak';
|
|
1782
|
+
if (!fs.existsSync(jsonl) && fs.existsSync(bak)) {
|
|
1783
|
+
fs.copyFileSync(bak, jsonl);
|
|
1784
|
+
break;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
} catch { /* ignore — best effort */ }
|
|
1788
|
+
}
|
|
1789
|
+
const cols = msg.cols || 120;
|
|
1790
|
+
const rows = msg.rows || 30;
|
|
1791
|
+
const env = { ...process.env, ...msg.env };
|
|
1792
|
+
// Remove CLAUDECODE so spawned Claude Code sessions don't think they're nested
|
|
1793
|
+
delete env.CLAUDECODE;
|
|
1794
|
+
|
|
1795
|
+
// Determine the label — strip any "Resume:" prefix to avoid accumulation
|
|
1796
|
+
let label = (msg.label || '').replace(/^Resume:\s*/i, '');
|
|
1797
|
+
if (!label) {
|
|
1798
|
+
if (cmd.includes('claude')) {
|
|
1799
|
+
label = `Claude: ${cwd}`;
|
|
1800
|
+
} else {
|
|
1801
|
+
label = `Shell: ${cwd}`;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
let ptyProcess;
|
|
1806
|
+
try {
|
|
1807
|
+
ptyProcess = pty.spawn(cmd, args, {
|
|
1808
|
+
name: 'xterm-256color',
|
|
1809
|
+
cols, rows, cwd, env,
|
|
1810
|
+
});
|
|
1811
|
+
} catch (e) {
|
|
1812
|
+
ws.send(JSON.stringify({ type: 'error', message: `Failed to spawn: ${e.message}` }));
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const session = {
|
|
1817
|
+
id,
|
|
1818
|
+
label,
|
|
1819
|
+
cmd,
|
|
1820
|
+
args,
|
|
1821
|
+
cwd,
|
|
1822
|
+
pid: ptyProcess.pid,
|
|
1823
|
+
ptyProcess,
|
|
1824
|
+
clients: [ws],
|
|
1825
|
+
scrollback: [],
|
|
1826
|
+
createdAt: Date.now(),
|
|
1827
|
+
lastActivity: Date.now(),
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
sessions.set(id, session);
|
|
1831
|
+
|
|
1832
|
+
ptyProcess.onData((data) => {
|
|
1833
|
+
session.lastActivity = Date.now();
|
|
1834
|
+
// Only strip \e[3J (Erase Scrollback) — it destroys scroll history on replay.
|
|
1835
|
+
// Do NOT strip \e[2J or \e[?1049h/l — those are needed for Claude Code's TUI rendering.
|
|
1836
|
+
const cleanData = data.replace(/\x1b\[3J/g, '');
|
|
1837
|
+
// Keep scrollback (limit to ~50KB)
|
|
1838
|
+
session.scrollback.push(cleanData);
|
|
1839
|
+
if (session.scrollback.length > 500) session.scrollback.shift();
|
|
1840
|
+
|
|
1841
|
+
const payload = JSON.stringify({ type: 'output', id, data: cleanData });
|
|
1842
|
+
for (const client of session.clients) {
|
|
1843
|
+
if (client.readyState === 1) client.send(payload);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// Feed output to queue engine for completion detection
|
|
1847
|
+
queueEngine.feedOutput(id, data);
|
|
1848
|
+
|
|
1849
|
+
// Feed output to auto-approval engine
|
|
1850
|
+
checkAutoApproval(id, session, data);
|
|
1851
|
+
|
|
1852
|
+
// Feed output to idle/waiting-for-input detection
|
|
1853
|
+
checkIdleNotify(id, session, data);
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
1857
|
+
const payload = JSON.stringify({ type: 'exit', id, exitCode, signal });
|
|
1858
|
+
for (const client of session.clients) {
|
|
1859
|
+
if (client.readyState === 1) client.send(payload);
|
|
1860
|
+
}
|
|
1861
|
+
sessions.delete(id);
|
|
1862
|
+
queueEngine.onSessionExit(id);
|
|
1863
|
+
cleanAutoApprovalBuffer(id);
|
|
1864
|
+
cleanIdleNotify(id);
|
|
1865
|
+
broadcastSessionList();
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
// Wire up queue engine hooks if a queue exists for this session
|
|
1869
|
+
setupQueueForSession(id);
|
|
1870
|
+
|
|
1871
|
+
// If the client provided an explicit label (e.g. prompt title), persist it
|
|
1872
|
+
if (msg.label) {
|
|
1873
|
+
dbModule.setSessionTitle(id, label, false);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
ws.send(JSON.stringify({ type: 'created', id, pid: ptyProcess.pid, label, cwd }));
|
|
1877
|
+
broadcastSessionList();
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function handleAttach(ws, msg) {
|
|
1881
|
+
const session = sessions.get(msg.id);
|
|
1882
|
+
if (!session) {
|
|
1883
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Session not found', id: msg.id }));
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
if (!session.clients.includes(ws)) {
|
|
1887
|
+
session.clients.push(ws);
|
|
1888
|
+
}
|
|
1889
|
+
// Send scrollback along with the PTY dimensions it was rendered at,
|
|
1890
|
+
// so the client can detect width mismatches
|
|
1891
|
+
const ptyCols = session.ptyProcess?.cols || 120;
|
|
1892
|
+
const ptyRows = session.ptyProcess?.rows || 30;
|
|
1893
|
+
ws.send(JSON.stringify({ type: 'scrollback', id: msg.id, data: session.scrollback.join(''), ptyCols, ptyRows }));
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function handleDetach(ws, msg) {
|
|
1897
|
+
const session = sessions.get(msg.id);
|
|
1898
|
+
if (session) {
|
|
1899
|
+
session.clients = session.clients.filter(c => c !== ws);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function handleInput(ws, msg) {
|
|
1904
|
+
const session = sessions.get(msg.id);
|
|
1905
|
+
if (session) {
|
|
1906
|
+
session.lastActivity = Date.now();
|
|
1907
|
+
session.ptyProcess.write(msg.data);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function handleResize(ws, msg) {
|
|
1912
|
+
const session = sessions.get(msg.id);
|
|
1913
|
+
if (session && msg.cols && msg.rows) {
|
|
1914
|
+
session.ptyProcess.resize(msg.cols, msg.rows);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function handleKill(ws, msg) {
|
|
1919
|
+
const session = sessions.get(msg.id);
|
|
1920
|
+
if (session) {
|
|
1921
|
+
session.ptyProcess.kill();
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function handleRename(ws, msg) {
|
|
1926
|
+
const { id, label } = msg;
|
|
1927
|
+
if (!id || !label) return;
|
|
1928
|
+
const trimmed = label.trim().slice(0, 120);
|
|
1929
|
+
if (!trimmed) return;
|
|
1930
|
+
|
|
1931
|
+
// Update in-memory session if active
|
|
1932
|
+
const session = sessions.get(id);
|
|
1933
|
+
if (session) {
|
|
1934
|
+
session.label = trimmed;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// Persist to DB with user_renamed flag
|
|
1938
|
+
dbModule.setSessionTitle(id, trimmed, true);
|
|
1939
|
+
|
|
1940
|
+
ws.send(JSON.stringify({ type: 'renamed', id, label: trimmed }));
|
|
1941
|
+
broadcastSessionList();
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function handleList(ws) {
|
|
1945
|
+
ws.send(JSON.stringify({
|
|
1946
|
+
type: 'sessions',
|
|
1947
|
+
sessions: Array.from(sessions.values()).map(s => ({
|
|
1948
|
+
id: s.id,
|
|
1949
|
+
label: s.label,
|
|
1950
|
+
cmd: s.cmd,
|
|
1951
|
+
cwd: s.cwd,
|
|
1952
|
+
pid: s.pid,
|
|
1953
|
+
createdAt: s.createdAt,
|
|
1954
|
+
lastActivity: s.lastActivity,
|
|
1955
|
+
})),
|
|
1956
|
+
}));
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function broadcastDataChanged(resource, method) {
|
|
1960
|
+
const payload = JSON.stringify({ type: 'data-changed', resource, method });
|
|
1961
|
+
for (const client of wss.clients) {
|
|
1962
|
+
if (client.readyState === 1) client.send(payload);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function broadcastSessionList() {
|
|
1967
|
+
const payload = JSON.stringify({
|
|
1968
|
+
type: 'sessions',
|
|
1969
|
+
sessions: Array.from(sessions.values()).map(s => ({
|
|
1970
|
+
id: s.id,
|
|
1971
|
+
label: s.label,
|
|
1972
|
+
cmd: s.cmd,
|
|
1973
|
+
cwd: s.cwd,
|
|
1974
|
+
pid: s.pid,
|
|
1975
|
+
createdAt: s.createdAt,
|
|
1976
|
+
lastActivity: s.lastActivity,
|
|
1977
|
+
})),
|
|
1978
|
+
});
|
|
1979
|
+
for (const client of wss.clients) {
|
|
1980
|
+
if (client.readyState === 1) client.send(payload);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// Heartbeat
|
|
1985
|
+
setInterval(() => {
|
|
1986
|
+
wss.clients.forEach((ws) => {
|
|
1987
|
+
if (!ws.isAlive) return ws.terminate();
|
|
1988
|
+
ws.isAlive = false;
|
|
1989
|
+
ws.ping();
|
|
1990
|
+
});
|
|
1991
|
+
}, 30000);
|
|
1992
|
+
|
|
1993
|
+
// Periodic change detection for code review badge (every 30 seconds)
|
|
1994
|
+
const _lastKnownFileCounts = new Map(); // project -> fileCount
|
|
1995
|
+
setInterval(async () => {
|
|
1996
|
+
if (wss.clients.size === 0) return;
|
|
1997
|
+
const trackedProjects = new Set();
|
|
1998
|
+
for (const [, session] of sessions) {
|
|
1999
|
+
if (session.cwd) trackedProjects.add(session.cwd);
|
|
2000
|
+
}
|
|
2001
|
+
for (const project of trackedProjects) {
|
|
2002
|
+
try {
|
|
2003
|
+
const result = await checkForChanges(project);
|
|
2004
|
+
const prev = _lastKnownFileCounts.get(project);
|
|
2005
|
+
if (result.fileCount === prev) continue; // No change, skip broadcast
|
|
2006
|
+
_lastKnownFileCounts.set(project, result.fileCount);
|
|
2007
|
+
const payload = JSON.stringify({
|
|
2008
|
+
type: 'files-changed',
|
|
2009
|
+
project,
|
|
2010
|
+
fileCount: result.fileCount,
|
|
2011
|
+
files: result.files.map(f => f.path),
|
|
2012
|
+
});
|
|
2013
|
+
for (const client of wss.clients) {
|
|
2014
|
+
if (client.readyState === 1) client.send(payload);
|
|
2015
|
+
}
|
|
2016
|
+
} catch {}
|
|
2017
|
+
}
|
|
2018
|
+
}, 30000);
|
|
2019
|
+
|
|
2020
|
+
// --- Graceful Shutdown ---
|
|
2021
|
+
function shutdown() {
|
|
2022
|
+
console.log('\n Shutting down...');
|
|
2023
|
+
dbModule.checkpointWal();
|
|
2024
|
+
dbModule.closeDb();
|
|
2025
|
+
for (const [id, session] of sessions) {
|
|
2026
|
+
try { session.ptyProcess.kill(); } catch {}
|
|
2027
|
+
}
|
|
2028
|
+
process.exit(0);
|
|
2029
|
+
}
|
|
2030
|
+
process.on('SIGINT', shutdown);
|
|
2031
|
+
process.on('SIGTERM', shutdown);
|
|
2032
|
+
|
|
2033
|
+
// --- Restart APIs ---
|
|
2034
|
+
const { execFile } = require('child_process');
|
|
2035
|
+
|
|
2036
|
+
const _ctmStartTime = Date.now();
|
|
2037
|
+
|
|
2038
|
+
function apiServicesStatus(req, res) {
|
|
2039
|
+
const ctmUptime = Math.floor((Date.now() - _ctmStartTime) / 1000);
|
|
2040
|
+
// Check Wall-E on port 3457
|
|
2041
|
+
execFile('lsof', ['-ti', ':3457'], (err, stdout) => {
|
|
2042
|
+
const pids = (stdout || '').trim().split('\n').filter(Boolean);
|
|
2043
|
+
// Filter to only node processes
|
|
2044
|
+
let wallePid = null;
|
|
2045
|
+
if (pids.length > 0) {
|
|
2046
|
+
// lsof returns PIDs — just use first one as indicator
|
|
2047
|
+
wallePid = parseInt(pids[0]) || null;
|
|
2048
|
+
}
|
|
2049
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2050
|
+
res.end(JSON.stringify({
|
|
2051
|
+
ctm: { running: true, pid: process.pid, uptime: ctmUptime },
|
|
2052
|
+
walle: { running: !!wallePid, pid: wallePid }
|
|
2053
|
+
}));
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
function apiStopWalle(req, res) {
|
|
2058
|
+
execFile('lsof', ['-ti', ':3457'], (err, stdout) => {
|
|
2059
|
+
const pids = (stdout || '').trim().split('\n').filter(Boolean);
|
|
2060
|
+
if (pids.length === 0) {
|
|
2061
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2062
|
+
res.end(JSON.stringify({ ok: true, message: 'Wall-E is not running' }));
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
for (const pid of pids) {
|
|
2066
|
+
try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
|
|
2067
|
+
}
|
|
2068
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2069
|
+
res.end(JSON.stringify({ ok: true, message: 'Wall-E stopped' }));
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function apiStartWalle(req, res) {
|
|
2074
|
+
const walleDir = path.join(__dirname, '..', 'wall-e');
|
|
2075
|
+
const agentScript = path.join(walleDir, 'agent.js');
|
|
2076
|
+
// Check if already running
|
|
2077
|
+
execFile('lsof', ['-ti', ':3457'], (err, stdout) => {
|
|
2078
|
+
const pids = (stdout || '').trim().split('\n').filter(Boolean);
|
|
2079
|
+
if (pids.length > 0) {
|
|
2080
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2081
|
+
res.end(JSON.stringify({ ok: true, message: 'Wall-E is already running' }));
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
const child = require('child_process').spawn(
|
|
2085
|
+
process.execPath,
|
|
2086
|
+
[agentScript],
|
|
2087
|
+
{ cwd: walleDir, detached: true, stdio: 'ignore', env: { ...process.env } }
|
|
2088
|
+
);
|
|
2089
|
+
child.unref();
|
|
2090
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2091
|
+
res.end(JSON.stringify({ ok: true, message: 'Wall-E starting...', pid: child.pid }));
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function apiRestartCtm(req, res) {
|
|
2096
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2097
|
+
res.end(JSON.stringify({ ok: true, message: 'CTM server restarting...' }));
|
|
2098
|
+
|
|
2099
|
+
// Spawn a bash one-liner that waits for this process to die, then starts new server
|
|
2100
|
+
const serverScript = path.join(__dirname, 'server.js');
|
|
2101
|
+
const cwdDir = path.resolve(__dirname, '..');
|
|
2102
|
+
const child = require('child_process').spawn('bash', ['-c',
|
|
2103
|
+
`while kill -0 ${process.pid} 2>/dev/null; do sleep 0.1; done; cd "${cwdDir}" && "${process.execPath}" "${serverScript}" >> /tmp/ctm.log 2>&1`
|
|
2104
|
+
], { detached: true, stdio: 'ignore' });
|
|
2105
|
+
child.unref();
|
|
2106
|
+
|
|
2107
|
+
// Exit after a brief delay for the response to flush
|
|
2108
|
+
setTimeout(() => {
|
|
2109
|
+
dbModule.checkpointWal();
|
|
2110
|
+
dbModule.closeDb();
|
|
2111
|
+
// Kill all PTY sessions
|
|
2112
|
+
for (const [, session] of sessions) {
|
|
2113
|
+
try { session.ptyProcess.kill(); } catch {}
|
|
2114
|
+
}
|
|
2115
|
+
// Force exit — node-pty can keep the event loop alive
|
|
2116
|
+
setTimeout(() => process.kill(process.pid, 'SIGKILL'), 200);
|
|
2117
|
+
process.exit(0);
|
|
2118
|
+
}, 300);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function apiRestartWalle(req, res) {
|
|
2122
|
+
const walleDir = path.join(__dirname, '..', 'wall-e');
|
|
2123
|
+
const agentScript = path.join(walleDir, 'agent.js');
|
|
2124
|
+
|
|
2125
|
+
// Kill existing Wall-E process on port 3457
|
|
2126
|
+
execFile('lsof', ['-ti', ':3457'], (err, stdout) => {
|
|
2127
|
+
const pids = (stdout || '').trim().split('\n').filter(Boolean);
|
|
2128
|
+
for (const pid of pids) {
|
|
2129
|
+
try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// Wait for old process to die, then spawn new one
|
|
2133
|
+
setTimeout(() => {
|
|
2134
|
+
const child = require('child_process').spawn(
|
|
2135
|
+
process.execPath,
|
|
2136
|
+
[agentScript],
|
|
2137
|
+
{ cwd: walleDir, detached: true, stdio: 'ignore', env: { ...process.env } }
|
|
2138
|
+
);
|
|
2139
|
+
child.unref();
|
|
2140
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2141
|
+
res.end(JSON.stringify({ ok: true, message: 'Wall-E restarting...', pid: child.pid }));
|
|
2142
|
+
}, 1000);
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
// --- Queue Engine Hook ---
|
|
2147
|
+
queueEngine.setOnQueueCreated((sessionId) => {
|
|
2148
|
+
if (sessions.has(sessionId)) {
|
|
2149
|
+
setupQueueForSession(sessionId);
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
// Re-wire restored queues to existing PTY sessions
|
|
2154
|
+
for (const [sessionId] of Object.entries(queueEngine.getAllStates())) {
|
|
2155
|
+
if (sessions.has(sessionId)) {
|
|
2156
|
+
setupQueueForSession(sessionId);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// --- Wire up tracked-projects API ---
|
|
2161
|
+
const apiReviews = require('./api-reviews');
|
|
2162
|
+
apiReviews._getTrackedProjects = async function() {
|
|
2163
|
+
// Gather ALL sessions (from session files), not just active ones
|
|
2164
|
+
const allSessions = [];
|
|
2165
|
+
for (const { filePath, projectPath, projectEntry } of getAllSessionFiles()) {
|
|
2166
|
+
try {
|
|
2167
|
+
allSessions.push(parseSessionFile(filePath, projectPath, projectEntry));
|
|
2168
|
+
} catch {}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
// Merge titles (AI or user-renamed)
|
|
2172
|
+
const allTitles = dbModule.getAllSessionTitles();
|
|
2173
|
+
for (const s of allSessions) {
|
|
2174
|
+
if (allTitles[s.sessionId]) {
|
|
2175
|
+
s.aiTitle = allTitles[s.sessionId].title;
|
|
2176
|
+
s.userRenamed = allTitles[s.sessionId].userRenamed;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
// Active session IDs for marking active status
|
|
2181
|
+
const activeIds = new Set(sessions.keys());
|
|
2182
|
+
|
|
2183
|
+
// Group by cwd, dedup by path — skip CTM-internal sessions
|
|
2184
|
+
const projectMap = new Map(); // cwd -> { sessions: [], mostRecent }
|
|
2185
|
+
for (const s of allSessions) {
|
|
2186
|
+
if (!s.cwd || s.isEmpty) continue;
|
|
2187
|
+
if (isCtmInternalSession(s.firstMessage)) continue;
|
|
2188
|
+
if (!projectMap.has(s.cwd)) {
|
|
2189
|
+
projectMap.set(s.cwd, { sessions: [] });
|
|
2190
|
+
}
|
|
2191
|
+
projectMap.get(s.cwd).sessions.push({
|
|
2192
|
+
id: s.sessionId,
|
|
2193
|
+
label: s.aiTitle || s.title || s.firstMessage?.slice(0, 60) || s.sessionId.slice(0, 8),
|
|
2194
|
+
modifiedAt: s.modifiedAt,
|
|
2195
|
+
active: activeIds.has(s.sessionId),
|
|
2196
|
+
projectEntry: s.projectEntry,
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// Sort sessions within each project (newest first), compute mostRecent
|
|
2201
|
+
for (const [, proj] of projectMap) {
|
|
2202
|
+
proj.sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Build results, ranked by most recent session per project
|
|
2206
|
+
const entries = Array.from(projectMap.entries()).map(([cwd, proj]) => ({
|
|
2207
|
+
cwd,
|
|
2208
|
+
mostRecent: proj.sessions[0]?.modifiedAt || '',
|
|
2209
|
+
sessions: proj.sessions,
|
|
2210
|
+
}));
|
|
2211
|
+
entries.sort((a, b) => new Date(b.mostRecent) - new Date(a.mostRecent));
|
|
2212
|
+
|
|
2213
|
+
// Fetch git info + diff stats for each project (in parallel)
|
|
2214
|
+
const gitUtils = require('./git-utils');
|
|
2215
|
+
const results = await Promise.all(entries.map(async (entry) => {
|
|
2216
|
+
let fileCount = 0, files = [], branch = '';
|
|
2217
|
+
try {
|
|
2218
|
+
const result = await checkForChanges(entry.cwd);
|
|
2219
|
+
fileCount = result.fileCount;
|
|
2220
|
+
files = result.files;
|
|
2221
|
+
} catch {}
|
|
2222
|
+
try { branch = await gitUtils.getBranch(entry.cwd); } catch {}
|
|
2223
|
+
return {
|
|
2224
|
+
path: entry.cwd,
|
|
2225
|
+
name: require('path').basename(entry.cwd),
|
|
2226
|
+
branch,
|
|
2227
|
+
fileCount,
|
|
2228
|
+
files,
|
|
2229
|
+
sessions: entry.sessions,
|
|
2230
|
+
};
|
|
2231
|
+
}));
|
|
2232
|
+
|
|
2233
|
+
return results;
|
|
2234
|
+
};
|
|
2235
|
+
|
|
2236
|
+
// --- Start ---
|
|
2237
|
+
const setup = require('../bin/setup');
|
|
2238
|
+
|
|
2239
|
+
// Auto-detect owner and create .env if missing (no interactive prompts)
|
|
2240
|
+
setup.runIfNeeded();
|
|
2241
|
+
|
|
2242
|
+
server.listen(PORT, HOST, () => {
|
|
2243
|
+
console.log(`\n Claude Task Manager running at:`);
|
|
2244
|
+
console.log(` Local: http://localhost:${PORT}/`);
|
|
2245
|
+
console.log(` Remote: http://localhost:${PORT}/?token=${config.token}`);
|
|
2246
|
+
console.log(` Auth token: ${config.token}`);
|
|
2247
|
+
console.log(` Database: ${dbModule.getDbPath()}`);
|
|
2248
|
+
console.log(` Config: ${CONFIG_FILE}`);
|
|
2249
|
+
if (setup.needsSetup()) {
|
|
2250
|
+
console.log(`\n → Finish setup at: http://localhost:${PORT}/setup.html\n`);
|
|
2251
|
+
} else {
|
|
2252
|
+
console.log('');
|
|
2253
|
+
}
|
|
2254
|
+
});
|