Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c23514d84 | ||
|
|
456ddcfe68 | ||
|
|
2a283a4f83 | ||
|
|
b29533259b |
@@ -7,6 +7,79 @@ import { Badge } from '../ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
* Pull the user-meaningful shell command out of the tool-call args.
|
||||
*
|
||||
* Different tool surfaces hand us different shapes:
|
||||
* - Netcatty's own `terminal_execute` MCP tool → `{command: "<string>"}`
|
||||
* - Codex `local_shell` (ACP) → `{command: ["zsh","-lc","<full>"]}`
|
||||
* - Claude `Bash` (ACP) → `{command: "<string>"}`
|
||||
*
|
||||
* And under the "Skill + CLI" integration, the agent's shell tool wraps a
|
||||
* call to our internal `netcatty-tool-cli` binary, so the real intent is one
|
||||
* level deeper:
|
||||
*
|
||||
* netcatty-tool-cli exec --session <id> --chat-session <id> -- <real-cmd>
|
||||
*
|
||||
* We unwrap both layers so the chat panel shows what the user actually
|
||||
* cares about (the remote command), not Codex's wrapper title which is
|
||||
* just the local path to the CLI binary.
|
||||
*/
|
||||
function extractDisplayCommand(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
|
||||
let cmdString: string;
|
||||
if (typeof raw === 'string') {
|
||||
if (!raw) return null;
|
||||
cmdString = raw;
|
||||
} else if (Array.isArray(raw) && raw.length > 0) {
|
||||
const isShellWrap =
|
||||
raw.length >= 3 &&
|
||||
/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/.test(String(raw[0] ?? '')) &&
|
||||
/^-l?c$/.test(String(raw[1] ?? ''));
|
||||
cmdString = isShellWrap
|
||||
? String(raw[raw.length - 1] ?? '')
|
||||
: raw.map((p) => String(p)).join(' ');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Netcatty CLI wrapper extraction.
|
||||
const cliIdx = cmdString.indexOf('netcatty-tool-cli');
|
||||
if (cliIdx >= 0) {
|
||||
const afterCli = cmdString
|
||||
.slice(cliIdx + 'netcatty-tool-cli'.length)
|
||||
.replace(/^["']?\s*/, '');
|
||||
const subMatch = afterCli.match(/^(\S+)/);
|
||||
const sub = subMatch ? subMatch[1] : '';
|
||||
|
||||
if (sub === 'exec' || sub === 'job-start') {
|
||||
// Pull out the command after the ` -- ` separator.
|
||||
const dashIdx = afterCli.indexOf(' -- ');
|
||||
if (dashIdx >= 0) {
|
||||
let inner = afterCli.slice(dashIdx + 4).trim();
|
||||
if (
|
||||
inner.length >= 2 &&
|
||||
((inner[0] === '"' && inner.endsWith('"')) ||
|
||||
(inner[0] === "'" && inner.endsWith("'")))
|
||||
) {
|
||||
inner = inner.slice(1, -1);
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
if (sub === 'job-poll') return 'netcatty: poll job';
|
||||
if (sub === 'job-stop') return 'netcatty: stop job';
|
||||
if (sub === 'session') return 'netcatty: inspect session';
|
||||
if (sub === 'env') return 'netcatty: list sessions';
|
||||
if (sub === 'status') return 'netcatty: status';
|
||||
if (sub) return `netcatty: ${sub}`;
|
||||
}
|
||||
|
||||
return cmdString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for display. Extracts stdout/stderr from structured
|
||||
* command results for terminal-like output.
|
||||
@@ -142,18 +215,22 @@ export const ToolCall = ({
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
{name === 'terminal_execute' && args?.command ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{String(args.command)}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const displayCmd = extractDisplayCommand(args);
|
||||
if (displayCmd) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{displayCmd}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{displayCmd}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-muted-foreground/70 truncate">{name}</span>;
|
||||
})()}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
|
||||
@@ -42,14 +42,22 @@ module.exports = {
|
||||
'!electron/.dev-config.json',
|
||||
'skills/**/*',
|
||||
'public/**/*',
|
||||
'node_modules/**/*'
|
||||
'node_modules/**/*',
|
||||
// @anthropic-ai/claude-agent-sdk@0.3.x bundles the native Claude Code
|
||||
// CLI (~211MB per arch) as optional sibling packages. Netcatty is
|
||||
// designed around the user's own Claude Code install — the wrapper
|
||||
// honors `CLAUDE_CODE_EXECUTABLE` (set by useAgentDiscovery.ts) and
|
||||
// only falls back to the bundled binary if that env var is empty.
|
||||
// Excluding the sibling packages from the build keeps the installer
|
||||
// ~150MB smaller and preserves the "bring your own Claude" design.
|
||||
'!node_modules/@anthropic-ai/claude-agent-sdk-*/**/*'
|
||||
],
|
||||
asarUnpack: [
|
||||
'node_modules/node-pty/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*',
|
||||
'node_modules/@vscode/windows-process-tree/**/*',
|
||||
'node_modules/@zed-industries/claude-agent-acp/**/*',
|
||||
'node_modules/@agentclientprotocol/claude-agent-acp/**/*',
|
||||
'node_modules/@agentclientprotocol/sdk/**/*',
|
||||
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',
|
||||
'node_modules/@zed-industries/codex-acp/**/*',
|
||||
|
||||
@@ -152,8 +152,12 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
}
|
||||
|
||||
case "fish":
|
||||
// Leading space: see the comment in the POSIX branch below. Fish
|
||||
// does not skip leading-space commands by default, but users can
|
||||
// define a `fish_should_add_to_history` function that filters them
|
||||
// — this prefix is what lets that opt-in actually take effect.
|
||||
return (
|
||||
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
||||
` set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
||||
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
|
||||
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
@@ -185,8 +189,15 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
// preventing the shell from aborting the compound command.
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
const escaped = escapePosixSingleQuoted(command);
|
||||
// Leading single space: lets bash/zsh skip recording this command
|
||||
// in history when the user already has HISTCONTROL=ignorespace
|
||||
// (bash) or HIST_IGNORE_SPACE (zsh) configured — Debian/Ubuntu and
|
||||
// most Oh-My-Zsh setups have this on by default; CentOS/RHEL users
|
||||
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
|
||||
// Without that config the prefix is harmless; it just doesn't
|
||||
// suppress history recording.
|
||||
return (
|
||||
`${marker}=0; ${marker}_cmd='${escaped}'; { printf '%s\\n' '${marker}_S'; trap ':' INT; ${noPager}eval "$${marker}_cmd"; __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }\n`
|
||||
` ${marker}=0; ${marker}_cmd='${escaped}'; { printf '%s\\n' '${marker}_S'; trap ':' INT; ${noPager}eval "$${marker}_cmd"; __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,11 +376,17 @@ function invalidateShellEnvCache() {
|
||||
// ── Claude Code ACP binary resolution ──
|
||||
|
||||
/**
|
||||
* Resolve the Claude ACP binary, returning { command, prependArgs }.
|
||||
* Resolve the Claude ACP binary, returning { command, prependArgs, env }.
|
||||
*
|
||||
* On macOS/Linux a shebang-based .js script can be spawned directly, but on
|
||||
* Windows `child_process.spawn` does not interpret shebangs — so when the
|
||||
* resolved path is a JS file we invoke it via the system Node runtime.
|
||||
* `@zed-industries/claude-agent-acp`'s `dist/index.js` is shipped as a Node
|
||||
* script (`#!/usr/bin/env node`). We bundle it with the app, but the user's
|
||||
* machine may not have `node` on PATH — Windows doesn't honour the shebang at
|
||||
* all, and on macOS/Linux the shebang only works when `node` is installed.
|
||||
*
|
||||
* To make the bundled copy self-sufficient, packaged builds run the script
|
||||
* with the Electron binary itself (via `ELECTRON_RUN_AS_NODE=1`). Dev mode
|
||||
* still prefers a PATH-resolved `claude-agent-acp` wrapper since `node` is
|
||||
* almost always available there.
|
||||
*/
|
||||
function resolveClaudeAcpBinaryPath(shellEnv, electronModule) {
|
||||
const binaryName = "claude-agent-acp";
|
||||
@@ -389,29 +395,34 @@ function resolveClaudeAcpBinaryPath(shellEnv, electronModule) {
|
||||
const isPackaged = electronModule?.app?.isPackaged;
|
||||
if (!isPackaged && shellEnv) {
|
||||
const systemPath = resolveCliFromPath(binaryName, shellEnv);
|
||||
if (systemPath) return { command: systemPath, prependArgs: [] };
|
||||
if (systemPath) return { command: systemPath, prependArgs: [], env: {} };
|
||||
}
|
||||
|
||||
// Packaged build (or dev fallback): use npm-bundled binary
|
||||
// Packaged build (or dev fallback): run the npm-bundled JS via process.execPath.
|
||||
// In packaged Electron `process.execPath` is the app binary (e.g. Netcatty.exe);
|
||||
// setting `ELECTRON_RUN_AS_NODE=1` makes it behave as the embedded Node so we
|
||||
// don't depend on the user having `node` installed. When `process.execPath`
|
||||
// is already a real `node` (e.g. during tests), no env var is needed.
|
||||
try {
|
||||
const resolved = require.resolve("@zed-industries/claude-agent-acp/dist/index.js");
|
||||
const resolved = require.resolve("@agentclientprotocol/claude-agent-acp/dist/index.js");
|
||||
const scriptPath = toUnpackedAsarPath(resolved);
|
||||
|
||||
// On Windows, .js files cannot be spawned directly (no shebang support) —
|
||||
// invoke via Node. In packaged Electron builds process.execPath is the
|
||||
// app binary (e.g. Netcatty.exe), not a Node runtime, so we must resolve
|
||||
// the real `node` from PATH. If Node is not installed, fall back to the
|
||||
// bare command name and let the system find the npm-generated .cmd wrapper.
|
||||
if (process.platform === "win32") {
|
||||
const nodePath = resolveCliFromPath("node", shellEnv);
|
||||
if (nodePath) {
|
||||
return { command: nodePath, prependArgs: [scriptPath] };
|
||||
}
|
||||
return { command: binaryName, prependArgs: [] };
|
||||
const runtime = process.execPath;
|
||||
if (runtime && existsSync(runtime)) {
|
||||
const runtimeBase = path.basename(runtime).toLowerCase();
|
||||
const isNodeBinary = runtimeBase === "node" || runtimeBase.startsWith("node.");
|
||||
const env = isNodeBinary ? {} : { ELECTRON_RUN_AS_NODE: "1" };
|
||||
return { command: runtime, prependArgs: [scriptPath], env };
|
||||
}
|
||||
return { command: scriptPath, prependArgs: [] };
|
||||
|
||||
// Last resort: try a system `node`, then the bare command name.
|
||||
const nodePath = shellEnv ? resolveCliFromPath("node", shellEnv) : null;
|
||||
if (nodePath) {
|
||||
return { command: nodePath, prependArgs: [scriptPath], env: {} };
|
||||
}
|
||||
return { command: binaryName, prependArgs: [], env: {} };
|
||||
} catch {
|
||||
return { command: binaryName, prependArgs: [] };
|
||||
return { command: binaryName, prependArgs: [], env: {} };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,12 +440,25 @@ function serializeStreamChunk(chunk) {
|
||||
case "reasoning-end":
|
||||
return { type: "reasoning-end", id: chunk.id ?? undefined };
|
||||
case "tool-call": {
|
||||
// ACP wraps all tools as "acp.acp_provider_agent_dynamic_tool" —
|
||||
// the real tool name and args are inside chunk.args
|
||||
// ACP wraps all tools as "acp.acp_provider_agent_dynamic_tool".
|
||||
// The real tool name and args are inside chunk.input — which the
|
||||
// @mcpc-tech/acp-ai-provider emits as a JSON.stringified payload
|
||||
// (see index.cjs, every controller.enqueue({ type: "tool-call", ...
|
||||
// input: JSON.stringify({ toolCallId, toolName, args }) })). AI SDK
|
||||
// may or may not pre-parse it before we see the chunk, so handle
|
||||
// both string and object shapes.
|
||||
const isAcpWrapper = chunk.toolName === "acp.acp_provider_agent_dynamic_tool";
|
||||
const acpInput = isAcpWrapper ? chunk.input : null;
|
||||
let acpInput = null;
|
||||
if (isAcpWrapper) {
|
||||
const raw = chunk.input ?? chunk.args;
|
||||
if (typeof raw === "string") {
|
||||
try { acpInput = JSON.parse(raw); } catch { acpInput = null; }
|
||||
} else if (raw && typeof raw === "object") {
|
||||
acpInput = raw;
|
||||
}
|
||||
}
|
||||
let realToolName = isAcpWrapper ? (acpInput?.toolName || chunk.toolName) : chunk.toolName;
|
||||
const realArgs = isAcpWrapper ? (acpInput?.args || chunk.args) : chunk.args;
|
||||
const realArgs = isAcpWrapper ? (acpInput?.args || chunk.args || chunk.input) : (chunk.input ?? chunk.args);
|
||||
const realToolCallId = isAcpWrapper ? (acpInput?.toolCallId || chunk.toolCallId) : chunk.toolCallId;
|
||||
// Simplify MCP tool names: "mcp__netcatty-remote-hosts__get_environment" → "get_environment"
|
||||
if (realToolName && realToolName.includes("__")) {
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
isPlausibleCliVersionOutput,
|
||||
looksLikeIdleAutoLogout,
|
||||
prepareCommandForSpawn,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
resolveClaudeCodeExecutableForAcp,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
@@ -283,3 +284,65 @@ test("looksLikeIdleAutoLogout returns false for empty / non-string input", () =>
|
||||
assert.equal(looksLikeIdleAutoLogout(undefined), false);
|
||||
assert.equal(looksLikeIdleAutoLogout(null), false);
|
||||
});
|
||||
|
||||
function withExecPath(fakePath, fn) {
|
||||
const original = process.execPath;
|
||||
Object.defineProperty(process, "execPath", { value: fakePath, configurable: true, writable: true });
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(process, "execPath", { value: original, configurable: true, writable: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("resolveClaudeAcpBinaryPath sets ELECTRON_RUN_AS_NODE when packaged execPath is not node", (t) => {
|
||||
// Simulate the packaged Electron case where process.execPath is the app
|
||||
// binary (e.g. Netcatty.exe). We copy the real node binary to a fake path
|
||||
// so existsSync() succeeds while basename != "node".
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-acp-runtime-"));
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
||||
const fakeRuntime = path.join(tempDir, process.platform === "win32" ? "Netcatty.exe" : "Netcatty");
|
||||
fs.copyFileSync(process.execPath, fakeRuntime);
|
||||
|
||||
const result = withExecPath(fakeRuntime, () =>
|
||||
resolveClaudeAcpBinaryPath(null, { app: { isPackaged: true } }),
|
||||
);
|
||||
|
||||
assert.equal(result.command, fakeRuntime);
|
||||
assert.equal(result.prependArgs.length, 1);
|
||||
assert.ok(
|
||||
result.prependArgs[0].endsWith(path.join("claude-agent-acp", "dist", "index.js")),
|
||||
`prependArgs[0] should point at the bundled script, got: ${result.prependArgs[0]}`,
|
||||
);
|
||||
assert.deepEqual(result.env, { ELECTRON_RUN_AS_NODE: "1" });
|
||||
});
|
||||
|
||||
test("resolveClaudeAcpBinaryPath leaves env empty when execPath is a real node binary", (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-acp-runtime-node-"));
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
||||
const fakeRuntime = path.join(tempDir, process.platform === "win32" ? "node.exe" : "node");
|
||||
fs.copyFileSync(process.execPath, fakeRuntime);
|
||||
|
||||
const result = withExecPath(fakeRuntime, () =>
|
||||
resolveClaudeAcpBinaryPath(null, { app: { isPackaged: true } }),
|
||||
);
|
||||
|
||||
assert.equal(result.command, fakeRuntime);
|
||||
assert.deepEqual(result.env, {});
|
||||
});
|
||||
|
||||
test("resolveClaudeAcpBinaryPath falls back to bundled script in dev mode when nothing is on PATH", () => {
|
||||
// Dev mode (isPackaged = false) with a shellEnv whose PATH cannot resolve
|
||||
// claude-agent-acp falls through to the bundled-script branch, which uses
|
||||
// process.execPath as the runtime.
|
||||
const result = resolveClaudeAcpBinaryPath({ PATH: "" }, { app: { isPackaged: false } });
|
||||
|
||||
assert.equal(result.command, process.execPath);
|
||||
assert.equal(result.prependArgs.length, 1);
|
||||
assert.ok(
|
||||
result.prependArgs[0].endsWith(path.join("claude-agent-acp", "dist", "index.js")),
|
||||
`prependArgs[0] should point at the bundled script, got: ${result.prependArgs[0]}`,
|
||||
);
|
||||
// Test runner's process.execPath is a real node, so no ELECTRON_RUN_AS_NODE here.
|
||||
assert.deepEqual(result.env, {});
|
||||
});
|
||||
|
||||
@@ -2407,6 +2407,9 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
if (claudeAcp?.env) {
|
||||
Object.assign(agentEnv, claudeAcp.env);
|
||||
}
|
||||
|
||||
provider = createACPProvider({
|
||||
command: resolvedCommand,
|
||||
@@ -2732,6 +2735,9 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
if (claudeAcp?.env) {
|
||||
Object.assign(agentEnv, claudeAcp.env);
|
||||
}
|
||||
const sessionMcpServers = isCopilotAgent ? [] : mcpSnapshot.mcpServers;
|
||||
|
||||
const provider = createACPProvider({
|
||||
@@ -2845,6 +2851,9 @@ function registerHandlers(ipcMain) {
|
||||
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
|
||||
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
|
||||
}
|
||||
if (fallbackClaudeAcp?.env) {
|
||||
Object.assign(fallbackEnv, fallbackClaudeAcp.env);
|
||||
}
|
||||
return fallbackEnv;
|
||||
})(),
|
||||
session: {
|
||||
|
||||
@@ -2059,7 +2059,9 @@ async function getSessionPwd(event, payload) {
|
||||
}, 5000);
|
||||
|
||||
// POSIX sh script that:
|
||||
// 1. Finds the sibling interactive shell under sshd ($PPID).
|
||||
// 1. Finds the user's interactive shell on the same SSH connection
|
||||
// (sibling under $PPID on newer OpenSSH, cousin reachable via the
|
||||
// shared SSH_CONNECTION env var on older OpenSSH like CentOS 7).
|
||||
// 2. Follows foreground child shells only, which covers bash->fish
|
||||
// without mistaking background shell scripts for the active shell.
|
||||
// 3. Reads /proc/<pid>/cwd via readlink.
|
||||
@@ -2069,20 +2071,60 @@ async function getSessionPwd(event, payload) {
|
||||
// so sh keeps the same PID and $PPID = sshd. Starting another shell
|
||||
// without exec would make $PPID point at the intermediate shell instead.
|
||||
const posixScript = `SELF=$$
|
||||
# Find the interactive shell child of this exec channel's sshd ($PPID).
|
||||
# Find the user's interactive shell on this SSH connection.
|
||||
# Prefer the one attached to a controlling tty (the user's shell): probe exec
|
||||
# channels like this one have no tty ("?"), and ps output is unsorted, so
|
||||
# without the tty preference a concurrent probe's shell could be picked when
|
||||
# several exist under the same sshd (#1065 review). Falls back to any shell
|
||||
# child if none has a tty.
|
||||
#
|
||||
# Strategy: try direct siblings of $PPID first — works on newer OpenSSH where
|
||||
# the PTY session and this exec channel share the same per-connection sshd
|
||||
# parent. Fall back to matching by SSH_CONNECTION env var, which covers older
|
||||
# OpenSSH (e.g. CentOS 7 / RHEL 7) that forks a SEPARATE sshd child per
|
||||
# channel — there the PTY shell ends up as a cousin (same grandparent sshd,
|
||||
# different parent) of this exec session, so the sibling search misses it
|
||||
# entirely (#1123).
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
_shell=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
}
|
||||
END { if (!found && any != "") print any }
|
||||
'
|
||||
')
|
||||
[ -n "$_shell" ] && { echo "$_shell"; return; }
|
||||
# SSH_CONNECTION is the unique client-port/server-port 4-tuple sshd injects
|
||||
# into every channel of one SSH connection, so processes that share it are
|
||||
# the channels of this very connection — and exactly one of them is the
|
||||
# user's PTY shell. Read /proc/<pid>/environ (NUL-separated, same uid only)
|
||||
# to find candidates, then pick the one with a shell comm and a controlling
|
||||
# tty. /proc/<pid>/comm is read directly here because ps -p PID -o tty=,comm=
|
||||
# gets misparsed on older procps (CentOS 7): the trailing ",comm=" is folded
|
||||
# into the tty column header instead of starting a second column, so tty and
|
||||
# comm come back swapped.
|
||||
_conn=$(tr '\\0' '\\n' < /proc/$SELF/environ 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)
|
||||
[ -z "$_conn" ] && return
|
||||
_any=""
|
||||
for _d in /proc/[0-9]*; do
|
||||
_pid=$(basename "$_d")
|
||||
[ "$_pid" = "$SELF" ] && continue
|
||||
[ -r "$_d/environ" ] || continue
|
||||
_conn2=$(tr '\\0' '\\n' < "$_d/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)
|
||||
[ "$_conn2" = "$_conn" ] || continue
|
||||
_comm=$(cat "$_d/comm" 2>/dev/null)
|
||||
case "$_comm" in
|
||||
sh|bash|zsh|fish|ksh|dash|ash) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
_tty=$(ps -p "$_pid" -o tty= 2>/dev/null | tr -d '[:space:]')
|
||||
if [ "$_tty" != "?" ] && [ -n "$_tty" ]; then
|
||||
echo "$_pid"
|
||||
return
|
||||
fi
|
||||
[ -z "$_any" ] && _any="$_pid"
|
||||
done
|
||||
[ -n "$_any" ] && echo "$_any"
|
||||
}
|
||||
# From the login shell, pick the DEEPEST foreground shell in its process
|
||||
# subtree. "Foreground" = the controlling tty's foreground process group ("+"
|
||||
|
||||
@@ -280,7 +280,13 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks): bo
|
||||
}
|
||||
case 'tool-call': {
|
||||
const toolName = (event.toolName as string) || 'unknown';
|
||||
const input = (event.input as Record<string, unknown>) || {};
|
||||
// The Electron bridge serializes tool args as `args` (see
|
||||
// shellUtils.cjs serializeStreamChunk), while direct AI SDK paths
|
||||
// use `input`. Read both so either source works.
|
||||
const input =
|
||||
(event.input as Record<string, unknown>) ||
|
||||
(event.args as Record<string, unknown>) ||
|
||||
{};
|
||||
const toolCallId = (event.toolCallId as string) || undefined;
|
||||
callbacks.onToolCall(toolName, input, toolCallId);
|
||||
return false;
|
||||
|
||||
675
package-lock.json
generated
675
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.tsx components/editor/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts lib/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.37.0",
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
"@ai-sdk/google": "^3.0.43",
|
||||
"@ai-sdk/openai": "^3.0.41",
|
||||
@@ -43,7 +44,7 @@
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@google/genai": "1.33.0",
|
||||
"@mcpc-tech/acp-ai-provider": "0.2.8",
|
||||
"@mcpc-tech/acp-ai-provider": "0.3.3",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-context-menu": "2.2.16",
|
||||
@@ -65,8 +66,7 @@
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@zed-industries/claude-agent-acp": "0.22.2",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
"ai": "^6.0.116",
|
||||
"clsx": "2.1.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
|
||||
Reference in New Issue
Block a user