From 3b24353e5afe6125dd4a2bd9d585e1a2ada3308e Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Tue, 13 Jan 2026 23:23:23 +0100 Subject: [PATCH 1/8] feat: add /agent-promote command for hot-reloading agent modes Add new command to change plugin agent types (subagent/primary/all) at runtime without restarting OpenCode. Uses tui.showToast for notification and instance.dispose for config reload. --- README.md | 42 +++++++++++++++++++++++++ command/agent-promote.md | 36 ++++++++++++++++++++++ src/index.ts | 16 ++++++++-- src/tools/agent-promote.ts | 63 ++++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 1 + 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 command/agent-promote.md create mode 100644 src/tools/agent-promote.ts diff --git a/README.md b/README.md index 16342e8..e57bdbe 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Plugin providing Claude Code–style hooks, specialized agents (doc-writer, code - [gitingest](#gitingest) - [prompt-session](#prompt-session) - [list-child-sessions](#list-child-sessions) + - [agent-promote](#agent-promote) - [Blockchain](#blockchain) - [Configuration](#configuration) - [eth-transaction](#eth-transaction) @@ -65,6 +66,7 @@ Alternatively, clone or copy the plugin files to one of these directories: | Command | Description | Agent | |---------|-------------|-------| +| `/agent-promote ` | Change the type of a plugin agent at runtime. Grades: `subagent`, `primary`, `all` | - | | `/commit-push` | Stage, commit, and push changes with user confirmation | `build` | | `/diff-summary [source] [target]` | Show working tree changes or diff between branches | - | | `/doc-changes` | Update documentation based on uncommitted changes (new features only) | `doc-writer` | @@ -228,6 +230,46 @@ Child sessions (2): --- +### agent-promote + +Change the type of a plugin agent at runtime. Promotes subagents to primary agents (visible in Tab selection) or demotes them back. + +#### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | `string` | Yes | Name of the plugin agent (e.g., `rubber-duck`, `architect`) | +| `grade` | `string` | Yes | Target type: `subagent`, `primary`, or `all` | + +#### Grade Types + +| Grade | Effect | +|-------|--------| +| `subagent` | Available only as a subagent (default for most agents) | +| `primary` | Appears in Tab selection for direct use | +| `all` | Available both as primary and subagent | + +#### Usage Examples + +```bash +# Promote rubber-duck to use it directly via Tab +/agent-promote rubber-duck primary + +# Make architect available everywhere +/agent-promote architect all + +# Revert code-reviewer to subagent only +/agent-promote code-reviewer subagent +``` + +#### Notes + +- Only agents from this plugin can be promoted (see [Agents](#agents) table) +- Changes persist in memory until OpenCode restarts +- After promotion, use `Tab` or `a` to select the agent + +--- + ### Blockchain Tools for querying Ethereum and EVM-compatible blockchains via Etherscan APIs. diff --git a/command/agent-promote.md b/command/agent-promote.md new file mode 100644 index 0000000..cffa9ae --- /dev/null +++ b/command/agent-promote.md @@ -0,0 +1,36 @@ +--- +description: Change the type of an agent to primary, subagent or all +--- + +Use the `agent-promote` tool with the following arguments: +- name: $1 +- grade: $2 + +## Parameters + +| Parameter | Description | Valid values | +|-----------|-------------|--------------| +| `name` | Agent name from the plugin | `rubber-duck`, `architect`, `code-reviewer`, `code-simplifier`, `doc-writer`, `partner` | +| `grade` | Target agent type | `subagent`, `primary`, `all` | + +## Grade types + +| Grade | Availability | Access method | +|-------|--------------|---------------| +| `subagent` | Sub-agent only | @mention or Task tool | +| `primary` | Primary agent only | Tab selector | +| `all` | Everywhere | Tab selector, @mention, Task tool | + +### Details + +- **subagent**: Agent available only as a sub-agent. Can be invoked via `@agent-name` mention or through the Task tool to spawn child sessions. +- **primary**: Agent available only as a primary agent. Selectable via the Tab key in the agent selector. Cannot be used as a sub-agent. +- **all**: Agent available in all contexts. Can be selected as the main agent and also invoked as a sub-agent. + +## Examples + +``` +/agent-promote rubber-duck subagent +/agent-promote doc-writer primary +/agent-promote architect all +``` diff --git a/src/index.ts b/src/index.ts index f72cb79..4633137 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,8 @@ import { gitingestTool, createPromptSessionTool, createListChildSessionsTool, + createAgentPromoteTool, + getPromotedAgents, ethTransactionTool, ethAddressTxsTool, ethAddressBalanceTool, @@ -92,6 +94,7 @@ const SmartfrogPlugin: Plugin = async (ctx) => { hooks: Array.from(hooks.keys()), tools: [ "gitingest", + "agent-promote", "eth-transaction", "eth-address-txs", "eth-address-balance", @@ -262,8 +265,16 @@ const SmartfrogPlugin: Plugin = async (ctx) => { return { config: async (config: Record): Promise => { - if (Object.keys(agents).length > 0) { - config.agent = { ...(config.agent as Record ?? {}), ...agents } + const loadedAgents = loadAgents(AGENT_DIR) + + for (const [name, mode] of getPromotedAgents()) { + if (loadedAgents[name]) { + loadedAgents[name].mode = mode + } + } + + if (Object.keys(loadedAgents).length > 0) { + config.agent = { ...(config.agent as Record ?? {}), ...loadedAgents } } if (Object.keys(commands).length > 0) { config.command = { ...(config.command as Record ?? {}), ...commands } @@ -274,6 +285,7 @@ const SmartfrogPlugin: Plugin = async (ctx) => { gitingest: gitingestTool, "prompt-session": createPromptSessionTool(ctx.client), "list-child-sessions": createListChildSessionsTool(ctx.client), + "agent-promote": createAgentPromoteTool(ctx.client, Object.keys(agents)), "eth-transaction": ethTransactionTool, "eth-address-txs": ethAddressTxsTool, "eth-address-balance": ethAddressBalanceTool, diff --git a/src/tools/agent-promote.ts b/src/tools/agent-promote.ts new file mode 100644 index 0000000..8a93e71 --- /dev/null +++ b/src/tools/agent-promote.ts @@ -0,0 +1,63 @@ +import { tool, type ToolContext } from "@opencode-ai/plugin" +import type { createOpencodeClient } from "@opencode-ai/sdk" +import { log } from "../logger" + +type Client = ReturnType +type AgentMode = "subagent" | "primary" | "all" + +const VALID_GRADES: AgentMode[] = ["subagent", "primary", "all"] + +const promotedAgents = new Map() + +export function getPromotedAgents(): ReadonlyMap { + return promotedAgents +} + +export interface AgentPromoteArgs { + name: string + grade: string +} + +export function createAgentPromoteTool(client: Client, pluginAgentNames: string[]) { + return tool({ + description: "Change the type of an agent to primary, subagent or all", + args: { + name: tool.schema.string().describe("Name of the agent"), + grade: tool.schema.string().describe("Target type: 'subagent', 'primary', or 'all'"), + }, + async execute(args: AgentPromoteArgs, _context: ToolContext) { + const { name, grade } = args + + if (!VALID_GRADES.includes(grade as AgentMode)) { + return `Invalid grade "${grade}". Valid grades: ${VALID_GRADES.join(", ")}` + } + + if (!pluginAgentNames.includes(name)) { + return `Agent "${name}" not found in this plugin. Available: ${pluginAgentNames.join(", ")}` + } + + const agentsResp = await client.app.agents() + const agents = agentsResp.data ?? [] + const existingAgent = agents.find((a) => a.name === name) + + if (existingAgent && existingAgent.mode === grade) { + return `Agent "${name}" is already of type "${grade}"` + } + + promotedAgents.set(name, grade as AgentMode) + log("[agent-promote] Agent type changed", { name, grade }) + + await client.tui.showToast({ + body: { + message: `Promoting agent "${name}" to "${grade}"...`, + variant: "success", + duration: 3000, + }, + }) + + await client.instance.dispose() + + return `Agent "${name}" changed to type "${grade}". Use Tab or a to select it.` + }, + }) +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 715f641..694e281 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ export { gitingestTool, fetchGitingest, type GitingestArgs } from "./gitingest" export { createPromptSessionTool, type PromptSessionArgs } from "./prompt-session" export { createListChildSessionsTool } from "./list-child-sessions" +export { createAgentPromoteTool, getPromotedAgents, type AgentPromoteArgs } from "./agent-promote" export { ethTransactionTool, From 3e9b7e8e68bc664abdf78a40dcd9d92fff94f67b Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Tue, 13 Jan 2026 23:28:29 +0100 Subject: [PATCH 2/8] fix: add persist-credentials false to opencode-review workflow Fixes duplicate Authorization header error when OpenCode action tries to access the repository. --- .github/workflows/opencode-review.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index e040263..d6e79a1 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -14,6 +14,8 @@ jobs: issues: read steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} From cdf4482831c1efd1c59c9d123043e2a77a1c9ab9 Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Tue, 13 Jan 2026 23:32:43 +0100 Subject: [PATCH 3/8] fix: use claude-opus-4-5 model with OPENCODE_API_KEY for review workflow --- .github/workflows/opencode-review.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index d6e79a1..b4fb84b 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -18,9 +18,9 @@ jobs: persist-credentials: false - uses: anomalyco/opencode/github@latest env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: anthropic/claude-sonnet-4-20250514 + model: claude-opus-4-5 prompt: | Review this pull request: - Check for code quality issues From 46c760c4aa860d2c72625ca0796183c8c65ed4b6 Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Tue, 13 Jan 2026 23:33:15 +0100 Subject: [PATCH 4/8] fix: use opencode/claude-opus-4-5 model format --- .github/workflows/opencode-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index b4fb84b..02d9676 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -20,7 +20,7 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: - model: claude-opus-4-5 + model: opencode/claude-opus-4-5 prompt: | Review this pull request: - Check for code quality issues From 1f82163615def88c14ce2d1d0ad5461479564677 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 13 Jan 2026 22:34:47 +0000 Subject: [PATCH 5/8] PR review: needs tests, error handling, docs fix Co-authored-by: fdematos --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index bde3587..016f05b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-froggy", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-froggy", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", From 2c372f05054863b068277c6c472e02388fcacf67 Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Tue, 13 Jan 2026 23:40:07 +0100 Subject: [PATCH 6/8] fix: grant write permissions to opencode-review workflow --- .github/workflows/opencode-review.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 02d9676..c79c006 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -9,9 +9,9 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write - contents: read - pull-requests: read - issues: read + contents: write + pull-requests: write + issues: write steps: - uses: actions/checkout@v6 with: From 5caa28f32ff0f5ca1491db386c880a93d62f3c11 Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Tue, 13 Jan 2026 23:51:15 +0100 Subject: [PATCH 7/8] test: add unit tests for agent-promote tool Extract core logic to agent-promote-core.ts for testability. Tests cover validation, grade types, and promotedAgents Map. --- src/tools/agent-promote-core.ts | 21 ++++++++ src/tools/agent-promote.test.ts | 93 +++++++++++++++++++++++++++++++++ src/tools/agent-promote.ts | 32 +++++++----- 3 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 src/tools/agent-promote-core.ts create mode 100644 src/tools/agent-promote.test.ts diff --git a/src/tools/agent-promote-core.ts b/src/tools/agent-promote-core.ts new file mode 100644 index 0000000..8d1c438 --- /dev/null +++ b/src/tools/agent-promote-core.ts @@ -0,0 +1,21 @@ +export type AgentMode = "subagent" | "primary" | "all" + +export const VALID_GRADES: AgentMode[] = ["subagent", "primary", "all"] + +const promotedAgents = new Map() + +export function getPromotedAgents(): ReadonlyMap { + return promotedAgents +} + +export function setPromotedAgent(name: string, mode: AgentMode): void { + promotedAgents.set(name, mode) +} + +export function validateGrade(grade: string): grade is AgentMode { + return VALID_GRADES.includes(grade as AgentMode) +} + +export function validateAgentName(name: string, pluginAgentNames: string[]): boolean { + return pluginAgentNames.includes(name) +} diff --git a/src/tools/agent-promote.test.ts b/src/tools/agent-promote.test.ts new file mode 100644 index 0000000..f58550d --- /dev/null +++ b/src/tools/agent-promote.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest" +import { + validateGrade, + validateAgentName, + getPromotedAgents, + setPromotedAgent, + VALID_GRADES, + type AgentMode, +} from "./agent-promote-core" + +describe("agent-promote", () => { + const pluginAgentNames = ["rubber-duck", "architect", "code-reviewer"] + + describe("VALID_GRADES", () => { + it("should contain subagent, primary, and all", () => { + expect(VALID_GRADES).toContain("subagent") + expect(VALID_GRADES).toContain("primary") + expect(VALID_GRADES).toContain("all") + expect(VALID_GRADES).toHaveLength(3) + }) + }) + + describe("validateGrade", () => { + it("should return true for valid grade: subagent", () => { + expect(validateGrade("subagent")).toBe(true) + }) + + it("should return true for valid grade: primary", () => { + expect(validateGrade("primary")).toBe(true) + }) + + it("should return true for valid grade: all", () => { + expect(validateGrade("all")).toBe(true) + }) + + it("should return false for invalid grade", () => { + expect(validateGrade("invalid")).toBe(false) + expect(validateGrade("foo")).toBe(false) + expect(validateGrade("")).toBe(false) + }) + }) + + describe("validateAgentName", () => { + it("should return true for agent in plugin", () => { + expect(validateAgentName("rubber-duck", pluginAgentNames)).toBe(true) + expect(validateAgentName("architect", pluginAgentNames)).toBe(true) + expect(validateAgentName("code-reviewer", pluginAgentNames)).toBe(true) + }) + + it("should return false for agent not in plugin", () => { + expect(validateAgentName("unknown", pluginAgentNames)).toBe(false) + expect(validateAgentName("build", pluginAgentNames)).toBe(false) + expect(validateAgentName("", pluginAgentNames)).toBe(false) + }) + }) + + describe("promotedAgents Map", () => { + it("should set and get promoted agent", () => { + setPromotedAgent("test-agent-1", "primary") + const promoted = getPromotedAgents() + expect(promoted.get("test-agent-1")).toBe("primary") + }) + + it("should update existing promotion", () => { + setPromotedAgent("test-agent-2", "primary") + expect(getPromotedAgents().get("test-agent-2")).toBe("primary") + + setPromotedAgent("test-agent-2", "all") + expect(getPromotedAgents().get("test-agent-2")).toBe("all") + + setPromotedAgent("test-agent-2", "subagent") + expect(getPromotedAgents().get("test-agent-2")).toBe("subagent") + }) + + it("should handle multiple agents", () => { + setPromotedAgent("agent-a", "primary") + setPromotedAgent("agent-b", "all") + setPromotedAgent("agent-c", "subagent") + + const promoted = getPromotedAgents() + expect(promoted.get("agent-a")).toBe("primary") + expect(promoted.get("agent-b")).toBe("all") + expect(promoted.get("agent-c")).toBe("subagent") + }) + + it("should return readonly map", () => { + const promoted = getPromotedAgents() + expect(typeof promoted.get).toBe("function") + expect(typeof promoted.has).toBe("function") + expect(typeof promoted.forEach).toBe("function") + }) + }) +}) diff --git a/src/tools/agent-promote.ts b/src/tools/agent-promote.ts index 8a93e71..f9f352f 100644 --- a/src/tools/agent-promote.ts +++ b/src/tools/agent-promote.ts @@ -1,17 +1,25 @@ import { tool, type ToolContext } from "@opencode-ai/plugin" import type { createOpencodeClient } from "@opencode-ai/sdk" import { log } from "../logger" +import { + type AgentMode, + VALID_GRADES, + getPromotedAgents, + setPromotedAgent, + validateGrade, + validateAgentName, +} from "./agent-promote-core" -type Client = ReturnType -type AgentMode = "subagent" | "primary" | "all" - -const VALID_GRADES: AgentMode[] = ["subagent", "primary", "all"] - -const promotedAgents = new Map() +export { + type AgentMode, + VALID_GRADES, + getPromotedAgents, + setPromotedAgent, + validateGrade, + validateAgentName, +} from "./agent-promote-core" -export function getPromotedAgents(): ReadonlyMap { - return promotedAgents -} +type Client = ReturnType export interface AgentPromoteArgs { name: string @@ -28,11 +36,11 @@ export function createAgentPromoteTool(client: Client, pluginAgentNames: string[ async execute(args: AgentPromoteArgs, _context: ToolContext) { const { name, grade } = args - if (!VALID_GRADES.includes(grade as AgentMode)) { + if (!validateGrade(grade)) { return `Invalid grade "${grade}". Valid grades: ${VALID_GRADES.join(", ")}` } - if (!pluginAgentNames.includes(name)) { + if (!validateAgentName(name, pluginAgentNames)) { return `Agent "${name}" not found in this plugin. Available: ${pluginAgentNames.join(", ")}` } @@ -44,7 +52,7 @@ export function createAgentPromoteTool(client: Client, pluginAgentNames: string[ return `Agent "${name}" is already of type "${grade}"` } - promotedAgents.set(name, grade as AgentMode) + setPromotedAgent(name, grade) log("[agent-promote] Agent type changed", { name, grade }) await client.tui.showToast({ From 9d4e31df7506fd88cf295bd2266b79447e501300 Mon Sep 17 00:00:00 2001 From: Fred DE MATOS Date: Tue, 13 Jan 2026 23:56:25 +0100 Subject: [PATCH 8/8] refactor: simplify agent-promote command prompt --- command/agent-promote.md | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/command/agent-promote.md b/command/agent-promote.md index cffa9ae..77b1556 100644 --- a/command/agent-promote.md +++ b/command/agent-promote.md @@ -2,35 +2,4 @@ description: Change the type of an agent to primary, subagent or all --- -Use the `agent-promote` tool with the following arguments: -- name: $1 -- grade: $2 - -## Parameters - -| Parameter | Description | Valid values | -|-----------|-------------|--------------| -| `name` | Agent name from the plugin | `rubber-duck`, `architect`, `code-reviewer`, `code-simplifier`, `doc-writer`, `partner` | -| `grade` | Target agent type | `subagent`, `primary`, `all` | - -## Grade types - -| Grade | Availability | Access method | -|-------|--------------|---------------| -| `subagent` | Sub-agent only | @mention or Task tool | -| `primary` | Primary agent only | Tab selector | -| `all` | Everywhere | Tab selector, @mention, Task tool | - -### Details - -- **subagent**: Agent available only as a sub-agent. Can be invoked via `@agent-name` mention or through the Task tool to spawn child sessions. -- **primary**: Agent available only as a primary agent. Selectable via the Tab key in the agent selector. Cannot be used as a sub-agent. -- **all**: Agent available in all contexts. Can be selected as the main agent and also invoked as a sub-agent. - -## Examples - -``` -/agent-promote rubber-duck subagent -/agent-promote doc-writer primary -/agent-promote architect all -``` +Use the agent-promote tool to change agent $1 to grade $2.