@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.
Files changed (107) hide show
  1. package/dashboard/dist/assets/index-CALp-XEo.js +197 -0
  2. package/dashboard/dist/assets/index-CALp-XEo.js.map +1 -0
  3. package/dashboard/dist/assets/index-MVvud9Tr.css +32 -0
  4. package/dashboard/dist/index.html +2 -2
  5. package/dist/agent-farm/cli.d.ts.map +1 -1
  6. package/dist/agent-farm/cli.js +53 -5
  7. package/dist/agent-farm/cli.js.map +1 -1
  8. package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
  9. package/dist/agent-farm/commands/cleanup.js +16 -0
  10. package/dist/agent-farm/commands/cleanup.js.map +1 -1
  11. package/dist/agent-farm/commands/index.d.ts +1 -0
  12. package/dist/agent-farm/commands/index.d.ts.map +1 -1
  13. package/dist/agent-farm/commands/index.js +1 -0
  14. package/dist/agent-farm/commands/index.js.map +1 -1
  15. package/dist/agent-farm/commands/open.d.ts.map +1 -1
  16. package/dist/agent-farm/commands/open.js +8 -6
  17. package/dist/agent-farm/commands/open.js.map +1 -1
  18. package/dist/agent-farm/commands/rename.d.ts +12 -0
  19. package/dist/agent-farm/commands/rename.d.ts.map +1 -0
  20. package/dist/agent-farm/commands/rename.js +42 -0
  21. package/dist/agent-farm/commands/rename.js.map +1 -0
  22. package/dist/agent-farm/commands/shell.js +1 -1
  23. package/dist/agent-farm/commands/start.js +1 -1
  24. package/dist/agent-farm/commands/start.js.map +1 -1
  25. package/dist/agent-farm/db/index.d.ts.map +1 -1
  26. package/dist/agent-farm/db/index.js +25 -1
  27. package/dist/agent-farm/db/index.js.map +1 -1
  28. package/dist/agent-farm/db/schema.d.ts +1 -1
  29. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  30. package/dist/agent-farm/db/schema.js +2 -0
  31. package/dist/agent-farm/db/schema.js.map +1 -1
  32. package/dist/agent-farm/lib/tower-client.d.ts +12 -0
  33. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  34. package/dist/agent-farm/lib/tower-client.js +9 -0
  35. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  36. package/dist/agent-farm/servers/analytics.d.ts +64 -0
  37. package/dist/agent-farm/servers/analytics.d.ts.map +1 -0
  38. package/dist/agent-farm/servers/analytics.js +246 -0
  39. package/dist/agent-farm/servers/analytics.js.map +1 -0
  40. package/dist/agent-farm/servers/overview.d.ts +6 -0
  41. package/dist/agent-farm/servers/overview.d.ts.map +1 -1
  42. package/dist/agent-farm/servers/overview.js +46 -5
  43. package/dist/agent-farm/servers/overview.js.map +1 -1
  44. package/dist/agent-farm/servers/send-buffer.d.ts.map +1 -1
  45. package/dist/agent-farm/servers/send-buffer.js +4 -3
  46. package/dist/agent-farm/servers/send-buffer.js.map +1 -1
  47. package/dist/agent-farm/servers/tower-cron.js +2 -2
  48. package/dist/agent-farm/servers/tower-cron.js.map +1 -1
  49. package/dist/agent-farm/servers/tower-instances.d.ts +3 -1
  50. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
  51. package/dist/agent-farm/servers/tower-instances.js +21 -13
  52. package/dist/agent-farm/servers/tower-instances.js.map +1 -1
  53. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  54. package/dist/agent-farm/servers/tower-routes.js +144 -67
  55. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  56. package/dist/agent-farm/servers/tower-server.js +2 -1
  57. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  58. package/dist/agent-farm/servers/tower-terminals.d.ts +24 -1
  59. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
  60. package/dist/agent-farm/servers/tower-terminals.js +121 -15
  61. package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
  62. package/dist/agent-farm/servers/tower-types.d.ts +2 -0
  63. package/dist/agent-farm/servers/tower-types.d.ts.map +1 -1
  64. package/dist/agent-farm/utils/config.d.ts +1 -1
  65. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  66. package/dist/agent-farm/utils/config.js +2 -2
  67. package/dist/agent-farm/utils/config.js.map +1 -1
  68. package/dist/agent-farm/utils/file-tabs.d.ts +11 -0
  69. package/dist/agent-farm/utils/file-tabs.d.ts.map +1 -1
  70. package/dist/agent-farm/utils/file-tabs.js +18 -0
  71. package/dist/agent-farm/utils/file-tabs.js.map +1 -1
  72. package/dist/cli.js +1 -1
  73. package/dist/cli.js.map +1 -1
  74. package/dist/commands/consult/metrics.d.ts +4 -0
  75. package/dist/commands/consult/metrics.d.ts.map +1 -1
  76. package/dist/commands/consult/metrics.js +16 -0
  77. package/dist/commands/consult/metrics.js.map +1 -1
  78. package/dist/lib/github.d.ts +46 -0
  79. package/dist/lib/github.d.ts.map +1 -1
  80. package/dist/lib/github.js +104 -1
  81. package/dist/lib/github.js.map +1 -1
  82. package/dist/terminal/pty-session.d.ts +4 -1
  83. package/dist/terminal/pty-session.d.ts.map +1 -1
  84. package/dist/terminal/pty-session.js +7 -0
  85. package/dist/terminal/pty-session.js.map +1 -1
  86. package/package.json +1 -1
  87. package/skeleton/.claude/skills/af/SKILL.md +7 -6
  88. package/skeleton/protocols/air/builder-prompt.md +69 -0
  89. package/skeleton/protocols/air/consult-types/impl-review.md +58 -0
  90. package/skeleton/protocols/air/consult-types/pr-review.md +58 -0
  91. package/skeleton/protocols/air/prompts/implement.md +89 -0
  92. package/skeleton/protocols/air/prompts/pr.md +98 -0
  93. package/skeleton/protocols/air/protocol.json +109 -0
  94. package/skeleton/protocols/air/protocol.md +88 -0
  95. package/skeleton/protocols/spike/builder-prompt.md +62 -0
  96. package/skeleton/protocols/spike/protocol.json +36 -0
  97. package/skeleton/protocols/spike/protocol.md +122 -0
  98. package/skeleton/protocols/spike/templates/findings.md +67 -0
  99. package/skeleton/resources/commands/agent-farm.md +26 -24
  100. package/skeleton/resources/commands/overview.md +4 -4
  101. package/skeleton/resources/risk-triage.md +111 -0
  102. package/skeleton/resources/workflow-reference.md +17 -9
  103. package/skeleton/roles/architect.md +65 -13
  104. package/skeleton/templates/cheatsheet.md +3 -3
  105. package/dashboard/dist/assets/index-B-WzJvht.css +0 -32
  106. package/dashboard/dist/assets/index-DszQyc2c.js +0 -134
  107. 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
