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
4 changes: 2 additions & 2 deletions vscode-extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 112 additions & 1 deletion vscode-extension/src/agentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,109 @@ const REGISTRY_URL =
import * as vscode from "vscode";
import * as os from "os";
import * as path from "path";
import * as fs from "fs";
import { promisify } from "util";
import { exec } from "child_process";

const execAsync = promisify(exec);

/**
* Availability status for built-in agents
*/
export interface AvailabilityStatus {
available: boolean;
reason?: string;
}

/**
* Check if a command exists on the PATH
*/
async function commandExists(command: string): Promise<boolean> {
try {
const checkCmd =
process.platform === "win32" ? `where ${command}` : `which ${command}`;
await execAsync(checkCmd);
return true;
} catch {
return false;
}
}

/**
* Check if a directory exists
*/
async function directoryExists(dirPath: string): Promise<boolean> {
try {
const stats = await fs.promises.stat(dirPath);
return stats.isDirectory();
} catch {
return false;
}
}

/**
* Availability checks for built-in agents.
* If an agent is not in this map, it's always available.
*/
const AVAILABILITY_CHECKS: Record<string, () => Promise<AvailabilityStatus>> = {
"zed-claude-code": async () => {
const claudeDir = path.join(os.homedir(), ".claude");
if (await directoryExists(claudeDir)) {
return { available: true };
}
return { available: false, reason: "~/.claude not found" };
},
"zed-codex": async () => {
if (await commandExists("codex")) {
return { available: true };
}
return { available: false, reason: "codex not found on PATH" };
},
"google-gemini": async () => {
if (await commandExists("gemini")) {
return { available: true };
}
return { available: false, reason: "gemini not found on PATH" };
},
"kiro-cli": async () => {
if (await commandExists("kiro-cli-chat")) {
return { available: true };
}
return { available: false, reason: "kiro-cli-chat not found on PATH" };
},
// elizacp has no check - always available (symposium builtin)
};

/**
* Check availability for a single agent
*/
export async function checkAgentAvailability(
agentId: string,
): Promise<AvailabilityStatus> {
const check = AVAILABILITY_CHECKS[agentId];
if (!check) {
return { available: true };
}
return check();
}

/**
* Check availability for all built-in agents
*/
export async function checkAllBuiltInAvailability(): Promise<
Map<string, AvailabilityStatus>
> {
const results = new Map<string, AvailabilityStatus>();

await Promise.all(
BUILT_IN_AGENTS.map(async (agent) => {
const status = await checkAgentAvailability(agent.id);
results.set(agent.id, status);
}),
);

return results;
}

/**
* Distribution methods for spawning an agent
Expand Down Expand Up @@ -108,6 +211,14 @@ export const BUILT_IN_AGENTS: AgentConfig[] = [
},
_source: "custom",
},
{
id: "kiro-cli",
name: "Kiro CLI",
distribution: {
local: { command: "kiro-cli-chat", args: ["acp"] },
},
_source: "custom",
},
];

/**
Expand Down Expand Up @@ -198,7 +309,7 @@ export interface ResolvedCommand {
command: string;
args: string[];
env?: Record<string, string>;
/** If true, this is a built-in symposium subcommand - don't wrap with conductor */
/** If true, this is a built-in symposium subcommand - use the same binary as the downstream agent */
isSymposiumBuiltin?: boolean;
}

Expand Down
3 changes: 3 additions & 0 deletions vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export function activate(context: vscode.ExtensionContext) {
),
);

// Check agent availability at startup
settingsProvider.refreshAvailability();

