Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions server/modules/providers/services/external-cli-sessions.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { spawn } from 'node:child_process';
import { readFile, realpath, stat } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import { access, readFile, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { isAbsolute, join, relative, sep } from 'node:path';
import { delimiter, dirname, isAbsolute, join, relative, sep } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';

import Database from 'better-sqlite3';
Expand Down Expand Up @@ -667,10 +668,56 @@ const EXTERNAL_CLI_COMMAND: Record<ExternalSpawnCli, string> = {
omp: 'omp',
};

type ExternalCliExecutableResolverOptions = {
path?: string;
pathExt?: string;
platform?: NodeJS.Platform;
isExecutable?: (candidate: string) => Promise<boolean>;
};

export function withoutNodeModulesBins(pathValue: string): string {
return pathValue
.split(delimiter)
.filter((entry) => entry && !(dirname(entry).endsWith(`${sep}node_modules`) && entry.endsWith(`${sep}.bin`)))
.join(delimiter);
}

/** Resolves user-installed agents without letting ChatMux's npm scripts shadow them. */
export async function resolveExternalCliExecutable(
cli: ExternalSpawnCli,
options: ExternalCliExecutableResolverOptions = {},
): Promise<string> {
const command = EXTERNAL_CLI_COMMAND[cli];
const platform = options.platform ?? process.platform;
const searchPath = withoutNodeModulesBins(options.path ?? process.env.PATH ?? '');
const extensions = platform === 'win32'
? (options.pathExt ?? process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';')
: [''];
const isExecutable = options.isExecutable ?? (async (candidate: string) => {
try {
await access(candidate, fsConstants.X_OK);
return (await stat(candidate)).isFile();
} catch {
return false;
}
});

for (const directory of searchPath.split(delimiter).filter(Boolean)) {
for (const extension of extensions) {
const candidate = join(directory, `${command}${extension}`);
if (await isExecutable(candidate)) {
return candidate;
}
}
}
return command;
}

/** Boots and tags a native CLI in a fresh detached tmux session. */
export async function spawnExternalCliSession(cli: ExternalSpawnCli, tmuxName: string, cwd: string): Promise<void> {
const executable = await resolveExternalCliExecutable(cli);
await runCommand('tmux', [
'new-session', '-d', '-s', tmuxName, '-c', cwd, EXTERNAL_CLI_COMMAND[cli],
'new-session', '-d', '-s', tmuxName, '-c', cwd, executable,
]);
try {
await runCommand('tmux', ['set-option', '-t', tmuxName, '@chatmux_cli_kind', cli]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,43 @@ import {
parseClaudeRuntimeSession,
parseExternalPanes,
parsePsTree,
resolveExternalCliExecutable,
withoutNodeModulesBins,
} from '@/modules/providers/services/external-cli-sessions.service.js';

test('external CLI resolution excludes app-local npm shims', async () => {
const searchPath = [
'/app/node_modules/.bin',
'/Users/test/.local/bin',
'/opt/homebrew/bin',
].join(':');
assert.equal(
withoutNodeModulesBins(searchPath),
['/Users/test/.local/bin', '/opt/homebrew/bin'].join(':'),
);

const checked: string[] = [];
const resolved = await resolveExternalCliExecutable('codex', {
path: searchPath,
platform: 'darwin',
isExecutable: async (candidate) => {
checked.push(candidate);
return candidate === '/opt/homebrew/bin/codex';
},
});

assert.equal(resolved, '/opt/homebrew/bin/codex');
assert.deepEqual(checked, [
'/Users/test/.local/bin/codex',
'/opt/homebrew/bin/codex',
]);
});
test('normalizeExternalPaneOutput removes control bytes and bounds the pane tail', () => {
assert.equal(
normalizeExternalPaneOutput('old\r\n\u0000Trust this folder?\u0007\n1. Yes\n', 24),
'Trust this folder?\n1. Yes'.slice(-24),
);
});

test('parseExternalPanes splits session_name<TAB>pane_pid<TAB>pane_current_command', () => {
const out = parseExternalPanes('patina\t113501\tclaude\ntest\t360992\tnode\n\nbad-line\n');
assert.deepEqual(out, [
Expand Down