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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^1.2.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.219",
"@anthropic-ai/claude-agent-sdk": "^0.3.233",
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@capacitor/app": "^8.1.0",
"@capacitor/push-notifications": "^8.1.1",
Expand Down
51 changes: 34 additions & 17 deletions packages/codex-protocol/scripts/generate.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, rmSync } from "node:fs";
import { existsSync, renameSync, rmSync } from "node:fs";
import { delimiter, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
Expand All @@ -13,29 +13,43 @@ const binDirectories = [
const codexBinDirectory = binDirectories.find((directory) =>
existsSync(resolve(directory, executableName)),
);
const pnpmCli = process.env.npm_execpath;

if (!codexBinDirectory || !pnpmCli) {
if (!codexBinDirectory) {
console.error(
"Cannot generate Codex protocol types: the pinned @openai/codex binary or pnpm CLI is missing. Run `pnpm install` from the repository root, then try again.",
"Cannot generate Codex protocol types: the pinned @openai/codex binary is missing. Run `pnpm install` from the repository root, then try again.",
);
process.exit(1);
}

rmSync(resolve(packageDir, "generated"), { recursive: true, force: true });

const result = spawnSync(
process.execPath,
[pnpmCli, "exec", "codex", "app-server", "generate-ts", "--experimental", "--out", "./generated"],
{
cwd: packageDir,
env: {
...process.env,
PATH: `${codexBinDirectory}${delimiter}${process.env.PATH ?? ""}`,
},
stdio: "inherit",
// Generate into a staging directory and swap on success, so a failed run never
// leaves the package without its committed-quality `generated/` output.
const generatedDir = resolve(packageDir, "generated");
const stagingDir = resolve(packageDir, "generated.tmp");
rmSync(stagingDir, { recursive: true, force: true });

const generateArgs = ["app-server", "generate-ts", "--experimental", "--out", "./generated.tmp"];
// `npm_execpath` is pnpm's own CLI entry point. With a JS install it is a
// script that must run under node; the standalone distribution is a native
// executable that must be spawned directly. When it is unavailable (script run
// outside pnpm), invoke the codex bin resolved above.
const pnpmCli = process.env.npm_execpath;
const pnpmCliIsScript = pnpmCli !== undefined && /\.[cm]?js$/i.test(pnpmCli);
const [command, args] = pnpmCli
? pnpmCliIsScript
? [process.execPath, [pnpmCli, "exec", "codex", ...generateArgs]]
: [pnpmCli, ["exec", "codex", ...generateArgs]]
: [resolve(codexBinDirectory, executableName), generateArgs];

const result = spawnSync(command, args, {
cwd: packageDir,
env: {
...process.env,
PATH: `${codexBinDirectory}${delimiter}${process.env.PATH ?? ""}`,
},
);
stdio: "inherit",
// .CMD shims cannot be spawned directly on Windows.
shell: process.platform === "win32" && command.endsWith(".CMD"),
});

if (result.error) {
console.error(`Cannot generate Codex protocol types: ${result.error.message}`);
Expand All @@ -46,3 +60,6 @@ if (result.status !== 0) {
console.error(`Codex protocol generation failed with exit code ${result.status ?? "unknown"}.`);
process.exit(result.status ?? 1);
}

rmSync(generatedDir, { recursive: true, force: true });
renameSync(stagingDir, generatedDir);
76 changes: 39 additions & 37 deletions pnpm-lock.yaml

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

9 changes: 9 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ minimumReleaseAgeExclude:
# The OpenCode connector tracks the current SDK used by Desktop's compatibility path.
- "@opencode-ai/sdk@1.18.10"
# Claude steering relies on the current official SDK lifecycle controls.
- "@anthropic-ai/claude-agent-sdk@0.3.233"
- "@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.233"
- "@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.233"
- "@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.233"
- "@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.233"
- "@anthropic-ai/claude-agent-sdk-linux-x64@0.3.233"
- "@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.233"
- "@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.233"
- "@anthropic-ai/claude-agent-sdk-win32-x64@0.3.233"
- "@anthropic-ai/claude-agent-sdk@0.3.219"
- "@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.219"
- "@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.219"
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/composer/MentionInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ function placeCaretAtEnd(editor: HTMLDivElement): Range | null {
function skillChipDataset(segment: Extract<PromptSegment, { kind: "skill" }>) {
return {
skillName: segment.name,
skillPath: segment.path,
...(segment.path ? { skillPath: segment.path } : {}),
skillInvocation: segment.invocation,
skillProvider: segment.provider,
skillScope: segment.scope,
Expand Down
9 changes: 3 additions & 6 deletions src/renderer/components/composer/SlashCommandChip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,13 @@ export function createSlashCommandChipElement(
const chip = document.createElement("span");
chip.contentEditable = "false";
chip.dataset.slashCommand = command.id;
// `skillPath` is optional: provider-native skills carry no SKILL.md path.
const isSkill = Boolean(
command.skillName &&
command.skillPath &&
command.skillInvocation &&
command.skillProvider &&
command.skillScope,
command.skillName && command.skillInvocation && command.skillProvider && command.skillScope,
);
if (isSkill) {
chip.dataset.skillName = command.skillName;
chip.dataset.skillPath = command.skillPath;
if (command.skillPath) chip.dataset.skillPath = command.skillPath;
chip.dataset.skillInvocation = command.skillInvocation;
chip.dataset.skillProvider = command.skillProvider;
chip.dataset.skillScope = command.skillScope;
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/composer/serializeMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ export function serializeToSegments(container: HTMLDivElement): PromptSegment[]
if (el.dataset.slashCommand) {
if (
el.dataset.skillName &&
el.dataset.skillPath &&
el.dataset.skillInvocation &&
el.dataset.skillProvider &&
(el.dataset.skillScope === "global" || el.dataset.skillScope === "project")
Expand All @@ -129,7 +128,8 @@ export function serializeToSegments(container: HTMLDivElement): PromptSegment[]
segments.push({
kind: "skill",
name: el.dataset.skillName,
path: el.dataset.skillPath,
// Absent for provider-native skills (no SKILL.md on disk).
...(el.dataset.skillPath ? { path: el.dataset.skillPath } : {}),
invocation: el.dataset.skillInvocation,
provider: el.dataset.skillProvider,
scope: el.dataset.skillScope,
Expand Down
36 changes: 36 additions & 0 deletions src/renderer/components/thread/ThreadSlashCommands.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,42 @@ describe("ThreadSlashCommands", () => {
]);
});

it("keeps the SKILL.md path when a provider reports a locally scanned skill", () => {
const local = {
id: "browser-control",
label: "browser-control — Drive the browser",
description: "Drive the browser",
section: "skills" as const,
skillName: "browser-control",
skillPath: "/plugins/browser-tools/skills/browser-control/SKILL.md",
skillInvocation: "Use the browser-control skill.",
skillProvider: "Browser Tools",
skillScope: "global" as const,
pluginId: "browser-tools",
pluginName: "Browser Tools",
};

const commands = resolveAvailableSlashCommands(
[
{
// Provider-native entry for the same skill: no SKILL.md path.
id: "browser-control",
label: "browser-control — Drive the browser",
description: "Drive the browser",
section: "skills",
skillName: "browser-control",
skillInvocation: "Use the browser-control skill.",
skillProvider: "Claude",
skillScope: "global",
},
],
undefined,
{ skillCommands: [local] },
);

expect(commands).toEqual([local]);
});

it("finds ACP skill commands by their short display name", () => {
const command = {
id: "skill:simplify",
Expand Down
38 changes: 29 additions & 9 deletions src/renderer/components/thread/threadSlashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,39 @@ function dedupeBaseCommands(
});
}

/**
* Collapses skill entries reported by several sources to one per name. Earlier
* sources win, so a provider's own entry beats the locally scanned one (an ACP
* agent must be handed back its own wire id).
*
* The one exception: when *both* entries carry complete skill metadata and only
* the later one knows the skill's SKILL.md path, the path-bearing entry wins.
* A provider reporting a skill it also loads from disk (Claude's SDK lists the
* skills Poracode projected into `.claude/skills`) would otherwise erase the
* plugin identity and the on-disk path the supervisor's plugin policy and
* portable-skill fallback depend on. Position follows first sighting.
*/
function mergeSkillCommands(
...sources: (readonly AgentSlashCommand[] | undefined)[]
): AgentSlashCommand[] {
const seen = new Set<string>();
return sources.flatMap((commands) =>
(commands ?? []).flatMap((command) => {
if (!isSkillCommand(command)) return [];
const byName = new Map<string, AgentSlashCommand>();
for (const commands of sources) {
for (const command of commands ?? []) {
if (!isSkillCommand(command)) continue;
const name = (command.skillName ?? command.id).toLowerCase();
if (seen.has(name)) return [];
seen.add(name);
return [command];
}),
);
const existing = byName.get(name);
if (existing) {
const upgradesPath =
command.skillPath !== undefined &&
existing.skillPath === undefined &&
skillSegmentFromSlashCommand(existing) !== undefined &&
skillSegmentFromSlashCommand(command) !== undefined;
if (!upgradesPath) continue;
}
byName.set(name, command);
}
}
return [...byName.values()];
}

function resolveSkillCommands(
Expand Down
Loading