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.
Files changed (136) hide show
  1. package/bin/create-walle.js +134 -0
  2. package/package.json +18 -0
  3. package/template/.env.example +40 -0
  4. package/template/CLAUDE.md +12 -0
  5. package/template/LICENSE +21 -0
  6. package/template/README.md +167 -0
  7. package/template/bin/setup.js +100 -0
  8. package/template/claude-code-skill.md +60 -0
  9. package/template/claude-task-manager/api-prompts.js +1841 -0
  10. package/template/claude-task-manager/api-reviews.js +275 -0
  11. package/template/claude-task-manager/approval-agent.js +454 -0
  12. package/template/claude-task-manager/bin/restart-ctm.sh +16 -0
  13. package/template/claude-task-manager/db.js +1721 -0
  14. package/template/claude-task-manager/docs/PROMPT-MANAGEMENT-DESIGN.md +631 -0
  15. package/template/claude-task-manager/git-utils.js +214 -0
  16. package/template/claude-task-manager/package-lock.json +1607 -0
  17. package/template/claude-task-manager/package.json +31 -0
  18. package/template/claude-task-manager/prompt-harvest.js +1148 -0
  19. package/template/claude-task-manager/public/css/prompts.css +880 -0
  20. package/template/claude-task-manager/public/css/reviews.css +430 -0
  21. package/template/claude-task-manager/public/css/walle.css +732 -0
  22. package/template/claude-task-manager/public/favicon.ico +0 -0
  23. package/template/claude-task-manager/public/icon.svg +37 -0
  24. package/template/claude-task-manager/public/index.html +8346 -0
  25. package/template/claude-task-manager/public/js/prompts.js +3159 -0
  26. package/template/claude-task-manager/public/js/reviews.js +1292 -0
  27. package/template/claude-task-manager/public/js/walle.js +3081 -0
  28. package/template/claude-task-manager/public/manifest.json +13 -0
  29. package/template/claude-task-manager/public/prompts.html +4353 -0
  30. package/template/claude-task-manager/public/setup.html +216 -0
  31. package/template/claude-task-manager/queue-engine.js +404 -0
  32. package/template/claude-task-manager/server-state.js +5 -0
  33. package/template/claude-task-manager/server.js +2254 -0
  34. package/template/claude-task-manager/session-utils.js +124 -0
  35. package/template/claude-task-manager/start.sh +17 -0
  36. package/template/claude-task-manager/tests/test-ai-search.js +61 -0
  37. package/template/claude-task-manager/tests/test-editor-ux.js +76 -0
  38. package/template/claude-task-manager/tests/test-editor-ux2.js +51 -0
  39. package/template/claude-task-manager/tests/test-features-v2.js +127 -0
  40. package/template/claude-task-manager/tests/test-insights-cached.js +78 -0
  41. package/template/claude-task-manager/tests/test-insights.js +124 -0
  42. package/template/claude-task-manager/tests/test-permissions-v2.js +127 -0
  43. package/template/claude-task-manager/tests/test-permissions.js +122 -0
  44. package/template/claude-task-manager/tests/test-pin.js +51 -0
  45. package/template/claude-task-manager/tests/test-prompts.js +164 -0
  46. package/template/claude-task-manager/tests/test-recent-sessions.js +96 -0
  47. package/template/claude-task-manager/tests/test-review.js +104 -0
  48. package/template/claude-task-manager/tests/test-send-dropdown.js +76 -0
  49. package/template/claude-task-manager/tests/test-send-final.js +30 -0
  50. package/template/claude-task-manager/tests/test-send-fixes.js +76 -0
  51. package/template/claude-task-manager/tests/test-send-integration.js +107 -0
  52. package/template/claude-task-manager/tests/test-send-visual.js +34 -0
  53. package/template/claude-task-manager/tests/test-session-create.js +147 -0
  54. package/template/claude-task-manager/tests/test-sidebar-ux.js +83 -0
  55. package/template/claude-task-manager/tests/test-url-hash.js +68 -0
  56. package/template/claude-task-manager/tests/test-ux-crop.js +34 -0
  57. package/template/claude-task-manager/tests/test-ux-review.js +130 -0
  58. package/template/claude-task-manager/tests/test-zoom-card.js +76 -0
  59. package/template/claude-task-manager/tests/test-zoom.js +92 -0
  60. package/template/claude-task-manager/tests/test-zoom2.js +67 -0
  61. package/template/docs/site/api/README.md +187 -0
  62. package/template/docs/site/guides/claude-code.md +58 -0
  63. package/template/docs/site/guides/configuration.md +96 -0
  64. package/template/docs/site/guides/quickstart.md +158 -0
  65. package/template/docs/site/index.md +14 -0
  66. package/template/docs/site/skills/README.md +135 -0
  67. package/template/wall-e/.dockerignore +11 -0
  68. package/template/wall-e/Dockerfile +25 -0
  69. package/template/wall-e/adapters/adapter-base.js +37 -0
  70. package/template/wall-e/adapters/ctm.js +193 -0
  71. package/template/wall-e/adapters/slack.js +56 -0
  72. package/template/wall-e/agent.js +319 -0
  73. package/template/wall-e/api-walle.js +1073 -0
  74. package/template/wall-e/brain.js +1235 -0
  75. package/template/wall-e/channels/agent-api.js +172 -0
  76. package/template/wall-e/channels/channel-base.js +14 -0
  77. package/template/wall-e/channels/imessage-channel.js +113 -0
  78. package/template/wall-e/channels/slack-channel.js +118 -0
  79. package/template/wall-e/chat.js +778 -0
  80. package/template/wall-e/decision/confidence.js +93 -0
  81. package/template/wall-e/deploy.sh +35 -0
  82. package/template/wall-e/docs/specs/2026-04-01-publish-plan.md +112 -0
  83. package/template/wall-e/docs/specs/SKILL-FORMAT.md +326 -0
  84. package/template/wall-e/extraction/contradiction.js +168 -0
  85. package/template/wall-e/extraction/knowledge-extractor.js +190 -0
  86. package/template/wall-e/fly.toml +24 -0
  87. package/template/wall-e/loops/ingest.js +34 -0
  88. package/template/wall-e/loops/reflect.js +63 -0
  89. package/template/wall-e/loops/tasks.js +487 -0
  90. package/template/wall-e/loops/think.js +125 -0
  91. package/template/wall-e/package-lock.json +533 -0
  92. package/template/wall-e/package.json +18 -0
  93. package/template/wall-e/scripts/ingest-slack-search.js +85 -0
  94. package/template/wall-e/scripts/pull-slack-via-claude.js +98 -0
  95. package/template/wall-e/scripts/slack-backfill.js +295 -0
  96. package/template/wall-e/scripts/slack-channel-history.js +454 -0
  97. package/template/wall-e/server.js +93 -0
  98. package/template/wall-e/skills/_bundled/email-digest/SKILL.md +95 -0
  99. package/template/wall-e/skills/_bundled/email-sync/SKILL.md +65 -0
  100. package/template/wall-e/skills/_bundled/email-sync/mail-reader.jxa +104 -0
  101. package/template/wall-e/skills/_bundled/email-sync/run.js +213 -0
  102. package/template/wall-e/skills/_bundled/google-calendar/SKILL.md +73 -0
  103. package/template/wall-e/skills/_bundled/google-calendar/cal-reader.swift +81 -0
  104. package/template/wall-e/skills/_bundled/google-calendar/run.js +181 -0
  105. package/template/wall-e/skills/_bundled/memory-search/SKILL.md +92 -0
  106. package/template/wall-e/skills/_bundled/morning-briefing/SKILL.md +131 -0
  107. package/template/wall-e/skills/_bundled/morning-briefing/run.js +264 -0
  108. package/template/wall-e/skills/_bundled/slack-backfill/SKILL.md +60 -0
  109. package/template/wall-e/skills/_bundled/slack-sync/SKILL.md +55 -0
  110. package/template/wall-e/skills/claude-code-reader.js +144 -0
  111. package/template/wall-e/skills/mcp-client.js +407 -0
  112. package/template/wall-e/skills/skill-executor.js +163 -0
  113. package/template/wall-e/skills/skill-loader.js +410 -0
  114. package/template/wall-e/skills/skill-planner.js +88 -0
  115. package/template/wall-e/skills/slack-ingest.js +329 -0
  116. package/template/wall-e/skills/slack-pull-live.js +270 -0
  117. package/template/wall-e/skills/tool-executor.js +188 -0
  118. package/template/wall-e/tests/adapter-base.test.js +20 -0
  119. package/template/wall-e/tests/adapter-ctm.test.js +122 -0
  120. package/template/wall-e/tests/adapter-slack.test.js +98 -0
  121. package/template/wall-e/tests/agent-api.test.js +256 -0
  122. package/template/wall-e/tests/api-walle.test.js +222 -0
  123. package/template/wall-e/tests/brain.test.js +602 -0
  124. package/template/wall-e/tests/channels.test.js +104 -0
  125. package/template/wall-e/tests/chat.test.js +103 -0
  126. package/template/wall-e/tests/confidence.test.js +134 -0
  127. package/template/wall-e/tests/contradiction.test.js +217 -0
  128. package/template/wall-e/tests/ingest.test.js +113 -0
  129. package/template/wall-e/tests/mcp-client.test.js +71 -0
  130. package/template/wall-e/tests/reflect.test.js +103 -0
  131. package/template/wall-e/tests/server.test.js +111 -0
  132. package/template/wall-e/tests/skills.test.js +198 -0
  133. package/template/wall-e/tests/slack-ingest.test.js +103 -0
  134. package/template/wall-e/tests/think.test.js +435 -0
  135. package/template/wall-e/tools/local-tools.js +697 -0
  136. 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
+ });