gadget/gadget-code/src/lib/code-session.ts
Rob Colbert 0a510de487 docs: Add plan for Project-Specific Agent Instructions feature
- Create comprehensive plan document for agent instructions text area
- Define requirements, acceptance criteria, and technical implementation
- Include UI/UX mockups and testing strategy
- Plan discovered during FILES panel implementation
- Addresses need for project-specific acceptance criteria
2026-05-12 15:39:38 -04:00

563 lines
17 KiB
TypeScript

// src/lib/code-session.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import {
GadgetSocket,
SocketSession,
SocketSessionType,
} from "./socket-session.js";
import {
FileTreeEntry,
FileTreeRequestArgs,
GadgetComponent,
GadgetLogLevel,
IChatSession,
IDroneRegistration,
IProject,
IUser,
ChatTurnStatus,
GadgetId,
ChatTurnDocument,
WorkspaceMode,
SubmitPromptCallback,
AbortWorkOrderCallback,
} from "@gadget/api";
import ChatSession from "../models/chat-session.ts";
import DroneRegistration from "../models/drone-registration.ts";
import { ChatTurn } from "../models/chat-turn.ts";
import { ChatSessionService, SocketService } from "../services/index.ts";
import TabLock from "./tab-lock.js";
export class CodeSession extends SocketSession {
protected type: SocketSessionType = SocketSessionType.Code;
protected project: IProject | undefined;
protected chatSession: IChatSession | undefined;
protected selectedDrone: IDroneRegistration | undefined;
protected currentTurnId: GadgetId | undefined;
protected workspaceMode: WorkspaceMode = WorkspaceMode.Idle;
private chatSessionId: GadgetId | undefined;
private isReconnecting = false;
private tabLockAcquired = false;
constructor(socket: GadgetSocket, user: IUser) {
super(socket, user);
}
register() {
super.register();
this.socket.on("disconnect", this.onDisconnect.bind(this));
this.socket.on("requestSessionLock", this.onRequestSessionLock.bind(this));
this.socket.on(
"requestWorkspaceMode",
this.onRequestWorkspaceMode.bind(this),
);
this.socket.on("submitPrompt", this.onSubmitPrompt.bind(this));
this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this));
this.socket.on("releaseSessionLock", this.onReleaseSessionLock.bind(this));
this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
// Check for active session on connect
this.checkAndReestablishActiveSession();
}
private async onDisconnect(): Promise<void> {
// Release tab lock on disconnect
if (this.chatSessionId && this.tabLockAcquired) {
await TabLock.release(this.chatSessionId, this.socket.id);
this.tabLockAcquired = false;
}
}
get hasLock(): boolean {
return this.selectedDrone !== undefined && this.chatSession !== undefined;
}
get selectedDroneId(): GadgetId | undefined {
return this.selectedDrone?._id;
}
get activeChatSession(): IChatSession | undefined {
return this.chatSession;
}
get activeProject(): IProject | undefined {
return this.project;
}
private async checkAndReestablishActiveSession(): Promise<void> {
if (this.isReconnecting) return;
this.isReconnecting = true;
try {
// Get user's most recent chat session
const recentSessions = await ChatSession.find({ user: this.user._id })
.sort({ createdAt: -1 })
.limit(5);
for (const session of recentSessions) {
// Check if this session has a processing turn
const latestTurn = await ChatTurn.findOne({ session: session._id })
.sort({ createdAt: -1 });
if (latestTurn && latestTurn.status === ChatTurnStatus.Processing) {
// Found active session - attempt to reestablish connection
this.chatSessionId = session._id;
// Get the drone that was processing this turn
const droneReg = await DroneRegistration.findOne({
chatSessionId: session._id,
}).populate('user');
if (droneReg && droneReg.user) {
await this.autoRelock(droneReg, session);
}
break;
}
}
} catch (error) {
this.log.error("failed to check for active session", { error });
} finally {
this.isReconnecting = false;
}
}
private async autoRelock(
registration: IDroneRegistration,
session: IChatSession
): Promise<void> {
try {
// Try to acquire tab lock
const lockResult = await TabLock.acquire(
session._id,
this.user._id,
this.socket.id,
);
if (!lockResult.success) {
this.log.warn("tab lock denied - session open in another tab", {
chatSessionId: session._id,
lockedBy: lockResult.info?.socketId,
});
this.socket.emit("tabLockDenied", {
message: "Chat session is open in another browser tab",
});
return;
}
this.tabLockAcquired = true;
const droneSession = SocketService.getDroneSession(registration);
// Re-establish the chat session index
SocketService.registerChatSession(session._id, this);
droneSession.setChatSessionId(session._id);
// Update CodeSession state
this.chatSession = session;
this.selectedDrone = registration;
// Drain any queued messages
await droneSession.drainMessageQueue();
this.log.info("auto-reestablished session connection", {
chatSessionId: session._id,
droneId: registration._id,
});
// Emit status to client
this.socket.emit("status", "Reconnected to active session");
} catch (error) {
this.log.error("failed to auto-relock session", { error });
this.socket.emit("status", "Failed to reconnect to session");
}
}
/**
* Sets the selected drone for this code session.
*/
setSelectedDrone(registration: IDroneRegistration): void {
this.selectedDrone = registration;
}
/**
* Sets the active chat session and project for this code session.
*/
setChatSession(chatSession: IChatSession, project: IProject): void {
this.chatSession = chatSession;
this.project = project;
}
/**
* Called when the IDE sends a requestSessionLock event to lock a gadget-drone
* instance to this code session.
* @param registration the gadget-drone registration to which the request will
* be sent.
* @param project the project we're locking the drone to
* @param chatSession the chat session we're locking the drone to
* @param cb response callback to call with the result of the request
*/
onRequestSessionLock(
registration: IDroneRegistration,
project: IProject,
chatSession: IChatSession,
cb: (success: boolean, chatSessionId: string) => void,
) {
try {
const droneSession = SocketService.getDroneSession(registration);
droneSession.socket.emit(
"requestSessionLock",
registration,
project,
chatSession,
async (success: boolean, chatSessionId: string): Promise<void> => {
if (success) {
this.selectedDrone = registration;
this.chatSession = chatSession;
this.project = project;
this.chatSessionId = chatSession._id;
SocketService.registerChatSession(chatSession._id, this);
droneSession.setChatSessionId(chatSession._id);
// Acquire tab lock
const lockResult = await TabLock.acquire(
chatSession._id,
this.user._id,
this.socket.id,
);
this.tabLockAcquired = lockResult.success;
}
cb(success, chatSessionId);
},
);
} catch (error) {
cb(false, "");
}
}
/**
* Called when the IDE sends a requestWorkspaceMode event to change the drone's
* workspace mode.
* @param registration the gadget-drone registration
* @param project the project
* @param chatSession the chat session
* @param mode the requested workspace mode
* @param cb response callback with success and current mode
*/
onRequestWorkspaceMode(
registration: IDroneRegistration,
project: IProject,
chatSession: IChatSession,
mode: WorkspaceMode,
cb: (success: boolean, currentMode: WorkspaceMode) => void,
) {
if (!this.selectedDrone) {
this.log.warn("workspace mode request rejected: no drone selected");
return cb(false, this.workspaceMode);
}
try {
const droneSession = SocketService.getDroneSession(this.selectedDrone);
droneSession.socket.emit(
"requestWorkspaceMode",
registration,
project,
chatSession,
mode,
(success: boolean, currentMode: WorkspaceMode) => {
if (success) {
this.workspaceMode = currentMode;
}
cb(success, currentMode);
},
);
} catch (error) {
cb(false, this.workspaceMode);
}
}
/**
* Called when the IDE submits a prompt to be processed by the agent.
* Creates a ChatTurn document and sends a work order to the selected drone.
*/
async onSubmitPrompt(
content: string,
cb: SubmitPromptCallback,
): Promise<void> {
if (!this.selectedDrone) {
this.log.warn("prompt rejected: no drone selected");
return cb(false, { message: "No drone selected" });
}
if (!this.chatSession) {
this.log.warn("prompt rejected: no chat session active");
return cb(false, { message: "No chat session active" });
}
if (!this.project) {
this.log.warn("prompt rejected: no project selected");
return cb(false, { message: "No project selected" });
}
try {
const droneSession = SocketService.getDroneSession(this.selectedDrone);
const latestSession = await ChatSessionService.getById(
this.chatSession._id,
);
this.chatSession = latestSession;
let turn: ChatTurnDocument = await ChatSessionService.createTurn(
this.chatSession,
content,
);
this.log.info("ChatTurn created", {
turnId: turn._id,
chatSessionId: this.chatSession._id,
});
this.currentTurnId = turn._id;
droneSession.setCurrentTurnId(turn._id);
/*
* Increment the chat session turn count and replace our cached view of
* it. Reject the prompt if the chat session has been removed.
*/
const newSession = await ChatSession.findOneAndUpdate(
{ _id: this.chatSession._id },
{ $inc: { "stats.turnCount": 1 } },
{
new: true,
populate: ChatSessionService.populateChatSession,
},
);
if (!newSession) {
// remove the turn
await ChatSessionService.delete(this.chatSession._id);
// reject the prompt
const error = new Error("chat session has been removed");
error.statusCode = 404;
throw error;
}
this.chatSession = newSession;
/*
* Signal the IDE that the turn was created successfully. This moves the
* IDE to the Processing state, and it will start expecting events to be
* streamed in from the drone while processing the prompt.
*/
cb(true, { turnId: turn._id, message: "turn created successfully" });
/*
* Forward to gadget-drone as a work order for processing.
*/
droneSession.socket.emit(
"processWorkOrder",
this.selectedDrone,
turn,
async (success: boolean, message?: string) => {
if (success) {
this.log.info("work order accepted by drone", {
turnId: turn._id,
message,
});
/*
* Auto-generate a session name from the first prompt. Only do this when
* the name is still the default (user hasn't set a custom name) and
* we're on the first turn (turnCount === 1 after the increment above).
*/
if (
this.chatSession &&
this.chatSession.name === "New Chat Session" &&
this.chatSession.stats.turnCount === 1
) {
this.chatSession =
await ChatSessionService.generateSessionNameFromPrompt(
this.chatSession,
content,
);
const update: Partial<IChatSession> = {
name: this.chatSession.name,
};
this.socket.emit("sessionUpdated", update);
}
} else {
this.log.error("work order rejected by drone", {
turnId: turn._id,
message,
});
turn.status = ChatTurnStatus.Error;
turn.errorMessage = message || "Drone rejected work order";
await turn.save();
}
},
);
} catch (error) {
this.log.error("prompt rejected", { error });
cb(false, {});
}
}
/**
* Called when the IDE sends an abortWorkOrder event to cancel the
* currently running work order. Forwards to the drone.
*/
onAbortWorkOrder(cb: AbortWorkOrderCallback): void {
if (!this.selectedDrone) {
return cb(false, "No drone selected");
}
try {
const droneSession = SocketService.getDroneSession(this.selectedDrone);
droneSession.socket.emit("abortWorkOrder", cb);
} catch (error) {
this.log.error("failed to forward abortWorkOrder to drone", { error });
cb(false, "Failed to reach drone");
}
}
/**
* Called when the IDE sends a fileTreeRequest event to list directory contents.
* Forwards to the drone and relays response back to IDE.
*/
onFileTreeRequest(
args: FileTreeRequestArgs,
cb: (success: boolean, data: { entries?: FileTreeEntry[]; error?: string }) => void,
): void {
if (!this.selectedDrone) {
return cb(false, { error: "No drone selected" });
}
try {
const droneSession = SocketService.getDroneSession(this.selectedDrone);
droneSession.socket.emit("fileTreeRequest", args, (success: boolean, data: { entries?: FileTreeEntry[]; error?: string }) => {
// Forward response to IDE
if (success && data?.entries) {
this.socket.emit("fileTreeResponse", args.path || "", data.entries);
} else {
this.socket.emit("fileTreeResponse", args.path || "", [], data?.error);
}
cb(success, data);
});
} catch (error) {
this.log.error("failed to forward fileTreeRequest to drone", { error });
cb(false, { error: "Failed to reach drone" });
}
}
/**
* Called when the IDE sends a releaseSessionLock event to release a
* previously-acquired session lock on a gadget-drone instance.
*/
onReleaseSessionLock(
registration: IDroneRegistration,
project: IProject,
chatSession: IChatSession,
cb: (success: boolean) => void,
) {
try {
const droneSession = SocketService.getDroneSession(registration);
droneSession.socket.emit(
"releaseSessionLock",
registration,
project,
chatSession,
async (success: boolean) => {
if (success) {
SocketService.unregisterChatSession(chatSession._id);
droneSession.chatSessionId = undefined;
this.selectedDrone = undefined;
this.chatSession = undefined;
this.project = undefined;
this.chatSessionId = undefined;
// Release tab lock
if (this.tabLockAcquired) {
await TabLock.release(chatSession._id, this.socket.id);
this.tabLockAcquired = false;
}
}
cb(success);
},
);
} catch (error) {
cb(false);
}
}
/**
* Called when the IDE sends a sessionHeartbeat event to keep the session
* lock alive. Forwards to the drone which maintains a timeout.
*/
onSessionHeartbeat(cb: (ack: boolean) => void) {
if (!this.selectedDrone) {
return cb(false);
}
try {
const droneSession = SocketService.getDroneSession(this.selectedDrone);
droneSession.socket.emit("sessionHeartbeat", (ack: boolean) => {
cb(ack);
});
} catch (error) {
this.log.error("failed to forward session heartbeat to drone", {
error,
});
cb(false);
}
}
/**
* Called by DroneSession when the drone emits a workspace mode change.
* Updates local state and forwards to the IDE socket.
*/
onWorkspaceModeChanged(mode: WorkspaceMode): void {
this.workspaceMode = mode;
this.socket.emit("workspaceModeChanged", mode);
}
onStatus(content: string): void {
this.socket.emit("status", content);
}
onLog(
timestamp: Date,
component: GadgetComponent,
level: GadgetLogLevel,
message: string,
metadata?: unknown,
): void {
this.socket.emit("log", timestamp, component, level, message, metadata);
}
onThinking(content: string): void {
this.socket.emit("thinking", content);
}
onResponse(content: string): void {
this.socket.emit("response", content);
}
onToolCall(
callId: string,
name: string,
params: string,
response: string,
): void {
this.socket.emit("toolCall", callId, name, params, response);
}
onWorkOrderComplete(
turnId: string,
success: boolean,
message?: string,
): void {
this.socket.emit("workOrderComplete", turnId, success, message);
}
}