[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
import type { ToolCall, ToolResult, AIPermissionMode, WebSearchConfig } from '../types';
import type {
TerminalContextReader,
} from '../../../domain/terminalContextRead';
import {
executeTerminalExecute,
executeWorkspaceGetInfo,
executeWorkspaceGetSessionInfo,
executeWebSearch,
executeUrlFetch,
type ToolDeps,
type ToolExecResult,
} from '../shared/toolExecutors';
import { fitTerminalExecuteResultForModel } from '../harness/terminalCompression';
/**
* Bridge interface for Catty Agent to interact with the Electron main process.
* This mirrors the AI-related subset of window.netcatty from electron/preload.cjs.
*/
export interface NetcattyBridge {
aiExec(
sessionId: string,
command: string,
chatSessionId?: string,
): Promise<{
ok: boolean;
stdout?: string;
stderr?: string;
exitCode?: number;
error?: string;
}>;
/**
* Cancel any in-flight Catty Agent command execution scoped to the
* given chat session. Idempotent — safe to call when nothing is
* running. Used by tools to re-issue cancel during the IPC transit
* window if the user clicks Stop after we've already dispatched
* `aiExec` but before the main process has registered it.
*/
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
aiCapability?(
rpcMethod: string,
params: Record<string, unknown>,
chatSessionId?: string,
): Promise<unknown>;
}
// Workspace context provided to the executor
export interface ExecutorContext {
// Available sessions in scope
sessions: Array<{
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>;
// Workspace info
workspaceId?: string;
workspaceName?: string;
readTerminalContext?: TerminalContextReader;
}
/** Convert a shared ToolExecResult into the executor's ToolResult format. */
function toToolResult(toolCallId: string, r: ToolExecResult): ToolResult {
if (r.ok === false) {
if (
typeof r.data === 'object'
&& r.data !== null
&& 'stdout' in r.data
&& 'stderr' in r.data
&& 'exitCode' in r.data
) {
const fitted = fitTerminalExecuteResultForModel(r.data as {
stdout: string;
stderr: string;
exitCode: number | null;
});
const output = [
r.error,
fitted.stdout ? `Partial output:\n${fitted.stdout}` : '',
fitted.stderr ? `Stderr:\n${fitted.stderr}` : '',
].filter(Boolean).join('\n\n');
return { toolCallId, content: output, isError: true };
}
return { toolCallId, content: r.error, isError: true };
}
// For terminal_execute, format as the legacy STDOUT/STDERR/exitCode text block
if (
typeof r.data === 'object' &&
r.data !== null &&
'stdout' in r.data &&
'stderr' in r.data &&
'exitCode' in r.data
) {
const d = r.data as { stdout: string; stderr: string; exitCode: number };
const output = [
d.stdout ? `STDOUT:\n${d.stdout}` : '',
d.stderr ? `STDERR:\n${d.stderr}` : '',
`Exit code: ${d.exitCode === -1 ? 'unknown' : d.exitCode}`,
]
.filter(Boolean)
.join('\n\n');
return { toolCallId, content: output || 'Command completed (no output)' };
}
// Default: JSON-serialize the data
return { toolCallId, content: JSON.stringify(r.data, null, 2) };
}
/**
* Create a tool executor function for the Catty Agent.
* This bridges tool calls to the netcatty Electron IPC layer.
*/
export function createToolExecutor(
bridge: NetcattyBridge | undefined,
context: ExecutorContext,
commandBlocklist?: string[],
permissionMode: AIPermissionMode = 'confirm',
webSearchConfig?: WebSearchConfig,
chatSessionId?: string,
): (toolCall: ToolCall) => Promise<ToolResult> {
return async (toolCall: ToolCall): Promise<ToolResult> => {
if (!bridge) {
return {
toolCallId: toolCall.id,
content: 'Netcatty bridge is not available',
isError: true,
};
}
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig, chatSessionId };
const args = toolCall.arguments;
try {
switch (toolCall.name) {
case 'terminal_execute': {
const r = await executeTerminalExecute(deps, {
sessionId: String(args.sessionId || ''),
command: String(args.command || ''),
});
return toToolResult(toolCall.id, r);
}
case 'workspace_get_info': {
const r = executeWorkspaceGetInfo(deps);
return toToolResult(toolCall.id, r);
}
case 'workspace_get_session_info': {
const r = executeWorkspaceGetSessionInfo(deps, {
sessionId: String(args.sessionId || ''),
});
return toToolResult(toolCall.id, r);
}
case 'web_search': {
const r = await executeWebSearch(deps, {
query: String(args.query || ''),
maxResults: Number(args.maxResults) || 5,
});
return toToolResult(toolCall.id, r);
}
case 'url_fetch': {
const r = await executeUrlFetch(deps, {
url: String(args.url || ''),
maxLength: Number(args.maxLength) || 50000,
});
return toToolResult(toolCall.id, r);
}
default:
return {
toolCallId: toolCall.id,
content: `Unknown tool: ${toolCall.name}`,
isError: true,
};
}
} catch (err) {
return {
toolCallId: toolCall.id,
content: `Tool execution error: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
}
};
}

View File

@@ -0,0 +1,163 @@
import commandBlocklistTable from '../../../lib/commandBlocklist.json';
import { DEFAULT_COMMAND_BLOCKLIST } from '../types';
/**
* Check if a regex pattern is safe from ReDoS attacks.
*
* Rejects patterns with nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`
* which can cause catastrophic backtracking / CPU exhaustion.
*/
function isSafeRegex(pattern: string): boolean {
// Detect nested quantifiers: a group containing a quantifier, followed by another quantifier.
// Matches patterns like (x+)+, (x*)+, (x+)*, (x{2,})+ etc.
const nestedQuantifier = /\([^)]*[+*}]\)[+*?{]/;
if (nestedQuantifier.test(pattern)) {
return false;
}
// Also catch overlapping alternations with quantifiers inside quantified groups
// e.g. (a|a)+ — not always dangerous but a common ReDoS vector
const overlappingAlt = /\([^)]*\|[^)]*\)[+*]{/;
if (overlappingAlt.test(pattern)) {
return false;
}
return true;
}
/**
* Pre-compiled RegExp cache for default blocklist patterns, grouped by the
* shell family the pattern targets.
*
* The blocklist is a best-effort defense-in-depth measure. It is NOT a
* security boundary — determined users or sophisticated prompt injection
* can bypass regex-based filtering. The primary security boundary is the
* permission / confirmation system and OS-level sandboxing.
*/
interface CompiledPattern { pattern: string; regex: RegExp }
const compileGroup = (patterns: string[]): CompiledPattern[] =>
patterns.flatMap((pattern) => {
try {
if (!isSafeRegex(pattern)) {
console.warn(`[Safety] Skipping default blocklist pattern with nested quantifiers (ReDoS risk): ${pattern}`);
return [];
}
return [{ pattern, regex: new RegExp(pattern, 'i') }];
} catch {
return [];
}
});
const compiledCommonGroup = compileGroup(commandBlocklistTable.common);
const compiledPosixNativeGroup = compileGroup(commandBlocklistTable.posixNative);
const compiledPosixGroup = compileGroup(commandBlocklistTable.posix);
const compiledPowershellGroup = compileGroup(commandBlocklistTable.powershell);
const compiledGroups = {
common: compiledCommonGroup,
posixNative: compiledPosixNativeGroup,
posix: compiledPosixGroup,
powershell: compiledPowershellGroup,
};
const compiledAllGroups = [
compiledCommonGroup,
compiledPosixNativeGroup,
compiledPosixGroup,
compiledPowershellGroup,
];
const DEFAULT_PATTERN_SET = new Set(DEFAULT_COMMAND_BLOCKLIST);
/**
* Default-blocklist groups that apply for a shell kind, from common
* (shell-independent) patterns to per-family ones. Unknown / empty kinds
* intentionally fall back to every group so callers that cannot classify a
* session keep the strict behavior.
*/
function selectDefaultGroups(shellKind?: string): CompiledPattern[][] {
const groupNames = commandBlocklistTable.shellGroups[
String(shellKind ?? '').toLowerCase() as keyof typeof commandBlocklistTable.shellGroups
];
if (!groupNames) return compiledAllGroups;
return groupNames.map((name) => compiledGroups[name as keyof typeof compiledGroups]);
}
function checkCommandAgainstGroups(
command: string,
blocklist: string[],
groups: CompiledPattern[][],
): { blocked: boolean; matchedPattern?: string } {
const enabledPatterns = new Set(blocklist);
// Settings entries that are not built-in defaults are user patterns and
// remain shell-independent.
for (const pattern of blocklist) {
if (DEFAULT_PATTERN_SET.has(pattern)) continue;
const regex = getCompiledPattern(pattern);
if (regex && regex.test(command)) {
return { blocked: true, matchedPattern: pattern };
}
}
// Shell selection narrows the built-in entries that are enabled in the
// configured list. It must not restore a default the user removed/edited.
for (const group of groups) {
for (const { pattern, regex } of group) {
if (enabledPatterns.has(pattern) && regex.test(command)) {
return { blocked: true, matchedPattern: pattern };
}
}
}
return { blocked: false };
}
/** Cache for user-provided (non-default) blocklist patterns. */
const userPatternCache = new Map<string, RegExp | null>();
function getCompiledPattern(pattern: string): RegExp | null {
if (userPatternCache.has(pattern)) {
return userPatternCache.get(pattern)!;
}
if (!isSafeRegex(pattern)) {
console.warn(`[Safety] Skipping user blocklist pattern with nested quantifiers (ReDoS risk): ${pattern}`);
userPatternCache.set(pattern, null);
return null;
}
try {
const regex = new RegExp(pattern, 'i');
userPatternCache.set(pattern, regex);
return regex;
} catch {
userPatternCache.set(pattern, null);
return null;
}
}
/**
* Check if a command matches any pattern in the blocklist.
* Returns the matching pattern if blocked, null if safe.
*
* The caller's list remains authoritative. User patterns apply on every shell,
* while enabled default patterns are narrowed by shell kind. Unknown shell
* kinds fall back to every enabled default group.
*
* Default blocklist patterns are pre-compiled at module load time.
* User-provided patterns are compiled once and cached.
*/
export function checkCommandSafety(
command: string,
blocklist: string[] = DEFAULT_COMMAND_BLOCKLIST,
shellKind?: string,
): { blocked: boolean; matchedPattern?: string } {
return checkCommandAgainstGroups(command, blocklist, selectDefaultGroups(shellKind));
}
/**
* Apply user patterns and enabled shell-independent defaults only. This is the
* safe pre-filter for renderer metadata that does not yet know the remote shell;
* the live bridge performs the final shell-selected check after probing.
*/
export function checkCommandSafetyCommonOnly(
command: string,
blocklist: string[] = DEFAULT_COMMAND_BLOCKLIST,
): { blocked: boolean; matchedPattern?: string } {
return checkCommandAgainstGroups(command, blocklist, [compiledCommonGroup]);
}

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildSystemPrompt } from './systemPrompt';
test('system prompt tells Catty how to import unknown attached host lists safely', () => {
const prompt = buildSystemPrompt({
scopeType: 'terminal',
hosts: [],
permissionMode: 'confirm',
});
assert.match(prompt, /list_attachments/i);
assert.match(prompt, /read_attachment/i);
assert.match(prompt, /unknown/i);
assert.match(prompt, /vault_hosts_create/i);
assert.match(prompt, /tool_output_read/i);
assert.match(prompt, /compressed|truncated/i);
});
test('system prompt prefers explicit script wait APIs', () => {
const prompt = buildSystemPrompt({
scopeType: 'terminal',
hosts: [],
permissionMode: 'confirm',
});
assert.match(prompt, /waitForText/);
assert.match(prompt, /waitForRegex/);
assert.doesNotMatch(prompt, /sendLine`,\s*`waitFor`,\s*dialogs/);
});
test('system prompt does not tell Catty to call host_open', () => {
const prompt = buildSystemPrompt({
scopeType: 'workspace',
hosts: [],
permissionMode: 'confirm',
});
assert.doesNotMatch(prompt, /host_open/);
assert.match(prompt, /cannot open new terminal sessions yourself/i);
assert.match(prompt, /ask them to open/i);
});

View File

@@ -0,0 +1,244 @@
export interface SystemPromptContext {
scopeType: 'terminal' | 'workspace' | 'global';
scopeLabel?: string;
hosts: Array<{
sessionId: string;
hostId?: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
activePortForwards?: Array<{
ruleId: string;
label?: string;
type?: string;
localPort?: number;
status?: string;
}>;
}>;
permissionMode: 'observer' | 'confirm' | 'auto';
webSearchEnabled?: boolean;
userSkillsContext?: string;
}
export function buildSystemPrompt(context: SystemPromptContext): string {
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled, userSkillsContext } = context;
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
const hostList = buildHostList(hosts);
const permissionRules = buildPermissionRules(permissionMode);
const shellGuidance = buildShellGuidance(hosts);
return `You are **Catty Agent**, a terminal automation assistant built into netcatty. You help users operate terminal sessions managed by Netcatty, including remote hosts and the user's local terminal.
## Current Scope
${scopeDescription}
## Available Sessions
${hostList}
${shellGuidance}
## Permission Mode: ${permissionMode}
${permissionRules}
## Guidelines
1. **Plan before acting.** When a task involves multiple steps, present a brief numbered plan to the user before executing.
2. **Use the right tool.** For normal shell commands, use \`terminal_execute\`. SFTP read/write, vault snippets, port forwarding, vault notes, and vault host tools are available when listed in your tool set — prefer them over manual shell workarounds.
**Prefer built-in diagnostic skills over hand-rolled command chains.** Use \`skill_run\` with \`skillName\` set to one of:
- \`diagnose_linux\` — CPU, memory, disk, Docker, systemd, kernel errors (Linux)
- \`diagnose_windows\` — CPU, memory, disk, top processes, services, ports, event log (Windows PowerShell)
- \`check_ports\` — All listening TCP/UDP ports with process info (auto-adapts to OS)
- \`check_docker\` — Docker daemon health, containers, disk usage
- \`security_audit\` — SSH config, firewall, failed logins, world-writable files (Linux)
These skills auto-select the correct commands for the host shell and return a structured report — much more reliable than building chains yourself.
**Vault → Hosts (SSH connections):** When the user asks to **add/create/import a host** (创建主机、添加主机、保存服务器连接凭据), use \`vault_hosts_create\` — NOT \`vault_notes_create\`. Extract \`hostname\`, \`username\`, \`password\` or local \`keyPath\`, \`port\`, \`group\`, \`tags\`, and \`label\` from the user's text; put long admin tables or remarks in the host's \`notes\` field (Host Details metadata). Call with \`dryRun: true\` first to preview, then write. Only use \`vault_hosts_import\` for known export formats (PuTTY, MobaXterm, CSV, SecureCRT, ssh_config). Use \`vault_hosts_list\` to check existing hosts and resolve \`hostId\` before \`vault_hosts_update\` or \`vault_hosts_delete\`.
**Open / connect a host:** You cannot open new terminal sessions yourself. Stay within the sessions listed under Available Sessions. If the user wants work on a saved host that is not already open in your scope, ask them to open that host (or add it to the current workspace) in the Netcatty UI, then continue once it appears in scope.
**Attached host files:** When the user asks to import attached host/server data, call \`list_attachments\` then \`read_attachment\`. If the attachment is a known export format, pass the exact text to \`vault_hosts_import\`. If the format is unknown or \`vault_hosts_import\` cannot detect it, do not search a terminal or remote filesystem; read the attached text, extract host fields yourself, and call \`vault_hosts_create\` with \`dryRun: true\` first. If a tool result is truncated or compressed and includes a \`tool_output_read\` handle, use \`tool_output_read\` to recover the needed original text before extracting fields.
**Vault → Notes (sidebar markdown docs):** When the user explicitly wants documentation saved to **Vault → Notes** (the notes sidebar / 保险箱笔记), use \`vault_notes_create\` or \`vault_notes_update\` — **not** \`host_notes_set\` (Host Details only) and **not** as a substitute for creating a host. When a message references a Vault note, use \`vault_notes_get\` with its exact \`noteId\` to read the latest content before summarizing or editing. Use that same ID for updates; do not substitute a title search. If the note no longer exists, tell the user.
**Snippets vs automation scripts:** Use \`snippets_*\` for shell command text (paste/execute with optional \`{{variables}}\`). Use \`scripts_*\` for multi-step terminal automation written in JavaScript with the \`nct.*\` API (\`await nct.screen.sendLine\`, \`waitForText\` / \`waitForRegex\`, dialogs, progress). Call \`scripts_reference\` before authoring or editing scripts. Run scripts with \`scripts_run\` (set \`wait: true\` to block until done); use \`scripts_runs_list\`, \`scripts_run_stop\`, \`scripts_run_pause\`, and \`scripts_run_resume\` for lifecycle control. Create/update/delete vault entries with \`snippets_create/update/delete\` (any kind) or \`scripts_create/update/delete\` (scripts only).
**Script triggers and hosts:** \`trigger: manual\` runs on demand; \`onConnect\` runs after SSH connect (global \`targetsAllHosts\`, dynamic \`targetGroups\`, then per-host \`connectScriptIds\` queue); \`onOutput\` runs when terminal output matches \`triggerPattern\` (regex). Link scripts to host IDs or dynamic group paths with \`scripts_targets_set\`, or manage per-host connect order with \`host_connect_scripts_list\` / \`host_connect_scripts_set\`.
**Never fallback:** If \`vault_hosts_create\` or \`vault_hosts_import\` fails, report the error to the user. Do **not** silently create a Vault note instead of the requested host.
When the user pastes unstructured text with host/server info, **you** extract fields and call \`vault_hosts_create\`. When operating on multiple sessions, call \`terminal_execute\` for each target session.
3. **Never execute dangerous commands.** Commands matching the blocklist (e.g. \`rm -rf /\`, \`mkfs\`, \`dd\` to disk devices, \`shutdown\`, fork bombs, recursive chmod 777 on root) are strictly forbidden and will be automatically denied. Do not attempt to bypass these restrictions.
4. **Explain before executing.** Before running any command, briefly explain what it does and why.
5. **Handle errors gracefully.** If a command fails, analyze the error output, explain what went wrong, and suggest alternatives or corrective actions. Do not retry the same failing command without modification.
6. **Stay focused.** Keep responses concise and relevant to terminal and server operations. Avoid unrelated commentary.
7. **Respect connection status.** Only attempt operations on sessions that are currently connected and listed in your scope. If a session is disconnected, ask the user to reconnect it in the Netcatty UI. If the needed host is not open in your scope, ask the user to open it (or join it into the current workspace) rather than inventing a workaround.
8. **Be careful with file operations.** When writing files via shell commands, prefer appending or targeted edits over full file overwrites when possible.
9. **Fetch URLs when provided.** When the user shares a URL or asks you to read a webpage, use \`url_fetch\` to retrieve its content.
10. **Network device sessions.** Sessions with \`protocol: serial\` (shell: raw) or \`deviceType: network\` (SSH-connected network equipment) are connected to network devices or embedded systems. They do NOT run a standard shell (bash/zsh/etc). Commands are sent as-is without shell wrapping. Do not use shell syntax (pipes, redirects, environment variables, subshells). Use the device's native CLI commands (e.g. Cisco IOS, Huawei VRP, Juniper JunOS). Exit codes are unavailable. Consider disabling pagination first (\`screen-length 0 temporary\` for Huawei, \`terminal length 0\` for Cisco). SFTP is not available for serial sessions.${webSearchEnabled ? `
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}
${userSkillsContext ? `\n\n## User Skills\n\n${userSkillsContext}` : ''}`;
}
function buildShellGuidance(
hosts: SystemPromptContext['hosts'],
): string {
const shells = new Set<string>();
for (const h of hosts) {
if (h.shellType) shells.add(h.shellType.toLowerCase());
}
const blocks: string[] = [];
if (shells.has('powershell')) {
blocks.push([
'### PowerShell (Windows) sessions — MUST follow these rules:',
'',
'- Command separator: use `;` NOT `&&` (PowerShell 5 does not support `&&`)',
'- HTTP: use `Invoke-RestMethod` / `Invoke-WebRequest`, NOT `curl` (PowerShell aliases curl to Invoke-WebRequest with incompatible params)',
'- Environment variables: use `$env:VARNAME`, NOT `$VARNAME`',
'- Processes: `Get-Process`, `Stop-Process -Id <pid>` — NOT `ps aux`, `kill -9`',
'- Services: `Get-Service`, `Start-Service`, `Stop-Service`',
'- File ops: `Get-ChildItem` (alias `dir`), `Remove-Item -Recurse -Force` (alias `rm -r -fo`)',
'- Run as admin: `Start-Process -Verb RunAs powershell`',
'- DO NOT use `<` input redirect (not supported), `&&` chaining, bash-style `$(...)` substitution (PowerShell uses `$()`)',
'- Use `Get-Content file | Select-String pattern` instead of `grep`',
].join('\n'));
}
if (shells.has('cmd')) {
blocks.push([
'### cmd.exe (Windows) sessions — MUST follow these rules:',
'',
'- Command separator: `&` or `&&`. Prefer separate lines or `&` when chaining.',
'- Environment variables: `%VARNAME%` (NOT `$VARNAME`, NOT `$env:VARNAME`)',
'- Process kill: `taskkill /PID <pid> /F`',
'- Service: `net start <svc>`, `net stop <svc>`',
'- Pipe `|` works but subshells `$()` are not available. No bash/PowerShell syntax.',
].join('\n'));
}
if (blocks.length === 0) return '';
return `\n## Shell-Specific Guidance\n\n${blocks.join('\n\n')}\n`;
}
function buildScopeDescription(
scopeType: 'terminal' | 'workspace' | 'global',
scopeLabel?: string,
): string {
switch (scopeType) {
case 'terminal':
return `You are scoped to a single terminal session${scopeLabel ? `: **${scopeLabel}**` : ''}. Focus operations on this specific session.`;
case 'workspace':
return `You are scoped to workspace${scopeLabel ? ` **${scopeLabel}**` : ''}. You can operate on any session within this workspace.`;
case 'global':
return `You have global scope and can operate on any connected session across all workspaces.`;
}
}
function formatHostChain(
hostChain: SystemPromptContext['hosts'][number]['hostChain'],
): string | null {
if (!hostChain?.length) return null;
return hostChain
.map((hop) => hop.label || hop.hostname || hop.hostId)
.join(' → ');
}
function formatActivePortForwards(
activePortForwards: SystemPromptContext['hosts'][number]['activePortForwards'],
): string | null {
if (!activePortForwards?.length) return null;
return activePortForwards
.map((rule) => {
const label = rule.label || rule.ruleId;
const port = rule.localPort != null ? `:${rule.localPort}` : '';
const status = rule.status ? ` (${rule.status})` : '';
return `${label}${port}${status}`;
})
.join(', ');
}
function buildHostList(
hosts: SystemPromptContext['hosts'],
): string {
if (hosts.length === 0) {
return '_No terminal sessions are currently available. The user needs to open or connect a terminal first._';
}
const lines = hosts.map(host => {
const status = host.connected ? 'connected' : 'disconnected';
const hostChain = formatHostChain(host.hostChain);
const portForwards = formatActivePortForwards(host.activePortForwards);
const details = [
`hostname: ${host.hostname}`,
`label: ${host.label}`,
host.protocol ? `protocol: ${host.protocol}` : null,
host.os ? `os: ${host.os}` : null,
host.username ? `user: ${host.username}` : null,
host.shellType ? `shell: ${host.shellType}` : null,
host.deviceType ? `deviceType: ${host.deviceType}` : null,
hostChain ? `hostChain: ${hostChain}` : null,
portForwards ? `portForwards: ${portForwards}` : null,
`status: ${status}`,
]
.filter(Boolean)
.join(', ');
return `- \`${host.sessionId}\` - ${details}`;
});
return lines.join('\n');
}
function buildPermissionRules(
permissionMode: 'observer' | 'confirm' | 'auto',
): string {
switch (permissionMode) {
case 'observer':
return [
'You are in **observer** mode. You may only perform read-only operations:',
'- Getting workspace and session info (`workspace_get_info`, `workspace_get_session_info`)',
'- Fetching URLs (`url_fetch`)',
'- Searching the web (`web_search`)',
'',
'All write and execute operations are denied. If the user asks you to run a command or modify a file, explain that observer mode does not allow it and suggest switching to confirm or auto mode.',
].join('\n');
case 'confirm':
return [
'You are in **confirm** mode. The system will automatically show an approval prompt to the user for write and execute operations:',
'- Command execution (`terminal_execute`) will pause and show approval buttons in the UI automatically.',
'',
'You do NOT need to ask the user for confirmation in your text responses. Just call the tool directly — the approval system handles it. Read-only operations are allowed without any approval.',
].join('\n');
case 'auto':
return [
'You are in **auto** mode. You may execute commands and write files without explicit per-action approval, as long as they are not on the blocklist.',
'',
'Even in auto mode:',
'- Always present a plan for multi-step tasks before starting.',
'- Blocked commands are still denied regardless of mode.',
'- Exercise caution with destructive or irreversible operations.',
].join('\n');
}
}