// Register the command to open chat
context.subscriptions.push(
vscode.commands.registerCommand("symposium.openChat", () => {
Expand Down
80 changes: 59 additions & 21 deletions vscode-extension/src/settingsViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import {
getCurrentAgentId,
AgentConfig,
checkForRegistryUpdates,
checkAllBuiltInAvailability,
AvailabilityStatus,
} from "./agentRegistry";

export class SettingsViewProvider implements vscode.WebviewViewProvider {
public static readonly viewType = "symposium.settingsView";
#view?: vscode.WebviewView;
#extensionUri: vscode.Uri;
#availabilityCache: Map<string, AvailabilityStatus> = new Map();

constructor(extensionUri: vscode.Uri) {
this.#extensionUri = extensionUri;
Expand All @@ -22,6 +25,15 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider {
});
}

/**
* Refresh availability checks for built-in agents.
* Call this at activation and when the settings panel becomes visible.
*/
async refreshAvailability(): Promise<void> {
this.#availabilityCache = await checkAllBuiltInAvailability();
this.#sendConfiguration();
}

public resolveWebviewView(
webviewView: vscode.WebviewView,
context: vscode.WebviewViewResolveContext,
Expand All @@ -39,8 +51,8 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider {
// Handle webview visibility changes
webviewView.onDidChangeVisibility(() => {
if (webviewView.visible) {
// Refresh configuration when view becomes visible
this.#sendConfiguration();
// Refresh availability checks and configuration when view becomes visible
this.refreshAvailability();
}
});

Expand Down Expand Up @@ -171,12 +183,18 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider {
const config = vscode.workspace.getConfiguration("symposium");
const bypassList = config.get<string[]>("bypassPermissions", []);

// Get effective agents (built-ins + settings) and merge bypass settings
// Get effective agents (built-ins + settings) and merge bypass/availability settings
const effectiveAgents = getEffectiveAgents();
const agents = effectiveAgents.map((agent) => ({
...agent,
bypassPermissions: bypassList.includes(agent.id),
}));
const agents = effectiveAgents.map((agent) => {
const availability = this.#availabilityCache.get(agent.id);
return {
...agent,
bypassPermissions: bypassList.includes(agent.id),
// Only built-in agents have availability checks (registry agents are always available)
disabled: availability ? !availability.available : false,
disabledReason: availability?.reason,
};
});

const currentAgentId = getCurrentAgentId();
const requireModifierToSend = config.get<boolean>(
Expand Down Expand Up @@ -265,6 +283,18 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider {
.badge.bypass:hover {
opacity: 0.8;
}
.agent-item.disabled {
opacity: 0.5;
cursor: default;
}
.agent-item.disabled:hover {
background: var(--vscode-list-inactiveSelectionBackground);
}
.disabled-reason {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
.toggle {
font-size: 11px;
color: var(--vscode-descriptionForeground);
Expand Down Expand Up @@ -376,44 +406,52 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider {
for (const agent of agents) {
const displayName = agent.name || agent.id;
const isActive = agent.id === currentAgentId;
const isDisabled = agent.disabled;

const item = document.createElement('div');
item.className = 'agent-item' + (isActive ? ' active' : '');
item.className = 'agent-item' + (isActive ? ' active' : '') + (isDisabled ? ' disabled' : '');

const badges = [];
if (isActive) {
badges.push('<span class="badge">Active</span>');
}
if (agent.bypassPermissions) {
if (agent.bypassPermissions && !isDisabled) {
badges.push('<span class="badge bypass" title="Click to disable bypass permissions">Bypass Permissions</span>');
}

const versionHtml = agent.version
? \`<span class="agent-version">v\${agent.version}</span>\`
: '';

const disabledHtml = isDisabled && agent.disabledReason
? \`<span class="disabled-reason">(\${agent.disabledReason})</span>\`
: '';

item.innerHTML = \`
<div class="agent-name">
<span>\${displayName}</span>
\${versionHtml}
\${disabledHtml}
</div>
<div class="badges">\${badges.join('')}</div>
\`;

// Handle clicking on the agent name (switch agent)
const nameSpan = item.querySelector('.agent-name');
nameSpan.onclick = (e) => {
e.stopPropagation();
vscode.postMessage({ type: 'set-current-agent', agentId: agent.id, agentName: displayName });
};

// Handle clicking on the bypass badge (toggle bypass)
const bypassBadge = item.querySelector('.badge.bypass');
if (bypassBadge) {
bypassBadge.onclick = (e) => {
// Handle clicking on the agent name (switch agent) - only if not disabled
if (!isDisabled) {
const nameSpan = item.querySelector('.agent-name');
nameSpan.onclick = (e) => {
e.stopPropagation();
vscode.postMessage({ type: 'toggle-bypass-permissions', agentId: agent.id });
vscode.postMessage({ type: 'set-current-agent', agentId: agent.id, agentName: displayName });
};

// Handle clicking on the bypass badge (toggle bypass)
const bypassBadge = item.querySelector('.badge.bypass');
if (bypassBadge) {
bypassBadge.onclick = (e) => {
e.stopPropagation();
vscode.postMessage({ type: 'toggle-bypass-permissions', agentId: agent.id });
};
}
}

list.appendChild(item);
Expand Down