Compare commits

..

4 Commits

Author SHA1 Message Date
陈大猫
6c23514d84 fix(ai): prefix wrapped AI commands with a leading space (#1129)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Netcatty's AI / Skill+CLI integration sends marker-wrapped commands
(__NCMCP_xxx=0; { ... eval ...; }) straight into the user's interactive
shell. preload.cjs filters the PTY echo of those wrappers from the
visible terminal, but they still land in ~/.bash_history — making the
user's shell history hard to read after each AI session (#1126 user
report on v1.1.16).

Prefix the POSIX (bash/zsh/dash) and fish wrappers with a single space.
On the shells/configurations that already honor "ignore leading-space"
in history recording, those wrappers now skip the history file
entirely:

- bash with HISTCONTROL containing `ignorespace` (Debian/Ubuntu default
  via /etc/bash.bashrc, also part of `ignoreboth` which is the most
  common explicit setting)
- zsh with HIST_IGNORE_SPACE set (Oh-My-Zsh and most prezto templates
  enable this)
- fish with a user-defined fish_should_add_to_history function (opt-in
  via fish config)

Known limitations (no behavior change needed on netcatty's side):

- bash on bare RHEL/CentOS ships HISTCONTROL=ignoredups by default —
  leading space is not honored. Users on those distros can opt in with
  `HISTCONTROL=ignoreboth` in their ~/.bashrc.
- zsh without HIST_IGNORE_SPACE: same; add `setopt HIST_IGNORE_SPACE`.
- Fish without a custom history filter: leading space is not honored.
- PowerShell, cmd, network-device CLIs: unaffected (their wrappers are
  not changed, and the persistent-history semantics differ).

This is intentionally a minimal change — 4 characters of behavior plus
the explanatory comments. We rely on the user's existing shell config
instead of trying to mutate HISTCONTROL ourselves at session start,
which would either be visible in the terminal echo, mis-fire on hosts
that already had ignorespace (deleting a real previous history entry),
or error on non-POSIX shells.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:02:18 +08:00
陈大猫
456ddcfe68 chore(ai): upgrade ACP packages and unwrap Skill+CLI command in tool-call panel (#1128)
* chore(ai): upgrade ACP packages and unwrap Skill+CLI command in tool-call panel

Package bumps:
- @zed-industries/claude-agent-acp 0.22.2 → @agentclientprotocol/claude-agent-acp 0.37.0
  (old npm package is deprecated; scope rename)
- @zed-industries/codex-acp 0.10.0 → 0.15.0
- @mcpc-tech/acp-ai-provider 0.2.8 → 0.3.3
- electron-builder asarUnpack glob + bridge require.resolve switched to the new scope

After the upgrade Codex tool-call cards started showing the local
worktree path for every step — "Run /Users/.../netcatty-tool-cli session
--session …" — instead of the remote command. Three things lined up:

1. The new acp-ai-provider maps ACP's `title` to `toolName`, and Codex's
   title is the full shell invocation it's about to run.
2. Codex local_shell ships args as ["/bin/zsh","-lc","<full>"], so the
   old `typeof args.command === 'string'` branch in ToolCall never fired
   and we fell through to printing `name` (i.e. the title).
3. The bridge serializes tool args under `args`, but the ACP adapter
   only read `event.input`, so even when args were available the
   renderer received {}.

Fixes:
- acpAgentAdapter: read tool input from both `event.input` and
  `event.args` so bridge-serialized chunks and direct AI SDK chunks
  both work.
- ai-elements/tool-call: new extractDisplayCommand() unwraps the shell
  array, then the netcatty-tool-cli wrapper (exec/job-start … -- <cmd>),
  and renders the real remote command. session/env/job-poll/etc. fall
  back to short labels ("netcatty: inspect session", …) instead of
  exposing the binary path.
- shellUtils.cjs: defensive JSON-parse the ACP wrapper input in case
  the AI SDK ever stops auto-parsing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(build): exclude bundled Claude CLI binaries from the installer

@anthropic-ai/claude-agent-sdk@0.3.x bundles the native Claude Code CLI
(~211MB per arch) as optional sibling packages. Including them would
silently regress Netcatty's "bring your own Claude" design — the project
has always required users to install Claude Code locally, and the entire
path-discovery flow exists precisely to honor that contract:

- useAgentDiscovery.ts scans the user's PATH for `claude` and writes
  the absolute path into the agent config's CLAUDE_CODE_EXECUTABLE env.
- aiBridge.cjs runs normalizeClaudeCodeExecutableEnvForAcp on every ACP
  spawn, forwarding the env var to the child process.
- The @agentclientprotocol/claude-agent-acp wrapper's claudeCliPath()
  (acp-agent.js) prefers process.env.CLAUDE_CODE_EXECUTABLE over the
  bundled binary and only falls back to sibling-package resolution when
  the env var is empty.

So the right place to enforce the design is electron-builder: exclude
node_modules/@anthropic-ai/claude-agent-sdk-* from `files`. Dev mode is
unaffected (optional deps still install for `npm run dev`); only the
packaged installer drops the binaries, saving ~150MB. Users without
Claude Code installed get the same SDK error they got pre-upgrade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:26:14 +08:00
陈大猫
2a283a4f83 fix(ai): run bundled claude-agent-acp via Electron's Node (#1127)
@zed-industries/claude-agent-acp ships dist/index.js with a
`#!/usr/bin/env node` shebang. We bundle the package and unpack it from
asar, but Windows ignores the shebang entirely and macOS/Linux only
honours it when `node` is on the user's PATH. When `node` was missing,
the resolver fell back to spawning the bare `claude-agent-acp` command,
which only works if the user manually ran
`npm install -g @zed-industries/claude-agent-acp` — see #1118.

Run the bundled script through `process.execPath` with
`ELECTRON_RUN_AS_NODE=1` (matching `resolveMcpServerRuntimeCommand` in
the MCP server bridge) so the embedded Electron acts as the Node
runtime. This makes the bundled copy work with zero external deps on
every supported platform.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:50:54 +08:00
陈大猫
b29533259b fix(terminal): probe cwd via SSH_CONNECTION on older OpenSSH (#1123) (#1125) 2026-05-27 18:09:21 +08:00
10 changed files with 568 additions and 443 deletions

View File

@@ -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' && (

View File

@@ -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/**/*',

View File

@@ -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`
);
}
}

View File

@@ -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("__")) {

View File

@@ -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, {});
});

View File

@@ -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: {

View File

@@ -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 ("+"

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",