mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-27 09:13:07 +08:00
feat: Add CLI session pause and resume functionality with UI integration
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
// Provides toggle buttons for floating panels (Issues/Queue/Inspector)
|
||||
// and layout preset controls. Sessions sidebar is always visible.
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -15,15 +15,28 @@ import {
|
||||
Columns2,
|
||||
Rows2,
|
||||
Square,
|
||||
Terminal,
|
||||
ChevronDown,
|
||||
Zap,
|
||||
Settings,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectAssociationChain,
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
import { useIssues, useIssueQueue } from '@/hooks/useIssues';
|
||||
import { useTerminalGridStore } from '@/stores/terminalGridStore';
|
||||
import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/terminalGridStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -76,8 +89,76 @@ export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolba
|
||||
[resetLayout]
|
||||
);
|
||||
|
||||
// Launch CLI handlers
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleQuickCreate = useCallback(async () => {
|
||||
if (!focusedPaneId || !projectPath) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createSessionAndAssign(focusedPaneId, {
|
||||
workingDir: projectPath,
|
||||
preferredShell: 'bash',
|
||||
}, projectPath);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [focusedPaneId, projectPath, createSessionAndAssign]);
|
||||
|
||||
const handleConfigure = useCallback(() => {
|
||||
// TODO: Open configuration modal (future implementation)
|
||||
console.log('Configure CLI session - modal to be implemented');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 h-[40px] border-b border-border bg-muted/30 shrink-0">
|
||||
{/* Launch CLI dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||||
isCreating && 'opacity-50 cursor-wait'
|
||||
)}
|
||||
disabled={isCreating || !projectPath}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.launchCli' })}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
onClick={handleQuickCreate}
|
||||
disabled={isCreating || !projectPath || !focusedPaneId}
|
||||
className="gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.quickCreate' })}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleConfigure}
|
||||
disabled={isCreating}
|
||||
className="gap-2"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.configure' })}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Panel toggle buttons */}
|
||||
<ToolbarButton
|
||||
icon={AlertCircle}
|
||||
|
||||
@@ -42,6 +42,8 @@ const statusDotStyles: Record<TerminalStatus, string> = {
|
||||
active: 'bg-green-500',
|
||||
idle: 'bg-gray-400',
|
||||
error: 'bg-red-500',
|
||||
paused: 'bg-yellow-500',
|
||||
resuming: 'bg-blue-400 animate-pulse',
|
||||
};
|
||||
|
||||
// ========== Props ==========
|
||||
|
||||
@@ -63,6 +63,7 @@ function getStoreState() {
|
||||
upsertCliSession: cliSessions.upsertSession,
|
||||
removeCliSession: cliSessions.removeSession,
|
||||
appendCliSessionOutput: cliSessions.appendOutput,
|
||||
updateCliSessionPausedState: cliSessions.updateSessionPausedState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,6 +196,22 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLI_SESSION_PAUSED': {
|
||||
const { sessionKey } = data.payload ?? {};
|
||||
if (typeof sessionKey === 'string') {
|
||||
stores.updateCliSessionPausedState(sessionKey, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLI_SESSION_RESUMED': {
|
||||
const { sessionKey } = data.payload ?? {};
|
||||
if (typeof sessionKey === 'string') {
|
||||
stores.updateCliSessionPausedState(sessionKey, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLI_OUTPUT': {
|
||||
const { executionId, chunkType, data: outputData, unit } = data.payload;
|
||||
|
||||
|
||||
@@ -6150,6 +6150,7 @@ export interface CliSession {
|
||||
resumeKey?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
export interface CreateCliSessionInput {
|
||||
@@ -6242,6 +6243,20 @@ export async function closeCliSession(sessionKey: string, projectPath?: string):
|
||||
});
|
||||
}
|
||||
|
||||
export async function pauseCliSession(sessionKey: string, projectPath?: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/pause`, projectPath), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resumeCliSession(sessionKey: string, projectPath?: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/resume`, projectPath), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCliSessionShareToken(
|
||||
sessionKey: string,
|
||||
input: { mode?: 'read' | 'write'; ttlMs?: number },
|
||||
|
||||
@@ -76,7 +76,10 @@
|
||||
"layoutSingle": "Single",
|
||||
"layoutSplitH": "Split Horizontal",
|
||||
"layoutSplitV": "Split Vertical",
|
||||
"layoutGrid": "Grid 2×2"
|
||||
"layoutGrid": "Grid 2x2",
|
||||
"launchCli": "Launch CLI",
|
||||
"quickCreate": "Quick Create",
|
||||
"configure": "Configure..."
|
||||
},
|
||||
"pane": {
|
||||
"selectSession": "Select a session",
|
||||
|
||||
@@ -76,7 +76,10 @@
|
||||
"layoutSingle": "单窗格",
|
||||
"layoutSplitH": "左右分割",
|
||||
"layoutSplitV": "上下分割",
|
||||
"layoutGrid": "2×2 网格"
|
||||
"layoutGrid": "2x2 网格",
|
||||
"launchCli": "启动 CLI",
|
||||
"quickCreate": "快速创建",
|
||||
"configure": "配置..."
|
||||
},
|
||||
"pane": {
|
||||
"selectSession": "选择会话",
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface CliSessionMeta {
|
||||
resumeKey?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
export interface CliSessionOutputChunk {
|
||||
@@ -30,6 +31,7 @@ interface CliSessionState {
|
||||
setSessions: (sessions: CliSessionMeta[]) => void;
|
||||
upsertSession: (session: CliSessionMeta) => void;
|
||||
removeSession: (sessionKey: string) => void;
|
||||
updateSessionPausedState: (sessionKey: string, isPaused: boolean) => void;
|
||||
|
||||
setBuffer: (sessionKey: string, buffer: string) => void;
|
||||
appendOutput: (sessionKey: string, data: string, timestamp?: number) => void;
|
||||
@@ -87,6 +89,18 @@ export const useCliSessionStore = create<CliSessionState>()(
|
||||
return { sessions: nextSessions, outputChunks: nextChunks, outputBytes: nextBytes };
|
||||
}),
|
||||
|
||||
updateSessionPausedState: (sessionKey, isPaused) =>
|
||||
set((state) => {
|
||||
const session = state.sessions[sessionKey];
|
||||
if (!session) return state;
|
||||
return {
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[sessionKey]: { ...session, isPaused, updatedAt: new Date().toISOString() },
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
setBuffer: (sessionKey, buffer) =>
|
||||
set((state) => ({
|
||||
outputChunks: {
|
||||
|
||||
@@ -17,6 +17,9 @@ import type {
|
||||
TerminalMeta,
|
||||
TerminalStatus,
|
||||
} from '../types/terminal-dashboard';
|
||||
import { pauseCliSession, resumeCliSession, closeCliSession, createCliSession } from '../lib/api';
|
||||
import { useCliSessionStore } from './cliSessionStore';
|
||||
import { useWorkflowStore, selectProjectPath } from './workflowStore';
|
||||
|
||||
// ========== Initial State ==========
|
||||
|
||||
@@ -178,6 +181,150 @@ export const useSessionManagerStore = create<SessionManagerStore>()(
|
||||
_workerRef.postMessage({ type: 'output', sessionId, text });
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Session Lifecycle Actions ==========
|
||||
|
||||
pauseSession: async (terminalId: string) => {
|
||||
const projectPath = selectProjectPath(useWorkflowStore.getState());
|
||||
try {
|
||||
await pauseCliSession(terminalId, projectPath ?? undefined);
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.terminalMetas[terminalId];
|
||||
if (!existing) return state;
|
||||
return {
|
||||
terminalMetas: {
|
||||
...state.terminalMetas,
|
||||
[terminalId]: { ...existing, status: 'paused' as TerminalStatus },
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'pauseSession'
|
||||
);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[SessionManager] pauseSession error:', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
resumeSession: async (terminalId: string) => {
|
||||
const projectPath = selectProjectPath(useWorkflowStore.getState());
|
||||
// First update to 'resuming' status
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.terminalMetas[terminalId];
|
||||
if (!existing) return state;
|
||||
return {
|
||||
terminalMetas: {
|
||||
...state.terminalMetas,
|
||||
[terminalId]: { ...existing, status: 'resuming' as TerminalStatus },
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'resumeSession/pending'
|
||||
);
|
||||
try {
|
||||
await resumeCliSession(terminalId, projectPath ?? undefined);
|
||||
// On success, update to 'active' status
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.terminalMetas[terminalId];
|
||||
if (!existing) return state;
|
||||
return {
|
||||
terminalMetas: {
|
||||
...state.terminalMetas,
|
||||
[terminalId]: { ...existing, status: 'active' as TerminalStatus },
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'resumeSession/fulfilled'
|
||||
);
|
||||
} catch (error) {
|
||||
// On error, revert to 'paused' status
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.terminalMetas[terminalId];
|
||||
if (!existing) return state;
|
||||
return {
|
||||
terminalMetas: {
|
||||
...state.terminalMetas,
|
||||
[terminalId]: { ...existing, status: 'paused' as TerminalStatus },
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'resumeSession/rejected'
|
||||
);
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[SessionManager] resumeSession error:', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
restartSession: async (terminalId: string) => {
|
||||
const projectPath = selectProjectPath(useWorkflowStore.getState());
|
||||
const cliStore = useCliSessionStore.getState();
|
||||
const session = cliStore.sessions[terminalId];
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${terminalId}`);
|
||||
}
|
||||
|
||||
// Store session config for recreation
|
||||
const sessionConfig = {
|
||||
workingDir: session.workingDir,
|
||||
tool: session.tool,
|
||||
model: session.model,
|
||||
resumeKey: session.resumeKey,
|
||||
shellKind: session.shellKind,
|
||||
};
|
||||
|
||||
try {
|
||||
// Close existing session
|
||||
await closeCliSession(terminalId, projectPath ?? undefined);
|
||||
|
||||
// Create new session with same config
|
||||
const result = await createCliSession(
|
||||
{
|
||||
workingDir: sessionConfig.workingDir,
|
||||
preferredShell: sessionConfig.shellKind === 'powershell' ? 'pwsh' : 'bash',
|
||||
tool: sessionConfig.tool,
|
||||
model: sessionConfig.model,
|
||||
resumeKey: sessionConfig.resumeKey,
|
||||
},
|
||||
projectPath ?? undefined
|
||||
);
|
||||
|
||||
// Update terminal meta to active status
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.terminalMetas[terminalId];
|
||||
if (!existing) return state;
|
||||
return {
|
||||
terminalMetas: {
|
||||
...state.terminalMetas,
|
||||
[terminalId]: { ...existing, status: 'active' as TerminalStatus, alertCount: 0 },
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'restartSession'
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[SessionManager] restartSession error:', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'SessionManagerStore' }
|
||||
)
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
removePaneFromLayout,
|
||||
getAllPaneIds,
|
||||
} from '@/lib/layout-utils';
|
||||
import type { CreateCliSessionInput, CliSession } from '@/lib/api';
|
||||
import { createCliSession } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -37,6 +39,12 @@ export interface TerminalGridActions {
|
||||
assignSession: (paneId: PaneId, sessionId: string | null) => void;
|
||||
setFocused: (paneId: PaneId) => void;
|
||||
resetLayout: (preset: 'single' | 'split-h' | 'split-v' | 'grid-2x2') => void;
|
||||
/** Create a new CLI session and assign it to a new pane (auto-split from specified pane) */
|
||||
createSessionAndAssign: (
|
||||
paneId: PaneId,
|
||||
config: CreateCliSessionInput,
|
||||
projectPath: string | null
|
||||
) => Promise<{ paneId: PaneId; session: CliSession } | null>;
|
||||
}
|
||||
|
||||
export type TerminalGridStore = TerminalGridState & TerminalGridActions;
|
||||
@@ -231,6 +239,60 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
'terminalGrid/resetLayout'
|
||||
);
|
||||
},
|
||||
|
||||
createSessionAndAssign: async (paneId, config, projectPath) => {
|
||||
try {
|
||||
// 1. Create the CLI session via API
|
||||
const result = await createCliSession(config, projectPath ?? undefined);
|
||||
const session = result.session;
|
||||
|
||||
// 2. Get current state
|
||||
const state = get();
|
||||
|
||||
// 3. Check if the current pane is empty (no session assigned)
|
||||
const currentPane = state.panes[paneId];
|
||||
const isCurrentPaneEmpty = !currentPane?.sessionId;
|
||||
|
||||
if (isCurrentPaneEmpty) {
|
||||
// Assign session to current empty pane
|
||||
set(
|
||||
{
|
||||
panes: {
|
||||
...state.panes,
|
||||
[paneId]: { ...currentPane, sessionId: session.sessionKey },
|
||||
},
|
||||
focusedPaneId: paneId,
|
||||
},
|
||||
false,
|
||||
'terminalGrid/createSessionAndAssign'
|
||||
);
|
||||
return { paneId, session };
|
||||
}
|
||||
|
||||
// 4. Current pane has session, auto-split and assign to new pane
|
||||
const newPaneId = generatePaneId(state.nextPaneIdCounter);
|
||||
const newLayout = addPaneToLayout(state.layout, newPaneId, paneId, 'horizontal');
|
||||
|
||||
set(
|
||||
{
|
||||
layout: newLayout,
|
||||
panes: {
|
||||
...state.panes,
|
||||
[newPaneId]: { id: newPaneId, sessionId: session.sessionKey },
|
||||
},
|
||||
focusedPaneId: newPaneId,
|
||||
nextPaneIdCounter: state.nextPaneIdCounter + 1,
|
||||
},
|
||||
false,
|
||||
'terminalGrid/createSessionAndAssign'
|
||||
);
|
||||
|
||||
return { paneId: newPaneId, session };
|
||||
} catch (error) {
|
||||
console.error('Failed to create CLI session:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'TerminalGridStore' }
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface SessionLayout {
|
||||
}
|
||||
|
||||
/** Terminal status indicator */
|
||||
export type TerminalStatus = 'active' | 'idle' | 'error';
|
||||
export type TerminalStatus = 'active' | 'idle' | 'error' | 'paused' | 'resuming';
|
||||
|
||||
/** Metadata for a terminal instance in the dashboard */
|
||||
export interface TerminalMeta {
|
||||
@@ -83,6 +83,12 @@ export interface SessionManagerActions {
|
||||
terminateMonitor: () => void;
|
||||
/** Forward a terminal output chunk to the monitor worker */
|
||||
feedMonitor: (sessionId: string, text: string) => void;
|
||||
/** Pause a terminal session (SIGSTOP) */
|
||||
pauseSession: (terminalId: string) => Promise<void>;
|
||||
/** Resume a paused terminal session (SIGCONT) */
|
||||
resumeSession: (terminalId: string) => Promise<void>;
|
||||
/** Restart a terminal session (close and recreate with same config) */
|
||||
restartSession: (terminalId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SessionManagerStore = SessionManagerState & SessionManagerActions;
|
||||
|
||||
@@ -444,5 +444,59 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/pause
|
||||
const pauseMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/pause$/);
|
||||
if (pauseMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(pauseMatch[1]);
|
||||
try {
|
||||
manager.pauseSession(sessionKey);
|
||||
appendCliSessionAudit({
|
||||
type: 'session_paused',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
if (message.includes('not found')) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
res.end(JSON.stringify({ error: message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/cli-sessions/:sessionKey/resume
|
||||
const resumeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/resume$/);
|
||||
if (resumeMatch && req.method === 'POST') {
|
||||
const sessionKey = decodeURIComponent(resumeMatch[1]);
|
||||
try {
|
||||
manager.resumeSession(sessionKey);
|
||||
appendCliSessionAudit({
|
||||
type: 'session_resumed',
|
||||
timestamp: new Date().toISOString(),
|
||||
projectRoot,
|
||||
sessionKey,
|
||||
...clientInfo(req),
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
if (message.includes('not found')) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
res.end(JSON.stringify({ error: message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import path from 'path';
|
||||
export type CliSessionAuditEventType =
|
||||
| 'session_created'
|
||||
| 'session_closed'
|
||||
| 'session_paused'
|
||||
| 'session_resumed'
|
||||
| 'session_send'
|
||||
| 'session_execute'
|
||||
| 'session_resize'
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface CliSession {
|
||||
resumeKey?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
export interface CreateCliSessionOptions {
|
||||
@@ -57,6 +58,7 @@ interface CliSessionInternal extends CliSession {
|
||||
buffer: string[];
|
||||
bufferBytes: number;
|
||||
lastActivityAt: number;
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
@@ -227,6 +229,7 @@ export class CliSessionManager {
|
||||
buffer: [],
|
||||
bufferBytes: 0,
|
||||
lastActivityAt: Date.now(),
|
||||
isPaused: false,
|
||||
};
|
||||
|
||||
pty.onData((data) => {
|
||||
@@ -318,6 +321,57 @@ export class CliSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
pauseSession(sessionKey: string): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${sessionKey}`);
|
||||
}
|
||||
if (session.isPaused) {
|
||||
throw new Error(`Session already paused: ${sessionKey}`);
|
||||
}
|
||||
const pid = session.pty.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error(`Session PTY has no PID: ${sessionKey}`);
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 'SIGSTOP');
|
||||
session.isPaused = true;
|
||||
session.updatedAt = nowIso();
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_PAUSED',
|
||||
payload: { sessionKey, timestamp: nowIso() }
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to pause session ${sessionKey}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
resumeSession(sessionKey: string): void {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${sessionKey}`);
|
||||
}
|
||||
if (!session.isPaused) {
|
||||
throw new Error(`Session is not paused: ${sessionKey}`);
|
||||
}
|
||||
const pid = session.pty.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error(`Session PTY has no PID: ${sessionKey}`);
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 'SIGCONT');
|
||||
session.isPaused = false;
|
||||
session.updatedAt = nowIso();
|
||||
session.lastActivityAt = Date.now();
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_RESUMED',
|
||||
payload: { sessionKey, timestamp: nowIso() }
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to resume session ${sessionKey}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
execute(sessionKey: string, options: ExecuteInCliSessionOptions): { executionId: string; command: string } {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
if (!session) {
|
||||
|
||||
Reference in New Issue
Block a user