Compare commits

..
67 changed files with 109 additions and 3842 deletions

No files matched your search

-17
View File
@@ -19,8 +19,6 @@ import {
} from '../state/connectionLogsStore';
import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { terminalReconnectRegistry } from '../state/terminalReconnectRegistry';
import { requestAgentSessionReconnect } from '../state/agentSessionReconnect';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
@@ -1529,19 +1527,6 @@ export function AppSideEffects() {
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const reconnectSessionForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
return requestAgentSessionReconnect(sessionId, session?.status, terminalReconnectRegistry);
}, [sessions]);
const inspectSessionRecoveryForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
if (!session) return { ok: false, error: `Session "${sessionId}" was not found.` };
const live = terminalReconnectRegistry.inspect(sessionId);
return { ok: true, sessionId, status: session.status, waitingForInput: false,
reconnecting: false, reconnectAllowed: false, ...live };
}, [sessions]);
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1586,8 +1571,6 @@ export function AppSideEffects() {
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
reconnectSession: reconnectSessionForVaultAgent,
inspectSessionRecovery: inspectSessionRecoveryForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestAgentSessionReconnect } from './agentSessionReconnect';
test('reconnect preserves identity and only dispatches disconnected eligible sessions', () => {
let calls = 0;
const registry = { isActive: () => false, requestIfAvailable: () => { calls++; return true; } };
assert.equal(requestAgentSessionReconnect('s', undefined, registry).ok, false);
assert.equal(requestAgentSessionReconnect('s', 'connected', registry).status, 'connected');
assert.equal(requestAgentSessionReconnect('s', 'connecting', registry).started, false);
const result = requestAgentSessionReconnect('s', 'disconnected', registry);
assert.deepEqual(result, { ok: true, started: true, sessionId: 's', status: 'reconnecting', shellReady: false });
assert.equal(calls, 1);
});
test('auto/manual reconnect and unavailable terminal cannot queue another recovery', () => {
const registry = { isActive: () => true, requestIfAvailable: () => { throw Error('must not dispatch'); } };
assert.equal(requestAgentSessionReconnect('s', 'disconnected', registry).started, false);
assert.equal(requestAgentSessionReconnect('s', 'disconnected', { isActive: () => false, requestIfAvailable: () => false }).code, 'RECONNECT_UNAVAILABLE');
});
@@ -1,21 +0,0 @@
type ReconnectRegistry = {
isActive: (sessionId: string) => boolean;
requestIfAvailable: (sessionId: string) => boolean;
};
/** Synchronous dispatch only: no queued recovery after scope/terminal disposal. */
export function requestAgentSessionReconnect(
sessionId: string,
status: string | undefined,
registry: ReconnectRegistry,
): Record<string, unknown> {
if (!status) return { ok: false, started: false, error: `Session "${sessionId}" was not found.` };
if (status === 'connected') return { ok: true, started: false, sessionId, status: 'connected', shellReady: false };
if (status === 'connecting' || registry.isActive(sessionId)) {
return { ok: true, started: false, sessionId, status: 'reconnecting' };
}
if (status !== 'disconnected' || !registry.requestIfAvailable(sessionId)) {
return { ok: false, started: false, sessionId, code: 'RECONNECT_UNAVAILABLE', error: 'Reconnect is unavailable: terminal may be unmounted, waiting for authentication, or recovering. Inspect session state and the Netcatty UI.' };
}
return { ok: true, started: true, sessionId, status: 'reconnecting', shellReady: false };
}
@@ -3,37 +3,6 @@ import test from 'node:test';
import { createTerminalReconnectRegistry } from './terminalReconnectRegistry';
test('recovery snapshots use the current handler and disappear on unmount', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.inspect('s'), null);
const unregister = registry.register('s', () => {}, () => false,
() => ({ status: 'disconnected', waitingForInput: true, reconnecting: false }));
assert.equal(registry.inspect('s')?.waitingForInput, true);
assert.equal(registry.inspect('s')?.reconnectAllowed, false);
unregister();
assert.equal(registry.inspect('s'), null);
});
test('agent reconnect never queues an unmounted terminal', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.requestIfAvailable('missing'), false);
let calls = 0;
registry.register('missing', () => { calls++; });
assert.equal(calls, 0);
});
test('agent reconnect respects live eligibility and active reconnects', () => {
const registry = createTerminalReconnectRegistry();
let eligible = false;
let calls = 0;
registry.register('s', () => { calls++; registry.setActive('s', true); }, () => eligible);
assert.equal(registry.requestIfAvailable('s'), false);
eligible = true;
assert.equal(registry.requestIfAvailable('s'), true);
assert.equal(registry.requestIfAvailable('s'), false);
assert.equal(calls, 1);
});
test('a registered terminal session can be reconnected on request', () => {
const registry = createTerminalReconnectRegistry();
const requestedSessionIds: string[] = [];
+1 -27
View File
@@ -1,10 +1,7 @@
type TerminalReconnectHandler = () => void;
export type TerminalRecoverySnapshot = { status: string; waitingForInput: boolean; reconnecting: boolean; stopped?: boolean };
export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>();
const eligibility = new Map<string, () => boolean>();
const recoveryReaders = new Map<string, () => TerminalRecoverySnapshot>();
const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>();
@@ -31,40 +28,19 @@ export const createTerminalReconnectRegistry = () => {
return () => listeners.delete(listener);
};
const register = (sessionId: string, handler: TerminalReconnectHandler,
canReconnect: () => boolean = () => false, readRecovery?: () => TerminalRecoverySnapshot): (() => void) => {
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
handlers.set(sessionId, handler);
eligibility.set(sessionId, canReconnect);
if (readRecovery) recoveryReaders.set(sessionId, readRecovery);
else recoveryReaders.delete(sessionId);
if (pendingRequests.delete(sessionId)) {
handler();
}
return () => {
if (handlers.get(sessionId) === handler) {
handlers.delete(sessionId);
eligibility.delete(sessionId);
recoveryReaders.delete(sessionId);
setActive(sessionId, false);
}
};
};
const inspect = (sessionId: string) => {
const state = recoveryReaders.get(sessionId)?.();
if (!state) return null;
return { ...state, reconnecting: state.reconnecting || isActive(sessionId),
reconnectAllowed: !isActive(sessionId) && Boolean(eligibility.get(sessionId)?.()) };
};
// Agent requests must never survive unmount or bypass authentication/auto-retry.
const requestIfAvailable = (sessionId: string): boolean => {
const handler = handlers.get(sessionId);
if (!handler || isActive(sessionId) || !eligibility.get(sessionId)?.()) return false;
handler();
return true;
};
const request = (sessionId: string): boolean => {
if (isActive(sessionId)) return false;
const handler = handlers.get(sessionId);
@@ -90,9 +66,7 @@ export const createTerminalReconnectRegistry = () => {
return {
register,
inspect,
request,
requestIfAvailable,
requestEnterReconnect,
setEnterReconnectEnabled,
setActive,
@@ -1,55 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { useAISessionsStore } from './aiSessionsStore';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots';
for (const enabled of [true, false]) {
test(`appending to long history preserves the synced prefix (chat sync ${enabled})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const source: AISession = {
id: 'long-chat', title: 'Long chat', agentId: 'codex', externalSessionId: 'native-thread',
scope: { type: 'global' }, createdAt: 1, updatedAt: 501,
messages: [{ id: 'system', role: 'system', content: 'Instruction', timestamp: 0 },
...Array.from({ length: 500 }, (_, index) => ({ id: `message-${index}`, role: 'user' as const, content: `Prompt ${index}`, timestamp: index + 1 }))],
};
setLatestAISessionsSnapshot([source]);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
dom.window.localStorage.setItem(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
let ai: ReturnType<typeof useAIState>;
let sessions: readonly AISession[] = [];
function Harness() {
ai = useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
await act(async () => {
ai!.addMessageToSession(source.id, { id: 'new-reply', role: 'assistant', content: 'Continued', timestamp: 502 });
});
const updated = sessions.find(value => value.id === source.id)!;
assert.equal(updated.messages.length, enabled ? 502 : 500);
assert.equal(updated.messages[0].id, 'system');
assert.equal(updated.messages[1].id, enabled ? 'message-0' : 'message-2');
assert.equal(updated.messages.at(-1)?.id, 'new-reply');
assert.equal(updated.externalSessionId, 'native-thread');
} finally {
await act(async () => { root.unmount(); });
container.remove();
dom.window.close();
}
});
}
+3 -4
View File
@@ -1053,17 +1053,16 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
// Local-only retention. Synced history must match the full-history storage policy.
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
let msgs = [...s.messages, message];
// Dropping synced prefixes makes the next cloud merge add them back.
if (!syncEnabled && msgs.length > MAX_MESSAGES_PER_SESSION) {
// Trim oldest messages if exceeding limit (keep system messages)
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
const systemMsgs = msgs.filter(m => m.role === 'system');
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
-4
View File
@@ -55,8 +55,6 @@ export interface UseVaultAgentBridgeInput {
openHost?: VaultAgentApiDeps['openHost'];
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
closeSession?: VaultAgentApiDeps['closeSession'];
reconnectSession?: VaultAgentApiDeps['reconnectSession'];
inspectSessionRecovery?: VaultAgentApiDeps['inspectSessionRecovery'];
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
}
@@ -275,8 +273,6 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
reconnectSession: current.reconnectSession,
inspectSessionRecovery: current.inspectSessionRecovery,
closeSession: current.closeSession
? (sessionId) => current.closeSession!(sessionId)
: undefined,
+2 -27
View File
@@ -155,7 +155,6 @@ import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalO
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import {
createTerminalSessionStarters,
isAuthFailureMessage,
type PendingAuth,
type TerminalSessionDataMeta,
} from "./terminal/runtime/createTerminalSessionStarters";
@@ -168,7 +167,7 @@ import {
} from "./terminal/runtime/createXTermRuntime";
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { isTerminalReadyForCommandInjection, registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import {
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
@@ -207,7 +206,7 @@ import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction, waitForOsc7SessionOutput } from "./terminal/osc7Setup";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
import {
getRemoteClipboardImageUploadErrorMessageKey,
type RemoteClipboardImageUploadResult,
@@ -603,7 +602,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
const agentRecoveryStoppedRef = useRef(false);
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
// connection: reusing a live/idle pooled transport multiplexes onto the old
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
@@ -1392,9 +1390,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setupOsc7Tracking: terminalBackend.setupOsc7Tracking,
writeToSession: terminalBackend.writeToSession,
writeLocalTerminalData,
isTerminalReadyForCommandInjection: () => isTerminalReadyForCommandInjection(sessionId),
waitForSessionOutput: (id, predicate, timeoutMs) =>
waitForOsc7SessionOutput(terminalBackend.onSessionData, id, predicate, timeoutMs),
}).then((result) => {
handleOsc7SetupOpenChange(false);
if (result.success) {
@@ -1418,7 +1413,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalCwdTracker,
terminalBackend.setupOsc7Tracking,
terminalBackend.writeToSession,
terminalBackend.onSessionData,
writeLocalTerminalData,
]);
@@ -3786,7 +3780,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
]);
const handleCancelConnect = () => {
agentRecoveryStoppedRef.current = true;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
@@ -3820,7 +3813,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleDisconnect = () => {
if (attachExistingSession) return;
if (statusRef.current === "disconnected") return;
agentRecoveryStoppedRef.current = true;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
@@ -4153,31 +4145,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
startReconnectRef.current = startReconnect;
const handleRetry = () => {
agentRecoveryStoppedRef.current = false;
startReconnect("manual");
};
manualReconnectRequestRef.current = handleRetry;
const canAgentReconnectRef = useRef<() => boolean>(() => false);
canAgentReconnectRef.current = () => statusRef.current === 'disconnected'
&& !agentRecoveryStoppedRef.current && !isAuthFailureMessage(error || '')
&& !auth.needsAuth && !needsHostKeyVerification && !isConnectionAwaitingUserInput
&& !autoReconnectLoopActiveRef.current && !reconnectWakeInFlightRef.current
&& reconnectPreparationTokenRef.current === null;
const readAgentRecoveryRef = useRef(() => ({ status: String(statusRef.current), waitingForInput: false, reconnecting: false, stopped: false }));
readAgentRecoveryRef.current = () => ({
status: statusRef.current,
waitingForInput: Boolean(auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput || isAuthFailureMessage(error || '')),
stopped: agentRecoveryStoppedRef.current,
reconnecting: autoReconnectLoopActiveRef.current || reconnectWakeInFlightRef.current
|| reconnectPreparationTokenRef.current !== null,
});
useEffect(() => {
if (attachExistingSession) return undefined;
return terminalReconnectRegistry.register(
sessionId,
() => manualReconnectRequestRef.current(),
() => canAgentReconnectRef.current(),
() => readAgentRecoveryRef.current(),
);
}, [attachExistingSession, sessionId]);
-396
View File
@@ -1,5 +1,4 @@
import test from "node:test";
import * as pty from "node-pty";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import {
@@ -18,22 +17,10 @@ import {
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { isIdleShellReadyForCommandInjection } from "../../domain/terminalCommandInjectionReady";
import {
isTerminalReadyForCommandInjection,
registerTerminalCommandInjectionReadyReader,
} from "./runtime/terminalCommandInjectionReadyRegistry";
import {
OSC7_MARKER,
OSC7_SETUP_OTHER_USER_MARKER,
OSC7_SETUP_STAGED_MARKER,
OSC7_PTY_SHELL_MARKER,
OSC7_PTY_ACK_MARKER,
OSC7_PTY_DONE_MARKER,
OSC7_PTY_FAILED_MARKER,
buildOsc7PtyShellProbeCommand,
buildOsc7PtySetupCommands,
buildOsc7ReloadCommand,
buildOsc7SetupCommand,
buildOsc7SetupExecCommand,
@@ -43,8 +30,6 @@ import {
parseOsc7SetupStagedPath,
runOsc7SetupAction,
shouldOfferOsc7SetupAction,
waitForOsc7SessionOutput,
type RunOsc7SetupActionOptions,
} from "./osc7Setup";
const runSetup = (env: NodeJS.ProcessEnv) => {
@@ -208,229 +193,6 @@ test("runOsc7SetupAction stages the script and types a short runner for user-swi
assert.deepEqual(localData, []);
});
// Model separate exec/PTY identities (e.g. Koko) with live output observers.
// Echo input first, then deliver acknowledgements in separate chunks.
const createExit4PtyHarness = (shell: string | null = "bash", completion: "done" | "failed" | null = "done") => {
const writes: string[] = [];
const setupCommands: string[] = [];
const timeouts: number[] = [];
let onData: ((chunk: string) => void) | undefined;
let disposed = 0;
let ready = true;
const options: RunOsc7SetupActionOptions = {
status: "connected",
sessionId: "pty-session",
setupCommand: "exec-only-setup",
setupOsc7Tracking: async (_id, command) => {
setupCommands.push(command);
return { success: false, code: 4, error: "exec identity mismatch" };
},
isTerminalReadyForCommandInjection: () => ready,
waitForSessionOutput: (id, predicate, timeout) => {
timeouts.push(timeout);
return waitForOsc7SessionOutput((observedId, callback) => {
assert.equal(observedId, options.sessionId);
onData = callback;
return () => { onData = undefined; disposed += 1; };
}, id, predicate, 10);
},
writeToSession: (id, command, writeOptions) => {
assert.equal(id, options.sessionId);
assert.equal(writeOptions?.automated, true);
assert.deepEqual(writeOptions?.logRewrite, { sentCommand: command, displayCommand: "" });
assert.ok(onData, "subscribe before writing so fast replies cannot be lost");
writes.push(command);
onData(`prompt> ${command.replace(/\r$/, "\r\n")}`);
assert.ok(onData, "input echo must not satisfy the output predicate");
const final = command.includes("base64 -d");
const marker = writes.length === 1 ? OSC7_PTY_SHELL_MARKER
: !final ? OSC7_PTY_ACK_MARKER
: completion === "failed" ? OSC7_PTY_FAILED_MARKER : OSC7_PTY_DONE_MARKER;
const value = writes.length === 1 ? shell : !final ? String(writes.length - 2) : completion && shell;
if (value) {
onData(`\r\n${marker.slice(0, 12)}`);
onData?.(`${marker.slice(12)}${value}\r`);
assert.ok(onData, "wait for the complete line, not a partial shell name");
onData?.("\n");
}
},
};
return {
options, writes, setupCommands, timeouts,
setReady: (value: boolean) => { ready = value; },
disposed: () => disposed,
};
};
test("exit 4 probes the foreground shell and waits for chunked installation DONE, without exec staging", async () => {
for (const shell of ["bash", "zsh"]) {
const h = createExit4PtyHarness(shell);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, true);
assert.equal(result.sentToTerminal, true);
assert.equal(result.error, undefined);
assert.deepEqual(h.setupCommands, ["exec-only-setup"]);
assert.deepEqual(h.writes, [buildOsc7PtyShellProbeCommand(), ...buildOsc7PtySetupCommands(shell as "bash" | "zsh")]);
assert.match(h.writes[0], /ZSH_VERSION.*BASH_VERSION/);
assert.match(h.writes[1], /mktemp/);
assert.match(h.writes.at(-1)!, new RegExp(`NETCATTY_OSC7_FORCE_SHELL=${shell}`));
for (const command of h.writes) assert.ok(Buffer.byteLength(command) < 3000);
assert.deepEqual(h.timeouts, h.writes.map((_, i) => i === h.writes.length - 1 ? 20000 : 5000));
assert.equal(h.disposed(), h.writes.length);
}
});
test("exit-4 stderr messages also select the dedicated PTY path", async () => {
for (const stderr of [
"Netcatty OSC 7 setup: could not identify the active terminal shell",
"Netcatty OSC 7 setup: active terminal shell did not match the current tab",
]) {
const h = createExit4PtyHarness();
h.options.setupOsc7Tracking = async () => ({ success: false, stderr: `${stderr}\n` });
assert.equal((await runOsc7SetupAction(h.options)).success, true);
assert.equal(h.writes.length, 1 + buildOsc7PtySetupCommands("bash").length);
}
});
test("exit 4 fails closed with an unsafe prompt or missing safety callbacks", async () => {
for (const missing of ["unsafe", "isTerminalReadyForCommandInjection", "waitForSessionOutput"] as const) {
const h = createExit4PtyHarness();
if (missing === "unsafe") h.setReady(false);
else delete h.options[missing];
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.match(result.error || "", /idle bash\/zsh prompt.*empty input/);
assert.deepEqual(h.writes, []);
assert.deepEqual(h.timeouts, []);
}
});
test("exit 4 blocks recalled input, editors, alt-screen and sensitive prompts with an empty command buffer", async () => {
for (const unsafe of [
{ userInputLength: 12 }, // ESC-prefixed history recall need not enter commandBufferRef.
{ isAtPrompt: false },
{ alternateScreenActive: true },
{ sensitiveInputActive: true },
{ hasLiveTerminal: false },
]) {
const h = createExit4PtyHarness();
const unregister = registerTerminalCommandInjectionReadyReader(h.options.sessionId, () =>
isIdleShellReadyForCommandInjection({
hasLiveTerminal: true, isAtPrompt: true, userInputLength: 0,
pendingTypedInputLength: 0, alternateScreenActive: false,
sensitiveInputActive: false, ...unsafe,
}));
h.options.isTerminalReadyForCommandInjection = () => isTerminalReadyForCommandInjection(h.options.sessionId);
try {
assert.equal((await runOsc7SetupAction(h.options)).success, false);
assert.deepEqual(h.writes, []);
} finally {
unregister();
}
assert.equal(isTerminalReadyForCommandInjection(h.options.sessionId), false);
}
});
test("Terminal wires the live command-injection readiness registry into OSC7 setup", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
assert.match(source, /runOsc7SetupAction\(\{[^]*?isTerminalReadyForCommandInjection: \(\) => isTerminalReadyForCommandInjection\(sessionId\)/);
});
test("exit 4 refuses installation after probe timeout, unknown shell, or unsupported fish", async () => {
for (const shell of [null, "unknown", "fish"]) {
const h = createExit4PtyHarness(shell);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.match(result.error || "", /idle bash\/zsh prompt.*OSC 7 manually/);
assert.equal(h.writes.length, 1);
assert.equal(h.disposed(), 1);
}
});
test("exit 4 rechecks prompt readiness after the probe and between payload chunks", async () => {
for (const stopAfter of [1, 3]) {
const h = createExit4PtyHarness();
const write = h.options.writeToSession;
h.options.writeToSession = (...args) => {
write(...args);
if (h.writes.length === stopAfter) h.setReady(false);
};
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.equal(h.writes.length, stopAfter);
}
});
test("exit 4 does not claim success when installation times out or reports failure", async () => {
for (const completion of [null, "failed"] as const) {
const h = createExit4PtyHarness("bash", completion);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.notEqual(result.sentToTerminal, true);
assert.match(result.error || "", /failed or did not finish/);
assert.equal(h.writes.length, 1 + buildOsc7PtySetupCommands("bash").length);
assert.equal(h.disposed(), h.writes.length);
}
});
test("exit 4 stops on a missing, stale or failed chunk acknowledgement", async () => {
for (const reply of [null, `${OSC7_PTY_ACK_MARKER}0`, `${OSC7_PTY_FAILED_MARKER}bash`]) {
const h = createExit4PtyHarness();
let count = 0;
let observer: ((chunk: string) => void) | undefined;
h.options.waitForSessionOutput = (id, predicate) => waitForOsc7SessionOutput((_id, callback) => {
observer = callback;
return () => { observer = undefined; };
}, id, predicate, 10);
h.options.writeToSession = (id, command, options) => {
count += 1;
if (count === 3) {
observer?.(`prompt> ${command}\r\n`);
if (reply) observer?.(`\r\n${reply}\r\n`);
} else {
observer?.(`prompt> ${command}\r\n`);
observer?.(`\r\n${count === 1 ? OSC7_PTY_SHELL_MARKER + "bash" : OSC7_PTY_ACK_MARKER + "0"}\r\n`);
}
assert.equal(id, h.options.sessionId);
assert.equal(options?.automated, true);
};
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.equal(count, 3, "no later chunk or final installer after a failed ack");
assert.match(result.error || "", /failed or did not finish/);
}
});
test("unrelated exec failures never select the exit-4 PTY transport", async () => {
for (const code of [1, 2, 3, 5]) {
const h = createExit4PtyHarness();
h.options.setupOsc7Tracking = async () => ({ success: false, code, stderr: "unrelated failure" });
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.deepEqual(h.writes, []);
assert.deepEqual(h.timeouts, []);
}
});
test("waitForOsc7SessionOutput disposes even if subscription immediately delivers output", async () => {
let disposed = false;
const result = await waitForOsc7SessionOutput((_id, callback) => {
callback("done");
return () => { disposed = true; };
}, "session", (output) => output === "done", 100);
assert.equal(result, "done");
assert.equal(disposed, true);
});
test("exit 4 sends no probe when the output subscription is unavailable", async () => {
const h = createExit4PtyHarness();
h.options.waitForSessionOutput = (id, predicate, timeout) => waitForOsc7SessionOutput(() => {
throw new Error("Session output unavailable");
}, id, predicate, timeout);
await assert.rejects(runOsc7SetupAction(h.options), /Session output unavailable/);
assert.deepEqual(h.writes, []);
});
test("runOsc7SetupAction fails when the setup script cannot be staged", async () => {
const writes: string[] = [];
let calls = 0;
@@ -1014,164 +776,6 @@ const withStagedSetupScript = (fn: (scriptPath: string) => void) => {
}
};
test("PTY builders chunk the exact shared script into lines below the canonical limit", () => {
for (const shell of ["bash", "zsh"] as const) {
const commands = buildOsc7PtySetupCommands(shell);
assert.ok(commands.length > 3);
for (const command of commands) {
assert.ok(command.startsWith(" "));
assert.ok(command.endsWith("\r"));
assert.doesNotMatch(command.slice(0, -1), /[\r\n]/);
assert.ok(Buffer.byteLength(command) < 3000);
}
assert.match(commands[0], /mktemp/);
assert.match(commands.at(-1)!, new RegExp(`NETCATTY_OSC7_FORCE_SHELL=${shell}`));
assert.doesNotMatch(commands.join(""), /NETCATTY_OSC7_EXPECTED_CWD/);
const payload = commands.flatMap((command) => {
const match = command.match(/printf '%s' '([A-Za-z0-9+/=]+)'/);
return match ? [match[1]] : [];
}).join("");
const script = Buffer.from(payload, "base64").toString("utf8");
assert.ok(buildOsc7SetupExecCommand().includes(quoteShellArg(script)), "reuse exact POSIX setup script");
assert.match(script, /netcatty-osc7-version: 2/);
}
});
test("exit-4 transport installs through a real canonical Bash PTY without exceeding its line limit", { timeout: 30000 }, async () => {
const home = mkdtempSync(join(tmpdir(), "netcatty-osc7-canonical-"));
const prompt = "__NETCATTY_TEST_PROMPT__> ";
const terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc", "--noediting", "-i"], {
name: "xterm", cols: 120, rows: 24,
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", PS1: prompt, PS2: "CONTINUATION> ", PROMPT_COMMAND: "", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
});
let screen = "";
let transcript = "";
const capture = terminal.onData((chunk) => { screen += chunk; transcript += chunk; });
const wait = (predicate: (output: string) => boolean, timeout = 5000) =>
waitForOsc7SessionOutput((_id, callback) => {
const listener = terminal.onData(callback);
return () => listener.dispose();
}, "real-pty", predicate, timeout);
try {
assert.ok(await wait((output) => output.endsWith(prompt)), "initial Bash prompt");
// --noediting keeps Bash on the kernel's canonical input path, unlike Readline.
const canonical = wait((output) => output.endsWith(prompt));
screen = "";
terminal.write("stty icanon; stty -a\r");
assert.match(await canonical || "", /(?:^|[;\s])icanon(?:[;\s]|$)/);
const writes: string[] = [];
const result = await runOsc7SetupAction({
status: "connected", sessionId: "real-pty", setupCommand: "exec-only-setup",
setupOsc7Tracking: async () => ({ success: false, code: 4 }),
isTerminalReadyForCommandInjection: () => screen.endsWith(prompt),
waitForSessionOutput: (_id, predicate, timeout) => wait(predicate, timeout),
writeToSession: (_id, command) => {
assert.ok(Buffer.byteLength(command) < 3000, "each typed line fits canonical input");
writes.push(command);
screen = "";
// Preserve the path for a shell-side cleanup assertion after the final command.
terminal.write(command.includes("base64 -d")
? ` __netcatty_test_file="$__netcatty_osc7_pty_file"; ${command}` : command);
},
});
assert.equal(result.success, true, `${result.error}\n${transcript.slice(-4000)}`);
assert.ok(writes.length > 3);
assert.ok(writes.reduce((total, command) => total + command.length, 0) > 4096);
assert.equal(markerCount(readFileSync(join(home, ".bashrc"), "utf8")), 2);
assert.ok(transcript.includes("\u001b]7;file://"));
assert.match(transcript, new RegExp(`\r?\n${OSC7_PTY_DONE_MARKER}bash\r?\n`));
assert.doesNotMatch(transcript, /CONTINUATION> /);
// A final shell assertion covers cleanup without exec sharing any staging file.
if (!screen.endsWith(prompt)) assert.ok(await wait((output) => output.endsWith(prompt)));
const cleaned = wait((output) => output.includes("\r\nCLEANED\r\n"));
terminal.write("test -n \"$__netcatty_test_file\" && test ! -e \"$__netcatty_test_file\" && test -z \"${__netcatty_osc7_pty_file-}\" && printf '\\n%s\\n' CLEANED\r");
assert.ok(await cleaned, "transport removes the PTY-local file and unsets its path");
} finally {
capture.dispose();
terminal.kill();
rmSync(home, { recursive: true, force: true });
}
});
test("PTY probe recognizes interactive bash and rejects a noninteractive shell", () => {
for (const interactive of [true, false]) {
const args = ["--noprofile", "--norc", ...(interactive ? ["-i"] : []), "-c",
buildOsc7PtyShellProbeCommand().replace(/\r$/, "\n")];
const output = execFileSync("/bin/bash", args, { stdio: "pipe" }).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_SHELL_MARKER}${interactive ? "bash" : "unknown"}\n`));
}
});
test("chunked PTY installer runs locally in bash, preserves hooks and stays idempotent with DONE ack", () => {
withTempHome("netcatty-osc7-inline-bash-", (home) => {
const rcPath = join(home, ".bashrc");
writeFileSync(rcPath, 'PROMPT_COMMAND="printf existing-title"\n');
const command = buildOsc7PtySetupCommands("bash").join("").replace(/\r/g, "\n");
for (let run = 0; run < 2; run += 1) {
const output = execFileSync("/bin/bash", ["--noprofile", "--norc", "-i", "-c", command], {
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_DONE_MARKER}bash\n`));
assert.doesNotMatch(output, /__NETCATTY_OSC7_PTY_FAILED__/);
assert.ok(output.includes("\u001b]7;file://"));
const rc = readFileSync(rcPath, "utf8");
assert.equal(markerCount(rc), 2);
assert.match(rc, /netcatty-osc7-version: 2/);
assert.match(rc, /printf existing-title/);
}
});
});
test("chunked PTY installer emits failure instead of DONE when install or reload fails", () => {
withTempHome("netcatty-osc7-inline-fail-", (home) => {
const blockedHome = join(home, "not-a-directory");
writeFileSync(blockedHome, "file");
writeFileSync(join(home, ".bashrc"), "return 1\n");
for (const targetHome of [blockedHome, home]) {
const output = execFileSync("/bin/bash", ["--noprofile", "--norc", "-c",
buildOsc7PtySetupCommands("bash").join("").replace(/\r/g, "\n")], {
env: { ...process.env, HOME: targetHome, ZDOTDIR: "", XDG_CONFIG_HOME: "" },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_FAILED_MARKER}bash\n`));
assert.doesNotMatch(output, /__NETCATTY_OSC7_PTY_DONE__/);
}
});
});
test("PTY probe and chunked installer remove their bash history entries", () => {
withTempHome("netcatty-osc7-inline-history-", (home) => {
const output = runInteractiveHistoryProbe({
shellPath: "/bin/bash",
shellArgs: ["--noprofile", "--norc", "-i"],
dumpHistoryCommand: `history > ${quoteShellArg(join(home, "history"))}`,
dumpPath: join(home, "history"),
env: { HOME: home, HISTFILE: join(home, ".bash_history"), SHELL: "/bin/bash" },
input: `echo keepme\n${buildOsc7PtyShellProbeCommand()}${buildOsc7PtySetupCommands("bash").join("")}`,
});
assert.match(output, /echo keepme/);
assert.doesNotMatch(output, /NETCATTY_OSC7|base64|history -d/);
});
});
test("PTY probe and chunked installer support zsh with shell-local ZDOTDIR", (t) => {
const shellPath = existingShells(["/bin/zsh", "/usr/bin/zsh"])[0];
if (!shellPath) return t.skip("zsh is not installed on this runner");
withTempHome("netcatty-osc7-inline-zsh-", (home) => {
const zdotdir = join(home, "custom-zsh");
const command = `ZDOTDIR=${quoteShellArg(zdotdir)}; ${buildOsc7PtyShellProbeCommand()}${buildOsc7PtySetupCommands("zsh").join("")}`;
const output = execFileSync(shellPath, ["-f", "-i", "-c", command.replace(/\r/g, "\n")], {
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", ZDOTDIR: undefined },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_SHELL_MARKER}zsh\n`));
assert.match(output, new RegExp(`\n${OSC7_PTY_DONE_MARKER}zsh\n`));
assert.equal(markerCount(readFileSync(join(zdotdir, ".zshrc"), "utf8")), 2);
assert.equal(existsSync(join(home, ".zshrc")), false);
});
});
test("buildOsc7TypedSetupCommand stays a single line for reliable history cleanup", async () => {
const sha256 = await getOsc7StagedScriptSha256();
for (const shell of ["bash", "zsh", "fish"] as const) {
+1 -159
View File
@@ -36,7 +36,7 @@ export type Osc7SetupRunResult = {
code?: number | null;
error?: string;
reloadCommand?: string;
/** Setup was sent through the foreground terminal. */
/** Setup was retyped into the terminal for a user-switched (su/sudo) shell. */
sentToTerminal?: boolean;
};
@@ -57,14 +57,6 @@ export type RunOsc7SetupActionOptions = {
},
) => void;
writeLocalTerminalData?: (data: string) => void;
/** Live prompt safety gate (drafts, sensitive prompts and alt-screen block). */
isTerminalReadyForCommandInjection?: () => boolean;
/** Subscribe synchronously (throw if unavailable); resolve null on timeout. */
waitForSessionOutput?: (
sessionId: string,
predicate: (output: string) => boolean,
timeoutMs: number,
) => Promise<string | null>;
};
export const shouldOfferOsc7SetupAction = ({
@@ -683,89 +675,6 @@ export const buildOsc7TypedSetupCommand = (
return ` ${run}; source (test -n "${DOLLAR}XDG_CONFIG_HOME"; and echo "${DOLLAR}XDG_CONFIG_HOME"; or echo "${DOLLAR}HOME/.config")/fish/config.fish >/dev/null 2>&1; __netcatty_osc7_cwd 2>/dev/null; true\r`;
};
export const OSC7_PTY_SHELL_MARKER = "__NETCATTY_OSC7_PTY_SHELL__=";
export const OSC7_PTY_ACK_MARKER = "__NETCATTY_OSC7_PTY_ACK__=";
export const OSC7_PTY_DONE_MARKER = "__NETCATTY_OSC7_PTY_DONE__=";
export const OSC7_PTY_FAILED_MARKER = "__NETCATTY_OSC7_PTY_FAILED__=";
type Osc7PtyShell = "bash" | "zsh";
// Split marker and value in printf so terminal input echo cannot acknowledge
// the probe/install. A leading newline separates markers from prompt/OSC data.
const ptyMarkerCommand = (marker: string, value: string): string =>
`printf '\\n%s%s\\n' '${marker}' ${value}`;
export const buildOsc7PtyShellProbeCommand = (): string =>
` case "$-" in *i*) if test -n "${DOLLAR}{ZSH_VERSION-}"; then ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "zsh")}; elif test -n "${DOLLAR}{BASH_VERSION-}"; then ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "bash")}; else ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "unknown")}; fi;; *) ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "unknown")};; esac; ${BASH_DELETE_MARKED_HISTORY_COMMAND}\r`;
/** PTY-local transport. Every line stays below Linux's canonical input limit. */
export const buildOsc7PtySetupCommands = (shell: Osc7PtyShell): string[] => {
const cleanup = shell === "bash" ? `; ${BASH_DELETE_MARKED_HISTORY_COMMAND}` : "";
const finish = (command: string): string => ` ${command}${cleanup}\r`;
const failed = ptyMarkerCommand(OSC7_PTY_FAILED_MARKER, shell);
const file = '"$__netcatty_osc7_pty_file"';
const commands = [finish(
`if __netcatty_osc7_pty_file=$(mktemp /tmp/.netcatty-osc7-pty.XXXXXX); then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, "0")}; else ${failed}; fi`,
)];
// Leave room for the append, ack, and Bash history cleanup on each line.
const payload = btoa(POSIX_SETUP_SCRIPT);
const chunkSize = 1800;
for (let offset = 0; offset < payload.length; offset += chunkSize) {
const ack = String(commands.length);
commands.push(finish(
`if test -n ${file} && printf '%s' '${payload.slice(offset, offset + chunkSize)}' >> ${file}; then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, ack)}; else rm -f -- ${file}; unset __netcatty_osc7_pty_file; ${failed}; fi`,
));
}
const runner = 'c=$(base64 -d "$1") && printf "%s\\n" "$c" | sh';
const shellEnv = shell === "zsh" ? ' NETCATTY_ZDOTDIR="${ZDOTDIR:-}"' : "";
const run = `env NETCATTY_OSC7_FORCE_SHELL=${shell}${shellEnv} sh -c ${quoteForSingleQuotedShellString(runner)} sh ${file}`;
const rc = shell === "bash" ? '$HOME/.bashrc' : '${ZDOTDIR:-$HOME}/.zshrc';
commands.push(finish(
`if ${run} && . "${rc}" >/dev/null 2>&1 && osc7_cwd 2>/dev/null; then __netcatty_osc7_pty_result='${OSC7_PTY_DONE_MARKER}'; else __netcatty_osc7_pty_result='${OSC7_PTY_FAILED_MARKER}'; fi; rm -f -- ${file}; unset __netcatty_osc7_pty_file; printf '\\n%s%s\\n' "$__netcatty_osc7_pty_result" ${shell}; unset __netcatty_osc7_pty_result`,
));
return commands;
};
// Only accept complete output lines, including when a marker spans chunks.
const readPtyMarker = (output: string, marker: string): string | null =>
readMarkerLine(output.slice(0, output.lastIndexOf("\n") + 1), marker);
/** Short-lived observer; never consume/replay terminal backlog or flow acks. */
export const waitForOsc7SessionOutput = (
subscribe: (sessionId: string, onData: (chunk: string) => void) => () => void,
sessionId: string,
predicate: (output: string) => boolean,
timeoutMs: number,
): Promise<string | null> => {
let output = "";
let dispose: (() => void) | undefined;
let settled = false;
let result: string | null = null;
let resolveOutput: ((value: string | null) => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (value: string | null) => {
settled = true;
result = value;
clearTimeout(timer);
dispose?.();
resolveOutput?.(value);
};
// Let subscription errors throw before runOsc7SetupAction sends any input.
dispose = subscribe(sessionId, (chunk) => {
if (settled) return;
output = (output + chunk).slice(-65536);
if (predicate(output)) finish(output);
});
if (settled) {
dispose();
return Promise.resolve(result);
}
return new Promise((resolve) => {
resolveOutput = resolve;
timer = setTimeout(() => finish(null), timeoutMs);
});
};
const isOsc7SetupShell = (value: string): value is Osc7SetupShell =>
value === "bash" || value === "zsh" || value === "fish";
@@ -835,8 +744,6 @@ export const runOsc7SetupAction = async ({
setupOsc7Tracking,
writeToSession,
writeLocalTerminalData,
isTerminalReadyForCommandInjection,
waitForSessionOutput,
}: RunOsc7SetupActionOptions): Promise<Osc7SetupRunResult> => {
if (status !== "connected") {
return { success: false, error: "Terminal is not connected" };
@@ -881,71 +788,6 @@ export const runOsc7SetupAction = async ({
return { ...result, success: true, sentToTerminal: true };
}
// Only this confirmed action may use foreground setup on exec identity
// failures (#4052). Keep the OTHER_USER staged transport above unchanged.
const identityErrors = [
"Netcatty OSC 7 setup: could not identify the active terminal shell",
"Netcatty OSC 7 setup: active terminal shell did not match the current tab",
];
if (result.code === 4 || identityErrors.includes(result.stderr?.trim() || "")) {
const idleError = "Configure directory tracking needs an idle bash/zsh prompt with empty input. Clear pending input and try again, or configure OSC 7 manually.";
if (!waitForSessionOutput || !isTerminalReadyForCommandInjection || !isTerminalReadyForCommandInjection()) {
return { ...result, success: false, error: idleError };
}
// Output observers run before xterm has necessarily rendered the next prompt.
// Wait briefly for the live safety reader; never infer readiness from an ack.
const waitUntilPromptReady = async (): Promise<boolean> => {
const deadline = Date.now() + 5000;
while (!isTerminalReadyForCommandInjection()) {
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return true;
};
const probeOutput = waitForSessionOutput(
sessionId,
(output) => readPtyMarker(output, OSC7_PTY_SHELL_MARKER) !== null,
5000,
);
const probe = buildOsc7PtyShellProbeCommand();
writeToSession(sessionId, probe, {
automated: true,
logRewrite: { sentCommand: probe, displayCommand: "" },
});
const shell = readPtyMarker(await probeOutput || "", OSC7_PTY_SHELL_MARKER);
if ((shell !== "bash" && shell !== "zsh") || !await waitUntilPromptReady()) {
return { ...result, success: false, error: idleError };
}
const completed = (output: string) => readPtyMarker(output, OSC7_PTY_DONE_MARKER) === shell;
const failed = (output: string) => readPtyMarker(output, OSC7_PTY_FAILED_MARKER) === shell;
const commands = buildOsc7PtySetupCommands(shell);
for (let index = 0; index < commands.length; index += 1) {
if (!await waitUntilPromptReady()) {
return { ...result, success: false, error: idleError };
}
const final = index === commands.length - 1;
const acknowledged = (output: string) => final
? completed(output) : readPtyMarker(output, OSC7_PTY_ACK_MARKER) === String(index);
// Subscribe before every write, and wait for this command's acknowledgement
// before typing another line (including when the shell uses canonical input).
const installOutput = waitForSessionOutput(sessionId, (output) => acknowledged(output) || failed(output), final ? 20000 : 5000);
const command = commands[index];
writeToSession(sessionId, command, {
automated: true,
logRewrite: { sentCommand: command, displayCommand: "" },
});
const output = await installOutput;
if (!output || failed(output) || !acknowledged(output)) {
return {
...result,
success: false,
error: "Foreground directory tracking setup failed or did not finish within 20 seconds. Check the terminal; manual OSC 7 configuration is also available.",
};
}
}
return { ...result, success: true, sentToTerminal: true, error: undefined };
}
if (!result.success || (typeof result.code === "number" && result.code !== 0)) {
return {
...result,
@@ -155,7 +155,7 @@ const formatPluginConnectionExitMessage = (event: TerminalSessionExitEvent): str
return `\r\n${lines.join("\r\n")}`;
};
export const isAuthFailureMessage = (message: string): boolean => {
const isAuthFailureMessage = (message: string): boolean => {
const normalized = message.toLowerCase();
return normalized.includes("all configured authentication methods failed") ||
normalized.includes("authentication failed") ||
@@ -3168,12 +3168,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
});
term.onBinary((data) => {
// Legacy mouse reports are wire bytes, not text input or broadcast keys.
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.writeToSession(id, data, { binary: true });
});
const handleKittyKeyboardBroadcast = createKittyKeyboardBroadcastHandler({
resolveOptions: () => ({
kittyProtocolEnabled: kittyKeyboardProtocolEnabled,
@@ -1,40 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const start = source.indexOf(" term.onBinary((data) => {");
const end = source.indexOf(" const handleKittyKeyboardBroadcast =", start);
assert.ok(start >= 0 && end > start, "runtime must subscribe to xterm onBinary");
test("onBinary forwards unchanged bytes only to the current session without text handling", () => {
const writes: Array<{ id: string; data: string; binary: boolean }> = [];
const sessionRef: { current: string | null } = { current: "first" };
let receive!: (data: string) => void;
runInNewContext(source.slice(start, end), {
term: { onBinary(callback: typeof receive) { receive = callback; } },
ctx: {
sessionRef,
terminalBackend: {
writeToSession(id: string, data: string, options: { binary: boolean }) {
writes.push({ id, data, binary: options.binary });
},
},
},
handleTerminalInputData: () => assert.fail("binary reports must bypass text handling"),
});
const mouse = String.fromCharCode(0x1b, 0x5b, 0x4d, 0x20, 0x21, 0xc0);
receive(mouse);
sessionRef.current = "replacement";
const allBytes = String.fromCharCode(...Array.from({ length: 256 }, (_, i) => i));
receive(allBytes);
sessionRef.current = null;
receive(mouse);
assert.deepEqual(writes, [
{ id: "first", data: mouse, binary: true },
{ id: "replacement", data: allBytes, binary: true },
]);
});
@@ -20,7 +20,7 @@ function section(start: string, end: string): string {
}
const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput =");
const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick =");
const dataCallback = section(" term.onData((data) => {", " term.onBinary((data) => {");
const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast =");
const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition =");
const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"');
const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast");
@@ -1,6 +1,5 @@
import test from "node:test";
import assert from "node:assert/strict";
import xterm from "@xterm/xterm";
import {
MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
@@ -19,159 +18,6 @@ import {
type StampCursorEstimate,
} from "./terminalLineTimestamps.ts";
import { MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS } from "./terminalControlSequenceLimits.ts";
import { installEraseInDisplayHandlers } from "../clearTerminalViewport.ts";
const createRealTimestampTerm = (wipeScrollback = true) => {
const term = new xterm.Terminal({
cols: 40, rows: 6, scrollback: 20, allowProposedApi: true,
});
const handler = installEraseInDisplayHandlers(term, {
getClearWipesScrollback: () => wipeScrollback,
});
const writes: string[] = [];
const originalWrite = term.write.bind(term);
term.write = (data, callback) => {
assert.equal(typeof data, "string");
writes.push(data as string);
originalWrite(data, callback);
};
return {
term,
writes,
write: (data: string, second = 0) => new Promise<void>((resolve) => {
writeTerminalDataWithLineTimestamps(term, data, resolve, {
timestampDate: new Date(2026, 9, 29, 12, 0, second),
});
}),
dispose: () => { handler.dispose(); term.dispose(); },
};
};
test("real xterm keeps cleared prompts timestamped across chunk and second boundaries", async () => {
for (const wipe of [true, false]) {
for (const second of [0, 1]) {
for (const clear of ["\x1b[H\x1b[2J\x1b[3J", "\x1b[H\x1b[J", "\x1b[1;1f\x1b[2J"]) {
for (const chunking of ["merged", "separate", "bytewise"]) {
const fixture = createRealTimestampTerm(wipe);
const { term, write } = fixture;
try {
const initial = "history\r\n$ clear\r\n";
await write(initial);
const redraw = clear + "$ ";
const chunks = chunking === "merged" ? [redraw]
: chunking === "separate" ? [clear, "$ "] : [...redraw];
for (const chunk of chunks) await write(chunk, second);
assert.equal(term.buffer.active.cursorY, 0, "prompt is at the top");
assert.equal(term.buffer.active.getLine(term.buffer.active.baseY)?.translateToString(true), "$ ");
assert.deepEqual(getVisibleTerminalLineTimestampRows(term), [
{ row: 0, label: `12:00:0${second}` },
], JSON.stringify({ wipe, second, clear, chunking }));
assert.equal(fixture.writes.join(""), initial + redraw, "PTY bytes are unchanged");
} finally {
fixture.dispose();
}
}
}
}
}
});
test("real xterm timestamps repeated same-second clears and redraws without a newline", async () => {
const fixture = createRealTimestampTerm();
try {
await fixture.write("$ ");
for (let index = 0; index < 5; index += 1) {
// Ctrl+L-style redraw: no CR/LF to reset the timestamp segmenter.
await fixture.write("\x1b[H\x1b[2J$ ");
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:00" },
]);
assert.equal(getTerminalLineTimestampLedgerCount(fixture.term), 1);
}
} finally {
fixture.dispose();
}
});
test("real xterm preserves timestamped history when output and clear share a batch", async () => {
const fixture = createRealTimestampTerm(false);
try {
await fixture.write("old\r\n", 0);
await fixture.write("new\r\n\x1b[H\x1b[2J\x1b[3J$ ", 1);
fixture.term.scrollToTop();
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:00" },
{ row: 1, label: "12:00:01" },
{ row: 2, label: "12:00:01" },
]);
} finally {
fixture.dispose();
}
});
test("real xterm retains clear anchors after scrollback trim and terminal reflow", async () => {
const fixture = createRealTimestampTerm();
try {
await fixture.write("line\r\n".repeat(40));
await fixture.write("\x1b[H\x1b[2J\x1b[3J$ ", 1);
fixture.term.resize(20, 6);
await fixture.write("command\r\nnext", 2);
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:01" },
{ row: 1, label: "12:00:02" },
]);
} finally {
fixture.dispose();
}
});
test("real xterm does not treat OSC payloads or alternate-screen redraws as clear boundaries", async () => {
const fixture = createRealTimestampTerm();
try {
const data = "\x1b]0;title\x1b[H\x1b[2J\x07$ ";
await fixture.write(data);
assert.equal(fixture.writes.length, 1, "string controls retain the bulk path");
await fixture.write("\x1b[?1049h\x1b[H\x1b[2JTUI", 1);
assert.equal(fixture.writes.length, 2, "alternate-screen output retains the bulk path");
await fixture.write("\x1b[?1049l\r\n$ ", 2);
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:00" },
{ row: 1, label: "12:00:02" },
]);
} finally {
fixture.dispose();
}
});
test("standalone cursor moves keep untouched suffix timestamps", async () => {
for (const position of ["\x1b[H", "\x1b[1;1f"]) {
const fixture = createRealTimestampTerm();
try {
await fixture.write("first\r\nsecond\r\n", 0);
await fixture.write(position + "new", 1);
assert.equal(fixture.term.buffer.active.getLine(1)?.translateToString(true), "second");
assert.equal(getVisibleTerminalLineTimestampRows(fixture.term).find(({ row }) => row === 1)?.label, "12:00:00");
assert.equal(fixture.writes.length, 2, "a standalone move does not split the write");
} finally {
fixture.dispose();
}
}
});
test("normal-screen cursor-heavy frames remain a single write", async () => {
const fixture = createRealTimestampTerm();
let notifications = 0;
const unsubscribe = onTerminalLineTimestampsChange(fixture.term, () => { notifications += 1; });
try {
const frame = Array.from({ length: 1000 }, (_, row) => `\x1b[${row % 6 + 1};1Hrow`).join("");
await fixture.write(frame);
assert.deepEqual(fixture.writes, [frame]);
assert.equal(notifications, 1, "one ledger notification per frame");
} finally {
unsubscribe();
fixture.dispose();
}
});
const createFakeTerm = (options: {
cols?: number;
@@ -323,6 +323,17 @@ const isBulkMeasurableEscapeSequence = (sequence: string): boolean =>
getAlternateScreenAction(sequence) === null
&& (getWraparoundAction(sequence) !== null || isSgrSequence(sequence));
const isPotentialAlternateScreenSequence = (sequence: string): boolean => {
if (!sequence.startsWith("\x1b[?")) return false;
const params = sequence.slice(3).split(";");
const alternateScreenModes = ["47", "1047", "1049"];
return params.some((part) => (
part === ""
|| alternateScreenModes.some((mode) => mode.startsWith(part) || part.startsWith(mode))
));
};
const isPrintableOutput = (char: string): boolean => {
if (char === "\t") return true;
const code = char.codePointAt(0);
@@ -1700,7 +1711,7 @@ const writeTerminalDataWithSecondLedger = (
const segments = store.segmenter.append(dataForTimestamps, stampDate);
const pendingEscapeSequence = store.segmenter.flushPendingEscapeSequence();
if (canRetainIncompleteTerminalControlSequence(pendingEscapeSequence)) {
if (isPotentialAlternateScreenSequence(pendingEscapeSequence)) {
store.timestampOnlyPrefix = pendingEscapeSequence;
}
@@ -1768,40 +1779,6 @@ const writeTerminalDataWithSecondLedger = (
});
};
/**
* Normal-screen erasure needs real parser coordinates. Keep erase writes
* separate from text so anchors are attached before an erase,
* and subsequent stamps are seeded after xterm has moved/trimmed the buffer.
* Standalone cursor moves stay on the bulk path: a backwards sparse stamp
* would overwrite the fill-forward time of untouched rows below it.
* Skip alternate-screen programs and string controls (OSC/DCS payloads).
*/
const findTimestampCursorBoundary = (term: XTerm, data: string): {
start: number;
end: number;
} | null => {
const prefix = getTimestampStore(term).timestampOnlyPrefix;
const input = prefix + data;
let altActive = term.buffer?.active?.type === "alternate";
for (let index = input.indexOf("\x1b"); index !== -1;) {
const parsed = readEscapeSequence(input, index);
if (!parsed?.complete) break;
const sequence = parsed.sequence;
const action = getAlternateScreenAction(sequence);
if (action) altActive = action === "enter";
const final = getCsiFinal(sequence);
const params = sequence.slice(2, -1);
if (!altActive && final === "J" && /^[\d;]*$/.test(params)) {
return {
start: Math.max(0, index - prefix.length),
end: parsed.endIndex + 1 - prefix.length,
};
}
index = input.indexOf("\x1b", parsed.endIndex + 1);
}
return null;
};
export const writeTerminalDataWithLineTimestamps = (
term: XTerm,
data: string,
@@ -1837,41 +1814,13 @@ export const writeTerminalDataWithLineTimestamps = (
return;
}
// Preserve arrival time across parser checkpoints, including a second rollover.
const writeOptions = { ...options, timestampDate: options?.timestampDate ?? new Date() };
const writeStamped = (part: string, onComplete: () => void) => {
writeTerminalDataWithSecondLedger(
term, part, onComplete, writeOptions, diagnostics, shouldMeasureDiagnostics,
);
};
const writeNext = (remaining: string): void => {
const boundary = findTimestampCursorBoundary(term, remaining);
if (!boundary) {
writeStamped(remaining, done);
return;
}
const writeControl = () => {
writeFallbackOnly(remaining.slice(boundary.start, boundary.end), () => {
const store = getTimestampStore(term);
store.segmenter.reset();
store.timestampOnlyPrefix = "";
// Erasure can dispose the current second's anchor. A redraw must be
// allowed to stamp again even when its arrival time is the same second.
store.lastStampSecondKey = null;
rebaseLedgerForScrollback(term, store);
notifyTimestampStore(store);
if (boundary.end < remaining.length) {
writeNext(remaining.slice(boundary.end));
} else {
done();
}
});
};
if (boundary.start > 0) {
writeStamped(remaining.slice(0, boundary.start), writeControl);
} else {
writeControl();
}
};
writeNext(data);
// Record/render separation: per-second ledger + sparse reflow anchors.
writeTerminalDataWithSecondLedger(
term,
data,
done,
options,
diagnostics,
shouldMeasureDiagnostics,
);
};
@@ -1,48 +0,0 @@
# Scoped disconnected-session reconnect
## Objective and scope
Continue the workspace session continuity objective after merged PR #3885. Expose explicit reconnect for an existing, authorized, disconnected session without creating a tab, moving it, granting ownership, or replaying commands. Reuse the terminal's existing reconnect handler. Connected, authenticating, unmounted and already-reconnecting terminals must not be restarted by this operation.
## Plan
1. Add failing lifecycle, permission and renderer-registry tests.
2. Add a catalog/RPC/CLI operation using existing approval and scope enforcement.
3. Bound agent requests per session incident: three attempts, 2/5 second backoff and a 120 second starting window; only an observed connected state resets the incident. No timers queue delayed input or recovery.
4. Update bundled operator guidance, run focused regressions, lint and build, then submit a follow-up PR against current upstream.
## Status
- Planning: complete. Base: upstream main `0256e7471`; original checkout changes preserved.
- Tests: first seven new cases failed before implementation; 314 focused regressions now pass, including real MCP transport startup, root bridge scope/cancellation, permission dispatch, renderer lifecycle gates and existing terminal view regressions. CLI adapter suite: 16/16 pass after the additional required-session case. Targeted ESLint passes.
- Implementation: complete. Catalog/generated tools, CLI, progressive MCP `sessions` category, scoped main-process service and renderer reconnect handler are connected. Lost replies consume budget; explicit non-dispatch replies do not. Live connected snapshots and explicit tab closure clear incident state.
- Build: `npm run build` passed; final rebuild after the Vault queue bypass also passed (1m 46s, existing large-chunk warnings). Additional final renderer bridge suite: 92/92 passed; targeted lint passed again.
- Submission: [PR #3935](https://github.com/binaricat/Netcatty/pull/3935), branch `feat/scoped-session-reconnect-20261026`. Local validation complete; upstream CI/review pending.
## Findings and limits
- Expanded tests caught the missing progressive MCP category: the new tool initially prevented MCP startup. Added `sessions` to the loader and reran the real transport test successfully.
- Existing UI auto-reconnect remains independent; limits here apply to agent-dispatched attempts during the main-process lifetime. No second automatic retry loop or deferred reconnect queue is introduced.
- No real user SSH connection was disconnected for fault injection. End-to-end desktop sleep/wake and SSH network fault tests remain manual acceptance cases; automated checks exercise the existing runtime dispatch boundary without affecting user sessions.
- The production build does not replace the installed application; the new CLI command requires a running build containing this capability.
## Acceptance
- Original tab, workspace and session ID retained.
- Out-of-scope, cancelled, observer and revoked requests cannot reconnect.
- Concurrent requests do not create duplicate connections; no request waits for a future terminal mount.
- Authentication and active reconnect remain under the existing UI lifecycle.
- Reconnect acknowledgement never claims shell readiness or command completion.
- Retry exhaustion is retained across chat turns; no session/tab storm.
Restarting a connected/hung transport, moving sessions and remote process termination remain later increments. A reconnect does not prove an old process has stopped.
## Follow-up: bounded recovery orchestration
Goal: inspect the existing terminal, retry transient disconnects with the existing shared reconnect budget, and observe connection/prompt readiness without sending task commands. Extend the still-open PR #3935, based on unchanged upstream `0256e7471` (fetched again before this increment).
Plan: write lifecycle tests, implement a short bounded recovery operation, expose actual authentication/reconnecting state from the terminal registry, connect scope/cancel/readiness checks, then run regressions/lint/build and update the PR. Each call waits at most 20 seconds with short renderer deadlines; continuation calls share a 120-second incident window. Stop on authentication, missing handler, busy tracked jobs, cancellation, lost scope or retry exhaustion. Never open/close tabs or replay commands in this increment.
Status: implementation complete; 438 focused regressions and targeted ESLint pass. New missing-service, expiry, stopped-terminal and flapping-budget cases failed before their fixes. Final `npm run build` after budget hardening passed (1m 6s, existing chunk-size warnings). Existing reconnect CI passed before this extension, which needs fresh CI. Live network fault injection remains pending.
Findings: reusing only the low-level reconnect budget was insufficient when a flapping transport briefly connected and cleared that budget. The recovery operation now retains its own three-attempt count and 2/5 second backoff until a ready prompt is observed; calls still honor the shared single-reconnect budget. No timer survives a completed recovery call. Explicit user Disconnect/Cancel and authentication failure stop automatic recovery. Private reconnect dispatch carries a renderer expiry deadline to prevent late execution after timeout.
+3 -9
View File
@@ -6,7 +6,7 @@ Let an agent continue an authorized task across multiple sessions in its workspa
The workspace is the collaboration and permission boundary. The host grants access to particular sessions and management operations; the main process enforces those grants across RPC, IPC, and CLI paths. Being displayed in a workspace does not itself grant access, transfer ownership, or authorize closing another chat's session. Existing task authorization should be reusable for permitted recovery without repeated permission questions.
## Merged foundation: PR 3885
## Current delivery: PR 3885
- Open a new host session in an explicitly selected workspace and bind it to the requesting chat scope, or return an explicit binding failure.
- Preserve upstream reuse-first behavior and original ownership, including visibility of sessions already auto-reconnecting.
@@ -40,21 +40,15 @@ The Skill is operator guidance, not a backend retry controller or a grant of per
| --- | --- | --- |
| Open a new connection for parallel work | Requested workspace, usable chat scope, explicit reuse/create result, no duplicate retry tabs | Placement/scope delivered; existing reuse preserved |
| Slow local shell or SSH shell startup | No command bytes before readiness; bounded cancellation and explicit not-sent failure | Delivered for tracked shell PTYs |
| Brief network loss or sleep/wake disconnect | Reconnect original tab within its grant, preserve scope, resume after observed readiness | Explicit reconnect implemented in the follow-up; live fault injection pending |
| Brief network loss or sleep/wake disconnect | Reconnect original tab within its grant, preserve scope, resume after observed readiness | Planned explicit reconnect |
| Old terminal is unresponsive | Inspect/cancel appropriately, then use one permitted independent connection in the same workspace | Creation is available where exposed; automated recovery orchestration planned |
| A command may have executed before disconnect | Diagnose outcome; no automatic replay of a possibly mutating command | Skill guidance; richer delivery state planned |
| Permission revoked or user stops the task | No reconnect/reopen/resume that bypasses that decision | Existing enforcement retained; lifecycle regressions required for new tools |
| Same failure persists | Bounded retries/backoff, no tab storm, clear blocked-dependency report | Skill guidance; controller enforcement required with lifecycle tools |
## Follow-up: explicit reconnect (2026-10-26)
Implementation and validation are tracked in [scoped-session-reconnect-20261026.md](scoped-session-reconnect-20261026.md). The follow-up adds `session_reconnect` / CLI `session-reconnect` for existing scoped disconnected sessions, using the existing terminal lifecycle with no new tab or command replay. Main-process attempt limits and scope checks supplement the operator policy. Restart/move and independent recovery-tab orchestration remain later work.
The same open PR is being extended with `session_recover` / CLI `session-recover`: short bounded recovery calls inspect authentication, existing reconnects and tracked job activity, retry with the shared reconnect budget, and observe prompt readiness. Continuations retain the incident deadline. This orchestration does not create recovery tabs, move sessions, restart connected transports or replay task commands. Actual sleep/wake/network-fault acceptance remains outstanding.
## Status
PR 3885 is merged. The follow-up implements explicit scoped reconnect and bounded same-session recovery; restart/move remain planned. Apply Skill updates through the application's normal distribution/synchronization path; do not rely on overwriting a user's separately maintained interruption skill.
Planning and Skill guidance for the continuity objective. PR 3885 remains the first implementation increment. No disconnected-session reconnect/restart/move tool is claimed by this document. Apply Skill updates through the application's normal distribution/synchronization path; do not rely on overwriting a user's separately maintained interruption skill.
## Guidance validation
@@ -1,79 +0,0 @@
# Terminal Compose Context Chips
在终端撰写栏中增加 **Context Chips(上下文芯片)**,让用户在编写命令时直接查看和操作当前目录、Git 分支、变更统计、Node 版本及关联 PR。
## 开发目标
Context Chips 位于**快捷命令下方、命令输入区上方**,各芯片绑定当前聚焦的终端。首期实现目录切换,第二步增加 Git 功能,随后增加 Node 和 PR;详细 Git 变更面板暂不实现,但预留入口。
```text
撰写栏调高把手
快捷命令/固定片段 管理
Context Chips: [目录] [分支] [变更统计] [Node] [PR]
命令输入区 关闭
```
芯片显示上下文,点击就近操作。首期只显示已实现的目录芯片,其余按终端能力与上下文出现。共享终端主题、紧凑图标和文字、悬停说明及键盘焦点;不额外显示占据高度的行标题。窄分屏优先保留目录,长路径/分支名截断显示,完整值可查看。
| 功能 | 开发目标 | 调研与交互 |
| --- | --- | --- |
| Directory Picker | 显示当前位置;搜索子目录、返回父目录、选择后立即切换并关闭菜单 | [directory-picker.md](directory-picker.md) · [交互示意](directory-picker-demo.html) |
| Git Branch | 显示当前分支,搜索并切换其他分支 | [git-branch.md](git-branch.md) |
| Git Changes | 显示改动文件数及增删行;预留变更文件面板入口 | [git-changes.md](git-changes.md) |
| Node Version | 显示当前终端 Node 版本;支持的管理器可提供版本切换 | [node-version.md](node-version.md) |
| Pull Request | 显示当前仓库/分支关联 PR,提供查看入口 | [pull-request.md](pull-request.md) |
当前目录功能已有规格草案和模拟交互;其余四项仍有待确认行为,分别列在对应文档。HTML 使用虚构目录,不连接文件系统或真实终端。
## 架构与数据流
沿用现有 domain → application state → UI 分层,外部读取和执行经过 infrastructure/Electron bridge。新增 Context Chips 行负责呈现,各功能 hook 负责状态及异步编排;不把文件读取、Git 查询或命令执行塞进 `TerminalComposeBar`。
```mermaid
flowchart TD
UI[TerminalComposeBar / Context Chips] --> State[各功能状态 hook]
State --> Domain[路径 / 过滤 / 展示 / 操作资格]
State --> Cwd[terminalCwdStore 与现有 cwd 探测]
State --> IO[本地 / SSH / provider 适配]
IO --> Read[目录 / Git / Node / PR 读取]
State --> Action[绑定 session 的操作入口]
Action --> Ready[终端就绪与目标身份检查]
Ready --> Terminal[当前交互 shell]
Terminal --> Cwd
```
显示数据与操作分开:读取目录不等于切换目录,写入 cd 不等于切换成功。目录和 Node 切换必须影响目标交互 shell;独立 exec 子进程里的 cd 或版本切换不会改变用户终端。PR 联网失败不阻塞本地目录和 Git 上下文。
## 函数拆分与整合
| 现有位置 | 拟调整 | 影响范围 |
| --- | --- | --- |
| `components/terminal/TerminalComposeBar.tsx` | 增加独立 Context Chips 行与菜单,保留快捷命令和草稿输入职责 | 撰写栏 UI、焦点和草稿选区 |
| `components/terminalLayer/TerminalLayerWorkspaceSection.tsx` | 将聚焦会话与功能 view model 接入芯片行 | 切终端/分屏时的目标绑定 |
| `application/state/useComposeBarHeight.ts` | 将新增行计入高度预算,兼容保存的旧高度 | 撰写栏最小高度和可用输入区;建议从最小 104px 验证,默认是否调整另定 |
| `application/state/terminalCwdStore.ts`、`components/TerminalLayer.tsx`、`commandCwdProbe.ts` | 合并芯片可见时的 cwd 刷新需求与现有命令后探测 | cwd 展示、目录恢复与 SFTP 跟随共用同一更新链 |
| `components/SftpSidePanel.tsx` 的 `handleLocatePathInTerminal`、`domain/sftpLocatePathInTerminal.ts` | 抽取/复用“定位到指定终端”的受控执行边界 | SFTP 定位与目录芯片共用会话检查、cd 意图与执行规则 |
| `domain/sessionRestore.ts` 的 `resolveInteractiveTerminalCdIntent`、`terminalCommandInjectionReadyRegistry.ts` | 复用路径转义和空提示符判断,补充目标代次与结果确认 | 导航执行;不改成直接复用草稿发送入口 |
| `useSftpBackend.ts`、`useTerminalBackend.ts`、现有 local FS/SSH bridge | 复用目录读取与严格 cwd 查询,按需要增加轻量适配 | 本地/远程 I/O;不依赖挂载 SFTP 视图 |
| Git 上下文提供器(新增) | 分支与变更统计共享 repo/worktree 识别和刷新 | 后续 Git Branch、Git Changes 及 PR 的仓库身份 |
| Node/PR 提供器(新增) | runtime/管理器能力与远程 PR 查询分别管理 | 独立加载、缓存和失败状态 |
| `domain/sidePanelLayout.ts`、`useTerminalSidePanelLayoutState.ts` 及侧栏渲染映射 | 将来详细变更面板接入现有 per-terminal 布局 | 暂只预留打开面板动作;真正实现时再扩展工具类型和渲染器 |
建议新增目录状态 hook,将名称过滤、排序、home 缩写和父路径留在 domain;本地/远程读取和定向执行封装为适配。Context Chips 行接收明确的 view model 与动作,不提前建设通用芯片插件注册框架。
## 关键接入约束
- **cwd 刷新**:当前命令后探测受目录恢复/SFTP 跟随条件控制。新增芯片要合并刷新需求,不能假定 `terminalCwdStore` 始终最新,也不另开恒定轮询。严格查询不允许将 home 或新登录 shell 的目录冒充交互 shell cwd。
- **定向执行**:`handleComposeSend` 包含广播路由,不能作为芯片导航入口。复用 SFTP 定位中的敏感输入和空提示符检查,只操作菜单打开时绑定的 session;忙碌、TUI、密码或 shell 已有半截输入时不注入命令。
- **异步身份**:请求绑定 session、连接代次及当前路径;Git 再包含 repo/worktree。切会话、cwd 变化或重连后,旧读取和操作确认不得更新新目标。
- **结果确认**:操作已接受与实际成功分别处理。目录等待可信 cwd,分支/Node 等待各自结果;失败保留真实旧值,超时不自动重发。取消等待不能撤销已写入 PTY 的命令。
- **能力范围**:local/SSH/provider 与 shell 差异通过适配处理。现有本地 cwd 读取主要覆盖 macOS/Linux POSIX;Windows、受限 SSH、mosh/ET 和其他 provider 不能直接假定可用。
- **持久化与协议**:首期复用现有设置和 bridge,不需要新增业务存储 schema 或扩大 MCP/CLI 工具面。若新增 IPC,沿现有类型、preload 和 main-process 校验链扩展。
## 改动范围判断
首期是**跨 UI、应用状态、纯路径逻辑和现有终端适配的局部扩展**。主要整合点是 cwd 刷新需求与 SFTP 定位执行逻辑,不需要重构整个终端运行时。新增行影响高度和焦点;共用函数抽取会牵涉 SFTP 定位、cwd 恢复/跟随及广播隔离,需保留这些已有行为。
后续 Git 需要新增仓库状态读取与缓存;Node 需要当前 shell 环境和管理器适配;PR 需要托管服务身份及联网查询。详细 Git 变更面板涉及侧栏工具类型、布局与渲染,影响范围明显大于统计芯片,因此单独实施。各项不作为目录功能上线的前置条件。
源码分析基于上游提交 `0fe069e57546d0056937d138bfccccab69a26c69`;上述是拟议改动范围,尚未实现。
@@ -1,114 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<title>Netcatty Context Chips · Directory Chip 交互示意</title>
<style>
:root{color-scheme:dark;--bg:#111315;--surface:#1c1f22;--line:#34383c;--fg:#e0e3e6;--muted:#a0a6ac;--accent:#52b7d6}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input,textarea{font:inherit}button{cursor:pointer;color:inherit}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.page{max-width:1160px;margin:auto;padding:42px 32px}h1{font-size:27px;line-height:1.3;margin:12px 0}p{margin:10px 0;color:var(--muted)}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 230px;gap:24px;margin-top:26px}.terminal{min-width:0;border:1px solid var(--line);border-radius:8px;background:#15181b;overflow:hidden}
.tabbar{padding:10px 16px;border-bottom:1px solid var(--line);font-size:12px;color:var(--muted)}.tabbar span{color:var(--fg);margin-right:18px}
.output{height:380px;padding:22px 18px;font:12px/1.9 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow:auto;color:#aab2b9}.output .prompt{color:#c1d7a2}.output .note{color:#6f7a83}
.compose{border-top:1px solid var(--line);padding:0 14px 14px;position:relative}.handle{height:6px;width:30px;border-bottom:2px solid #41464b;margin:0 auto 6px}.snippets{display:flex;gap:6px;height:30px;align-items:center;font:11px ui-monospace,monospace;color:var(--muted)}.snippet{padding:1px 7px;border-radius:3px;background:#25292d}
.context{position:relative;display:flex;gap:8px;align-items:center;height:30px;min-width:0}.chip{display:flex;align-items:center;gap:6px;max-width:100%;height:24px;padding:0 7px;border:1px solid #363b40;border-radius:4px;background:#101214;font:12px ui-monospace,monospace}.chip:hover,.chip[aria-expanded=true]{background:#272c31;border-color:#5b6872}.chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chip:disabled{cursor:wait;opacity:.7}.icon{width:14px;height:14px;flex-shrink:0;fill:none;stroke:currentColor;stroke-width:1.7}
.progress{font-size:11px;color:var(--muted)}textarea{display:block;width:100%;height:66px;resize:vertical;min-height:48px;border:0;background:transparent;color:var(--fg);font:12px/1.8 ui-monospace,monospace;margin-top:8px;padding:2px}
.popover{display:flex;flex-direction:column;position:absolute;z-index:2;left:0;bottom:calc(100% + 6px);width:min(360px,100%);max-height:310px;border:1px solid #383e44;border-radius:8px;overflow:hidden;background:var(--surface);box-shadow:0 10px 30px #0007}.popover[hidden]{display:none}.search{flex-shrink:0;padding:9px 12px;background:#121416;border-bottom:1px solid #2a2f34}.search input{width:100%;border:0;padding:0;background:transparent;color:var(--fg);font-size:13px;outline:none}.search input::placeholder{color:#858d94}
#directories{display:flex;flex-direction:column;min-height:0;overflow:hidden}#parent{flex-shrink:0}
.row{display:flex;align-items:center;gap:9px;padding:5px 12px;min-height:31px;cursor:pointer;color:#d7dcdf;font-size:13px}.row.active{background:var(--accent);color:#092631}.row:hover{background:#303940}.row.active:hover{background:var(--accent)}.row.parent{color:var(--muted);border-bottom:1px solid #343a40}.row.parent.active{color:#092631}.row .label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scroll{min-height:0;max-height:217px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}.empty{padding:20px 12px;color:var(--muted);font-size:12px}.foot{flex-shrink:0;padding:7px 12px;border-top:1px solid #343a40;display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted)}.foot label{display:flex;align-items:center;gap:4px}.foot input{accent-color:var(--accent)}
aside{padding-top:2px}aside h2{font-size:14px;margin:0 0 12px}aside p{font-size:12px}.badge{font-size:11px;color:#b5c79b;border:1px solid #46503b;padding:2px 6px;display:inline-block;border-radius:3px}.checks{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-top:18px}.checks label{display:flex;align-items:center;gap:6px}.checks input{accent-color:var(--accent)}.status{min-height:44px;border-top:1px solid var(--line);padding-top:12px;margin-top:16px;font-size:12px;color:var(--muted)}.reset{border:1px solid #424a51;border-radius:4px;background:transparent;padding:4px 9px;font-size:12px;margin-top:12px}.warning{color:#ddbc85}kbd{font:11px ui-monospace,monospace;border:1px solid #3c4248;border-radius:3px;padding:0 4px}
@media(max-width:760px){.page{padding:24px 16px}.layout{grid-template-columns:minmax(0,1fr)}aside{border-top:1px solid var(--line);padding-top:18px}.output{height:355px}h1{font-size:23px}}
</style>
</head>
<body>
<main class="page">
<h1>目录即入口,选中即切换</h1>
<p>Context Chips 建议交互稿,位于快捷命令下方。首期仅演示目录芯片;使用虚构数据,不访问文件系统或执行命令。</p>
<div class="layout">
<section aria-label="终端交互示意" class="terminal">
<div class="tabbar"><span>本地终端</span>zsh · 模拟会话 A</div>
<div class="output" id="output"><span class="prompt">~/git/Netcatty</span>
$ pwd
/home/demo/git/Netcatty
<span class="note">从目录菜单选择一个文件夹。
每次选择都会关闭菜单;再次打开可继续导航。</span>
</div>
<div class="compose" id="compose">
<div class="handle" aria-hidden="true"></div>
<div class="snippets" aria-label="现有快捷片段区域"><span class="snippet">git status</span><span class="snippet">npm run dev</span></div>
<div class="context" role="group" aria-label="Context Chips">
<button id="trigger" class="chip" aria-haspopup="dialog" aria-expanded="true" aria-controls="picker"><svg class="icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M2 6V4h6l2 2h8v10H2zM2 8h16"/></svg><span id="path">~/git/Netcatty</span></button>
<span class="progress" id="progress"></span>
<div id="picker" class="popover" role="dialog" aria-label="更改工作目录">
<div class="search"><input id="query" type="search" placeholder="搜索当前目录下的文件夹…" aria-label="搜索子目录" aria-controls="directories" autocomplete="off" spellcheck="false"></div>
<div id="directories" role="listbox" aria-label="目录"><div id="parent"></div><div class="scroll" id="children"></div></div>
<div class="foot"><label><input id="hidden" type="checkbox">显示隐藏目录</label><span><kbd>↑</kbd> <kbd>↓</kbd> <kbd>Enter</kbd></span></div>
</div>
</div>
<textarea id="draft" aria-label="命令草稿" spellcheck="false">npm run test -- --watch</textarea>
</div>
</section>
<aside>
<span class="badge">首期建议范围</span>
<h2 style="margin-top:16px">可以直接操作</h2>
<p>搜索当前层子目录;按上下键后 Enter,或单击目录;父目录同样会切换并关闭。</p>
<p>命令草稿始终保留。约 650ms 后模拟 cwd 确认,chip 才更新。</p>
<h2 style="margin-top:22px">观察失败状态</h2>
<p>勾选下方“模拟忙碌”或“模拟失败”,核对无法注入时的反馈和目录真值保持。</p>
<p>目录在模拟 cwd 确认后更新;切换失败时保持原路径。</p>
<button class="reset" id="reset">重置演示</button>
</aside>
</div>
<div class="checks"><label><input type="checkbox" id="busy">模拟终端忙碌</label><label><input type="checkbox" id="fail">模拟 cd 失败</label></div>
<div id="status" class="status" role="status" aria-live="polite">示意:菜单已展开,可搜索或选择。所有路径均为虚构。</div>
</main>
<script>
const $=id=>document.getElementById(id);
const home='/home/demo';
const tree={
'/':['home','srv','tmp','var'], '/home':['demo'], '/home/demo':['.config','Downloads','git'],
'/home/demo/git':['Netcatty','sample-app','tools'],
'/home/demo/git/Netcatty':['.github','.vscode','application','build','components','dist','docs','domain','electron','examples','infrastructure','lib','node_modules','packages','public','scripts'],
'/home/demo/git/Netcatty/application':['i18n','state'], '/home/demo/git/Netcatty/components':['ai','sftp','terminal','terminalLayer','ui'],
'/home/demo/git/Netcatty/docs':['assets','features','plans','research'], '/home/demo/git/Netcatty/domain':[],
'/home/demo/git/sample-app':['src','tests'], '/home/demo/.config':['demo'], '/tmp':[], '/srv':['app'], '/var':['log']
};
let cwd=home+'/git/Netcatty', open=true, pending=false, active=0, rows=[], epoch=0, composing=false;
const folder='<svg class="icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M2 6V4h6l2 2h8v10H2zM2 8h16"/></svg>';
const up='<svg class="icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 16V4M5 9l5-5 5 5"/></svg>';
function display(p){return p===home?'~':p.startsWith(home+'/')?'~'+p.slice(home.length):p}
function parent(p){return p==='/'?null:p.slice(0,p.lastIndexOf('/'))||'/'}
function join(p,n){return (p==='/'?'':p)+'/'+n}
function status(s,error=false){$('status').textContent=s;$('status').classList.toggle('warning',error)}
function updateChip(){ $('path').textContent=display(cwd);$('trigger').title=cwd;$('trigger').setAttribute('aria-label','当前目录 '+display(cwd)+',更改工作目录');$('trigger').disabled=pending;$('progress').textContent=pending?'切换中…':'' }
function setOpen(v,focus=true){open=v;$('picker').hidden=!v;$('trigger').setAttribute('aria-expanded',String(v));if(v){$('query').value='';render();if(focus)$('query').focus()}else if(focus)$('trigger').focus()}
function paint(){rows.forEach((r,i)=>{r.node.classList.toggle('active',i===active);r.node.setAttribute('aria-selected',String(i===active))});if(rows[active])$('query').setAttribute('aria-activedescendant',rows[active].node.id);else $('query').removeAttribute('aria-activedescendant')}
function render(){
$('parent').replaceChildren();$('children').replaceChildren();rows=[];
const query=$('query').value.toLocaleLowerCase();const par=parent(cwd);
const names=(tree[cwd]||[]).filter(n=>($('hidden').checked||!n.startsWith('.'))&&n.toLocaleLowerCase().includes(query)).sort((a,b)=>a.localeCompare(b,'en',{numeric:true}));
function add(label,path,isParent){const node=document.createElement('div');node.className='row'+(isParent?' parent':'');node.role='option';node.id='dir-'+rows.length;node.title=path;node.innerHTML=isParent?up:folder;const text=document.createElement('span');text.className='label';text.textContent=label;node.append(text);const index=rows.length;rows.push({path,node});node.addEventListener('click',()=>select(index));node.addEventListener('pointermove',()=>{active=index;paint()});$(isParent?'parent':'children').append(node)}
if(par)add('..(父目录)',par,true);names.forEach(n=>add(n,join(cwd,n),false));
active=names.length?(par?1:0):-1;paint();
if(!names.length){const el=document.createElement('div');el.className='empty';el.textContent=query?'没有匹配的子目录':'没有子目录';$('children').append(el)}
}
function select(index){
if(!rows[index]||pending)return;
if($('busy').checked){status('请等待终端回到空命令提示符。未提交 cd,草稿保持。',true);return}
const target=rows[index].path, token=++epoch, fail=$('fail').checked;
setOpen(false,false);pending=true;updateChip();$('draft').focus();status('模拟已提交导航;等待目标会话 cwd 确认,暂不更改 chip。');
setTimeout(()=>{if(token!==epoch)return;pending=false;if(fail){status('模拟切换失败:无法进入目标目录。保留原实际目录,可重新打开菜单。',true)}else{cwd=target;const line=document.createElement('div');line.textContent='$ cd -- '+"'"+target+"'"+'\n'+display(cwd);$('output').append(line);$('output').scrollTop=$('output').scrollHeight;status('模拟 cwd 已确认:'+display(cwd)+'。菜单已关闭,命令草稿保留。')}updateChip()},650);
}
$('trigger').addEventListener('click',()=>setOpen(!open));$('query').addEventListener('input',render);$('hidden').addEventListener('change',render);
$('query').addEventListener('compositionstart',()=>composing=true);$('query').addEventListener('compositionend',()=>composing=false);
$('picker').addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();setOpen(false)}else if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();e.stopPropagation();if(!rows.length)return;active=active<0?(e.key==='ArrowUp'?rows.length-1:0):Math.max(0,Math.min(rows.length-1,active+(e.key==='ArrowDown'?1:-1)));paint();rows[active].node.scrollIntoView({block:'nearest'})}else if(e.key==='Enter'&&e.target===$('query')){e.preventDefault();e.stopPropagation();if(!e.isComposing&&!composing)select(active)}});
document.addEventListener('pointerdown',e=>{if(open&&!$('picker').contains(e.target)&&!$('trigger').contains(e.target))setOpen(false,false)});
$('reset').addEventListener('click',()=>{epoch++;pending=false;cwd=home+'/git/Netcatty';$('busy').checked=false;$('fail').checked=false;$('hidden').checked=false;$('draft').value='npm run test -- --watch';updateChip();setOpen(true);status('演示已重置。虚构目录数据,不执行命令。')});
updateChip();render();$('query').focus();
</script>
</body>
</html>
@@ -1,185 +0,0 @@
# Directory Picker:目录显示与切换
日期:2026-10-29。状态:功能规格草案。总体目标与架构影响见 [README](README.md)。
参考行为:Warp 的目录芯片显示当前路径,点击打开搜索、父目录和直接子目录菜单;贡献者亲自操作确认,单击子目录会立即切换并关闭菜单。匹配算法、隐藏目录默认策略、父目录关闭行为与错误恢复仍未逐项验证;下文为 Netcatty 拟采用的明确规则。
本地模拟见 [directory-picker-demo.html](directory-picker-demo.html),不访问文件系统或真实终端。
## 1. 目标与首期边界
用户在撰写命令前,能看到**当前聚焦终端的真实工作目录**,并通过一个小菜单搜索当前层子目录、返回父目录、选择目录,使接下来执行的命令落在目标位置。
本期包含:目录芯片、当前 cwd 同步、弹出菜单、当前层搜索、父目录、直接子目录与目录符号链接、内部滚动、定向 cd、成功确认、键盘操作、错误恢复、草稿保护、会话隔离。
本期不包含:文件操作、文件预览、目录创建、全盘或递归搜索、历史目录、书签、路径自由输入、在菜单内下钻后单独确认、修改新会话默认目录、其他芯片功能。
范围建议:优先 macOS/Linux 本地 POSIX shell。SSH POSIX 是同一产品能力的目标场景,但是否同批上线应由就绪状态、cwd 可信度与连接能力共同决定;不得为了演示远程功能而在远程菜单中列举本地目录。Windows PowerShell/CMD、WSL、mosh/ET、跳板单通道、串口和网络设备的支持以能力探测为准,不能无条件套用 POSIX 命令。
## 2. 外观
目录芯片使用文件夹图标与路径,长路径保留尾部目录名,悬停显示完整路径。菜单锚定芯片上方、左对齐,间隔约 6–8px;宽约 360 CSS px,受窗口可用宽度约束。搜索行高约 34–36px,目录行约 30–32px,列表内部滚动。背景、文字及活动项沿用终端主题和应用 accent。
## 3. 信息模型与显示规则
必须分别保存三个概念:
| 值 | 含义 | 可否作为当前目录显示 |
| --- | --- | --- |
| confirmedCwd | 该会话最新可信 cwd 回报 | 可以 |
| listingBase | 本次菜单读取目录列表所依据的绝对路径 | 菜单上下文;不自动改变芯片 |
| pendingTarget | 用户已选择、尚未确认成功的路径 | 只能作为“切换中”提示 |
目录 chip:已知用户 home 时,可将 home 的完整路径前缀缩为 `~`;必须按目录边界比较,不能把 `/home/alice2` 错缩成 alice 的 home。远程 home 来自远程身份,不能使用应用本机 home。
显示绝对路径与命令参数是不同层次。`~/git` 只是显示,执行时应优先使用从目标会话/文件系统得到的绝对路径,不把 UI 中的波浪号文本当成真实文件名。
未知 cwd 显示“目录未知”,不显示 `/`、`~` 或某个 SFTP 面板曾浏览的位置。已有旧值但重连/有效性不明时显示弱化的“待确认”状态,不允许借旧值立即导航。
## 4. 打开、搜索与选择
### 4.1 打开
1. 单击目录芯片;再次单击关闭。
2. 菜单锚定芯片上方、左对齐;空间不足时调整位置,避免窗口边界和其他浮层裁切。
3. 固定本次 `sessionId + connectionGeneration + cwdRevision`。浮层打开后如果聚焦切到别的终端,关闭菜单,绝不把旧条目转发给新终端。
4. 搜索框自动获得焦点。显示当前层列表;若暂无缓存,显示“正在读取目录…”。
5. 列表读取不执行 cd,不影响草稿,不改变 SFTP 浏览位置。
### 4.2 搜索
首期定义为**当前目录直接子目录名称的过滤**,不递归、不访问其他目录、不调用系统全局搜索。这样行为和性能都清楚,不会让 SSH 中的一次打字变成递归扫描。
- 首期建议不区分大小写的连续子串匹配,支持中文及 Unicode 原名;不修改真正路径的大小写或 Unicode 字节表示。
- 输入查询即时过滤已读取列表,不因每个按键重新请求远程目录。
- 同一固定排序用于空查询和过滤结果;建议按名称的自然排序。不要依赖文件系统返回顺序。
- 父目录项不参与名称匹配,在非根目录置顶保留。无匹配时仍能返回父目录,结果区显示“没有匹配的子目录”。
- 第一个匹配的**子目录**为默认活动项,与参考菜单默认高亮子目录的展示一致;无子目录时默认不选中父目录,避免用户空列表按 Enter 意外离开当前目录。
- 查询只存在于当前打开周期,关闭后清空。下次从实际 cwd 重新打开。
- 占位文案建议“搜索当前目录下的文件夹…”,比全局“搜索目录”更准确地交代范围。
隐藏目录:Warp 的默认规则尚未验证。建议默认隐藏点目录/平台 hidden 条目,并提供可发现的“显示隐藏目录”开关;精简首期若不做开关,必须在帮助文案中说明且记录为已知限制。不要自行排除 `node_modules`、`build`、`dist` 等正常目录。
### 4.3 目录列表
父目录行:上箭头 + `..(父目录)`,固定在搜索框下,普通结果区内部滚动;根目录不显示可执行的父目录项。
目录行:文件夹图标 + 名称。只列真正目录和已确认目标是目录的符号链接;不列普通文件、链接到文件、断链和类型未知的链接。保留路径原始名称,展示截断时使用 tooltip,执行时不使用截断文本。
名称超长/包含控制字符时,显示层将不可见字符可视化,不能破坏列表。首期命令注入拒绝含 NUL、CR、LF、ESC 等终端控制字符的目标路径;提示“该目录名称暂不支持快捷切换”,用户仍可自行在 shell 中处理。不能因“已正确引号转义”就忽略终端输入控制字符。
列表最大高约 240–260 CSS px,外层菜单随内容收缩。滚轮只滚结果列表,不滚终端历史,也不把方向键发送给终端。超过约 500 条时建议虚拟化;具体阈值在实现中用性能结果确定,不把大列表截断成“没有更多目录”。
### 4.4 选择子目录或父目录
**统一动作:提交一次目录切换,并关闭菜单。** 子目录即时切换关闭来自用户对 Warp 的确认;父目录采用相同动作是 Netcatty 的一致性选择。
```text
打开菜单 → 搜索 / 移动活动项 → 单击或 Enter
→ 校验会话与 shell 就绪状态 → 定向提交 cd → 关闭菜单
→ 芯片显示切换中,保留旧 cwd → 实际 cwd 回报 → 更新芯片
```
- 前置校验失败:不发命令,菜单保留并显示原因。
- 已提交但结果失败/超时:保持旧的确认值,显示可重试提示。再次打开时从实际 cwd 重新读取。
- 写入被接受不代表成功;chip 不立即乐观变成目标路径。
- 同一时间每个会话最多一个切换操作,防止双击写入两条 cd。
- 用户选择的是 symlink 时,shell 可能回报逻辑路径或物理路径。确认规则需要识别等价路径,不因回报真实目标路径就误报失败;不要为了检测而额外改变 `cd -L/-P` 策略。
- 用户在操作期间手动运行别的 cd 时,以最新实际回报为准;已经被更新事件取代的旧操作不能再弹出“切换成功”。
## 5. 键盘、焦点与草稿
| 按键/操作 | 菜单行为 |
| --- | --- |
| Tab / Shift+Tab | 依次进入/离开芯片;浮层内部遵循既有 Popover 焦点规则 |
| Enter / Space(芯片聚焦) | 打开菜单 |
| 普通文字 | 输入搜索词,支持输入法组合 |
| Up / Down | 移动活动项,必要时滚入可视区;建议到边缘停止而不循环 |
| Enter(菜单内) | 激活当前条目;无活动项时无操作 |
| Escape | 关闭,回到打开前的焦点位置 |
| 单击外部 | 关闭,不执行目录切换,不强行夺走外部目标焦点 |
| 单击条目 | 选择、定向提交并关闭 |
输入法组合期间 Enter 只结束组合,不触发 cd。方向键不能穿透到命令历史。不要首期抢占 Alt+Up、Cmd+K 等现有终端快捷键;如后续增加全局入口,要经过 Netcatty 快捷键配置体系。
撰写栏草稿必须保持文本、选择范围与光标位置。目录动作不调用“发送草稿”,不清空草稿,也不把 cd 塞进撰写栏输入框。成功关闭菜单后回到撰写栏,用户可继续编辑原命令。
**区分两类草稿**:撰写栏 textarea 内的草稿允许保留;真实 shell 提示符里已有半截输入时,首期拒绝自动注入。不能清空后恢复终端命令行,也不能把 `cd` 拼接到用户已有文字后面。
无障碍:芯片是可聚焦 button,包含“当前目录…,更改工作目录”的可访问名称和 expanded 状态。浮层使用搜索输入 + 可键盘操作的 listbox;活动条目通过 aria-activedescendant 或等价模式表达。不要把“当前活动目录项”错误描述为已经生效的 cwd。结果数量、读取失败与切换完成可温和播报,避免每个按键打断读屏。
## 6. 终端操作规则
目录选择是用户显式操作,首期无需额外“确认 cd”弹窗,但以下状态不能执行:
- 会话未连接、重连中、已关闭,或菜单绑定的连接代次已改变。
- 密码、MFA、sudo 提示或其他敏感输入状态。
- vim、less、top、REPL、持续运行命令或 alternate screen。
- 未能可靠判断 shell 正在空提示符等待命令;包含 shell 端已有输入。
- 只读/观察会话或 provider 未提供受控执行能力。
- 支持的 shell/路径转义策略未知。
不自动 Ctrl+C,不发送 Ctrl+U,不尝试退出 TUI,不把导航排到任意正在运行的进程后面。可以在这些状态下允许只读浏览菜单,但条目激活前必须再次校验,并给出“请等待终端回到空命令提示符”等具体原因。
一次选择固定目标终端,不读取全局“当前终端”作为异步完成时的目标;无论广播开关如何,只发给该会话。不会修改 Electron 主进程工作目录,不会更改同一主机上的其他终端,也不会修改新会话启动目录设置。
POSIX 命令建议使用绝对路径与集中转义,如 `cd -- '/home/me/project dir'`。路径内单引号使用已有转义辅助逻辑;绝不 `eval`,不拼 `ls | grep`,不将查询词解释为 shell 代码。PowerShell/CMD 需要独立策略,不能套用此命令模板。
命令可正常回显到终端,保留用户操作可理解性;Netcatty 撰写栏发送历史不应混入这类内部导航动作。shell 自身历史是否记录 cd 随 shell 正常规则,不承诺隐身。
## 7. 状态与反馈
| 状态 | 芯片 | 菜单/操作 |
| --- | --- | --- |
| cwd 未知 | “目录未知”弱化 | 提供刷新;没有可信 cwd 时不从 home 猜测列举 |
| 已知且空闲 | 路径、文件夹图标 | 可浏览、可选择 |
| 目录读取中 | 路径不变 | 搜索栏保留,结果区 loading;旧目录列表不能假装为新结果 |
| 空目录 | 路径不变 | 父目录 +“没有子目录” |
| 无搜索结果 | 路径不变 | 父目录 +“没有匹配的子目录” |
| 无读取权限 | 路径不变 | “无法读取此目录”,可重试;若可信父路径已知可尝试父目录 |
| shell 忙碌/敏感 | 路径仍可显示 | 只读浏览或禁用并给出原因,禁止注入 |
| 切换已提交 | 旧路径 + 小型进度提示 | 菜单关闭,暂禁重复提交 |
| 切换确认 | 最新 cwd | 更新路径,恢复可操作 |
| 切换失败 | 保留实际 cwd | 简短失败提示,点击可重新打开菜单 |
| 超时 | 保留最后确认值并标记待确认 | 建议等待 prompt 后刷新;不自动重发 cd |
| 会话切换/关闭 | 跟随新会话或移除 | 关闭旧菜单、取消结果应用 |
建议产品目标:有缓存时打开反馈在约 100ms 内;无缓存立即显示读取状态。目录读取超时与 cwd 确认超时分别设计,SSH 应允许更长等待。可建议从本地 3s、SSH 8s 的确认预算开始验证,但不是服务质量承诺;超时不能作为执行失败的唯一证据,也不能触发无条件重试。
## 8. 验收场景
| ID | 场景 | 预期 |
| --- | --- | --- |
| DIR-01 | 在 `~/git` 打开 | 显示该终端实际路径;菜单上方锚定,搜索获焦 |
| DIR-02 | 输入 `net` | 只过滤当前层匹配目录;不递归,不每键联网 |
| DIR-03 | 点击 Netcatty | 一次 cd 发给绑定会话;菜单关闭;真实 cwd 回报后更新 |
| DIR-04 | 点击父目录 | 定向切到父目录并关闭;根目录无有效父目录项 |
| DIR-05 | 目录数超过可视高度 | 内部滚动可达最后一项,搜索框不滚走 |
| DIR-06 | 空目录/无匹配 | 各自正确文案;不把空结果当读取失败 |
| DIR-07 | 有撰写栏草稿和选区 | 浏览、取消、切换均保留;导航不发送草稿 |
| DIR-08 | shell 内已有半条命令 | 不清空、不拼接、不注入;给出就绪提示 |
| DIR-09 | 命令运行中、TUI、密码/MFA | 所有情况下均无导航字节写入 |
| DIR-10 | 广播开启、多个终端/分屏 | 只切换选定会话,其余 cwd 和输入不变 |
| DIR-11 | 菜单读取时切换终端/重连 | 旧结果作废,旧条目不能对新会话执行 |
| DIR-12 | 读取失败、无权限、目标被删除 | 明确错误、可重试;保留实际 cwd |
| DIR-13 | 空格、单引号、中文、`$()`、反引号的目录 | 正确当作路径;不产生额外 shell 行为 |
| DIR-14 | 目录名包含换行或控制字符 | 拒绝快捷切换且解释;不写终端控制字节 |
| DIR-15 | 目录 symlink/断链/文件链接 | 目录链接可用,确认允许等价路径;其他不冒充目录 |
| DIR-16 | Enter 输入法组合与上下键 | 不误导航;事件不落到终端/历史 |
| DIR-17 | 快速双击或重复 Enter | 同一切换仅提交一次 |
| DIR-18 | 目标写入已接受但 cd 实际失败 | 不乐观展示目标;cwd 保持真实值 |
| DIR-19 | 普通 cd、脚本 cd、OSC 7 外部更新 | 芯片随真实 cwd 更新,不依赖仅从菜单触发 |
| DIR-20 | 不同主题、200% 缩放、窄分屏 | 可读、可聚焦、浮层不溢出、不遮掉全部输入区 |
| DIR-21 | SFTP 在别的目录 | 列表仍以终端 cwd 为准;不强制改 SFTP 的手动位置 |
| DIR-22 | SSH 额外通道受限/provider 无能力 | 明确不支持,不隐式新建登录到其他目标 |
## 9. 待确定的功能范围
1. 本地 POSIX 与普通 SSH POSIX 是否同批交付;建议先验证既有就绪 reader 和严格 cwd 回报,再决定。
2. 隐藏目录开关是否首期包含;推荐包含以便进入 `.config` 等常见目录。
3. 对只读会话采用禁用整个入口还是允许只读列举;推荐允许浏览、禁用激活并展示原因。
4. 最小高度 104px 与默认 144px 是否适合现有撰写栏;使用真实 UI 截图核定。
核心交互保持“单击目录即时切换并关闭”。
参考:[Warp prompt](https://docs.warp.dev/terminal/appearance/prompt/);[2025 更新日志](https://docs.warp.dev/changelog/2025/)中的目录搜索记录。
@@ -1,34 +0,0 @@
# Git Branch:分支显示与切换
日期:2026-10-29。状态:初步调研,切换行为尚待验证。
## 功能目标
在当前终端位于 Git 仓库时显示分支图标和名称,点击弹出可搜索的分支列表,选择后切换该终端所对应仓库/worktree 的分支。非仓库时隐藏;detached HEAD 显示短提交哈希。长名称在芯片内截断,菜单提供完整名称。
## Warp 参考行为
已观察到分支搜索框、完整分支条目和整行高亮;芯片采用前部省略、保留尾部的展示。官方更新日志还包含 detached HEAD 展示短哈希及创建新分支入口。尚未亲自验证候选分支范围、选择后的关闭时机和切换结果。
## 拟议交互
- 点击芯片打开上方菜单,搜索框获焦;菜单样式与目录选择器一致。
- 当前分支有明确标记;上下键移动活动项,Enter 选择,Escape 关闭。
- 切换动作绑定当前 session 与 worktree,不参与终端广播,也不发送或清空命令草稿。
- 只有实际分支状态确认后才更新芯片;失败保留当前分支并显示原因。
- 遇到未提交改动冲突,不隐式 stash 或丢弃变更。创建分支、远程跟踪分支是否包含仍待确定。
## 待查清的行为
| 主题 | 问题 |
| --- | --- |
| 分支列表 | 本地/远程候选范围、排序、搜索规则、当前项标识 |
| 切换动作 | 执行方式、菜单关闭时机、结果确认及失败反馈 |
| 特殊状态 | 空仓库、unborn branch、detached HEAD、分支被删除 |
| worktree | `.git` 为文件、另一 worktree 占用目标分支、不同终端指向不同 worktree |
| 环境 | local/SSH、Git 缺失、权限或锁文件、忙碌终端时的能力限制 |
| 新建分支 | 入口、名称校验、起点与确认过程;是否纳入本期 |
仓库识别与刷新可和 [Git Changes](git-changes.md) 共用。切 cwd/会话或重连后,旧分支列表和切换结果必须失效。
参考:[Warp prompt](https://docs.warp.dev/terminal/appearance/prompt/);[2025 changelog](https://docs.warp.dev/changelog/2025/)的分支搜索;[2026 changelog](https://docs.warp.dev/changelog/2026/)的创建分支记录;[2023 changelog](https://docs.warp.dev/changelog/2023/)的 detached HEAD 展示。
@@ -1,38 +0,0 @@
# Git Changes:变更统计与面板入口
日期:2026-10-29。状态:初步调研;详细变更面板暂不实现。
## 功能目标
在当前终端位于 Git 仓库时显示改动文件数与增删行。清洁状态可显示 `± 0`;存在变化时展示如 `3 files · +24 -8` 的摘要。点击打开该仓库的变更文件面板是后续目标,本期只保留数据与动作边界。
## Warp 参考行为
本机观察到清洁状态的 `± 0`。官方说明变更芯片可显示文件数与增删行,点击打开 Code Review;面板默认位于右侧,可查看未提交变化,也支持与 main/master 或其他分支比较。尚未验证 staged、unstaged、未跟踪及二进制文件的实际计数。
## 拟议统计口径
- 初步建议展示**相对 HEAD 的未提交净变化**,与“当前分支相对 main 的差异”分开。
- 已暂存与未暂存变化不能简单相加,避免同一文件重复计数或抵消关系错误。
- 分别保留文件数、增加行、删除行;重命名、二进制与未跟踪状态需要明确表达。
- 二进制无法计行时不以 `0` 冒充无变化;没有统计能力或读取失败也不显示假 `± 0`。
- 统计查询按 repo/worktree 缓存,随 cwd、分支和文件变化刷新;详细 diff 在面板打开后单独加载。
以上是 Netcatty 的候选口径,需用受控文件改动核对后确定。
## 侧栏预留
统计模型至少区分比较基线、文件数、增删行、二进制和未跟踪状态。打开动作绑定 session、连接代次、repoRoot 和 worktreeRoot,例如 `openChangesPanel(target)`;真正实现时接入现有终端侧栏布局,避免不同终端共用错误仓库。
面板未实现时不展示可点击的虚假入口。文件列表、diff 编辑、stage、revert、discard、commit 和分支比较交互不纳入当前统计功能。
## 待查清的行为
| 主题 | 问题 |
| --- | --- |
| 计数 | HEAD/index/working tree 的关系,已暂存与未暂存抵消,空仓库 |
| 文件类型 | rename、二进制、删除、未跟踪及忽略文件的计数与显示 |
| 刷新 | 外部编辑、切分支、切目录、大仓库性能、local/SSH 差异 |
| 点击 | 清洁状态是否可点击、比较基线选择、面板与芯片的同步 |
参考:[Warp Code Review](https://docs.warp.dev/code/code-review/)。共享分支上下文见 [Git Branch](git-branch.md),侧栏影响范围见 [README](README.md)。
@@ -1,35 +0,0 @@
# Node Version:版本显示与切换
日期:2026-10-29。状态:初步调研,尚未形成实施规格。总体目标与架构影响见 [README](README.md)。
## 目标与范围
通过 **Node Version Chip** 显示当前终端实际使用的 Node 版本。版本管理器发现、可用版本列表、切换当前 shell 版本是额外能力,应分别确定范围,不能用 Electron 主进程的 Node 版本替代用户终端版本。
## 已知参考行为
Warp 已有 Node 版本芯片,官方 2025-09-10 更新日志记录点击切换版本。本机观察到芯片显示 `v26.13.0`,菜单却提示没有已安装版本,并提供 `nvm install node` 入口。菜单为空不能解释为“系统没有 Node”;当前 PATH 中可用的版本与管理器可管理的列表需要分开建模。
本机安装包的只读资源检查支持 nvm 管理的菜单线索,以及在启用芯片时按目录与 PATH 缓存版本检测的脚本路径。官方 2026-06-10 也记录了避免每次 prompt 启动 Node 的缓存修复。静态线索不等于已验证实际切换,也不能据此断言所有平台/管理器的行为。
## 待补充调研
| 主题 | 需确认 |
| --- | --- |
| 显示条件 | package.json、祖先目录、无 Git 项目、worktree、Node 缺失 |
| 版本列表 | 多版本排序、当前项、别名、没有可用版本的状态 |
| 切换流程 | 直接执行还是填入编辑器;关闭时机;目标 shell 的版本与 PATH 确认 |
| 管理器 | nvm 不存在、未加载、非 nvm 安装;首个支持范围 |
| 安装入口 | 命令按钮实际行为、确认过程与失败反馈 |
| 刷新 | cd、PATH 变化、外部版本切换、缓存失效与性能 |
| 环境 | bash/zsh/fish/PowerShell、local/SSH 的差异 |
## 功能范围待确定
至少验证一次“列表 → 选择 → 当前交互 shell 版本确认”,以及无管理器、空列表和失败状态。可先只做展示,再增加 nvm;fnm、Volta、asdf、mise 等均需独立调研。
共享提供器应区分 `activeVersion`、`managerKind`、`installedVersions` 和切换能力。
参考:[Warp prompt](https://docs.warp.dev/terminal/appearance/prompt/);[2026 changelog](https://docs.warp.dev/changelog/2026/)。
Node 版本切换另见 [2025 changelog](https://docs.warp.dev/changelog/2025/)。
@@ -1,33 +0,0 @@
# Pull Request:关联 PR 显示与查看
日期:2026-10-29。状态:初步调研,尚未形成实施规格。总体目标与架构影响见 [README](README.md)。
## 目标与范围
通过 **Pull Request Chip** 显示当前仓库/分支关联的 PR,并提供明确查看动作。创建、评论、提交、合并和 CI 操作不自动纳入。依赖 Git 上下文和远程查询,但联网失败不应阻塞目录导航或本地 Git 展示。
## 已知参考行为
本机 Warp 芯片行显示过 `PR #4056`。官方 2026-03-25 的更新日志记录:AI 输入模式中,当前分支有开放 GitHub PR 时可显示 PR 芯片。这个记录不能直接泛化为所有输入模式、平台或代码托管服务。
具体点击落点、认证来源、关联查询与多个候选的处理尚未验证。点击浏览器打开是可讨论的 Netcatty 方案,尚不能写成已确认的 Warp 行为。
## 待补充调研
| 主题 | 需确认 |
| --- | --- |
| 出现条件 | 无 PR、draft/open/closed/merged、状态过期 |
| 点击 | 浏览器、菜单或内嵌面板;hover 详情与 URL |
| 关联 | 多 remote、fork、同名分支、多个 PR、不同 base 与跨仓库 PR |
| 服务与身份 | GitHub/Enterprise/GitLab 的实际支持,私有仓库和认证边界 |
| 查询来源 | 参考产品可确认的机制与 Netcatty 可复用适配,不靠文案猜 API |
| 失败与刷新 | 离线、未登录、限流、切分支/目录、重连、缓存与旧结果失效 |
| 环境 | local/SSH 使用哪一侧身份,provider 不支持的反馈 |
## 功能范围待确定
先能明确说明“这个 PR 为什么属于当前终端的这个仓库和分支”,并验证点击与常见失败路径。未查询到、没有关联和查询失败应分开呈现。
慢请求和身份切换不能污染另一个终端;共享仓库/会话边界见 [README](README.md)。
参考:[Warp prompt](https://docs.warp.dev/terminal/appearance/prompt/);[2026 changelog](https://docs.warp.dev/changelog/2026/)。
+3 -26
View File
@@ -99,26 +99,6 @@ const OH_MY_ZSH_YS_HOST_LINE_PATTERN = /^#\s+[^\s@]+\s+@\s+[^\s]+(?:\s+[^\n\r]+)
// within the path so partially entered commands do not count as idle prompts.
const ALPINE_PROMPT_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]*:(?:~(?:\/[^#$\n\r]*)?|\/[^#$\n\r]*)[#$]$/;
// Powerlevel10k / similar Unicode prompt-ending ❯ (U+276F). Common idle forms:
// `~ ❯`, `~/path ❯`, `/tmp ❯`, short-dir `proj ❯`, optional `user@host `
// prefix (`alice@host ~ ❯`). Require a non-empty prefix and whitespace before
// ❯ so pending input (`~ ❯ echo READY`, `❯ vim`) cannot match. Bare `❯` alone
// is intentionally rejected — too ambiguous without a path/host prefix
// (same rationale as bare `$`/`#`). Do not attempt two-line bare-❯ here.
const POWERLEVEL10K_PROMPT_PATTERN =
/^(?:[^\s@]+@[^\s:]+\s+)?[^\s❯][^❯\r\n]*\s+❯$/;
// Oh My Zsh default theme (robbyrussell): leading ➜ (U+279C) then cwd (%c),
// optionally ` git:(branch)` and a dirty/clean marker. Idle forms look like
// `➜ ~`, `➜ netcatty`, `➜ ~ git:(main)`, `➜ ~ git:(main) ✗`.
// The theme emits two spaces between ➜ and %c (`➜ ` + ` %c`); require that
// so one-space pending input like `➜ ls` cannot match. Also accept the common
// single-space home form `➜ ~` (+ optional git). Only the optional git
// decoration may follow the cwd — `➜ ~ ls` / `➜ ~ echo hi` fail.
// Do not broaden to arbitrary starship themes.
const OH_MY_ZSH_ROBBYRUSSELL_PROMPT_PATTERN =
/^➜(?:\s{2,}[^\s➜][^\s➜]*|\s~)(?:\s+git:\([^)\r\n]+\))?(?:\s+[✗✓])?$/;
function isDefaultPowerShellPromptLine(line) {
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
}
@@ -137,9 +117,7 @@ function isDefaultPosixPromptLine(line) {
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
PAREN_POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix) ||
ZSH_PROMPT_PATTERN.test(withoutEnvPrefix) ||
POWERLEVEL10K_PROMPT_PATTERN.test(withoutEnvPrefix) ||
OH_MY_ZSH_ROBBYRUSSELL_PROMPT_PATTERN.test(withoutEnvPrefix)
ZSH_PROMPT_PATTERN.test(withoutEnvPrefix)
);
}
@@ -256,9 +234,8 @@ function trackSessionIdlePrompt(session, chunk) {
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// `user@host[:path][#$]`, two-line classic `user@host[:path]` or Oh My Zsh
// `ys` (`# user @ host ...`) + `$`/`#`, RHEL `[user@host path][#$]`,
// Alpine `host:path[#$]`, Powerlevel10k-style `... ❯`, or Oh My Zsh
// robbyrussell `➜ …`);
// `ys` (`# user @ host ...`) + `$`/`#`, RHEL `[user@host path][#$]`, or
// Alpine `host:path[#$]`);
// a remote shell switch into another shell, an oh-my-posh / starship / custom
// PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
-123
View File
@@ -261,129 +261,6 @@ test("extracts an Alpine idle prompt while preserving its trailing whitespace",
assert.equal(extractTrailingIdlePrompt("localhost:~# \r\n"), "");
});
test("isDefaultPosixPromptLine matches Powerlevel10k ❯ idle prompts", () => {
for (const prompt of [
"~ ❯",
"~ ❯ ",
"~/proj ❯",
"~/git/Netcatty ❯ ",
"/tmp ❯",
"proj ❯",
"alice@host ~ ❯",
"alice@host ~/src ❯ ",
"(venv) ~ ❯ ",
"(venv) alice@host ~ ❯",
]) {
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
}
});
test("Powerlevel10k ❯ recognition rejects pending input and bare ❯", () => {
for (const line of [
"❯",
"❯ ",
"❯ vim",
"~ ❯ echo READY",
"~ ❯echo",
"alice@host ~ ❯ ls",
"proj ❯ vim",
"(venv) ~ ❯ echo hi",
// Must not treat a lone marker as idle (same rationale as bare $/).
]) {
assert.equal(isDefaultPosixPromptLine(line), false, line);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
}
});
test("extractTrailingIdlePrompt recognizes Powerlevel10k ❯ and preserves trailing space", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\n~ ❯"), "~ ❯");
assert.equal(extractTrailingIdlePrompt("welcome\r\n~ ❯ "), "~ ❯ ");
assert.equal(
extractTrailingIdlePrompt("Last login...\r\nalice@host ~/src ❯ "),
"alice@host ~/src ❯ ",
);
assert.equal(extractTrailingIdlePrompt("welcome\r\nproj ❯"), "proj ❯");
assert.equal(extractTrailingIdlePrompt("(venv) ~ ❯ "), "(venv) ~ ❯ ");
assert.equal(extractTrailingIdlePrompt("welcome\r\n~ ❯ echo READY"), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n❯ "), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n~ ❯\r\n"), "");
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept a Powerlevel10k ❯ prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "Last login...\r\n~ ❯ ");
assert.equal(session.lastIdlePrompt, "~ ❯ ");
assert.equal(getFreshIdlePrompt(session), "~ ❯ ");
assert.equal(getVisibleIdlePrompt(session), "~ ❯ ");
trackSessionIdlePrompt(session, "printf busy");
assert.equal(getFreshIdlePrompt(session), "");
assert.equal(getVisibleIdlePrompt(session), "");
trackSessionIdlePrompt(session, "\r\nbusy\r\nalice@host ~/src ❯ ");
assert.equal(getFreshIdlePrompt(session), "alice@host ~/src ❯ ");
assert.equal(getVisibleIdlePrompt(session), "alice@host ~/src ❯ ");
});
test("isDefaultPosixPromptLine matches Oh My Zsh robbyrussell ➜ idle prompts", () => {
for (const line of [
"➜ ~",
"➜ ~ ",
"➜ ~",
"➜ netcatty",
"➜ ~/proj",
"➜ ~ git:(main)",
"➜ ~ git:(main) ",
"➜ ~ git:(main) ✗",
"➜ netcatty git:(main) ✓",
"(venv) ➜ ~ ",
"(venv) ➜ ~ git:(main) ✗",
]) {
assert.equal(isDefaultPosixPromptLine(line), true, JSON.stringify(line));
}
});
test("Oh My Zsh ➜ recognition rejects pending input and bare ➜", () => {
for (const line of [
"➜",
"➜ ",
"➜ ~ ls",
"➜ ~ echo hi",
"➜ netcatty vim",
"➜ ls",
"➜ ~ git status",
"(venv) ➜ ~ echo hi",
]) {
assert.equal(isDefaultPosixPromptLine(line), false, JSON.stringify(line));
}
});
test("extractTrailingIdlePrompt recognizes Oh My Zsh ➜ and preserves trailing space", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\n➜ ~"), "➜ ~");
assert.equal(extractTrailingIdlePrompt("welcome\r\n➜ ~ "), "➜ ~ ");
assert.equal(
extractTrailingIdlePrompt("Last login...\r\n➜ ~ git:(main) ✗"),
"➜ ~ git:(main) ✗",
);
assert.equal(extractTrailingIdlePrompt("(venv) ➜ ~ "), "(venv) ➜ ~ ");
assert.equal(extractTrailingIdlePrompt("welcome\r\n➜ ~ ls"), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n➜ "), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n➜ ~\r\n"), "");
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oh My Zsh ➜ prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "Last login...\r\n➜ ~ ");
assert.equal(session.lastIdlePrompt, "➜ ~ ");
assert.equal(getFreshIdlePrompt(session), "➜ ~ ");
assert.equal(getVisibleIdlePrompt(session), "➜ ~ ");
trackSessionIdlePrompt(session, "\r\nbusy\r\n➜ ~ git:(main) ✗");
assert.equal(getFreshIdlePrompt(session), "➜ ~ git:(main) ✗");
assert.equal(getVisibleIdlePrompt(session), "➜ ~ git:(main) ✗");
});
test("extracts a trailing RHEL-family bracketed idle prompt", () => {
// Oracle Linux / RHEL / CentOS / Fedora default PS1 is `[\u@\h \W]\$ `.
// Trailing space must round-trip, same as the PowerShell prompt capture.
+11 -37
View File
@@ -395,21 +395,12 @@ function init(deps) {
transferBridge = deps.transferBridge;
}
/**
* Resolve Windows tar before switching to an upload-controlled directory.
*/
function resolveLocalTarCommand() {
return process.platform === 'win32'
? path.join(process.env.SystemRoot || process.env.windir || 'C:\\Windows', 'System32', 'tar.exe')
: 'tar';
}
/**
* Check if tar command is available on the system
*/
async function checkTarAvailable(signal) {
return new Promise((resolve) => {
const tar = spawn(resolveLocalTarCommand(), ['--version'], { stdio: 'ignore' });
const tar = spawn('tar', ['--version'], { stdio: 'ignore' });
let settled = false;
let childClosed = false;
let forceKillTimer = null;
@@ -493,41 +484,24 @@ async function compressFolder(folderPath, outputPath, compressionId, sendProgres
}
// Use tar with gzip compression, excluding macOS resource fork files
// -czf -: create, gzip, write to stdout (the opened output file)
// cwd: run in the parent directory (so we don't include the full path in archive)
// -czf: create, gzip, file
// -C: change to directory (so we don't include the full path in archive)
// --exclude='._*': exclude macOS resource fork files
// --exclude='.DS_Store': exclude macOS folder metadata files
// Windows tar.exe converts argv to the ANSI code page, so local paths are
// passed via cwd and a file descriptor instead of -C and -f. Explicitly
// exclude the archive when it lies inside the source tree.
const folderName = path.basename(folderPath);
const parentDir = path.dirname(folderPath);
let outputFd;
try {
outputFd = fs.openSync(outputPath, 'w');
} catch (err) {
reject(new Error(`Failed to create tar archive: ${err.message}`));
return;
}
const tarArgs = [
'-czf', '-',
const tar = spawn('tar', [
'-czf', outputPath,
'-C', parentDir,
'--exclude=._*',
'--exclude=.DS_Store',
'--exclude=.Spotlight-V100',
'--exclude=.Trashes'
];
const relativeOutputPath = path.relative(folderPath, outputPath);
if (relativeOutputPath !== '..' && !relativeOutputPath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeOutputPath)) {
tarArgs.push(`--exclude=${path.relative(parentDir, outputPath).split(path.sep).join('/')}`);
}
tarArgs.push(folderName);
const tar = spawn(resolveLocalTarCommand(), tarArgs, {
cwd: parentDir,
stdio: ['ignore', outputFd, 'pipe']
'--exclude=.Trashes',
folderName
], {
stdio: ['ignore', 'pipe', 'pipe']
});
// tar holds its own handle; close ours so the archive can be removed later.
fs.closeSync(outputFd);
compression.process = tar;
const stderr = createBoundedUtf8Collector(MAX_LOCAL_TAR_STDERR_BYTES);
@@ -1,131 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const test = require("node:test");
const localTarCommand = process.platform === "win32"
? path.join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", "tar.exe")
: "tar";
// The bridge destructures spawn and getTempFilePath at load time, so record
// tar calls and redirect temp archives before requiring it.
let tempDir = null;
const spawnCalls = [];
const realSpawn = childProcess.spawn;
const originalLoad = Module._load;
childProcess.spawn = (command, args, options) => {
spawnCalls.push({ command, args, options });
return realSpawn(command, args, options);
};
Module._load = function load(request, parent, isMain) {
if (request === "./tempDirBridge.cjs") {
return { getTempFilePath: (fileName) => path.join(tempDir, `1_1_${fileName}`) };
}
return originalLoad.call(this, request, parent, isMain);
};
const compressUploadBridge = require("./compressUploadBridge.cjs");
childProcess.spawn = realSpawn;
Module._load = originalLoad;
function createFolder(t, parentName) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-compress-folder-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const folderPath = path.join(root, parentName, "folder");
fs.mkdirSync(folderPath, { recursive: true });
fs.writeFileSync(path.join(folderPath, "file.txt"), "payload");
return { root, folderPath };
}
async function compressAndList(folderPath) {
let archiveEntries = null;
const handlers = new Map();
compressUploadBridge._resetCompressionSupportCacheForTests();
compressUploadBridge.init({
sftpClients: new Map([["sftp-folder", {
client: {
writable: true,
exec(command, callback) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
stream.end = () => {};
stream.destroy = () => {};
callback(null, stream);
process.nextTick(() => {
if (command === "tar --version") stream.emit("data", Buffer.from("tar"));
stream.emit("close", 0);
});
},
},
__netcattyEndpointKey: "endpoint:folder",
}]]),
transferBridge: {
acquireTransferSessionLeases(_leaseId, payload) { return [payload.targetSftpId]; },
releaseTransferSessionLeases() {},
broadcastGlobalTransferEvent() {},
async startInternalTransfer(_event, payload) {
// Read the archive through stdin so its path never reaches tar argv.
archiveEntries = childProcess.execFileSync("tar", ["-tzf", "-"], {
input: fs.readFileSync(payload.sourcePath),
encoding: "utf8",
})
.split(/\r?\n/)
.filter(Boolean)
.map((entry) => entry.replace(/\/$/, ""))
.sort();
return { success: true };
},
async cancelTransfer() { return { success: true }; },
},
});
compressUploadBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
spawnCalls.length = 0;
const result = await handlers.get("netcatty:compress:start")({ sender: { id: 1, send() {} } }, {
compressionId: "compress-folder",
folderPath,
targetPath: "/srv",
sftpId: "sftp-folder",
folderName: "folder",
totalBytes: 7,
});
const tarCall = spawnCalls.find((call) => call.command === localTarCommand && call.args.includes("-czf"));
return { result, archiveEntries, tarCall };
}
test("local tar gets its paths through cwd and stdout instead of argv", async (t) => {
// Non-ASCII parent and temp directories, as with a Chinese Windows user name.
const { root, folderPath } = createFolder(t, "下载目录");
tempDir = path.dirname(folderPath);
const { result, archiveEntries, tarCall } = await compressAndList(folderPath);
assert.equal(result.success, true);
assert.equal(spawnCalls.find((call) => call.args.includes("--version"))?.command, localTarCommand);
assert.ok(tarCall, "local tar compression must be spawned");
assert.equal(tarCall.options.cwd, path.dirname(folderPath));
assert.equal(tarCall.args.includes("-C"), false, "parent directory must not be passed through argv");
assert.deepEqual(tarCall.args.slice(0, 2), ["-czf", "-"]);
assert.equal(typeof tarCall.options.stdio[1], "number", "the archive file must be tar's stdout");
assert.equal(tarCall.args.some((arg) => arg.includes(root)), false, "no local path may reach tar argv");
assert.equal(tarCall.args.at(-1), "folder");
assert.deepEqual(archiveEntries, ["folder", "folder/file.txt"]);
});
test("an archive written inside the source folder is excluded from itself", async (t) => {
const { folderPath } = createFolder(t, "parent");
tempDir = path.join(folderPath, "tmp");
fs.mkdirSync(tempDir);
const { result, archiveEntries, tarCall } = await compressAndList(folderPath);
assert.equal(result.success, true);
assert.ok(tarCall.args.includes("--exclude=folder/tmp/1_1_folder.tar.gz"));
assert.deepEqual(archiveEntries, ["folder", "folder/file.txt", "folder/tmp"]);
});
+2 -3
View File
@@ -1466,15 +1466,14 @@ function registerHandlers(ipcMain) {
});
ipcMain.handle("netcatty:trayPanel:startPortForward", async (_event, ruleId) => {
const win = await openMainWindowReady();
if (!win) return { success: false, error: "Main window is not available" };
if (isAppRuntimeLocked()) {
const win = await openMainWindowReady();
if (!win) return { success: false, error: "Main window is not available" };
sendOrQueuePortForwardToggle(win, ruleId, true);
return { success: true };
}
const delivered = await sendToMainWindow("netcatty:trayPanel:startPortForward", ruleId, {
focus: false,
createIfMissing: true,
});
return delivered
? { success: true }
+1 -71
View File
@@ -14,7 +14,7 @@ const { PluginAgentMcpDelegations } = require("../plugins/agent/mcpDelegation.cj
const pluginAgentDelegations = new PluginAgentMcpDelegations();
const { existsSync } = require("node:fs");
const { toUnpackedAsarPath, getFreshIdlePrompt, getVisibleIdlePrompt, formatSyntheticEcho } = require("./ai/shellUtils.cjs");
const { toUnpackedAsarPath, getFreshIdlePrompt, formatSyntheticEcho } = require("./ai/shellUtils.cjs");
const { appendVaultAgentGuidance } = require("../shared/vaultAgentGuidance.cjs");
const {
checkBlocklistForShell,
@@ -804,7 +804,6 @@ function updateLiveSessionMetadata(sessionList) {
const next = new Map();
for (const entry of incoming) {
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
if (entry.connected === true) sessionService?.forgetReconnect(entry.sessionId);
next.set(entry.sessionId, {
hostname: entry.hostname || "",
label: entry.label || "",
@@ -1272,8 +1271,6 @@ function ensureOpenedSessionMetadata(chatSessionId, sessionId, result) {
*/
function forgetClosedTerminalSession(sessionId) {
if (!sessionId) return;
sessionService?.forgetReconnect(sessionId);
sessionService?.forgetRecovery(sessionId);
terminalSessionCloseRevision += 1;
closedTerminalSessionRevisions.delete(sessionId);
closedTerminalSessionRevisions.set(sessionId, terminalSessionCloseRevision);
@@ -1617,74 +1614,7 @@ function reportOpenedSessionActivity(event = {}) {
return sessionIdleManager.touch(null, sessionId);
}
// In the default terminal worker path the SSH runtime (and its prompt
// tracking state) lives in the worker, so the main-process `sessions` map is
// empty for it. Ask the owning worker for read-only prompt readiness. A miss
// or a worker failure yields null: an unknown state must never report ready.
// The probe is bounded well below the 20s recovery budget so a live-but-
// unresponsive worker cannot hang inspectRecovery past the recovery loop's
// deadline/cancel machinery.
const WORKER_SHELL_READINESS_TIMEOUT_MS = 2000;
async function queryWorkerShellReadiness(sessionId, status) {
if (status !== "connected" || !terminalWorkerManager?.request) return null;
// terminalWorkerManager.request has no cancellation support, so the probe
// cannot be aborted once posted. Race it against a timeout instead: a late
// worker response settles into an abandoned promise (fire-and-forget) and
// never mutates state after the timeout decision.
let timeoutId;
try {
const result = await Promise.race([
terminalWorkerManager.request("netcatty:ai:shellReadiness", { sessionId }, {}),
new Promise((resolve) => {
timeoutId = setTimeout(
() => resolve(null),
WORKER_SHELL_READINESS_TIMEOUT_MS,
);
}),
]);
if (result?.ok !== true || result.found !== true) return null;
return {
trackedShell: result.trackedShell === true,
ready: Boolean(result.freshIdlePrompt || result.visibleIdlePrompt),
};
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
sessionService = createSessionService({
validateReconnect: (params = {}) => {
const scopeErr = validateSessionScope(params.sessionId, params.chatSessionId, params.scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
if (isChatSessionCancelled(params.chatSessionId)) return { ok: false, error: "Chat session is cancelled." };
if (permissionMode === "observer") return { ok: false, error: "Recovery is unavailable in observer mode." };
if (sessionIdleManager.isClosing(params.sessionId)) return { ok: false, error: "Session is closing." };
return { ok: true };
},
inspectRecovery: async (params, timeoutMs) => {
if (typeof invokeVaultAgentFn !== "function") return { ok: false, error: "Session recovery bridge is unavailable." };
sessionIdleManager.touch(null, params.sessionId);
const state = await invokeVaultAgentFn("session.inspectRecovery", { sessionId: params.sessionId }, { timeoutMs });
if (state?.ok !== true) return state;
const runtime = sessions?.get(params.sessionId);
const meta = getSessionMeta(params.sessionId, params.chatSessionId);
const probe = runtime ? {
trackedShell: typeof runtime._promptTrackTail === "string",
ready: Boolean(getFreshIdlePrompt(runtime) || getVisibleIdlePrompt(runtime)),
} : await queryWorkerShellReadiness(params.sessionId, state.status);
const trackedShell = Boolean(probe?.trackedShell) && !isNetworkDeviceLikeMeta(meta);
// shellReady semantics: transport not connected → false; connected but
// the shell is untracked or the probe missed/timed out → null (unknown);
// a tracked shell reports its observed readiness. It is advisory for
// callers: recovery never writes to the PTY, so an unknown or false
// readiness only asks for an inspect_shell step, never blocks.
const shellReady = state.status !== "connected" ? false : !probe ? null : trackedShell
? Boolean(probe.ready) : null;
return { ...state, shellReady, busy: Boolean(getSessionBusyError(params.sessionId)) };
},
invokeSessionAgent: (...args) => {
if (typeof invokeVaultAgentFn !== "function") {
return Promise.resolve({ ok: false, error: "Vault agent bridge is unavailable." });
@@ -1,185 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { isolateCliDiscoveryFile } = require("./cliDiscoveryTestIsolation.cjs");
isolateCliDiscoveryFile();
test("live bridge reconnect requires scope, respects cancellation and never grants close ownership", async (t) => {
require("../shared/aiPrivacyService.cjs").configure({ enabled: false });
const bridge = require("./mcpServerBridge.cjs");
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
let calls = 0;
bridge.setVaultAgentInvoker(async (op, params) => {
assert.equal(op, "session.reconnect");
assert.equal(params.sessionId, "s");
calls++;
return { ok: true, started: true, sessionId: "s", status: "reconnecting" };
});
bridge.updateSessionMetadata([{ sessionId: "s", connected: false, protocol: "ssh" }], "chat");
assert.equal((await bridge.dispatchBuiltinRpc("session/reconnect", { sessionId: "s", chatSessionId: "other" })).ok, false);
assert.equal(calls, 0);
const result = await bridge.dispatchBuiltinRpc("session/reconnect", { sessionId: "s", chatSessionId: "chat" });
assert.equal(result.started, true);
assert.equal(calls, 1);
const close = await bridge.dispatchBuiltinRpc("session/close", { sessionId: "s", chatSessionId: "chat" });
assert.equal(close.ok, false);
bridge.setChatSessionCancelled("chat", true);
assert.match((await bridge.dispatchBuiltinRpc("session/reconnect", { sessionId: "s", chatSessionId: "chat" })).error, /cancelled/i);
assert.equal(calls, 1);
});
test('root recovery observes live prompt readiness without executing commands and stops for auth', async (t) => {
const bridge = require('./mcpServerBridge.cjs');
t.after(() => bridge.cleanup());
const runtime = { protocol: 'ssh', _promptTrackTail: 'root@host:~# ', lastIdlePrompt: 'root@host:~# ' };
bridge.init({ sessions: new Map([['ready', runtime]]), electronModule: null });
bridge.setPermissionMode('auto');
bridge.updateSessionMetadata([{ sessionId: 'ready', protocol: 'ssh', connected: true }], 'recover-chat');
let inspections = 0;
let auth = false;
bridge.setVaultAgentInvoker(async (op, params, options) => {
assert.equal(op, 'session.inspectRecovery');
assert.equal(params.sessionId, 'ready');
assert.ok(options.timeoutMs <= 2000);
inspections++;
return { ok: true, status: 'connected', waitingForInput: auth };
});
assert.equal((await bridge.dispatchBuiltinRpc('session/recover', { sessionId: 'ready', chatSessionId: 'foreign' })).ok, false);
assert.equal(inspections, 0);
const result = await bridge.dispatchBuiltinRpc('session/recover', { sessionId: 'ready', chatSessionId: 'recover-chat' });
assert.equal(result.status, 'ready');
assert.equal(result.shellReady, true);
auth = true;
assert.equal((await bridge.dispatchBuiltinRpc('session/recover', { sessionId: 'ready', chatSessionId: 'recover-chat' })).status, 'needs_input');
bridge.setPermissionMode('observer');
assert.equal((await bridge.dispatchBuiltinRpc('session/recover', { sessionId: 'ready', chatSessionId: 'recover-chat' })).ok, false);
assert.equal(inspections, 2);
});
test('worker-owned SSH sessions resolve prompt readiness through the terminal worker', async (t) => {
const bridge = require('./mcpServerBridge.cjs');
t.after(() => bridge.cleanup());
const readinessRequests = [];
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
request(channel, payload) {
if (channel === 'netcatty:ai:shellReadiness') {
readinessRequests.push(payload.sessionId);
if (payload.sessionId === 'worker-ready') {
return Promise.resolve({
ok: true,
found: true,
trackedShell: true,
freshIdlePrompt: 'root@host:~# ',
visibleIdlePrompt: 'root@host:~# ',
});
}
if (payload.sessionId === 'worker-false') {
return Promise.resolve({ ok: true, found: true, trackedShell: true, freshIdlePrompt: '', visibleIdlePrompt: '' });
}
if (payload.sessionId === 'worker-untracked') {
return Promise.resolve({ ok: true, found: true, trackedShell: false, freshIdlePrompt: '', visibleIdlePrompt: '' });
}
return Promise.resolve({ ok: true, found: false });
}
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
},
},
});
bridge.setPermissionMode('auto');
bridge.updateSessionMetadata([{ sessionId: 'worker-ready', protocol: 'ssh', connected: true }], 'worker-chat');
let inspections = 0;
bridge.setVaultAgentInvoker(async (op, params) => {
assert.equal(op, 'session.inspectRecovery');
assert.equal(params.sessionId, 'worker-ready');
inspections++;
return { ok: true, status: 'connected' };
});
// The SSH runtime exists only in the worker; recovery must consult it and
// return ready instead of looping on a false shellReady.
const result = await bridge.dispatchBuiltinRpc('session/recover', { sessionId: 'worker-ready', chatSessionId: 'worker-chat' });
assert.equal(result.status, 'ready');
assert.equal(result.shellReady, true);
assert.deepEqual(readinessRequests, ['worker-ready']);
// A confirmed not-idle prompt stays advisory: recovery completes with a
// hint instead of looping, since it never writes to the PTY.
bridge.updateSessionMetadata([{ sessionId: 'worker-false', protocol: 'ssh', connected: true }], 'worker-chat');
bridge.setVaultAgentInvoker(async (op, params) => {
assert.equal(op, 'session.inspectRecovery');
inspections++;
return { ok: true, status: 'connected' };
});
const unconfirmed = await bridge.dispatchBuiltinRpc('session/recover', { sessionId: 'worker-false', chatSessionId: 'worker-chat' });
assert.equal(unconfirmed.status, 'ready');
assert.equal(unconfirmed.shellReady, false);
assert.equal(unconfirmed.nextAction, 'inspect_shell');
// An untracked worker shell stays unknown (null), never falsely ready.
bridge.updateSessionMetadata([{ sessionId: 'worker-untracked', protocol: 'ssh', connected: true }], 'worker-chat');
bridge.setVaultAgentInvoker(async (op, params) => {
assert.equal(op, 'session.inspectRecovery');
inspections++;
return { ok: true, status: 'connected' };
});
const untracked = await bridge.dispatchBuiltinRpc('session/recover', { sessionId: 'worker-untracked', chatSessionId: 'worker-chat' });
assert.equal(untracked.status, 'ready');
assert.equal(untracked.shellReady, null);
assert.equal(untracked.nextAction, 'inspect_shell');
assert.equal(inspections, 3);
});
test('a hung worker readiness probe times out and recovery completes with an advisory hint', async (t) => {
const bridge = require('./mcpServerBridge.cjs');
t.after(() => bridge.cleanup());
// Synthetic regression for the unresolved P2: a live-but-unresponsive
// worker left `await terminalWorkerManager.request` pending forever, so
// recovery hung inside ctx.inspect and never reached its deadline or
// released the session's running lock. The probe must be bounded instead.
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
request(channel) {
if (channel === 'netcatty:ai:shellReadiness') {
// Never settles, like a hung-but-live worker process.
return new Promise(() => {});
}
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
},
},
});
bridge.setPermissionMode('auto');
bridge.updateSessionMetadata([{ sessionId: 'worker-hang', protocol: 'ssh', connected: true }], 'hang-chat');
let inspections = 0;
bridge.setVaultAgentInvoker(async (op, params) => {
assert.equal(op, 'session.inspectRecovery');
assert.equal(params.sessionId, 'worker-hang');
inspections++;
return { ok: true, status: 'connected' };
});
const startedAt = Date.now();
const result = await bridge.dispatchBuiltinRpc('session/recover', { sessionId: 'worker-hang', chatSessionId: 'hang-chat' });
const elapsed = Date.now() - startedAt;
// The hung probe held for its timeout bound, yielded an unknown state,
// and recovery completed on the connected transport truth instead of
// looping: shellReady is an advisory null with an inspect_shell hint.
assert.equal(result.ok, true);
assert.equal(result.status, 'ready');
assert.equal(result.shellReady, null);
assert.equal(result.nextAction, 'inspect_shell');
assert.ok(elapsed >= 1900, `recovery settled too early (${elapsed}ms): probe timeout did not engage`);
assert.ok(elapsed < 15000, `recovery hung for ${elapsed}ms`);
assert.equal(inspections, 1);
// The running lock must be released: a follow-up recovery proceeds at once.
const secondStartedAt = Date.now();
const second = await bridge.dispatchBuiltinRpc('session/recover', { sessionId: 'worker-hang', chatSessionId: 'hang-chat' });
assert.equal(second.status, 'ready');
assert.equal(second.shellReady, null);
assert.ok(Date.now() - secondStartedAt < 15000, 'running lock was not released after the timeout');
assert.equal(inspections, 2);
});
@@ -60,8 +60,6 @@ const SERVICE_BINDINGS = Object.freeze({
"portforward.start": { domain: "portforward", method: "start" },
"portforward.stop": { domain: "portforward", method: "stop" },
"session.close": { domain: "session", method: "close" },
"session.reconnect": { domain: "session", method: "reconnect" },
"session.recover": { domain: "session", method: "recover" },
});
function resolveCapabilitySurface(rpcMethod) {
@@ -98,8 +96,6 @@ function createCapabilityRpcDispatcher(deps) {
const sessionService = deps.sessionService || createSessionService({
invokeSessionAgent: invokeVaultAgent,
validateClose: deps.validateSessionClose,
validateReconnect: deps.validateSessionReconnect,
inspectRecovery: deps.inspectSessionRecovery,
beforeClose: deps.beforeSessionClose,
afterClose: deps.afterSessionClose,
onClosed: deps.onSessionClosed,
@@ -134,10 +130,10 @@ function createCapabilityRpcDispatcher(deps) {
return UNROUTED;
}
// Session lifecycle operations execute against params.sessionId. host.open instead
// Only session.close executes against params.sessionId. host.open instead
// targets its requested vault host; other vault/script operations must not
// borrow an unrelated session's host grant.
let approvalTarget = ["session.close", "session.reconnect", "session.recover"].includes(capability.id)
let approvalTarget = capability.id === "session.close"
? (deps.getApprovalTarget?.(params) || null)
: null;
if (capability.id === "vault.host.open" && deps.permissionMode === PERMISSION_MODES.CONFIRM) {
@@ -33,31 +33,6 @@ function createTestDispatcher(overrides = {}) {
});
}
test('reconnect respects observer mode and denial before touching transport', async () => {
let calls = 0;
for (const mode of [PERMISSION_MODES.OBSERVER, PERMISSION_MODES.CONFIRM]) {
const dispatch = createTestDispatcher({ permissionMode: mode, evaluatePermissionWithGrants,
validateSessionReconnect: () => ({ ok: true }),
requestApprovalFromRenderer: async () => false,
invokeVaultAgent: async () => { calls++; return { ok: true }; } });
assert.equal((await dispatch('session/reconnect', { sessionId: 's', chatSessionId: 'chat' })).ok, false);
assert.equal((await dispatch('session/recover', { sessionId: 's', chatSessionId: 'chat' })).ok, false);
}
assert.equal(calls, 0);
});
test('reconnect revalidates scope after approval and supports public RPC', async () => {
let allowed = true;
let calls = 0;
const dispatch = createTestDispatcher({ evaluatePermissionWithGrants,
validateSessionReconnect: () => allowed ? { ok: true } : { ok: false, error: 'scope revoked' },
requestApprovalFromRenderer: async () => { allowed = false; return true; },
invokeVaultAgent: async () => { calls++; return { ok: true }; } });
const result = await dispatch('public/session/reconnect', { sessionId: 's', chatSessionId: 'chat' });
assert.equal(result.error, 'scope revoked');
assert.equal(calls, 0);
});
test("host open does not report success after its scope binding was revoked", async () => {
const dispatch = createTestDispatcher({
invokeVaultAgent: async () => ({ ok: true, sessionId: "opened-1" }),
@@ -1,72 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createPreloadApi } = require("../preload/api.cjs");
const terminalBridge = require("./terminalBridge.cjs");
function setup(session) {
terminalBridge.init({ sessions: new Map([["session", session]]) });
const sent = [];
const api = createPreloadApi({
ipcRenderer: {
send(channel, payload) {
sent.push({ channel, payload });
// Model Electron structured-clone IPC, including the binary flag.
terminalBridge.writeToSession({}, structuredClone(payload));
},
},
webUtils: {},
});
return { api, sent };
}
for (const transport of ["stream", "proc", "socket", "serialPort"]) {
for (const encoding of ["utf-8", "gb18030"]) {
test(`binary input preserves all byte values through preload to ${transport} (${encoding})`, () => {
const writes = [];
const { api, sent } = setup({
type: transport === "serialPort" ? "serial" : "ssh",
encoding,
[transport]: { write: (data) => writes.push(data) },
});
const bytes = Array.from({ length: 256 }, (_, i) => i);
const data = String.fromCharCode(...bytes);
api.writeToSession("session", data, { binary: true });
assert.equal(sent[0].channel, "netcatty:write");
assert.equal(sent[0].payload.binary, true);
assert.equal(sent[0].payload.data, data);
assert.equal(writes.length, 1);
assert.ok(Buffer.isBuffer(writes[0]));
assert.deepEqual([...writes[0]], bytes);
});
}
}
test("binary Telnet input preserves bytes and still doubles IAC", () => {
const writes = [];
const { api } = setup({
type: "telnet-native",
encoding: "gb18030",
telnetProtocolActive: true,
socket: { write: (data) => writes.push(data) },
});
const bytes = [0x1b, 0x5b, 0x4d, 0x20, 0xc0, 0xff, 0x0d, 0x21, 0x0a];
api.writeToSession("session", String.fromCharCode(...bytes), { binary: true });
assert.equal(writes.length, 1);
assert.ok(Buffer.isBuffer(writes[0]));
assert.deepEqual([...writes[0]], [0x1b, 0x5b, 0x4d, 0x20, 0xc0, 0xff, 0xff, 0x0d, 0x21, 0x0a]);
});
test("ordinary CJK text retains the default UTF-8 string path", () => {
const writes = [];
const { api, sent } = setup({
type: "ssh",
encoding: "utf-8",
stream: { write: (data) => writes.push(data) },
});
const text = "\u4e2d\u6587";
api.writeToSession("session", text);
assert.equal(sent[0].payload.binary, false);
assert.deepEqual(writes, [text]);
assert.deepEqual([...Buffer.from(writes[0], "utf8")], [0xe4, 0xb8, 0xad, 0xe6, 0x96, 0x87]);
});
+2 -7
View File
@@ -1734,14 +1734,9 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
// the transport's native string serialization keeps handling that case.
sessionLogStreamManager.registerSudoAutofillInput(payload.sessionId, data);
sessionLogStreamManager.registerProgrammaticCommandLogRewrite(payload.sessionId, logRewrite);
// xterm onBinary strings contain wire bytes, including values above 127.
// Convert before text charset encoding or Telnet newline normalization.
const wireInput = payload.binary === true && typeof data === 'string'
? Buffer.from(data, 'latin1')
: data;
const inputData = session.type === 'telnet-native'
? telnetProtocol.normalizeNvtNewlines(wireInput)
: wireInput;
? telnetProtocol.normalizeNvtNewlines(data)
: data;
const outgoing = encodeTerminalInput(inputData, session.encoding);
if (session.stream) {
@@ -18,8 +18,6 @@ function fakeCreateError(code, message) {
test("getCliRpcMethod resolves implemented cli commands to rpc methods", () => {
assert.equal(getCliRpcMethod(["exec"]), "netcatty/exec");
assert.equal(getCliRpcMethod(["session-reconnect"]), "session/reconnect");
assert.equal(getCliRpcMethod(["session-recover"]), "session/recover");
assert.equal(getCliRpcMethod(["attachment", "read"]), "netcatty/readAttachment");
assert.equal(getCliRpcMethod(["sftp", "list"]), "netcatty/sftp/list");
assert.equal(getCliRpcMethod(["vault", "host", "get"]), "vault/host/get");
@@ -27,16 +25,6 @@ test("getCliRpcMethod resolves implemented cli commands to rpc methods", () => {
assert.equal(getCliRpcMethod(["capabilities"]), null);
});
test('reconnect CLI requires the explicit target session', () => {
assert.deepEqual(buildCatalogCliParams('session.reconnect', { sessionId: 's' }, fakeCreateError), { sessionId: 's' });
assert.throws(() => buildCatalogCliParams('session.reconnect', {}, fakeCreateError), /session/i);
});
test('recovery CLI requires an existing explicit session', () => {
assert.deepEqual(buildCatalogCliParams('session.recover', { sessionId: 's' }, fakeCreateError), { sessionId: 's' });
assert.throws(() => buildCatalogCliParams('session.recover', {}, fakeCreateError), /session/i);
});
test("listCliCapabilities returns implemented commands by default", () => {
const entries = listCliCapabilities();
assert.ok(entries.some((entry) => entry.id === "terminal.execute"));
-40
View File
@@ -139,46 +139,6 @@ const META_CAPABILITIES = [
cli: { command: ["session"] },
},
},
{
id: "session.reconnect",
domain: "session",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Request reconnect of a disconnected session in the current scope, preserving its tab and workspace. Does not restart connected sessions or replay commands. Returns acknowledgement, not shell readiness. Recovery is limited to three attempts with 2/5 second backoff in a 120 second incident; inspect authentication or exhausted recovery instead of retrying indefinitely.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
global: { rpcMethod: "session/reconnect" },
public: { rpcMethod: "public/session/reconnect", mcpTool: "session_reconnect" },
cli: { command: ["session-reconnect"] },
},
},
{
id: "session.recover",
domain: "session",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Inspect and recover a scoped disconnected session in place. Automatically applies bounded reconnect/backoff for up to 20 seconds per call and reports ready only once the transport is restored (still-offline budgets return pending); shellReady is advisory (true/false/null) and, when unconfirmed, nextAction \"inspect_shell\" asks you to verify the prompt before typing. Stops for authentication, busy jobs, cancellation or unavailable state. Never creates tabs or executes/replays commands.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
global: { rpcMethod: "session/recover" },
public: { rpcMethod: "public/session/recover", mcpTool: "session_recover" },
cli: { command: ["session-recover"] },
},
},
{
id: "session.close",
domain: "session",
@@ -117,20 +117,6 @@ test("session_close is exposed to agents and external MCP clients", () => {
assert.equal(cattyTool.rpcMethod, "session/close");
});
test('session reconnect is exposed to both agents without cleanup permission bypasses', () => {
const spec = listCattyToolSpecs().find(tool => tool.toolName === 'session_reconnect');
assert.equal(spec?.rpcMethod, 'session/reconnect');
const mcp = listMcpTools().find(tool => tool.mcpTool === 'session_reconnect');
assert.equal(mcp?.publicRpcMethod, 'public/session/reconnect');
assert.equal(mcp?.policy.bypassesApproval, false);
assert.equal(mcp?.policy.bypassesObserverBlock, false);
assert.equal(mcp?.policy.bypassesChatCancel, false);
const recover = listCattyToolSpecs().find(tool => tool.toolName === 'session_recover');
assert.equal(recover?.rpcMethod, 'session/recover');
const recoverMcp = listMcpTools().find(tool => tool.mcpTool === 'session_recover');
assert.equal(recoverMcp?.policy.bypassesApproval, false);
});
test("host_open tells agents to close sessions after use", () => {
const hostOpen = listMcpTools().find((tool) => tool.mcpTool === "host_open");
assert.match(hostOpen?.description || "", /session_close/i);
@@ -21,12 +21,6 @@ const TOOL_INPUT_FIELDS = Object.freeze({
jobId: { type: "string", description: "The background job ID returned by terminal_start." },
},
"session.environment": {},
"session.reconnect": {
sessionId: { type: "string", description: "An existing disconnected terminal session ID in the current granted scope." },
},
"session.recover": {
sessionId: { type: "string", description: "The existing terminal session ID in the current granted scope. Recovery preserves this session and never replays commands." },
},
"session.close": {
sessionId: { type: "string", description: "The session ID returned by host_open." },
},
@@ -1,114 +0,0 @@
"use strict";
/** Short, checkpointed recovery. Never executes commands or creates terminals. */
function createSessionRecoveryService(ctx = {}) {
const incidents = new Map();
const running = new Set();
const now = ctx.now || Date.now;
const sleep = ctx.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
const pending = sessionId => ({ ok: true, sessionId, status: "pending", shellReady: false,
nextAction: "session_recover", retryAfterMs: 1000 });
async function recover(params = {}, executionContext = {}) {
const id = params.sessionId;
if (typeof id !== "string" || !id.trim()) return { ok: false, error: "sessionId is required." };
if (!ctx.validate || !ctx.inspect || !ctx.reconnect) return { ok: false, error: "Scoped recovery is unavailable." };
const validate = () => {
const result = ctx.validate(params);
if (result?.ok !== true) return result || { ok: false, error: "Recovery not authorized." };
executionContext.beforeExecute?.();
return null;
};
const denied = validate();
if (denied) return denied;
if (running.has(id)) return pending(id);
running.add(id);
const incident = incidents.get(id) || { startedAt: now(), attempts: 0, nextAt: 0 };
const { startedAt } = incident;
incidents.set(id, incident);
const callDeadline = now() + 20000;
try {
const finishConnected = (state) => {
// Transport truth has converged: the connection is restored and
// this bounded budget observed no transport error. shellReady is
// ADVISORY here, not a gate. Recovery never types or replays
// anything into the PTY, so a false-ready cannot inject input —
// the agent verifies with its next action before writing. The
// harm of a false-ready (one extra inspect_shell step) is far
// below the harm of a false-NOT-ready, which used to loop this
// recovery forever on custom/colored prompts (starship, PowerShell
// continuation) the readiness probe cannot parse, leaving agents
// convinced the session was unusable.
incidents.delete(id);
const shellReady = state.shellReady === true ? true : state.shellReady === false ? false : null;
return { ...state, sessionId: id, shellReady, status: "ready",
nextAction: shellReady === true ? "continue_task" : "inspect_shell" };
};
while (now() < callDeadline) {
const blocked = validate();
if (blocked) return blocked;
const state = await ctx.inspect(params, Math.min(2000, callDeadline - now()));
const revoked = validate();
if (revoked) return revoked;
if (state?.ok !== true) return state || { ok: false, error: "Recovery state is unavailable." };
if (state.stopped) return { ok: false, sessionId: id, status: "stopped", code: "RECOVERY_CANCELLED", error: "The user stopped this terminal connection. Do not reconnect automatically." };
if (state.waitingForInput) return { ...state, sessionId: id, status: "needs_input", shellReady: false, nextAction: "user_input" };
if (state.busy) return { ...state, sessionId: id, status: "busy", shellReady: false, nextAction: "inspect_job" };
if (state.status === "connected") return finishConnected(state);
if (now() - startedAt >= 120000) return { ok: false, sessionId: id, code: "RECOVERY_LIMIT", error: "Recovery elapsed budget exhausted. Inspect the blocked session and continue independent authorized work." };
if (now() >= callDeadline) break;
let waitMs = 1000;
if (state.status === "disconnected" && !state.reconnecting) {
if (!state.reconnectAllowed) return { ...state, sessionId: id, status: "unavailable", nextAction: "inspect_terminal" };
if (incident.attempts >= 3) return { ok: false, sessionId: id, code: "RECONNECT_LIMIT", error: "Three recovery attempts exhausted without a restored connection." };
if (incident.nextAt > now()) waitMs = incident.nextAt - now();
else {
const result = await ctx.reconnect(params, { ...executionContext, deadlineAt: Math.min(callDeadline, startedAt + 120000) });
if (result?.started !== false && result?.code !== "RECONNECT_BACKOFF") {
incident.attempts++;
incident.nextAt = now() + (incident.attempts === 1 ? 2000 : 5000);
}
const cancelled = validate();
if (cancelled) return cancelled;
if (result?.ok === false) {
if (result.code !== "RECONNECT_BACKOFF") return result;
waitMs = Math.max(1000, result.retryAfterMs || 1000);
}
}
} else if (!["connected", "connecting", "disconnected"].includes(state.status)) {
return { ok: false, sessionId: id, error: "Unknown terminal recovery state; inspect before retrying." };
}
// Revalidate at least once a second, including during reconnect backoff.
const waitUntil = Math.min(now() + waitMs, callDeadline, startedAt + 120000);
while (now() < waitUntil) {
await sleep(Math.min(1000, waitUntil - now()));
const cancelled = validate();
if (cancelled) return cancelled;
}
}
// Call budget expired without a restored transport. Do not report
// ready while still offline/connecting — that was a false success.
// Preserve nonconnected truth as pending so the shared incident can
// continue. A final inspect covers a race where the transport landed
// after the last loop iteration.
const blocked = validate();
if (blocked) return blocked;
const finalState = await ctx.inspect(params, Math.min(2000, Math.max(1, callDeadline + 20000 - now())));
const revoked = validate();
if (revoked) return revoked;
if (finalState?.ok !== true) return finalState || { ok: false, error: "Recovery state is unavailable." };
if (finalState.stopped) return { ok: false, sessionId: id, status: "stopped", code: "RECOVERY_CANCELLED", error: "The user stopped this terminal connection. Do not reconnect automatically." };
if (finalState.waitingForInput) return { ...finalState, sessionId: id, status: "needs_input", shellReady: false, nextAction: "user_input" };
if (finalState.busy) return { ...finalState, sessionId: id, status: "busy", shellReady: false, nextAction: "inspect_job" };
if (finalState.status === "connected") return finishConnected(finalState);
// Leave RECOVERY_LIMIT for the next call's loop so a just-expired
// 120s incident still reports pending for this call budget.
return pending(id);
} finally {
running.delete(id);
}
}
return { recover, forget: id => incidents.delete(id) };
}
module.exports = { createSessionRecoveryService };
@@ -1,154 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createSessionRecoveryService } = require("./sessionRecoveryService.cjs");
function fixture(overrides = {}) {
let time = 0;
let calls = 0;
let state = { ok: true, status: "disconnected", reconnectAllowed: true };
const service = createSessionRecoveryService({
now: () => time,
sleep: async (ms) => { time += ms; },
validate: () => ({ ok: true }),
inspect: async () => state,
reconnect: async () => { calls++; state = { ok: true, status: "connected", shellReady: true }; return { ok: true, started: true }; },
...overrides,
});
return { service, calls: () => calls, setState: value => { state = value; }, now: () => time };
}
test("recovers the original session, reports observed prompt readiness and sends no command", async () => {
const f = fixture();
const result = await f.service.recover({ sessionId: "s" });
assert.equal(result.sessionId, "s");
assert.equal(result.status, "ready");
assert.equal(result.shellReady, true);
assert.equal(f.calls(), 1);
});
test("continuations preserve the overall deadline and never restart a connecting terminal", async () => {
const f = fixture();
f.setState({ ok: true, status: "connecting" });
for (let i = 0; i < 6; i++) {
const result = await f.service.recover({ sessionId: "s", chatSessionId: String(i) });
// Still-connecting budget exhaustion must preserve nonconnected truth
// as pending — never a false ready.
assert.equal(result.status, "pending");
assert.equal(result.shellReady, false);
assert.equal(result.nextAction, "session_recover");
assert.ok(f.now() <= (i + 1) * 20000);
}
assert.equal((await f.service.recover({ sessionId: "s" })).code, "RECOVERY_LIMIT");
assert.equal(f.calls(), 0);
});
test("offline call-budget exhaustion returns pending, not ready", async () => {
const f = fixture({
reconnect: async () => ({ ok: true, started: true }),
});
f.setState({ ok: true, status: "disconnected", reconnectAllowed: true, reconnecting: true });
const result = await f.service.recover({ sessionId: "s" });
assert.equal(result.status, "pending");
assert.equal(result.shellReady, false);
assert.equal(result.nextAction, "session_recover");
assert.notEqual(result.status, "ready");
assert.equal(f.calls(), 0);
});
test("auth, busy jobs and unavailable handlers stop without reconnecting", async () => {
for (const state of [
{ ok: true, status: "disconnected", waitingForInput: true },
{ ok: true, status: "connected", busy: true, shellReady: false },
{ ok: true, status: "disconnected", reconnectAllowed: false },
]) {
const f = fixture(); f.setState(state);
const result = await f.service.recover({ sessionId: "s" });
assert.equal(result.status, state.waitingForInput ? "needs_input" : state.busy ? "busy" : "unavailable");
assert.equal(f.calls(), 0);
}
});
test("a user-disconnected terminal cannot be revived by automatic recovery", async () => {
const f = fixture();
f.setState({ ok: true, status: "disconnected", stopped: true, reconnectAllowed: true });
assert.equal((await f.service.recover({ sessionId: "s" })).code, "RECOVERY_CANCELLED");
assert.equal(f.calls(), 0);
});
test("cancellation during backoff stops before another inspection or reconnect", async () => {
let time = 0;
let calls = 0;
const f = fixture({ now: () => time, sleep: async ms => { time += ms; },
validate: () => time === 0 ? { ok: true } : { ok: false, error: "cancelled" },
reconnect: async () => { calls++; return { ok: false, code: "RECONNECT_BACKOFF", retryAfterMs: 5000 }; } });
assert.equal((await f.service.recover({ sessionId: "s" })).error, "cancelled");
assert.equal(time, 1000);
assert.equal(calls, 1);
});
test("checks scope again after inspecting and before another recovery action", async () => {
let allowed = true;
const f = fixture({ validate: () => allowed ? { ok: true } : { ok: false, error: "scope revoked" },
inspect: async () => { allowed = false; return { ok: true, status: "disconnected", reconnectAllowed: true }; } });
assert.equal((await f.service.recover({ sessionId: "s" })).error, "scope revoked");
assert.equal(f.calls(), 0);
});
test("waits for backoff and respects the shared reconnect attempt limit", async () => {
let calls = 0;
const f = fixture({ reconnect: async () => {
calls++;
return calls === 1 ? { ok: false, code: "RECONNECT_BACKOFF", retryAfterMs: 2000 }
: { ok: false, code: "RECONNECT_LIMIT", error: "three attempts exhausted" };
} });
const result = await f.service.recover({ sessionId: "s" });
assert.equal(result.code, "RECONNECT_LIMIT");
assert.equal(f.now(), 2000);
assert.equal(calls, 2);
});
test("concurrent callers inspect their scope but do not start duplicate recovery loops", async () => {
let resolve;
let valid = true;
const f = fixture({ validate: () => valid ? { ok: true } : { ok: false, error: "cancelled" },
inspect: () => new Promise(r => { resolve = r; }) });
const running = f.service.recover({ sessionId: "s" });
assert.equal((await f.service.recover({ sessionId: "s" })).status, "pending");
valid = false;
assert.equal((await f.service.recover({ sessionId: "s" })).error, "cancelled");
resolve({ ok: true, status: "connected", shellReady: true });
assert.equal((await running).error, "cancelled");
});
test("unknown readiness is an advisory hint, not a gate, and failures do not loop", async () => {
const f = fixture();
f.setState({ ok: true, status: "connected", shellReady: null });
const result = await f.service.recover({ sessionId: "s" });
assert.equal(result.status, "ready");
assert.equal(result.shellReady, null);
assert.equal(result.nextAction, "inspect_shell");
const failed = fixture({ inspect: async () => ({ ok: false, error: "renderer timeout" }) });
assert.equal((await failed.service.recover({ sessionId: "s" })).error, "renderer timeout");
assert.equal(failed.calls(), 0);
});
test("a false-ready prompt no longer gates a connected transport", async () => {
const f = fixture();
f.setState({ ok: true, status: "connected", shellReady: false });
const result = await f.service.recover({ sessionId: "s" });
assert.equal(result.status, "ready");
assert.equal(result.shellReady, false);
assert.equal(result.nextAction, "inspect_shell");
assert.equal(f.calls(), 0);
});
test("flapping connection success cannot reset the overall three-attempt recovery limit", async () => {
let calls = 0;
const attemptsAt = [];
const f = fixture({ reconnect: async () => { calls++; attemptsAt.push(f.now()); return { ok: true, started: true }; } });
const result = await f.service.recover({ sessionId: 'flapping' });
assert.equal(result.code, 'RECONNECT_LIMIT');
assert.equal(calls, 3);
assert.deepEqual(attemptsAt, [0, 2000, 7000]);
});
@@ -1,62 +1,7 @@
"use strict";
const { createSessionRecoveryService } = require("./sessionRecoveryService.cjs");
function createSessionService(ctx = {}) {
const { invokeSessionAgent, validateClose, beforeClose, afterClose, onClosed } = ctx;
const incidents = new Map();
const reconnecting = new Set();
const now = ctx.now || Date.now;
const recovery = createSessionRecoveryService({ now, sleep: ctx.sleep,
validate: ctx.validateReconnect, inspect: ctx.inspectRecovery, reconnect });
async function reconnect(params = {}, executionContext = {}) {
if (typeof params.sessionId !== "string" || !params.sessionId.trim()) {
return { ok: false, error: "sessionId is required." };
}
if (typeof ctx.validateReconnect !== "function" || typeof invokeSessionAgent !== "function") {
return { ok: false, error: "Scoped session reconnect is unavailable." };
}
const validation = ctx.validateReconnect(params);
if (validation?.ok !== true) return validation || { ok: false, error: "Reconnect not authorized." };
executionContext.beforeExecute?.();
const timeoutMs = Math.min(5000, (executionContext.deadlineAt ?? now() + 5000) - now());
if (timeoutMs <= 0) return { ok: false, started: false, code: "RECONNECT_EXPIRED", error: "Reconnect dispatch deadline expired." };
const id = params.sessionId;
if (validation.connected === true) {
incidents.delete(id);
return { ok: true, sessionId: id, status: "connected", started: false, shellReady: false };
}
if (reconnecting.has(id)) return { ok: true, sessionId: id, status: "reconnecting", started: false };
const time = now();
const incident = incidents.get(id);
if (incident && (incident.attempts >= 3 || time - incident.startedAt >= 120000)) {
return { ok: false, code: "RECONNECT_LIMIT", error: "Recovery budget exhausted. Inspect the session or request user assistance; do not open repeated replacement tabs." };
}
if (incident && time < incident.nextAt) {
return { ok: false, code: "RECONNECT_BACKOFF", retryAfterMs: incident.nextAt - time, error: "Wait before retrying this session." };
}
reconnecting.add(id);
// Reserve before dispatch: a lost reply may still have started a connection.
const next = { startedAt: incident?.startedAt ?? time, attempts: (incident?.attempts || 0) + 1, nextAt: time + (incident ? 5000 : 2000) };
incidents.set(id, next);
try {
const result = await invokeSessionAgent("session.reconnect", { sessionId: id, expiresAt: now() + timeoutMs }, { timeoutMs });
const current = ctx.validateReconnect(params);
if (current?.ok !== true) return current || { ok: false, error: "Reconnect scope was revoked." };
executionContext.beforeExecute?.();
if (result?.status === "connected") incidents.delete(id);
else if (incidents.get(id) === next && result?.started !== true) {
// Explicit non-dispatch acknowledgement is safe; unknown replies consume an attempt.
if (result?.started === false) {
if (incident) incidents.set(id, incident);
else incidents.delete(id);
}
}
return result;
} finally {
reconnecting.delete(id);
}
}
async function close(params = {}, options = {}) {
if (!params.sessionId || typeof params.sessionId !== "string") {
@@ -91,10 +36,6 @@ function createSessionService(ctx = {}) {
}
return {
reconnect,
recover: recovery.recover,
forgetRecovery: recovery.forget,
forgetReconnect: (sessionId) => incidents.delete(sessionId),
close: (params = {}, executionContext = {}) => close(params, {
beforeExecute: executionContext.beforeExecute,
}),
@@ -4,104 +4,6 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const { createSessionService } = require("./sessionService.cjs");
test('automatic recovery shares the single-reconnect budget and stops after three failed connections', async () => {
let time = 0;
let attempts = 0;
const service = createSessionService({ now: () => time, sleep: async ms => { time += ms; },
validateReconnect: () => ({ ok: true }),
inspectRecovery: async () => ({ ok: true, status: 'disconnected', reconnectAllowed: true }),
invokeSessionAgent: async () => { attempts++; return { ok: true, started: true }; } });
const result = await service.recover({ sessionId: 'failed' });
assert.equal(result.code, 'RECONNECT_LIMIT');
assert.equal(attempts, 3);
assert.ok(time >= 7000 && time < 20000);
assert.equal((await service.reconnect({ sessionId: 'failed', chatSessionId: 'another' })).code, 'RECONNECT_LIMIT');
});
test("reconnect fails closed without scoped authorization", async () => {
let calls = 0;
const service = createSessionService({ invokeSessionAgent: async () => { calls++; } });
assert.equal((await service.reconnect({ sessionId: "s" })).ok, false);
assert.equal(calls, 0);
});
test("reconnect bounds attempts across chats, uses backoff and resets only after observed connection", async () => {
let now = 0;
let calls = 0;
let connected = false;
const service = createSessionService({
now: () => now,
validateReconnect: () => ({ ok: true, connected }),
invokeSessionAgent: async () => { calls++; return { ok: true, started: true, status: "reconnecting" }; },
});
const params = { sessionId: "s", chatSessionId: "first" };
assert.equal((await service.reconnect(params)).started, true);
assert.equal((await service.reconnect(params)).code, "RECONNECT_BACKOFF");
now = 2000;
assert.equal((await service.reconnect({ ...params, chatSessionId: "second" })).started, true);
assert.equal((await service.reconnect(params)).retryAfterMs, 5000);
now = 7000;
assert.equal((await service.reconnect(params)).started, true);
now = 200000;
assert.equal((await service.reconnect(params)).code, "RECONNECT_LIMIT");
connected = true;
assert.equal((await service.reconnect(params)).status, "connected");
assert.equal(calls, 3);
connected = false;
assert.equal((await service.reconnect(params)).started, true);
});
test("reconnect coalesces concurrent requests but revalidates every caller", async () => {
let finish;
let permitted = true;
let calls = 0;
const service = createSessionService({
validateReconnect: () => permitted ? { ok: true } : { ok: false, error: "scope revoked" },
invokeSessionAgent: () => { calls++; return new Promise(resolve => { finish = resolve; }); },
});
const first = service.reconnect({ sessionId: "s" });
assert.equal((await service.reconnect({ sessionId: "s" })).status, "reconnecting");
permitted = false;
assert.equal((await service.reconnect({ sessionId: "s" })).error, "scope revoked");
finish({ ok: true, started: true });
assert.equal((await first).error, "scope revoked");
assert.equal(calls, 1);
});
test("unavailable renderer does not consume attempts and execution guard runs before dispatch", async () => {
let calls = 0;
const service = createSessionService({
validateReconnect: () => ({ ok: true }),
invokeSessionAgent: async () => { calls++; return { ok: false, started: false, code: "RECONNECT_UNAVAILABLE" }; },
});
await assert.rejects(service.reconnect({ sessionId: "s" }, { beforeExecute: () => { throw Error("cancelled"); } }), /cancelled/);
for (let i = 0; i < 4; i++) assert.equal((await service.reconnect({ sessionId: "s" })).code, "RECONNECT_UNAVAILABLE");
assert.equal(calls, 4);
});
test("elapsed recovery budget cannot be reset by waiting or changing chat", async () => {
let now = 0;
const service = createSessionService({ now: () => now, validateReconnect: () => ({ ok: true }),
invokeSessionAgent: async () => ({ ok: true, started: true }) });
await service.reconnect({ sessionId: "s" });
now = 120000;
assert.equal((await service.reconnect({ sessionId: "s", chatSessionId: "another" })).code, "RECONNECT_LIMIT");
});
test("unknown dispatch outcomes consume budget but observed success resets it", async () => {
let now = 0;
let fail = true;
const service = createSessionService({ now: () => now, validateReconnect: () => ({ ok: true }),
invokeSessionAgent: async () => { if (fail) throw Error('reply lost'); return { ok: true, started: true }; } });
await assert.rejects(service.reconnect({ sessionId: 's' }), /reply lost/);
assert.equal((await service.reconnect({ sessionId: 's' })).code, 'RECONNECT_BACKOFF');
now = 120000;
assert.equal((await service.reconnect({ sessionId: 's' })).code, 'RECONNECT_LIMIT');
service.forgetReconnect('s');
fail = false;
assert.equal((await service.reconnect({ sessionId: 's' })).started, true);
});
test("closeTracked uses the same lifecycle while bypassing user-scope validation", async () => {
const events = [];
const service = createSessionService({
+1 -5
View File
@@ -26,10 +26,6 @@ const CORE_MCP_TOOL_NAMES = Object.freeze([
]);
const DEFERRED_MCP_TOOLSETS = Object.freeze({
sessions: Object.freeze([
"session_reconnect",
"session_recover",
]),
attachments: Object.freeze([
"list_attachments",
"read_attachment",
@@ -148,7 +144,7 @@ function registerProgressiveMcpTools(server, deps, toolDefs = listMcpTools()) {
server.tool(
MCP_TOOL_LOADER_NAME,
"Load more Netcatty tools by category. Choose sessions, attachments, sftp_advanced, vault_hosts, vault_notes, snippets, scripts, portforward, or all. Newly loaded tools appear immediately without restarting the MCP server.",
"Load more Netcatty tools by category. Choose attachments, sftp_advanced, vault_hosts, vault_notes, snippets, scripts, portforward, or all. Newly loaded tools appear immediately without restarting the MCP server.",
{
toolset: z.enum(LOADABLE_MCP_TOOLSET_NAMES).describe("Netcatty tool category to load"),
},
-1
View File
@@ -422,7 +422,6 @@ function createPreloadApi(ctx) {
ipcRenderer.send("netcatty:write", {
sessionId,
data,
binary: options?.binary === true,
automated: Boolean(options?.automated),
pasteRequestId: typeof options?.pasteRequestId === "string" ? options.pasteRequestId : undefined,
sensitive: options?.sensitive === true,
-15
View File
@@ -685,21 +685,6 @@ function registerWorkerAiExecHandlers(ipcMain, { sessions }) {
ipcMain.handle("netcatty:ai:jobPoll", createWorkerAiJobPollHandler({
backgroundJobs,
}));
ipcMain.handle("netcatty:ai:shellReadiness", (_event, payload = {}) => {
// Read-only probe used by session recovery readiness inspection. Sessions
// live here in the default worker path, so the main process cannot see
// prompt tracking state without this route. Never mutates the session and
// never writes to the PTY.
const session = sessions.get(payload?.sessionId);
if (!session) return { ok: true, found: false };
return {
ok: true,
found: true,
trackedShell: typeof session._promptTrackTail === "string",
freshIdlePrompt: getFreshIdlePrompt(session) || "",
visibleIdlePrompt: getVisibleIdlePrompt(session) || "",
};
});
ipcMain.handle("netcatty:ai:jobStop", createWorkerAiJobStopHandler({
backgroundJobs,
}));
-18
View File
@@ -29,24 +29,6 @@ test("worker SSH exec waits for a slow interactive shell prompt before any PTY w
assert.equal((await pending).stdout, 'ready');
});
test("worker shell readiness route reports prompt state read-only", () => {
const session = { protocol: 'ssh', stream: new FakePty(), shellKind: 'posix', _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, 'root@host:~# ');
const ipcMain = createFakeIpcMain();
registerWorkerAiExecHandlers(ipcMain, { sessions: new Map([['ssh-1', session]]) });
const handler = ipcMain.handlers.get('netcatty:ai:shellReadiness');
const ready = handler(createFakeEvent(), { sessionId: 'ssh-1' });
assert.deepEqual(ready, {
ok: true,
found: true,
trackedShell: true,
freshIdlePrompt: 'root@host:~# ',
visibleIdlePrompt: 'root@host:~# ',
});
assert.deepEqual(session.stream.writes, [], "readiness probe must never write to the PTY");
assert.deepEqual(handler(createFakeEvent(), { sessionId: 'gone' }), { ok: true, found: false });
});
class FakePty extends EventEmitter {
constructor() {
super();
@@ -2,11 +2,9 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
createCattyAnthropicSignatureRetryError,
createCattyRequestTooLargeRetryError,
hadToolProgressBeforeRequestTooLarge,
} from "./cattyRequestTooLargeRetry.ts";
import { isInvalidAnthropicThinkingSignatureError } from "./errorClassifier.ts";
test("createCattyRequestTooLargeRetryError marks 413 retry errors after tool progress", () => {
const source = Object.assign(new Error("HTTP 413 Request Entity Too Large"), {
@@ -29,71 +27,3 @@ test("hadToolProgressBeforeRequestTooLarge is false when no tool progress was re
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), false);
assert.equal(hadToolProgressBeforeRequestTooLarge(new Error("HTTP 413")), false);
});
test("createCattyAnthropicSignatureRetryError preserves classifier fields and tool progress", () => {
const source = Object.assign(
new Error("Invalid signature in thinking block. The block is bound to a different conversation."),
{
statusCode: 400,
responseBody: JSON.stringify({
error: {
message: "Invalid signature in thinking block. The block is bound to a different conversation.",
},
}),
},
);
const retryError = createCattyAnthropicSignatureRetryError(source, true);
assert.equal(retryError.statusCode, 400);
assert.equal(retryError.responseBody, source.responseBody);
assert.equal(retryError.cause, source);
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), true);
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
});
test("createCattyAnthropicSignatureRetryError defaults status 400 and no progress", () => {
const retryError = createCattyAnthropicSignatureRetryError(
"Invalid signature in thinking block. The block is bound to a different conversation.",
false,
);
assert.equal(retryError.statusCode, 400);
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), false);
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
});
test("createCattyAnthropicSignatureRetryError preserves plain-object SSE signature message", () => {
const source = {
type: "invalid_request_error",
message: "Invalid signature in thinking block",
status: 400,
};
const retryError = createCattyAnthropicSignatureRetryError(source, false);
assert.equal(retryError.message, "Invalid signature in thinking block");
assert.notEqual(retryError.message, "[object Object]");
assert.equal(retryError.statusCode, 400);
assert.equal(retryError.status, 400);
assert.equal(retryError.cause, source);
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
});
test("createCattyAnthropicSignatureRetryError preserves plain-object message with statusCode", () => {
const source = {
type: "invalid_request_error",
message: "Invalid signature in thinking block. The block is bound to a different conversation.",
statusCode: 400,
};
const retryError = createCattyAnthropicSignatureRetryError(source, true);
assert.equal(
retryError.message,
"Invalid signature in thinking block. The block is bound to a different conversation.",
);
assert.equal(retryError.statusCode, 400);
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), true);
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
});
+3 -49
View File
@@ -5,32 +5,13 @@ export type CattyRequestTooLargeRetryError = Error & {
responseBody?: string;
};
export type CattyAnthropicSignatureRetryError = Error & {
cattyHadToolProgress?: boolean;
statusCode?: number;
status?: number;
responseBody?: string;
};
/**
* Mirror errorClassifier.extractMessage so plain SDK / SSE error objects
* with a string `.message` keep that text (not `[object Object]`).
*/
function extractRetryMessage(error: unknown, fallback: string): string {
if (error instanceof Error) return error.message || fallback;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error) {
const m = (error as { message: unknown }).message;
if (typeof m === 'string' && m.length > 0) return m;
}
return fallback;
}
export function createCattyRequestTooLargeRetryError(
error: unknown,
hadToolProgress: boolean,
): CattyRequestTooLargeRetryError {
const message = extractRetryMessage(error, 'Request too large');
const message = error instanceof Error
? error.message
: String(error ?? 'Request too large');
const retryError = new Error(message) as CattyRequestTooLargeRetryError;
retryError.name = 'CattyRequestTooLargeRetryError';
retryError.cause = error;
@@ -44,33 +25,6 @@ export function createCattyRequestTooLargeRetryError(
return retryError;
}
/**
* Wrap an Anthropic thinking-signature failure so the turn-driver retry path
* can see prior tool progress, while keeping message/body/status intact for
* `isInvalidAnthropicThinkingSignatureError`.
*/
export function createCattyAnthropicSignatureRetryError(
error: unknown,
hadToolProgress: boolean,
): CattyAnthropicSignatureRetryError {
const message = extractRetryMessage(error, 'Invalid signature in thinking block');
const retryError = new Error(message) as CattyAnthropicSignatureRetryError;
retryError.name = 'CattyAnthropicSignatureRetryError';
retryError.cause = error;
retryError.cattyHadToolProgress = hadToolProgress;
if (error && typeof error === 'object') {
const source = error as Record<string, unknown>;
if (typeof source.statusCode === 'number') retryError.statusCode = source.statusCode;
else if (typeof source.status === 'number') retryError.statusCode = source.status;
else retryError.statusCode = 400;
if (typeof source.status === 'number') retryError.status = source.status;
if (typeof source.responseBody === 'string') retryError.responseBody = source.responseBody;
} else {
retryError.statusCode = 400;
}
return retryError;
}
export function hadToolProgressBeforeRequestTooLarge(error: unknown): boolean {
return !!(
error &&
+1 -56
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { classifyError, isInvalidAnthropicThinkingSignatureError, isRequestTooLargeError, sanitizeErrorMessage } from "./errorClassifier.ts";
import { classifyError, isRequestTooLargeError, sanitizeErrorMessage } from "./errorClassifier.ts";
// -------------------------------------------------------------------
// sanitizeErrorMessage — regression guard for pre-existing behavior
@@ -74,61 +74,6 @@ test("isRequestTooLargeError does not treat timing text as HTTP 413", () => {
assert.equal(isRequestTooLargeError("413 ms"), false);
});
// -------------------------------------------------------------------
// isInvalidAnthropicThinkingSignatureError — #4073 prefix binding
// -------------------------------------------------------------------
test("isInvalidAnthropicThinkingSignatureError matches Anthropic 400 signature text", () => {
const err = Object.assign(
new Error("Invalid signature in thinking block. The block is bound to a different conversation."),
{ statusCode: 400 },
);
assert.equal(isInvalidAnthropicThinkingSignatureError(err), true);
});
test("isInvalidAnthropicThinkingSignatureError matches responseBody-only signature errors", () => {
const err = Object.assign(new Error("AI_APICallError"), {
statusCode: 400,
responseBody: JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message: "Invalid signature in thinking block. The block is bound to a different conversation.",
},
}),
});
assert.equal(isInvalidAnthropicThinkingSignatureError(err), true);
});
test("isInvalidAnthropicThinkingSignatureError matches text without statusCode", () => {
assert.equal(
isInvalidAnthropicThinkingSignatureError(
new Error("bound to a different conversation"),
),
true,
);
});
test("isInvalidAnthropicThinkingSignatureError rejects unrelated 400s and non-400 signature-ish text", () => {
assert.equal(
isInvalidAnthropicThinkingSignatureError(
Object.assign(new Error("Invalid request: missing field"), { statusCode: 400 }),
),
false,
);
assert.equal(
isInvalidAnthropicThinkingSignatureError(
Object.assign(
new Error("Invalid signature in thinking block. The block is bound to a different conversation."),
{ statusCode: 500 },
),
),
false,
);
assert.equal(isInvalidAnthropicThinkingSignatureError(new Error("plain failure")), false);
});
// -------------------------------------------------------------------
// classifyError — 502 / 503 / 504 upstream gateway
// -------------------------------------------------------------------
-19
View File
@@ -72,25 +72,6 @@ export function isRequestTooLargeError(error: unknown): boolean {
);
}
/**
* Anthropic preserved-thinking prefix-binding failure (#4073):
* "Invalid signature in thinking block. The block is bound to a different conversation."
* Triggered when client history mutates an earlier tool_result / drops early turns /
* injects a durable summary while still replaying later signed thinking blocks.
*/
export function isInvalidAnthropicThinkingSignatureError(error: unknown): boolean {
const message = extractMessage(error).trim();
const responseBody = extractResponseBody(error) ?? "";
const combined = `${message}\n${responseBody}`;
const statusCode = extractStatusCode(error, combined);
const looksLikeSignature = /Invalid[\s\S]*signature[\s\S]*thinking|bound to a different conversation/i.test(combined);
if (!looksLikeSignature) return false;
// Prefer structured 400 when present; still match when only the text is available
// (some SDK shims omit statusCode and put the body in the message).
if (statusCode !== undefined && statusCode !== 400) return false;
return true;
}
function looksLikeHtml(text: string): boolean {
if (!text) return false;
const lower = text.toLowerCase();
@@ -1,191 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ModelMessage } from 'ai';
import {
hasAnthropicThinkingSignature,
providerAllowsAnthropicSignedThinkingRemoval,
stripAnthropicSignedReasoningParts,
} from './anthropicThinkingBinding.ts';
function assistantWithParts(parts: unknown[]): ModelMessage {
return { role: 'assistant', content: parts } as ModelMessage;
}
test('stripAnthropicSignedReasoningParts drops only signed anthropic reasoning', () => {
const input: ModelMessage[] = [
{ role: 'user', content: 'hi' },
assistantWithParts([
{ type: 'reasoning', text: 'signed', providerOptions: { anthropic: { signature: 'sig-1' } } },
{ type: 'reasoning', text: 'unsigned' },
{ type: 'text', text: 'hello' },
{
type: 'tool-call',
toolCallId: 'c1',
toolName: 'terminal_execute',
input: { command: 'ls' },
},
]),
assistantWithParts([
{ type: 'reasoning', text: 'also signed', providerOptions: { anthropic: { signature: 'sig-2' } } },
{ type: 'text', text: 'done' },
]),
];
const { messages, didStrip } = stripAnthropicSignedReasoningParts(input);
assert.equal(didStrip, true);
assert.equal(messages[0], input[0], 'unchanged messages stay same reference');
assert.notEqual(messages[1], input[1], 'changed assistant gets new object');
assert.deepEqual(
(messages[1].content as { type: string; text?: string }[]).map((p) => [p.type, p.text]),
[
['reasoning', 'unsigned'],
['text', 'hello'],
['tool-call', undefined],
],
);
assert.deepEqual(
(messages[2].content as { type: string; text?: string }[]).map((p) => [p.type, p.text]),
[['text', 'done']],
);
// Input not mutated
assert.equal((input[1].content as unknown[]).length, 4);
});
test('stripAnthropicSignedReasoningParts leaves unsigned / empty-signature alone', () => {
const input: ModelMessage[] = [
assistantWithParts([
{ type: 'reasoning', text: 'plain' },
{ type: 'reasoning', text: 'empty sig', providerOptions: { anthropic: { signature: '' } } },
{ type: 'reasoning', text: 'other provider', providerOptions: { openai: { itemId: 'rs_1' } } },
{ type: 'text', text: 'ok' },
]),
];
const { messages, didStrip } = stripAnthropicSignedReasoningParts(input);
assert.equal(didStrip, false);
assert.equal(messages, input);
});
test('stripAnthropicSignedReasoningParts ignores non-array assistant content', () => {
const input: ModelMessage[] = [
{ role: 'assistant', content: 'plain string' },
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 't', output: { type: 'text', value: 'x' } }] } as ModelMessage,
];
const { messages, didStrip } = stripAnthropicSignedReasoningParts(input);
assert.equal(didStrip, false);
assert.equal(messages, input);
});
test('hasAnthropicThinkingSignature matches non-empty anthropic.signature', () => {
assert.equal(hasAnthropicThinkingSignature({
providerOptions: { anthropic: { signature: 'abc' } },
}), true);
assert.equal(hasAnthropicThinkingSignature({
providerOptions: { anthropic: { signature: '' } },
}), false);
assert.equal(hasAnthropicThinkingSignature({ providerOptions: {} }), false);
assert.equal(hasAnthropicThinkingSignature({}), false);
});
test('prepareStep strip condition: signed after long tool_result + didAdjust → no signatures remain', () => {
// Mirrors the long-chat path: prepareStepContext mutates an earlier
// tool_result (didAdjust=true) while a later assistant still carries a
// signed thinking block. After the strip helper runs, no anthropic.signature
// may remain on the retained tail.
const longToolResult = 'x'.repeat(5000);
const messages: ModelMessage[] = [
assistantWithParts([
{ type: 'reasoning', text: 'plan', providerOptions: { anthropic: { signature: 'bound-to-prefix' } } },
{ type: 'text', text: 'running' },
{ type: 'tool-call', toolCallId: 'c1', toolName: 'terminal_execute', input: { command: 'cat big' } },
]),
{
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: 'c1',
toolName: 'terminal_execute',
output: { type: 'text', value: longToolResult },
}],
} as ModelMessage,
assistantWithParts([
{ type: 'reasoning', text: 'after tool', providerOptions: { anthropic: { signature: 'later-sig' } } },
{ type: 'text', text: 'done' },
]),
];
// Simulate the turn-driver wiring: when didAdjust and provider allows
// removal, strip before returning (official Anthropic).
const didAdjust = true;
const preserveAnthropicThinkingReplay = true;
const allowStripSignedThinking = true;
let stepMessages = messages;
if (preserveAnthropicThinkingReplay && didAdjust && allowStripSignedThinking) {
stepMessages = stripAnthropicSignedReasoningParts(stepMessages).messages;
}
for (const message of stepMessages) {
if (message.role !== 'assistant' || !Array.isArray(message.content)) continue;
for (const part of message.content as { type?: string; providerOptions?: { anthropic?: { signature?: string } } }[]) {
if (part.type === 'reasoning') {
assert.equal(
part.providerOptions?.anthropic?.signature,
undefined,
'no anthropic.signature may remain after strip-on-didAdjust',
);
}
}
}
// Unsigned-looking text / tool-call parts still present
const first = stepMessages[0].content as { type: string }[];
assert.deepEqual(first.map((p) => p.type), ['text', 'tool-call']);
});
test('providerAllowsAnthropicSignedThinkingRemoval is false for DeepSeek identities', () => {
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({ providerId: 'deepseek' }), false);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
providerId: 'custom',
baseURL: 'https://api.deepseek.com/anthropic',
}), false);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval(
{ providerId: 'anthropic' },
'deepseek-v4-flash',
), false);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
providerId: 'custom',
defaultModel: 'DeepSeek-V3',
}), false);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
providerId: 'anthropic',
defaultModel: 'claude-opus-4-5',
}), true);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
providerId: 'anthropic',
baseURL: 'https://api.anthropic.com',
}, 'claude-sonnet-4'), true);
});
test('prepareStep strip is skipped when provider forbids thinking removal (DeepSeek)', () => {
const messages: ModelMessage[] = [
assistantWithParts([
{ type: 'reasoning', text: 'plan', providerOptions: { anthropic: { signature: 'keep-me' } } },
{ type: 'text', text: 'running' },
{ type: 'tool-call', toolCallId: 'c1', toolName: 'terminal_execute', input: { command: 'ls' } },
]),
];
const didAdjust = true;
const preserveAnthropicThinkingReplay = true;
const allowStripSignedThinking = providerAllowsAnthropicSignedThinkingRemoval(
{ providerId: 'deepseek' },
'deepseek-v4-flash',
);
let stepMessages = messages;
if (preserveAnthropicThinkingReplay && didAdjust && allowStripSignedThinking) {
stepMessages = stripAnthropicSignedReasoningParts(stepMessages).messages;
}
assert.equal(allowStripSignedThinking, false);
assert.equal(stepMessages, messages);
const first = stepMessages[0].content as { type: string; providerOptions?: { anthropic?: { signature?: string } } }[];
assert.equal(first[0].providerOptions?.anthropic?.signature, 'keep-me');
});
@@ -1,89 +0,0 @@
import type { ModelMessage } from 'ai';
/**
* Anthropic preserved-thinking signatures are cryptographically bound to the
* exact conversation prefix (system + tools + every earlier messages[] entry).
* Client-side edits that shorten tool_results, drop early turns, or inject a
* durable summary invalidate later signatures with:
* "Invalid signature in thinking block. The block is bound to a different conversation."
*
* Anthropic allows removing thinking blocks wholesale; it forbids keeping a
* later signed block after mutating its prefix. Strip signed reasoning parts
* when we know the prefix mutated (#4073).
*/
type ContentPart = Extract<ModelMessage['content'], readonly unknown[]>[number];
function anthropicSignatureOf(part: ContentPart): string | undefined {
if (!part || typeof part !== 'object') return undefined;
if (!('type' in part) || (part as { type?: unknown }).type !== 'reasoning') return undefined;
const providerOptions = (part as { providerOptions?: unknown }).providerOptions;
if (!providerOptions || typeof providerOptions !== 'object') return undefined;
const anthropic = (providerOptions as { anthropic?: unknown }).anthropic;
if (!anthropic || typeof anthropic !== 'object') return undefined;
const signature = (anthropic as { signature?: unknown }).signature;
return typeof signature === 'string' && signature.length > 0 ? signature : undefined;
}
function isSignedAnthropicReasoningPart(part: ContentPart): boolean {
return anthropicSignatureOf(part) !== undefined;
}
/**
* Drop assistant `reasoning` parts that carry a non-empty
* `providerOptions.anthropic.signature`. Unsigned reasoning / text / tool-call
* parts are left alone. Does not mutate input arrays in place — returns new
* message objects only when a message actually changes.
*/
export function stripAnthropicSignedReasoningParts(
messages: ModelMessage[],
): { messages: ModelMessage[]; didStrip: boolean } {
let didStrip = false;
const next = messages.map((message) => {
if (message.role !== 'assistant' || !Array.isArray(message.content)) {
return message;
}
const content = message.content as ContentPart[];
const filtered = content.filter((part) => !isSignedAnthropicReasoningPart(part));
if (filtered.length === content.length) {
return message;
}
didStrip = true;
return { ...message, content: filtered } as ModelMessage;
});
return { messages: didStrip ? next : messages, didStrip };
}
/** True when a reasoning-like part carries a non-empty Anthropic signature. */
export function hasAnthropicThinkingSignature(part: {
providerOptions?: { anthropic?: { signature?: unknown } } | Record<string, unknown> | undefined;
}): boolean {
const providerOptions = part.providerOptions;
if (!providerOptions || typeof providerOptions !== 'object') return false;
const anthropic = (providerOptions as { anthropic?: unknown }).anthropic;
if (!anthropic || typeof anthropic !== 'object') return false;
const signature = (anthropic as { signature?: unknown }).signature;
return typeof signature === 'string' && signature.length > 0;
}
/**
* Official Anthropic allows removing thinking blocks wholesale after a prefix
* mutate (#4073). DeepSeek's Anthropic-compatible endpoint does NOT — it
* requires thinking blocks to accompany replayed tool calls. Return false when
* the active provider/model looks like DeepSeek so callers keep signed thinking.
*/
export function providerAllowsAnthropicSignedThinkingRemoval(
provider: { providerId?: string; baseURL?: string; defaultModel?: string } | null | undefined,
modelId?: string | null,
): boolean {
const providerId = String(provider?.providerId ?? '').toLowerCase();
if (providerId === 'deepseek') return false;
const baseURL = String(provider?.baseURL ?? '').toLowerCase();
if (baseURL.includes('deepseek.com')) return false;
const models = [modelId, provider?.defaultModel]
.map((value) => String(value ?? '').toLowerCase())
.filter(Boolean);
if (models.some((value) => value.includes('deepseek'))) return false;
return true;
}
@@ -61,52 +61,6 @@
"bypassesChatCancel": true
}
},
{
"capabilityId": "session.reconnect",
"toolName": "session_reconnect",
"rpcMethod": "session/reconnect",
"localExecution": false,
"description": "Request reconnect of a disconnected session in the current scope, preserving its tab and workspace. Does not restart connected sessions or replay commands. Returns acknowledgement, not shell readiness. Recovery is limited to three attempts with 2/5 second backoff in a 120 second incident; inspect authentication or exhausted recovery instead of retrying indefinitely.",
"inputShape": {
"sessionId": {
"type": "string",
"optional": false,
"description": "An existing disconnected terminal session ID in the current granted scope."
}
},
"policy": {
"write": true,
"sensitiveRead": false,
"longRunning": false,
"requiresChatSession": true,
"bypassesObserverBlock": false,
"bypassesApproval": false,
"bypassesChatCancel": false
}
},
{
"capabilityId": "session.recover",
"toolName": "session_recover",
"rpcMethod": "session/recover",
"localExecution": false,
"description": "Inspect and recover a scoped disconnected session in place. Automatically applies bounded reconnect/backoff for up to 20 seconds per call and reports ready only once the transport is restored (still-offline budgets return pending); shellReady is advisory (true/false/null) and, when unconfirmed, nextAction \"inspect_shell\" asks you to verify the prompt before typing. Stops for authentication, busy jobs, cancellation or unavailable state. Never creates tabs or executes/replays commands.",
"inputShape": {
"sessionId": {
"type": "string",
"optional": false,
"description": "The existing terminal session ID in the current granted scope. Recovery preserves this session and never replays commands."
}
},
"policy": {
"write": true,
"sensitiveRead": false,
"longRunning": false,
"requiresChatSession": true,
"bypassesObserverBlock": false,
"bypassesApproval": false,
"bypassesChatCancel": false
}
},
{
"capabilityId": "session.close",
"toolName": "session_close",
@@ -64,54 +64,6 @@
},
"agentKind": "global"
},
{
"capabilityId": "session.reconnect",
"toolName": "session_reconnect",
"rpcMethod": "session/reconnect",
"localExecution": false,
"description": "Request reconnect of a disconnected session in the current scope, preserving its tab and workspace. Does not restart connected sessions or replay commands. Returns acknowledgement, not shell readiness. Recovery is limited to three attempts with 2/5 second backoff in a 120 second incident; inspect authentication or exhausted recovery instead of retrying indefinitely.",
"inputShape": {
"sessionId": {
"type": "string",
"optional": false,
"description": "An existing disconnected terminal session ID in the current granted scope."
}
},
"policy": {
"write": true,
"sensitiveRead": false,
"longRunning": false,
"requiresChatSession": true,
"bypassesObserverBlock": false,
"bypassesApproval": false,
"bypassesChatCancel": false
},
"agentKind": "global"
},
{
"capabilityId": "session.recover",
"toolName": "session_recover",
"rpcMethod": "session/recover",
"localExecution": false,
"description": "Inspect and recover a scoped disconnected session in place. Automatically applies bounded reconnect/backoff for up to 20 seconds per call and reports ready only once the transport is restored (still-offline budgets return pending); shellReady is advisory (true/false/null) and, when unconfirmed, nextAction \"inspect_shell\" asks you to verify the prompt before typing. Stops for authentication, busy jobs, cancellation or unavailable state. Never creates tabs or executes/replays commands.",
"inputShape": {
"sessionId": {
"type": "string",
"optional": false,
"description": "The existing terminal session ID in the current granted scope. Recovery preserves this session and never replays commands."
}
},
"policy": {
"write": true,
"sensitiveRead": false,
"longRunning": false,
"requiresChatSession": true,
"bypassesObserverBlock": false,
"bypassesApproval": false,
"bypassesChatCancel": false
},
"agentKind": "global"
},
{
"capabilityId": "session.close",
"toolName": "session_close",
@@ -665,157 +665,3 @@ test('Anthropic-format signed thinking survives build and stream preparation', (
assert.equal(plainThinking?.text, 'final thinking');
assert.deepEqual(plainThinking?.providerOptions?.anthropic, { signature: 'sig-xyz' });
});
test('durable summary skips Anthropic signed thinking on the retained tail (#4073)', () => {
// Durable compaction injects a synthetic summary and keeps the recent tail.
// Replaying signed thinking on that tail would 400 (prefix binding). Unsigned
// reasoning may still be replayed.
const messages: ChatMessage[] = [
{
id: 'assistant-old',
role: 'assistant',
content: 'Earlier.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'claude-opus-4-5' },
reasoningParts: [{
text: 'compacted thinking',
providerOptions: { anthropic: { signature: 'sig-old' } },
}],
},
},
{
id: 'assistant-tail',
role: 'assistant',
content: 'Recent reply.',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'claude-opus-4-5' },
reasoningParts: [
{
text: 'signed recent',
providerOptions: { anthropic: { signature: 'sig-tail' } },
},
{ text: 'unsigned recent' },
],
},
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 3,
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
},
];
const built = buildCattySdkMessages({
allMessages: messages,
contextCompaction: {
summary: 'Earlier work finished.',
compactedMessageCount: 1,
},
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('anthropic-1', 'anthropic', 'claude-opus-4-5'),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
assert.match(String(built[0].content), /Earlier work finished/);
const tailAssistant = built.find((m) => m.role === 'assistant' && Array.isArray(m.content)
&& (m.content as { type?: string }[]).some((p) => p.type === 'tool-call'));
assert.ok(tailAssistant);
const parts = tailAssistant!.content as {
type: string;
text?: string;
providerOptions?: { anthropic?: { signature?: string } };
}[];
const reasoning = parts.filter((p) => p.type === 'reasoning');
assert.equal(reasoning.length, 1, 'only unsigned reasoning should remain');
assert.equal(reasoning[0].text, 'unsigned recent');
assert.equal(reasoning[0].providerOptions?.anthropic?.signature, undefined);
for (const part of parts) {
if (part.type === 'reasoning') {
assert.equal(part.providerOptions?.anthropic?.signature, undefined);
}
}
});
test('durable summary keeps Anthropic signed thinking for DeepSeek (#4074 P2)', () => {
// DeepSeek Anthropic-compat requires thinking blocks with replayed tool calls.
// Even with a durable summary (prefix mutate), do not strip signed thinking.
const messages: ChatMessage[] = [
{
id: 'assistant-old',
role: 'assistant',
content: 'Earlier.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'deepseek-v4-flash' },
reasoningParts: [{
text: 'compacted thinking',
providerOptions: { anthropic: { signature: 'sig-old' } },
}],
},
},
{
id: 'assistant-tail',
role: 'assistant',
content: 'Recent reply.',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'deepseek-v4-flash' },
reasoningParts: [
{
text: 'signed recent',
providerOptions: { anthropic: { signature: 'sig-tail' } },
},
{ text: 'unsigned recent' },
],
},
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 3,
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
},
];
const built = buildCattySdkMessages({
allMessages: messages,
contextCompaction: {
summary: 'Earlier work finished.',
compactedMessageCount: 1,
},
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('anthropic-1', 'anthropic', 'deepseek-v4-flash'),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
allowStripAnthropicSignedThinking: false,
});
assert.match(String(built[0].content), /Earlier work finished/);
const tailAssistant = built.find((m) => m.role === 'assistant' && Array.isArray(m.content)
&& (m.content as { type?: string }[]).some((p) => p.type === 'tool-call'));
assert.ok(tailAssistant);
const parts = tailAssistant!.content as {
type: string;
text?: string;
providerOptions?: { anthropic?: { signature?: string } };
}[];
const reasoning = parts.filter((p) => p.type === 'reasoning');
assert.equal(reasoning.length, 2, 'signed + unsigned reasoning must both remain for DeepSeek');
const signed = reasoning.find((p) => p.providerOptions?.anthropic?.signature === 'sig-tail');
assert.ok(signed, 'signed thinking must survive when allowStrip is false');
assert.equal(signed!.text, 'signed recent');
assert.ok(reasoning.some((p) => p.text === 'unsigned recent'));
});
@@ -30,7 +30,6 @@ import {
import { redactSecretsForModel, redactSecretsInValueForModel } from '../modelSecretRedaction';
import { fitLargeUserInputForModel } from '../largeUserInput';
import type { ToolOutputStore } from '../toolOutputStore';
import { hasAnthropicThinkingSignature } from '../anthropicThinkingBinding';
const OPENAI_CHAT_ASSISTANT_FIELDS = Symbol('netcatty.openAIChatAssistantFields');
@@ -202,12 +201,6 @@ export interface BuildCattySdkMessagesInput {
chatSessionId: string;
toolOutputStore: ToolOutputStore;
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>;
/**
* When true (default), durable-summary builds skip Anthropic signed thinking
* on the retained tail (#4073). DeepSeek Anthropic-compat must pass false —
* it requires thinking blocks with replayed tool calls.
*/
allowStripAnthropicSignedThinking?: boolean;
}
export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelMessage[] {
@@ -221,7 +214,6 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
chatSessionId,
toolOutputStore,
fieldsByMessage,
allowStripAnthropicSignedThinking = true,
} = input;
const { resolvedToolCallsByAssistant, toolCallByToolResult } = buildHistoricalToolReplayMaps(allMessages);
@@ -239,14 +231,6 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
// The boundary can become zero when storage trims messages that were all
// covered by the durable summary. Keep injecting that summary even though
// no remaining persisted message needs to be skipped.
// Durable summary replaces the compacted prefix. Replaying Anthropic signed
// thinking on the retained tail would 400 on official Anthropic (prefix
// binding / #4073) — skip those signatures while leaving unsigned reasoning
// intact. DeepSeek Anthropic-compat forbids wholesale removal, so callers
// pass allowStripAnthropicSignedThinking=false for that provider.
const skipSignedAnthropicReasoning = Boolean(contextCompaction?.summary)
&& allowStripAnthropicSignedThinking;
if (contextCompaction?.summary) {
sdkMessages.push({
role: 'user',
@@ -338,7 +322,6 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
if (replayedCalls.length > 0) {
for (const part of collectReplayableReasoningParts(replayContinuation)) {
if (!part.text && !part.providerOptions) continue;
if (skipSignedAnthropicReasoning && hasAnthropicThinkingSignature(part)) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
@@ -374,7 +357,6 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
const contentParts: AssistantContentPart[] = [];
for (const part of collectReplayableReasoningParts(replayContinuation)) {
if (!part.text && !part.providerOptions) continue;
if (skipSignedAnthropicReasoning && hasAnthropicThinkingSignature(part)) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
@@ -1,9 +1,9 @@
import { streamText, isStepCount, type ModelMessage } from 'ai';
import type { LanguageModelV4 } from '@ai-sdk/provider';
import { classifyError, isInvalidAnthropicThinkingSignatureError, isRequestTooLargeError } from '../../errorClassifier';
import { classifyError } from '../../errorClassifier';
import { isRequestTooLargeError } from '../../errorClassifier';
import { isSdkStreamStateError } from '../../shared/streamStateErrors';
import {
createCattyAnthropicSignatureRetryError,
createCattyRequestTooLargeRetryError,
hadToolProgressBeforeRequestTooLarge,
} from '../../cattyRequestTooLargeRetry';
@@ -368,9 +368,6 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
if (isRequestTooLargeError(readErr)) {
throw createCattyRequestTooLargeRetryError(readErr, hadToolProgress);
}
if (isInvalidAnthropicThinkingSignatureError(readErr)) {
throw createCattyAnthropicSignatureRetryError(readErr, hadToolProgress);
}
throw readErr;
}
const { done, value } = readResult;
@@ -538,16 +535,6 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
hadToolProgress,
);
}
if (isInvalidAnthropicThinkingSignatureError(typedChunk.error)) {
// Propagate to turn-driver strip-and-retry (#4074 P1); do not consume
// as a terminal error message the way other stream errors are.
cancelPendingFlush();
flushText();
throw createCattyAnthropicSignatureRetryError(
typedChunk.error,
hadToolProgress,
);
}
cancelPendingFlush();
flushText();
ui.updateMessageById(streamSessionId, activeMsgId, msg => ({
@@ -31,11 +31,7 @@ import {
} from '../cattyRuntime';
import { computeTotalInputTokens, DEFAULT_MAX_OUTPUT_TOKENS } from '../contextBudget';
import { clearChatSessionCancelled } from '../agentStop';
import { isInvalidAnthropicThinkingSignatureError, isRequestTooLargeError } from '../../errorClassifier';
import {
providerAllowsAnthropicSignedThinkingRemoval,
stripAnthropicSignedReasoningParts,
} from '../anthropicThinkingBinding';
import { isRequestTooLargeError } from '../../errorClassifier';
import { getNetcattyBridge, generateId, resolveUserSkillsContext } from '../../aiChatStreamingSupport';
import {
buildCattySdkMessages,
@@ -221,10 +217,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
try {
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
const allowStripSignedThinking = providerAllowsAnthropicSignedThinkingRemoval(
context.activeProvider,
activeModelId,
);
const buildSdkMessages = (
allMessages: import('../../types').ChatMessage[],
@@ -240,7 +232,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
chatSessionId: sessionId,
toolOutputStore: ctx.toolOutputStore,
fieldsByMessage: openAIChatAssistantFieldsByMessage,
allowStripAnthropicSignedThinking: allowStripSignedThinking,
});
const responseIdleTimeoutSeconds = normalizeResponseIdleTimeoutSeconds(
@@ -307,7 +298,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
// Keep the reasoning parts buildCattySdkMessages replayed instead of
// pruning them like generic Chat Completions histories.
const preserveAnthropicThinkingReplay = resolveProviderStyle(context.activeProvider) === 'anthropic';
// allowStripSignedThinking computed once above (DeepSeek must keep signed thinking).
const preserveReasoningForStream = preserveStatelessResponsesReasoning || preserveAnthropicThinkingReplay;
const reasoningReserveTokens = estimateReasoningOutputReserve(reasoningProviderOptions);
// Fold thinking budget into compaction maxOutput only. reservedTokens is
@@ -507,20 +497,9 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
runtimeContext: stepRuntimeContext,
onEvent: (event) => ctx.emit(event),
});
// Prefix-mutating step prep (tool_result compress/prune/budget head-prune)
// invalidates Anthropic preserved-thinking signatures on the retained
// tail (#4073). Strip signed reasoning when we know the prefix changed.
let stepMessages = prepared.messages;
if (preserveAnthropicThinkingReplay && prepared.didAdjust && allowStripSignedThinking) {
const stripped = stripAnthropicSignedReasoningParts(stepMessages);
if (stripped.didStrip) {
console.warn('[Catty] Stripped Anthropic signed thinking after step context mutate (#4073)');
stepMessages = stripped.messages;
}
}
emitContextSnapshot(stepMessages);
emitContextSnapshot(prepared.messages);
return {
messages: stepMessages,
messages: prepared.messages,
runtimeContext: prepared.runtimeContext,
};
},
@@ -534,116 +513,55 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
try {
await runStream(messagesForStream, assistantMsgId);
} catch (streamErr) {
if (signal.aborted) {
if (signal.aborted || !isRequestTooLargeError(streamErr)) {
throw streamErr;
}
if (isRequestTooLargeError(streamErr)) {
if (context.delegated) console.warn('[Plugin Agent] Context compaction requested');
else console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
let preservedWriteFingerprints: string[] = [];
if (hadToolProgress) {
const latestSession = ui.getLatestSession?.(sessionId);
if (latestSession) {
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
latestSession.messages,
assistantMsgId,
sessionId,
);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
}
retryAssistantMsgId = generateId();
ui.addMessageToSession(sessionId, {
id: retryAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: activeModelId || context.activeProvider?.defaultModel || '',
providerId: context.activeProvider?.providerId,
});
} else {
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
...msg,
content: '',
thinking: undefined,
thinkingDurationMs: undefined,
providerContinuation: undefined,
toolCalls: undefined,
errorInfo: undefined,
executionStatus: undefined,
pendingApproval: undefined,
}));
if (context.delegated) console.warn('[Plugin Agent] Context compaction requested');
else console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
let preservedWriteFingerprints: string[] = [];
if (hadToolProgress) {
const latestSession = ui.getLatestSession?.(sessionId);
if (latestSession) {
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
latestSession.messages,
assistantMsgId,
sessionId,
);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
}
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
const retryMessages = prepareMessagesForStream((await compactMessages(retryBaseMessages, {
force: true,
compressForRequestTooLargeRetry: true,
})).messages);
emitContextSnapshot(retryMessages);
await runStream(retryMessages, retryAssistantMsgId);
} else if (
preserveAnthropicThinkingReplay
&& isInvalidAnthropicThinkingSignatureError(streamErr)
) {
// Once per turn: strip signed thinking and retry. Nested failure throws
// to the outer catch (no loop). Mirror the 413 progress-aware path so
// completed tool calls/results are retained and write replay is enabled
// (#4074 P1) instead of clearing the initial assistant and restarting.
console.warn(
'[Catty] Anthropic thinking signature invalid after prefix mutate; stripping signed reasoning and retrying once (#4073).',
streamErr,
);
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
let preservedWriteFingerprints: string[] = [];
if (hadToolProgress) {
const latestSession = ui.getLatestSession?.(sessionId);
if (latestSession) {
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
latestSession.messages,
assistantMsgId,
sessionId,
);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
retryBaseMessages = prepareMessagesForStream(retryBaseMessages);
}
retryAssistantMsgId = generateId();
ui.addMessageToSession(sessionId, {
id: retryAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: activeModelId || context.activeProvider?.defaultModel || '',
providerId: context.activeProvider?.providerId,
});
} else {
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
...msg,
content: '',
thinking: undefined,
thinkingDurationMs: undefined,
providerContinuation: undefined,
toolCalls: undefined,
errorInfo: undefined,
executionStatus: undefined,
pendingApproval: undefined,
}));
}
const stripped = stripAnthropicSignedReasoningParts(retryBaseMessages);
if (!stripped.didStrip) {
throw streamErr;
}
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
const retryMessages = stripped.messages;
emitContextSnapshot(retryMessages);
await runStream(retryMessages, retryAssistantMsgId);
retryAssistantMsgId = generateId();
ui.addMessageToSession(sessionId, {
id: retryAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: activeModelId || context.activeProvider?.defaultModel || '',
providerId: context.activeProvider?.providerId,
});
} else {
throw streamErr;
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
...msg,
content: '',
thinking: undefined,
thinkingDurationMs: undefined,
providerContinuation: undefined,
toolCalls: undefined,
errorInfo: undefined,
executionStatus: undefined,
pendingApproval: undefined,
}));
}
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
const retryMessages = prepareMessagesForStream((await compactMessages(retryBaseMessages, {
force: true,
compressForRequestTooLargeRetry: true,
})).messages);
emitContextSnapshot(retryMessages);
await runStream(retryMessages, retryAssistantMsgId);
}
} catch (err) {
if (context.delegated && signal.aborted) return;
@@ -3,32 +3,7 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { fitLargeToolResultForModel } from './harness/toolResultFitting';
import type { GroupConfig, Host, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, VaultNote } from '../../domain/models';
import { handleVaultAgentOp, runSerializedVaultAgentRequest, sanitizeHostForAgent, shouldBypassVaultAgentSerialization, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
it('terminal reconnect is immediate and leaves vault mutations serialized', () => {
assert.equal(shouldBypassVaultAgentSerialization('session.reconnect', {}), true);
assert.equal(shouldBypassVaultAgentSerialization('host.update', {}), false);
assert.equal(shouldBypassVaultAgentSerialization('session.inspectRecovery', {}), true);
});
it('expired reconnect requests cannot execute after a delayed renderer delivery', async () => {
let called = false;
const result = await handleVaultAgentOp('session.reconnect', { sessionId: 's', expiresAt: Date.now() - 1000 }, {
reconnectSession: () => { called = true; return { ok: true }; },
} as VaultAgentApiDeps);
assert.equal(result.code, 'RECONNECT_EXPIRED');
assert.equal(result.started, false);
assert.equal(called, false);
});
it('session reconnect forwards runtime acknowledgement without claiming a connection', async () => {
const result = await handleVaultAgentOp('session.reconnect', { sessionId: 's' }, {
reconnectSession: (id) => ({ ok: true, sessionId: id, started: true, status: 'reconnecting' }),
} as VaultAgentApiDeps);
assert.equal(result.status, 'reconnecting');
assert.equal(result.sessionId, 's');
assert.equal((await handleVaultAgentOp('session.reconnect', { sessionId: 's' }, {} as VaultAgentApiDeps)).started, false);
});
import { handleVaultAgentOp, runSerializedVaultAgentRequest, sanitizeHostForAgent, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
type DepsSeed = {
hosts?: Host[];
@@ -574,8 +574,6 @@ export interface VaultAgentApiDeps {
ok: false;
error: string;
};
reconnectSession?: (sessionId: string) => Record<string, unknown>;
inspectSessionRecovery?: (sessionId: string) => Record<string, unknown>;
getScriptSessionMeta?: (sessionId: string) => {
connected?: boolean;
name?: string;
@@ -659,20 +657,6 @@ export async function handleVaultAgentOp(
): Promise<Record<string, unknown>> {
switch (op) {
case 'terminal.readContext': return handleTerminalContextRead(params);
case 'session.inspectRecovery': {
const sessionId = String(params.sessionId || '').trim();
if (!sessionId || !deps.inspectSessionRecovery) return { ok: false, error: 'Session recovery inspection is unavailable.' };
return deps.inspectSessionRecovery(sessionId);
}
case 'session.reconnect': {
const sessionId = String(params.sessionId || '').trim();
if (!sessionId) return { ok: false, started: false, error: 'sessionId is required.' };
if (typeof params.expiresAt === 'number' && Date.now() >= params.expiresAt) {
return { ok: false, started: false, code: 'RECONNECT_EXPIRED', error: 'Reconnect request expired before renderer dispatch. Inspect the session before continuing.' };
}
if (!deps.reconnectSession) return { ok: false, started: false, error: 'Session reconnect is not available in this window.' };
return deps.reconnectSession(sessionId);
}
case 'session.close': {
const sessionId = String(params.sessionId || '').trim();
if (!sessionId) return { ok: false, error: 'sessionId is required.' };
@@ -1592,9 +1576,6 @@ export function shouldBypassVaultAgentSerialization(
op: string,
params: Record<string, unknown>,
): boolean {
// Reconnect changes terminal runtime, not vault records. Never leave a
// recovery request queued behind an unrelated long-running vault operation.
if (op === 'session.reconnect' || op === 'session.inspectRecovery') return true;
if (op !== 'scripts.run' && op !== 'snippets.run') return false;
return parseOptionalBoolean(params.wait) ?? false;
}
-58
View File
@@ -93,61 +93,3 @@ test("compressed folder totals exclude directory entries", async () => {
}
}
});
test("non-ASCII folder names fall back to per-file upload on Windows", async () => {
const previousWindow = globalThis.window;
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
let supportChecks = 0;
const createdTasks: string[] = [];
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { platform: "Win32" },
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
netcatty: {
checkCompressedUploadSupport: async () => {
supportChecks += 1;
return { supported: true, localTar: true, remoteTar: true };
},
startCompressedUpload: async (options: { compressionId: string }) => (
{ compressionId: options.compressionId, success: true }
),
},
},
});
try {
const results = await uploadFoldersCompressed(
[
["前端", [{ file: null, localPath: "C:\\Users\\me\\前端\\a.txt", relativePath: "前端/a.txt", isDirectory: false, size: 1 }]],
["mpr", [{ file: null, localPath: "C:\\Users\\me\\等10个文件\\mpr\\a.txt", relativePath: "mpr/a.txt", isDirectory: false, size: 1 }]],
],
"/remote",
"sftp-1",
{ onTaskCreated: (task) => createdTasks.push(task.fileName) },
);
assert.deepEqual(results, [
{ fileName: "前端", success: false, error: "Compressed upload not supported - fallback needed" },
{ fileName: "mpr", success: true },
]);
assert.equal(supportChecks, 1, "the non-ASCII folder must not probe compressed upload support");
assert.deepEqual(createdTasks, ["mpr"], "no compressed task is created for the fallback folder");
} finally {
if (previousNavigator) {
Object.defineProperty(globalThis, "navigator", previousNavigator);
} else {
Reflect.deleteProperty(globalThis, "navigator");
}
if (previousWindow === undefined) {
Reflect.deleteProperty(globalThis, "window");
} else {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: previousWindow,
});
}
}
});
-10
View File
@@ -8,9 +8,6 @@ import type { UploadController } from "./uploadController";
const formatUploadError = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const isWindowsPlatform = (): boolean =>
typeof navigator !== "undefined" && /Win/i.test(navigator.platform);
export async function uploadFoldersCompressed(
folderEntries: Array<[string, DropEntry[]]>,
targetPath: string,
@@ -98,13 +95,6 @@ export async function uploadFoldersCompressed(
}
}
// Windows tar.exe converts argv to the ANSI code page, so a non-ASCII
// folder name would be mangled. Upload such folders file by file instead.
if (isWindowsPlatform() && /[\u0080-\uffff]/.test(folderName)) {
results.push({ fileName: folderName, success: false, error: "Compressed upload not supported - fallback needed" });
continue;
}
let taskId: string | null = null; // Declare taskId outside try block for error handling
try {
@@ -15,17 +15,6 @@ Read this for diagnostics, cancellation, or bounded recovery after a session can
- Runtime diagnostics:
- `<netcatty-cli-prefix> status --json`
- Reconnect a disconnected session in its existing tab/workspace, when exposed by the running build:
- `<netcatty-cli-prefix> session-reconnect --session <id> --json`
- This is a write operation under the existing scope and approval policy. It creates no session, transfers no ownership, and sends no task command. `started: true` acknowledges one reconnect request; it does not mean connected or shell-ready. Inspect `session --session <id> --json` before continuing.
- A connecting/reconnecting session is left alone. Authentication, host-key prompts and unavailable terminal handlers return without queuing a future reconnect. Do not send credentials or probes into those prompts.
- The backend limits agent-dispatched attempts per session incident to three, with 2/5 second backoff and a 120-second window for starting attempts. `RECONNECT_BACKOFF` includes `retryAfterMs`; `RECONNECT_LIMIT` stops this recovery branch. Observed connection success resets the incident; another chat/turn does not. Existing UI auto-reconnect has its own lifecycle; this tool does not add another automatic retry loop.
- Inspect and recover the same session with bounded automatic reconnect and readiness observation:
- `<netcatty-cli-prefix> session-recover --session <id> --json`
- Prefer this operation for a transient disconnect when available. It checks actual terminal state, uses the same three-attempt budget as `session-reconnect`, and waits up to 20 seconds per call. A `pending` result may be continued after `retryAfterMs`; continuation does not reset the 120-second recovery incident. The operation never opens/closes a tab or sends a task command.
- `ready` means an idle prompt was observed on a tracked shell. `connected` with `shellReady: null` means readiness is unknown (for example a raw device CLI); inspect the appropriate protocol. Exec still applies its normal readiness/permission checks.
- `needs_input`, `busy`, `unavailable`, `RECOVERY_CANCELLED`, `RECONNECT_LIMIT`, and `RECOVERY_LIMIT` stop automatic recovery for that dependency. Inspect authentication/host keys, the original job or terminal, or report the limit; continue independent authorized work. An explicit terminal Disconnect/Cancel is not undone by automatic recovery; a user UI Reconnect can clear that terminal stop.
- Prompt recognition remains a heuristic. After an ambiguous command outcome, inspect its original job or durable result before continuing; `ready` does not authorize replay of that command.
- Cancel outstanding Netcatty work for this chat scope:
- `<netcatty-cli-prefix> cancel --json`
- Re-enable execution for that same chat scope:
@@ -44,7 +33,7 @@ Read this for diagnostics, cancellation, or bounded recovery after a session can
1. Keep the task context: target host/session/workspace, active job, and last command outcome. Inspect `session --session <id> --json`; use scoped `env` when discovery is needed. Distinguish a transport failure from a program returning a nonzero exit code, a busy job, or a program waiting for input.
2. Prefer a usable existing session or an already in-flight auto-reconnect. For a known job, inspect it with `job-poll` at the host's normal polling cadence instead of injecting another command. Do not send a shell probe into a password/input prompt or an unknown foreground application.
3. For a transient connection/startup failure, allow at most three recovery attempts including the first, with waits of 2 seconds and then 5 seconds before retrying, and a 120-second elapsed budget including connection/wait time. Do not start another call if its budget would exceed the remaining time. User/host limits can shorten this policy. Do not reset an incident's budget by changing tabs or chat turns.
4. Prefer `session-recover`, or the lower-level `session-reconnect`, only if the running build exposes it and the current grant permits it. Explicit restart/move remain unavailable in this increment; `resume` only removes a chat cancellation block, and `host open` does not reconnect an inactive disconnected tab. Do not invent a command or switch identities to obtain a missing capability.
4. Use explicit reconnect/restart/move tools only if the running build exposes them and the current grant permits them. This PR adds no such tools; `resume` only removes a chat cancellation block, and `host open` does not reconnect an inactive disconnected tab. Do not invent a command or switch identities to obtain a missing capability.
5. When an independent connection is needed and host opening is available, verify the saved host identity and request the intended workspace explicitly (prefer its concrete ID when known). Create at most one new recovery session per incident; inspect/reuse that result instead of repeatedly opening tabs. Require successful scope binding and confirm session metadata before using it. Visible workspace membership alone does not grant access or ownership. Close only sessions the current scope is permitted to manage.
6. `SHELL_NOT_READY` from the tracked PTY readiness gate means the command was not delivered. Diagnose startup/busy/input/custom-prompt state before a bounded retry. An execution timeout, dropped response, or disconnect after delivery can mean the command ran: inspect its job/marker/process or durable result before considering replay, especially for writes or deployments.
7. Stop automatic recovery on authentication rejection, missing permission, revoked scope, an invalid target, or an explicit user stop. Do not call `resume` or open another connection to undo the user's cancellation. Preserve existing authorization for recovery actions already granted; do not ask for the same permission repeatedly.
-2
View File
@@ -359,8 +359,6 @@ declare global {
sessionId: string,
data: string,
options?: {
/** Byte-valued xterm onBinary string; bypasses text charset encoding. */
binary?: boolean;
automated?: boolean;
pasteRequestId?: string;
/** Host-classified secret/no-echo input; always bypasses plugin observers and interceptors. */