- session.write(msg.formattedMessage);
46
- if (!msg.noEnter) {
47
- session.write('\r');
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 composing (typed but not submitted) OR recently typed (idle threshold)
606
- const shouldDefer = !interrupt && (session.composing || !session.isUserIdle(sendBuffer.idleThresholdMs));
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
- session.write(formattedMessage);
622
- if (!noEnter) {
623
- session.write('\r');
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 workspace file by path
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('Dashboard not available');
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: `Shell ${shellId.replace('shell-', '')}`,
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: `Shell ${shellId.replace('shell-', '')}`,
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: `Shell ${shellId.replace('shell-', '')}`,
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
- // Security: symlink-aware containment check
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
- resolvedPath = fs.realpathSync(fullPath);
1293
+ fullPath = fs.realpathSync(fullPath);
1200
1294
  }
1201
1295
  catch {
1202
1296
  try {
1203
- resolvedPath = path.join(fs.realpathSync(path.dirname(fullPath)), path.basename(fullPath));
1297
+ fullPath = path.join(fs.realpathSync(path.dirname(fullPath)), path.basename(fullPath));
1204
1298
  }
1205
1299
  catch {
1206
- resolvedPath = path.resolve(fullPath);
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 (entry.fileTabs.has(tabId)) {
1353
- entry.fileTabs.delete(tabId);
1354
- deleteFileTab(tabId);
1355
- ctx.log('INFO', `Deleted file tab: ${tabId}`);
1356
- res.writeHead(204);
1357
- res.end();
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
  }