diff --git a/.env.example b/.env.example index 67369c76..fd2e9d56 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,9 @@ TUNNEL_TOKEN=replace-with-cloudflare-tunnel-token # 可选:浏览器自动化镜像。 # AGENTDOCK_IMAGE=ghcr.io/uvwt/agentdock:browser-latest # AGENTDOCK_BROWSER_ENABLED=true + +# AGENTS.md 原生自动上下文,默认开启。false 只禁用自动发现,不禁用显式 instructions 文件。 +# 这些变量需由实际 Core 启动环境或容器 environment/env_file 传入。 +# AGENTDOCK_AGENTS_AUTOLOAD=true +# 可选:覆盖 ${AGENTDOCK_HOME}/AGENTS.md 的全局规则来源,必须为 Core 可访问的绝对路径。 +# AGENTDOCK_INSTRUCTIONS_FILE=/path/to/global/AGENTS.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2678996..7fe38e8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -610,12 +610,13 @@ jobs: "$install_dir/bin/agentdock" --help >/dev/null 2>&1 - test -f "$data_dir/.agentdock/skill-store/bundled-skills.json" + test -f "$data_dir/.agentdock/skills/.system/.agentdock-system-skills.marker" for skill in skill-authoring skill-installation; do - state="$data_dir/.agentdock/skill-store/state/$skill.json" + state="$data_dir/.agentdock/skills/.state/$skill.json" version="$(jq -r .active_version "$state")" test -n "$version" - test -f "$data_dir/.agentdock/skill-store/installed/$skill/$version/SKILL.md" + test "$(jq -r .system "$state")" = "true" + test -f "$data_dir/.agentdock/skills/.system/$skill/SKILL.md" done systemd_dir="$RUNNER_TEMP/systemd" @@ -726,11 +727,12 @@ jobs: AGENTDOCK_INSTALL_DIR="$RUNNER_TEMP/bin" \ sh "$installer" --version "$tag" "$RUNNER_TEMP/bin/agentdock" --help >/dev/null 2>&1 - test -f "$RUNNER_TEMP/home/.agentdock/skill-store/bundled-skills.json" + test -f "$RUNNER_TEMP/home/.agentdock/skills/.system/.agentdock-system-skills.marker" for skill in skill-authoring skill-installation; do - state="$RUNNER_TEMP/home/.agentdock/skill-store/state/$skill.json" + state="$RUNNER_TEMP/home/.agentdock/skills/.state/$skill.json" version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["active_version"])' "$state")" - test -f "$RUNNER_TEMP/home/.agentdock/skill-store/installed/$skill/$version/SKILL.md" + test "$(python3 -c 'import json,sys; print(str(bool(json.load(open(sys.argv[1])).get("system"))).lower())' "$state")" = "true" + test -f "$RUNNER_TEMP/home/.agentdock/skills/.system/$skill/SKILL.md" done HOME="$RUNNER_TEMP/home" \ AGENTDOCK_INSTALLER_BASE_URL="$release_base" \ diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 379a33e8..5fa42cd0 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -105,6 +105,10 @@ jobs: $env:GOOS = 'windows' $env:GOARCH = 'amd64' go build -trimpath -o $runtimeTestAgentDockBinary .\cmd\agentdock + if ($LASTEXITCODE -ne 0) { throw 'Runtime test Core build failed.' } + $runtimeTestNativeLauncher = Join-Path $env:RUNNER_TEMP 'agentdock-tray-shim.exe' + go build -trimpath -ldflags '-s -w -H=windowsgui' -o $runtimeTestNativeLauncher .\cmd\agentdock-shim + if ($LASTEXITCODE -ne 0) { throw 'Runtime test native GUI launcher build failed.' } & .\scripts\test\test-windows-runtime-launch-diagnostics.ps1 ` -LauncherPath .\scripts\install\launch-windows-process.ps1 ` -AgentDockBinary $runtimeTestAgentDockBinary diff --git a/README.md b/README.md index 7bd6b2cf..5f0da2aa 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ English | [简体中文](./README.zh-CN.md) # AgentDock MCP +This fork's Windows release: **[AgentDock 1.0.1](https://github.com/A-m-o-r-F-a-t-i/agentdock/releases/tag/v1.0.1)** · [Fixes and verification scope](./docs/releases/v1.0.1.md). Version 1.0.0 is no longer recommended for Windows upgrades. + **Give AI agents secure, controlled access to every machine you operate.** Open ChatGPT in your browser and manage multiple computers and servers from one conversation. Write code, change configuration, run commands, and deploy in the real environment where the work belongs—without consuming a dedicated Codex coding quota. @@ -123,13 +125,15 @@ AgentDock exposes tools over MCP Streamable HTTP. The exact client syntax varies Official and community Skill sources live in [uvwt/agentdock-skills](https://github.com/uvwt/agentdock-skills). This repository only keeps core Skills that must ship with the AgentDock runtime, including bootstrap/security Skills and the built-in `agentdock-user-guide` official user guide. -- Validate, install, uninstall, activate, and roll back Skill packages +- Validate, install, uninstall, activate, enable, disable, and roll back Skill packages +- Store active Skills in a [Codex-style visible directory tree](docs/skill-directory-layout.md) while keeping inactive versions and transaction state hidden - Stable, development, canary, and pinned release channels - Isolated environment variables and runtimes for each Skill - Register, enable, disable, refresh, and remove dynamic MCP servers - Streamable HTTP and stdio transports - Search tools, inspect schemas, and perform controlled calls - Configuration isolation between MCP servers +- Install [self-contained heavy plugins](docs/heavy-plugins.md) that directly carry their Skills, MCP definitions, implementation files, switches, and two-stage progressive disclosure without a plugin cache layer ### Native ACP diff --git a/README.zh-CN.md b/README.zh-CN.md index 79fb0424..7ec452da 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,6 +6,8 @@ # AgentDock MCP +本分支 Windows 发行版:**[AgentDock 1.0.1](https://github.com/A-m-o-r-F-a-t-i/agentdock/releases/tag/v1.0.1)** · [修复内容与验证范围](./docs/releases/v1.0.1.md)。1.0.0 已撤下推荐状态,请勿继续用于覆盖安装。 + **让 AI 的双手,真正触达你的每一台设备。** 打开网页版 ChatGPT,即可管理多台电脑与服务器:在真实设备上写代码、改配置、跑命令与部署,执行发生在你的机器上,不消耗Codex额度。 @@ -127,13 +129,15 @@ AgentDock 通过 MCP Streamable HTTP 提供工具能力。下面是一个通用 官方与社区 Skill 源码统一维护在 [uvwt/agentdock-skills](https://github.com/uvwt/agentdock-skills)。本仓库只保留必须随 AgentDock 运行时发布的核心 Skill,包括自举/安全相关 Skill,以及内置的 `agentdock-user-guide` 官方用户指南。 -- Skill 包校验、安装、卸载、激活和回滚 +- Skill 包校验、安装、卸载、激活、启停和回滚 +- 当前激活 Skill 使用[与 Codex 一致的可见目录结构](docs/skill-directory-layout.md),非激活版本和事务状态保存在隐藏目录 - 稳定版、开发版、Canary 和固定版本通道 - Skill 独立环境变量与运行环境 - 动态 MCP Server 注册、启停、刷新和移除 - Streamable HTTP 与 stdio 传输 - 工具搜索、Schema 检查和受控调用 - MCP Server 之间的配置隔离 +- 安装[自包含重插件](docs/heavy-plugins.md):插件直接携带 Skill、MCP 定义、实现文件和开关,按两层渐进式方式暴露能力,不增加插件 cache 中间层 ### 原生 ACP diff --git a/cmd/agentdock-shim/main_windows.go b/cmd/agentdock-shim/main_windows.go index 3d40c33d..b5360b04 100644 --- a/cmd/agentdock-shim/main_windows.go +++ b/cmd/agentdock-shim/main_windows.go @@ -4,6 +4,7 @@ package main import ( "bytes" + "encoding/json" "errors" "fmt" "os" @@ -14,12 +15,20 @@ import ( "golang.org/x/sys/windows" + "github.com/uvwt/agentdock/internal/desktopruntime" "github.com/uvwt/agentdock/internal/fs/processlock" processctl "github.com/uvwt/agentdock/internal/process" "github.com/uvwt/agentdock/internal/updateengine" ) func main() { + if len(os.Args) == 3 && os.Args[1] == "--setup-exec" { + code, err := desktopruntime.RunSetupExecutor(os.Args[2]) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + } + os.Exit(code) + } if err := run(); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -27,6 +36,9 @@ func main() { } func run() error { + if len(os.Args) == 3 && (os.Args[1] == "--setup-launch" || os.Args[1] == "--setup-worker") { + return desktopruntime.RunSetupLauncher(os.Args[2], os.Args[1] == "--setup-worker") + } executable, err := os.Executable() if err != nil { return fmt.Errorf("resolve AgentDock stable entry: %w", err) @@ -44,12 +56,12 @@ func run() error { if err != nil { return err } - active, err := resolveActiveWithRecovery(root, store, layout) + tray := strings.EqualFold(filepath.Base(executable), updateengine.StableTrayShimName) + active, err := resolveActiveWithRecovery(root, store, layout, !tray && coreLaunchRequiresParentLifetime(os.Args[1:])) if err != nil { return err } - tray := strings.EqualFold(filepath.Base(executable), updateengine.StableTrayShimName) target := layout.GenerationCore(active.ActiveVersion) if tray { target = layout.GenerationTray(active.ActiveVersion) @@ -109,12 +121,26 @@ func coreLaunchRequiresParentLifetime(args []string) bool { strings.EqualFold(strings.TrimSpace(args[1]), "launch-core") } -func resolveActiveWithRecovery(root string, store *updateengine.Store, layout updateengine.WindowsLayout) (updateengine.ActiveVersion, error) { +func resolveActiveWithRecovery(root string, store *updateengine.Store, layout updateengine.WindowsLayout, allowInstallerHost ...bool) (updateengine.ActiveVersion, error) { active, err := store.ReadActive() if err != nil { return updateengine.ActiveVersion{}, fmt.Errorf("read AgentDock active version: %w", err) } + // An elevated Core task must enter through the stable, job-owning shim while + // Installer Engine is synchronously verifying its trial. Only that service + // host entry may use a matching live install journal; ordinary commands and + // abandoned trials retain the fail-closed behavior below. + if active.State == updateengine.StateTrial && len(allowInstallerHost) > 0 && allowInstallerHost[0] { + live, err := liveInstallerTrial(root, active) + if err != nil { + return updateengine.ActiveVersion{}, err + } + if live { + return active, nil + } + } + transaction, transactionErr := store.ReadTransaction() if transactionErr != nil { if active.State == updateengine.StateTrial { @@ -197,3 +223,45 @@ func trayRequiresWait(args []string) bool { } return false } + +// liveInstallerTrial never commits, repairs, or discards installation state. +func liveInstallerTrial(root string, active updateengine.ActiveVersion) (bool, error) { + data, err := os.ReadFile(filepath.Join(root, "install", "transaction.json")) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read installer trial: %w", err) + } + var transaction struct { + SchemaVersion int `json:"schema_version"` + TransactionID string `json:"transaction_id"` + Action string `json:"action"` + State updateengine.State `json:"state"` + Phase string `json:"phase"` + TargetVersion string `json:"target_version"` + InstallRoot string `json:"install_root"` + RuntimeRoot string `json:"runtime_root"` + } + if err := json.Unmarshal(data, &transaction); err != nil { + return false, fmt.Errorf("decode installer trial: %w", err) + } + if transaction.SchemaVersion != 1 || transaction.TransactionID == "" || + transaction.TransactionID != active.TransactionID || + (transaction.Action != "install" && transaction.Action != "repair") || + transaction.State != updateengine.StateTrial || active.State != updateengine.StateTrial || + (transaction.Phase != "start" && transaction.Phase != "health") || + updateengine.NormalizeVersion(transaction.TargetVersion) != updateengine.NormalizeVersion(active.ActiveVersion) || + !strings.EqualFold(filepath.Clean(transaction.InstallRoot), filepath.Clean(root)) || + !strings.EqualFold(filepath.Clean(transaction.RuntimeRoot), filepath.Clean(root)) { + return false, nil + } + lock, acquired, err := processlock.TryAcquire(filepath.Join(root, "install", "transaction.lock")) + if err != nil { + return false, fmt.Errorf("probe installer trial lock: %w", err) + } + if acquired { + return false, lock.Release() + } + return true, nil +} diff --git a/cmd/agentdock-shim/main_windows_test.go b/cmd/agentdock-shim/main_windows_test.go index 5b5a0a44..a6113e5c 100644 --- a/cmd/agentdock-shim/main_windows_test.go +++ b/cmd/agentdock-shim/main_windows_test.go @@ -2,7 +2,14 @@ package main -import "testing" +import ( + "encoding/json" + "github.com/uvwt/agentdock/internal/fs/processlock" + "github.com/uvwt/agentdock/internal/updateengine" + "os" + "path/filepath" + "testing" +) func TestTrayRequiresWaitOnlyDetachesNormalBackgroundLaunches(t *testing.T) { tests := []struct { @@ -49,3 +56,80 @@ func TestCoreLaunchRequiresParentLifetimeOnlyForServiceHost(t *testing.T) { }) } } + +func TestInstallerTrialRequiresLiveMatchingOwner(t *testing.T) { + root := t.TempDir() + directory := filepath.Join(root, "install") + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + active := updateengine.ActiveVersion{SchemaVersion: 1, State: updateengine.StateTrial, TransactionID: "install-test", ActiveVersion: "v0.8.4"} + valid := func() map[string]any { + return map[string]any{"schema_version": 1, "action": "install", "transaction_id": "install-test", "state": "trial", "phase": "start", "target_version": "v0.8.4", "install_root": root, "runtime_root": root} + } + write := func(value map[string]any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "transaction.json"), data, 0600); err != nil { + t.Fatal(err) + } + } + write(valid()) + if live, err := liveInstallerTrial(root, active); err != nil || live { + t.Fatalf("unowned trial: live=%v err=%v", live, err) + } + lock, acquired, err := processlock.TryAcquire(filepath.Join(directory, "transaction.lock")) + if err != nil || !acquired { + t.Fatalf("hold fixture lock: %v", err) + } + defer lock.Release() + for _, test := range []struct { + name, key string + value any + want bool + }{ + {name: "matching start", want: true}, + {name: "matching health", key: "phase", value: "health", want: true}, + {name: "wrong id", key: "transaction_id", value: "other"}, + {name: "wrong target", key: "target_version", value: "v0.8.3"}, + {name: "unsupported schema", key: "schema_version", value: 2}, + {name: "failed", key: "state", value: "failed"}, + {name: "uninstall", key: "action", value: "uninstall"}, + {name: "adapter commit wait", key: "phase", value: "commit"}, + {name: "wrong install root", key: "install_root", value: filepath.Join(root, "other")}, + {name: "wrong runtime root", key: "runtime_root", value: filepath.Join(root, "other")}, + } { + t.Run(test.name, func(t *testing.T) { + value := valid() + if test.key != "" { + value[test.key] = test.value + } + write(value) + live, err := liveInstallerTrial(root, active) + if err != nil || live != test.want { + t.Fatalf("live=%v want=%v err=%v", live, test.want, err) + } + }) + } + write(valid()) + store, err := updateengine.NewStore(root) + if err != nil { + t.Fatal(err) + } + if err := store.WriteActive(active); err != nil { + t.Fatal(err) + } + layout, err := updateengine.NewWindowsLayout(root) + if err != nil { + t.Fatal(err) + } + if _, err := resolveActiveWithRecovery(root, store, layout); err == nil { + t.Fatal("ordinary shim command must not enter an installer trial") + } + if got, err := resolveActiveWithRecovery(root, store, layout, true); err != nil || got.TransactionID != active.TransactionID { + t.Fatalf("live service host: %+v %v", got, err) + } +} diff --git a/cmd/agentdock/command_install.go b/cmd/agentdock/command_install.go index b0789e39..813b8b4e 100644 --- a/cmd/agentdock/command_install.go +++ b/cmd/agentdock/command_install.go @@ -28,6 +28,9 @@ func runInstallCommand(ctx context.Context, args []string, stdout, stderr io.Wri if len(args) > 0 && args[0] == "commit" { return runInstallCommit(ctx, args[1:], stdout, stderr) } + if len(args) > 0 && args[0] == "restore-files" { + return runInstallRestoreFiles(ctx, args[1:], stdout, stderr) + } if len(args) > 0 && args[0] == "prepare-windows-legacy" { return runInstallPrepareWindowsLegacy(ctx, args[1:], stdout, stderr) } @@ -58,6 +61,7 @@ func runInstallCommand(ctx context.Context, args []string, stdout, stderr io.Wri flags.StringVar(&request.BinaryPath, "binary", "", "已就位的 agentdock 二进制") flags.StringVar(&request.SkillBundle, "skill-bundle", "", "核心 Skill Bundle 目录") flags.StringVar(&request.Version, "version", "", "目标版本") + flags.StringVar(&request.TransactionID, "transaction-id", "", "OS adapter 预分配的32位十六进制事务 ID") flags.StringVar(&request.Channel, "channel", "official", "安装通道") // host/port 默认必须是“未指定”,不能写成 127.0.0.1:8765。 // 否则 update/repair 省略这两个标志时会把用户已有监听地址覆盖掉。 @@ -231,6 +235,7 @@ func runInstallAbandon(ctx context.Context, args []string, stdout, stderr io.Wri flags.SetOutput(stderr) var request installer.Request request.Action = installer.ActionAbandon + flags.BoolVar(&request.RequireHealth, "require-health", false, "确认原版本健康后才结束回滚") flags.StringVar(&request.InstallRoot, "install-root", "", "安装根目录") flags.StringVar(&request.RuntimeRoot, "runtime-root", "", "运行配置目录") flags.StringVar(&request.TransactionID, "transaction-id", "", "要撤销的 install 事务 ID") @@ -254,6 +259,8 @@ func runInstallCommit(ctx context.Context, args []string, stdout, stderr io.Writ flags.SetOutput(stderr) var request installer.Request request.Action = installer.ActionCommit + flags.BoolVar(&request.RequireHealth, "require-health", false, "提交前确认目标版本健康") + flags.BoolVar(&request.KeepJournal, "keep-journal", false, "为外层安装激活阶段保留回滚文件") flags.StringVar(&request.InstallRoot, "install-root", "", "安装根目录") flags.StringVar(&request.RuntimeRoot, "runtime-root", "", "运行配置目录") flags.StringVar(&request.TransactionID, "transaction-id", "", "要提交的 install 事务 ID") diff --git a/cmd/agentdock/command_install_restore.go b/cmd/agentdock/command_install_restore.go new file mode 100644 index 00000000..e60189bc --- /dev/null +++ b/cmd/agentdock/command_install_restore.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "io" + "strings" + + "github.com/uvwt/agentdock/internal/installer" +) + +func runInstallRestoreFiles(ctx context.Context, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("agentdock install restore-files", flag.ContinueOnError) + flags.SetOutput(stderr) + request := installer.Request{Action: installer.ActionRestoreFiles} + flags.StringVar(&request.InstallRoot, "install-root", "", "安装根目录") + flags.StringVar(&request.RuntimeRoot, "runtime-root", "", "运行配置目录") + flags.StringVar(&request.TransactionID, "transaction-id", "", "本次事务 ID,必填") + flags.StringVar(&request.PayloadDir, "payload-dir", "", "已验证的新载荷,用于停止运行时") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 || strings.TrimSpace(request.TransactionID) == "" { + return errors.New("restore-files requires --install-root and --transaction-id") + } + result, err := (installer.Engine{}).Run(ctx, request) + if err != nil { + return err + } + return json.NewEncoder(stdout).Encode(result) +} diff --git a/cmd/agentdock/command_plugin.go b/cmd/agentdock/command_plugin.go new file mode 100644 index 00000000..3a678e01 --- /dev/null +++ b/cmd/agentdock/command_plugin.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/uvwt/agentdock/internal/installer" + "github.com/uvwt/agentdock/internal/plugin" +) + +func runPluginCommand(ctx context.Context, args []string, stdout, stderr io.Writer) error { + if len(args) == 0 { + return errors.New("用法:agentdock plugin validate --source <目录或ZIP> | migrate --home <离线数据目录> | list --home <数据目录>") + } + flags := flag.NewFlagSet("agentdock plugin "+args[0], flag.ContinueOnError) + flags.SetOutput(stderr) + source := flags.String("source", "", "标准插件目录或ZIP") + home := flags.String("home", "", "显式数据目录;migrate 前应停止该实例") + if err := flags.Parse(args[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("plugin 子命令不接受额外位置参数") + } + switch args[0] { + case "migrate": + if *source != "" || !filepath.IsAbs(*home) { + return errors.New("migrate 需要 --home 绝对目录,且不接受 --source") + } + names, err := installer.MigratePluginHome(ctx, *home) + if err != nil { + return err + } + return json.NewEncoder(stdout).Encode(map[string]any{"migrated": names, "format": "Agent Plugins 1.0.0"}) + case "validate": + if *source == "" || *home != "" { + return errors.New("validate 需要 --source,且不接受 --home") + } + temporary, err := os.MkdirTemp("", "agentdock-plugin-validation-") + if err != nil { + return err + } + defer os.RemoveAll(temporary) + store, err := plugin.New(temporary) + if err != nil { + return err + } + definition, err := store.Validate(*source) + if err != nil { + return err + } + if err := json.NewEncoder(stdout).Encode(definition); err != nil { + return err + } + if len(definition.Diagnostics) > 0 { + return fmt.Errorf("插件包含 %d 项校验提示", len(definition.Diagnostics)) + } + return nil + case "list": + if !filepath.IsAbs(*home) || *source != "" { + return errors.New("list 需要 --home 绝对目录,且不接受 --source") + } + store, err := plugin.New(*home) + if err != nil { + return err + } + items, err := store.List() + if err != nil { + return err + } + return json.NewEncoder(stdout).Encode(items) + default: + return errors.New("未知 plugin 子命令") + } +} diff --git a/cmd/agentdock/command_skill.go b/cmd/agentdock/command_skill.go index 28961846..4532b65f 100644 --- a/cmd/agentdock/command_skill.go +++ b/cmd/agentdock/command_skill.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "io" + "os" "strings" "github.com/uvwt/agentdock/internal/config" @@ -28,13 +29,10 @@ func runSkillCommand(ctx context.Context, args []string, stdout, stderr io.Write return errors.New("用法:agentdock skill bootstrap --bundle <目录>") } - cfg, err := config.FromEnv() + cfg, err := config.StorageConfig(os.Getenv("AGENTDOCK_HOME")) if err != nil { return err } - if err := cfg.Normalize(); err != nil { - return err - } stateDir, err := config.SkillStateDir(cfg) if err != nil { return err diff --git a/cmd/agentdock/main.go b/cmd/agentdock/main.go index f682a8ab..9d58967a 100644 --- a/cmd/agentdock/main.go +++ b/cmd/agentdock/main.go @@ -76,6 +76,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error { if len(args) > 0 && args[0] == "skill" { return runSkillCommand(ctx, args[1:], stdout, stderr) } + if len(args) > 0 && args[0] == "plugin" { + return runPluginCommand(ctx, args[1:], stdout, stderr) + } if len(args) > 0 && args[0] == "nexus" { return runNexusCommand(ctx, args[1:], stdout, stderr) } diff --git a/core-skills/agentdock-user-guide/SKILL.md b/core-skills/agentdock-user-guide/SKILL.md index 7b44e1ba..0745378e 100644 --- a/core-skills/agentdock-user-guide/SKILL.md +++ b/core-skills/agentdock-user-guide/SKILL.md @@ -1,7 +1,7 @@ --- name: agentdock-user-guide description: 当用户询问 AgentDock 是什么、如何使用、配置在哪里、不同平台或安装方式怎样修改配置并生效、如何重启或验证配置、如何发现并配置 Codex/Claude/Grok 等 Coding Agent 的 ACP,以及常见运行问题时使用;覆盖 macOS Desktop、Windows Desktop、Linux 服务、Docker 和直接运行二进制,不用于源码开发与贡献流程。 -version: 1.3.0 +version: 1.5.0 --- # AgentDock User Guide @@ -55,6 +55,22 @@ AgentDock 主仓库的 `core-skills/` 只保留必须随 AgentDock 运行时一 官方仓库: +### Skill 与标准插件 + +独立 Skill 使用 `~/.agentdock/skills//`,内置 Skill 使用 `skills/.system//`。多版本与回滚数据继续由 `skill_package` 管理。 + +插件采用 Agent Plugins 1.0.0,自包含目录直接安装在 `~/.agentdock/plugins//`。根目录 `plugin.json` 只保存标准元数据与命名空间扩展,`mcp.json` 保存标准 MCP 定义,Skill 位于 `skills//SKILL.md`。不创建第二层插件缓存,不把启停状态或凭据打进插件包。 + +插件管理位于“功能与插件”页,每张卡片可展开或收起。普通插件的已启用 Skill/MCP 直接进入顶层索引。只有 Heavy 插件先显示摘要,命中后调用 `plugin_load` 展开成员。Heavy 开关与成员开关保存在宿主状态中,切换不改写原插件包。普通工具检索会搜索普通插件,Heavy 插件使用 `plugin_load` 返回的服务器名或完整工具名继续调用。 + +安装、更新、卸载使用 `plugin_manage`;插件内成员不能通过独立 Skill/MCP 接口重复安装或删除。更新保留总开关、Heavy 覆盖值和仍存在的成员开关。外部数据、凭据保留,卸载插件不会自动删除它们。 + +从 `.agentdock-plugin/plugin.json` 旧格式升级时,使用当前 Windows Setup。安装事务会停止旧实例、迁移清单及宿主状态、验证后启动新版,失败时按安装事务恢复。普通 Core 启动不进行隐式迁移。离线副本可以使用当前版本的 `agentdock plugin migrate --home <绝对数据目录>`;先停止写入该目录的进程,不在运行中的真实目录手工转换。 + +需要从旧 `skill-store` 和逻辑分组迁移整个目录时,使用源码内 `scripts/migrate/migrate-agentdock-home.ps1`,显式提供不同的源/目标目录及当前 `-AgentDockBinary`,必要时提供仓库外的 `-PluginPlanPath`。源目录保持不变,结果报告保存在目标目录。 + +Windows“运行配置”页可编辑默认全局工作区、AGENTS.md 自动加载、额外指令文件、浏览器程序、可信代理 CIDR、命令环境变量引用及 ACP 并发/超时。额外指令文件通常留空;明确配置的文件仍须存在、非空且符合 UTF-8/64 KiB 限制。保存会校验并重启,细节见 `references/windows.md`。 + ### ChatGPT 的工具 Schema 缓存 使用 ChatGPT 平台连接 AgentDock 时,工具定义还存在一层平台侧缓存。AgentDock 已经完成工具变更,不代表当前 ChatGPT 会话会立即拿到新的 Schema。 diff --git a/core-skills/agentdock-user-guide/references/configuration.md b/core-skills/agentdock-user-guide/references/configuration.md index 20e0388b..3e4ebe9e 100644 --- a/core-skills/agentdock-user-guide/references/configuration.md +++ b/core-skills/agentdock-user-guide/references/configuration.md @@ -6,16 +6,18 @@ | 配置键 | 作用 | 常见入口 | |---|---|---| +| `AGENTDOCK_DEFAULT_DIR` | 默认全局工作区 | Windows“运行配置”页或启动环境 | +| `AGENTDOCK_AGENTS_AUTOLOAD` | 自动读取全局与工作区 AGENTS.md,默认启用 | Windows“运行配置”页或启动环境 | | `AGENTDOCK_HOST` | Core 监听地址 | Linux env、Docker environment、直接启动 | | `AGENTDOCK_PORT` | MCP/HTTP 监听端口,默认 8765 | Desktop 设置、Linux env、Docker environment | | `AGENTDOCK_AUTH_TOKEN` | Bearer Token | 安装器、平台受保护凭据或容器 secret/env | | `AGENTDOCK_LOG_LEVEL` | `debug` / `info` / `warn` / `error` | Desktop 设置、Linux env、Docker environment | | `AGENTDOCK_MCP_APPS_ENABLED` | 是否启用 MCP Apps UI,默认启用 | Desktop 设置或启动环境 | | `AGENTDOCK_BROWSER_ENABLED` | 是否启用浏览器能力 | Desktop 设置或启动环境 | -| `AGENTDOCK_BROWSER_EXECUTABLE_PATH` | 显式浏览器可执行文件 | Docker/服务器/高级运行环境 | +| `AGENTDOCK_BROWSER_EXECUTABLE_PATH` | 显式浏览器可执行文件 | Windows“运行配置”页或高级运行环境 | | `AGENTDOCK_BROWSER_CDP_URL` | 复用已有 Chromium 的 CDP 地址 | Desktop 设置或启动环境 | | `AGENTDOCK_BROWSER_REUSE_EXISTING_CDP` | 自动复用唯一已发现 CDP | Desktop 设置或启动环境 | -| `AGENTDOCK_COMMAND_ENV_FROM_ENV_JSON` | 显式允许 `exec_command` 从 Core 宿主环境复制的变量映射 | Linux/Docker/直接启动的高级配置 | +| `AGENTDOCK_COMMAND_ENV_FROM_ENV_JSON` | 显式允许 `exec_command` 从 Core 宿主环境复制的变量映射 | Windows“运行配置”页或高级启动配置 | | `AGENTDOCK_ACP_ENABLED` | 是否启用 ACP Client | Desktop 设置或启动环境 | | `AGENTDOCK_ACP_PROFILES_JSON` | 多 ACP Profile JSON 数组;每项包含 `id/kind/command/args/env_from_env/enabled` | Desktop 设置或高级启动环境 | | `AGENTDOCK_ACP_DEFAULT_PROFILE` | 未显式传 `profile_id` 时使用的默认 ACP Profile ID | Desktop 设置或高级启动环境 | @@ -23,16 +25,16 @@ | `AGENTDOCK_ACP_COMMAND` | 旧单 ACP Adapter 命令;仅用于升级读取兼容,新配置不再写入 | 旧版自定义/高级 ACP 配置 | | `AGENTDOCK_ACP_ARGS_JSON` | 旧单 ACP Adapter 参数;仅用于升级读取兼容,新配置不再写入 | 旧版自定义/高级 ACP 配置 | | `AGENTDOCK_ACP_ENV_FROM_ENV_JSON` | 旧单 ACP 环境映射;仅用于升级读取兼容,新配置不再写入 | 旧版高级 ACP 配置 | -| `AGENTDOCK_ACP_MAX_CONCURRENT_PROMPTS` | ACP 并发 prompt 上限 | 高级 ACP 配置 | -| `AGENTDOCK_ACP_INTERACTION_TIMEOUT_MS` | ACP 交互超时 | 高级 ACP 配置 | +| `AGENTDOCK_ACP_MAX_CONCURRENT_PROMPTS` | ACP 并发 prompt 上限 | Windows“运行配置”页或高级 ACP 配置 | +| `AGENTDOCK_ACP_INTERACTION_TIMEOUT_MS` | ACP 交互超时 | Windows“运行配置”页或高级 ACP 配置 | | `AGENTDOCK_SERVER_URL` | 对外服务 Origin | 公网/OAuth 安装流程 | | `AGENTDOCK_OAUTH_ENABLED` | 是否启用 OAuth | 安装器/公网访问配置 | | `AGENTDOCK_OAUTH_PASSWORD` | OAuth 登录密码 | 平台安全存储或受保护环境 | | `AGENTDOCK_OAUTH_TOKEN_SECRET` | OAuth Token 签名密钥 | 平台安全存储或受保护环境 | | `AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL` | OAuth Access Token 有效期 | Desktop/高级启动配置 | | `AGENTDOCK_STDIO` | 是否启用 stdio 运行模式 | 直接启动/集成场景 | -| `AGENTDOCK_TRUSTED_PROXY_CIDRS` | 受信任反向代理网段 | 服务器/反代场景 | -| `AGENTDOCK_INSTRUCTIONS_FILE` | 额外 Instructions 文件 | 高级启动配置 | +| `AGENTDOCK_TRUSTED_PROXY_CIDRS` | 受信任反向代理网段 | Windows“运行配置”页或服务器/反代配置 | +| `AGENTDOCK_INSTRUCTIONS_FILE` | 额外 Instructions 文件 | Windows“运行配置”页或高级启动配置 | Coding Agent 的发现、Codex / Claude Adapter 安装、Grok stdio 模式、平台配置和验证流程见 `acp.md`。 diff --git a/core-skills/agentdock-user-guide/references/windows.md b/core-skills/agentdock-user-guide/references/windows.md index 01403e91..2d35d4f3 100644 --- a/core-skills/agentdock-user-guide/references/windows.md +++ b/core-skills/agentdock-user-guide/references/windows.md @@ -20,7 +20,7 @@ Windows Desktop 的运行配置由多部分组成: -- `control-panel-settings.json`:端口、日志、MCP Apps UI、浏览器、ACP 等普通设置; +- `control-panel-settings.json`:端口、日志、MCP Apps UI、浏览器、ACP 以及 `runtime_options` 运行参数; - `runtime.json`:安装位置、Core、Tray、Tunnel 与启动方式等运行清单; - `auth-token.dpapi`、`oauth-password.dpapi`、`oauth-token-secret.dpapi`、`cloudflared-token.dpapi`:受当前 Windows 用户保护的秘密; - `server-url.txt`、Tunnel 状态文件等:公网/OAuth/Tunnel 运行状态。 @@ -58,3 +58,22 @@ agentdock service status --runtime-root "$env:LOCALAPPDATA\AgentDock" 2. 当前端口的 `/healthz` 成功; 3. 本次设置对应的功能真的变化; 4. 如果配置更新返回回滚错误,检查当前文件和 Core 状态是否已恢复,不要继续覆盖 DPAPI 或 runtime 文件。 + +## 运行配置页面 + +“运行配置”页集中管理以下非敏感选项,数据目录、设置文件和运行清单位置只读展示: + +| 选项 | 规则 | +| --- | --- | +| 默认全局工作区 | 绝对目录路径;不能指向文件。保存后同步运行清单。 | +| AGENTS.md 自动加载 | 默认开启;控制全局与工作区规则发现。 | +| 额外指令文件 | 可留空。显式文件须存在、非空、UTF-8,大小不超过 64 KiB。 | +| 浏览器程序 | 留空自动发现;显式路径须为现有普通文件。 | +| 可信代理 | 每行一个 CIDR,只填写实际控制的反向代理网段。 | +| 命令环境变量引用 | JSON 的键和值均为变量名;不能填写凭据值。 | +| ACP 并发提示数 | 1—8。 | +| ACP 交互超时 | 1000—3600000 毫秒。 | + +`config runtime-get --runtime-root <实际目录>` 返回已保存值和配置位置,Core 停止时也可使用。`config runtime-update --runtime-root <实际目录> --options-json ` 保存这些选项,并保留其他页面的设置。GUI 保存前校验,CLI 再执行同一组校验,失败不会先停止当前实例。 + +安装或重新安装时,Skill 自举只校验存储目录,不读取浏览器、ACP、监听端口或额外指令文件等残余运行参数。Windows Desktop 启动时由明确的运行清单和设置重新生成受管环境;不把旧环境中的失效额外指令路径当作新安装的必需文件。已经显式保存在 `runtime_options` 中的文件路径仍会严格检查,失效后可通过本页清空或修正。 diff --git a/core-skills/skill-authoring/SKILL.md b/core-skills/skill-authoring/SKILL.md index 9940ae8c..a4684b94 100644 --- a/core-skills/skill-authoring/SKILL.md +++ b/core-skills/skill-authoring/SKILL.md @@ -1,7 +1,7 @@ --- name: skill-authoring description: 创建、设计、修改、升级、重构和验证 AgentDock Skill 时使用;负责可移植核心、文档、引用、辅助脚本、测试、版本和本地安装验证。 -version: 1.2.0 +version: 1.4.0 --- # Skill Authoring @@ -109,9 +109,17 @@ skills// 不要为了形式创建空目录,也不要把普通集成重新放回 AgentDock 主仓库。 +需要把 Skill 随 Agent Plugins 1.0.0 插件发布时,目标插件源码中的放置位置是: + +```text +/skills// +``` + +该目录仍然是完整、可移植的普通 Skill 包。插件清单不重复列出 Skill;AgentDock 从 `skills/*/SKILL.md` 自动发现成员。Skill 不得依赖插件安装后的绝对路径,也不得越过自身包根目录读取同插件的其他私有文件;跨成员协作应通过明确工具或环境契约完成。 + ### 4. 编写 Frontmatter -当前 AgentDock 正式解析: +AgentDock 独立安装及内置 Skill 仍使用以下带版本的宿主格式: ```yaml --- @@ -129,6 +137,8 @@ version: 1.0.0 - Frontmatter 后必须有非空 Markdown 正文; - 不增加当前解析器未支持的环境变量或执行字段。 +标准插件内的 Skill 遵循 Agent Skills:只要求合法的 `name`、`description`,版本不是必填字段。需要记录版本时使用 `metadata.version` 字符串。支持 YAML 引号和多行说明,不把版本管理字段当作发现插件成员的条件。插件根 `plugin.json` 使用 Agent Plugins 1.0.0 的 `$schema`,成员位置固定,不重复声明 Skill 路径。 + ### 5. 编写可移植核心 目标 Skill 的正文和脚本默认只假设: @@ -147,7 +157,7 @@ printf '%s' '{"skill_action":"status"}' | python3 run.py 不得把以下内容作为核心运行前提: -- `~/.agentdock/skill-store/installed/...`; +- `~/.agentdock/skills/`、`~/.agentdock/skills/.system/` 或 `~/.agentdock/skills/.versions/...`; - 固定安装版本号; - `AGENTDOCK_DIR`、`AGENTDOCK_HOME` 或 `AGENTDOCK_SKILL_DIR` 用于定位包内脚本或私有数据; - `skill_env`、`exec_command` 或 `skill://`; @@ -294,7 +304,7 @@ exec_command - `skill_run`; - `skill_env_manage`; - `AGENTDOCK_OPERATION`; -- `PLUGIN_*` 旧协议; +- 旧式 `PLUGIN_*` 统一执行协议。Agent Plugins 1.0.0 为 stdio MCP 定义的 `PLUGIN_ROOT`、`PLUGIN_DATA` 和对应单次占位符展开属于标准机制,可以使用; - 旧式 `operation` 或 `entrypoint` 清单; - 统一 Skill 执行器或旧 Skill Runtime。 diff --git a/core-skills/skill-authoring/run.py b/core-skills/skill-authoring/run.py index b8f60e10..6cc11702 100755 --- a/core-skills/skill-authoring/run.py +++ b/core-skills/skill-authoring/run.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from pathlib import Path -VERSION = "1.1.3" +VERSION = "1.1.4" MAX_FILES = 500 MAX_TEXT_BYTES = 1 << 20 HOST_METADATA_FILES = {".agentdock-install.json"} @@ -35,7 +35,10 @@ class Rule: r"(?:" r"(?:~|\$HOME|\$\{HOME\}|/[A-Za-z0-9._-]+)?/?\.agentdock" r"|\$AGENTDOCK_HOME|\$\{AGENTDOCK_HOME\}" - r")/skill-store/installed/[a-z][a-z0-9-]*/v?\d+\.\d+\.\d+", + r")/skills/(?:" + r"(?:\.system/)?[a-z][a-z0-9-]*" + r"|\.versions/[a-z][a-z0-9-]*/v?\d+\.\d+\.\d+" + r")", re.I, ), "不应硬编码 AgentDock 已安装版本目录。", diff --git a/core-skills/skill-authoring/tests/test_run.py b/core-skills/skill-authoring/tests/test_run.py index 24e0eb46..5fe569ff 100644 --- a/core-skills/skill-authoring/tests/test_run.py +++ b/core-skills/skill-authoring/tests/test_run.py @@ -4,6 +4,7 @@ import os import pathlib import subprocess +import sys import tempfile import unittest @@ -15,7 +16,7 @@ def run_skill(self, payload): env = os.environ.copy() env["PYTHONDONTWRITEBYTECODE"] = "1" proc = subprocess.run( - [str(ROOT / "run.py")], + [sys.executable, str(ROOT / "run.py")], input=json.dumps(payload), text=True, capture_output=True, @@ -121,7 +122,7 @@ def test_hardcoded_install_path_and_env_file_access_fail(self): version: 1.0.0 --- -运行 `python3 ~/.agentdock/skill-store/installed/demo-skill/1.0.0/run.py`。 +运行 `python3 ~/.agentdock/skills/demo-skill/run.py`。 """, "from pathlib import Path\nPath('~/.agentdock/env/skill/demo-skill.env').expanduser().read_text()\n") self.addCleanup(temp.cleanup) @@ -140,7 +141,7 @@ def test_agentdock_home_installed_path_fails(self): --- ```bash -SKILL_DIR="$AGENTDOCK_HOME/skill-store/installed/demo-skill/1.0.0" +SKILL_DIR="$AGENTDOCK_HOME/skills/.versions/demo-skill/1.0.0" python3 "$SKILL_DIR/run.py" ``` """) @@ -180,7 +181,7 @@ def test_explicit_prohibition_list_is_not_reported_as_dependency(self): - `AGENTDOCK_SKILL_DIR` - `AGENTDOCK_DIR` -- `~/.agentdock/skill-store/installed/demo-skill/1.0.0` +- `~/.agentdock/skills/demo-skill` """) self.addCleanup(temp.cleanup) diff --git a/core-skills/skill-installation/SKILL.md b/core-skills/skill-installation/SKILL.md index 56dd71ec..8b2ed6d9 100644 --- a/core-skills/skill-installation/SKILL.md +++ b/core-skills/skill-installation/SKILL.md @@ -1,7 +1,7 @@ --- name: skill-installation description: 审查、安装、配置、激活、验证、更新、回滚和卸载 AgentDock Skill 时使用;负责来源校验、安全评估、环境配置和已安装版本验收。 -version: 1.2.1 +version: 1.3.0 --- # Skill Installation @@ -35,6 +35,42 @@ version: 1.2.1 6. 安装成功不等于可用;必须验证当前激活版本、索引、正文、引用和只读状态。 7. 回滚只切换已安装版本,不删除私有状态,也不覆盖共享环境配置。 +## Codex 风格目录布局 + +新安装使用以下结构: + +```text +~/.agentdock/skills/ +├── / # 当前激活的用户 Skill +├── .system// # 当前激活的内置 Skill +├── .versions///# 未激活版本与回滚版本 +├── .state/.json # 版本、禁用状态与分类元数据 +├── .cache/ # 下载与校验缓存 +├── .locks/ # 安装、激活和卸载锁 +└── .tmp/ # 同一文件系统内的事务暂存 +``` + +顶层用户目录与 `.system` 目录刻意兼容 Codex 的 Skill 发现方式;多版本、回滚和事务状态放在隐藏目录中。不要手工拼接这些路径:读取正文使用 `skill:///...`,运行脚本使用绑定了 `skill` 的 `exec_command`,版本管理使用 `skill_package`。 + +旧的 `~/.agentdock/skill-store/` 不会被新源码自动迁移或合并。迁移必须作为独立、显式、可回滚的操作处理,不能在普通启动或安装过程中静默执行。 + +环境变量和私有运行数据继续与安装包隔离: + +- `~/.agentdock/env/skill/.env`; +- `~/.agentdock/skill-data//`。 + +### 插件内 Skill 的边界 + +自包含重插件中的 Skill 位于 `~/.agentdock/plugins//skills/`,其安装、更新、删除和启停由插件包与 `plugin_manage` 统一管理。遇到插件内 Skill 时: + +- 不要把它再次安装到 `~/.agentdock/skills/`; +- 不要使用独立 `skill_package activate/uninstall/enable/disable` 改写其状态; +- 更新整个插件时重新审查插件清单、全部 Skill、MCP 定义、脚本和二进制; +- 环境变量仍通过按 Skill/MCP 名称隔离的正式环境管理能力配置,不写入插件包; +- `skill:///...` 与绑定 Skill 的 `exec_command` 会解析到插件携带的当前成员目录。 + +插件目录或 ZIP 的完整审查不能由单个 Skill 的 `skill_package validate` 代替;应先使用 `plugin_manage validate`,再按本 Skill 的安全规则审查每个包含的 Skill。 + ## 标准流程 ### 1. 识别来源 diff --git a/desktop/windows/control-panel/AgentDock.ControlPanel.csproj b/desktop/windows/control-panel/AgentDock.ControlPanel.csproj index e163794d..330c93bf 100644 --- a/desktop/windows/control-panel/AgentDock.ControlPanel.csproj +++ b/desktop/windows/control-panel/AgentDock.ControlPanel.csproj @@ -9,6 +9,7 @@ agentdock-tray AgentDock AgentDock + 1.0.1 AgentDock Control Panel AgentDock.ControlPanel ..\..\..\packaging\windows\assets\agentdock.ico diff --git a/desktop/windows/control-panel/MainWindow.Capabilities.cs b/desktop/windows/control-panel/MainWindow.Capabilities.cs new file mode 100644 index 00000000..05c39e31 --- /dev/null +++ b/desktop/windows/control-panel/MainWindow.Capabilities.cs @@ -0,0 +1,534 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Button = System.Windows.Controls.Button; +using CheckBox = System.Windows.Controls.CheckBox; +using MessageBox = System.Windows.MessageBox; +using Brushes = System.Windows.Media.Brushes; +using Color = System.Windows.Media.Color; + +namespace AgentDock.ControlPanel; + +public partial class MainWindow +{ + private readonly SemaphoreSlim _capabilityGate = new(1, 1); + private CapabilityInventory _capabilityInventory = new(); + private bool _updatingCapabilities; + private readonly HashSet _expandedPlugins = new(StringComparer.Ordinal); + private int _capabilityLoadGeneration; + private bool _capabilityControlsEnabled = true; + + private async Task RefreshCapabilitiesAsync(bool coreAvailable = true, bool showErrors = true) + { + if (!await _capabilityGate.WaitAsync(0)) + { + return; + } + try + { + if (!coreAvailable) + { + // Retain the last displayed inventory for read-only inspection. + RenderCapabilityInventory(); + SetCapabilityControlsEnabled(false); + CapabilityStatusText.Text = UiText.Get("CapabilitiesRequireRunningCore"); + return; + } + await LoadCapabilityInventoryCoreAsync(); + } + catch (Exception ex) + { + CapabilityStatusText.Text = ex.Message; + if (showErrors) + { + MessageBox.Show(this, ex.Message, "AgentDock", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + finally + { + _capabilityGate.Release(); + } + } + + private async Task LoadCapabilityInventoryCoreAsync() + { + var generation = ++_capabilityLoadGeneration; + SetCapabilityControlsEnabled(false); + CapabilityStatusText.Text = UiText.Get("LoadingCapabilities"); + var progress = new Progress(update => + { + if (generation != _capabilityLoadGeneration) return; + MergeCapabilitySection(update.Section, update.Inventory); + RenderCapabilityInventory(); + CapabilityStatusText.Text = UiText.Get("LoadingCapabilities") + " " + CapabilityInventoryStatus(); + }); + try + { + var inventory = await _runtime.GetCapabilityInventoryAsync(progress); + ++_capabilityLoadGeneration; // Ignore queued notifications after this final snapshot. + foreach (var section in new[] { "plugins", "skills", "mcp" }) MergeCapabilitySection(section, inventory); + RenderCapabilityInventory(); + CapabilityStatusText.Text = CapabilityInventoryStatus(); + } + finally + { + if (generation == _capabilityLoadGeneration) ++_capabilityLoadGeneration; + SetCapabilityControlsEnabled(true); + } + } + + private void MergeCapabilitySection(string section, CapabilityInventory inventory) + { + if (inventory.Errors.TryGetValue(section, out var error)) + { + _capabilityInventory.Errors[section] = error; + return; + } + _capabilityInventory.Errors.Remove(section); + switch (section) + { + case "plugins": _capabilityInventory.Plugins = inventory.Plugins; break; + case "skills": _capabilityInventory.Skills = inventory.Skills; break; + case "mcp": _capabilityInventory.McpServers = inventory.McpServers; break; + } + } + + private string CapabilityInventoryStatus() + { + var text = UiText.Format("CapabilitiesLoaded", _capabilityInventory.Plugins.Count, + _capabilityInventory.Skills.Count, _capabilityInventory.McpServers.Count); + if (_capabilityInventory.Errors.Count > 0) + text += " · " + string.Join(" | ", _capabilityInventory.Errors.Select(error => $"{error.Key}: {error.Value}")); + return text; + } + + private void SetCapabilityControlsEnabled(bool enabled) + { + // Expanders and read-only metadata remain usable while status is pending. + _capabilityControlsEnabled = enabled; + RenderCapabilityInventory(); + } + + private async Task ExecuteCapabilityActionAsync(string pendingText, Func action) + { + if (!await _capabilityGate.WaitAsync(0)) + { + return; + } + try + { + CapabilityStatusText.Text = pendingText; + await action(); + await LoadCapabilityInventoryCoreAsync(); + } + catch (Exception ex) + { + CapabilityStatusText.Text = ex.Message; + MessageBox.Show(this, ex.Message, "AgentDock", MessageBoxButton.OK, MessageBoxImage.Error); + RenderCapabilityInventory(); + } + finally + { + _capabilityGate.Release(); + } + } + + private void RenderCapabilityInventory() + { + var timer = Stopwatch.StartNew(); + var previous = _updatingCapabilities; + _updatingCapabilities = true; + try + { + PluginListPanel.Children.Clear(); + StandaloneSkillListPanel.Children.Clear(); + StandaloneMcpListPanel.Children.Clear(); + + var plugins = _capabilityInventory.Plugins + .OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + foreach (var plugin in plugins) + { + PluginListPanel.Children.Add(BuildPluginCard(plugin)); + } + if (plugins.Count == 0) + { + PluginListPanel.Children.Add(BuildEmptyCapabilityText("NoPlugins")); + } + + var ownedSkills = plugins.SelectMany(plugin => plugin.Skills ?? []).ToHashSet(StringComparer.Ordinal); + var ownedMcpServers = plugins.SelectMany(plugin => plugin.McpServers ?? []).ToHashSet(StringComparer.Ordinal); + + var standaloneSkills = _capabilityInventory.Skills + .Where(skill => string.IsNullOrWhiteSpace(skill.Plugin) && !ownedSkills.Contains(skill.Identifier)) + .OrderBy(skill => skill.DisplayName, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + foreach (var skill in standaloneSkills) + { + StandaloneSkillListPanel.Children.Add(BuildSkillCapabilityRow(skill, nested: false, pluginName: "")); + } + if (standaloneSkills.Count == 0) + { + StandaloneSkillListPanel.Children.Add(BuildEmptyCapabilityText("NoStandaloneSkills")); + } + + var standaloneMcp = _capabilityInventory.McpServers + .Where(server => string.IsNullOrWhiteSpace(server.Plugin) && !ownedMcpServers.Contains(server.Name)) + .OrderBy(server => server.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + foreach (var server in standaloneMcp) + { + StandaloneMcpListPanel.Children.Add(BuildMcpCapabilityRow(server, nested: false, pluginName: "")); + } + if (standaloneMcp.Count == 0) + { + StandaloneMcpListPanel.Children.Add(BuildEmptyCapabilityText("NoStandaloneMcpServers")); + } + } + finally + { + _updatingCapabilities = previous; + // Render logging is sent to the worker; file I/O never blocks WPF. + var elapsed = timer.ElapsedMilliseconds; + _ = Task.Run(() => _runtime.RecordCapabilityRenderTime(elapsed)); + } + } + + private Border BuildPluginCard(PluginCapabilityInfo plugin) + { + var content = new StackPanel(); + var header = new Grid(); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var title = new TextBlock + { + Text = plugin.Name, + FontSize = 15, + FontWeight = FontWeights.SemiBold, + VerticalAlignment = VerticalAlignment.Center + }; + var remove = new Button + { + Content = UiText.Get("Delete"), + Tag = plugin.Name, + MinWidth = 70, + Margin = new Thickness(8, 0, 0, 0) + }; + remove.Click += PluginRemoveButton_Click; + var toggle = new CheckBox + { + Content = UiText.Get("Enabled"), + IsChecked = plugin.Enabled, + IsEnabled = _capabilityControlsEnabled, + Tag = plugin.Name, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(14, 0, 0, 0) + }; + toggle.Checked += PluginToggle_Changed; + toggle.Unchecked += PluginToggle_Changed; + + var heavy = new CheckBox + { + Content = "Heavy", IsChecked = plugin.Heavy, Tag = plugin.Name, + IsEnabled = _capabilityControlsEnabled, + ToolTip = UiText.Get("HeavyPluginHelp"), + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(14, 0, 0, 0) + }; + heavy.Checked += PluginHeavy_Changed; + heavy.Unchecked += PluginHeavy_Changed; + Grid.SetColumn(heavy, 2); + header.Children.Add(heavy); + Grid.SetColumn(title, 0); + Grid.SetColumn(remove, 1); + Grid.SetColumn(toggle, 3); + header.Children.Add(title); + header.Children.Add(remove); + header.Children.Add(toggle); + content.Children.Add(header); + content.Children.Add(new TextBlock + { + Text = plugin.Description, + Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 6, 0, 3) + }); + var expander = new Expander + { + Header = UiText.Format("PluginMemberSummary", plugin.Skills?.Count ?? 0, plugin.McpServers?.Count ?? 0), + IsExpanded = _expandedPlugins.Contains(plugin.Name), Margin = new Thickness(0, 5, 0, 0) + }; + expander.Expanded += (_, _) => + { + _expandedPlugins.Add(plugin.Name); + if (expander.Content is null) + { + var previous = _updatingCapabilities; + _updatingCapabilities = true; + try { expander.Content = BuildPluginDetails(plugin); } + finally { _updatingCapabilities = previous; } + } + }; + expander.Collapsed += (_, _) => _expandedPlugins.Remove(plugin.Name); + if (expander.IsExpanded) expander.Content = BuildPluginDetails(plugin); + content.Children.Add(expander); + if ((plugin.Diagnostics?.Count ?? 0) > 0) + { + content.Children.Add(new TextBlock { Text = UiText.Format("PluginDiagnosticCount", plugin.Diagnostics!.Count), + Foreground = new SolidColorBrush(Color.FromRgb(180, 35, 24)), TextWrapping = TextWrapping.Wrap }); + } + + return new Border + { + Child = content, + BorderBrush = new SolidColorBrush(Color.FromRgb(208, 213, 221)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(14), + Margin = new Thickness(0, 0, 0, 10), + Background = Brushes.White + }; + } + + private StackPanel BuildPluginDetails(PluginCapabilityInfo plugin) + { + var details = new StackPanel(); + details.Children.Add(new TextBlock + { + Text = UiText.Format("PluginPackageMetadata", plugin.Version, plugin.Path), + Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 10) + }); + + var skillsByName = _capabilityInventory.Skills + .GroupBy(skill => skill.Identifier, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + var mcpByName = _capabilityInventory.McpServers + .GroupBy(server => server.Name, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + if ((plugin.Skills?.Count ?? 0) > 0) + { + details.Children.Add(BuildPluginSectionTitle(UiText.Get("PluginSkills"))); + foreach (var name in (plugin.Skills ?? []).OrderBy(value => value, StringComparer.OrdinalIgnoreCase)) + { + details.Children.Add(skillsByName.TryGetValue(name, out var skill) + ? BuildSkillCapabilityRow(skill, nested: true, pluginName: plugin.Name) + : BuildUnavailableCapabilityRow("Skill", name)); + } + } + if ((plugin.McpServers?.Count ?? 0) > 0) + { + details.Children.Add(BuildPluginSectionTitle(UiText.Get("PluginMcpServers"))); + foreach (var name in (plugin.McpServers ?? []).OrderBy(value => value, StringComparer.OrdinalIgnoreCase)) + { + details.Children.Add(mcpByName.TryGetValue(name, out var server) + ? BuildMcpCapabilityRow(server, nested: true, pluginName: plugin.Name) + : BuildUnavailableCapabilityRow("MCP", name)); + } + } + + foreach (var diagnostic in plugin.Diagnostics ?? []) + { + details.Children.Add(new TextBlock { Text = diagnostic, TextWrapping = TextWrapping.Wrap, + Foreground = new SolidColorBrush(Color.FromRgb(180, 35, 24)), Margin = new Thickness(0, 5, 0, 0) }); + } + return details; + } + + private static TextBlock BuildPluginSectionTitle(string text) => new() + { + Text = text, + FontWeight = FontWeights.SemiBold, + Margin = new Thickness(0, 8, 0, 3) + }; + + private Border BuildSkillCapabilityRow(SkillCapabilityInfo skill, bool nested, string pluginName) + { + var details = skill.Description; + var metadata = new List(); + if (!string.IsNullOrWhiteSpace(skill.ActiveVersion)) + { + metadata.Add(skill.ActiveVersion); + } + if (skill.Bundled) + { + metadata.Add(UiText.Get("Bundled")); + } + if (metadata.Count > 0) + { + details = string.IsNullOrWhiteSpace(details) + ? string.Join(" · ", metadata) + : details + " · " + string.Join(" · ", metadata); + } + var title = skill.DisplayName; + if (!string.Equals(skill.DisplayName, skill.Identifier, StringComparison.Ordinal)) + { + title += $" ({skill.Identifier})"; + } + return BuildCapabilityToggleRow( + title, + details, + skill.Enabled, + new CapabilityToggleTarget("skill", skill.Identifier, pluginName), + nested); + } + + private Border BuildMcpCapabilityRow(McpCapabilityInfo server, bool nested, string pluginName) + { + var metadata = UiText.Format("McpStatusSummary", server.Status, server.ToolCount); + var details = string.IsNullOrWhiteSpace(server.Description) + ? metadata + : server.Description + " · " + metadata; + if (!string.IsNullOrWhiteSpace(server.LastErrorCode)) + { + details += " · " + server.LastErrorCode; + } + return BuildCapabilityToggleRow( + server.Name, + details, + server.Enabled, + new CapabilityToggleTarget("mcp", server.Name, pluginName), + nested); + } + + private Border BuildCapabilityToggleRow( + string title, + string description, + bool enabled, + CapabilityToggleTarget target, + bool nested) + { + var row = new Grid(); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var text = new StackPanel(); + text.Children.Add(new TextBlock + { + Text = title, + FontWeight = FontWeights.Medium, + TextWrapping = TextWrapping.Wrap + }); + if (!string.IsNullOrWhiteSpace(description)) + { + text.Children.Add(new TextBlock + { + Text = description, + Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 2, 14, 0) + }); + } + var toggle = new CheckBox + { + IsChecked = enabled, + IsEnabled = _capabilityControlsEnabled, + Tag = target, + VerticalAlignment = VerticalAlignment.Center, + ToolTip = enabled ? UiText.Get("DisableCapability") : UiText.Get("EnableCapability") + }; + toggle.Checked += CapabilityToggle_Changed; + toggle.Unchecked += CapabilityToggle_Changed; + Grid.SetColumn(text, 0); + Grid.SetColumn(toggle, 1); + row.Children.Add(text); + row.Children.Add(toggle); + return new Border + { + Child = row, + BorderBrush = new SolidColorBrush(Color.FromRgb(234, 236, 240)), + BorderThickness = new Thickness(0, 0, 0, 1), + Padding = nested ? new Thickness(18, 7, 8, 7) : new Thickness(8, 9, 8, 9) + }; + } + + private static Border BuildUnavailableCapabilityRow(string kind, string name) => new() + { + Child = new TextBlock + { + Text = UiText.Format("UnavailablePluginMember", kind, name), + Foreground = new SolidColorBrush(Color.FromRgb(180, 35, 24)), + TextWrapping = TextWrapping.Wrap + }, + BorderBrush = new SolidColorBrush(Color.FromRgb(254, 205, 202)), + BorderThickness = new Thickness(0, 0, 0, 1), + Padding = new Thickness(18, 7, 8, 7) + }; + + private static TextBlock BuildEmptyCapabilityText(string resourceKey) => new() + { + Text = UiText.Get(resourceKey), + Foreground = new SolidColorBrush(Color.FromRgb(102, 112, 133)), + Margin = new Thickness(8), + TextWrapping = TextWrapping.Wrap + }; + + private async void RefreshCapabilitiesButton_Click(object sender, RoutedEventArgs e) => + await RefreshCapabilitiesAsync(_snapshot?.Healthy == true); + + private async void PluginToggle_Changed(object sender, RoutedEventArgs e) + { + if (_updatingCapabilities || sender is not CheckBox toggle || toggle.Tag is not string name) + { + return; + } + var enabled = toggle.IsChecked == true; + await ExecuteCapabilityActionAsync( + UiText.Format(enabled ? "EnablingPlugin" : "DisablingPlugin", name), + () => _runtime.SetPluginEnabledAsync(name, enabled)); + } + + private async void PluginHeavy_Changed(object sender, RoutedEventArgs e) + { + if (_updatingCapabilities || sender is not CheckBox toggle || toggle.Tag is not string name) { return; } + await ExecuteCapabilityActionAsync(UiText.Format("SavingPlugin", name), + () => _runtime.SetPluginHeavyAsync(name, toggle.IsChecked == true)); + } + + private async void CapabilityToggle_Changed(object sender, RoutedEventArgs e) + { + if (_updatingCapabilities || sender is not CheckBox toggle || toggle.Tag is not CapabilityToggleTarget target) + { + return; + } + var enabled = toggle.IsChecked == true; + var pendingKey = enabled ? "EnablingCapability" : "DisablingCapability"; + await ExecuteCapabilityActionAsync( + UiText.Format(pendingKey, target.Name), + string.IsNullOrWhiteSpace(target.Plugin) + ? target.Kind == "skill" + ? () => _runtime.SetSkillEnabledAsync(target.Name, enabled) + : () => _runtime.SetMcpEnabledAsync(target.Name, enabled) + : () => _runtime.SetPluginMemberEnabledAsync( + target.Plugin, + target.Kind == "skill" ? "skill" : "mcp_server", + target.Name, + enabled)); + } + + private async void PluginRemoveButton_Click(object sender, RoutedEventArgs e) + { + if (_updatingCapabilities || sender is not Button button || button.Tag is not string name) + { + return; + } + var confirm = MessageBox.Show( + this, + UiText.Format("ConfirmRemovePlugin", name), + "AgentDock", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + if (confirm != MessageBoxResult.Yes) + { + return; + } + await ExecuteCapabilityActionAsync( + UiText.Format("RemovingPlugin", name), + () => _runtime.RemovePluginAsync(name)); + } + + private sealed record CapabilityToggleTarget(string Kind, string Name, string Plugin); +} diff --git a/desktop/windows/control-panel/MainWindow.RuntimeOptions.cs b/desktop/windows/control-panel/MainWindow.RuntimeOptions.cs new file mode 100644 index 00000000..e8264c3f --- /dev/null +++ b/desktop/windows/control-panel/MainWindow.RuntimeOptions.cs @@ -0,0 +1,140 @@ +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Windows; +using Button = System.Windows.Controls.Button; +using MessageBox = System.Windows.MessageBox; +using Forms = System.Windows.Forms; + +namespace AgentDock.ControlPanel; + +public partial class MainWindow +{ + private bool _runtimeOptionsLoaded; + private bool _runtimeOptionsBusy; + + private async void RuntimeOptionsPanel_Loaded(object sender, RoutedEventArgs e) + { + if (!_runtimeOptionsLoaded) { await LoadRuntimeOptionsAsync(); } + } + + private async Task LoadRuntimeOptionsAsync() + { + if (_runtimeOptionsBusy) { return; } + SetRuntimeOptionsBusy(true); + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + RuntimeOptionsStatusText.Text = UiText.Get("Refreshing"); + var view = await _runtime.GetRuntimeOptionsAsync(timeout.Token); + var options = view.Options; + DefaultWorkspaceTextBox.Text = options.DefaultDir; + AgentsAutoLoadCheckBox.IsChecked = options.AgentsAutoLoad; + ExtraInstructionsTextBox.Text = options.InstructionsFile; + BrowserExecutableTextBox.Text = options.BrowserExecutablePath; + TrustedProxyTextBox.Text = string.Join(Environment.NewLine, options.TrustedProxyCidrs ?? []); + CommandEnvReferencesTextBox.Text = JsonSerializer.Serialize(options.CommandEnvFromEnv ?? [], + new JsonSerializerOptions { WriteIndented = true }); + AcpConcurrencyTextBox.Text = options.AcpMaxConcurrentPrompts.ToString(CultureInfo.InvariantCulture); + AcpTimeoutTextBox.Text = options.AcpInteractionTimeoutMs.ToString(CultureInfo.InvariantCulture); + AgentDockHomeTextBox.Text = view.AgentDockHome; + RuntimeConfigurationPathsTextBox.Text = view.SettingsPath + Environment.NewLine + view.ManifestPath; + RuntimeOptionsStatusText.Text = UiText.Get("Ready"); + _runtimeOptionsLoaded = true; + } + catch (Exception ex) + { + RuntimeOptionsStatusText.Text = ex.Message; + } + finally { SetRuntimeOptionsBusy(false); } + } + + private void SetRuntimeOptionsBusy(bool busy) + { + _runtimeOptionsBusy = busy; + SaveRuntimeOptionsButton.IsEnabled = !busy && _runtimeOptionsLoaded; + ReloadRuntimeOptionsButton.IsEnabled = !busy; + } + + private async void ReloadRuntimeOptions_Click(object sender, RoutedEventArgs e) => await LoadRuntimeOptionsAsync(); + + private async void SaveRuntimeOptions_Click(object sender, RoutedEventArgs e) + { + if (_runtimeOptionsBusy || !_runtimeOptionsLoaded) { return; } + RuntimeOptions options; + try + { + if (!Path.IsPathFullyQualified(DefaultWorkspaceTextBox.Text.Trim()) || + !int.TryParse(AcpConcurrencyTextBox.Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var concurrency) || + !int.TryParse(AcpTimeoutTextBox.Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var timeout) || + concurrency is < 1 or > 8 || timeout is < 1000 or > 3600000) + { + throw new InvalidOperationException(UiText.Get("RuntimeOptionsInvalid")); + } + var references = JsonSerializer.Deserialize>(CommandEnvReferencesTextBox.Text); + if (references is null || references.Any(pair => pair.Value is null)) + { + throw new InvalidOperationException(UiText.Get("RuntimeOptionsInvalid")); + } + options = new RuntimeOptions + { + DefaultDir = DefaultWorkspaceTextBox.Text.Trim(), + AgentsAutoLoad = AgentsAutoLoadCheckBox.IsChecked == true, + InstructionsFile = ExtraInstructionsTextBox.Text.Trim(), + BrowserExecutablePath = BrowserExecutableTextBox.Text.Trim(), + TrustedProxyCidrs = TrustedProxyTextBox.Text.Split(['\r', '\n', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(), + CommandEnvFromEnv = references, + AcpMaxConcurrentPrompts = concurrency, + AcpInteractionTimeoutMs = timeout + }; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or ArgumentException) + { + MessageBox.Show(this, ex.Message, "AgentDock", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + SetRuntimeOptionsBusy(true); + try + { + RuntimeOptionsStatusText.Text = UiText.Get("SavingAndRestarting"); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + await _runtime.SaveRuntimeOptionsAsync(options, timeout.Token); + await RefreshAsync(); + RuntimeOptionsStatusText.Text = UiText.Get("OperationCompleted"); + } + catch (Exception ex) + { + RuntimeOptionsStatusText.Text = ex.Message; + MessageBox.Show(this, ex.Message, "AgentDock", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally { SetRuntimeOptionsBusy(false); } + } + + private void DefaultWorkspaceBrowse_Click(object sender, RoutedEventArgs e) + { + using var dialog = new Forms.FolderBrowserDialog + { + Description = UiText.Get("DefaultGlobalWorkspace"), UseDescriptionForTitle = true, + ShowNewFolderButton = true, + SelectedPath = Directory.Exists(DefaultWorkspaceTextBox.Text) ? DefaultWorkspaceTextBox.Text : "" + }; + if (dialog.ShowDialog() == Forms.DialogResult.OK) { DefaultWorkspaceTextBox.Text = dialog.SelectedPath; } + } + + private void RuntimeFileBrowse_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button) { return; } + var instructions = string.Equals(button.Tag as string, "instructions", StringComparison.Ordinal); + var dialog = new Microsoft.Win32.OpenFileDialog + { + CheckFileExists = true, Multiselect = false, + Filter = instructions ? "Markdown (*.md)|*.md|All files (*.*)|*.*" : "Executable (*.exe)|*.exe|All files (*.*)|*.*" + }; + if (dialog.ShowDialog(this) == true) + { + if (instructions) { ExtraInstructionsTextBox.Text = dialog.FileName; } + else { BrowserExecutableTextBox.Text = dialog.FileName; } + } + } +} diff --git a/desktop/windows/control-panel/MainWindow.xaml b/desktop/windows/control-panel/MainWindow.xaml index 65af50fd..7734bf13 100644 --- a/desktop/windows/control-panel/MainWindow.xaml +++ b/desktop/windows/control-panel/MainWindow.xaml @@ -152,6 +152,93 @@ + + + + + +