sudo commands through ephemeral terminal

This commit is contained in:
2026-02-19 21:43:37 +00:00
parent 04c79a09f0
commit 7c0b11c5a5
6 changed files with 240 additions and 46 deletions
@@ -1,10 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { Copy, Check } from 'lucide-react'; import { Copy, Check, Play } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useServerSettings } from '@/state/useServerSettings'; import { useServerSettings } from '@/state/useServerSettings';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null }; type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string }; type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
@@ -106,15 +118,28 @@ export const AIHarnessesSection = () => {
setTimeout(() => setCopied(null), 1500); setTimeout(() => setCopied(null), 1500);
}; };
const CopyCommand = ({ command }: { command: string }) => ( const [, setRunCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [confirmCommand, setConfirmCommand] = useState<{ command: string; refetchKeys: string[] } | null>(null);
const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
<div className="mt-2 text-xs text-amber-600"> <div className="mt-2 text-xs text-amber-600">
Not globally accessible. Run: Not globally accessible. Run:
<div className="flex items-center gap-1 mt-1"> <div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code> <code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
<button
type="button"
onClick={() => setConfirmCommand({ command, refetchKeys })}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Run in terminal"
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button <button
type="button" type="button"
onClick={() => copyToClipboard(command)} onClick={() => copyToClipboard(command)}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors" className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
title="Copy command"
> >
{copied === command ? ( {copied === command ? (
<Check className="h-3.5 w-3.5 text-green-600" /> <Check className="h-3.5 w-3.5 text-green-600" />
@@ -127,6 +152,7 @@ export const AIHarnessesSection = () => {
); );
return ( return (
<>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer">
@@ -163,7 +189,7 @@ export const AIHarnessesSection = () => {
</div> </div>
)} )}
{!opencodeVersion.globalPath && opencodeVersion.path && ( {!opencodeVersion.globalPath && opencodeVersion.path && (
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} /> <CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} refetchKeys={['OPENCODE_VERSION']} />
)} )}
</> </>
) : ( ) : (
@@ -215,7 +241,7 @@ export const AIHarnessesSection = () => {
</div> </div>
)} )}
{!claudeVersion.globalPath && claudeVersion.path && ( {!claudeVersion.globalPath && claudeVersion.path && (
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} /> <CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} refetchKeys={['CLAUDE_CODE_VERSION']} />
)} )}
</> </>
) : ( ) : (
@@ -249,7 +275,7 @@ export const AIHarnessesSection = () => {
<div>{piMonoVersion.version}</div> <div>{piMonoVersion.version}</div>
<div>{piMonoVersion.path}</div> <div>{piMonoVersion.path}</div>
{!piMonoVersion.globalPath && piMonoVersion.path && ( {!piMonoVersion.globalPath && piMonoVersion.path && (
<CopyCommand command={`sudo ln -s ${piMonoVersion.path} /usr/local/bin/pi`} /> <CopyCommand command={`sudo ln -s ${piMonoVersion.path} /usr/local/bin/pi`} refetchKeys={['PI_MONO_VERSION']} />
)} )}
</> </>
) : ( ) : (
@@ -266,5 +292,30 @@ export const AIHarnessesSection = () => {
)} )}
</div> </div>
</div> </div>
<AlertDialog open={!!confirmCommand} onOpenChange={(open) => !open && setConfirmCommand(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Run with elevated privileges</AlertDialogTitle>
<AlertDialogDescription>
You are about to run a command with elevated privileges (sudo). Are you sure?
</AlertDialogDescription>
</AlertDialogHeader>
<code className="text-xs bg-duck-dark/5 rounded px-3 py-2 text-duck-dark/70 break-all">{confirmCommand?.command}</code>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold"
onClick={() => {
if (confirmCommand) setRunCommand(confirmCommand);
setConfirmCommand(null);
}}
>
Run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
); );
}; };
@@ -0,0 +1,6 @@
export type RunCommandState = {
command: string;
refetchKeys: string[];
} | null;
export const RUN_COMMAND_CHANNEL = 'system-settings:run-command';
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings } from 'lucide-react'; import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings, X } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -10,8 +10,11 @@ import { Switch } from '@/components/ui/switch';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace'; import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { TerminalView } from 'apps/Terminal';
import { appRegistry } from '../Workspaces/app-registry'; import { appRegistry } from '../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel'; import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from '@/state/useSettings'; import { useSettings } from '@/state/useSettings';
@@ -27,6 +30,7 @@ import {
import type { UserSettings } from '@/state/types/user-settings'; import type { UserSettings } from '@/state/types/user-settings';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
import { PluginsSection } from './ServerSettings/PluginsSection'; import { PluginsSection } from './ServerSettings/PluginsSection';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
const groups: SettingsSectionGroup[] = [ const groups: SettingsSectionGroup[] = [
{ {
@@ -54,7 +58,7 @@ const { Sidebar, Content } = createSettingsPanelComponents({
groups, groups,
}); });
const layout: LayoutNode = { const baseLayout: LayoutNode = {
type: 'group', type: 'group',
id: 'system-root', id: 'system-root',
direction: 'horizontal', direction: 'horizontal',
@@ -64,11 +68,87 @@ const layout: LayoutNode = {
], ],
}; };
const splitLayout: LayoutNode = {
type: 'group',
id: 'system-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'system-left', appType: null }, size: 20 },
{
node: {
type: 'group',
id: 'system-right-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'system-right', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'system-terminal', appType: null }, size: 50 },
],
},
size: 80,
},
],
};
const SystemTerminalPanel = () => {
const queryClient = useQueryClient();
const [state, setState] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [session, setSession] = useState<{ id: string; command: string } | null>(null);
useEffect(() => {
if (state && (!session || session.command !== state.command)) {
setSession({ id: `run-cmd-${Date.now()}`, command: state.command });
} else if (!state) {
setSession(null);
}
}, [state]);
const close = () => setState(null);
const onCommandDone = (exitCode: number, output: string) => {
if (state) {
for (const key of state.refetchKeys) {
queryClient.invalidateQueries({ queryKey: [key] });
}
}
if (exitCode === 0) {
toast.success('Command completed successfully');
} else {
toast.error(output || `Command failed with exit code ${exitCode}`, { duration: 8000 });
}
setState(null);
};
if (!state || !session) return null;
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
<button onClick={close} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<TerminalView
className="flex-1"
sandboxed={false}
command={session.command}
sessionId={session.id}
onCommandDone={onCommandDone}
/>
</div>
);
};
export const SystemSettings = () => { export const SystemSettings = () => {
const [runCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]);
const panelComponents: PanelComponents = useMemo( const panelComponents: PanelComponents = useMemo(
() => ({ () => ({
'system-left': Sidebar, 'system-left': Sidebar,
'system-right': Content, 'system-right': Content,
'system-terminal': SystemTerminalPanel,
}), }),
[], [],
); );
+44 -34
View File
@@ -3,9 +3,16 @@ import { existsSync } from 'node:fs';
import { cp, mkdir } from 'node:fs/promises'; import { cp, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import * as pty from 'node-pty'; import * as pty from 'node-pty';
const run = (cmd, args, opts = {}) =>
new Promise((resolve) => {
const proc = execFile(cmd, args, { stdio: 'ignore', ...opts }, () => resolve());
proc.on('error', () => resolve());
});
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const isDocker = existsSync('/opt/terminal-templates/.zshrc'); const isDocker = existsSync('/opt/terminal-templates/.zshrc');
@@ -60,25 +67,14 @@ const ensureUserFiles = async (homeDir) => {
if (ohMyZshSource && existsSync(ohMyZshSource)) { if (ohMyZshSource && existsSync(ohMyZshSource)) {
await cp(ohMyZshSource, ohMyZshPath, { recursive: true }); await cp(ohMyZshSource, ohMyZshPath, { recursive: true });
} else { } else {
const proc = Bun.spawn({ await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]);
cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath],
stdout: 'ignore',
stderr: 'ignore',
});
await proc.exited;
} }
} }
if (!isDocker) { if (!isDocker) {
const starshipBin = join(homeDir, '.local', 'bin', 'starship'); const starshipBin = join(homeDir, '.local', 'bin', 'starship');
if (!existsSync(starshipBin)) { if (!existsSync(starshipBin)) {
const installProc = Bun.spawn({ await run('sh', ['-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], { env: { ...process.env, HOME: homeDir } });
cmd: ['sh', '-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'],
env: { ...process.env, HOME: homeDir },
stdout: 'ignore',
stderr: 'ignore',
});
await installProc.exited;
} }
} }
}; };
@@ -149,15 +145,36 @@ wss.on('connection', (ws) => {
const cwd = msg.cwd ?? process.cwd(); const cwd = msg.cwd ?? process.cwd();
const homeDir = msg.homeDir ?? process.cwd(); const homeDir = msg.homeDir ?? process.cwd();
const userLabel = msg.userLabel ?? 'officer'; const userLabel = msg.userLabel ?? 'officer';
const prompt = `${userLabel} in %~ %# `;
const bashPrompt = `${userLabel} \\w \\$ `;
const cols = msg.cols ?? 80; const cols = msg.cols ?? 80;
const rows = msg.rows ?? 24; const rows = msg.rows ?? 24;
const isHost = !!msg.host;
try { let ptyEnv;
await ensureUserFiles(homeDir); if (isHost) {
} catch { ptyEnv = { ...process.env, TERM: 'xterm-256color' };
// ignore } else {
const prompt = `${userLabel} in %~ %# `;
const bashPrompt = `${userLabel} \\w \\$ `;
try {
await ensureUserFiles(homeDir);
} catch {
// ignore
}
ptyEnv = {
...process.env,
HOME: homeDir,
ZDOTDIR: homeDir,
ZSH: `${homeDir}/.oh-my-zsh`,
SHELL: shell.command,
USER: userLabel,
LOGNAME: userLabel,
OFFICER_TERMINAL_USER: userLabel,
PROMPT: prompt,
PS1: bashPrompt,
TERM: 'xterm-256color',
};
} }
let term; let term;
@@ -167,19 +184,7 @@ wss.on('connection', (ws) => {
cols, cols,
rows, rows,
cwd, cwd,
env: { env: ptyEnv,
...process.env,
HOME: homeDir,
ZDOTDIR: homeDir,
ZSH: `${homeDir}/.oh-my-zsh`,
SHELL: shell.command,
USER: userLabel,
LOGNAME: userLabel,
OFFICER_TERMINAL_USER: userLabel,
PROMPT: prompt,
PS1: bashPrompt,
TERM: 'xterm-256color',
},
}); });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start terminal'; const message = err instanceof Error ? err.message : 'Failed to start terminal';
@@ -205,7 +210,8 @@ wss.on('connection', (ws) => {
} }
}); });
term.onExit(() => { term.onExit(({ exitCode, signal }) => {
console.log(`[sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
if (session.ws) { if (session.ws) {
sendJson(session.ws, { type: 'exit' }); sendJson(session.ws, { type: 'exit' });
} }
@@ -228,7 +234,11 @@ wss.on('connection', (ws) => {
if (msg.cols > 0 && msg.rows > 0) { if (msg.cols > 0 && msg.rows > 0) {
session.cols = msg.cols; session.cols = msg.cols;
session.rows = msg.rows; session.rows = msg.rows;
session.term.resize(msg.cols, msg.rows); try {
session.term.resize(msg.cols, msg.rows);
} catch {
// PTY may have already exited
}
} }
break; break;
case 'cwd': case 'cwd':
+14 -5
View File
@@ -236,20 +236,28 @@ const sidecarAlive = async (port: number): Promise<boolean> => {
} }
}; };
const startHostSidecar = async () => { const killSidecarOnPort = (port: number) => {
if (await sidecarAlive(HOST_SIDECAR_PORT)) { try {
console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`); const result = Bun.spawnSync({ cmd: ['fuser', '-k', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' });
return; if (result.exitCode === 0) console.log(`[terminal] killed stale sidecar on port ${port}`);
} catch {
// fuser not available or failed
} }
};
const startHostSidecar = async () => {
if (hostSidecarProcess) { if (hostSidecarProcess) {
hostSidecarProcess.kill(); hostSidecarProcess.kill();
await hostSidecarProcess.exited.catch(() => {}); await hostSidecarProcess.exited.catch(() => {});
hostSidecarProcess = null; hostSidecarProcess = null;
} }
killSidecarOnPort(HOST_SIDECAR_PORT);
await new Promise((resolve) => setTimeout(resolve, 200));
const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url)); const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url));
hostSidecarProcess = Bun.spawn({ hostSidecarProcess = Bun.spawn({
cmd: ['bun', sidecarPath], cmd: ['node', sidecarPath],
env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) }, env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) },
stdout: 'inherit', stdout: 'inherit',
stderr: 'inherit', stderr: 'inherit',
@@ -323,6 +331,7 @@ export const terminalWebsocket = {
sidecar.send( sidecar.send(
JSON.stringify({ JSON.stringify({
type: 'init', type: 'init',
host: true,
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`, sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }, shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
cwd: resolveCwd(process.env.HOME!, ws.data.cwd), cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
+38
View File
@@ -19,12 +19,14 @@ export type TerminalViewProps = {
sessionId?: string; sessionId?: string;
sandboxed?: boolean; sandboxed?: boolean;
cwd?: string; cwd?: string;
command?: string;
fontSize?: number; fontSize?: number;
fontFamily?: string; fontFamily?: string;
theme?: TerminalTheme; theme?: TerminalTheme;
autoFocus?: boolean; autoFocus?: boolean;
onReady?: (term: XTerm) => void; onReady?: (term: XTerm) => void;
onExit?: () => void; onExit?: () => void;
onCommandDone?: (exitCode: number, output: string) => void;
onDisconnect?: () => void; onDisconnect?: () => void;
}; };
@@ -53,12 +55,14 @@ export const TerminalView = ({
sessionId, sessionId,
sandboxed = true, sandboxed = true,
cwd, cwd,
command,
fontSize = 14, fontSize = 14,
fontFamily = 'Menlo, Monaco, "Courier New", monospace', fontFamily = 'Menlo, Monaco, "Courier New", monospace',
theme, theme,
autoFocus = true, autoFocus = true,
onReady, onReady,
onExit, onExit,
onCommandDone,
onDisconnect, onDisconnect,
}: TerminalViewProps) => { }: TerminalViewProps) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -68,11 +72,15 @@ export const TerminalView = ({
const isMounted = useMounted(); const isMounted = useMounted();
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady); const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit); const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect); const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
const commandRef = useRef(command);
onReadyRef.current = onReady; onReadyRef.current = onReady;
onExitRef.current = onExit; onExitRef.current = onExit;
onCommandDoneRef.current = onCommandDone;
onDisconnectRef.current = onDisconnect; onDisconnectRef.current = onDisconnect;
commandRef.current = command;
const background = theme?.background ?? DEFAULT_THEME.background; const background = theme?.background ?? DEFAULT_THEME.background;
const foreground = theme?.foreground ?? DEFAULT_THEME.foreground; const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
@@ -118,6 +126,12 @@ export const TerminalView = ({
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd)); const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd));
wsRef.current = ws; wsRef.current = ws;
let commandSent = false;
let commandDone = false;
let commandOutput = '';
const EXIT_MARKER = '__OFFICER_EXIT_';
// eslint-disable-next-line no-control-regex
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, '');
const handleOpen = () => { const handleOpen = () => {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
@@ -128,6 +142,30 @@ export const TerminalView = ({
const msg = JSON.parse(ev.data as string); const msg = JSON.parse(ev.data as string);
if (msg.type === 'output') { if (msg.type === 'output') {
term.write(msg.data); term.write(msg.data);
if (commandRef.current && !commandSent) {
commandSent = true;
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
const wrapped = onCommandDoneRef.current
? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
: commandRef.current;
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
}
}, 100);
}
if (commandSent && !commandDone && onCommandDoneRef.current) {
commandOutput += msg.data as string;
const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/);
if (markerMatch) {
const exitCode = Number(markerMatch[1]);
const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim();
commandDone = true;
onCommandDoneRef.current(exitCode, output);
}
}
} else if (msg.type === 'exit') { } else if (msg.type === 'exit') {
term.write('\r\n[Process exited]\r\n'); term.write('\r\n[Process exited]\r\n');
onExitRef.current?.(); onExitRef.current?.();