@cluesmith/codev 2.0.18 → 2.1.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/dist/assets/index-CALp-XEo.js +197 -0
- package/dashboard/dist/assets/index-CALp-XEo.js.map +1 -0
- package/dashboard/dist/assets/index-MVvud9Tr.css +32 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +53 -5
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
- package/dist/agent-farm/commands/cleanup.js +16 -0
- package/dist/agent-farm/commands/cleanup.js.map +1 -1
- package/dist/agent-farm/commands/index.d.ts +1 -0
- package/dist/agent-farm/commands/index.d.ts.map +1 -1
- package/dist/agent-farm/commands/index.js +1 -0
- package/dist/agent-farm/commands/index.js.map +1 -1
- package/dist/agent-farm/commands/open.d.ts.map +1 -1
- package/dist/agent-farm/commands/open.js +8 -6
- package/dist/agent-farm/commands/open.js.map +1 -1
- package/dist/agent-farm/commands/rename.d.ts +12 -0
- package/dist/agent-farm/commands/rename.d.ts.map +1 -0
- package/dist/agent-farm/commands/rename.js +42 -0
- package/dist/agent-farm/commands/rename.js.map +1 -0
- package/dist/agent-farm/commands/shell.js +1 -1
- package/dist/agent-farm/commands/start.js +1 -1
- package/dist/agent-farm/commands/start.js.map +1 -1
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +25 -1
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/schema.d.ts +1 -1
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +2 -0
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/lib/tower-client.d.ts +12 -0
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tower-client.js +9 -0
- package/dist/agent-farm/lib/tower-client.js.map +1 -1
- package/dist/agent-farm/servers/analytics.d.ts +64 -0
- package/dist/agent-farm/servers/analytics.d.ts.map +1 -0
- package/dist/agent-farm/servers/analytics.js +246 -0
- package/dist/agent-farm/servers/analytics.js.map +1 -0
- package/dist/agent-farm/servers/overview.d.ts +6 -0
- package/dist/agent-farm/servers/overview.d.ts.map +1 -1
- package/dist/agent-farm/servers/overview.js +46 -5
- package/dist/agent-farm/servers/overview.js.map +1 -1
- package/dist/agent-farm/servers/send-buffer.d.ts.map +1 -1
- package/dist/agent-farm/servers/send-buffer.js +4 -3
- package/dist/agent-farm/servers/send-buffer.js.map +1 -1
- package/dist/agent-farm/servers/tower-cron.js +2 -2
- package/dist/agent-farm/servers/tower-cron.js.map +1 -1
- package/dist/agent-farm/servers/tower-instances.d.ts +3 -1
- package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-instances.js +21 -13
- package/dist/agent-farm/servers/tower-instances.js.map +1 -1
- package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-routes.js +144 -67
- package/dist/agent-farm/servers/tower-routes.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +2 -1
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.d.ts +24 -1
- package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.js +121 -15
- package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
- package/dist/agent-farm/servers/tower-types.d.ts +2 -0
- package/dist/agent-farm/servers/tower-types.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.d.ts +1 -1
- package/dist/agent-farm/utils/config.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.js +2 -2
- package/dist/agent-farm/utils/config.js.map +1 -1
- package/dist/agent-farm/utils/file-tabs.d.ts +11 -0
- package/dist/agent-farm/utils/file-tabs.d.ts.map +1 -1
- package/dist/agent-farm/utils/file-tabs.js +18 -0
- package/dist/agent-farm/utils/file-tabs.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/consult/metrics.d.ts +4 -0
- package/dist/commands/consult/metrics.d.ts.map +1 -1
- package/dist/commands/consult/metrics.js +16 -0
- package/dist/commands/consult/metrics.js.map +1 -1
- package/dist/lib/github.d.ts +46 -0
- package/dist/lib/github.d.ts.map +1 -1
- package/dist/lib/github.js +104 -1
- package/dist/lib/github.js.map +1 -1
- package/dist/terminal/pty-session.d.ts +4 -1
- package/dist/terminal/pty-session.d.ts.map +1 -1
- package/dist/terminal/pty-session.js +7 -0
- package/dist/terminal/pty-session.js.map +1 -1
- package/package.json +1 -1
- package/skeleton/.claude/skills/af/SKILL.md +7 -6
- package/skeleton/protocols/air/builder-prompt.md +69 -0
- package/skeleton/protocols/air/consult-types/impl-review.md +58 -0
- package/skeleton/protocols/air/consult-types/pr-review.md +58 -0
- package/skeleton/protocols/air/prompts/implement.md +89 -0
- package/skeleton/protocols/air/prompts/pr.md +98 -0
- package/skeleton/protocols/air/protocol.json +109 -0
- package/skeleton/protocols/air/protocol.md +88 -0
- package/skeleton/protocols/spike/builder-prompt.md +62 -0
- package/skeleton/protocols/spike/protocol.json +36 -0
- package/skeleton/protocols/spike/protocol.md +122 -0
- package/skeleton/protocols/spike/templates/findings.md +67 -0
- package/skeleton/resources/commands/agent-farm.md +26 -24
- package/skeleton/resources/commands/overview.md +4 -4
- package/skeleton/resources/risk-triage.md +111 -0
- package/skeleton/resources/workflow-reference.md +17 -9
- package/skeleton/roles/architect.md +65 -13
- package/skeleton/templates/cheatsheet.md +3 -3
- package/dashboard/dist/assets/index-B-WzJvht.css +0 -32
- package/dashboard/dist/assets/index-DszQyc2c.js +0 -134
- package/dashboard/dist/assets/index-DszQyc2c.js.map +0 -1
|
@@ -31,9 +31,10 @@ import { formatArchitectMessage, formatBuilderMessage } from '../utils/message-f
|
|
|
31
31
|
import { SendBuffer } from './send-buffer.js';
|
|
32
32
|
import { getKnownWorkspacePaths, getInstances, getDirectorySuggestions, launchInstance, killTerminalWithShellper, stopInstance, } from './tower-instances.js';
|
|
33
33
|
import { OverviewCache } from './overview.js';
|
|
34
|
+
import { computeAnalytics } from './analytics.js';
|
|
34
35
|
import { getAllTasks, executeTask, getTaskId } from './tower-cron.js';
|
|
35
36
|
import { getGlobalDb } from '../db/index.js';
|
|
36
|
-
import { getWorkspaceTerminals, getTerminalManager, getWorkspaceTerminalsEntry, getNextShellId, saveTerminalSession, isSessionPersistent, deleteTerminalSession, removeTerminalFromRegistry, deleteWorkspaceTerminalSessions, saveFileTab, deleteFileTab, getTerminalsForWorkspace, } from './tower-terminals.js';
|
|
37
|
+
import { getWorkspaceTerminals, getTerminalManager, getWorkspaceTerminalsEntry, getNextShellId, saveTerminalSession, isSessionPersistent, deleteTerminalSession, removeTerminalFromRegistry, deleteWorkspaceTerminalSessions, deleteFileTabsForWorkspace, saveFileTab, deleteFileTab, getTerminalsForWorkspace, getTerminalSessionById, getActiveShellLabels, updateTerminalLabel, } from './tower-terminals.js';
|
|
37
38
|
const __filename = fileURLToPath(import.meta.url);
|
|
38
39
|
const __dirname = path.dirname(__filename);
|
|
39
40
|
// Singleton cache for overview endpoint (Spec 0126 Phase 4)
|
|
@@ -42,10 +43,10 @@ const overviewCache = new OverviewCache();
|
|
|
42
43
|
const sendBuffer = new SendBuffer();
|
|
43
44
|
/** Deliver a buffered message to a session (write + broadcast + log). */
|
|
44
45
|
function deliverBufferedMessage(session, msg) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// Combine message + Enter into a single write for atomic delivery through
|
|
47
|
+
// the shellper protocol (Bugfix #481: split writes can arrive as separate
|
|
48
|
+
// DATA frames, allowing the Enter to be lost between frames).
|
|
49
|
+
session.write(msg.noEnter ? msg.formattedMessage : msg.formattedMessage + '\r');
|
|
49
50
|
broadcastMessage(msg.broadcastPayload);
|
|
50
51
|
}
|
|
51
52
|
/** Start the send buffer flush timer (called from tower-server during init). */
|
|
@@ -63,6 +64,7 @@ const ROUTES = {
|
|
|
63
64
|
'GET /api/terminals': (_req, res) => handleTerminalList(res),
|
|
64
65
|
'GET /api/status': (_req, res) => handleStatus(res),
|
|
65
66
|
'GET /api/overview': (_req, res, url) => handleOverview(res, url),
|
|
67
|
+
'GET /api/analytics': (_req, res, url) => handleAnalytics(res, url),
|
|
66
68
|
'POST /api/overview/refresh': (_req, res, _url, ctx) => handleOverviewRefresh(res, ctx),
|
|
67
69
|
'GET /api/events': (req, res, _url, ctx) => handleSSEEvents(req, res, ctx),
|
|
68
70
|
'POST /api/notify': (req, res, _url, ctx) => handleNotify(req, res, ctx),
|
|
@@ -92,7 +94,7 @@ export async function handleRequest(req, res, ctx) {
|
|
|
92
94
|
origin.startsWith('https://'))) {
|
|
93
95
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
94
96
|
}
|
|
95
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
97
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
|
|
96
98
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
97
99
|
res.setHeader('Cache-Control', 'no-store');
|
|
98
100
|
if (req.method === 'OPTIONS') {
|
|
@@ -297,7 +299,7 @@ async function handleTerminalCreate(req, res, ctx) {
|
|
|
297
299
|
else {
|
|
298
300
|
entry.shells.set(roleId, session.id);
|
|
299
301
|
}
|
|
300
|
-
saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime);
|
|
302
|
+
saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, label ?? null, cwd ?? null);
|
|
301
303
|
ctx.log('INFO', `Registered shellper terminal ${session.id} as ${termType} "${roleId}" for workspace ${workspacePath}`);
|
|
302
304
|
}
|
|
303
305
|
}
|
|
@@ -318,7 +320,7 @@ async function handleTerminalCreate(req, res, ctx) {
|
|
|
318
320
|
else {
|
|
319
321
|
entry.shells.set(roleId, info.id);
|
|
320
322
|
}
|
|
321
|
-
saveTerminalSession(info.id, workspacePath, termType, roleId, info.pid);
|
|
323
|
+
saveTerminalSession(info.id, workspacePath, termType, roleId, info.pid, null, null, null, null, cwd ?? null);
|
|
322
324
|
ctx.log('WARN', `Terminal ${info.id} for ${workspacePath} is non-persistent (shellper unavailable)`);
|
|
323
325
|
}
|
|
324
326
|
}
|
|
@@ -432,6 +434,73 @@ async function handleTerminalRoutes(req, res, url, match) {
|
|
|
432
434
|
res.end(JSON.stringify(output));
|
|
433
435
|
return;
|
|
434
436
|
}
|
|
437
|
+
// PATCH /api/terminals/:id/rename - Rename terminal session (Spec 468)
|
|
438
|
+
if (req.method === 'PATCH' && subpath === '/rename') {
|
|
439
|
+
try {
|
|
440
|
+
const body = await parseJsonBody(req);
|
|
441
|
+
let name = body.name;
|
|
442
|
+
if (typeof name !== 'string') {
|
|
443
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
444
|
+
res.end(JSON.stringify({ error: 'Name must be 1-100 characters' }));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
// Strip control characters
|
|
448
|
+
name = name.replace(/[\x00-\x1f\x7f]/g, '');
|
|
449
|
+
if (name.length === 0 || name.length > 100) {
|
|
450
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
451
|
+
res.end(JSON.stringify({ error: 'Name must be 1-100 characters' }));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Two-step ID lookup: direct PtySession ID match, then shellperSessionId match
|
|
455
|
+
let session = manager.getSession(terminalId);
|
|
456
|
+
if (!session) {
|
|
457
|
+
for (const info of manager.listSessions()) {
|
|
458
|
+
const candidate = manager.getSession(info.id);
|
|
459
|
+
if (candidate?.shellperSessionId === terminalId) {
|
|
460
|
+
session = candidate;
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (!session) {
|
|
466
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
467
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
// Look up terminal_sessions row to check type
|
|
471
|
+
const dbSession = getTerminalSessionById(session.id);
|
|
472
|
+
if (!dbSession) {
|
|
473
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
474
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (dbSession.type !== 'shell') {
|
|
478
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
479
|
+
res.end(JSON.stringify({ error: 'Cannot rename builder/architect terminals' }));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// Dedup: check active shell labels in the same workspace, excluding current session
|
|
483
|
+
const otherLabels = new Set(getActiveShellLabels(dbSession.workspace_path, session.id));
|
|
484
|
+
let finalName = name;
|
|
485
|
+
if (otherLabels.has(name)) {
|
|
486
|
+
let suffix = 1;
|
|
487
|
+
while (otherLabels.has(`${name}-${suffix}`)) {
|
|
488
|
+
suffix++;
|
|
489
|
+
}
|
|
490
|
+
finalName = `${name}-${suffix}`;
|
|
491
|
+
}
|
|
492
|
+
// Update SQLite and in-memory
|
|
493
|
+
updateTerminalLabel(session.id, finalName);
|
|
494
|
+
session.label = finalName;
|
|
495
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
496
|
+
res.end(JSON.stringify({ id: terminalId, name: finalName }));
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
500
|
+
res.end(JSON.stringify({ error: 'Invalid JSON body' }));
|
|
501
|
+
}
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
435
504
|
}
|
|
436
505
|
async function handleStatus(res) {
|
|
437
506
|
const instances = await getInstances();
|
|
@@ -473,6 +542,35 @@ function handleOverviewRefresh(res, ctx) {
|
|
|
473
542
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
474
543
|
res.end(JSON.stringify({ ok: true }));
|
|
475
544
|
}
|
|
545
|
+
async function handleAnalytics(res, url, workspaceOverride) {
|
|
546
|
+
let workspaceRoot = workspaceOverride || url.searchParams.get('workspace');
|
|
547
|
+
if (!workspaceRoot) {
|
|
548
|
+
const knownPaths = getKnownWorkspacePaths();
|
|
549
|
+
workspaceRoot = knownPaths.find(p => !p.includes('/.builders/')) || null;
|
|
550
|
+
}
|
|
551
|
+
// Validate range parameter (before workspace check so fallback uses correct range)
|
|
552
|
+
const rangeParam = url.searchParams.get('range') ?? '7';
|
|
553
|
+
if (!['1', '7', '30', 'all'].includes(rangeParam)) {
|
|
554
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
555
|
+
res.end(JSON.stringify({ error: 'Invalid range. Must be 1, 7, 30, or all.' }));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const rangeLabel = rangeParam === 'all' ? 'all' : rangeParam === '1' ? '24h' : `${rangeParam}d`;
|
|
559
|
+
if (!workspaceRoot) {
|
|
560
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
561
|
+
res.end(JSON.stringify({ timeRange: rangeLabel, github: { prsMerged: 0, avgTimeToMergeHours: null, bugBacklog: 0, nonBugBacklog: 0, issuesClosed: 0, avgTimeToCloseBugsHours: null }, builders: { projectsCompleted: 0, throughputPerWeek: 0, activeBuilders: 0 }, consultation: { totalCount: 0, totalCostUsd: null, costByModel: {}, avgLatencySeconds: null, successRate: null, byModel: [], byReviewType: {}, byProtocol: {}, costByProject: [] } }));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const range = rangeParam;
|
|
565
|
+
const refresh = url.searchParams.get('refresh') === '1';
|
|
566
|
+
// Get active builder count from workspace terminals
|
|
567
|
+
const wsTerminals = getWorkspaceTerminals();
|
|
568
|
+
const entry = wsTerminals.get(normalizeWorkspacePath(workspaceRoot));
|
|
569
|
+
const activeBuilders = entry?.builders.size ?? 0;
|
|
570
|
+
const data = await computeAnalytics(workspaceRoot, range, activeBuilders, refresh);
|
|
571
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
572
|
+
res.end(JSON.stringify(data));
|
|
573
|
+
}
|
|
476
574
|
function handleSSEEvents(req, res, ctx) {
|
|
477
575
|
const clientId = crypto.randomBytes(8).toString('hex');
|
|
478
576
|
res.writeHead(200, {
|
|
@@ -602,8 +700,10 @@ async function handleSend(req, res, ctx) {
|
|
|
602
700
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
603
701
|
}
|
|
604
702
|
// Check if user is idle — deliver immediately or buffer (Spec 403, Bugfix #450)
|
|
605
|
-
// Defer when
|
|
606
|
-
|
|
703
|
+
// Defer only when user has typed recently (within idle threshold).
|
|
704
|
+
// Bugfix #492: removed session.composing check — composing gets stuck true
|
|
705
|
+
// after non-Enter keystrokes (Ctrl+C, arrows, Tab), causing 60s delays.
|
|
706
|
+
const shouldDefer = !interrupt && !session.isUserIdle(sendBuffer.idleThresholdMs);
|
|
607
707
|
if (shouldDefer) {
|
|
608
708
|
// User is actively typing — buffer for deferred delivery
|
|
609
709
|
sendBuffer.enqueue({
|
|
@@ -617,11 +717,11 @@ async function handleSend(req, res, ctx) {
|
|
|
617
717
|
ctx.log('INFO', `Message deferred (user typing): ${from ?? 'unknown'} → ${result.agent} (terminal ${result.terminalId.slice(0, 8)}...)`);
|
|
618
718
|
}
|
|
619
719
|
else {
|
|
620
|
-
// User is idle (or interrupt) — deliver immediately
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
720
|
+
// User is idle (or interrupt) — deliver immediately.
|
|
721
|
+
// Combine message + Enter into a single write for atomic delivery through
|
|
722
|
+
// the shellper protocol (Bugfix #481: split writes can arrive as separate
|
|
723
|
+
// DATA frames, allowing the Enter to be lost between frames).
|
|
724
|
+
session.write(noEnter ? formattedMessage : formattedMessage + '\r');
|
|
625
725
|
broadcastMessage(broadcastPayload);
|
|
626
726
|
ctx.log('INFO', logMessage);
|
|
627
727
|
}
|
|
@@ -804,23 +904,10 @@ async function handleWorkspaceRoutes(req, res, ctx, url) {
|
|
|
804
904
|
await handleTunnelEndpoint(req, res, tunnelSub);
|
|
805
905
|
return;
|
|
806
906
|
}
|
|
807
|
-
// GET /file?path=<relative-path> — Read
|
|
907
|
+
// GET /file?path=<relative-path> — Read file by path (allows files outside workspace — see issue #502)
|
|
808
908
|
if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
|
|
809
909
|
const relPath = url.searchParams.get('path');
|
|
810
910
|
const fullPath = path.resolve(workspacePath, relPath);
|
|
811
|
-
// Security: symlink-aware containment check (consistent with POST /tabs/file)
|
|
812
|
-
let resolvedFilePath;
|
|
813
|
-
try {
|
|
814
|
-
resolvedFilePath = fs.realpathSync(fullPath);
|
|
815
|
-
}
|
|
816
|
-
catch {
|
|
817
|
-
resolvedFilePath = path.resolve(fullPath);
|
|
818
|
-
}
|
|
819
|
-
if (!resolvedFilePath.startsWith(workspacePath + path.sep) && resolvedFilePath !== workspacePath) {
|
|
820
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
821
|
-
res.end('Forbidden');
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
911
|
try {
|
|
825
912
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
826
913
|
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
@@ -972,6 +1059,10 @@ async function handleWorkspaceRoutes(req, res, ctx, url) {
|
|
|
972
1059
|
if (req.method === 'POST' && apiPath === 'overview/refresh') {
|
|
973
1060
|
return handleOverviewRefresh(res, ctx);
|
|
974
1061
|
}
|
|
1062
|
+
// GET /api/analytics - Dashboard analytics (Spec 456)
|
|
1063
|
+
if (req.method === 'GET' && apiPath === 'analytics') {
|
|
1064
|
+
return handleAnalytics(res, url, workspacePath);
|
|
1065
|
+
}
|
|
975
1066
|
// GET /api/events - SSE push notifications (Bugfix #388)
|
|
976
1067
|
if (req.method === 'GET' && apiPath === 'events') {
|
|
977
1068
|
return handleSSEEvents(req, res, ctx);
|
|
@@ -991,7 +1082,7 @@ async function handleWorkspaceRoutes(req, res, ctx, url) {
|
|
|
991
1082
|
// If we get here for non-API, non-WS paths and React dashboard is not available
|
|
992
1083
|
if (!ctx.hasReactDashboard) {
|
|
993
1084
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
994
|
-
res.end('
|
|
1085
|
+
res.end('Overview not available');
|
|
995
1086
|
return;
|
|
996
1087
|
}
|
|
997
1088
|
// Fallback for unmatched paths
|
|
@@ -1042,11 +1133,12 @@ async function handleWorkspaceState(res, workspacePath) {
|
|
|
1042
1133
|
if (session) {
|
|
1043
1134
|
state.utils.push({
|
|
1044
1135
|
id: shellId,
|
|
1045
|
-
name:
|
|
1136
|
+
name: session.label,
|
|
1046
1137
|
port: 0,
|
|
1047
1138
|
pid: session.pid || 0,
|
|
1048
1139
|
terminalId,
|
|
1049
1140
|
persistent: isSessionPersistent(terminalId, session),
|
|
1141
|
+
lastDataAt: session.lastDataAt,
|
|
1050
1142
|
});
|
|
1051
1143
|
}
|
|
1052
1144
|
}
|
|
@@ -1096,6 +1188,9 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
|
|
|
1096
1188
|
// Strip CLAUDECODE so spawned Claude processes don't detect nesting
|
|
1097
1189
|
const shellEnv = { ...process.env };
|
|
1098
1190
|
delete shellEnv['CLAUDECODE'];
|
|
1191
|
+
// Inject session identity for af rename (Spec 468)
|
|
1192
|
+
shellEnv['SHELLPER_SESSION_ID'] = sessionId;
|
|
1193
|
+
shellEnv['TOWER_PORT'] = String(ctx.port);
|
|
1099
1194
|
const client = await shellperManager.createSession({
|
|
1100
1195
|
sessionId,
|
|
1101
1196
|
command: shellCmd,
|
|
@@ -1116,13 +1211,13 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
|
|
|
1116
1211
|
}
|
|
1117
1212
|
const entry = getWorkspaceTerminalsEntry(workspacePath);
|
|
1118
1213
|
entry.shells.set(shellId, session.id);
|
|
1119
|
-
saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime);
|
|
1214
|
+
saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, session.label, workspacePath);
|
|
1120
1215
|
shellCreated = true;
|
|
1121
1216
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1122
1217
|
res.end(JSON.stringify({
|
|
1123
1218
|
id: shellId,
|
|
1124
1219
|
port: 0,
|
|
1125
|
-
name:
|
|
1220
|
+
name: session.label,
|
|
1126
1221
|
terminalId: session.id,
|
|
1127
1222
|
persistent: true,
|
|
1128
1223
|
}));
|
|
@@ -1133,6 +1228,8 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
|
|
|
1133
1228
|
}
|
|
1134
1229
|
// Fallback: non-persistent session (graceful degradation per plan)
|
|
1135
1230
|
// Shellper is the only persistence backend for new sessions.
|
|
1231
|
+
// Note: SHELLPER_SESSION_ID is not set for non-persistent sessions since
|
|
1232
|
+
// they don't survive Tower restarts and rename wouldn't persist.
|
|
1136
1233
|
if (!shellCreated) {
|
|
1137
1234
|
const session = await manager.createSession({
|
|
1138
1235
|
command: shellCmd,
|
|
@@ -1143,13 +1240,13 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
|
|
|
1143
1240
|
});
|
|
1144
1241
|
const entry = getWorkspaceTerminalsEntry(workspacePath);
|
|
1145
1242
|
entry.shells.set(shellId, session.id);
|
|
1146
|
-
saveTerminalSession(session.id, workspacePath, 'shell', shellId, session.pid);
|
|
1243
|
+
saveTerminalSession(session.id, workspacePath, 'shell', shellId, session.pid, null, null, null, session.label, workspacePath);
|
|
1147
1244
|
ctx.log('WARN', `Shell ${shellId} for ${workspacePath} is non-persistent (shellper unavailable)`);
|
|
1148
1245
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1149
1246
|
res.end(JSON.stringify({
|
|
1150
1247
|
id: shellId,
|
|
1151
1248
|
port: 0,
|
|
1152
|
-
name:
|
|
1249
|
+
name: session.label,
|
|
1153
1250
|
terminalId: session.id,
|
|
1154
1251
|
persistent: false,
|
|
1155
1252
|
}));
|
|
@@ -1191,35 +1288,18 @@ async function handleWorkspaceFileTabCreate(req, res, ctx, workspacePath) {
|
|
|
1191
1288
|
else {
|
|
1192
1289
|
fullPath = path.join(workspacePath, filePath);
|
|
1193
1290
|
}
|
|
1194
|
-
//
|
|
1195
|
-
// For non-existent files, resolve the parent directory to handle
|
|
1196
|
-
// intermediate symlinks (e.g., /tmp -> /private/tmp on macOS).
|
|
1197
|
-
let resolvedPath;
|
|
1291
|
+
// Resolve symlinks for canonical path (but allow files outside workspace — see issue #502)
|
|
1198
1292
|
try {
|
|
1199
|
-
|
|
1293
|
+
fullPath = fs.realpathSync(fullPath);
|
|
1200
1294
|
}
|
|
1201
1295
|
catch {
|
|
1202
1296
|
try {
|
|
1203
|
-
|
|
1297
|
+
fullPath = path.join(fs.realpathSync(path.dirname(fullPath)), path.basename(fullPath));
|
|
1204
1298
|
}
|
|
1205
1299
|
catch {
|
|
1206
|
-
|
|
1300
|
+
fullPath = path.resolve(fullPath);
|
|
1207
1301
|
}
|
|
1208
1302
|
}
|
|
1209
|
-
let normalizedWorkspace;
|
|
1210
|
-
try {
|
|
1211
|
-
normalizedWorkspace = fs.realpathSync(workspacePath);
|
|
1212
|
-
}
|
|
1213
|
-
catch {
|
|
1214
|
-
normalizedWorkspace = path.resolve(workspacePath);
|
|
1215
|
-
}
|
|
1216
|
-
const isWithinWorkspace = resolvedPath.startsWith(normalizedWorkspace + path.sep)
|
|
1217
|
-
|| resolvedPath === normalizedWorkspace;
|
|
1218
|
-
if (!isWithinWorkspace) {
|
|
1219
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1220
|
-
res.end(JSON.stringify({ error: 'Path outside workspace' }));
|
|
1221
|
-
return;
|
|
1222
|
-
}
|
|
1223
1303
|
// Non-existent files still create a tab (spec 0101: file viewer shows "File not found")
|
|
1224
1304
|
const fileExists = fs.existsSync(fullPath);
|
|
1225
1305
|
const entry = getWorkspaceTerminalsEntry(workspacePath);
|
|
@@ -1349,17 +1429,12 @@ async function handleWorkspaceTabDelete(res, ctx, workspacePath, tabId) {
|
|
|
1349
1429
|
const manager = getTerminalManager();
|
|
1350
1430
|
// Check if it's a file tab first (Spec 0092, write-through: in-memory + SQLite)
|
|
1351
1431
|
if (tabId.startsWith('file-')) {
|
|
1352
|
-
if (
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
}
|
|
1359
|
-
else {
|
|
1360
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1361
|
-
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
1362
|
-
}
|
|
1432
|
+
// Bugfix #474: Always attempt DB deletion even if not in memory (stale tab recovery)
|
|
1433
|
+
entry.fileTabs.delete(tabId);
|
|
1434
|
+
deleteFileTab(tabId);
|
|
1435
|
+
ctx.log('INFO', `Deleted file tab: ${tabId}`);
|
|
1436
|
+
res.writeHead(204);
|
|
1437
|
+
res.end();
|
|
1363
1438
|
return;
|
|
1364
1439
|
}
|
|
1365
1440
|
// Find and delete the terminal
|
|
@@ -1412,6 +1487,8 @@ async function handleWorkspaceStopAll(res, workspacePath) {
|
|
|
1412
1487
|
getWorkspaceTerminals().delete(workspacePath);
|
|
1413
1488
|
// TICK-001: Delete all terminal sessions from SQLite
|
|
1414
1489
|
deleteWorkspaceTerminalSessions(workspacePath);
|
|
1490
|
+
// Bugfix #474: Delete all file tabs for this workspace
|
|
1491
|
+
deleteFileTabsForWorkspace(workspacePath);
|
|
1415
1492
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1416
1493
|
res.end(JSON.stringify({ ok: true }));
|
|
1417
1494
|
}
|