chore: update package.json and refactor terminal WebSocket connection handling

- Added a postinstall script in package.json to set permissions for spawn-helper on macOS.
- Refactored the terminal WebSocket connection handling in index.ts for improved readability and consistency.
- Enhanced error logging and connection management in the terminal service.
- Cleaned up formatting and indentation across multiple files for better code clarity.
This commit is contained in:
Cody Seibert
2025-12-13 22:02:30 -05:00
parent c21a298e07
commit 7f5cdc0345
3 changed files with 165 additions and 126 deletions

View File

@@ -30,7 +30,12 @@ import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
import { createWorkspaceRoutes } from "./routes/workspace.js";
import { createTemplatesRoutes } from "./routes/templates.js";
import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js";
import {
createTerminalRoutes,
validateTerminalToken,
isTerminalEnabled,
isTerminalPasswordRequired,
} from "./routes/terminal.js";
import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js";
import { AutoModeService } from "./services/auto-mode-service.js";
@@ -64,7 +69,9 @@ if (!hasAnthropicKey && !hasOAuthToken) {
╚═══════════════════════════════════════════════════════════════════════╝
`);
} else if (hasOAuthToken) {
console.log("[Server] ✓ CLAUDE_CODE_OAUTH_TOKEN detected (subscription auth)");
console.log(
"[Server] ✓ CLAUDE_CODE_OAUTH_TOKEN detected (subscription auth)"
);
} else {
console.log("[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)");
}
@@ -130,7 +137,10 @@ const terminalService = getTerminalService();
// Handle HTTP upgrade requests manually to route to correct WebSocket server
server.on("upgrade", (request, socket, head) => {
const { pathname } = new URL(request.url || "", `http://${request.headers.host}`);
const { pathname } = new URL(
request.url || "",
`http://${request.headers.host}`
);
if (pathname === "/api/events") {
wss.handleUpgrade(request, socket, head, (ws) => {
@@ -171,139 +181,153 @@ wss.on("connection", (ws: WebSocket) => {
const terminalConnections: Map<string, Set<WebSocket>> = new Map();
// Terminal WebSocket connection handler
terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => {
// Parse URL to get session ID and token
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
const token = url.searchParams.get("token");
terminalWss.on(
"connection",
(ws: WebSocket, req: import("http").IncomingMessage) => {
// Parse URL to get session ID and token
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
const token = url.searchParams.get("token");
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
// Check if terminal is enabled
if (!isTerminalEnabled()) {
console.log("[Terminal WS] Terminal is disabled");
ws.close(4003, "Terminal access is disabled");
return;
}
// Validate token if password is required
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
console.log("[Terminal WS] Invalid or missing token");
ws.close(4001, "Authentication required");
return;
}
if (!sessionId) {
console.log("[Terminal WS] No session ID provided");
ws.close(4002, "Session ID required");
return;
}
// Check if session exists
const session = terminalService.getSession(sessionId);
if (!session) {
console.log(`[Terminal WS] Session ${sessionId} not found`);
ws.close(4004, "Session not found");
return;
}
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
// Track this connection
if (!terminalConnections.has(sessionId)) {
terminalConnections.set(sessionId, new Set());
}
terminalConnections.get(sessionId)!.add(ws);
// Subscribe to terminal data
const unsubscribeData = terminalService.onData((sid, data) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "data", data }));
// Check if terminal is enabled
if (!isTerminalEnabled()) {
console.log("[Terminal WS] Terminal is disabled");
ws.close(4003, "Terminal access is disabled");
return;
}
});
// Subscribe to terminal exit
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "exit", exitCode }));
ws.close(1000, "Session ended");
// Validate token if password is required
if (
isTerminalPasswordRequired() &&
!validateTerminalToken(token || undefined)
) {
console.log("[Terminal WS] Invalid or missing token");
ws.close(4001, "Authentication required");
return;
}
});
// Handle incoming messages
ws.on("message", (message) => {
try {
const msg = JSON.parse(message.toString());
if (!sessionId) {
console.log("[Terminal WS] No session ID provided");
ws.close(4002, "Session ID required");
return;
}
switch (msg.type) {
case "input":
// Write user input to terminal
terminalService.write(sessionId, msg.data);
break;
// Check if session exists
const session = terminalService.getSession(sessionId);
if (!session) {
console.log(`[Terminal WS] Session ${sessionId} not found`);
ws.close(4004, "Session not found");
return;
}
case "resize":
// Resize terminal
if (msg.cols && msg.rows) {
terminalService.resize(sessionId, msg.cols, msg.rows);
}
break;
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
case "ping":
// Respond to ping
ws.send(JSON.stringify({ type: "pong" }));
break;
// Track this connection
if (!terminalConnections.has(sessionId)) {
terminalConnections.set(sessionId, new Set());
}
terminalConnections.get(sessionId)!.add(ws);
default:
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
// Subscribe to terminal data
const unsubscribeData = terminalService.onData((sid, data) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "data", data }));
}
} catch (error) {
console.error("[Terminal WS] Error processing message:", error);
}
});
});
ws.on("close", () => {
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`);
unsubscribeData();
unsubscribeExit();
// Remove from connections tracking
const connections = terminalConnections.get(sessionId);
if (connections) {
connections.delete(ws);
if (connections.size === 0) {
terminalConnections.delete(sessionId);
// Subscribe to terminal exit
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "exit", exitCode }));
ws.close(1000, "Session ended");
}
});
// Handle incoming messages
ws.on("message", (message) => {
try {
const msg = JSON.parse(message.toString());
switch (msg.type) {
case "input":
// Write user input to terminal
terminalService.write(sessionId, msg.data);
break;
case "resize":
// Resize terminal
if (msg.cols && msg.rows) {
terminalService.resize(sessionId, msg.cols, msg.rows);
}
break;
case "ping":
// Respond to ping
ws.send(JSON.stringify({ type: "pong" }));
break;
default:
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
}
} catch (error) {
console.error("[Terminal WS] Error processing message:", error);
}
});
ws.on("close", () => {
console.log(
`[Terminal WS] Client disconnected from session ${sessionId}`
);
unsubscribeData();
unsubscribeExit();
// Remove from connections tracking
const connections = terminalConnections.get(sessionId);
if (connections) {
connections.delete(ws);
if (connections.size === 0) {
terminalConnections.delete(sessionId);
}
}
});
ws.on("error", (error) => {
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
unsubscribeData();
unsubscribeExit();
});
// Send initial connection success
ws.send(
JSON.stringify({
type: "connected",
sessionId,
shell: session.shell,
cwd: session.cwd,
})
);
// Send scrollback buffer to replay previous output
const scrollback = terminalService.getScrollback(sessionId);
if (scrollback && scrollback.length > 0) {
ws.send(
JSON.stringify({
type: "scrollback",
data: scrollback,
})
);
}
});
ws.on("error", (error) => {
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
unsubscribeData();
unsubscribeExit();
});
// Send initial connection success
ws.send(JSON.stringify({
type: "connected",
sessionId,
shell: session.shell,
cwd: session.cwd,
}));
// Send scrollback buffer to replay previous output
const scrollback = terminalService.getScrollback(sessionId);
if (scrollback && scrollback.length > 0) {
ws.send(JSON.stringify({
type: "scrollback",
data: scrollback,
}));
}
});
);
// Start server
server.listen(PORT, () => {
const terminalStatus = isTerminalEnabled()
? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled")
? isTerminalPasswordRequired()
? "enabled (password protected)"
: "enabled"
: "disabled";
console.log(`
╔═══════════════════════════════════════════════════════╗

View File

@@ -9,9 +9,7 @@ import fs from "fs/promises";
import type { EventEmitter } from "../lib/events.js";
import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js";
import {
readImageAsBase64,
} from "../lib/image-handler.js";
import { readImageAsBase64 } from "../lib/image-handler.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { getEffectiveModel } from "../lib/model-resolver.js";
import { isAbortError } from "../lib/error-handler.js";
@@ -136,7 +134,10 @@ export class AgentService {
filename: imageData.filename,
});
} catch (error) {
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
console.error(
`[AgentService] Failed to load image ${imagePath}:`,
error
);
}
}
}
@@ -197,7 +198,8 @@ export class AgentService {
"WebFetch",
],
abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
conversationHistory:
conversationHistory.length > 0 ? conversationHistory : undefined,
};
// Build prompt content with images
@@ -381,7 +383,11 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
await fs.writeFile(sessionFile, JSON.stringify(messages, null, 2), "utf-8");
await fs.writeFile(
sessionFile,
JSON.stringify(messages, null, 2),
"utf-8"
);
await this.updateSessionTimestamp(sessionId);
} catch (error) {
console.error("[AgentService] Failed to save session:", error);
@@ -398,7 +404,11 @@ export class AgentService {
}
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
await fs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), "utf-8");
await fs.writeFile(
this.metadataFile,
JSON.stringify(metadata, null, 2),
"utf-8"
);
}
async updateSessionTimestamp(sessionId: string): Promise<void> {
@@ -418,7 +428,8 @@ export class AgentService {
}
return sessions.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
@@ -505,7 +516,10 @@ export class AgentService {
return true;
}
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
private emitAgentEvent(
sessionId: string,
data: Record<string, unknown>
): void {
this.events.emit("agent:stream", { sessionId, ...data });
}

View File

@@ -7,6 +7,7 @@
"libs/*"
],
"scripts": {
"postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\"",
"dev": "npm run dev --workspace=apps/app",
"dev:web": "npm run dev:web --workspace=apps/app",
"dev:electron": "npm run dev:electron --workspace=apps/app",