mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
6b9b71ac8a
* feat(scripts): add terminal script automation, recording, and side panel Introduce nct script runtime in the main process with terminal-side recording, execution overlay, Monaco editor, connect-script queues, and scripts side panel integration. Fix recording to wire scriptRecorderRef into xterm, use waitForPrompt for root/user shells, and save recorded scripts through the global editor dialog. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): address Bugbot review findings for recording and side panel Refresh script run state in side panel memo, guard toolbar recording against multi-session conflicts, reset connect scripts on reconnect, resolve focused session via getActiveTerminalSessionId, and avoid wiping host edit form when snippets list updates. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): prevent pending script re-run and improve workspace run feedback Track one-shot pendingScript execution separately from onConnect queue so reconnects do not replay Run-now scripts. Fall back to focused session when running on all tabs outside a workspace, and toast when every tab is still connecting. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): consume connect-script window once and filter serial hosts Mark the onConnect script evaluation as consumed even when the queue is empty so later snippet/host edits do not rerun automation mid-session. Restore serial-host filtering in SelectHostPanel confirm handler. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): grace period before giving up on connect-script queue Only mark onConnect automation as consumed after scripts run or a short post-connect grace window expires, so late-loading snippets still trigger while mid-session Vault edits do not. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): gate connect-script consumption on vault hydration Replace the grace timer with vault-init and unresolved host script binding checks so late-loading snippets still run onConnect automation while mid-session edits stay suppressed. Clear pending script run state on disconnect so reconnect retries workspace pending scripts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): honor run targets and retry failed connect automation Route QuickScriptEditorDialog Run through onRunSnippet for multi-host workspaces. Defer connect/pending script completion markers until each script succeeds and retry the queue after failures. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): stabilize pending runs and global connect queue sync Keep pending script run state across reconnects, run onConnect scripts before one-shot workspace pending scripts, and strip per-host queue entries when an onConnect script targets all hosts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): defer host queue snippet sync and guard connect runs Persist snippet target links only when the host panel is saved, block duplicate connect-script scheduling while a batch is running, and require runnable hosts before showing Run in the snippets editor footer. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): retry connect failures and surface output-trigger errors Use a retry tick in connect-script effect deps, toast automation failures from output triggers, and require runnable hosts before QuickScript Run. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): track id-less pending runs and re-arm output triggers Remember anonymous pendingScript objects after first run and allow onOutput triggers to fire again when new terminal output arrives. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): unblock paused connect runs and pass session meta Resolve waitForScriptRun on pause, run workspace scripts per session with hostname context, defer output-trigger arming until runs start, and add noRunnableHosts i18n for QuickScript Run. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): serialize output triggers and defer empty connect finalize Fire at most one onOutput trigger per scan while a run is active, and only finalize an empty connect queue after vault snippets are loaded. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): guard output launch race and script run waits Block overlapping onOutput launches, timeout stuck waitForScriptRun promises, and disable vault Run when no connectable targets exist. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): match output triggers on new chunks only Scan onOutput patterns against recent terminal output instead of buffer length dedupe, and restore overflow-visible on SelectHostPanel new-host flow. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): await sequential workspace runs and output launch Wait for each tab script to finish in sequential workspace mode, and hold output-trigger launch guard until the session run becomes active. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): wait through pause and hold output trigger until done Keep connect and sequential workspace queues blocked until scripts complete or fail, and retain output-trigger guard until the launched run finishes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): harden output trigger wait helpers Add explicit timeouts to output-trigger wait helpers and stop rescanning the buffer after script completion to avoid sticky-match rerun loops. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): allow empty connect queue and match output in new chunks Treat an empty connectScriptIds array as an explicit cleared queue instead of re-migrating from snippet targets, and only fire onOutput triggers when the latest terminal output contains the pattern. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): require fresh output overlap for onOutput triggers Only fire output-matched scripts when the regex match extends into the latest terminal chunk, preventing reruns from stale buffer text. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): promote manual scripts added to host connect queue Diff connect queue changes from raw connectScriptIds so saving a host updates manual scripts to onConnect when they are newly queued. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): dedupe output triggers by match position Track the buffer end offset of each output trigger match and ignore stale re-scans, with a longer wait for run registration before retry. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): record output trigger matches only after run starts Propagate automation launch failures to output triggers and commit match dedupe only once the session run is registered as active. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): address Codex review on wait cursor and sensitive codegen Track scan offset so waitFor/waitForAny only match output after the previous wait, and emit sendLine after sensitive prompt in recorded scripts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): harden onOutput triggers against PTY echo and local input Filter server output before pattern matching so onOutput fires only after Enter on real stdout, not on keystroke/paste echo, OSC/SGR sequences, or alternate-screen apps. Add dev diagnostics and scope triggers to the active session when no explicit host targets are configured. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(scripts): remove onOutput trigger debug logging Drop temporary diagnostics module and console traces now that PTY echo filtering is verified; keep the production trigger pipeline unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
171 lines
5.2 KiB
TypeScript
171 lines
5.2 KiB
TypeScript
import type { Snippet } from '@/domain/models';
|
|
import { isScriptSnippet, scriptContainsWriteOperations } from '@/domain/snippetScript.ts';
|
|
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter.ts';
|
|
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
|
|
import type { AIPermissionMode } from '@/infrastructure/ai/types.ts';
|
|
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
|
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
|
|
|
type RunsListener = (runs: ScriptRun[]) => void;
|
|
|
|
let runs: ScriptRun[] = [];
|
|
const runsListeners = new Set<RunsListener>();
|
|
|
|
function readPermissionMode(): AIPermissionMode {
|
|
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
|
if (stored === 'observer' || stored === 'confirm' || stored === 'auto') return stored;
|
|
return 'confirm';
|
|
}
|
|
|
|
export function subscribeScriptRuns(listener: RunsListener): () => void {
|
|
runsListeners.add(listener);
|
|
listener(runs);
|
|
return () => runsListeners.delete(listener);
|
|
}
|
|
|
|
export function setScriptRuns(nextRuns: ScriptRun[]) {
|
|
runs = nextRuns;
|
|
runsListeners.forEach((listener) => listener(runs));
|
|
}
|
|
|
|
export function getActiveScriptRunForSession(sessionId: string): ScriptRun | undefined {
|
|
return runs.find((run) =>
|
|
run.sessionId === sessionId && (run.status === 'running' || run.status === 'paused'),
|
|
);
|
|
}
|
|
|
|
export async function runAutomationScript(params: {
|
|
snippet: Snippet;
|
|
sessionId: string;
|
|
sessionIds?: string[];
|
|
mode?: 'sequential' | 'parallel';
|
|
sessionMeta?: {
|
|
connected?: boolean;
|
|
hostname?: string;
|
|
username?: string;
|
|
};
|
|
}): Promise<{ runId: string; runIds: string[] }> {
|
|
const permissionMode = readPermissionMode();
|
|
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
|
|
throw new Error('Observer mode blocks scripts that write to the terminal.');
|
|
}
|
|
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.scriptRun) {
|
|
throw new Error('Script bridge unavailable');
|
|
}
|
|
return bridge.scriptRun({
|
|
scriptId: params.snippet.id,
|
|
scriptLabel: params.snippet.label,
|
|
content: params.snippet.command,
|
|
sessionId: params.sessionId,
|
|
sessionIds: params.sessionIds,
|
|
mode: params.mode,
|
|
permissionMode,
|
|
sessionMeta: params.sessionMeta,
|
|
});
|
|
}
|
|
|
|
const TERMINAL_SCRIPT_STATUSES = new Set<ScriptRun['status']>(['completed', 'failed']);
|
|
|
|
export function waitForScriptRun(
|
|
runId: string,
|
|
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
): Promise<ScriptRun> {
|
|
const timeoutMs = options.timeoutMs ?? 3_600_000;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const finish = (handler: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeoutId !== undefined) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
unsubscribe();
|
|
options.signal?.removeEventListener('abort', onAbort);
|
|
handler();
|
|
};
|
|
|
|
const onAbort = () => {
|
|
finish(() => reject(new Error('Aborted')));
|
|
};
|
|
|
|
const settleRun = (run: ScriptRun | undefined) => {
|
|
if (!run || !TERMINAL_SCRIPT_STATUSES.has(run.status)) return;
|
|
if (run.status === 'completed') {
|
|
finish(() => resolve(run));
|
|
return;
|
|
}
|
|
finish(() => reject(new Error(run.error || 'Script failed')));
|
|
};
|
|
|
|
const unsubscribe = subscribeScriptRuns((currentRuns) => {
|
|
settleRun(currentRuns.find((entry) => entry.runId === runId));
|
|
});
|
|
|
|
timeoutId = setTimeout(() => {
|
|
finish(() => reject(new Error('Script run timed out')));
|
|
}, timeoutMs);
|
|
|
|
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
}
|
|
|
|
export async function runConnectScriptsSequential(params: {
|
|
scripts: Snippet[];
|
|
sessionId: string;
|
|
signal?: AbortSignal;
|
|
onScriptStart?: (snippet: Snippet) => void;
|
|
onScriptComplete?: (snippet: Snippet) => void;
|
|
sessionMeta?: {
|
|
connected?: boolean;
|
|
hostname?: string;
|
|
username?: string;
|
|
};
|
|
}): Promise<void> {
|
|
for (const snippet of params.scripts) {
|
|
if (params.signal?.aborted) {
|
|
throw new Error('Aborted');
|
|
}
|
|
params.onScriptStart?.(snippet);
|
|
const { runId } = await runAutomationScript({
|
|
snippet,
|
|
sessionId: params.sessionId,
|
|
sessionMeta: params.sessionMeta,
|
|
});
|
|
await waitForScriptRun(runId, { signal: params.signal });
|
|
params.onScriptComplete?.(snippet);
|
|
}
|
|
}
|
|
|
|
export async function runSnippetOrScript(params: {
|
|
snippet: Snippet;
|
|
sessionId: string;
|
|
runSnippetText: (command: string, noAutoRun?: boolean) => void;
|
|
command: string;
|
|
}) {
|
|
if (isScriptSnippet(params.snippet)) {
|
|
await runAutomationScript({
|
|
snippet: params.snippet,
|
|
sessionId: params.sessionId,
|
|
});
|
|
return;
|
|
}
|
|
params.runSnippetText(params.command, params.snippet.noAutoRun);
|
|
}
|
|
|
|
export async function stopScriptRun(runId: string) {
|
|
await netcattyBridge.get()?.scriptStop?.(runId);
|
|
}
|
|
|
|
export async function pauseScriptRun(runId: string) {
|
|
await netcattyBridge.get()?.scriptPause?.(runId);
|
|
}
|
|
|
|
export async function resumeScriptRun(runId: string) {
|
|
await netcattyBridge.get()?.scriptResume?.(runId);
|
|
}
|