From 9349ce8e93f770140b1a4a74180f891678a395bc Mon Sep 17 00:00:00 2001 From: David Choi Date: Tue, 15 Sep 2026 21:57:27 -0400 Subject: [PATCH 1/4] improve-plugins --- .../DockerRunRequestValidator.swift | 23 +++++++- .../DockerRunnerXPCTests.swift | 25 +++++++++ .../DerrickDockerDanglingImagePruner.swift | 14 +++++ .../DerrickDockerOrphanSweeper.swift | 23 ++++++-- .../Tests/MCPServerTests/MCPServerTests.swift | 52 +++++++++++++++++++ .../DerrickDockerRuntimeIdentity.swift | 20 +++++++ .../DockerRunnerXPC/DockerHostLaunch.swift | 3 +- .../DockerRunnerXPC/DockerWorkerRuntime.swift | 4 ++ .../AppLayerServicesWireTests.swift | 19 +++++++ .../DockerWorkerDockerfileTests.swift | 6 +++ scripts/prune-dangling-worker-images.sh | 29 ++++++++++- .../MCPServiceDockerHelperRunner.swift | 9 ++++ .../DockerRunner/XPCDockerRunner.swift | 18 +++++++ ui/ui.xcodeproj/project.pbxproj | 6 +-- 14 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 packages/MCPServer/Sources/MCPServer/DerrickDockerDanglingImagePruner.swift diff --git a/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift b/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift index 31c7bb2c..897c04a6 100644 --- a/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift +++ b/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift @@ -159,17 +159,30 @@ public enum DockerRunRequestValidator: Sendable { return nil } - /// `docker ps -aq --filter label=app.derrick=runtime` (or an allowed name prefix). + /// `docker ps -aq --filter ` or the same plus `--filter status=`. private static func validatePsArguments( _ dockerArgs: [String] ) -> DockerRunRequestValidationError? { - guard dockerArgs.count == 4, + guard dockerArgs.first == "ps", + dockerArgs.count >= 4, dockerArgs[1] == "-aq", dockerArgs[2] == "--filter", DerrickDockerRuntimeIdentity.isAllowedPsFilter(dockerArgs[3]) else { return .disallowedDockerFlag("ps") } + if dockerArgs.count == 4 { + return nil + } + guard dockerArgs.count == 6, + dockerArgs[4] == "--filter", + dockerArgs[5].hasPrefix("status="), + DerrickDockerRuntimeIdentity.isAllowedPsStatus( + String(dockerArgs[5].dropFirst("status=".count)) + ) + else { + return .disallowedDockerFlag("ps") + } return nil } @@ -265,6 +278,12 @@ public enum DockerRunRequestValidator: Sendable { DockerHostLaunch.allowedImageSubcommands.contains(second) else { return .disallowedDockerSubcommand("image \(args.first ?? "")") } + if second == "prune" { + guard dockerArgs == DockerWorkerRuntime.danglingImagePruneArguments else { + return .disallowedDockerFlag("image prune") + } + return nil + } guard second == "inspect" else { return nil } if args.count == 2 { return nil diff --git a/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift b/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift index 3de0744c..26ed91fb 100644 --- a/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift +++ b/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift @@ -157,6 +157,25 @@ struct DockerRunnerXPCTests { #expect(DockerRunRequestValidator.validate(r) == .disallowedDockerSubcommand("system")) } + @Test func rejectsBroadImagePruneAndRunningContainerPs() { + #expect( + DockerRunRequestValidator.validate( + request(arguments: DockerHostLaunch.dockerCLIArguments([ + "image", "prune", "-af", + ])) + ) != nil + ) + #expect( + DockerRunRequestValidator.validate( + request(arguments: DockerHostLaunch.dockerCLIArguments([ + "ps", "-aq", + "--filter", "name=derrick-guest-runtime", + "--filter", "status=running", + ])) + ) == .disallowedDockerFlag("ps") + ) + } + @Test func rejectsPrivilegedFlag() { let r = request(arguments: DockerHostLaunch.dockerCLIArguments([ "create", "--privileged", "--name", "x", "image" @@ -247,6 +266,12 @@ struct DockerRunnerXPCTests { ], ["start", "c"], ["rm", "-f", "c"], + DockerWorkerRuntime.danglingImagePruneArguments, + [ + "ps", "-aq", + "--filter", "name=derrick-guest-runtime", + "--filter", "status=exited", + ], ["inspect", "-f", "{{.State.Running}}", "c"], ["exec", "-i", "c", "/usr/local/bin/derrick-file-extractor"], [ diff --git a/packages/MCPServer/Sources/MCPServer/DerrickDockerDanglingImagePruner.swift b/packages/MCPServer/Sources/MCPServer/DerrickDockerDanglingImagePruner.swift new file mode 100644 index 00000000..c9d2451b --- /dev/null +++ b/packages/MCPServer/Sources/MCPServer/DerrickDockerDanglingImagePruner.swift @@ -0,0 +1,14 @@ +import Foundation +import Structure + +/// Drops untagged worker images left after a rebuild. Never removes the live tag. +public enum DerrickDockerDanglingImagePruner: Sendable { + @discardableResult + public static func prune(executor: DockerCLIExecutor) async -> Bool { + let arguments = DockerWorkerRuntime.danglingImagePruneArguments + guard let result = try? await executor(arguments, Data(), 60) else { + return false + } + return result.exitCode == 0 + } +} diff --git a/packages/MCPServer/Sources/MCPServer/DerrickDockerOrphanSweeper.swift b/packages/MCPServer/Sources/MCPServer/DerrickDockerOrphanSweeper.swift index a26aa755..a99caeef 100644 --- a/packages/MCPServer/Sources/MCPServer/DerrickDockerOrphanSweeper.swift +++ b/packages/MCPServer/Sources/MCPServer/DerrickDockerOrphanSweeper.swift @@ -2,16 +2,29 @@ import Foundation import Structure /// Removes leftover Derrick Docker containers from a previous crash or kill. -/// -/// Call from daemon Docker sync — not a one-off on the developer machine, not on -/// UI launch (the daemon may still be running jobs), and not on UI quit. public enum DerrickDockerOrphanSweeper: Sendable { + public enum Scope: Sendable { + /// Every matching container, including running ones. Daemon crash recovery only. + case allMatching + /// Exited, dead, or created only. Safe at UI launch while jobs may still be running. + case stoppedOnly + } + /// Best-effort: list by label and name prefix, then `docker rm -f`. /// Returns how many container IDs were passed to remove (0 if none or list failed). @discardableResult - public static func sweep(executor: DockerCLIExecutor) async -> Int { + public static func sweep( + executor: DockerCLIExecutor, + scope: Scope = .allMatching + ) async -> Int { var ids = Set() - for arguments in DerrickDockerRuntimeIdentity.psListArguments { + let lists = switch scope { + case .allMatching: + DerrickDockerRuntimeIdentity.psListArguments + case .stoppedOnly: + DerrickDockerRuntimeIdentity.psStoppedListArguments + } + for arguments in lists { guard let result = try? await executor(arguments, Data(), 30), result.exitCode == 0 else { diff --git a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift index df85c618..7e51ac10 100644 --- a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift +++ b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift @@ -748,6 +748,58 @@ import WebCrawler } } + @Test func orphanSweeperStoppedOnlyListsExitedDeadAndCreated() async throws { + let recorder = DockerCallRecorder() + let executor: DockerCLIExecutor = { args, _, _ in + await recorder.append(args) + if args.first == "ps", + args.contains("name=derrick-guest-runtime"), + args.contains("status=exited") { + return DockerCLIResult(exitCode: 0, stdout: Data("cccccccccccc\n".utf8), stderr: Data()) + } + if args.first == "ps" { + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let removed = await DerrickDockerOrphanSweeper.sweep(executor: executor, scope: .stoppedOnly) + #expect(removed == 1) + let calls = await recorder.calls + #expect(calls.filter { $0.first == "ps" }.count == DerrickDockerRuntimeIdentity.psStoppedListArguments.count) + #expect(calls.contains { $0.first == "ps" && $0.contains("status=exited") }) + #expect(calls.contains { $0.first == "ps" && $0.contains("status=dead") }) + #expect(calls.contains { $0.first == "ps" && $0.contains("status=created") }) + #expect(!calls.contains { $0.contains("status=running") }) + let rm = try #require(calls.first { $0.first == "rm" }) + #expect(rm.contains("cccccccccccc")) + for call in calls where call.first == "ps" || call.first == "rm" { + #expect( + DockerRunRequestValidator.validate( + DockerHostLaunch.makeRequest(dockerArguments: call, timeoutSeconds: 60) + ) == nil + ) + } + } + + @Test func danglingImagePrunerUsesLabeledPruneOnly() async throws { + let recorder = DockerCallRecorder() + let executor: DockerCLIExecutor = { args, _, _ in + await recorder.append(args) + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + #expect(await DerrickDockerDanglingImagePruner.prune(executor: executor)) + let calls = await recorder.calls + #expect(calls == [DockerWorkerRuntime.danglingImagePruneArguments]) + #expect( + DockerRunRequestValidator.validate( + DockerHostLaunch.makeRequest( + dockerArguments: DockerWorkerRuntime.danglingImagePruneArguments, + timeoutSeconds: 60 + ) + ) == nil + ) + } + @Test func orphanSweeperSkipsRemoveWhenNothingMatches() async throws { let recorder = DockerCallRecorder() let executor: DockerCLIExecutor = { args, _, _ in diff --git a/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift b/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift index 076a54b4..713154e6 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift @@ -33,6 +33,9 @@ public enum DerrickDockerRuntimeIdentity: Sendable { [psLabelFilterArguments] + namePrefixes.map(psNameFilterArguments) } + /// Stopped oneshots only — UI launch must not `rm` a container that is still running a job. + public static let stoppedStatuses = ["exited", "dead", "created"] + public static func isAllowedPsFilter(_ filter: String) -> Bool { if filter == "label=\(labelAssignment)" { return true @@ -40,6 +43,23 @@ public enum DerrickDockerRuntimeIdentity: Sendable { return namePrefixes.contains { filter == "name=\($0)" } } + public static func isAllowedPsStatus(_ status: String) -> Bool { + stoppedStatuses.contains(status) + } + + /// `docker ps` argv that lists only stopped Derrick containers. + public static var psStoppedListArguments: [[String]] { + identityFilters.flatMap { identity in + stoppedStatuses.map { status in + ["ps", "-aq", "--filter", identity, "--filter", "status=\(status)"] + } + } + } + + private static var identityFilters: [String] { + ["label=\(labelAssignment)"] + namePrefixes.map { "name=\($0)" } + } + public static func createHasRuntimeLabel(_ dockerArgs: [String]) -> Bool { let args = Array(dockerArgs.dropFirst()) var index = 0 diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerHostLaunch.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerHostLaunch.swift index 9529be90..4a3648fd 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DockerHostLaunch.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerHostLaunch.swift @@ -37,7 +37,8 @@ public enum DockerHostLaunch: Sendable { /// Second-level tokens for `docker image …`. public static let allowedImageSubcommands: Set = [ - "inspect" + "inspect", + "prune", ] /// Exact flags that must never appear on the docker CLI. diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift index a15b1330..8dd0e9cc 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift @@ -41,6 +41,10 @@ public enum DockerWorkerRuntime: Sendable { /// OCI label written by `docker/worker/Dockerfile`; used to detect stale local images. public static let binariesLabelKey = "derrick.worker.binaries" public static let binariesLabelValue = "crawler,extractor,search" + /// Untagged leftover worker images only. Does not remove `derrick-worker:go-v1`. + public static let danglingImagePruneArguments = [ + "image", "prune", "-f", "--filter", "label=\(binariesLabelKey)", + ] /// `docker image inspect --format` template for `binariesLabelKey`. public static let binariesInspectFormat = "{{index .Config.Labels \"\(binariesLabelKey)\"}}" diff --git a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift index ca942714..a249f3c9 100644 --- a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift +++ b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift @@ -1253,6 +1253,25 @@ import Testing #expect(DerrickDockerRuntimeIdentity.isAllowedPsFilter("label=app.derrick=runtime")) #expect(DerrickDockerRuntimeIdentity.isAllowedPsFilter("name=derrick-guest-runtime")) #expect(!DerrickDockerRuntimeIdentity.isAllowedPsFilter("name=nginx")) + #expect(DerrickDockerRuntimeIdentity.isAllowedPsStatus("exited")) + #expect(DerrickDockerRuntimeIdentity.isAllowedPsStatus("dead")) + #expect(DerrickDockerRuntimeIdentity.isAllowedPsStatus("created")) + #expect(!DerrickDockerRuntimeIdentity.isAllowedPsStatus("running")) + #expect(DerrickDockerRuntimeIdentity.psStoppedListArguments.count == 18) + #expect( + DerrickDockerRuntimeIdentity.psStoppedListArguments.contains { + $0 == [ + "ps", "-aq", + "--filter", "name=derrick-guest-runtime", + "--filter", "status=exited", + ] + } + ) + #expect( + DockerWorkerRuntime.danglingImagePruneArguments == [ + "image", "prune", "-f", "--filter", "label=derrick.worker.binaries", + ] + ) #expect( DerrickDockerRuntimeIdentity.createHasRuntimeLabel( ["create"] + DerrickDockerRuntimeIdentity.createLabelArguments + [DockerWorkerRuntime.image] diff --git a/packages/Structure/Tests/StructureTests/DockerWorkerDockerfileTests.swift b/packages/Structure/Tests/StructureTests/DockerWorkerDockerfileTests.swift index b3ce0060..46af77d8 100644 --- a/packages/Structure/Tests/StructureTests/DockerWorkerDockerfileTests.swift +++ b/packages/Structure/Tests/StructureTests/DockerWorkerDockerfileTests.swift @@ -21,6 +21,12 @@ import Testing #expect(text.contains("derrick.worker.binaries")) #expect(text.contains("docker rmi \"golang:${tag}\"")) #expect(text.contains("Keep the live derrick-worker tag")) + #expect(text.contains("derrick-guest-runtime")) + #expect(text.contains("label=app.derrick=runtime")) + #expect(text.contains("exited dead created")) + #expect(text.contains("--filter \"status=$2\"")) + #expect(text.contains("docker rm -f")) + #expect(text.contains("still-running containers")) } private func repoRoot() -> URL { diff --git a/scripts/prune-dangling-worker-images.sh b/scripts/prune-dangling-worker-images.sh index 369e9d86..7b5ef1ae 100755 --- a/scripts/prune-dangling-worker-images.sh +++ b/scripts/prune-dangling-worker-images.sh @@ -1,6 +1,7 @@ #!/bin/sh -# Drop leftover derrick-worker rebuilds after Xcode builds a new tag. -# Keep the live derrick-worker tag and the Dockerfile's golang base. +# After Xcode builds: drop leftover derrick-worker images and stopped oneshot +# containers (guest, search, crawl, extract). Keep the live derrick-worker tag, +# the Dockerfile's golang base, and any still-running containers. # Older local builds have no OCI labels, so label-only prune misses them. PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${PATH}" if ! command -v docker >/dev/null 2>&1; then @@ -21,6 +22,30 @@ GOLANG_KEEP=$(awk ' } ' "$DOCKERFILE") +remove_stopped_runtime_containers() { + ids=$(docker ps -aq --filter "$1" --filter "status=$2" 2>/dev/null) || ids="" + for id in $ids; do + [ -n "$id" ] || continue + docker rm -f "$id" >/dev/null 2>&1 || true + done +} + +for prefix in \ + derrick-guest-runtime \ + derrick-web-crawler \ + derrick-web-search \ + derrick-file-extractor \ + derrick-swift-runtime +do + for status in exited dead created; do + remove_stopped_runtime_containers "name=${prefix}" "$status" + done +done + +for status in exited dead created; do + remove_stopped_runtime_containers "label=app.derrick=runtime" "$status" +done + docker image prune -f --filter "label=derrick.worker.binaries" >/dev/null 2>&1 || true ids=$(docker images -q --filter dangling=true 2>/dev/null) || ids="" diff --git a/ui/MCPService/MCPServiceDockerHelperRunner.swift b/ui/MCPService/MCPServiceDockerHelperRunner.swift index b623facf..2fc0982f 100644 --- a/ui/MCPService/MCPServiceDockerHelperRunner.swift +++ b/ui/MCPService/MCPServiceDockerHelperRunner.swift @@ -63,6 +63,15 @@ final class MCPServiceDockerHelperRunner: @unchecked Sendable { await DerrickDockerOrphanSweeper.sweep(executor: makeStdinCLIExecutor()) } + /// Remove stopped Derrick containers only (UI-safe; leaves running jobs). + @discardableResult + func sweepStoppedRuntimeContainers() async -> Int { + await DerrickDockerOrphanSweeper.sweep( + executor: makeStdinCLIExecutor(), + scope: .stoppedOnly + ) + } + /// Prewarm the shared Go worker image used by script_exec and plugin.invoke. func prewarmGuestRuntime() async throws { try await WorkerImageGate.shared.ensureReady(executor: makeStdinCLIExecutor()) diff --git a/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift b/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift index b08dedf1..0e339048 100644 --- a/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift +++ b/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift @@ -309,6 +309,7 @@ public final class XPCDockerRunner: @unchecked Sendable { dockerReachableState.markCompleted() await reportBootstrapTaskCompleted(.docker) Task { + await pruneLeftoverDockerArtifacts() await prewarmWorkerImage() } } catch { @@ -318,6 +319,23 @@ public final class XPCDockerRunner: @unchecked Sendable { } } + /// Stopped Derrick containers and dangling labeled worker images. Does not + /// remove running jobs or the live `derrick-worker:go-v1` tag. + private func pruneLeftoverDockerArtifacts() async { + let executor = makeDockerExecutor() + let removed = await DerrickDockerOrphanSweeper.sweep( + executor: executor, + scope: .stoppedOnly + ) + if removed > 0 { + debugLog("Removed \(removed) leftover Docker container(s)") + } + let pruned = await DerrickDockerDanglingImagePruner.prune(executor: executor) + if !pruned { + debugLog("Dangling worker image prune skipped") + } + } + private func prewarmWorkerImage() async { do { await reportBootstrap(phase: .preparingImage, message: "Preparing worker image…") diff --git a/ui/ui.xcodeproj/project.pbxproj b/ui/ui.xcodeproj/project.pbxproj index 3d50414f..a18c7274 100644 --- a/ui/ui.xcodeproj/project.pbxproj +++ b/ui/ui.xcodeproj/project.pbxproj @@ -443,7 +443,7 @@ DEAA100A2FFF00000042F228 /* Embed XPC Services */, DEKA10122A00000000KA001 /* Embed JobKeepAlive */, DEKA10132A00000000KA001 /* Embed LaunchAgents */, - DEPRUNE12A00000000UI001 /* Prune dangling Derrick worker images */, + DEPRUNE12A00000000UI001 /* Prune leftover Derrick Docker images and containers */, ); buildRules = ( ); @@ -780,10 +780,10 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - DEPRUNE12A00000000UI001 /* Prune dangling Derrick worker images */ = { + DEPRUNE12A00000000UI001 /* Prune leftover Derrick Docker images and containers */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; - name = "Prune dangling Derrick worker images"; + name = "Prune leftover Derrick Docker images and containers"; shellPath = /bin/sh; shellScript = ( "\"${SRCROOT}/../scripts/prune-dangling-worker-images.sh\"", From 6df6e25d0122a4eedfbce79d19421722ae5941f9 Mon Sep 17 00:00:00 2001 From: David Choi Date: Thu, 17 Sep 2026 17:39:51 -0400 Subject: [PATCH 2/4] update plugins and pages --- .../DBRepositoryPluginFactory.swift | 10 + .../DBRepositoryTests/DBRepositoryTests.swift | 31 +- packages/MCPServer/Package.swift | 17 +- .../LegacySlackConnectorPurge.swift | 31 ++ .../ReferenceSlackConnectorDraft.swift | 329 ------------------ ...swift => SlackConnectorFactoryInput.swift} | 17 +- .../MCPServer/PluginFactoryToolModule.swift | 2 +- .../MCPServer/PluginRuntimeToolModule.swift | 142 +++++++- .../E2EEnvironment.swift | 8 +- .../SlackConnectorInstallReferenceMain.swift | 131 +------ .../Tests/MCPServerTests/MCPServerTests.swift | 48 +++ .../Factory/PluginFactoryImplementation.swift | 21 +- .../PluginTests/PluginFactoryTests.swift | 11 +- .../Plugin/PluginSpecProcession.swift | 3 + .../Contract/ConnectorContractPrompts.swift | 15 +- .../ScriptExecContract.generated.swift | 2 +- .../contracts/script-exec-contract.json | 2 +- .../MCPToolCatalog/AllowedMCPTool.swift | 6 +- .../Factory/PluginFactoryLegacyPurge.swift | 8 + .../Factory/PluginFactoryRuntimeTypes.swift | 9 +- .../Plugin/Factory/PluginFactoryTypes.swift | 179 +++++++++- .../Plugin/Manifest/PluginManifestError.swift | 5 +- .../Plugin/Manifest/PluginPackage.swift | 30 +- .../Plugin/UI/PluginSkillDisclosure.swift | 166 +++++++++ .../AppLayerServicesWireTests.swift | 2 +- .../ConnectorContractTests.swift | 5 +- .../PluginFactoryLegacyPurgeTests.swift | 12 + ...uginFactoryReleaseEditableFilesTests.swift | 183 ++++++++++ .../PluginSpecProcessionTests.swift | 1 + scripts/install-slack-reference-connector.sh | 13 - ui/MCPService/MCPServiceToolHost.swift | 38 +- .../Conversation/ConversationModel.swift | 40 ++- ui/ui/Components/PillSubtabBar.swift | 75 ++++ ui/ui/Messaging/AppWorkspace.swift | 5 + ui/ui/Plugins/PluginCreationController.swift | 149 +++++++- .../PluginPackageBrowserController.swift | 149 ++++++++ ui/ui/Plugins/PluginPackageBrowserView.swift | 256 ++++++++++++++ ui/ui/Plugins/PluginsWorkspaceShellView.swift | 49 +++ ui/ui/Session/ChatSessionStore.swift | 16 +- ui/ui/Session/PluginFactoryListStore.swift | 60 +++- ui/ui/Views/ChatTabBarView.swift | 22 +- ui/ui/Views/ContentView.swift | 69 +++- ui/ui/Views/DebugLogsView.swift | 30 +- .../Views/PluginFactorySettingsListView.swift | 2 +- ui/ui/Views/SidebarView.swift | 14 +- ui/uiTests/ChatTabRoutingTests.swift | 35 +- 46 files changed, 1829 insertions(+), 619 deletions(-) create mode 100644 packages/MCPServer/Sources/FactoryHarnessSupport/LegacySlackConnectorPurge.swift delete mode 100644 packages/MCPServer/Sources/FactoryHarnessSupport/ReferenceSlackConnectorDraft.swift rename packages/MCPServer/Sources/FactoryHarnessSupport/{E2EFactoryBuilder.swift => SlackConnectorFactoryInput.swift} (64%) create mode 100644 packages/Structure/Sources/Plugin/Factory/PluginFactoryLegacyPurge.swift create mode 100644 packages/Structure/Sources/Plugin/UI/PluginSkillDisclosure.swift create mode 100644 packages/Structure/Tests/StructureTests/PluginFactoryLegacyPurgeTests.swift create mode 100644 packages/Structure/Tests/StructureTests/PluginFactoryReleaseEditableFilesTests.swift delete mode 100755 scripts/install-slack-reference-connector.sh create mode 100644 ui/ui/Components/PillSubtabBar.swift create mode 100644 ui/ui/Plugins/PluginPackageBrowserController.swift create mode 100644 ui/ui/Plugins/PluginPackageBrowserView.swift create mode 100644 ui/ui/Plugins/PluginsWorkspaceShellView.swift diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift index a8224ddb..1b1673da 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift @@ -185,4 +185,14 @@ public extension DBRepository { ) } } + + /// Replaces an existing version in place after a manual package edit. + /// The content hash must match the edited package files. + func replacePluginFactoryRelease(_ release: PluginFactoryRelease) throws { + guard release.verifyIntegrity() else { + throw DBRepositoryError.sqliteOperationFailed("Refusing to store a release with an invalid content hash.") + } + try deletePluginFactoryRelease(pluginID: release.pluginID, version: release.version) + try savePluginFactoryRelease(release) + } } diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift index 343da150..c6c13b23 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift @@ -123,6 +123,32 @@ final class DBRepositoryTests: XCTestCase { XCTAssertTrue(loaded?.verifyIntegrity() == true) } + func testReplacePluginFactoryReleaseUpdatesSameVersion() async throws { + let repository = try makeRepository() + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + let release = makeGoFactoryRelease( + pluginID: "weather-tool", + manifestName: "weather-tool", + skillFiles: ["skills/weather/SKILL.md": "# Weather"] + ) + try await repository.savePluginFactoryRelease(release) + + var drafts = Dictionary(uniqueKeysWithValues: release.editableTextPackageFiles().map { + ($0.path, $0.body) + }) + drafts["skills/weather/SKILL.md"] = "# Weather\n\nEdited." + let updated = release.replacingEditableTextPackageFiles(drafts) + try await repository.replacePluginFactoryRelease(updated) + + let loaded = try await repository.pluginFactoryRelease( + pluginID: "weather-tool", + version: "1.0.0" + ) + XCTAssertEqual(loaded?.skillFiles["skills/weather/SKILL.md"], "# Weather\n\nEdited.") + XCTAssertEqual(loaded?.contentHash, updated.contentHash) + XCTAssertTrue(loaded?.verifyIntegrity() == true) + } + func testPluginFactoryReleaseRejectsStaleContentHash() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -600,11 +626,10 @@ final class DBRepositoryTests: XCTestCase { ) -> PluginFactoryRelease { let artifact = Data("compiled".utf8) let guestSource = "package main" - let manifestJSON = "{\"name\":\"\(manifestName)\"}" - let runtimeJSON = #"{"language":"go"}"# + let manifestJSON = "{\"name\":\"\(manifestName)\",\"extensions\":{\"app.derrick\":{\"entrypoint\":\"./app.derrick/plugin.go\"}}}" + let runtimeJSON = "" var files: [String: Data] = [ "plugin.json": Data(manifestJSON.utf8), - "app.derrick/runtime.json": Data(runtimeJSON.utf8), "app.derrick/plugin.go": Data(guestSource.utf8), "app.derrick/plugin": artifact, ] diff --git a/packages/MCPServer/Package.swift b/packages/MCPServer/Package.swift index bcd454fe..53e52ea3 100644 --- a/packages/MCPServer/Package.swift +++ b/packages/MCPServer/Package.swift @@ -69,12 +69,25 @@ let package = Package( ), .target( name: "FactoryHarnessSupport", - dependencies: ["MCPServer", "Plugin", "LLMAgentClient", "Structure"], + dependencies: [ + "MCPServer", + "Plugin", + "LLMAgentClient", + "Structure", + "DBRepository", + ], path: "Sources/FactoryHarnessSupport" ), .executableTarget( name: "FactoryHarness", - dependencies: ["FactoryHarnessSupport", "MCPServer", "Plugin", "LLMAgentClient", "Structure"], + dependencies: [ + "FactoryHarnessSupport", + "MCPServer", + "Plugin", + "LLMAgentClient", + "Structure", + "DBRepository", + ], path: "Sources/FactoryHarness" ), .executableTarget( diff --git a/packages/MCPServer/Sources/FactoryHarnessSupport/LegacySlackConnectorPurge.swift b/packages/MCPServer/Sources/FactoryHarnessSupport/LegacySlackConnectorPurge.swift new file mode 100644 index 00000000..357e7479 --- /dev/null +++ b/packages/MCPServer/Sources/FactoryHarnessSupport/LegacySlackConnectorPurge.swift @@ -0,0 +1,31 @@ +import DBRepository +import Foundation +import Structure + +/// Deletes factory releases and messaging connectors for legacy Slack reference installs. +public enum LegacySlackConnectorPurge: Sendable { + /// Returns how many factory release rows were deleted. + @discardableResult + public static func run(repository: DBRepository) async throws -> Int { + let summaries = try await repository.listPluginFactoryReleaseSummaries() + var pluginIDs = Set() + for summary in summaries where PluginFactoryLegacyPurge.isLegacySlackPluginID(summary.pluginID) { + pluginIDs.insert(summary.pluginID) + } + var deletedReleases = 0 + for pluginID in pluginIDs.sorted() { + let before = summaries.filter { $0.pluginID == pluginID }.count + try await repository.deletePluginFactoryRelease(pluginID: pluginID) + deletedReleases += before + } + + let connectors = try await repository.listMessagingConnectors() + let keep = Set( + connectors + .map(\.pluginID) + .filter { !PluginFactoryLegacyPurge.isLegacySlackPluginID($0) } + ) + try await repository.pruneMessagingConnectors(keeping: keep) + return deletedReleases + } +} diff --git a/packages/MCPServer/Sources/FactoryHarnessSupport/ReferenceSlackConnectorDraft.swift b/packages/MCPServer/Sources/FactoryHarnessSupport/ReferenceSlackConnectorDraft.swift deleted file mode 100644 index 54110bc1..00000000 --- a/packages/MCPServer/Sources/FactoryHarnessSupport/ReferenceSlackConnectorDraft.swift +++ /dev/null @@ -1,329 +0,0 @@ -import Foundation -import Plugin -import Structure - -public enum ReferenceSlackConnectorDraft { - static func make( - scope: PluginFactoryCreateInput.ConnectorScope, - userGoal: String? - ) -> PluginFactoryDraft { - _ = scope - return fullSync(userGoal: userGoal) - } - - private static func fullSync(userGoal: String?) -> PluginFactoryDraft { - draft( - ops: ["sync_threads", "poll_inbox", "send_message"], - testInput: """ - {"hops":[ - {"kind":"manual","params":{"messaging_op":"sync_threads"}}, - {"kind":"http_results","http_results":[{"request_id":"sync-1","status":200,"body":"{\\"ok\\":true,\\"channels\\":[{\\"id\\":\\"C123\\",\\"name\\":\\"general\\",\\"is_member\\":true},{\\"id\\":\\"C999\\",\\"name\\":\\"secret\\",\\"is_member\\":false}],\\"response_metadata\\":{\\"next_cursor\\":\\"\\"}}"}],"params":{"messaging_op":"sync_threads"}}, - {"kind":"manual","params":{"messaging_op":"poll_inbox","vendor_thread_id":"C123"}}, - {"kind":"http_results","http_results":[{"request_id":"poll-1","status":200,"body":"{\\"ok\\":true,\\"messages\\":[{\\"type\\":\\"message\\",\\"user\\":\\"U1\\",\\"text\\":\\"hello\\",\\"ts\\":\\"1710000000.000100\\",\\"channel\\":\\"C123\\",\\"reply_count\\":1,\\"thread_ts\\":\\"1710000000.000100\\"}]}"}],"params":{"messaging_op":"poll_inbox","vendor_thread_id":"C123"}}, - {"kind":"manual","params":{"messaging_op":"poll_inbox","vendor_thread_id":"C123","parent_vendor_message_id":"1710000000.000100"}}, - {"kind":"http_results","http_results":[{"request_id":"replies-1","status":200,"body":"{\\"ok\\":true,\\"messages\\":[{\\"type\\":\\"message\\",\\"user\\":\\"U1\\",\\"text\\":\\"hello\\",\\"ts\\":\\"1710000000.000100\\",\\"thread_ts\\":\\"1710000000.000100\\",\\"reply_count\\":1},{\\"type\\":\\"message\\",\\"user\\":\\"U2\\",\\"text\\":\\"hi this is a thread\\",\\"ts\\":\\"1710000002.000100\\",\\"thread_ts\\":\\"1710000000.000100\\"}]}"}],"params":{"messaging_op":"poll_inbox","vendor_thread_id":"C123","parent_vendor_message_id":"1710000000.000100"}}, - {"kind":"message_in_room","params":{"messaging_op":"send_message","vendor_thread_id":"C123","text":"hello"}}, - {"kind":"http_results","http_results":[{"request_id":"send-1","status":200,"body":"{\\"ok\\":true,\\"channel\\":\\"C123\\",\\"ts\\":\\"1710000001.000100\\",\\"message\\":{\\"text\\":\\"hello\\"}}"}],"params":{"messaging_op":"send_message","vendor_thread_id":"C123","text":"hello"}} - ]} - """, - source: fullSyncSource, - userGoal: userGoal - ) - } - - private static func draft( - ops: [String], - testInput: String, - source: String, - userGoal: String? - ) -> PluginFactoryDraft { - let opsJSON = ops.map { "\"\($0)\"" }.joined(separator: ", ") - let manifestJSON = """ - {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-connection","version":"1.0.0",\ - "description":"Slack messaging connector",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","auth_scheme":"bot_token","secrets":[{"id":"bot_token","label":"Bot Token","kind":"token"}],"permissions":["channels:history","channels:read","chat:write","groups:history","groups:read","im:history","im:read","mpim:history","mpim:read","users:read"],"messaging_ops":[\(opsJSON)]}}} - """ - return PluginFactoryDraft( - manifestJSON: manifestJSON, - guestSource: source, - testInput: Data(testInput.utf8), - userGoal: userGoal - ) - } - - private static let fullSyncSource = """ - package main - - import ( - "encoding/json" - "os" - "sort" - ) - - func emit(v any) { - enc := json.NewEncoder(os.Stdout) - enc.SetEscapeHTML(false) - _ = enc.Encode(v) - } - - func sortedResults(results []map[string]any) []map[string]any { - if len(results) == 0 { - return nil - } - sort.Slice(results, func(i, j int) bool { - left, _ := results[i]["request_id"].(string) - right, _ := results[j]["request_id"].(string) - return left < right - }) - seen := map[string]bool{} - out := []map[string]any{} - for _, item := range results { - rid, _ := item["request_id"].(string) - if rid == "" || seen[rid] { - continue - } - seen[rid] = true - out = append(out, item) - } - return out - } - - func asMap(v any) map[string]any { - if m, ok := v.(map[string]any); ok { - return m - } - return map[string]any{} - } - - func asString(v any) string { - if s, ok := v.(string); ok { - return s - } - return "" - } - - func messageFrom(msg map[string]any, defaultChannel string) map[string]any { - channel := asString(msg["channel"]) - if channel == "" { - channel = defaultChannel - } - ts := asString(msg["ts"]) - if channel == "" || ts == "" { - return nil - } - threadTS := asString(msg["thread_ts"]) - parent := "" - if threadTS != "" && threadTS != ts { - parent = threadTS - } - replyCount := 0 - if n, ok := msg["reply_count"].(float64); ok { - replyCount = int(n) - } - row := map[string]any{ - "vendor_thread_id": channel, - "vendor_message_id": ts, - "direction": "inbound", - "sender": func() string { - if s := asString(msg["user"]); s != "" { - return s - } - return "slack" - }(), - "body": asString(msg["text"]), - "created_at": ts, - "reply_count": replyCount, - } - if parent != "" { - row["parent_vendor_message_id"] = parent - } - return row - } - - func main() { - var event map[string]any - if err := json.NewDecoder(os.Stdin).Decode(&event); err != nil { - return - } - params := asMap(event["params"]) - op := asString(params["messaging_op"]) - kind := asString(event["kind"]) - - switch { - case kind == "manual" && op == "sync_threads": - emit([]map[string]any{{ - "verb": "http.request", "request_id": "sync-1", "method": "GET", - "url": "https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true", - "headers": map[string]any{"Authorization": "Bearer {{secret:bot_token}}"}, - }}) - return - case kind == "manual" && op == "poll_inbox": - channel := asString(params["vendor_thread_id"]) - if channel == "" { - channel = asString(params["channel"]) - } - parent := asString(params["parent_vendor_message_id"]) - if parent == "" { - parent = asString(params["thread_ts"]) - } - if channel == "" { - emit([]map[string]any{{"verb": "result.emit", "messages": []map[string]any{}}}) - return - } - if parent != "" { - url := "https://slack.com/api/conversations.replies?channel=" + channel + "&ts=" + parent + "&limit=50" - emit([]map[string]any{{ - "verb": "http.request", "request_id": "replies-1", "method": "GET", "url": url, - "headers": map[string]any{"Authorization": "Bearer {{secret:bot_token}}"}, - }}) - return - } - url := "https://slack.com/api/conversations.history?channel=" + channel + "&limit=50" - emit([]map[string]any{{ - "verb": "http.request", "request_id": "poll-1", "method": "GET", "url": url, - "headers": map[string]any{"Authorization": "Bearer {{secret:bot_token}}"}, - }}) - return - case kind == "message_in_room" && op == "send_message": - channel := asString(params["vendor_thread_id"]) - text := asString(params["text"]) - parent := asString(params["parent_vendor_message_id"]) - if parent == "" { - parent = asString(params["thread_ts"]) - } - payload := map[string]any{"channel": channel, "text": text} - if parent != "" { - payload["thread_ts"] = parent - } - emit([]map[string]any{{ - "verb": "http.request", "request_id": "send-1", "method": "POST", - "url": "https://slack.com/api/chat.postMessage", - "headers": map[string]any{ - "Authorization": "Bearer {{secret:bot_token}}", - "Content-Type": "application/json", - }, - "json": payload, - }}) - return - case kind == "http_results" && op == "sync_threads": - threads := []map[string]any{} - rawResults, _ := event["http_results"].([]any) - results := []map[string]any{} - for _, item := range rawResults { - results = append(results, asMap(item)) - } - for _, item := range sortedResults(results) { - if asString(item["request_id"]) != "sync-1" { - continue - } - var payload map[string]any - _ = json.Unmarshal([]byte(asString(item["body"])), &payload) - if payload["ok"] == false { - emit([]map[string]any{{ - "verb": "result.emit", - "title": "Slack list failed", - "summary": asString(payload["error"]), - }}) - return - } - channels, _ := payload["channels"].([]any) - sort.Slice(channels, func(i, j int) bool { - left := asMap(channels[i]) - right := asMap(channels[j]) - return asString(left["id"]) < asString(right["id"]) - }) - for _, chAny := range channels { - ch := asMap(chAny) - if ch["is_member"] == false { - continue - } - cid := asString(ch["id"]) - name := asString(ch["name"]) - if name == "" { - name = cid - } - if cid != "" { - threads = append(threads, map[string]any{ - "vendor_thread_id": cid, - "title": "#" + name, - }) - } - } - } - emit([]map[string]any{{"verb": "result.emit", "threads": threads}}) - return - case kind == "http_results" && op == "poll_inbox": - channel := asString(params["vendor_thread_id"]) - if channel == "" { - channel = asString(params["channel"]) - } - messages := []map[string]any{} - rawResults, _ := event["http_results"].([]any) - results := []map[string]any{} - for _, item := range rawResults { - results = append(results, asMap(item)) - } - for _, item := range sortedResults(results) { - rid := asString(item["request_id"]) - if rid != "poll-1" && rid != "replies-1" { - continue - } - var payload map[string]any - _ = json.Unmarshal([]byte(asString(item["body"])), &payload) - if payload["ok"] == false { - emit([]map[string]any{{ - "verb": "result.emit", - "title": "Slack blocked this thread", - "summary": asString(payload["error"]), - }}) - return - } - rawMessages, _ := payload["messages"].([]any) - sort.Slice(rawMessages, func(i, j int) bool { - left := asMap(rawMessages[i]) - right := asMap(rawMessages[j]) - return asString(left["ts"]) < asString(right["ts"]) - }) - for _, msgAny := range rawMessages { - parsed := messageFrom(asMap(msgAny), channel) - if parsed != nil { - messages = append(messages, parsed) - } - } - } - emit([]map[string]any{{"verb": "result.emit", "messages": messages}}) - return - case kind == "http_results" && op == "send_message": - body := map[string]any{} - rawResults, _ := event["http_results"].([]any) - results := []map[string]any{} - for _, item := range rawResults { - results = append(results, asMap(item)) - } - for _, item := range sortedResults(results) { - if asString(item["request_id"]) == "send-1" { - _ = json.Unmarshal([]byte(asString(item["body"])), &body) - } - } - if body["ok"] == true { - ts := asString(body["ts"]) - if ts == "" { - ts = asString(asMap(body["message"])["ts"]) - } - emit([]map[string]any{{ - "verb": "result.emit", - "sent_message": map[string]any{ - "vendor_message_id": ts, - "created_at": ts, - }, - }}) - } else { - emit([]map[string]any{{"verb": "result.emit", "summary": "send failed"}}) - } - return - default: - emit([]map[string]any{{"verb": "result.emit", "summary": "unsupported"}}) - } - } - """ -} diff --git a/packages/MCPServer/Sources/FactoryHarnessSupport/E2EFactoryBuilder.swift b/packages/MCPServer/Sources/FactoryHarnessSupport/SlackConnectorFactoryInput.swift similarity index 64% rename from packages/MCPServer/Sources/FactoryHarnessSupport/E2EFactoryBuilder.swift rename to packages/MCPServer/Sources/FactoryHarnessSupport/SlackConnectorFactoryInput.swift index 7e3b5f74..b8ec8fcd 100644 --- a/packages/MCPServer/Sources/FactoryHarnessSupport/E2EFactoryBuilder.swift +++ b/packages/MCPServer/Sources/FactoryHarnessSupport/SlackConnectorFactoryInput.swift @@ -1,21 +1,8 @@ import Foundation -import Plugin import Structure -/// Supplies known-good Slack connector drafts for end-to-end messaging verification. -public actor E2EFactoryBuilder: PluginFactoryBuilder { - private let scope: PluginFactoryCreateInput.ConnectorScope - - public init(scope: PluginFactoryCreateInput.ConnectorScope) { - self.scope = scope - } - - public func makeDraft(_ request: PluginFactoryBuilderRequest) async throws -> PluginFactoryDraft { - fputs("[E2E] building reference Slack draft for \(scope.rawValue)\n", stderr) - return ReferenceSlackConnectorDraft.make(scope: scope, userGoal: request.userGoal) - } -} - +/// Shared Slack connector factory input helpers for live/E2E LLM builds. +/// Not a packaged reference draft — builders must use LiveFactoryBuilder. public enum SlackConnectorFactoryInput { public static let defaultCrawlSummary = """ Slack Web API chat.postMessage accepts JSON with channel and text. Authenticate with a bot token \ diff --git a/packages/MCPServer/Sources/MCPServer/PluginFactoryToolModule.swift b/packages/MCPServer/Sources/MCPServer/PluginFactoryToolModule.swift index 3bcb7363..fd07a6b7 100644 --- a/packages/MCPServer/Sources/MCPServer/PluginFactoryToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/PluginFactoryToolModule.swift @@ -92,7 +92,7 @@ public enum PluginFactoryToolModule: MCPToolModule { let diagnostics: [ToolExecutionOutcome.Diagnostic] let retryAllowed: Bool switch error { - case .invalidManifest, .invalidSkillPath, .reservedPluginID, .invalidSource: + case .invalidManifest, .invalidSkillPath, .missingSkillFiles, .reservedPluginID, .invalidSource: status = .blocked stage = .validation diagnostics = [diagnostic(for: error)] diff --git a/packages/MCPServer/Sources/MCPServer/PluginRuntimeToolModule.swift b/packages/MCPServer/Sources/MCPServer/PluginRuntimeToolModule.swift index 2e7906b9..cba5a29e 100644 --- a/packages/MCPServer/Sources/MCPServer/PluginRuntimeToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/PluginRuntimeToolModule.swift @@ -7,7 +7,8 @@ import Structure /// plugin-specific dispatch here: every release receives JSON on stdin. public enum PluginRuntimeToolModule { public static func makeListRegistration( - list: @escaping @Sendable () async throws -> [PluginFactoryReleaseSummary] + list: @escaping @Sendable () async throws -> [PluginFactoryReleaseSummary], + skillIndex: @escaping @Sendable () async throws -> [PluginSkillDisclosure.IndexEntry] = { [] } ) -> MCPToolRegistration { MCPToolRegistration( tool: .pluginList, @@ -18,17 +19,142 @@ public enum PluginRuntimeToolModule { ]) ) { _ in let releases = try await list() - let data = try JSONEncoder().encode(releases.map { release in - [ - "plugin_id": release.pluginID, - "version": release.version, - "content_hash": release.contentHash, - ] - }) + let skills = try await skillIndex() + let payload: [String: Any] = [ + "releases": releases.map { release in + [ + "plugin_id": release.pluginID, + "version": release.version, + "content_hash": release.contentHash, + ] as [String: String] + }, + "skills": skills.map { entry in + [ + "plugin_id": entry.pluginID, + "skill_name": entry.skillName, + "description": entry.description, + ] as [String: String] + }, + ] + let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) return String(decoding: data, as: UTF8.self) } } + public static func makeSkillRegistration( + loadRelease: @escaping @Sendable (String) async throws -> PluginFactoryRelease? + ) -> MCPToolRegistration { + MCPToolRegistration( + tool: .pluginSkill, + description: AllowedMCPTool.pluginSkill.defaultDescription, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "plugin_id": .object([ + "type": .string("string"), + "description": .string("Approved plugin id that owns the skill."), + ]), + "action": .object([ + "type": .string("string"), + "description": .string("activate (full SKILL.md) or reference (one references/* file)."), + ]), + "skill": .object([ + "type": .string("string"), + "description": .string("Skill name or skills//SKILL.md path (required for activate)."), + ]), + "path": .object([ + "type": .string("string"), + "description": .string("Reference path or filename (required for reference)."), + ]), + ]), + "required": .array([.string("plugin_id"), .string("action")]), + ]) + ) { arguments in + let pluginID = arguments["plugin_id"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let action = arguments["action"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + guard !pluginID.isEmpty else { + return try failure( + stage: .validation, + code: "plugin_id_required", + message: "plugin_id is required." + ).encodedJSON() + } + guard let release = try await loadRelease(pluginID) else { + return try failure( + stage: .validation, + code: "plugin_not_found", + message: "No approved plugin named \(pluginID)." + ).encodedJSON() + } + switch action { + case "activate": + let skill = arguments["skill"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !skill.isEmpty else { + return try failure( + stage: .validation, + code: "skill_required", + message: "skill is required for action=activate." + ).encodedJSON() + } + guard let body = PluginSkillDisclosure.activate( + skillFiles: release.skillFiles, + skillNameOrPath: skill + ) else { + return try failure( + stage: .validation, + code: "skill_not_found", + message: "No skill matching \(skill) on /\(pluginID)." + ).encodedJSON() + } + return try ToolExecutionOutcome.completed( + output: ToolExecutionOutcome.Output(format: .text, value: body) + ).encodedJSON() + case "reference": + let path = arguments["path"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !path.isEmpty else { + return try failure( + stage: .validation, + code: "path_required", + message: "path is required for action=reference." + ).encodedJSON() + } + guard let hit = PluginSkillDisclosure.reference( + skillFiles: release.skillFiles, + requested: path + ) else { + let available = PluginSkillDisclosure.referencePaths(skillFiles: release.skillFiles) + let hint = available.isEmpty + ? "No references shipped for /\(pluginID)." + : "Available: \(available.joined(separator: ", "))" + return try failure( + stage: .validation, + code: "reference_not_found", + message: "No reference matching \(path). \(hint)" + ).encodedJSON() + } + let payload: [String: String] = ["path": hit.path, "body": hit.body] + let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + return try ToolExecutionOutcome.completed( + output: ToolExecutionOutcome.Output( + format: .json, + value: String(decoding: data, as: UTF8.self) + ) + ).encodedJSON() + default: + return try failure( + stage: .validation, + code: "invalid_action", + message: "action must be activate or reference." + ).encodedJSON() + } + } + } + public static func makeInvokeRegistration( invoke: @escaping @Sendable (String, Data) async throws -> PluginFactoryExecutionResult ) -> MCPToolRegistration { diff --git a/packages/MCPServer/Sources/SlackConnectorE2EHarness/E2EEnvironment.swift b/packages/MCPServer/Sources/SlackConnectorE2EHarness/E2EEnvironment.swift index dac39f40..ab3176b7 100644 --- a/packages/MCPServer/Sources/SlackConnectorE2EHarness/E2EEnvironment.swift +++ b/packages/MCPServer/Sources/SlackConnectorE2EHarness/E2EEnvironment.swift @@ -270,14 +270,18 @@ struct E2EEnvironment { ) let goal = input.connectorBuildGoal(crawlSummary: SlackConnectorFactoryInput.defaultCrawlSummary) - fputs("[E2E] factory build scope=\(scope.rawValue)…\n", stderr) + fputs("[E2E] factory build scope=\(scope.rawValue) via LiveFactoryBuilder…\n", stderr) + let apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] ?? "" + guard !apiKey.isEmpty else { + throw E2EError.factoryFailed("OPENAI_API_KEY is required; reference Slack drafts were removed.") + } let executor = GoPluginFactoryDockerExecutor(executor: dockerExecutor) let release = try await PluginFactorySession( configuration: PluginFactoryConfiguration(maxBuilderAttempts: 5) ).build( userGoal: goal, hostManifest: input.hostManifest, - builder: E2EFactoryBuilder(scope: scope), + builder: LiveFactoryBuilder(apiKey: apiKey), executor: executor, reviewer: E2EHarnessReviewer(), logger: { message in diff --git a/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift b/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift index 9fd283b6..cd304c17 100644 --- a/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift +++ b/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift @@ -2,23 +2,22 @@ import DBRepository import DerrickBackend import FactoryHarnessSupport import Foundation -import MCPServer -import Plugin import Structure +/// One-shot cleanup: removes legacy reference Slack factory releases and messaging connectors. @main enum SlackConnectorInstallReference { static func main() async { do { - try await run() - fputs("SlackConnectorInstallReference: SUCCESS\n", stderr) + try await purge() + fputs("SlackConnectorInstallReference: purged legacy Slack reference connectors.\n", stderr) } catch { fputs("SlackConnectorInstallReference: FAILED — \(error)\n", stderr) exit(1) } } - private static func run() async throws { + private static func purge() async throws { let directory = try DerrickAppSupport.databaseDirectory() let repository = DBRepository( configuration: DBRepositoryConfiguration( @@ -30,125 +29,7 @@ enum SlackConnectorInstallReference { ) ) _ = try await repository.createEmptyDatabaseIfNeeded(username: "ui", password: "ui") - fputs("[install] DB: \(await repository.databaseURL.path)\n", stderr) - - try await deleteExistingConnectors(repository: repository) - - let dockerExecutor = DirectShellDocker.executor() - let first = try await createWorkingSlackConnector( - pluginID: "slack-connector-1", - repository: repository, - dockerExecutor: dockerExecutor - ) - let second = try await createWorkingSlackConnector( - pluginID: "slack-connector-2", - repository: repository, - dockerExecutor: dockerExecutor - ) - guard first != second else { - throw InstallError("Second create reused plugin id \(first).") - } - fputs("[install] created \(first) then \(second)\n", stderr) - } - - private static func deleteExistingConnectors(repository: DBRepository) async throws { - let summaries = try await repository.listPluginFactoryReleaseSummaries() - var seen = Set() - for summary in summaries { - let pluginID = summary.pluginID - guard seen.insert(pluginID).inserted else { continue } - let slack = pluginID.localizedCaseInsensitiveContains("slack") - if slack { - try await repository.deletePluginFactoryRelease(pluginID: pluginID) - fputs("[install] deleted factory release \(pluginID)\n", stderr) - } - } - try await repository.pruneMessagingConnectors(keeping: []) - fputs("[install] cleared messaging connectors\n", stderr) - } - - private static func createWorkingSlackConnector( - pluginID: String, - repository: DBRepository, - dockerExecutor: @escaping DockerCLIExecutor - ) async throws -> String { - let input = try SlackConnectorFactoryInput.make(pluginID: pluginID) - let goal = input.connectorBuildGoal( - crawlSummary: SlackConnectorFactoryInput.defaultCrawlSummary - ) - fputs("[install] packaging \(pluginID)…\n", stderr) - let release = try await PluginFactorySession( - configuration: PluginFactoryConfiguration(maxBuilderAttempts: 1) - ).build( - userGoal: goal, - hostManifest: input.hostManifest, - builder: E2EFactoryBuilder(scope: .fullSync), - executor: GoPluginFactoryDockerExecutor(executor: dockerExecutor), - reviewer: E2EHarnessReviewer(), - logger: { fputs("\($0)\n", stderr) } - ) - guard release.pluginID == pluginID else { - throw InstallError("Factory saved \(release.pluginID) instead of \(pluginID).") - } - try await repository.savePluginFactoryRelease(release) - fputs("[install] saved \(release.pluginID)@\(release.version)\n", stderr) - - let fields = PluginSecretField.fields(fromManifestJSON: Data(release.manifestJSON.utf8)) - .map(\.descriptor) - PluginSecretHostMirror.syncDevelopmentSecretsToKeychain( - pluginID: pluginID, - fields: fields - ) - guard PluginSecretResolver.resolve(pluginID: pluginID, fieldID: "bot_token") != nil else { - throw InstallError("Slack bot token missing. Set SLACK_BOT_KEY in ui/ui/Resources/.env.") - } - - try await repository.upsertMessagingConnector( - MessagingConnectorDTO( - pluginID: pluginID, - displayName: pluginID, - listening: true, - listeningSince: Date() - ) - ) - - await HostHTTPClient.shared.setAccessGate(AllowAllHostHTTPAccessGate()) - await HostHTTPClient.shared.setSecretAttacher(HarnessSecretAttacher(pluginID: pluginID)) - - let invoker = ConnectorPluginInvoker { _, input in - let result = try await GuestPluginRunner.run( - release: release, - input: input, - dockerExecutor: dockerExecutor, - timeoutSeconds: 180 - ) - guard result.exitCode == 0 else { - let stderrText = String(decoding: result.stderr, as: UTF8.self) - let stdoutText = String(decoding: result.stdout, as: UTF8.self) - throw InstallError(stderrText.isEmpty ? stdoutText : stderrText) - } - let stdout = String(decoding: result.stdout, as: UTF8.self) - return try ToolExecutionOutcome.completed( - output: ToolExecutionOutcome.Output(format: .json, value: stdout) - ).encodedJSON() - } - - let adapter = PluginMessagingIngressAdapter(pluginID: pluginID, invoker: invoker) - try await adapter.bootstrap(repository: repository) - let threads = try await repository.listMessagingThreads(pluginID: pluginID) - fputs("[install] \(pluginID) bootstrap loaded \(threads.count) conversation(s)\n", stderr) - for thread in threads.prefix(8) { - fputs(" - \(thread.title) (\(thread.vendorThreadID))\n", stderr) - } - guard !threads.isEmpty else { - throw InstallError("Bootstrap of \(pluginID) completed but no conversations were loaded.") - } - return pluginID + let removed = try await LegacySlackConnectorPurge.run(repository: repository) + fputs("[purge] removed \(removed) Slack factory release(s)\n", stderr) } } - -private struct InstallError: Error, CustomStringConvertible { - let message: String - init(_ message: String) { self.message = message } - var description: String { message } -} diff --git a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift index 7e51ac10..1ae03cbe 100644 --- a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift +++ b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift @@ -1074,6 +1074,54 @@ import WebCrawler #expect(result.text.contains("guest runtime failed")) } + @Test func pluginSkillActivatesOnDemand() async throws { + let release = PluginFactoryRelease( + pluginID: "weather-tool", + version: "1.0.0", + manifestJSON: #"{"name":"weather-tool"}"#, + runtimeJSON: "", + guestSource: "package main", + compiledArtifact: Data(), + skillFiles: [ + "skills/weather/SKILL.md": "---\nname: weather\ndescription: Forecasts\n---\n# Full skill\n", + "skills/weather/references/api.md": "# API", + ], + contentHash: try PluginContentHash(hex: String(repeating: "c", count: 64)), + reviewSummary: "ok" + ) + let bridge = try await MCPLocalBridge.make { server in + await server.register( + PluginRuntimeToolModule.makeSkillRegistration { pluginID in + pluginID == release.pluginID ? release : nil + } + ) + } + + let activated = try await bridge.client.callTool( + named: "plugin.skill", + arguments: [ + "plugin_id": .string("weather-tool"), + "action": .string("activate"), + "skill": .string("weather"), + ] + ) + #expect(!activated.isError) + #expect(activated.text.contains("# Full skill")) + + let referenced = try await bridge.client.callTool( + named: "plugin.skill", + arguments: [ + "plugin_id": .string("weather-tool"), + "action": .string("reference"), + "path": .string("api.md"), + ] + ) + #expect(!referenced.isError) + #expect(referenced.text.contains("weather")) + #expect(referenced.text.contains("api.md")) + #expect(referenced.text.contains("# API")) + } + @Test func pluginFactorySurfacesReviewFailureOutcome() async throws { let bridge = try await MCPLocalBridge.make { server in await server.register( diff --git a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift index fda61334..138bbe66 100644 --- a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift +++ b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift @@ -261,14 +261,15 @@ public struct PluginFactory: Sendable { throw PluginFactoryError.invalidPackagedOutput(error.localizedDescription) } - let runtimeJSON = try runtimeJSON(for: manifest) let guestPath = PluginFactoryRuntime.guestSourcePackagePath( - runtimeJSON: runtimeJSON, + runtimeJSON: "", manifestJSON: draft.manifestJSON ) + guard draft.skillFiles.keys.contains(where: { PluginFactorySkillFile.isSkillMarkdownPath($0) }) else { + throw PluginFactoryError.missingSkillFiles + } var files: [String: Data] = [ "plugin.json": Data(draft.manifestJSON.utf8), - "app.derrick/runtime.json": Data(runtimeJSON.utf8), guestPath: Data(draft.guestSource.utf8), "app.derrick/plugin": artifact, ] @@ -284,7 +285,7 @@ public struct PluginFactory: Sendable { pluginID: manifest.name.rawValue, version: version, manifestJSON: draft.manifestJSON, - runtimeJSON: runtimeJSON, + runtimeJSON: "", guestSource: draft.guestSource, compiledArtifact: artifact, skillFiles: draft.skillFiles, @@ -336,18 +337,6 @@ public struct PluginFactory: Sendable { } } - private func runtimeJSON(for manifest: AgentPluginManifest) throws -> String { - guard let entrypoint = manifest.derrick?.entrypoint else { - throw PluginFactoryError.invalidManifest("A Go entrypoint is required.") - } - let object: [String: String] = [ - "language": "go", - "entrypoint": entrypoint, - ] - let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) - return String(decoding: data, as: UTF8.self) - } - private func validateOutput(_ data: Data) throws { _ = try PluginEnvelopeList.decode(data) } diff --git a/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift b/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift index 842a6c65..0ba58a74 100644 --- a/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift +++ b/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift @@ -38,7 +38,6 @@ import Testing #expect(guestPath == "app.derrick/plugin.go") let files: [String: Data] = [ "plugin.json": Data(manifestJSON.utf8), - "app.derrick/runtime.json": Data(runtimeJSON.utf8), guestPath: Data(guestSource.utf8), "app.derrick/plugin": artifact, ] @@ -46,11 +45,15 @@ import Testing pluginID: "slack-connector-1", version: "1.0.0", manifestJSON: manifestJSON, - runtimeJSON: runtimeJSON, + runtimeJSON: "", guestSource: guestSource, compiledArtifact: artifact, - skillFiles: [:], - contentHash: PluginContentHash.hash(files: files), + skillFiles: ["skills/slack-connector-1/SKILL.md": "---\nname: slack\ndescription: Slack\n---\n"], + contentHash: PluginContentHash.hash(files: { + var all = files + all["skills/slack-connector-1/SKILL.md"] = Data("---\nname: slack\ndescription: Slack\n---\n".utf8) + return all + }()), reviewSummary: "ok" ) #expect(release.verifyIntegrity()) diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift index 0fe0c82b..6967eb6a 100644 --- a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift @@ -53,6 +53,9 @@ public struct PluginSpecTurn: Equatable, Sendable { public enum PluginSpecProcession: Sendable { public static let creatorTabID = "plugin-creator" public static let creatorTabIDPrefix = "plugin-creator" + /// Chat tab title for the Plugins workspace (Create plugin + Plugins browser). + public static let pluginsTabTitle = "Plugins" + /// Label for the Create plugin subtab and seeded creator turn prompt. public static let creatorTabTitlePrefix = "Create plugin" public static let creatorTabTitleSnippetLimit = 42 diff --git a/packages/Structure/Sources/Contract/ConnectorContractPrompts.swift b/packages/Structure/Sources/Contract/ConnectorContractPrompts.swift index 37f80748..341c29e6 100644 --- a/packages/Structure/Sources/Contract/ConnectorContractPrompts.swift +++ b/packages/Structure/Sources/Contract/ConnectorContractPrompts.swift @@ -93,13 +93,11 @@ public enum ConnectorContractPrompts: Sendable { parts.append( """ After listing conversations, emit ui.present in the same envelope list as result.emit. \ - Use a root tree whose element ids come only from host-ui-library.json. \ - Prefer the messaging_inbox example: screen holds=message_exchange and selection=conversations, tab_strip bind=conversations, \ - message_list and composer on the selected conversation, sidebar for replies. \ + Use element ids from the host UI catalog summary only. Prefer asking for the messaging_inbox example tree, then adapt. \ selection=conversations means the host opens the first conversation immediately — do not request an empty screen. \ Do not add error or timeout widgets; the host shows those only after a later command fails. \ - Ask the host to build those pieces; do not invent vendor widgets. Follow crawled API notes for nesting. \ - Keep http hops for vendor calls. The host records ui.present and finishes that hop — do not wait for another guest run after present. + Keep http hops for vendor calls. The host records ui.present and finishes that hop — do not wait for another guest run after present. \ + skill_files must include at least one skills//SKILL.md (Agent Skills required). """ ) return parts.joined(separator: "\n\n") @@ -135,11 +133,10 @@ public enum ConnectorContractPrompts: Sendable { lines.append("--- \(GuestContract.Schema.connectorResultEmit.rawValue) ---") lines.append(try GuestContract.loadSchemaText(.connectorResultEmit)) lines.append("") - lines.append("--- host-ui-library.json ---") - lines.append(try HostUILibraryStore.loadText()) + lines.append("--- host UI catalog (summary) ---") + lines.append(try HostUIDisclosure.catalogSummary()) lines.append("") - lines.append("--- \(GuestContract.Schema.hostUINode.rawValue) ---") - lines.append(try GuestContract.loadSchemaText(.hostUINode)) + lines.append("Ask the host for HostUIDisclosure.elementSchema(id:) or exampleTree(named:) before inventing config. Do not expect a full host-ui-library.json dump.") if let vendor, let vendorJSON = try ConnectorContractStore.loadVendorText(vendor.vendor) { lines.append("") lines.append("--- vendor \(vendor.vendor) ---") diff --git a/packages/Structure/Sources/Contract/Generated/ScriptExecContract.generated.swift b/packages/Structure/Sources/Contract/Generated/ScriptExecContract.generated.swift index 82f75309..15d4f9dc 100644 --- a/packages/Structure/Sources/Contract/Generated/ScriptExecContract.generated.swift +++ b/packages/Structure/Sources/Contract/Generated/ScriptExecContract.generated.swift @@ -2,7 +2,7 @@ /// SHA-256 of script_exec protocol JSON and schemas. `swift test` fails when this is stale. public enum ScriptExecContractFingerprint: Sendable { - public static let sha256 = "2984a39bfbb89d8c66607807a3eb49b6adf06e26d41820d2d125179ae558c919" + public static let sha256 = "5edcb48c764a7a7cbd1ecc89f496a5d88c6d16625596f656c2be824eb2efa75d" public static let sourceFiles: [String] = [ "schemas/script-exec-contract.schema.json", "schemas/guest-runtime.schema.json", diff --git a/packages/Structure/Sources/Contract/Resources/contracts/script-exec-contract.json b/packages/Structure/Sources/Contract/Resources/contracts/script-exec-contract.json index 99f30c60..3ec5bfc2 100644 --- a/packages/Structure/Sources/Contract/Resources/contracts/script-exec-contract.json +++ b/packages/Structure/Sources/Contract/Resources/contracts/script-exec-contract.json @@ -148,7 +148,7 @@ "messaging_ops" ], "skill_files_path_pattern": "skills//SKILL.md", - "empty_skill_files_when_unused": true, + "empty_skill_files_when_unused": false, "test_input_is_serialized_json_object": true, "test_input_must_not_be_empty": true, "test_input_exercises_terminal_result": true, diff --git a/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift b/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift index e81374cf..f4e00270 100644 --- a/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift +++ b/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift @@ -22,6 +22,8 @@ public enum AllowedMCPTool: String, CaseIterable, Sendable, Codable, Hashable { case pluginFactoryBuild = "plugin_factory_build" /// Lists approved compiled plugin releases. case pluginList = "plugin.list" + /// Progressive disclosure: activate a skill (full SKILL.md) or fetch a references/* file. + case pluginSkill = "plugin.skill" /// Runs one approved compiled plugin release. case pluginInvoke = "plugin.invoke" /// Crawls a bounded same-origin website in an isolated container. @@ -67,7 +69,9 @@ public enum AllowedMCPTool: String, CaseIterable, Sendable, Codable, Hashable { case .pluginFactoryBuild: return "Translate a user goal into an Agent Plugin draft, test it, independently review it, compile it with Swift, and verify its release hash." case .pluginList: - return "List approved compiled Agent Plugin releases." + return "List approved Agent Plugin releases with a cheap skill index (name + description only)." + case .pluginSkill: + return "Progressive disclosure for Agent Plugins: action=activate returns full SKILL.md; action=reference returns one references/* file. Do not dump the whole package." case .pluginInvoke: return "Run one approved compiled Agent Plugin by id with a JSON input object." case .webCrawl: diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryLegacyPurge.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryLegacyPurge.swift new file mode 100644 index 00000000..fcda6b13 --- /dev/null +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryLegacyPurge.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Identifies and removes legacy Slack reference connector installs from the factory store. +public enum PluginFactoryLegacyPurge: Sendable { + public static func isLegacySlackPluginID(_ pluginID: String) -> Bool { + pluginID.lowercased().contains("slack") + } +} diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift index 20586073..200e5f14 100644 --- a/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift @@ -27,20 +27,21 @@ public struct PluginFactoryRuntime: Sendable, Equatable { return PluginFactoryRuntime(language: language, entrypoint: entrypoint) } - /// Package-relative guest source path used when hashing and verifying releases. + /// Package-relative guest source path from `extensions.app.derrick.entrypoint`. + /// `runtimeJSON` is ignored (legacy argument); prefer manifest. public static func guestSourcePackagePath( runtimeJSON: String, manifestJSON: String, defaultPath: String = "app.derrick/plugin.go" ) -> String { - if let runtime = decode(from: runtimeJSON) { - return normalizePackageRelativePath(runtime.entrypoint) - } if let data = manifestJSON.data(using: .utf8), let manifest = try? AgentPluginManifest.decode(data), let entrypoint = manifest.derrick?.entrypoint { return normalizePackageRelativePath(entrypoint) } + if let runtime = decode(from: runtimeJSON) { + return normalizePackageRelativePath(runtime.entrypoint) + } return defaultPath } diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift index 42c6e1f9..a34ca887 100644 --- a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift @@ -236,6 +236,11 @@ public struct PluginFactorySkillFile: Codable, Sendable, Hashable { } public static func isValidPath(_ path: String) -> Bool { + isSkillMarkdownPath(path) || isSkillReferencePath(path) + } + + /// Agent Skills required file: `skills//SKILL.md`. + public static func isSkillMarkdownPath(_ path: String) -> Bool { let components = path.split(separator: "/", omittingEmptySubsequences: false) guard components.count == 3, components[0] == "skills", @@ -243,7 +248,25 @@ public struct PluginFactorySkillFile: Codable, Sendable, Hashable { else { return false } - return components[1].range( + return isValidSkillDirectoryName(String(components[1])) + } + + /// Optional progressive-disclosure assets under a skill. + public static func isSkillReferencePath(_ path: String) -> Bool { + let components = path.split(separator: "/", omittingEmptySubsequences: false) + guard components.count == 4, + components[0] == "skills", + components[2] == "references", + !components[3].isEmpty, + !components[3].contains("..") + else { + return false + } + return isValidSkillDirectoryName(String(components[1])) + } + + private static func isValidSkillDirectoryName(_ name: String) -> Bool { + name.range( of: #"^[A-Za-z0-9][A-Za-z0-9_-]*$"#, options: .regularExpression ) != nil @@ -470,6 +493,9 @@ public struct PluginFactoryBuilderResponse: Codable, Sendable, Hashable { } files[skill.path] = skill.body } + guard files.keys.contains(where: { PluginFactorySkillFile.isSkillMarkdownPath($0) }) else { + throw PluginFactoryError.missingSkillFiles + } return try PluginFactoryDraft( manifest: PluginFactoryManifestInput( pluginID: pluginID, @@ -601,6 +627,10 @@ public struct PluginFactoryReleaseSummary: Identifiable, Sendable, Hashable { } public struct PluginFactoryRelease: Sendable, Hashable { + public static let compiledArtifactPackagePath = "app.derrick/plugin" + public static let manifestPackagePath = "plugin.json" + public static let runtimePackagePath = "app.derrick/runtime.json" + public let pluginID: String public let version: String public let manifestJSON: String @@ -634,10 +664,11 @@ public struct PluginFactoryRelease: Sendable, Hashable { } /// Recompute the digest immediately before execution. A release is usable - /// only when its manifest, source, skills, runtime metadata, and binary - /// still match the digest captured at promotion. + /// only when its manifest, source, skills, and binary still match the digest + /// captured at promotion. Legacy releases that hashed `runtime.json` still verify. public func verifyIntegrity() -> Bool { Self.verifyIntegrity(files: packageFiles(), expected: contentHash) + || Self.verifyIntegrity(files: legacyPackageFilesIncludingRuntime(), expected: contentHash) } /// Verifies files read back from storage before a release is executed. @@ -648,27 +679,155 @@ public struct PluginFactoryRelease: Sendable, Hashable { PluginContentHash.hash(files: files) == expected } + /// Agent Plugin package members used for hashing. No proprietary `runtime.json`. public func packageFiles() -> [String: Data] { let guestPath = PluginFactoryRuntime.guestSourcePackagePath( - runtimeJSON: runtimeJSON, + runtimeJSON: "", manifestJSON: manifestJSON ) var files: [String: Data] = [ - "plugin.json": Data(manifestJSON.utf8), - "app.derrick/runtime.json": Data(runtimeJSON.utf8), + Self.manifestPackagePath: Data(manifestJSON.utf8), guestPath: Data(guestSource.utf8), - "app.derrick/plugin": compiledArtifact, + Self.compiledArtifactPackagePath: compiledArtifact, ] for (path, body) in skillFiles { files[path] = Data(body.utf8) } return files } + + /// Pre-revamp packages included `app.derrick/runtime.json` in the digest. + public func legacyPackageFilesIncludingRuntime() -> [String: Data] { + var files = packageFiles() + let trimmed = runtimeJSON.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return files } + let guestPath = PluginFactoryRuntime.guestSourcePackagePath( + runtimeJSON: runtimeJSON, + manifestJSON: manifestJSON + ) + files[guestPath] = Data(guestSource.utf8) + files[Self.runtimePackagePath] = Data(runtimeJSON.utf8) + return files + } + + /// Text package members (includes legacy runtime when present). Prefer `browserPackageFiles`. + public func editableTextPackageFiles() -> [(path: String, body: String)] { + browserPackageFiles() + } + + /// Readonly Plugins browser: pretty `plugin.json`, Go source, skills/references. + /// Omits compiled binary and proprietary `runtime.json`. + public func browserPackageFiles() -> [(path: String, body: String)] { + let guestPath = PluginFactoryRuntime.guestSourcePackagePath( + runtimeJSON: "", + manifestJSON: manifestJSON + ) + var items: [(path: String, body: String)] = [ + (Self.manifestPackagePath, Self.prettyPrintedJSON(manifestJSON)), + (guestPath, guestSource), + ] + for path in skillFiles.keys.sorted() { + let body = skillFiles[path] ?? "" + items.append((path, path.hasSuffix(".json") ? Self.prettyPrintedJSON(body) : body)) + } + return items + } + + /// - Warning: Deprecated name; use `browserPackageFiles()`. + public func browserEditableTextPackageFiles() -> [(path: String, body: String)] { + browserPackageFiles() + } + + public static func prettyPrintedJSON(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let pretty = try? JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ), + let text = String(data: pretty, encoding: .utf8) + else { + return raw + } + return text + } + + public static func defaultSkillPackagePath(pluginID: String) -> String { + "skills/\(pluginID)/SKILL.md" + } + + public static func defaultSkillMarkdown(pluginID: String) -> String { + """ + --- + name: \(pluginID) + description: Describe when Derrick should use /\(pluginID). + --- + + # /\(pluginID) + + Explain what this plugin does and when to call it. + """ + } + + /// Rebuilds this release from edited text files, keeping the compiled artifact. + /// Paths that are not package text members are ignored. Does not write `runtime.json`. + public func replacingEditableTextPackageFiles( + _ files: [String: String] + ) -> PluginFactoryRelease { + let newManifest = files[Self.manifestPackagePath] ?? manifestJSON + let oldGuestPath = PluginFactoryRuntime.guestSourcePackagePath( + runtimeJSON: "", + manifestJSON: manifestJSON + ) + let newGuestPath = PluginFactoryRuntime.guestSourcePackagePath( + runtimeJSON: "", + manifestJSON: newManifest + ) + let newGuest = files[newGuestPath] ?? files[oldGuestPath] ?? guestSource + + let reserved: Set = [ + Self.manifestPackagePath, + Self.runtimePackagePath, + Self.compiledArtifactPackagePath, + oldGuestPath, + newGuestPath, + ] + var newSkills: [String: String] = [:] + for (path, body) in files where !reserved.contains(path) { + newSkills[path] = body + } + for (path, body) in skillFiles where newSkills[path] == nil && files[path] == nil { + newSkills[path] = body + } + + var package: [String: Data] = [ + Self.manifestPackagePath: Data(newManifest.utf8), + newGuestPath: Data(newGuest.utf8), + Self.compiledArtifactPackagePath: compiledArtifact, + ] + for (path, body) in newSkills { + package[path] = Data(body.utf8) + } + + return PluginFactoryRelease( + pluginID: pluginID, + version: version, + manifestJSON: newManifest, + runtimeJSON: "", + guestSource: newGuest, + compiledArtifact: compiledArtifact, + skillFiles: newSkills, + contentHash: PluginContentHash.hash(files: package), + reviewSummary: reviewSummary + ) + } } public enum PluginFactoryError: Error, LocalizedError, Equatable, Sendable { case invalidManifest(String) case invalidSkillPath(String) + case missingSkillFiles case reservedPluginID(String) case invalidSource(String) case directRunFailed(String) @@ -683,7 +842,7 @@ public enum PluginFactoryError: Error, LocalizedError, Equatable, Sendable { switch self { case .directRunFailed, .invalidDirectOutput: return true - case .invalidSkillPath, .invalidManifest, .invalidSource: + case .invalidSkillPath, .missingSkillFiles, .invalidManifest, .invalidSource: return true case .reviewRejected, .draftValidationFailed: return true @@ -696,7 +855,9 @@ public enum PluginFactoryError: Error, LocalizedError, Equatable, Sendable { switch self { case .invalidManifest(let message): return "Invalid Agent Plugin manifest: \(message)" case .invalidSkillPath(let path): - return "Invalid skill path '\(path)'. Skill path must be skills//SKILL.md." + return "Invalid skill path '\(path)'. Use skills//SKILL.md or skills//references/." + case .missingSkillFiles: + return "Agent Plugin packages require at least one skills//SKILL.md file." case .reservedPluginID(let id): return "The plugin id '\(id)' is reserved by Derrick." case .invalidSource(let message): return "Invalid Go guest source: \(message)" case .directRunFailed(let message): return "Go draft test failed: \(message)" diff --git a/packages/Structure/Sources/Plugin/Manifest/PluginManifestError.swift b/packages/Structure/Sources/Plugin/Manifest/PluginManifestError.swift index d575c57b..52862bb6 100644 --- a/packages/Structure/Sources/Plugin/Manifest/PluginManifestError.swift +++ b/packages/Structure/Sources/Plugin/Manifest/PluginManifestError.swift @@ -23,6 +23,7 @@ public enum PluginManifestError: Error, Equatable, LocalizedError { case invalidDependency(String) case invalidContentHash(String) case invalidSkill(String) + case missingSkillFiles public var errorDescription: String? { switch self { @@ -53,7 +54,7 @@ public enum PluginManifestError: Error, Equatable, LocalizedError { case .pathEscapesRoot(let p): return "Path escapes the plugin root: \(p)" case .missingRuntime: - return "app.derrick runtime.json is required for a handle plugin" + return "extensions.app.derrick.entrypoint is required for a handle plugin" case .missingFile(let p): return "Missing plugin file: \(p)" case .unknownAuthProvider(let p): @@ -70,6 +71,8 @@ public enum PluginManifestError: Error, Equatable, LocalizedError { return "Invalid content hash: \(h)" case .invalidSkill(let s): return "Invalid skill: \(s)" + case .missingSkillFiles: + return "Agent Plugin packages require at least one skills//SKILL.md" } } } diff --git a/packages/Structure/Sources/Plugin/Manifest/PluginPackage.swift b/packages/Structure/Sources/Plugin/Manifest/PluginPackage.swift index 34b35fee..04f6f5a0 100644 --- a/packages/Structure/Sources/Plugin/Manifest/PluginPackage.swift +++ b/packages/Structure/Sources/Plugin/Manifest/PluginPackage.swift @@ -26,6 +26,9 @@ public struct PluginPackage: Sendable, Hashable { } let (skills, skipped) = discoverSkills(root: root) + guard skills.contains(where: { $0.relativePath.hasSuffix("/SKILL.md") }) else { + throw PluginManifestError.missingSkillFiles + } let contentHash = try PluginContentHash.hash(root: root) return PluginPackage( manifest: manifest, @@ -37,22 +40,29 @@ public struct PluginPackage: Sendable, Hashable { } private static func loadRuntime(root: URL, pointers: DerrickExtensionPointers) throws -> DerrickRuntime { - let runtimeRel = pointers.runtime ?? "./\(PluginContract.derrickExtensionNamespace)/runtime.json" - let runtimeURL = try PluginPath.resolve(root: root, relative: runtimeRel) - guard FileManager.default.fileExists(atPath: runtimeURL.path) else { - throw PluginManifestError.missingRuntime + if let runtimeRel = pointers.runtime { + let runtimeURL = try PluginPath.resolve(root: root, relative: runtimeRel) + if FileManager.default.fileExists(atPath: runtimeURL.path) { + let data = try Data(contentsOf: runtimeURL) + var runtime = try PluginDecoding.decode(DerrickRuntime.self, from: data) + if let entry = pointers.entrypoint { + runtime.entrypoint = try PluginPath.validateRuntimeEntrypoint(entry) + } + let entryURL = try PluginPath.resolve(root: root, relative: runtime.entrypoint) + guard FileManager.default.fileExists(atPath: entryURL.path) else { + throw PluginManifestError.missingFile(runtime.entrypoint) + } + return runtime + } } - let data = try Data(contentsOf: runtimeURL) - var runtime = try PluginDecoding.decode(DerrickRuntime.self, from: data) - if let entry = pointers.entrypoint { - runtime.entrypoint = try PluginPath.validateRuntimeEntrypoint(entry) + guard let entry = pointers.entrypoint else { + throw PluginManifestError.missingRuntime } - + let runtime = try DerrickRuntime(entrypoint: entry) let entryURL = try PluginPath.resolve(root: root, relative: runtime.entrypoint) guard FileManager.default.fileExists(atPath: entryURL.path) else { throw PluginManifestError.missingFile(runtime.entrypoint) } - return runtime } diff --git a/packages/Structure/Sources/Plugin/UI/PluginSkillDisclosure.swift b/packages/Structure/Sources/Plugin/UI/PluginSkillDisclosure.swift new file mode 100644 index 00000000..84401f48 --- /dev/null +++ b/packages/Structure/Sources/Plugin/UI/PluginSkillDisclosure.swift @@ -0,0 +1,166 @@ +import Foundation + +/// Progressive disclosure for Agent Plugin skills and host UI catalog. +/// Index always; full bodies and references only when activated or requested. +public enum PluginSkillDisclosure: Sendable { + public struct IndexEntry: Sendable, Hashable, Identifiable { + public var id: String { "\(pluginID)/\(skillName)" } + public let pluginID: String + public let skillName: String + public let description: String + public let skillMarkdownPath: String + + public init( + pluginID: String, + skillName: String, + description: String, + skillMarkdownPath: String + ) { + self.pluginID = pluginID + self.skillName = skillName + self.description = description + self.skillMarkdownPath = skillMarkdownPath + } + } + + /// Cheap routing index: skill name + description only. + public static func index(from release: PluginFactoryRelease) -> [IndexEntry] { + release.skillFiles.keys + .filter { PluginFactorySkillFile.isSkillMarkdownPath($0) } + .sorted() + .map { path in + let body = release.skillFiles[path] ?? "" + let front = SkillFrontmatter.parse(body) + let directory = path.split(separator: "/")[1] + let name = front.name ?? String(directory) + return IndexEntry( + pluginID: release.pluginID, + skillName: name, + description: front.description ?? release.reviewSummary, + skillMarkdownPath: path + ) + } + } + + public static func index(from releases: [PluginFactoryRelease]) -> [IndexEntry] { + releases.flatMap { index(from: $0) } + .sorted { lhs, rhs in + if lhs.pluginID != rhs.pluginID { return lhs.pluginID < rhs.pluginID } + return lhs.skillName < rhs.skillName + } + } + + /// Full SKILL.md body when the skill is activated. + public static func activate( + skillFiles: [String: String], + skillNameOrPath: String + ) -> String? { + if let direct = skillFiles[skillNameOrPath], + PluginFactorySkillFile.isSkillMarkdownPath(skillNameOrPath) { + return direct + } + let needle = skillNameOrPath.trimmingCharacters(in: .whitespacesAndNewlines) + for (path, body) in skillFiles where PluginFactorySkillFile.isSkillMarkdownPath(path) { + let directory = String(path.split(separator: "/")[1]) + let front = SkillFrontmatter.parse(body) + if directory == needle || front.name == needle || path == needle { + return body + } + } + return nil + } + + /// On-demand: return a reference file when the model asks for it by path or filename. + public static func reference( + skillFiles: [String: String], + requested: String + ) -> (path: String, body: String)? { + let needle = requested.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return nil } + if let body = skillFiles[needle], PluginFactorySkillFile.isSkillReferencePath(needle) { + return (needle, body) + } + let filename = (needle as NSString).lastPathComponent + for (path, body) in skillFiles where PluginFactorySkillFile.isSkillReferencePath(path) { + if path == needle + || path.hasSuffix("/\(needle)") + || (path as NSString).lastPathComponent == filename { + return (path, body) + } + } + return nil + } + + public static func referencePaths(skillFiles: [String: String]) -> [String] { + skillFiles.keys.filter { PluginFactorySkillFile.isSkillReferencePath($0) }.sorted() + } + + /// System-prompt block: routing index only (no SKILL bodies). + public static func indexPromptBlock(entries: [IndexEntry]) -> String { + guard !entries.isEmpty else { return "" } + var lines = [ + "Installed Agent Plugin skills (routing index only).", + "Call plugin.skill with action=activate and the skill name for full SKILL.md.", + "Call plugin.skill with action=reference and a references path when the skill says to open one:", + ] + for entry in entries { + lines.append("- /\(entry.pluginID) · \(entry.skillName): \(entry.description)") + } + return lines.joined(separator: "\n") + } +} + +/// Progressive disclosure for the host UI control library. +public enum HostUIDisclosure: Sendable { + /// Element id + one-line purpose for builder prompts (not full schemas). + public static func catalogSummary() throws -> String { + let json = try HostUILibraryStore.loadJSON() + guard let elements = json["elements"] as? [String: Any] else { + throw HostUILibraryError.invalidJSON + } + var lines: [String] = [ + "Host UI catalog (ids only). Ask for an element schema before using unfamiliar config keys.", + "Emit ui.present trees using only these element ids. The Swift host renders them.", + ] + for id in elements.keys.sorted() { + let description: String + if let obj = elements[id] as? [String: Any], + let text = obj["description"] as? String { + description = text + } else { + description = "Host control." + } + lines.append("- \(id): \(description)") + } + if let examples = json["examples"] as? [String: Any] { + lines.append("Named examples (ask by name for full tree): \(examples.keys.sorted().joined(separator: ", "))") + } + return lines.joined(separator: "\n") + } + + /// Full element definition when the model asks for a specific control. + public static func elementSchema(id: String) throws -> String { + let json = try HostUILibraryStore.loadJSON() + guard let elements = json["elements"] as? [String: Any], + let element = elements[id] else { + throw HostUILibraryError.unknownElement(id) + } + let data = try JSONSerialization.data( + withJSONObject: ["element": id, "schema": element], + options: [.prettyPrinted, .sortedKeys] + ) + return String(decoding: data, as: UTF8.self) + } + + /// Full example tree when the model asks for a named example (e.g. messaging_inbox). + public static func exampleTree(named name: String) throws -> String { + let node = try HostUILibraryStore.example(named: name) + let data = try JSONEncoder().encode(node) + let object = try JSONSerialization.jsonObject(with: data) + let pretty = try JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ) + return String(decoding: pretty, as: UTF8.self) + } +} diff --git a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift index a249f3c9..3c68308c 100644 --- a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift +++ b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift @@ -1583,7 +1583,7 @@ import Testing #expect(goal.contains("sync_threads")) #expect(goal.contains("poll_inbox")) #expect(goal.contains("ui.present")) - #expect(goal.contains("host-ui-library.json")) + #expect(goal.contains("host UI catalog (summary)")) #expect(goal.contains("Host plugin id")) #expect(goal.contains("conversations.list") || goal.contains("vendor slack")) #expect(!goal.contains("must sync and send messages")) diff --git a/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift b/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift index 8d1fc0f0..5ac70794 100644 --- a/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift +++ b/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift @@ -65,12 +65,15 @@ import Testing #expect(goal.contains("Slack Web API notes")) #expect(goal.contains("ui.present")) #expect(goal.contains("same envelope list as result.emit")) - #expect(goal.contains("host-ui-library.json")) + #expect(goal.contains("host UI catalog (summary)")) #expect(goal.contains("tab_strip")) + #expect(goal.contains("skills//SKILL.md")) #expect(goal.contains("selection=conversations")) #expect(ConnectorContractPrompts.reviewerGuide().contains("If a rule is not in the JSON")) #expect(ConnectorContractPrompts.builderGuide(forUserGoal: goal).contains("--- connector-contract.json ---")) #expect(ConnectorContractPrompts.builderGuide(forUserGoal: goal).contains("--- vendor slack ---")) + #expect(ConnectorContractPrompts.builderGuide(forUserGoal: goal).contains("host UI catalog (summary)")) + #expect(!ConnectorContractPrompts.builderGuide(forUserGoal: goal).contains("--- host-ui-library.json ---")) #expect(ConnectorContractPrompts.reviewerGuide(forUserGoal: goal).contains("--- vendor slack ---")) #expect(!ConnectorContractPrompts.builderGuide().contains("--- vendor slack ---")) #expect(ConnectorContractPrompts.builderGuide(forUserGoal: goal).contains("--- \(GuestContract.Schema.connectorParams.rawValue) ---")) diff --git a/packages/Structure/Tests/StructureTests/PluginFactoryLegacyPurgeTests.swift b/packages/Structure/Tests/StructureTests/PluginFactoryLegacyPurgeTests.swift new file mode 100644 index 00000000..ebe147a1 --- /dev/null +++ b/packages/Structure/Tests/StructureTests/PluginFactoryLegacyPurgeTests.swift @@ -0,0 +1,12 @@ +import Testing +import Structure + +@Suite struct PluginFactoryLegacyPurgeTests { + @Test func matchesSlackPluginIDs() { + #expect(PluginFactoryLegacyPurge.isLegacySlackPluginID("slack-connection")) + #expect(PluginFactoryLegacyPurge.isLegacySlackPluginID("Slack-Bot")) + #expect(PluginFactoryLegacyPurge.isLegacySlackPluginID("my-slack-helper")) + #expect(!PluginFactoryLegacyPurge.isLegacySlackPluginID("discord-bot")) + #expect(!PluginFactoryLegacyPurge.isLegacySlackPluginID("weather-tool")) + } +} diff --git a/packages/Structure/Tests/StructureTests/PluginFactoryReleaseEditableFilesTests.swift b/packages/Structure/Tests/StructureTests/PluginFactoryReleaseEditableFilesTests.swift new file mode 100644 index 00000000..a9e270f9 --- /dev/null +++ b/packages/Structure/Tests/StructureTests/PluginFactoryReleaseEditableFilesTests.swift @@ -0,0 +1,183 @@ +import Foundation +import Testing +@testable import Structure + +@Suite struct PluginFactoryReleaseEditableFilesTests { + @Test func packageFilesOmitRuntimeAndIncludeSkills() { + let skillFiles = [ + "skills/weather/SKILL.md": "# Weather", + "skills/weather/references/api.md": "# API", + ] + let release = makeRelease(skillFiles: skillFiles) + let paths = Set(release.packageFiles().keys) + #expect(paths.contains("plugin.json")) + #expect(paths.contains("app.derrick/plugin.go")) + #expect(paths.contains("skills/weather/SKILL.md")) + #expect(paths.contains("skills/weather/references/api.md")) + #expect(!paths.contains(PluginFactoryRelease.runtimePackagePath)) + #expect(paths.contains(PluginFactoryRelease.compiledArtifactPackagePath)) + #expect(release.verifyIntegrity()) + } + + @Test func browserFilesPrettyPrintManifestAndHideRuntime() { + let release = makeRelease( + pluginID: "slack-connector-1", + skillFiles: [ + "skills/slack-connector-1/SKILL.md": "---\nname: slack\ndescription: Slack\n---\n", + ], + manifestJSON: #"{"name":"slack-connector-1","version":"1.0.0"}"# + ) + let items = release.browserPackageFiles() + let paths = items.map(\.path) + #expect(paths.contains("plugin.json")) + #expect(paths.contains("app.derrick/plugin.go")) + #expect(paths.contains("skills/slack-connector-1/SKILL.md")) + #expect(!paths.contains(PluginFactoryRelease.runtimePackagePath)) + let manifest = items.first { $0.path == "plugin.json" }?.body ?? "" + #expect(manifest.contains("\n")) + #expect(manifest.contains("slack-connector-1")) + } + + @Test func legacyRuntimeHashStillVerifies() { + let artifact = Data("compiled-binary".utf8) + let guestSource = "package main\n" + let manifestJSON = #"{"name":"weather-tool","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go"}}}"# + let runtimeJSON = #"{"language":"go","entrypoint":"./app.derrick/plugin.go"}"# + let skillFiles = ["skills/weather/SKILL.md": "# Weather"] + var files: [String: Data] = [ + "plugin.json": Data(manifestJSON.utf8), + "app.derrick/runtime.json": Data(runtimeJSON.utf8), + "app.derrick/plugin.go": Data(guestSource.utf8), + "app.derrick/plugin": artifact, + ] + for (path, body) in skillFiles { + files[path] = Data(body.utf8) + } + let release = PluginFactoryRelease( + pluginID: "weather-tool", + version: "1.0.0", + manifestJSON: manifestJSON, + runtimeJSON: runtimeJSON, + guestSource: guestSource, + compiledArtifact: artifact, + skillFiles: skillFiles, + contentHash: PluginContentHash.hash(files: files), + reviewSummary: "approved" + ) + #expect(release.verifyIntegrity()) + } + + @Test func replacingEditableTextFilesRecalculatesHashAndKeepsArtifact() { + let release = makeRelease(skillFiles: [ + "skills/weather/SKILL.md": "# Weather", + ]) + var drafts = Dictionary(uniqueKeysWithValues: release.browserPackageFiles().map { + ($0.path, $0.body) + }) + drafts["skills/weather/SKILL.md"] = "# Weather\n\nUpdated." + drafts["app.derrick/plugin.go"] = "package main\n// edited\n" + + let updated = release.replacingEditableTextPackageFiles(drafts) + #expect(updated.verifyIntegrity()) + #expect(updated.compiledArtifact == release.compiledArtifact) + #expect(updated.skillFiles["skills/weather/SKILL.md"] == "# Weather\n\nUpdated.") + #expect(updated.guestSource.contains("edited")) + #expect(updated.runtimeJSON.isEmpty) + #expect(updated.contentHash != release.contentHash) + } + + private func makeRelease( + pluginID: String = "weather-tool", + skillFiles: [String: String], + manifestJSON: String? = nil + ) -> PluginFactoryRelease { + let artifact = Data("compiled-binary".utf8) + let guestSource = "package main\n" + let manifest = manifestJSON + ?? "{\"name\":\"\(pluginID)\",\"extensions\":{\"app.derrick\":{\"entrypoint\":\"./app.derrick/plugin.go\"}}}" + var files: [String: Data] = [ + "plugin.json": Data(manifest.utf8), + "app.derrick/plugin.go": Data(guestSource.utf8), + "app.derrick/plugin": artifact, + ] + for (path, body) in skillFiles { + files[path] = Data(body.utf8) + } + return PluginFactoryRelease( + pluginID: pluginID, + version: "1.0.0", + manifestJSON: manifest, + runtimeJSON: "", + guestSource: guestSource, + compiledArtifact: artifact, + skillFiles: skillFiles, + contentHash: PluginContentHash.hash(files: files), + reviewSummary: "approved" + ) + } +} + +@Suite struct PluginSkillDisclosureTests { + @Test func indexExposesNameAndDescriptionOnly() { + let release = PluginFactoryRelease( + pluginID: "weather-tool", + version: "1.0.0", + manifestJSON: #"{"name":"weather-tool"}"#, + runtimeJSON: "", + guestSource: "package main", + compiledArtifact: Data(), + skillFiles: [ + "skills/weather/SKILL.md": """ + --- + name: weather + description: Fetch forecasts + --- + # Weather + Long body that should not appear in the index alone. + """, + ], + contentHash: try! PluginContentHash(hex: String(repeating: "a", count: 64)), + reviewSummary: "ok" + ) + let index = PluginSkillDisclosure.index(from: release) + #expect(index.count == 1) + #expect(index[0].skillName == "weather") + #expect(index[0].description == "Fetch forecasts") + } + + @Test func activateAndReferenceAreOnDemand() { + let skills = [ + "skills/weather/SKILL.md": "---\nname: weather\ndescription: x\n---\n# Body\n", + "skills/weather/references/api.md": "# API docs", + ] + #expect(PluginSkillDisclosure.activate(skillFiles: skills, skillNameOrPath: "weather")?.contains("# Body") == true) + let ref = PluginSkillDisclosure.reference(skillFiles: skills, requested: "api.md") + #expect(ref?.path == "skills/weather/references/api.md") + #expect(ref?.body == "# API docs") + } + + @Test func indexPromptBlockIsRoutingOnly() { + let entries = [ + PluginSkillDisclosure.IndexEntry( + pluginID: "weather-tool", + skillName: "weather", + description: "Fetch forecasts", + skillMarkdownPath: "skills/weather/SKILL.md" + ) + ] + let block = PluginSkillDisclosure.indexPromptBlock(entries: entries) + #expect(block.contains("plugin.skill")) + #expect(block.contains("/weather-tool · weather: Fetch forecasts")) + #expect(!block.contains("# Body")) + #expect(PluginSkillDisclosure.indexPromptBlock(entries: []).isEmpty) + } + + @Test func hostUICatalogSummaryListsElementsWithoutFullDump() throws { + let summary = try HostUIDisclosure.catalogSummary() + #expect(summary.contains("tab_strip")) + #expect(summary.contains("button")) + #expect(!summary.contains("\"examples\"")) + let schema = try HostUIDisclosure.elementSchema(id: "button") + #expect(schema.contains("button")) + } +} diff --git a/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift b/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift index 0ec84039..b0c5a00c 100644 --- a/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift +++ b/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift @@ -204,6 +204,7 @@ import Testing PluginSpecProcession.creatorTabTitle(from: "connect to slack") == "Create plugin - connect to slack" ) + #expect(PluginSpecProcession.pluginsTabTitle == "Plugins") let long = "connect to slack and send and receive messages extra words" let title = PluginSpecProcession.creatorTabTitle(from: long) #expect(title.hasPrefix("Create plugin - ")) diff --git a/scripts/install-slack-reference-connector.sh b/scripts/install-slack-reference-connector.sh deleted file mode 100755 index 42c533dd..00000000 --- a/scripts/install-slack-reference-connector.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -ENV_FILE="$ROOT/ui/ui/Resources/.env" -if [[ -f "$ENV_FILE" ]]; then - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a -fi -SWIFT="${SWIFT:-/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift}" -cd "$ROOT/packages/MCPServer" -exec "$SWIFT" run -c release SlackConnectorInstallReference "$@" diff --git a/ui/MCPService/MCPServiceToolHost.swift b/ui/MCPService/MCPServiceToolHost.swift index e5f3d73a..5441a8eb 100644 --- a/ui/MCPService/MCPServiceToolHost.swift +++ b/ui/MCPService/MCPServiceToolHost.swift @@ -137,8 +137,25 @@ actor MCPServiceToolHost { ) ) await server.register( - PluginRuntimeToolModule.makeListRegistration { - try await repo.listPluginFactoryReleaseSummaries() + PluginRuntimeToolModule.makeListRegistration( + list: { + try await repo.listPluginFactoryReleaseSummaries() + }, + skillIndex: { + try await Self.skillIndex(from: repo) + } + ) + ) + await server.register( + PluginRuntimeToolModule.makeSkillRegistration { pluginID in + guard let summary = try await repo.listPluginFactoryReleaseSummaries() + .first(where: { $0.pluginID == pluginID }) else { + return nil + } + return try await repo.pluginFactoryRelease( + pluginID: summary.pluginID, + version: summary.version + ) } ) await server.register( @@ -362,6 +379,23 @@ actor MCPServiceToolHost { ) } } + + private static func skillIndex(from repo: DBRepository) async throws -> [PluginSkillDisclosure.IndexEntry] { + let summaries = try await repo.listPluginFactoryReleaseSummaries() + var latestByPlugin: [String: PluginFactoryReleaseSummary] = [:] + for summary in summaries where latestByPlugin[summary.pluginID] == nil { + latestByPlugin[summary.pluginID] = summary + } + var entries: [PluginSkillDisclosure.IndexEntry] = [] + for summary in latestByPlugin.values.sorted(by: { $0.pluginID < $1.pluginID }) { + guard let release = try await repo.pluginFactoryRelease( + pluginID: summary.pluginID, + version: summary.version + ) else { continue } + entries.append(contentsOf: PluginSkillDisclosure.index(from: release)) + } + return entries + } } private func pluginFactoryFailureDetail(for error: Error) -> String { diff --git a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift index 495c59b7..b2e0015b 100644 --- a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift +++ b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift @@ -273,6 +273,7 @@ final class ConversationModel { let effectiveThinking: ModelThinkingOption? let userRagBase: String let retrievalLimit: Int + let skillIndexBlock = await Self.skillIndexPromptBlock(repository: repository) if let profileContext { effectiveModel = (try? JSONDecoder().decode(LLMModelChoice.self, from: profileContext.modelJSON)) ?? model effectiveThinking = profileContext.thinkingJSON.flatMap { @@ -285,6 +286,7 @@ final class ConversationModel { ? profileContext.rag.customInstructions! : ragInstructions, profileContext.instructions.trimmingCharacters(in: .whitespacesAndNewlines), + skillIndexBlock, ] .filter { !$0.isEmpty } .joined(separator: "\n\n") @@ -292,7 +294,8 @@ final class ConversationModel { } else { effectiveModel = model effectiveThinking = thinking - userRagBase = [ragInstructions, WorkerOverlays.userFacingWithSpawn] + userRagBase = [ragInstructions, WorkerOverlays.userFacingWithSpawn, skillIndexBlock] + .filter { !$0.isEmpty } .joined(separator: "\n\n") retrievalLimit = 5 } @@ -511,12 +514,43 @@ final class ConversationModel { return (pluginID, remainder) } + /// Cheap skill routing index for system prompts (progressive disclosure layer 1). + private static func skillIndexPromptBlock(repository: DBRepository) async -> String { + do { + let summaries = try await repository.listPluginFactoryReleaseSummaries() + var latestByPlugin: [String: PluginFactoryReleaseSummary] = [:] + for summary in summaries { + if latestByPlugin[summary.pluginID] == nil { + latestByPlugin[summary.pluginID] = summary + } + } + var entries: [PluginSkillDisclosure.IndexEntry] = [] + for summary in latestByPlugin.values.sorted(by: { $0.pluginID < $1.pluginID }) { + guard let release = try await repository.pluginFactoryRelease( + pluginID: summary.pluginID, + version: summary.version + ) else { continue } + entries.append(contentsOf: PluginSkillDisclosure.index(from: release)) + } + return PluginSkillDisclosure.indexPromptBlock(entries: entries) + } catch { + return "" + } + } + private static func pluginIDs(from listJSON: String) -> Set { guard let data = listJSON.data(using: .utf8), - let obj = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] else { + let root = try? JSONSerialization.jsonObject(with: data) else { return [] } - return Set(obj.compactMap { $0["plugin_id"] }) + if let rows = root as? [[String: String]] { + return Set(rows.compactMap { $0["plugin_id"] }) + } + if let object = root as? [String: Any], + let releases = object["releases"] as? [[String: Any]] { + return Set(releases.compactMap { $0["plugin_id"] as? String }) + } + return [] } /// Builds the existing conversation pipeline stream for one envelope body (turn engine unchanged). diff --git a/ui/ui/Components/PillSubtabBar.swift b/ui/ui/Components/PillSubtabBar.swift new file mode 100644 index 00000000..cf51b758 --- /dev/null +++ b/ui/ui/Components/PillSubtabBar.swift @@ -0,0 +1,75 @@ +import SwiftUI + +/// Standard in-panel sub-tab chrome: dark selected pill, light unselected pill. +struct PillSubtabBar: View where Tab.ID: Hashable { + let tabs: [Tab] + @Binding var selection: Tab + var title: (Tab) -> String + var accessibilityIdentifier: String? = nil + + private let chromeFill = Color(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 246.0 / 255.0) + private let selectedFill = Color(red: 0.18, green: 0.18, blue: 0.17) + private let unselectedFill = Color.primary.opacity(0.06) + + var body: some View { + HStack(spacing: 6) { + ForEach(tabs) { tab in + pillButton(tab) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(chromeFill) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(height: 1) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(accessibilityIdentifier ?? "pill-subtabs") + } + + private func pillButton(_ tab: Tab) -> some View { + let selected = selection == tab + let label = title(tab) + return Button { + selection = tab + } label: { + Text(label) + .font(.system(size: 12, weight: selected ? .semibold : .regular)) + .foregroundStyle(selected ? chromeFill : Color.primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(selected ? selectedFill : unselectedFill, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selected ? .isSelected : []) + .accessibilityLabel(label) + } +} + +/// Compact pill chip for mutually exclusive filters (same visual language as sub-tabs). +struct PillFilterChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + private let chromeFill = Color(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 246.0 / 255.0) + private let selectedFill = Color(red: 0.18, green: 0.18, blue: 0.17) + private let unselectedFill = Color.primary.opacity(0.06) + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 12, weight: isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? chromeFill : Color.primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? selectedFill : unselectedFill, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityLabel(title) + } +} diff --git a/ui/ui/Messaging/AppWorkspace.swift b/ui/ui/Messaging/AppWorkspace.swift index c2e5b23d..3c68d07a 100644 --- a/ui/ui/Messaging/AppWorkspace.swift +++ b/ui/ui/Messaging/AppWorkspace.swift @@ -2,11 +2,16 @@ import Foundation enum AppWorkspace: Equatable { case chats + case plugins case debugLogs } enum ChatShellNotification { static let startPluginCreation = Notification.Name("derrick.startPluginCreation") + static let startPluginEdit = Notification.Name("derrick.startPluginEdit") static let openPluginInChat = Notification.Name("derrick.openPluginInChat") + static let pluginFactorySucceeded = Notification.Name("derrick.pluginFactorySucceeded") static let pluginIDUserInfoKey = "pluginID" + static let pluginVersionUserInfoKey = "pluginVersion" + static let editPromptUserInfoKey = "editPrompt" } diff --git a/ui/ui/Plugins/PluginCreationController.swift b/ui/ui/Plugins/PluginCreationController.swift index 0a9a040d..1045cd31 100644 --- a/ui/ui/Plugins/PluginCreationController.swift +++ b/ui/ui/Plugins/PluginCreationController.swift @@ -303,6 +303,131 @@ final class PluginCreationController: ObservableObject { hide() } + /// LLM edit from the Plugins browser. Rebuilds via factory and promotes a new version. + func beginEdit( + pluginID: String, + version: String, + prompt: String, + sessionID: String, + helperAPIKey: String?, + helperReviewerModelJSON: String? + ) { + creationSessionID = sessionID + creationAPIKey = helperAPIKey + creationReviewerModelJSON = helperReviewerModelJSON + reservedPluginID = pluginID + completedSpec = nil + pendingAuth = nil + guard let creationAPIKey, !creationAPIKey.isEmpty else { + phase = .failed( + step: .build, + message: "Add an API key in Settings before updating a plugin." + ) + return + } + cancelPolling() + phase = .creating + statusMessage = "Updating /\(pluginID)…" + resetProgressSteps() + markProgressCompleted("credentials") + pollAfterSeq = 0 + + pollTask = Task { @MainActor in + do { + guard let release = try await PluginFactoryListStore.shared.release( + pluginID: pluginID, + version: version + ) else { + phase = .failed( + step: .build, + message: "Could not load /\(pluginID) v\(version) to edit." + ) + return + } + let input = try Self.editInput( + release: release, + prompt: prompt + ) + let inputJSON = try input.encodedJSON() + let handle = try await WorkflowRuntimeClient.shared.startWorkflow( + WorkflowStartRequest( + kind: .pluginFactoryCreate, + sessionID: creationSessionID, + agentID: "ui", + inputJSON: inputJSON, + principal: .agent(sessionID: creationSessionID, agentID: "ui"), + helperAPIKey: creationAPIKey, + helperReviewerModelJSON: creationReviewerModelJSON + ) + ) + workflowID = handle.workflowID + await pollUntilTerminal() + await PluginFactoryListStore.shared.reload() + } catch { + phase = .failed(step: .build, message: error.localizedDescription) + } + } + } + + private static func editInput( + release: PluginFactoryRelease, + prompt: String + ) throws -> PluginFactoryCreateInput { + let nextVersion = nextVersion(after: release.version) + let skillPath = release.skillFiles.keys + .first { PluginFactorySkillFile.isSkillMarkdownPath($0) } + let skillBody = skillPath.flatMap { release.skillFiles[$0] } + ?? PluginFactoryRelease.defaultSkillMarkdown(pluginID: release.pluginID) + let description = """ + EDIT existing Agent Plugin \(release.pluginID) at version \(release.version). \ + Ship version \(nextVersion) (do not reuse \(release.version)). + User change request: + \(prompt.trimmingCharacters(in: .whitespacesAndNewlines)) + + Keep a complete Agent Plugin package: plugin.json + skills//SKILL.md (+ references if needed) + Go guest. \ + Do not invent app.derrick/runtime.json. + + Current SKILL.md: + \(skillBody) + + Current guest source (adapt as needed): + \(String(release.guestSource.prefix(12_000))) + """ + let isConnector = release.manifestJSON.contains("\"role\":\"connector\"") + || release.manifestJSON.contains("\"role\": \"connector\"") + if isConnector { + let vendor: PluginFactoryCreateInput.ConnectorVendor = + release.pluginID.localizedCaseInsensitiveContains("slack") ? .slack : .custom + return PluginFactoryCreateInput.makeConnector( + vendor: vendor, + pluginID: release.pluginID, + customVendorName: vendor == .custom ? release.pluginID : nil, + scope: .fullSync, + userDescription: description + ) + } + return PluginFactoryCreateInput( + pluginType: .custom, + description: description, + pluginID: release.pluginID, + skillMarkdown: skillBody + ) + } + + private static func nextVersion(after version: String) -> String { + let parts = version.split(separator: ".").compactMap { Int($0) } + if parts.count >= 3 { + return "\(parts[0]).\(parts[1]).\(parts[2] + 1)" + } + if parts.count == 2 { + return "\(parts[0]).\(parts[1]).1" + } + if parts.count == 1 { + return "\(parts[0]).0.1" + } + return "0.1.1" + } + private func startFactoryCreation() { guard let creationAPIKey, !creationAPIKey.isEmpty else { phase = .failed( @@ -470,12 +595,28 @@ final class PluginCreationController: ObservableObject { markProgressCompleted("review") markProgressCompleted("trial") await PluginFactoryListStore.shared.reload() - if let pluginID = parseSuccessPluginID(result.resultJSON) { + if let saved = parseSuccessResult(result.resultJSON) { markProgressCompleted("credentials") - phase = .succeeded(pluginID: pluginID, outcome: .plugin) + phase = .succeeded(pluginID: saved.pluginID, outcome: .plugin) + NotificationCenter.default.post( + name: ChatShellNotification.pluginFactorySucceeded, + object: nil, + userInfo: [ + ChatShellNotification.pluginIDUserInfoKey: saved.pluginID, + ChatShellNotification.pluginVersionUserInfoKey: saved.version, + ] + ) } else if let saved = PluginFactoryListStore.shared.releases.first { markProgressCompleted("credentials") phase = .succeeded(pluginID: saved.pluginID, outcome: .plugin) + NotificationCenter.default.post( + name: ChatShellNotification.pluginFactorySucceeded, + object: nil, + userInfo: [ + ChatShellNotification.pluginIDUserInfoKey: saved.pluginID, + ChatShellNotification.pluginVersionUserInfoKey: saved.version, + ] + ) } else { phase = .failed( step: .build, @@ -517,13 +658,13 @@ final class PluginCreationController: ObservableObject { } } - private func parseSuccessPluginID(_ json: String?) -> String? { + private func parseSuccessResult(_ json: String?) -> PluginFactoryCreateResult? { guard let json, let data = json.data(using: .utf8), let result = try? JSONDecoder.service.decode(PluginFactoryCreateResult.self, from: data) else { return nil } - return result.pluginID + return result } private func startAuthDiscovery() { diff --git a/ui/ui/Plugins/PluginPackageBrowserController.swift b/ui/ui/Plugins/PluginPackageBrowserController.swift new file mode 100644 index 00000000..f5176b81 --- /dev/null +++ b/ui/ui/Plugins/PluginPackageBrowserController.swift @@ -0,0 +1,149 @@ +import Combine +import DBRepository +import Foundation +import Structure + +@MainActor +final class PluginPackageBrowserController: ObservableObject { + @Published private(set) var groups: [PluginFactoryReleaseGroup] = [] + @Published var expandedPluginIDs: Set = [] + @Published var selectedPluginID: String? + @Published var selectedVersion: String? + @Published var selectedFilePath: String? + @Published private(set) var filePaths: [String] = [] + @Published private(set) var fileBodies: [String: String] = [:] + @Published private(set) var isLoading = false + @Published var editPrompt: String = "" + @Published var statusMessage: String? + @Published var errorMessage: String? + + private var loadedRelease: PluginFactoryRelease? + + var selectedFileText: String { + guard let path = selectedFilePath else { return "" } + return fileBodies[path] ?? "" + } + + var canSubmitEdit: Bool { + selectedPluginID != nil + && selectedVersion != nil + && !editPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func reloadList() async { + await PluginFactoryListStore.shared.reload() + groups = PluginFactoryListStore.shared.groups + if let selectedPluginID, + !groups.contains(where: { $0.pluginID == selectedPluginID }) { + clearSelection() + } else if let selectedPluginID, let selectedVersion { + await loadRelease(pluginID: selectedPluginID, version: selectedVersion) + } + } + + func selectPlugin(_ pluginID: String) async { + expandedPluginIDs.insert(pluginID) + guard let group = groups.first(where: { $0.pluginID == pluginID }), + let latest = group.latest + else { + selectedPluginID = pluginID + selectedVersion = nil + clearEditor() + return + } + selectedPluginID = pluginID + await selectVersion(latest.version, pluginID: pluginID) + } + + func selectVersion(_ version: String, pluginID: String? = nil) async { + let pluginID = pluginID ?? selectedPluginID + guard let pluginID else { return } + selectedPluginID = pluginID + selectedVersion = version + expandedPluginIDs.insert(pluginID) + await loadRelease(pluginID: pluginID, version: version) + } + + func selectFile(_ path: String) { + selectedFilePath = path + statusMessage = nil + errorMessage = nil + } + + /// Starts an LLM edit turn for the selected plugin. Promotes a new version on success. + func submitEditPrompt() { + let prompt = editPrompt.trimmingCharacters(in: .whitespacesAndNewlines) + guard let pluginID = selectedPluginID, + let version = selectedVersion, + !prompt.isEmpty + else { return } + NotificationCenter.default.post( + name: ChatShellNotification.startPluginEdit, + object: nil, + userInfo: [ + ChatShellNotification.pluginIDUserInfoKey: pluginID, + ChatShellNotification.pluginVersionUserInfoKey: version, + ChatShellNotification.editPromptUserInfoKey: prompt, + ] + ) + editPrompt = "" + statusMessage = "Updating /\(pluginID)…" + } + + /// Reloads list and focuses the newly saved version after a factory create/edit succeeds. + func handleFactorySucceeded(pluginID: String, version: String?) async { + await reloadList() + if groups.contains(where: { $0.pluginID == pluginID }) { + if let version, groups.first(where: { $0.pluginID == pluginID })? + .releases.contains(where: { $0.version == version }) == true { + await selectVersion(version, pluginID: pluginID) + } else { + await selectPlugin(pluginID) + } + statusMessage = "Updated /\(pluginID)" + (version.map { " v\($0)" } ?? "") + "." + errorMessage = nil + } + } + + private func loadRelease(pluginID: String, version: String) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + guard let release = try await PluginFactoryListStore.shared.release( + pluginID: pluginID, + version: version + ) else { + clearEditor() + errorMessage = "Could not load \(pluginID) v\(version)." + return + } + loadedRelease = release + let files = release.browserPackageFiles() + fileBodies = Dictionary(uniqueKeysWithValues: files.map { ($0.path, $0.body) }) + filePaths = files.map(\.path) + if let selectedFilePath, filePaths.contains(selectedFilePath) { + // keep + } else { + selectedFilePath = filePaths.first + } + } catch { + clearEditor() + errorMessage = error.localizedDescription + } + } + + private func clearSelection() { + selectedPluginID = nil + selectedVersion = nil + clearEditor() + } + + private func clearEditor() { + loadedRelease = nil + filePaths = [] + fileBodies = [:] + selectedFilePath = nil + } +} diff --git a/ui/ui/Plugins/PluginPackageBrowserView.swift b/ui/ui/Plugins/PluginPackageBrowserView.swift new file mode 100644 index 00000000..cfdba895 --- /dev/null +++ b/ui/ui/Plugins/PluginPackageBrowserView.swift @@ -0,0 +1,256 @@ +import Structure +import SwiftUI + +struct PluginPackageBrowserView: View { + @ObservedObject var controller: PluginPackageBrowserController + + private let chromeFill = Color(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 246.0 / 255.0) + private let sidebarWidth: CGFloat = 220 + private let filesWidth: CGFloat = 200 + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + pluginSidebar + .frame(width: sidebarWidth) + .background(chromeFill) + + Divider() + + fileSidebar + .frame(width: filesWidth) + .background(chromeFill.opacity(0.7)) + + Divider() + + readerPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + editComposer + } + .background(chromeFill) + .task { + await controller.reloadList() + } + } + + private var pluginSidebar: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Plugins") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 12) + .padding(.bottom, 8) + + if controller.groups.isEmpty { + Text("No plugins yet") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + Spacer() + } else { + List { + ForEach(controller.groups) { group in + DisclosureGroup( + isExpanded: expansionBinding(for: group.pluginID) + ) { + ForEach(group.releases) { release in + Button { + Task { + await controller.selectVersion( + release.version, + pluginID: group.pluginID + ) + } + } label: { + HStack { + Text("v\(release.version)") + .font(.caption.monospaced()) + Spacer(minLength: 0) + if group.pluginID == controller.selectedPluginID, + release.version == controller.selectedVersion { + Image(systemName: "checkmark") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } label: { + Button { + Task { await controller.selectPlugin(group.pluginID) } + } label: { + Text("/\(group.pluginID)") + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + .listStyle(.sidebar) + } + } + .accessibilityIdentifier("plugin-package-browser-sidebar") + } + + private var fileSidebar: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Files") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 12) + .padding(.bottom, 8) + + if controller.selectedVersion == nil { + Text("Select a plugin") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + Spacer() + } else if controller.isLoading { + ProgressView() + .controlSize(.small) + .padding(12) + Spacer() + } else if controller.filePaths.isEmpty { + Text("No package files") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + Spacer() + } else { + List(selection: Binding( + get: { controller.selectedFilePath }, + set: { path in + if let path { controller.selectFile(path) } + } + )) { + ForEach(controller.filePaths, id: \.self) { path in + Text(path) + .font(.caption.monospaced()) + .lineLimit(2) + .tag(path) + } + } + .listStyle(.sidebar) + } + } + .accessibilityIdentifier("plugin-package-browser-files") + } + + private var readerPane: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(controller.selectedFilePath ?? "Package file") + .font(.subheadline.weight(.semibold).monospaced()) + .lineLimit(1) + if let pluginID = controller.selectedPluginID, + let version = controller.selectedVersion { + Text("/\(pluginID) · v\(version) · read only") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(height: 1) + } + + if controller.selectedFilePath == nil { + ContentUnavailableView( + "Choose a file", + systemImage: "doc.text", + description: Text("Pick a plugin version to inspect plugin.json, SKILL.md, references, or Go source.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + Text(controller.selectedFileText) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .accessibilityIdentifier("plugin-package-file-reader") + } + + if let error = controller.errorMessage { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 16) + .padding(.bottom, 10) + } else if let status = controller.statusMessage { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 16) + .padding(.bottom, 10) + } + } + } + + private var editComposer: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Update plugin") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + HStack(alignment: .bottom, spacing: 10) { + TextField( + controller.selectedPluginID == nil + ? "Select a plugin to describe a change" + : "Describe the change for /\(controller.selectedPluginID ?? "")…", + text: $controller.editPrompt, + axis: .vertical + ) + .textFieldStyle(.roundedBorder) + .lineLimit(2...5) + .disabled(controller.selectedPluginID == nil) + .accessibilityIdentifier("plugin-package-edit-prompt") + + Button("Update") { + controller.submitEditPrompt() + } + .buttonStyle(.borderedProminent) + .disabled(!controller.canSubmitEdit) + .keyboardShortcut(.defaultAction) + } + Text("Derrick rebuilds a new version via the factory. Package files stay read-only here.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(chromeFill) + } + + private func expansionBinding(for pluginID: String) -> Binding { + Binding( + get: { controller.expandedPluginIDs.contains(pluginID) }, + set: { expanded in + if expanded { + controller.expandedPluginIDs.insert(pluginID) + Task { await controller.selectPlugin(pluginID) } + } else { + controller.expandedPluginIDs.remove(pluginID) + } + } + ) + } +} diff --git a/ui/ui/Plugins/PluginsWorkspaceShellView.swift b/ui/ui/Plugins/PluginsWorkspaceShellView.swift new file mode 100644 index 00000000..42872c23 --- /dev/null +++ b/ui/ui/Plugins/PluginsWorkspaceShellView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +enum PluginsWorkspaceSubtab: String, CaseIterable, Identifiable, Hashable { + case create = "Create plugin" + case plugins = "Plugins" + + var id: String { rawValue } +} + +/// Plugins tab chrome: Create plugin Q&A and Plugins package browser as pill sub-tabs. +struct PluginsWorkspaceShellView: View { + @ViewBuilder var createContent: () -> CreateContent + @State private var subtab: PluginsWorkspaceSubtab = .create + @StateObject private var browser = PluginPackageBrowserController() + + var body: some View { + VStack(spacing: 0) { + PillSubtabBar( + tabs: Array(PluginsWorkspaceSubtab.allCases), + selection: $subtab, + title: { $0.rawValue }, + accessibilityIdentifier: "plugins-workspace-subtabs" + ) + + Group { + switch subtab { + case .create: + createContent() + case .plugins: + PluginPackageBrowserView(controller: browser) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onChange(of: subtab) { _, newValue in + if newValue == .plugins { + Task { await browser.reloadList() } + } + } + .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.pluginFactorySucceeded)) { notification in + guard let pluginID = notification.userInfo?[ChatShellNotification.pluginIDUserInfoKey] as? String, + !pluginID.isEmpty + else { return } + let version = notification.userInfo?[ChatShellNotification.pluginVersionUserInfoKey] as? String + subtab = .plugins + Task { await browser.handleFactorySucceeded(pluginID: pluginID, version: version) } + } + } +} diff --git a/ui/ui/Session/ChatSessionStore.swift b/ui/ui/Session/ChatSessionStore.swift index 4f61eccb..83de97a9 100644 --- a/ui/ui/Session/ChatSessionStore.swift +++ b/ui/ui/Session/ChatSessionStore.swift @@ -81,16 +81,17 @@ struct ChatTab: Identifiable, Hashable { return (rest, nil) } - /// Recents can restore this tab with no turns; New plugin must still show the creator. + /// Recents can restore this tab with no turns; Plugins must still show the creator. static func pluginCreator(existing: ChatTab? = nil) -> ChatTab { var tab = existing ?? ChatTab( id: PluginSpecProcession.newCreatorTabID(), - title: PluginSpecProcession.creatorTabTitlePrefix, + title: PluginSpecProcession.pluginsTabTitle, isPluginCreator: true, specSession: PluginSpecSession() ) - if tab.title.isEmpty { - tab.title = PluginSpecProcession.creatorTabTitlePrefix + if tab.title.isEmpty || tab.title == PluginSpecProcession.creatorTabTitlePrefix + || tab.title.hasPrefix(PluginSpecProcession.creatorTabTitlePrefix + " - ") { + tab.title = PluginSpecProcession.pluginsTabTitle } tab.isPluginCreator = true if tab.specSession == nil { @@ -99,7 +100,7 @@ struct ChatTab: Identifiable, Hashable { if tab.turns.isEmpty { tab.turns = [ ChatTurn( - prompt: "Create plugin", + prompt: PluginSpecProcession.creatorTabTitlePrefix, response: PluginSpecProcession.openingQuestion, status: .complete ), @@ -121,10 +122,7 @@ struct ChatTab: Identifiable, Hashable { toolName: isDocsReview ? AllowedMCPTool.webSearch.rawValue : nil ) ) - if let outcome = session.draft.claimedOutcome, - !outcome.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - title = PluginSpecProcession.creatorTabTitle(from: outcome) - } + title = PluginSpecProcession.pluginsTabTitle return turn } diff --git a/ui/ui/Session/PluginFactoryListStore.swift b/ui/ui/Session/PluginFactoryListStore.swift index 042dfa95..f0ec48a3 100644 --- a/ui/ui/Session/PluginFactoryListStore.swift +++ b/ui/ui/Session/PluginFactoryListStore.swift @@ -1,7 +1,6 @@ import Combine import DBRepository import Foundation -import Plugin import Structure @MainActor @@ -9,6 +8,7 @@ final class PluginFactoryListStore: ObservableObject { static let shared = PluginFactoryListStore() @Published private(set) var releases: [PluginFactoryReleaseSummary] = [] + @Published private(set) var skillIndex: [PluginSkillDisclosure.IndexEntry] = [] @Published private(set) var lastError: String? private var repository: DBRepository? @@ -25,8 +25,14 @@ final class PluginFactoryListStore: ObservableObject { groups.map(\.pluginID) } + /// Progressive disclosure block for chat system prompts (names + descriptions only). + var skillIndexPromptBlock: String { + PluginSkillDisclosure.indexPromptBlock(entries: skillIndex) + } + func configure(repository: DBRepository) async { self.repository = repository + await purgeLegacySlackConnectors() await reload() } @@ -34,6 +40,20 @@ final class PluginFactoryListStore: ObservableObject { guard let repository else { return } lastError = nil releases = (try? await repository.listPluginFactoryReleaseSummaries()) ?? [] + await refreshSkillIndex() + } + + func release(pluginID: String, version: String) async throws -> PluginFactoryRelease? { + guard let repository else { return nil } + return try await repository.pluginFactoryRelease(pluginID: pluginID, version: version) + } + + func replace(_ release: PluginFactoryRelease) async throws { + guard let repository else { + throw DBRepositoryError.sqliteOperationFailed("Plugin factory repository is not configured.") + } + try await repository.replacePluginFactoryRelease(release) + await reload() } func delete(_ release: PluginFactoryReleaseSummary) async { @@ -48,6 +68,44 @@ final class PluginFactoryListStore: ObservableObject { lastError = error.localizedDescription } } + + /// Removes legacy Slack reference connectors installed outside the LLM create path. + func purgeLegacySlackConnectors() async { + guard let repository else { return } + do { + let summaries = try await repository.listPluginFactoryReleaseSummaries() + var pluginIDs = Set() + for summary in summaries where PluginFactoryLegacyPurge.isLegacySlackPluginID(summary.pluginID) { + pluginIDs.insert(summary.pluginID) + } + for pluginID in pluginIDs { + try await repository.deletePluginFactoryRelease(pluginID: pluginID) + } + let connectors = try await repository.listMessagingConnectors() + let keep = Set( + connectors + .map(\.pluginID) + .filter { !PluginFactoryLegacyPurge.isLegacySlackPluginID($0) } + ) + try await repository.pruneMessagingConnectors(keeping: keep) + } catch { + lastError = error.localizedDescription + } + } + + private func refreshSkillIndex() async { + var entries: [PluginSkillDisclosure.IndexEntry] = [] + for group in groups { + guard let latest = group.latest, + let release = try? await release(pluginID: latest.pluginID, version: latest.version) + else { continue } + entries.append(contentsOf: PluginSkillDisclosure.index(from: release)) + } + skillIndex = entries.sorted { lhs, rhs in + if lhs.pluginID != rhs.pluginID { return lhs.pluginID < rhs.pluginID } + return lhs.skillName < rhs.skillName + } + } } struct PluginFactoryReleaseGroup: Identifiable, Sendable { diff --git a/ui/ui/Views/ChatTabBarView.swift b/ui/ui/Views/ChatTabBarView.swift index fb22866b..0aeefe5b 100644 --- a/ui/ui/Views/ChatTabBarView.swift +++ b/ui/ui/Views/ChatTabBarView.swift @@ -1,16 +1,33 @@ import SwiftUI struct ChatTabBarView: View { + enum TabFilter: Equatable { + /// Regular chats and plugin surfaces — never plugin-creator tabs. + case chats + /// Only the Plugins creator tab(s). + case plugins + } + @ObservedObject var store: ChatSessionStore + var filter: TabFilter = .chats private let stripColor = Color(red: 236.0 / 255.0, green: 236.0 / 255.0, blue: 233.0 / 255.0) private let selectedFill = Color(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 246.0 / 255.0) private let tabCorner: CGFloat = 8 + private var visibleTabs: [ChatTab] { + switch filter { + case .chats: + return store.tabs.filter { !$0.isPluginCreator } + case .plugins: + return store.tabs.filter(\.isPluginCreator) + } + } + var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .bottom, spacing: 2) { - ForEach(store.tabs) { tab in + ForEach(visibleTabs) { tab in browserTab(tab) .id("\(tab.id)-\(tab.title)") } @@ -24,6 +41,7 @@ struct ChatTabBarView: View { .fill(Color.primary.opacity(0.08)) .frame(height: 1) } + .accessibilityIdentifier(filter == .plugins ? "chat-tab-bar-plugins" : "chat-tab-bar-chats") } private func browserTab(_ tab: ChatTab) -> some View { @@ -46,7 +64,7 @@ struct ChatTabBarView: View { } .buttonStyle(.plain) - if store.tabs.count > 1 { + if visibleTabs.count > 1 { Button { store.closeTab(id: tab.id) } label: { diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index 8b07bd6f..f870d6cd 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -415,12 +415,15 @@ struct ContentView: View { VStack(spacing: 0) { if workspace != .debugLogs { - ChatTabBarView(store: chatSessions) + ChatTabBarView( + store: chatSessions, + filter: workspace == .plugins ? .plugins : .chats + ) } switch workspace { case .debugLogs: DebugLogsView(repository: repository) - case .chats: + case .chats, .plugins: if chatSessions.selectedTab?.surface == .thread { MessagingConversationView( store: messaging, @@ -469,6 +472,14 @@ struct ContentView: View { messaging.setWorkspaceActive( newValue == .chats && chatSessions.selectedTab?.surface == .thread ) + switch newValue { + case .chats: + ensureChatMenuSelection() + case .plugins: + ensurePluginsMenuSelection() + case .debugLogs: + break + } } .onChange(of: chatSessions.selectedSessionID) { _, _ in Task { @MainActor in @@ -522,9 +533,29 @@ struct ContentView: View { } } .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.startPluginCreation)) { _ in - workspace = .chats bindPluginCreatorCompletion() chatSessions.openOrFocusPluginCreator() + workspace = .plugins + } + .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.startPluginEdit)) { notification in + guard let pluginID = notification.userInfo?[ChatShellNotification.pluginIDUserInfoKey] as? String, + let version = notification.userInfo?[ChatShellNotification.pluginVersionUserInfoKey] as? String, + let prompt = notification.userInfo?[ChatShellNotification.editPromptUserInfoKey] as? String, + !pluginID.isEmpty, + !version.isEmpty, + !prompt.isEmpty + else { + return + } + workspace = .plugins + pluginCreationController.beginEdit( + pluginID: pluginID, + version: version, + prompt: prompt, + sessionID: pluginWizardSessionID, + helperAPIKey: currentHelperAPIKey, + helperReviewerModelJSON: currentHelperReviewerModelJSON + ) } .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.openPluginInChat)) { notification in guard let pluginID = notification.userInfo?[ChatShellNotification.pluginIDUserInfoKey] as? String, @@ -955,8 +986,9 @@ struct ContentView: View { } } + @ViewBuilder var mainPanel: some View { - Color(red: 248.0/255.0, green: 248.0/255.0, blue: 246.0/255.0) + let panel = Color(red: 248.0/255.0, green: 248.0/255.0, blue: 246.0/255.0) .ignoresSafeArea() .overlay { GeometryReader { proxy in @@ -966,6 +998,14 @@ struct ContentView: View { panelContent(inputHeight: inputHeight, panelWidth: panelWidth) } } + // Plugin tab + pill sub-tabs only while the Plugins menu is active. + if workspace == .plugins, chatSessions.selectedTab?.isPluginCreator == true { + PluginsWorkspaceShellView { + panel + } + } else { + panel + } } func panelContent(inputHeight: CGFloat, panelWidth: CGFloat) -> some View { @@ -1461,6 +1501,27 @@ struct ContentView: View { } } + /// Chat menu: hide Plugin tab by leaving any creator selection. + private func ensureChatMenuSelection() { + guard chatSessions.selectedTab?.isPluginCreator == true else { return } + if let chat = chatSessions.tabs.last(where: { !$0.isPluginCreator }) { + chatSessions.selectSession(id: chat.id) + } else { + chatSessions.openNewChat() + } + } + + /// Plugins menu: show/focus a Plugin tab (creator). + private func ensurePluginsMenuSelection() { + if chatSessions.selectedTab?.isPluginCreator == true { return } + if let creator = chatSessions.tabs.last(where: \.isPluginCreator) { + chatSessions.selectSession(id: creator.id) + return + } + bindPluginCreatorCompletion() + chatSessions.openOrFocusPluginCreator() + } + private func bindPluginCreatorCompletion() { chatSessions.onPluginSpecComplete = { session in pluginCreationController.beginFromCompletedSpec( diff --git a/ui/ui/Views/DebugLogsView.swift b/ui/ui/Views/DebugLogsView.swift index f8ce0400..b00080b6 100644 --- a/ui/ui/Views/DebugLogsView.swift +++ b/ui/ui/Views/DebugLogsView.swift @@ -128,30 +128,20 @@ struct DebugLogsView: View { private var serviceFilter: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - filterChip(title: "All", service: nil) + PillFilterChip(title: "All", isSelected: viewModel.selectedService == nil) { + viewModel.selectService(nil) + } ForEach(viewModel.knownServices, id: \.self) { service in - filterChip(title: service, service: service) + PillFilterChip( + title: service, + isSelected: viewModel.selectedService == service + ) { + viewModel.selectService(service) + } } } } - } - - private func filterChip(title: String, service: String?) -> some View { - let selected = viewModel.selectedService == service - return Button(title) { - viewModel.selectService(service) - } - .buttonStyle(.plain) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - selected ? Color.accentColor.opacity(0.18) : Color.black.opacity(0.05), - in: Capsule() - ) - .overlay( - Capsule() - .stroke(selected ? Color.accentColor.opacity(0.45) : Color.clear, lineWidth: 1) - ) + .accessibilityIdentifier("debug-logs-service-pills") } private func copyToPasteboard(_ text: String) { diff --git a/ui/ui/Views/PluginFactorySettingsListView.swift b/ui/ui/Views/PluginFactorySettingsListView.swift index f62be8d7..797a8852 100644 --- a/ui/ui/Views/PluginFactorySettingsListView.swift +++ b/ui/ui/Views/PluginFactorySettingsListView.swift @@ -38,7 +38,7 @@ struct PluginFactorySettingsListView: View { } } .padding(.leading, SettingsLayout.fieldIndent) - Text("Type / and the plugin name in Chat to open it in a tab. Create plugin asks one spec slot at a time in a Chat tab.") + Text("Type / and the plugin name in Chat to open it in a tab. Plugins opens Create plugin and a browser for installed package files.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/ui/ui/Views/SidebarView.swift b/ui/ui/Views/SidebarView.swift index 2934d0a3..3405d68e 100644 --- a/ui/ui/Views/SidebarView.swift +++ b/ui/ui/Views/SidebarView.swift @@ -44,9 +44,13 @@ struct SidebarView: View { chatSessions.openNewChat() } SidebarActionRow( - row: SidebarPrimaryActions.newPlugin + row: SidebarRow( + id: SidebarPrimaryActions.newPlugin.id, + icon: SidebarPrimaryActions.newPlugin.icon, + title: SidebarPrimaryActions.newPlugin.title, + isProminent: workspace == .plugins + ) ) { - workspace = .chats NotificationCenter.default.post( name: ChatShellNotification.startPluginCreation, object: nil @@ -170,7 +174,9 @@ struct SidebarView: View { } else { ForEach(chatSessions.recentSessions) { session in Button { - workspace = .chats + let isCreator = session.metadata["pluginCreator"] == "true" + || PluginSpecProcession.isCreatorTabID(session.sessionID) + workspace = isCreator ? .plugins : .chats chatSessions.selectSession(id: session.sessionID) } label: { Text(session.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false @@ -232,7 +238,7 @@ struct SidebarRow: Identifiable, Hashable, Sendable { enum SidebarPrimaryActions { static let newChat = SidebarRow(id: "new-chat", icon: "plus.circle.fill", title: "New chat") - static let newPlugin = SidebarRow(id: "new-plugin", icon: "plus.square.fill", title: "New plugin") + static let newPlugin = SidebarRow(id: "new-plugin", icon: "puzzlepiece.extension.fill", title: "Plugins") } struct SidebarActionRow: View { diff --git a/ui/uiTests/ChatTabRoutingTests.swift b/ui/uiTests/ChatTabRoutingTests.swift index 660e6c69..cc1220e4 100644 --- a/ui/uiTests/ChatTabRoutingTests.swift +++ b/ui/uiTests/ChatTabRoutingTests.swift @@ -89,10 +89,23 @@ import CoreGraphics @Test func newPluginSitsBelowNewChatInTheSidebar() { #expect(SidebarPrimaryActions.newChat.title == "New chat") - #expect(SidebarPrimaryActions.newPlugin.title == "New plugin") + #expect(SidebarPrimaryActions.newPlugin.title == "Plugins") #expect(SidebarPrimaryActions.newPlugin.id == "new-plugin") } + @Test func pluginsWorkspaceSubtabsAreCreateAndPlugins() { + #expect(PluginsWorkspaceSubtab.allCases.map(\.rawValue) == ["Create plugin", "Plugins"]) + } + + @Test func chatTabBarHidesPluginTabsInChatFilter() { + #expect(ChatTabBarView.TabFilter.chats != .plugins) + } + + @Test func pluginsMenuIsSeparateFromChatMenu() { + #expect(AppWorkspace.plugins != .chats) + #expect(SidebarPrimaryActions.newPlugin.title == "Plugins") + } + @Test func pluginCreatorIntroExplainsWhatAPluginIs() { #expect(PluginCreatorIntroCopy.body.contains("extension to derrick")) #expect(PluginCreatorIntroCopy.body.contains("additional capability to derrick")) @@ -114,7 +127,8 @@ import CoreGraphics #expect(PluginSpecProcession.isCreatorTabID(fresh.id)) #expect(fresh.turns.isEmpty == false) #expect(fresh.isPluginCreator) - #expect(fresh.title == PluginSpecProcession.creatorTabTitlePrefix) + #expect(fresh.title == PluginSpecProcession.pluginsTabTitle) + #expect(seeded.title == PluginSpecProcession.pluginsTabTitle) } @MainActor @@ -127,7 +141,7 @@ import CoreGraphics #expect(PluginSpecProcession.isCreatorTabID(second)) #expect(store.tabs.filter(\.isPluginCreator).count == 2) #expect(store.selectedSessionID == second) - #expect(store.selectedTab?.title == PluginSpecProcession.creatorTabTitlePrefix) + #expect(store.selectedTab?.title == PluginSpecProcession.pluginsTabTitle) } @MainActor @@ -141,23 +155,18 @@ import CoreGraphics #expect(store.selectedTab?.pluginID == "slack-connector-1") } - @Test func pluginCreatorKeepsDescriptionTitleWhenReseeded() { + @Test func pluginCreatorKeepsPluginsTabTitleWhenReseeded() { var tab = ChatTab.pluginCreator() tab.title = PluginSpecProcession.creatorTabTitle(from: "connect to slack and send receive messages") let reseeded = ChatTab.pluginCreator(existing: tab) - #expect(reseeded.title == tab.title) - #expect(reseeded.title.hasPrefix("Create plugin - ")) + #expect(reseeded.title == PluginSpecProcession.pluginsTabTitle) } - @Test func pluginCreatorTabTitleUpdatesAfterClaimedOutcome() { + @Test func pluginCreatorTabTitleStaysPluginsAfterClaimedOutcome() { var tab = ChatTab.pluginCreator() - #expect(tab.title == PluginSpecProcession.creatorTabTitlePrefix) + #expect(tab.title == PluginSpecProcession.pluginsTabTitle) _ = tab.applyCreatorUtterance("connect to slack and send receive messages") - #expect(tab.title == PluginSpecProcession.creatorTabTitle( - from: "connect to slack and send receive messages" - )) - #expect(tab.title.hasPrefix("Create plugin - ")) - #expect(tab.title.contains("slack")) + #expect(tab.title == PluginSpecProcession.pluginsTabTitle) } @Test func pluginCreatorAccessWaitsForDocsThenAsksFromTheSummary() { From 1349145a955cfdd28f0a664fdb380fdcef165a87 Mon Sep 17 00:00:00 2001 From: David Choi Date: Thu, 17 Sep 2026 22:49:19 -0400 Subject: [PATCH 3/4] fix plugin issues and display Co-authored-by: Cursor --- master-todo.md | 26 ++ .../DBRepository/DBRepositoryAgents.swift | 10 + .../DBRepositoryPluginFactory.swift | 25 +- .../DBRepositoryPluginPurge.swift | 424 ++++++++++++++++++ .../DBRepositoryPluginPurgeTests.swift | 198 ++++++++ .../DerrickBackend/AgentPluginSpecFetch.swift | 66 +++ .../PluginFactoryCreateWorkflow.swift | 140 ++++-- .../PluginMessagingIngressAdapter.swift | 7 +- .../WorkflowIntegrationTests.swift | 14 +- packages/MCPServer/Package.swift | 16 - .../HarnessSecretAttacher.swift | 6 +- .../LegacySlackConnectorPurge.swift | 31 -- .../SlackConnectorInstallReferenceMain.swift | 35 -- .../MCPService/PluginFactoryCreateInput.swift | 23 +- .../Plugin/ConnectorAuthPreference.swift | 4 +- .../Plugin/PluginSpecDraft.swift | 11 +- .../Plugin/PluginSpecProcession.swift | 42 +- .../PluginSecretDevelopmentSource.swift | 2 +- .../PluginSecretKeychain.swift | 97 ++++ .../PluginSecretResolver.swift | 25 ++ .../Contract/ConnectorContractPrompts.swift | 24 +- .../Plugin/Factory/AgentPluginSpec.swift | 76 ++++ .../Factory/PluginFactoryLegacyPurge.swift | 8 - .../Plugin/Factory/PluginFactoryTypes.swift | 66 ++- .../HTTP/SlackUserDisplayNameResolver.swift | 7 +- .../StructureTests/AgentPluginSpecTests.swift | 28 ++ .../AppLayerServicesWireTests.swift | 36 ++ .../ConnectorContractTests.swift | 15 + .../PluginFactoryLegacyPurgeTests.swift | 12 - .../PluginSpecProcessionTests.swift | 84 ++-- ui/MCPService/MCPServiceToolHost.swift | 5 +- ui/MCPService/WorkflowProgressPublisher.swift | 24 + .../AgentProfileHighlightedText.swift | 13 +- .../InAppNotificationBannerChrome.swift | 96 ++++ ui/ui/Components/SelectableLinkTextView.swift | 129 ++++++ ui/ui/Jobs/JobResultPresenter.swift | 38 +- ui/ui/Messaging/AppWorkspace.swift | 17 +- .../MessagingConnectorCredentials.swift | 1 + .../Messaging/MessagingConversationView.swift | 101 +++-- ui/ui/Plugins/PluginCreationController.swift | 94 +++- ui/ui/Plugins/PluginPackageBrowserView.swift | 15 + ui/ui/Plugins/PluginsWorkspaceShellView.swift | 49 -- ui/ui/Session/ChatSessionStore.swift | 91 +++- ui/ui/Session/PluginFactoryListStore.swift | 56 ++- ui/ui/Views/ChatTabBarView.swift | 17 +- ui/ui/Views/ContentView.swift | 145 +++--- ui/ui/Views/MarkdownView.swift | 25 +- ui/ui/Views/PromptView.swift | 2 +- ui/ui/Views/SidebarView.swift | 96 +++- ui/uiTests/ChatTabRoutingTests.swift | 75 +++- .../InAppNotificationBannerChromeTests.swift | 13 + ui/uiTests/MessagingMarkdownTextTests.swift | 8 + 52 files changed, 2174 insertions(+), 494 deletions(-) create mode 100644 packages/DBRepository/Sources/DBRepository/DBRepositoryPluginPurge.swift create mode 100644 packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryPluginPurgeTests.swift create mode 100644 packages/DerrickBackend/Sources/DerrickBackend/AgentPluginSpecFetch.swift delete mode 100644 packages/MCPServer/Sources/FactoryHarnessSupport/LegacySlackConnectorPurge.swift delete mode 100644 packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift create mode 100644 packages/Structure/Sources/Plugin/Factory/AgentPluginSpec.swift delete mode 100644 packages/Structure/Sources/Plugin/Factory/PluginFactoryLegacyPurge.swift create mode 100644 packages/Structure/Tests/StructureTests/AgentPluginSpecTests.swift delete mode 100644 packages/Structure/Tests/StructureTests/PluginFactoryLegacyPurgeTests.swift create mode 100644 ui/ui/Components/InAppNotificationBannerChrome.swift create mode 100644 ui/ui/Components/SelectableLinkTextView.swift delete mode 100644 ui/ui/Plugins/PluginsWorkspaceShellView.swift create mode 100644 ui/uiTests/InAppNotificationBannerChromeTests.swift diff --git a/master-todo.md b/master-todo.md index 9cd98b98..affeecf7 100644 --- a/master-todo.md +++ b/master-todo.md @@ -4,6 +4,32 @@ Living list. Newest decisions first. Check items off in the same change that lan ## Now +### Plugin factory: staged workflow (reliability) — creation first + +**Goal:** Most reliable, repeatable create path so users struggle less. Workflow is the only orchestrator; every gate is host-enforced. + +**Create stages (do this next):** +1. Validate input +2. Fetch Agent Plugin spec from live published URL (https://agent-plugins.org/specification); disclaimer that the latest published version always preferred over any cached/bundled copy +3. Fetch vendor/inbox docs (connectors) — same force-read pattern as today +4. Builder draft +5. Host package checks (SKILL.md, manifest, hash rules) +6. Compile/trial +7. Independent reviewer +8. Promote or retry with structured feedback (attempt budget is workflow policy) + +Progressive disclosure stays for chat use; builder gets goal + forced summaries + prior feedback only. + +**Edit — deferred until create stages are correct:** +- Today Plugins **Update**/`beginEdit` still starts `pluginFactoryCreate` (create-with-edit-prompt). Wire kind `pluginFactoryEdit` exists but is unused. +- After create pipeline is solid: integrate edit into the **same** staged workflow (load prior release, ship new version). Do not leave edit on the older glued create path. + +- [x] Host force-fetch Agent Plugin spec before builder (prefer live latest; bundled fallback). +- [x] Clearer create workflow stages (`validate` → `spec` → `docs` → `builder`/`review`/`trial` → `promote`) + stage-mapped failures. +- [ ] Further split builder/review/trial into separate workflow tool steps (still one `plugin_factory_build` today; progress stages already map). +- [ ] Structured retry feedback packet surfaced as first-class workflow events (session already retries inside the tool). +- [ ] **Later:** real `pluginFactoryEdit` on the same staged pipeline. + ### Messaging tabs vs Slack reply threads (locked) - **Conversations** (Slack channels/DMs the bot is in) are **tabs**. Show every discovered conversation. Only the selected tab loads the 100-message window. diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryAgents.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryAgents.swift index 96968da9..b1289341 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryAgents.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryAgents.swift @@ -88,6 +88,16 @@ public extension DBRepository { } } + func deleteChatSession(applicationName: String, sessionID: String) throws { + try withDatabaseHandle { handle in + try Self.execute(""" + DELETE FROM chat_sessions + WHERE application_name = \(quoted(applicationName)) + AND session_id = \(quoted(sessionID)); + """, on: handle) + } + } + // MARK: - Agents func upsertAgentRecord(_ record: AgentRecord, applicationName: String) throws { diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift index 1b1673da..39975646 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift @@ -172,18 +172,7 @@ public extension DBRepository { } func deletePluginFactoryRelease(pluginID: String, version: String? = nil) throws { - let versionClause = version.map { - " AND version = \(quoted($0))" - } ?? "" - try withDatabaseHandle { handle in - try Self.execute( - """ - DELETE FROM plugin_factory_releases - WHERE plugin_id = \(quoted(pluginID))\(versionClause); - """, - on: handle - ) - } + _ = try purgePlugin(pluginID: pluginID, version: version) } /// Replaces an existing version in place after a manual package edit. @@ -192,7 +181,17 @@ public extension DBRepository { guard release.verifyIntegrity() else { throw DBRepositoryError.sqliteOperationFailed("Refusing to store a release with an invalid content hash.") } - try deletePluginFactoryRelease(pluginID: release.pluginID, version: release.version) + // Version-only row replace — do not cascade associated plugin data. + try withDatabaseHandle { handle in + try Self.execute( + """ + DELETE FROM plugin_factory_releases + WHERE plugin_id = \(quoted(release.pluginID)) + AND version = \(quoted(release.version)); + """, + on: handle + ) + } try savePluginFactoryRelease(release) } } diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginPurge.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginPurge.swift new file mode 100644 index 00000000..4cbefb6d --- /dev/null +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginPurge.swift @@ -0,0 +1,424 @@ +import Foundation +import SQLite3 +import Structure + +/// Outcome of deleting a plugin (or one version) and its associated rows. +public struct PluginPurgeResult: Sendable, Equatable { + public var pluginID: String + public var removedReleaseCount: Int + /// True when no factory releases remain for this plugin and associated data was purged. + public var purgedAssociatedData: Bool + public var removedMessagingConnectors: Int + public var removedAgentHandled: Int + public var removedChatSessions: Int + public var removedWorkflowRuns: Int + public var removedContentSensitivityGrants: Int + + public init( + pluginID: String, + removedReleaseCount: Int = 0, + purgedAssociatedData: Bool = false, + removedMessagingConnectors: Int = 0, + removedAgentHandled: Int = 0, + removedChatSessions: Int = 0, + removedWorkflowRuns: Int = 0, + removedContentSensitivityGrants: Int = 0 + ) { + self.pluginID = pluginID + self.removedReleaseCount = removedReleaseCount + self.purgedAssociatedData = purgedAssociatedData + self.removedMessagingConnectors = removedMessagingConnectors + self.removedAgentHandled = removedAgentHandled + self.removedChatSessions = removedChatSessions + self.removedWorkflowRuns = removedWorkflowRuns + self.removedContentSensitivityGrants = removedContentSensitivityGrants + } + + public var removedAnything: Bool { + removedReleaseCount > 0 + || removedMessagingConnectors > 0 + || removedAgentHandled > 0 + || removedChatSessions > 0 + || removedWorkflowRuns > 0 + || removedContentSensitivityGrants > 0 + } +} + +public extension DBRepository { + /// Deletes factory release row(s) and, when the plugin is fully gone, all associated + /// messaging / chat / workflow / sensitivity rows in one SQLite transaction. + @discardableResult + func purgePlugin(pluginID: String, version: String? = nil) throws -> PluginPurgeResult { + let trimmed = pluginID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw DBRepositoryError.sqliteOperationFailed("Plugin id is required to purge.") + } + return try withDatabaseHandle { handle in + try Self.withImmediateTransaction(on: handle) { + try Self.purgePluginUnlocked( + pluginID: trimmed, + version: version?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + on: handle, + quoted: { self.quoted($0) } + ) + } + } + } + + /// Removes messaging / chat / workflow leftovers whose plugin no longer has a factory release. + @discardableResult + func purgeOrphanedPluginAssociatedData() throws -> [PluginPurgeResult] { + try withDatabaseHandle { handle in + try Self.withImmediateTransaction(on: handle) { + let live = try Self.pluginIDsWithReleases(on: handle, quoted: { self.quoted($0) }) + let candidates = try Self.pluginIDsWithAssociatedData(on: handle, quoted: { self.quoted($0) }) + let orphans = candidates.subtracting(live).sorted() + var results: [PluginPurgeResult] = [] + for pluginID in orphans { + var result = try Self.purgeAssociatedDataUnlocked( + pluginID: pluginID, + on: handle, + quoted: { self.quoted($0) } + ) + result.pluginID = pluginID + result.purgedAssociatedData = true + if result.removedAnything { + results.append(result) + } + } + return results + } + } + } + + /// Lists plugin ids that still have factory releases. + func listInstalledPluginIDs() throws -> Set { + try withDatabaseHandle { handle in + try Self.pluginIDsWithReleases(on: handle, quoted: { self.quoted($0) }) + } + } +} + +private extension String { + var nilIfEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +private enum PluginPurgeSQL { + static func escapeLike(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "%", with: "\\%") + .replacingOccurrences(of: "_", with: "\\_") + } + + static func sessionMatchSQL(pluginID: String, quoted: (String) -> String) -> String { + let like = escapeLike(pluginID) + let root = "plugin:\(like)" + let thread = "plugin:\(like):thread:%" + let messaging = "messaging-\(like)-%" + let metaPluginID = "%\"pluginID\":\"\(like)\"%" + let metaPlugin_id = "%\"plugin_id\":\"\(like)\"%" + let title = "%/\(like)%" + return """ + session_id = \(quoted("plugin:\(pluginID)")) + OR session_id LIKE \(quoted(thread)) ESCAPE '\\' + OR session_id LIKE \(quoted(messaging)) ESCAPE '\\' + OR session_id LIKE \(quoted(root + "%")) ESCAPE '\\' + OR metadata_json LIKE \(quoted(metaPluginID)) ESCAPE '\\' + OR metadata_json LIKE \(quoted(metaPlugin_id)) ESCAPE '\\' + OR IFNULL(title, '') LIKE \(quoted(title)) ESCAPE '\\' + """ + } + + static func workflowMatchSQL(pluginID: String, quoted: (String) -> String) -> String { + let like = escapeLike(pluginID) + let patterns = [ + "%\"plugin_id\":\"\(like)\"%", + "%\"pluginID\":\"\(like)\"%", + "%plugin:\(like)%", + "%/\(like)%", + ] + return patterns.map { pattern in + """ + input_json LIKE \(quoted(pattern)) ESCAPE '\\' + OR context_json LIKE \(quoted(pattern)) ESCAPE '\\' + OR IFNULL(result_json, '') LIKE \(quoted(pattern)) ESCAPE '\\' + OR IFNULL(error_message, '') LIKE \(quoted(pattern)) ESCAPE '\\' + """ + }.joined(separator: " OR ") + } +} + +private extension DBRepository { + static func purgePluginUnlocked( + pluginID: String, + version: String?, + on handle: OpaquePointer, + quoted: (String) -> String + ) throws -> PluginPurgeResult { + let beforeCount = try releaseCount(pluginID: pluginID, on: handle, quoted: quoted) + guard beforeCount > 0 || version == nil else { + // No releases for a versioned delete — still allow associated cleanup when version is nil. + return PluginPurgeResult(pluginID: pluginID) + } + + let versionClause = version.map { " AND version = \(quoted($0))" } ?? "" + if beforeCount > 0 { + try execute( + """ + DELETE FROM plugin_factory_releases + WHERE plugin_id = \(quoted(pluginID))\(versionClause); + """, + on: handle + ) + } + let afterCount = try releaseCount(pluginID: pluginID, on: handle, quoted: quoted) + let removedReleases = max(0, beforeCount - afterCount) + + var result = PluginPurgeResult( + pluginID: pluginID, + removedReleaseCount: removedReleases + ) + guard afterCount == 0 else { + return result + } + + let associated = try purgeAssociatedDataUnlocked( + pluginID: pluginID, + on: handle, + quoted: quoted + ) + result.purgedAssociatedData = true + result.removedMessagingConnectors = associated.removedMessagingConnectors + result.removedAgentHandled = associated.removedAgentHandled + result.removedChatSessions = associated.removedChatSessions + result.removedWorkflowRuns = associated.removedWorkflowRuns + result.removedContentSensitivityGrants = associated.removedContentSensitivityGrants + return result + } + + static func purgeAssociatedDataUnlocked( + pluginID: String, + on handle: OpaquePointer, + quoted: (String) -> String + ) throws -> PluginPurgeResult { + var result = PluginPurgeResult(pluginID: pluginID, purgedAssociatedData: true) + + result.removedAgentHandled = try scalarCount( + """ + SELECT COUNT(*) FROM messaging_agent_handled + WHERE plugin_id = \(quoted(pluginID)); + """, + on: handle + ) + try execute( + """ + DELETE FROM messaging_agent_handled + WHERE plugin_id = \(quoted(pluginID)); + """, + on: handle + ) + + result.removedMessagingConnectors = try scalarCount( + """ + SELECT COUNT(*) FROM messaging_connectors + WHERE plugin_id = \(quoted(pluginID)); + """, + on: handle + ) + // Threads + messages cascade from connectors. + try execute( + """ + DELETE FROM messaging_connectors + WHERE plugin_id = \(quoted(pluginID)); + """, + on: handle + ) + + let sessionWhere = PluginPurgeSQL.sessionMatchSQL(pluginID: pluginID, quoted: quoted) + result.removedChatSessions = try scalarCount( + """ + SELECT COUNT(*) FROM chat_sessions + WHERE \(sessionWhere); + """, + on: handle + ) + result.removedContentSensitivityGrants = try scalarCount( + """ + SELECT COUNT(*) FROM content_sensitivity_grants + WHERE session_id IN ( + SELECT session_id FROM chat_sessions WHERE \(sessionWhere) + ); + """, + on: handle + ) + try execute( + """ + DELETE FROM content_sensitivity_grants + WHERE session_id IN ( + SELECT session_id FROM chat_sessions WHERE \(sessionWhere) + ); + """, + on: handle + ) + // agents / agent_turns cascade from chat_sessions. + try execute( + """ + DELETE FROM chat_sessions + WHERE \(sessionWhere); + """, + on: handle + ) + + let workflowWhere = PluginPurgeSQL.workflowMatchSQL(pluginID: pluginID, quoted: quoted) + result.removedWorkflowRuns = try scalarCount( + """ + SELECT COUNT(*) FROM workflow_runs + WHERE \(workflowWhere); + """, + on: handle + ) + try execute( + """ + DELETE FROM workflow_run_events + WHERE workflow_id IN ( + SELECT id FROM workflow_runs WHERE \(workflowWhere) + ); + """, + on: handle + ) + try execute( + """ + DELETE FROM workflow_run_steps + WHERE workflow_id IN ( + SELECT id FROM workflow_runs WHERE \(workflowWhere) + ); + """, + on: handle + ) + try execute( + """ + DELETE FROM workflow_runs + WHERE \(workflowWhere); + """, + on: handle + ) + + return result + } + + static func releaseCount( + pluginID: String, + on handle: OpaquePointer, + quoted: (String) -> String + ) throws -> Int { + try scalarCount( + """ + SELECT COUNT(*) FROM plugin_factory_releases + WHERE plugin_id = \(quoted(pluginID)); + """, + on: handle + ) + } + + static func pluginIDsWithReleases( + on handle: OpaquePointer, + quoted: (String) -> String + ) throws -> Set { + try stringSet( + "SELECT DISTINCT plugin_id FROM plugin_factory_releases;", + on: handle + ) + } + + static func pluginIDsWithAssociatedData( + on handle: OpaquePointer, + quoted: (String) -> String + ) throws -> Set { + var ids = try stringSet( + "SELECT DISTINCT plugin_id FROM messaging_connectors;", + on: handle + ) + ids.formUnion(try stringSet( + "SELECT DISTINCT plugin_id FROM messaging_agent_handled;", + on: handle + )) + // Chat / workflow orphans are harder to reverse-map; connector + handled cover messaging. + // Also pick session_ids that look like plugin roots. + let sessionSQL = """ + SELECT DISTINCT + CASE + WHEN session_id LIKE 'plugin:%:thread:%' THEN + substr(session_id, 8, instr(substr(session_id, 8), ':') - 1) + WHEN session_id LIKE 'plugin:%' THEN + substr(session_id, 8) + WHEN session_id LIKE 'messaging-%' THEN + -- messaging---orchestrator + NULL + ELSE NULL + END + FROM chat_sessions + WHERE session_id LIKE 'plugin:%'; + """ + ids.formUnion(try stringSet(sessionSQL, on: handle).filter { !$0.isEmpty }) + + // Parse messaging- orchestrator session ids in Swift-friendly second pass. + let messagingSessions = try stringSet( + """ + SELECT session_id FROM chat_sessions + WHERE session_id LIKE 'messaging-%'; + """, + on: handle + ) + for sessionID in messagingSessions { + if let pluginID = messagingOrchestratorPluginID(sessionID) { + ids.insert(pluginID) + } + } + return ids + } + + /// `messaging---orchestrator` + static func messagingOrchestratorPluginID(_ sessionID: String) -> String? { + guard sessionID.hasPrefix("messaging-"), sessionID.hasSuffix("-orchestrator") else { + return nil + } + let body = String(sessionID.dropFirst("messaging-".count).dropLast("-orchestrator".count)) + // UUID is last 36 chars after a hyphen. + guard body.count > 37, body[body.index(body.endIndex, offsetBy: -37)] == "-" else { + return nil + } + let pluginID = String(body.dropLast(37)) + return pluginID.isEmpty ? nil : pluginID + } + + static func scalarCount(_ sql: String, on handle: OpaquePointer) throws -> Int { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw sqliteError(handle: handle, fallback: "Failed to count purge rows.") + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { return 0 } + return Int(sqlite3_column_int64(statement, 0)) + } + + static func stringSet(_ sql: String, on handle: OpaquePointer) throws -> Set { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw sqliteError(handle: handle, fallback: "Failed to list plugin ids.") + } + defer { sqlite3_finalize(statement) } + var values = Set() + while sqlite3_step(statement) == SQLITE_ROW { + guard let c = sqlite3_column_text(statement, 0) else { continue } + let value = String(cString: c).trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + values.insert(value) + } + } + return values + } +} diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryPluginPurgeTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryPluginPurgeTests.swift new file mode 100644 index 00000000..4f1105d1 --- /dev/null +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryPluginPurgeTests.swift @@ -0,0 +1,198 @@ +import XCTest +import Plugin +@testable import DBRepository +import Structure + +final class DBRepositoryPluginPurgeTests: XCTestCase { + func testPurgePluginRemovesMessagingChatWorkflowAndHandledInOneShot() async throws { + let repository = try await makeRepository() + let release = makeGoFactoryRelease(pluginID: "slack-connector-9") + try await repository.savePluginFactoryRelease(release) + + try await repository.upsertMessagingConnector( + MessagingConnectorDTO(pluginID: "slack-connector-9", displayName: "Slack 9", listening: true) + ) + let threadID = UUID().uuidString + try await repository.upsertMessagingThread( + MessagingThreadDTO( + id: threadID, + pluginID: "slack-connector-9", + vendorThreadID: "C9", + title: "#general" + ) + ) + _ = try await repository.persistMessagingInbound( + MessagingInboundRecord( + pluginID: "slack-connector-9", + vendorThreadID: "C9", + threadTitle: "#general", + vendorMessageID: "m1", + sender: "alice", + body: "hello", + createdAt: Date() + ) + ) + _ = try await repository.claimMessagingAgentHandling( + pluginID: "slack-connector-9", + vendorMessageID: "m1" + ) + + try await repository.upsertChatSession( + ChatSessionDTO( + applicationName: "ui", + sessionID: "plugin:slack-connector-9", + title: "/slack-connector-9", + createdAt: .now, + updatedAt: .now, + metadata: ["pluginID": "slack-connector-9"] + ) + ) + try await repository.upsertChatSession( + ChatSessionDTO( + applicationName: "ui", + sessionID: "plugin:slack-connector-9:thread:\(threadID)", + title: "general", + createdAt: .now, + updatedAt: .now, + metadata: ["pluginID": "slack-connector-9", "threadID": threadID] + ) + ) + + let workflow = WorkflowRunRow( + id: UUID().uuidString, + kind: WorkflowKind.pluginFactoryCreate.rawValue, + status: WorkflowRunStatus.completed.rawValue, + contextJSON: "{}", + inputJSON: #"{"plugin_id":"slack-connector-9","description":"x"}"#, + idempotencyKey: nil, + currentStepID: nil, + resultJSON: #"{"plugin_id":"slack-connector-9"}"#, + errorMessage: nil, + createdAt: .now, + finishedAt: .now + ) + try await repository.insertWorkflowRun(workflow) + _ = try await repository.appendWorkflowEvent( + workflowID: workflow.id, + kind: "progress", + stage: "complete", + message: "done" + ) + + let result = try await repository.purgePlugin(pluginID: "slack-connector-9") + XCTAssertEqual(result.removedReleaseCount, 1) + XCTAssertTrue(result.purgedAssociatedData) + XCTAssertEqual(result.removedMessagingConnectors, 1) + XCTAssertEqual(result.removedAgentHandled, 1) + XCTAssertGreaterThanOrEqual(result.removedChatSessions, 2) + XCTAssertEqual(result.removedWorkflowRuns, 1) + + let releases = try await repository.listPluginFactoryReleaseSummaries() + XCTAssertFalse(releases.contains { $0.pluginID == "slack-connector-9" }) + let connectors = try await repository.listMessagingConnectors() + XCTAssertTrue(connectors.isEmpty) + let threads = try await repository.listMessagingThreads(pluginID: "slack-connector-9") + XCTAssertTrue(threads.isEmpty) + let sessions = try await repository.listRecentChatSessions(applicationName: "ui", limit: 20) + XCTAssertFalse(sessions.contains { $0.sessionID.contains("slack-connector-9") }) + let missingWorkflow = try await repository.workflowRun(id: workflow.id) + XCTAssertNil(missingWorkflow) + } + + func testVersionDeleteKeepsAssociatedDataUntilLastRelease() async throws { + let repository = try await makeRepository() + let v1 = makeGoFactoryRelease(pluginID: "weather-tool") + try await repository.savePluginFactoryRelease(v1) + + let artifact2 = Data("compiled-v2".utf8) + let guest2 = "package main // v2" + let manifest2 = "{\"name\":\"weather-tool\",\"extensions\":{\"app.derrick\":{\"entrypoint\":\"./app.derrick/plugin.go\"}}}" + let files2: [String: Data] = [ + "plugin.json": Data(manifest2.utf8), + "app.derrick/plugin.go": Data(guest2.utf8), + "app.derrick/plugin": artifact2, + ] + let v2 = PluginFactoryRelease( + pluginID: "weather-tool", + version: "1.0.1", + manifestJSON: manifest2, + runtimeJSON: "", + guestSource: guest2, + compiledArtifact: artifact2, + skillFiles: [:], + contentHash: PluginContentHash.hash(files: files2), + reviewSummary: "approved" + ) + try await repository.savePluginFactoryRelease(v2) + + try await repository.upsertMessagingConnector( + MessagingConnectorDTO(pluginID: "weather-tool", displayName: "Weather") + ) + + let first = try await repository.purgePlugin(pluginID: "weather-tool", version: "1.0.0") + XCTAssertEqual(first.removedReleaseCount, 1) + XCTAssertFalse(first.purgedAssociatedData) + let connectorsAfterFirst = try await repository.listMessagingConnectors() + XCTAssertEqual(connectorsAfterFirst.map(\.pluginID), ["weather-tool"]) + + let second = try await repository.purgePlugin(pluginID: "weather-tool", version: "1.0.1") + XCTAssertEqual(second.removedReleaseCount, 1) + XCTAssertTrue(second.purgedAssociatedData) + let connectorsAfterSecond = try await repository.listMessagingConnectors() + XCTAssertTrue(connectorsAfterSecond.isEmpty) + } + + func testPurgeOrphansRemovesStaleConnectorWithoutRelease() async throws { + let repository = try await makeRepository() + try await repository.upsertMessagingConnector( + MessagingConnectorDTO(pluginID: "ghost-connector", displayName: "Ghost") + ) + _ = try await repository.claimMessagingAgentHandling( + pluginID: "ghost-connector", + vendorMessageID: "old" + ) + + let orphans = try await repository.purgeOrphanedPluginAssociatedData() + XCTAssertTrue(orphans.contains { $0.pluginID == "ghost-connector" }) + let connectors = try await repository.listMessagingConnectors() + XCTAssertTrue(connectors.isEmpty) + } + + private func makeRepository() async throws -> DBRepository { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let configuration = DBRepositoryConfiguration( + applicationName: "ui", + databaseName: "derrick", + databaseDirectoryURL: directory, + username: "app-user", + password: "app-secret" + ) + let repository = DBRepository(configuration: configuration) + _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") + return repository + } + + private func makeGoFactoryRelease(pluginID: String) -> PluginFactoryRelease { + let artifact = Data("compiled".utf8) + let guestSource = "package main" + let manifestJSON = "{\"name\":\"\(pluginID)\",\"extensions\":{\"app.derrick\":{\"entrypoint\":\"./app.derrick/plugin.go\"}}}" + let files: [String: Data] = [ + "plugin.json": Data(manifestJSON.utf8), + "app.derrick/plugin.go": Data(guestSource.utf8), + "app.derrick/plugin": artifact, + ] + return PluginFactoryRelease( + pluginID: pluginID, + version: "1.0.0", + manifestJSON: manifestJSON, + runtimeJSON: "", + guestSource: guestSource, + compiledArtifact: artifact, + skillFiles: [:], + contentHash: PluginContentHash.hash(files: files), + reviewSummary: "approved" + ) + } +} diff --git a/packages/DerrickBackend/Sources/DerrickBackend/AgentPluginSpecFetch.swift b/packages/DerrickBackend/Sources/DerrickBackend/AgentPluginSpecFetch.swift new file mode 100644 index 00000000..0f415af6 --- /dev/null +++ b/packages/DerrickBackend/Sources/DerrickBackend/AgentPluginSpecFetch.swift @@ -0,0 +1,66 @@ +import Foundation +import Structure + +/// Host-forced fetch of the published Agent Plugins Specification before factory build. +enum AgentPluginSpecFetch { + typealias ExecuteTool = VendorDocsFetch.ExecuteTool + + struct Document: Sendable { + let summary: String + let sourceURL: String + let usedLiveFetch: Bool + } + + static func resolve( + workflowID: String, + request: WorkflowStartRequest, + baseContext: ExecutionContextWire, + executeTool: ExecuteTool, + log: (String) async throws -> Void + ) async throws -> Document { + let url = AgentPluginSpec.publishedURL.absoluteString + try await log("Reading the Agent Plugins Specification…") + do { + let crawlResult = try await executeTool( + AllowedMCPTool.webCrawl.rawValue, + try crawlArguments(startURL: url), + baseContext, + request.principal, + request.helperAPIKey, + request.helperReviewerModelJSON, + workflowID, + "spec" + ) + if !crawlResult.isError, + let summary = AgentPluginSpec.summary(fromCrawlToolText: crawlResult.text), + !summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try await log("Using the live Agent Plugins Specification.") + return Document(summary: summary, sourceURL: url, usedLiveFetch: true) + } + try await log("Live specification page was empty; using bundled fallback (prefer live next time).") + } catch { + try await log("Could not fetch the live specification; using bundled fallback (prefer live next time).") + } + return Document( + summary: AgentPluginSpec.bundledFallbackSummary(), + sourceURL: url, + usedLiveFetch: false + ) + } + + private static func crawlArguments(startURL: String) throws -> String { + let payload: [String: Any] = [ + "start_url": startURL, + "goal": """ + Extract the normative Agent Plugins package requirements: plugin.json, component discovery, \ + skills (SKILL.md required), optional references, and client conformance notes. \ + Prefer concise rules over marketing text. + """, + "max_pages": 2, + "max_depth": 0, + "timeout_seconds": 120, + ] + let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } +} diff --git a/packages/DerrickBackend/Sources/DerrickBackend/PluginFactoryCreateWorkflow.swift b/packages/DerrickBackend/Sources/DerrickBackend/PluginFactoryCreateWorkflow.swift index e7d1828a..3b7f87d0 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/PluginFactoryCreateWorkflow.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/PluginFactoryCreateWorkflow.swift @@ -2,6 +2,9 @@ import DBRepository import Foundation import Structure +/// Host-orchestrated plugin create: validate → Agent Plugin spec → vendor docs → factory build → promote. +/// Builder/review/trial still run inside `plugin_factory_build`, but the workflow logs finer stages and +/// maps tool failures to the matching stage for the UI. enum PluginFactoryCreateWorkflow { private struct FactoryBuildResult: Decodable { let ok: Bool? @@ -19,22 +22,30 @@ enum PluginFactoryCreateWorkflow { } } + typealias ExecuteTool = ( + String, + String, + ExecutionContextWire, + ServicePrincipal, + String?, + String?, + String, + String + ) async throws -> MCPToolCallResultDTO + static func run( workflowID: String, request: WorkflowStartRequest, baseContext: ExecutionContextWire, repositoryProvider: @escaping @Sendable () async throws -> DBRepository, - executeTool: @escaping ( - String, - String, - ExecutionContextWire, - ServicePrincipal, - String?, - String?, - String, - String - ) async throws -> MCPToolCallResultDTO + executeTool: @escaping ExecuteTool ) async throws { + try await log( + workflowID: workflowID, + stage: "validate", + message: "Checking plugin create inputs…", + repositoryProvider: repositoryProvider + ) let input = try PluginFactoryCreateInput.decodeJSON(request.inputJSON) guard input.pluginType == .connector || input.pluginType == .custom else { try await fail( @@ -64,12 +75,28 @@ enum PluginFactoryCreateWorkflow { return } + let spec = try await AgentPluginSpecFetch.resolve( + workflowID: workflowID, + request: request, + baseContext: baseContext, + executeTool: executeTool, + log: { message in + try await log( + workflowID: workflowID, + stage: "spec", + message: message, + repositoryProvider: repositoryProvider + ) + } + ) + if input.pluginType == .custom { try await runCustomBuild( workflowID: workflowID, request: request, input: input, pluginID: pluginID, + spec: spec, baseContext: baseContext, repositoryProvider: repositoryProvider, executeTool: executeTool @@ -180,13 +207,21 @@ enum PluginFactoryCreateWorkflow { try await log( workflowID: workflowID, - stage: "factory", - message: "Building \(vendor.displayName) connector — waiting on the plugin builder, then tests and safety review…", + stage: "skill", + message: "Writing SKILL.md from the Agent Plugin spec…", + repositoryProvider: repositoryProvider + ) + try await log( + workflowID: workflowID, + stage: "builder", + message: "Building \(vendor.displayName) connector — draft, trial, then safety review…", repositoryProvider: repositoryProvider ) let goal = input.connectorBuildGoal( crawlSummary: crawlSummary, - inboxAPISummary: inboxAPISummary + inboxAPISummary: inboxAPISummary, + agentPluginSpecSummary: spec.summary, + agentPluginSpecSourceURL: spec.sourceURL ) let buildArgs = try buildArguments(goal: goal, hostManifest: input.hostManifest) let buildResult = try await executeTool( @@ -197,13 +232,16 @@ enum PluginFactoryCreateWorkflow { request.helperAPIKey, request.helperReviewerModelJSON, workflowID, - "factory" + "builder" ) if buildResult.isError { try await fail( workflowID: workflowID, - stage: "factory", - message: userFacingToolError(buildResult, fallback: "Plugin factory could not finish building the connector."), + stage: failureStage(from: buildResult, fallback: "builder"), + message: userFacingToolError( + buildResult, + fallback: "Plugin factory could not finish building the connector." + ), repositoryProvider: repositoryProvider ) return @@ -220,13 +258,19 @@ enum PluginFactoryCreateWorkflow { ?? "Plugin factory did not return a saved connector." try await fail( workflowID: workflowID, - stage: "factory", + stage: "builder", message: PluginFactoryCreateFailureMessage.userFacing(raw), repositoryProvider: repositoryProvider ) return } + try await log( + workflowID: workflowID, + stage: "promote", + message: "Saving /\(pluginID)…", + repositoryProvider: repositoryProvider + ) let resultJSON = try JSONEncoder.service.encode( PluginFactoryCreateResult( pluginID: pluginID, @@ -249,26 +293,27 @@ enum PluginFactoryCreateWorkflow { request: WorkflowStartRequest, input: PluginFactoryCreateInput, pluginID: String, + spec: AgentPluginSpecFetch.Document, baseContext: ExecutionContextWire, repositoryProvider: @escaping @Sendable () async throws -> DBRepository, - executeTool: @escaping ( - String, - String, - ExecutionContextWire, - ServicePrincipal, - String?, - String?, - String, - String - ) async throws -> MCPToolCallResultDTO + executeTool: @escaping ExecuteTool ) async throws { try await log( workflowID: workflowID, - stage: "factory", - message: "Writing SKILL.md, building the guest program, and running trial tests…", + stage: "skill", + message: "Writing SKILL.md from the Agent Plugin spec…", + repositoryProvider: repositoryProvider + ) + try await log( + workflowID: workflowID, + stage: "builder", + message: "Building the guest program and running trial tests…", repositoryProvider: repositoryProvider ) - let goal = input.customBuildGoal() + let goal = input.customBuildGoal( + agentPluginSpecSummary: spec.summary, + agentPluginSpecSourceURL: spec.sourceURL + ) let buildArgs = try buildArguments(goal: goal, hostManifest: nil) let buildResult = try await executeTool( AllowedMCPTool.pluginFactoryBuild.rawValue, @@ -278,13 +323,16 @@ enum PluginFactoryCreateWorkflow { request.helperAPIKey, request.helperReviewerModelJSON, workflowID, - "factory" + "builder" ) if buildResult.isError { try await fail( workflowID: workflowID, - stage: "factory", - message: userFacingToolError(buildResult, fallback: "Plugin factory could not finish building the plugin."), + stage: failureStage(from: buildResult, fallback: "builder"), + message: userFacingToolError( + buildResult, + fallback: "Plugin factory could not finish building the plugin." + ), repositoryProvider: repositoryProvider ) return @@ -301,13 +349,19 @@ enum PluginFactoryCreateWorkflow { ?? "Plugin factory did not return a saved plugin." try await fail( workflowID: workflowID, - stage: "factory", + stage: "builder", message: PluginFactoryCreateFailureMessage.userFacing(raw), repositoryProvider: repositoryProvider ) return } + try await log( + workflowID: workflowID, + stage: "promote", + message: "Saving /\(savedID)…", + repositoryProvider: repositoryProvider + ) let resultJSON = try JSONEncoder.service.encode( PluginFactoryCreateResult( pluginID: savedID, @@ -345,6 +399,24 @@ enum PluginFactoryCreateWorkflow { return try? JSONDecoder.service.decode(FactoryBuildResult.self, from: data) } + private static func failureStage(from result: MCPToolCallResultDTO, fallback: String) -> String { + guard let outcome = ToolExecutionOutcome.decode(from: result.text) else { + return fallback + } + switch outcome.stage { + case .review: + return "review" + case .validation: + return "package" + case .compilation: + return "package" + case .execution: + return "trial" + case .none, .network, .timeout, .persistence: + return fallback + } + } + private static func userFacingToolError(_ result: MCPToolCallResultDTO, fallback: String) -> String { if let outcome = ToolExecutionOutcome.decode(from: result.text), let summary = outcome.failureSummary?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/packages/DerrickBackend/Sources/DerrickBackend/PluginMessagingIngressAdapter.swift b/packages/DerrickBackend/Sources/DerrickBackend/PluginMessagingIngressAdapter.swift index a6b706ab..5cf47351 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/PluginMessagingIngressAdapter.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/PluginMessagingIngressAdapter.swift @@ -16,12 +16,7 @@ public final class PluginMessagingIngressAdapter: MessagingIngressAdapter, @unch } public func hasCredentials() -> Bool { - for fieldID in ["bot_token", "token", "api_key"] { - if PluginSecretResolver.resolve(pluginID: pluginID, fieldID: fieldID) != nil { - return true - } - } - return false + PluginSecretResolver.hasCallCredential(pluginID: pluginID) } public func syncThreads(repository: DBRepository) async throws { diff --git a/packages/DerrickBackend/Tests/DerrickBackendTests/WorkflowIntegrationTests.swift b/packages/DerrickBackend/Tests/DerrickBackendTests/WorkflowIntegrationTests.swift index 5b1d8f5f..85382f6b 100644 --- a/packages/DerrickBackend/Tests/DerrickBackendTests/WorkflowIntegrationTests.swift +++ b/packages/DerrickBackend/Tests/DerrickBackendTests/WorkflowIntegrationTests.swift @@ -57,7 +57,12 @@ import Testing text: outcome ) case "web.crawl": - let pages = #"{"pages":[{"url":"https://api.slack.com/docs","title":"Slack","text":"auth"}]}"# + let pages: String + if call.argumentsJSON.contains("agent-plugins.org") { + pages = #"{"pages":[{"url":"https://agent-plugins.org/specification","title":"Spec","text":"plugin.json and skills/SKILL.md are required."}]}"# + } else { + pages = #"{"pages":[{"url":"https://api.slack.com/docs","title":"Slack","text":"auth"}]}"# + } let outcome = try ToolExecutionOutcome.completed( output: ToolExecutionOutcome.Output(format: .json, value: pages) ).encodedJSON() @@ -125,7 +130,12 @@ import Testing InProcessServiceBridges.mcpCallTool = { request in switch request.toolName { case "web.crawl": - let pages = #"{"pages":[{"url":"https://api.slack.com/docs","title":"Slack","text":"auth"}]}"# + let pages: String + if request.argumentsJSON.contains("agent-plugins.org") { + pages = #"{"pages":[{"url":"https://agent-plugins.org/specification","title":"Spec","text":"plugin.json and skills/SKILL.md are required."}]}"# + } else { + pages = #"{"pages":[{"url":"https://api.slack.com/docs","title":"Slack","text":"auth"}]}"# + } let outcome = try ToolExecutionOutcome.completed( output: ToolExecutionOutcome.Output(format: .json, value: pages) ).encodedJSON() diff --git a/packages/MCPServer/Package.swift b/packages/MCPServer/Package.swift index 53e52ea3..175e8dfa 100644 --- a/packages/MCPServer/Package.swift +++ b/packages/MCPServer/Package.swift @@ -22,10 +22,6 @@ let package = Package( name: "SlackConnectorE2EHarness", targets: ["SlackConnectorE2EHarness"] ), - .executable( - name: "SlackConnectorInstallReference", - targets: ["SlackConnectorInstallReference"] - ), .executable( name: "SlackConnectorBootstrapProbe", targets: ["SlackConnectorBootstrapProbe"] @@ -117,18 +113,6 @@ let package = Package( ], path: "Sources/SlackConnectorLiveHarness" ), - .executableTarget( - name: "SlackConnectorInstallReference", - dependencies: [ - "FactoryHarnessSupport", - "MCPServer", - "Plugin", - "Structure", - "DBRepository", - "DerrickBackend", - ], - path: "Sources/SlackConnectorInstallReference" - ), .executableTarget( name: "SlackConnectorBootstrapProbe", dependencies: [ diff --git a/packages/MCPServer/Sources/FactoryHarnessSupport/HarnessSecretAttacher.swift b/packages/MCPServer/Sources/FactoryHarnessSupport/HarnessSecretAttacher.swift index 92bd71d1..4b6d2660 100644 --- a/packages/MCPServer/Sources/FactoryHarnessSupport/HarnessSecretAttacher.swift +++ b/packages/MCPServer/Sources/FactoryHarnessSupport/HarnessSecretAttacher.swift @@ -10,10 +10,8 @@ public struct HarnessSecretAttacher: HostHTTPSecretAttacher { } public func apply(url: URL) async -> (url: URL, headers: [String: String]) { - for fieldID in ["bot_token", "token", "api_key"] { - if let token = PluginSecretResolver.resolve(pluginID: pluginID, fieldID: fieldID) { - return (url, ["Authorization": "Bearer \(token)"]) - } + if let token = PluginSecretResolver.resolveCallCredential(pluginID: pluginID) { + return (url, ["Authorization": "Bearer \(token)"]) } return (url, [:]) } diff --git a/packages/MCPServer/Sources/FactoryHarnessSupport/LegacySlackConnectorPurge.swift b/packages/MCPServer/Sources/FactoryHarnessSupport/LegacySlackConnectorPurge.swift deleted file mode 100644 index 357e7479..00000000 --- a/packages/MCPServer/Sources/FactoryHarnessSupport/LegacySlackConnectorPurge.swift +++ /dev/null @@ -1,31 +0,0 @@ -import DBRepository -import Foundation -import Structure - -/// Deletes factory releases and messaging connectors for legacy Slack reference installs. -public enum LegacySlackConnectorPurge: Sendable { - /// Returns how many factory release rows were deleted. - @discardableResult - public static func run(repository: DBRepository) async throws -> Int { - let summaries = try await repository.listPluginFactoryReleaseSummaries() - var pluginIDs = Set() - for summary in summaries where PluginFactoryLegacyPurge.isLegacySlackPluginID(summary.pluginID) { - pluginIDs.insert(summary.pluginID) - } - var deletedReleases = 0 - for pluginID in pluginIDs.sorted() { - let before = summaries.filter { $0.pluginID == pluginID }.count - try await repository.deletePluginFactoryRelease(pluginID: pluginID) - deletedReleases += before - } - - let connectors = try await repository.listMessagingConnectors() - let keep = Set( - connectors - .map(\.pluginID) - .filter { !PluginFactoryLegacyPurge.isLegacySlackPluginID($0) } - ) - try await repository.pruneMessagingConnectors(keeping: keep) - return deletedReleases - } -} diff --git a/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift b/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift deleted file mode 100644 index cd304c17..00000000 --- a/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift +++ /dev/null @@ -1,35 +0,0 @@ -import DBRepository -import DerrickBackend -import FactoryHarnessSupport -import Foundation -import Structure - -/// One-shot cleanup: removes legacy reference Slack factory releases and messaging connectors. -@main -enum SlackConnectorInstallReference { - static func main() async { - do { - try await purge() - fputs("SlackConnectorInstallReference: purged legacy Slack reference connectors.\n", stderr) - } catch { - fputs("SlackConnectorInstallReference: FAILED — \(error)\n", stderr) - exit(1) - } - } - - private static func purge() async throws { - let directory = try DerrickAppSupport.databaseDirectory() - let repository = DBRepository( - configuration: DBRepositoryConfiguration( - applicationName: DerrickAppSupport.defaultApplicationName, - databaseName: "derrick", - databaseDirectoryURL: directory, - username: "ui", - password: "ui" - ) - ) - _ = try await repository.createEmptyDatabaseIfNeeded(username: "ui", password: "ui") - let removed = try await LegacySlackConnectorPurge.run(repository: repository) - fputs("[purge] removed \(removed) Slack factory release(s)\n", stderr) - } -} diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift index a2c1c146..10b5cc83 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift @@ -269,10 +269,12 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { ) } - /// Factory goal passed to `plugin_factory_build` after vendor docs are crawled. + /// Factory goal passed to `plugin_factory_build` after host-forced spec + vendor docs. public func connectorBuildGoal( crawlSummary: String?, - inboxAPISummary: String? = nil + inboxAPISummary: String? = nil, + agentPluginSpecSummary: String? = nil, + agentPluginSpecSourceURL: String? = nil ) -> String { let vendorLabel = vendor?.displayName ?? customVendorName ?? "messaging" let summary = crawlSummary ?? auth?.crawlSummary @@ -302,6 +304,8 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { vendor: vendor, crawlSummary: summary, inboxAPISummary: inboxAPISummary, + agentPluginSpecSummary: agentPluginSpecSummary, + agentPluginSpecSourceURL: agentPluginSpecSourceURL, reference: extra.joined(separator: "\n"), includeVendorBindings: true ) @@ -314,11 +318,22 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { } } - public func customBuildGoal() -> String { + public func customBuildGoal( + agentPluginSpecSummary: String? = nil, + agentPluginSpecSourceURL: String? = nil + ) -> String { var lines = [ "Create an Agent Plugin capability.", description, ] + if let agentPluginSpecSummary, !agentPluginSpecSummary.isEmpty { + lines.append( + AgentPluginSpec.forcedPromptBlock( + summary: agentPluginSpecSummary, + sourceURL: agentPluginSpecSourceURL + ) + ) + } if let skillMarkdown, !skillMarkdown.isEmpty { lines.append("SKILL.md draft:\n\(skillMarkdown)") } @@ -347,7 +362,7 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { return .preview case "auth", "discover", "credentials": return .credentials - case "crawl", "docs", "factory", "build", "review": + case "crawl", "docs", "factory", "build", "review", "spec", "builder", "trial", "package": return .build default: return .build diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/ConnectorAuthPreference.swift b/packages/Structure/Sources/AppLayerServices/Plugin/ConnectorAuthPreference.swift index 6a8f6428..3d383aa9 100644 --- a/packages/Structure/Sources/AppLayerServices/Plugin/ConnectorAuthPreference.swift +++ b/packages/Structure/Sources/AppLayerServices/Plugin/ConnectorAuthPreference.swift @@ -27,9 +27,11 @@ enum ConnectorAuthPreference: Sendable { } // Derrick cannot run an OAuth install dance. Ask for a call token instead. + // Use `bot_token` so host prompts, Keychain slots, and connector runtime agree + // (daemon/ingress look for bot_token first; api_token alone used to look "missing"). if discovery.authScheme == .oauth || discovery.secrets.contains(where: isInstallCredential) { if let fallback = try? PluginSecretField( - id: "api_token", + id: "bot_token", label: "API token or bot token", kind: .token ) { diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecDraft.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecDraft.swift index 1159c5b5..197e16f4 100644 --- a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecDraft.swift +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecDraft.swift @@ -136,12 +136,9 @@ public struct PluginSpecDraft: Sendable, Hashable, Codable { public func asSkillDraft(pluginName: String = "") -> PluginSkillDraft { let outcome = claimedOutcome ?? "" - var skillTriggers: Set = [] - if triggers.isEmpty { - skillTriggers = [.chat] - } else { - skillTriggers = Set(triggers.map(Self.skillTrigger)) - } + var draftCopy = self + PluginSpecProcession.bindInferredTriggers(onto: &draftCopy) + let skillTriggers = Set(draftCopy.triggers.map(Self.skillTrigger)) let purposeParts = [ outcome, connect.map { "Connect: \($0.klass.rawValue) \($0.detail)" }, @@ -152,7 +149,7 @@ public struct PluginSpecDraft: Sendable, Hashable, Codable { return PluginSkillDraft( goal: outcome, purpose: purposeParts.joined(separator: ". "), - triggers: skillTriggers, + triggers: skillTriggers.isEmpty ? [.chat] : skillTriggers, examples: [ PluginSkillDraft.Example( userSays: outcome.isEmpty ? "Run this plugin" : outcome, diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift index 6967eb6a..8ae0b878 100644 --- a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift @@ -113,7 +113,8 @@ public enum PluginSpecProcession: Sendable { case .slot(.returnPayload): return "What should come back — a brief, a list, a message, a file, an image, or thread items?" case .slot(.trigger): - return "How would you like to run this plugin? You can pick more than one: chat, a job or schedule, typing /name, or from messaging." + // Trigger is inferred from plugin kind; never asked. + return "What would make this the wrong plugin? What must not happen?" case .presentChoice: return "In this chat tab, should this show as readable text, a view you can scan, or a file?" case .wrongness: @@ -287,12 +288,15 @@ public enum PluginSpecProcession: Sendable { if draft.access == .unreachable { return .blocked(.accessUnreachable) } if draft.work == nil { return .slot(.work) } if draft.returnClass == nil { return .slot(.returnPayload) } - if draft.triggers.isEmpty { return .slot(.trigger) } + bindInferredTriggers(onto: &draft) if draft.present == nil { - if case .needsHumanChoice = PluginPresentPolicy.bind(spec: draft) { + switch PluginPresentPolicy.bind(spec: draft) { + case .decided(let present): + draft.present = present + draft.presentSource = .inferred + case .needsHumanChoice: return .presentChoice } - return .wrongness } if draft.wrongness?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { return .wrongness @@ -314,7 +318,7 @@ public enum PluginSpecProcession: Sendable { return PluginAccessAskPolicy.documentationURL(from: draft.connect) } - /// Host binds Present after Return (and Trigger) without asking, unless tied. + /// Host binds Present after Return without asking, unless tied. public static func bindInferredPresent(onto draft: inout PluginSpecDraft) { guard draft.present == nil, draft.returnClass != nil else { return } switch PluginPresentPolicy.bind(spec: draft) { @@ -326,11 +330,27 @@ public enum PluginSpecProcession: Sendable { } } + /// Host fills Trigger from connect/kind. Never asked in Create. + public static func bindInferredTriggers(onto draft: inout PluginSpecDraft) { + guard draft.triggers.isEmpty, draft.connect != nil || draft.returnClass != nil else { return } + draft.triggers = defaultTriggers(for: draft) + } + + /// Triggers that apply for this connect kind — not a user multi-select. + public static func defaultTriggers(for draft: PluginSpecDraft) -> Set { + if draft.isMessagingConnect { + return [.chat, .messaging, .mention] + } + return [.chat, .mention, .schedule] + } + private static func applyParkedBindings(_ session: inout PluginSpecSession) { if session.ask == .accessSecret { + bindInferredTriggers(onto: &session.draft) bindInferredPresent(onto: &session.draft) return } + bindInferredTriggers(onto: &session.draft) bindInferredPresent(onto: &session.draft) var progressed = true while progressed { @@ -341,15 +361,18 @@ public enum PluginSpecProcession: Sendable { session.draft.parked[slot.rawValue] = nil progressed = true } + bindInferredTriggers(onto: &session.draft) bindInferredPresent(onto: &session.draft) } session.ask = nextAsk(&session.draft) + bindInferredTriggers(onto: &session.draft) bindInferredPresent(onto: &session.draft) session.ask = nextAsk(&session.draft) } private static func parkLaterSlots(from text: String, onto draft: inout PluginSpecDraft) { - let later: [PluginSpecSlot] = [.connect, .access, .work, .returnPayload, .trigger] + // Trigger is host-inferred; do not park volunteered trigger answers as a slot. + let later: [PluginSpecSlot] = [.connect, .access, .work, .returnPayload] for slot in later { if extract(slot, from: text) != nil { draft.parked[slot.rawValue] = text @@ -453,9 +476,6 @@ public enum PluginSpecProcession: Sendable { } return "That is not a place Derrick can open. Name a site, a feed, files on this Mac, or an app you already use." } - if case .slot(.trigger) = ask, PluginSpecClassifier.triggers(from: utterance).isEmpty { - return "You can pick more than one: chat, a job or schedule, typing /name, or from messaging." - } return nil } @@ -464,9 +484,9 @@ public enum PluginSpecProcession: Sendable { You fill a finite spec. Ask only the next unfilled legal slot. Received means bound, not merely spoken. - Slots, in order: Connect, Access, Work, Return, Trigger. + Slots, in order: Connect, Access, Work, Return. Oracles, not slots: claimed outcome (first), wrongness (last). - Present is bound by the host after Return. Do not ask for Present unless the host cannot decide. + Present and Trigger are bound by the host after Return. Do not ask for them unless the host cannot decide Present. Park volunteered later answers. Do not jump ahead. """ diff --git a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretDevelopmentSource.swift b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretDevelopmentSource.swift index a740f1c0..22316e9c 100644 --- a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretDevelopmentSource.swift +++ b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretDevelopmentSource.swift @@ -79,7 +79,7 @@ public enum PluginSecretDevelopmentSource: Sendable { // Factory Slack connectors often use slack-connector (or another unused id). // `.env` still documents SLACK_BOT_KEY from the original slack-connection plugin. if trimmedPluginID.localizedCaseInsensitiveContains("slack"), - ["bot_token", "token", "api_key"].contains(trimmedFieldID) { + PluginSecretResolver.callCredentialFieldIDs.contains(trimmedFieldID) { keys.append(contentsOf: ["SLACK_BOT_KEY", "SLACK_BOT_TOKEN"]) if trimmedPluginID != "slack-connection" { keys.append(environmentVariableKey(pluginID: "slack-connection", fieldID: "bot_token")) diff --git a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretKeychain.swift b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretKeychain.swift index d7e39100..ae00aa90 100644 --- a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretKeychain.swift +++ b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretKeychain.swift @@ -101,7 +101,71 @@ public enum PluginSecretKeychain: Sendable { } } + /// When a declared call-credential field is empty, copy from a sibling alias + /// (`api_token` → `bot_token`, etc.) so older creates keep working. + public static func migrateCallCredentialAliases( + pluginID: String, + fields: [PluginSecretDescriptor] + ) { + let aliases = PluginSecretResolver.callCredentialFieldIDs + for field in fields { + guard aliases.contains(field.id), + !hasKeychainValue(pluginID: pluginID, fieldID: field.id) + else { continue } + for alias in aliases where alias != field.id { + guard let value = try? loadFromKeychain(pluginID: pluginID, fieldID: alias) + else { continue } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + try? save(pluginID: pluginID, fieldID: field.id, value: trimmed) + break + } + } + } + public static func deleteForTesting(pluginID: String, fieldID: String) { + deleteStoredSecret(pluginID: pluginID, fieldID: fieldID) + } + + /// Removes every stored secret for a plugin (shared app-group files + Keychain). + /// Pass `fieldIDs` from the manifest when known; files matching the plugin prefix are + /// always scanned so leftover fields from older installs are not orphaned. + public static func deleteAllStoredSecrets(pluginID: String, fieldIDs: [String] = []) { + let trimmed = pluginID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + var ids = Set(fieldIDs.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }) + ids.formUnion(discoveredSharedStoreFieldIDs(pluginID: trimmed)) + // Common slots even when no file / manifest was found. + for fallback in ["bot_token", "api_token", "api_key", "password", "username", "token"] { + ids.insert(fallback) + } + for fieldID in ids { + deleteStoredSecret(pluginID: trimmed, fieldID: fieldID) + } + } + + /// Best-effort wipe of shared secret files that no longer map to an installed plugin. + public static func deleteOrphanedSharedSecrets(keepingPluginIDs: Set) { + guard let directory = try? sharedStoreDirectory() else { return } + let prefix = "\(accountPrefix)_" + let entries = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + )) ?? [] + for url in entries { + let name = url.lastPathComponent + guard name.hasPrefix(prefix) else { continue } + guard let pluginID = pluginIDFromSharedStoreFilename(name) else { + try? FileManager.default.removeItem(at: url) + continue + } + if !keepingPluginIDs.contains(pluginID) { + try? FileManager.default.removeItem(at: url) + } + } + } + + private static func deleteStoredSecret(pluginID: String, fieldID: String) { if let url = try? sharedStoreURL(pluginID: pluginID, fieldID: fieldID) { try? FileManager.default.removeItem(at: url) } @@ -116,6 +180,39 @@ public enum PluginSecretKeychain: Sendable { } } + private static func discoveredSharedStoreFieldIDs(pluginID: String) -> Set { + guard let directory = try? sharedStoreDirectory() else { return [] } + // Filename encoding replaces `:` and `/` in the full account string. + // account(pluginID, "") ends with `/` → encoded prefix ends with `_`. + let encodedPrefix = account(pluginID: pluginID, fieldID: "") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "_") + let entries = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + )) ?? [] + var fields = Set() + for url in entries { + let name = url.lastPathComponent + guard name.hasPrefix(encodedPrefix) else { continue } + let field = String(name.dropFirst(encodedPrefix.count)) + if !field.isEmpty { + fields.insert(field) + } + } + return fields + } + + private static func pluginIDFromSharedStoreFilename(_ name: String) -> String? { + // plugin-secret__ (':' and '/' already '_') + let prefix = "\(accountPrefix)_" + guard name.hasPrefix(prefix) else { return nil } + let rest = String(name.dropFirst(prefix.count)) + guard let split = rest.lastIndex(of: "_") else { return nil } + let pluginID = String(rest[.. [String] { let services = [ DerrickAppSupport.hostAppBundleIdentifier, diff --git a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretResolver.swift b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretResolver.swift index 1ac826fc..30e02280 100644 --- a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretResolver.swift +++ b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PluginSecretResolver.swift @@ -2,6 +2,17 @@ import Foundation /// Resolves declared plugin secrets from Keychain, with a development `.env` escape hatch. public enum PluginSecretResolver: Sendable { + /// Field ids the host and daemon accept as an HTTP call credential. + /// Create-time auth preference may store `api_token` when docs look OAuth-only; + /// Slack runtime historically looked only for `bot_token`. + public static let callCredentialFieldIDs: [String] = [ + "bot_token", + "token", + "api_key", + "api_token", + "access_token", + ] + public static func resolve(pluginID: String, fieldID: String) -> String? { if let development = PluginSecretDevelopmentSource.resolve( pluginID: pluginID, @@ -18,4 +29,18 @@ public enum PluginSecretResolver: Sendable { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } + + /// First non-empty call credential under any known field id. + public static func resolveCallCredential(pluginID: String) -> String? { + for fieldID in callCredentialFieldIDs { + if let value = resolve(pluginID: pluginID, fieldID: fieldID) { + return value + } + } + return nil + } + + public static func hasCallCredential(pluginID: String) -> Bool { + resolveCallCredential(pluginID: pluginID) != nil + } } diff --git a/packages/Structure/Sources/Contract/ConnectorContractPrompts.swift b/packages/Structure/Sources/Contract/ConnectorContractPrompts.swift index 341c29e6..4f1bfb35 100644 --- a/packages/Structure/Sources/Contract/ConnectorContractPrompts.swift +++ b/packages/Structure/Sources/Contract/ConnectorContractPrompts.swift @@ -41,6 +41,8 @@ public enum ConnectorContractPrompts: Sendable { vendor: PluginFactoryCreateInput.ConnectorVendor?, crawlSummary: String?, inboxAPISummary: String? = nil, + agentPluginSpecSummary: String? = nil, + agentPluginSpecSourceURL: String? = nil, reference: String?, includeVendorBindings: Bool = true ) throws -> String { @@ -51,21 +53,35 @@ public enum ConnectorContractPrompts: Sendable { "Create an Agent Plugin messaging connector for \(vendorLabel).", "Scope id: \(scopeID)", "Implement messaging_ops: \(scopeSpec.ops.map { "\"\($0)\"" }.joined(separator: ", ")).", + ] + if let agentPluginSpecSummary, !agentPluginSpecSummary.isEmpty { + parts.append( + AgentPluginSpec.forcedPromptBlock( + summary: agentPluginSpecSummary, + sourceURL: agentPluginSpecSourceURL + ) + ) + } + parts.append( try dump( scopeID: scopeID, vendorName: includeVendorBindings ? vendor?.rawValue : nil, preamble: "Obey this protocol JSON. Do not add ops or vendor calls outside it. Vendor HTTP bindings are host facts; use those URLs." - ), + ) + ) + parts.append( """ test_input_json must include a hops array with http_results fixtures that exercise every messaging_op you implement \ (\(scopeSpec.ops.joined(separator: ", "))) through to result.emit. - """, + """ + ) + parts.append( PluginFactoryCreateInput.defaultDescription( vendor: vendor, customVendorName: vendor == .custom ? vendorLabel : nil, scope: scope - ), - ] + ) + ) if let reference, !reference.isEmpty { parts.append(reference) } diff --git a/packages/Structure/Sources/Plugin/Factory/AgentPluginSpec.swift b/packages/Structure/Sources/Plugin/Factory/AgentPluginSpec.swift new file mode 100644 index 00000000..1201deab --- /dev/null +++ b/packages/Structure/Sources/Plugin/Factory/AgentPluginSpec.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Canonical Agent Plugins package format — always prefer the live published document. +public enum AgentPluginSpec: Sendable { + /// Published specification (source of truth). + public static let publishedURL = URL(string: "https://agent-plugins.org/specification")! + + public static let preferLatestDisclaimer = """ + Prefer the latest published Agent Plugins Specification over any cached or bundled copy. \ + If this summary disagrees with https://agent-plugins.org/specification, follow the live document. + """ + + /// Condensed package rules used only when the live fetch fails. + public static func bundledFallbackSummary() -> String { + """ + Agent Plugins package model (bundled fallback — verify against the live spec): + - Distributable plugin is a directory (or archive) with a root plugin.json manifest. + - Manifest names the plugin and declares components (skills, agents, commands, hooks, mcpServers, etc.). + - Skills live under skills// and MUST include SKILL.md (YAML frontmatter name + description, then instructions). + - Optional progressive-disclosure files may live under skills//references/. + - Clients discover components from the manifest; do not invent a proprietary package layout. + - Derrick host writes plugin.json for connectors; guest Go + required SKILL.md still ship in the package. + """ + } + + /// Host-forced block injected into builder/reviewer goals. + public static func forcedPromptBlock(summary: String, sourceURL: String?) -> String { + let clipped = String(summary.prefix(6_000)) + let source = sourceURL ?? publishedURL.absoluteString + return """ + --- Agent Plugins Specification (host-enforced) --- + \(preferLatestDisclaimer) + Source: \(source) + + \(clipped) + --- end Agent Plugins Specification --- + Obey this package model. Include at least one skills//SKILL.md. Do not invent app.derrick/runtime.json. + """ + } + + /// Extract usable text from a web.crawl tool result for the specification page. + public static func summary(fromCrawlToolText text: String) -> String? { + guard let data = text.data(using: .utf8) else { return nil } + if let outcome = ToolExecutionOutcome.decode(from: text), + let value = outcome.output?.value { + return summary(fromCrawlPayload: value) + } + return summary(fromCrawlPayload: text) + } + + private static func summary(fromCrawlPayload payload: String) -> String? { + guard let data = payload.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : String(trimmed.prefix(6_000)) + } + if let pages = object["pages"] as? [[String: Any]] { + let chunks = pages.compactMap { page -> String? in + let title = (page["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let body = (page["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if title.isEmpty, body.isEmpty { return nil } + if title.isEmpty { return body } + if body.isEmpty { return title } + return "\(title)\n\(body)" + } + let joined = chunks.joined(separator: "\n\n").trimmingCharacters(in: .whitespacesAndNewlines) + return joined.isEmpty ? nil : String(joined.prefix(6_000)) + } + if let text = object["text"] as? String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : String(trimmed.prefix(6_000)) + } + return nil + } +} diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryLegacyPurge.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryLegacyPurge.swift deleted file mode 100644 index fcda6b13..00000000 --- a/packages/Structure/Sources/Plugin/Factory/PluginFactoryLegacyPurge.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -/// Identifies and removes legacy Slack reference connector installs from the factory store. -public enum PluginFactoryLegacyPurge: Sendable { - public static func isLegacySlackPluginID(_ pluginID: String) -> Bool { - pluginID.lowercased().contains("slack") - } -} diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift index a34ca887..13a5c9ff 100644 --- a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift @@ -95,7 +95,12 @@ public struct PluginFactoryManifestInput: Sendable, Hashable { self.permissions = permissions .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - self.secrets = Self.normalizedSecrets(pluginID: pluginID, secrets: secrets, role: role) + self.secrets = Self.normalizedSecrets( + pluginID: pluginID, + secrets: secrets, + role: role, + authScheme: authScheme + ) self.authScheme = DerrickExtensionPointers.resolvedAuthScheme( declared: authScheme, role: role, @@ -122,15 +127,66 @@ public struct PluginFactoryManifestInput: Sendable, Hashable { ) } - /// Slack connectors always declare `bot_token` so the host can prompt or read `.env`. + /// Prefer one call-credential field id per auth scheme so create and runtime agree. private static func normalizedSecrets( pluginID: String, secrets: [PluginSecretField], - role: PluginRole + role: PluginRole, + authScheme: ConnectorAuthScheme? ) -> [PluginSecretField] { - if !secrets.isEmpty { return secrets } guard role.isConnector else { return secrets } - return PluginSecretField.resolvedFields(pluginID: pluginID, declared: secrets) + let canonical = canonicalizeCallCredentialSecrets(secrets, authScheme: authScheme) + if !canonical.isEmpty { return canonical } + return PluginSecretField.resolvedFields(pluginID: pluginID, declared: canonical) + } + + private static func canonicalizeCallCredentialSecrets( + _ secrets: [PluginSecretField], + authScheme: ConnectorAuthScheme? + ) -> [PluginSecretField] { + guard let preferredID = preferredCallCredentialFieldID(for: authScheme) else { + return secrets + } + let callIDs = Set(PluginSecretResolver.callCredentialFieldIDs) + guard secrets.contains(where: { callIDs.contains($0.id) }) else { + return secrets + } + if secrets.contains(where: { $0.id == preferredID }) { + return secrets.filter { $0.id == preferredID || !callIDs.contains($0.id) } + } + var result: [PluginSecretField] = [] + var replaced = false + for secret in secrets { + if callIDs.contains(secret.id) { + if replaced { continue } + if let preferred = try? PluginSecretField( + id: preferredID, + label: secret.label, + kind: secret.kind + ) { + result.append(preferred) + } else { + result.append(secret) + } + replaced = true + } else { + result.append(secret) + } + } + return result + } + + private static func preferredCallCredentialFieldID( + for authScheme: ConnectorAuthScheme? + ) -> String? { + switch authScheme { + case .botToken, .oauth: + return "bot_token" + case .apiKey: + return "api_key" + case .basic, .none: + return nil + } } public func encodedJSON() throws -> String { diff --git a/packages/Structure/Sources/Plugin/HTTP/SlackUserDisplayNameResolver.swift b/packages/Structure/Sources/Plugin/HTTP/SlackUserDisplayNameResolver.swift index 8c6bb270..4e34340c 100644 --- a/packages/Structure/Sources/Plugin/HTTP/SlackUserDisplayNameResolver.swift +++ b/packages/Structure/Sources/Plugin/HTTP/SlackUserDisplayNameResolver.swift @@ -55,12 +55,7 @@ public enum SlackUserDisplayNameResolver: Sendable { } static func resolveBotToken(pluginID: String) -> String? { - for fieldID in ["bot_token", "token", "api_key"] { - if let value = PluginSecretResolver.resolve(pluginID: pluginID, fieldID: fieldID) { - return value - } - } - return nil + PluginSecretResolver.resolveCallCredential(pluginID: pluginID) } static func fetchDisplayName(userID: String, token: String) async -> String? { diff --git a/packages/Structure/Tests/StructureTests/AgentPluginSpecTests.swift b/packages/Structure/Tests/StructureTests/AgentPluginSpecTests.swift new file mode 100644 index 00000000..12f70b3d --- /dev/null +++ b/packages/Structure/Tests/StructureTests/AgentPluginSpecTests.swift @@ -0,0 +1,28 @@ +import Testing +import Structure + +@Suite struct AgentPluginSpecTests { + @Test func forcedBlockPrefersLatestDisclaimer() { + let block = AgentPluginSpec.forcedPromptBlock( + summary: "Skills require SKILL.md", + sourceURL: AgentPluginSpec.publishedURL.absoluteString + ) + #expect(block.contains("Prefer the latest published")) + #expect(block.contains("agent-plugins.org/specification")) + #expect(block.contains("Skills require SKILL.md")) + #expect(block.contains("skills//SKILL.md")) + } + + @Test func bundledFallbackMentionsManifestAndSkills() { + let summary = AgentPluginSpec.bundledFallbackSummary() + #expect(summary.contains("plugin.json")) + #expect(summary.contains("SKILL.md")) + } + + @Test func summaryParsesCrawlPagesPayload() { + let payload = #"{"pages":[{"title":"Agent Plugins","text":"plugin.json is required. Skills need SKILL.md."}]}"# + let summary = AgentPluginSpec.summary(fromCrawlToolText: payload) + #expect(summary?.contains("plugin.json") == true) + #expect(summary?.contains("SKILL.md") == true) + } +} diff --git a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift index 3c68308c..6ea50bd7 100644 --- a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift +++ b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift @@ -502,6 +502,42 @@ import Testing #expect(try PluginSecretKeychain.load(pluginID: destID, fieldID: "bot_token") == "legacy-token") } + @Test func pluginSecretResolverAcceptsApiTokenCallCredentialAlias() throws { + let pluginID = "test-api-token-alias-\(UUID().uuidString)" + defer { + PluginSecretKeychain.deleteForTesting(pluginID: pluginID, fieldID: "api_token") + PluginSecretKeychain.deleteForTesting(pluginID: pluginID, fieldID: "bot_token") + } + try PluginSecretKeychain.save( + pluginID: pluginID, + fieldID: "api_token", + value: "xoxb-create" + ) + #expect(PluginSecretResolver.hasCallCredential(pluginID: pluginID)) + #expect(PluginSecretResolver.resolveCallCredential(pluginID: pluginID) == "xoxb-create") + + let fields = [PluginSecretDescriptor(id: "bot_token", label: "Bot token", kind: "token")] + PluginSecretKeychain.migrateCallCredentialAliases(pluginID: pluginID, fields: fields) + #expect(try PluginSecretKeychain.load(pluginID: pluginID, fieldID: "bot_token") == "xoxb-create") + } + + @Test func hostManifestRewritesCallCredentialAliasToSchemeField() throws { + let auth = ConnectorAuthDiscovery( + authScheme: .botToken, + secrets: [ + try PluginSecretField(id: "api_token", label: "API token or bot token", kind: .token), + ], + setupHint: nil, + crawlSummary: nil + ) + let manifest = PluginFactoryManifestInput.connector( + pluginID: "messaging-connector-9", + description: "Messaging connector", + auth: auth + ) + #expect(manifest.secrets.map(\.id) == ["bot_token"]) + } + @Test func pluginSecretKeychainSharedStoreIsReadableAfterSave() throws { let pluginID = "test-shared-store-\(UUID().uuidString)" defer { diff --git a/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift b/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift index 5ac70794..def66609 100644 --- a/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift +++ b/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift @@ -79,6 +79,21 @@ import Testing #expect(ConnectorContractPrompts.builderGuide(forUserGoal: goal).contains("--- \(GuestContract.Schema.connectorParams.rawValue) ---")) } + @Test func factoryGoalInjectsHostForcedAgentPluginSpec() throws { + let goal = try ConnectorContractPrompts.factoryGoal( + vendorLabel: "Slack", + scope: .fullSync, + vendor: .slack, + crawlSummary: nil, + agentPluginSpecSummary: "Skills require SKILL.md", + agentPluginSpecSourceURL: AgentPluginSpec.publishedURL.absoluteString, + reference: nil + ) + #expect(goal.contains("Prefer the latest published")) + #expect(goal.contains("Skills require SKILL.md")) + #expect(goal.contains("agent-plugins.org/specification")) + } + @Test func legacySendOnlyInputStillBuildsFullSyncGoal() throws { let sendOnly = """ {"pluginType":"connector","vendor":"slack","scope":"send_only","description":"x"} diff --git a/packages/Structure/Tests/StructureTests/PluginFactoryLegacyPurgeTests.swift b/packages/Structure/Tests/StructureTests/PluginFactoryLegacyPurgeTests.swift deleted file mode 100644 index ebe147a1..00000000 --- a/packages/Structure/Tests/StructureTests/PluginFactoryLegacyPurgeTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Testing -import Structure - -@Suite struct PluginFactoryLegacyPurgeTests { - @Test func matchesSlackPluginIDs() { - #expect(PluginFactoryLegacyPurge.isLegacySlackPluginID("slack-connection")) - #expect(PluginFactoryLegacyPurge.isLegacySlackPluginID("Slack-Bot")) - #expect(PluginFactoryLegacyPurge.isLegacySlackPluginID("my-slack-helper")) - #expect(!PluginFactoryLegacyPurge.isLegacySlackPluginID("discord-bot")) - #expect(!PluginFactoryLegacyPurge.isLegacySlackPluginID("weather-tool")) - } -} diff --git a/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift b/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift index b0c5a00c..90a92bce 100644 --- a/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift +++ b/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift @@ -65,11 +65,12 @@ import Testing _ = PluginSpecProcession.advance(session: &session, utterance: "A short brief of my notes") _ = PluginSpecProcession.advance(session: &session, utterance: "files on this Mac") _ = PluginSpecProcession.advance(session: &session, utterance: "yes I can open them") + // Claimed outcome already parked Return ("brief"); after Work, triggers + Present bind + // and the procession asks wrongness without a Trigger question. _ = PluginSpecProcession.advance(session: &session, utterance: "summarize them") - _ = PluginSpecProcession.advance(session: &session, utterance: "a brief") - _ = PluginSpecProcession.advance(session: &session, utterance: "when I ask in chat") #expect(session.draft.present == .conversation) #expect(session.draft.presentSource == .inferred) + #expect(session.draft.triggers == [.chat, .mention, .schedule]) #expect(session.ask == .wrongness) let done = PluginSpecProcession.advance(session: &session, utterance: "nothing, that is fine") #expect(done.isComplete) @@ -81,46 +82,39 @@ import Testing _ = PluginSpecProcession.advance(session: &session, utterance: "Read my Slack inbox") _ = PluginSpecProcession.advance(session: &session, utterance: "Slack") _ = PluginSpecProcession.advance(session: &session, utterance: "yes I am logged in") + // "list my channels" binds Work and parks Return (.list); triggers + Present infer next. _ = PluginSpecProcession.advance(session: &session, utterance: "list my channels") - _ = PluginSpecProcession.advance(session: &session, utterance: "thread items") - _ = PluginSpecProcession.advance(session: &session, utterance: "from messaging") #expect(session.draft.present == .thread) - #expect(session.draft.triggers == [.messaging]) + #expect(session.draft.triggers == [.chat, .messaging, .mention]) + #expect(session.ask == .wrongness) } - @Test func triggerAcceptsEveryListedWayToCallThePlugin() { - var session = PluginSpecSession() - session.ask = .slot(.trigger) - session.draft.claimedOutcome = "Slack" - session.draft.connect = PluginConnectBinding(klass: .messagingInbox, detail: "Slack") - session.draft.access = .reachable - session.draft.work = .send - session.draft.returnClass = .message - let turn = PluginSpecProcession.advance( - session: &session, - utterance: "all of the ones you listed" - ) - #expect(session.draft.triggers == Set(PluginTriggerClass.allCases)) - #expect(session.ask != .slot(.trigger)) - #expect(turn.reply.contains("not ready") == false) + @Test func triggersAreInferredForMessagingWithoutAsking() { + var draft = PluginSpecDraft() + draft.claimedOutcome = "Slack" + draft.connect = PluginConnectBinding(klass: .messagingInbox, detail: "Slack") + draft.access = .reachable + draft.work = .send + draft.returnClass = .message + let ask = PluginSpecProcession.nextAsk(&draft) + #expect(draft.triggers == [.chat, .messaging, .mention]) + #expect(ask != .slot(.trigger)) } - @Test func triggerAcceptsChatAndSlashTogether() { - var session = PluginSpecSession() - session.ask = .slot(.trigger) - session.draft.claimedOutcome = "Notes" - session.draft.connect = PluginConnectBinding(klass: .localFiles, detail: "files on this Mac") - session.draft.access = .reachable - session.draft.work = .summarize - session.draft.returnClass = .brief - _ = PluginSpecProcession.advance( - session: &session, - utterance: "when I ask in chat and when I type /name" - ) - #expect(session.draft.triggers == [.chat, .mention]) + @Test func triggersAreInferredForCustomWithoutAsking() { + var draft = PluginSpecDraft() + draft.claimedOutcome = "Summarize notes" + draft.connect = PluginConnectBinding(klass: .localFiles, detail: "files on this Mac") + draft.access = .reachable + draft.work = .summarize + draft.returnClass = .brief + let ask = PluginSpecProcession.nextAsk(&draft) + #expect(draft.triggers == [.chat, .mention, .schedule]) + #expect(ask != .slot(.trigger)) + #expect(ask == .wrongness || ask == .presentChoice) } - @Test func allThreeSoundsGoodBindsEveryTrigger() { + @Test func triggerClassifierStillParsesVolunteeredWays() { #expect( PluginSpecClassifier.triggers(from: "all") == Set(PluginTriggerClass.allCases) @@ -135,7 +129,7 @@ import Testing ) } - @Test func slackScreenshotConversationBindsEveryTriggerAndDoesNotRepeatTheAsk() { + @Test func slackScreenshotConversationSkipsTriggerAskAfterCredentials() { var session = PluginSpecSession() let afterGoal = PluginSpecProcession.advance( session: &session, @@ -183,20 +177,10 @@ import Testing let afterForm = PluginSpecProcession.completeAccessCollection(session: &session) #expect(session.accessSecretsCollected) #expect(session.draft.access == .reachable) - #expect(session.ask == .slot(.trigger)) - #expect(afterForm.reply == "How would you like to run this plugin? You can pick more than one: chat, a job or schedule, typing /name, or from messaging.") - #expect(afterForm.reply.contains("job/schedule") == false) - - let unclear = PluginSpecProcession.advance(session: &session, utterance: "whatever you think") - #expect(session.ask == .slot(.trigger)) - #expect(unclear.reply == "You can pick more than one: chat, a job or schedule, typing /name, or from messaging.") - #expect(unclear.reply.contains("job/schedule") == false) - - let bound = PluginSpecProcession.advance(session: &session, utterance: "all three sounds good") - #expect(session.draft.triggers == Set(PluginTriggerClass.allCases)) + #expect(session.draft.triggers == [.chat, .messaging, .mention]) #expect(session.ask != .slot(.trigger)) - #expect(bound.reply != PluginSpecProcession.question(for: .slot(.trigger))) - #expect(bound.reply != unclear.reply) + #expect(afterForm.reply.contains("How would you like to run") == false) + #expect(session.ask == .wrongness || session.ask == .presentChoice || session.ask == .slot(.work) || session.ask == .slot(.returnPayload)) } @Test func creatorTabTitleUsesDescriptionAndTruncates() { @@ -309,7 +293,7 @@ import Testing ) #expect(oauthOnly.preferringCallCredential().authScheme == .botToken) #expect(oauthOnly.preferringCallCredential().authScheme.isSupportedInWizard) - #expect(oauthOnly.preferringCallCredential().secrets.map(\.id) == ["api_token"]) + #expect(oauthOnly.preferringCallCredential().secrets.map(\.id) == ["bot_token"]) #expect(oauthOnly.preferringCallCredential().secrets.map(\.id).contains("client_id") == false) } @@ -574,7 +558,7 @@ import Testing ) #expect(input.pluginID == "slack-connector-1") #expect(input.auth?.authScheme.isSupportedInWizard == true) - #expect(input.auth?.secrets.map(\.id) == ["api_token"]) + #expect(input.auth?.secrets.map(\.id) == ["bot_token"]) #expect( PluginAccessAskPolicy.credentialFormPrompt(discovery: oauthOnly) .lowercased().contains("client id") == false diff --git a/ui/MCPService/MCPServiceToolHost.swift b/ui/MCPService/MCPServiceToolHost.swift index 5441a8eb..58d775ba 100644 --- a/ui/MCPService/MCPServiceToolHost.swift +++ b/ui/MCPService/MCPServiceToolHost.swift @@ -67,7 +67,10 @@ actor MCPServiceToolHost { code: "plugin_factory" ) if let progress = WorkflowProgressPublisher.userFacingFactoryProgress(from: message) { - await WorkflowProgressPublisher.publish(stage: "factory", message: progress) + await WorkflowProgressPublisher.publish( + stage: WorkflowProgressPublisher.factoryStage(from: message), + message: progress + ) } }, ) diff --git a/ui/MCPService/WorkflowProgressPublisher.swift b/ui/MCPService/WorkflowProgressPublisher.swift index a275e2f3..4ad6cee0 100644 --- a/ui/MCPService/WorkflowProgressPublisher.swift +++ b/ui/MCPService/WorkflowProgressPublisher.swift @@ -26,4 +26,28 @@ enum WorkflowProgressPublisher { static func userFacingFactoryProgress(from logLine: String) -> String? { WorkflowChatProgress.factoryProgressMessage(from: logLine) } + + /// Maps `[plugin_factory]` log lines to workflow stages for the create UI. + static func factoryStage(from logLine: String) -> String { + let line = logLine.lowercased() + if line.contains("review_started") + || line.contains("review_streaming") + || line.contains("review decision") + || line.contains("review rejected") { + return "review" + } + if line.contains("package_started") + || line.contains("packaged_test") { + return "package" + } + if line.contains("direct_test") + || line.contains("draft_ready") { + return "trial" + } + if line.contains("draft_started") + || line.contains("builder_streaming") { + return "builder" + } + return "builder" + } } diff --git a/ui/ui/AgentProfiles/AgentProfileHighlightedText.swift b/ui/ui/AgentProfiles/AgentProfileHighlightedText.swift index c11d385c..bb38f287 100644 --- a/ui/ui/AgentProfiles/AgentProfileHighlightedText.swift +++ b/ui/ui/AgentProfiles/AgentProfileHighlightedText.swift @@ -1,3 +1,4 @@ +import AppKit import Structure import SwiftUI @@ -24,14 +25,16 @@ struct AgentProfileHighlightedText: View { struct MessagingMarkdownText: View { let text: String - var font: Font = .body var baseColor: Color = .primary + var fontSize: CGFloat = 13 var body: some View { - Text(Self.attributed(text)) - .font(font) - .foregroundStyle(baseColor) - .textSelection(.enabled) + let attributed = Self.attributed(text) + SelectableLinkTextView( + attributedString: attributed, + fontSize: fontSize, + textColor: NSColor(baseColor) + ) } static func attributed(_ text: String) -> AttributedString { diff --git a/ui/ui/Components/InAppNotificationBannerChrome.swift b/ui/ui/Components/InAppNotificationBannerChrome.swift new file mode 100644 index 00000000..e170a230 --- /dev/null +++ b/ui/ui/Components/InAppNotificationBannerChrome.swift @@ -0,0 +1,96 @@ +import SwiftUI + +/// Shared look for floating / in-app notification surfaces (job results, inbound toasts). +enum InAppNotificationKind: Equatable { + case message + case success + case warning + case failure + case info + + var symbolName: String { + switch self { + case .message: return "bubble.left.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .failure: return "xmark.octagon.fill" + case .info: return "info.circle.fill" + } + } + + var accent: Color { + switch self { + case .message: + return Color(red: 0.176, green: 0.286, blue: 0.576) + case .success: + return Color(red: 0.15, green: 0.48, blue: 0.32) + case .warning: + return Color(red: 0.72, green: 0.48, blue: 0.18) + case .failure: + return Color(red: 0.72, green: 0.22, blue: 0.18) + case .info: + return Color(red: 0.176, green: 0.286, blue: 0.576) + } + } +} + +enum InAppNotificationBannerChrome { + static let fill = Color(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 246.0 / 255.0) + /// Pill-like continuous corners (matches sub-tab language without forcing a true Capsule on tall cards). + static let cornerRadius: CGFloat = 22 +} + +/// Compact toast used for inbound messaging alerts. +struct InAppNotificationToast: View { + let text: String + var kind: InAppNotificationKind = .message + var action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: kind.symbolName) + .font(.system(size: 14, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(kind.accent) + Text(text) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(maxWidth: 520, alignment: .leading) + .background(InAppNotificationBannerChrome.fill, in: Capsule()) + .overlay( + Capsule() + .strokeBorder(kind.accent.opacity(0.35), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.08), radius: 8, y: 2) + } + .buttonStyle(.plain) + .pointerStyle(.link) + } +} + +extension View { + /// Link / hand pointer when this attributed string contains tappable links. + @ViewBuilder + func linkPointerStyle(ifPresentIn attributed: AttributedString) -> some View { + if attributed.runs.contains(where: { $0.link != nil }) { + self.pointerStyle(.link) + } else { + self + } + } + + /// Same as above for markdown source that may parse to links. + func linkPointerStyle(forMarkdown markdown: String) -> some View { + var options = AttributedString.MarkdownParsingOptions() + options.interpretedSyntax = .inlineOnlyPreservingWhitespace + let attributed = (try? AttributedString(markdown: markdown, options: options)) + ?? AttributedString(markdown) + return linkPointerStyle(ifPresentIn: attributed) + } +} diff --git a/ui/ui/Components/SelectableLinkTextView.swift b/ui/ui/Components/SelectableLinkTextView.swift new file mode 100644 index 00000000..957b413a --- /dev/null +++ b/ui/ui/Components/SelectableLinkTextView.swift @@ -0,0 +1,129 @@ +import AppKit +import SwiftUI + +/// Selectable markdown/attributed text that shows the pointing-hand cursor over links. +/// SwiftUI `Text` + `.textSelection(.enabled)` forces the I-beam and overrides `.pointerStyle(.link)`. +struct SelectableLinkTextView: NSViewRepresentable { + let attributedString: AttributedString + var fontSize: CGFloat = 13 + var textColor: NSColor = .labelColor + /// Cap ideal (uncompressed) width so `ViewThatFits` / trailing bubbles stay reasonable. + var maxIdealWidth: CGFloat = 420 + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> MeasuringLinkTextView { + let textView = MeasuringLinkTextView(usingTextLayoutManager: false) + textView.delegate = context.coordinator + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = true + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = false + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.setContentHuggingPriority(.defaultLow, for: .horizontal) + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.linkTextAttributes = [ + .foregroundColor: NSColor.linkColor, + .underlineStyle: 0, + .cursor: NSCursor.pointingHand, + ] + apply(to: textView) + return textView + } + + func updateNSView(_ textView: MeasuringLinkTextView, context: Context) { + textView.delegate = context.coordinator + apply(to: textView) + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: MeasuringLinkTextView, + context: Context + ) -> CGSize? { + apply(to: nsView) + let width: CGFloat + if let proposed = proposal.width, proposed.isFinite, proposed > 1 { + width = proposed + } else { + width = min(max(nsView.idealWidth(), 1), maxIdealWidth) + } + let height = nsView.height(forWidth: width) + return CGSize(width: width, height: height) + } + + private func apply(to textView: MeasuringLinkTextView) { + let nsFont = NSFont.systemFont(ofSize: fontSize) + var styled = attributedString + styled.font = Font(nsFont) + styled.foregroundColor = Color(nsColor: textColor) + + let next = NSMutableAttributedString(attributedString: NSAttributedString(styled)) + next.enumerateAttribute(.link, in: NSRange(location: 0, length: next.length)) { value, range, _ in + guard value != nil else { return } + next.addAttribute(.cursor, value: NSCursor.pointingHand, range: range) + next.addAttribute(.foregroundColor, value: NSColor.linkColor, range: range) + } + + if textView.textStorage?.string != next.string { + textView.textStorage?.setAttributedString(next) + } else if let storage = textView.textStorage, !storage.isEqual(to: next) { + storage.setAttributedString(next) + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { + let url: URL? + if let value = link as? URL { + url = value + } else if let value = link as? String { + url = URL(string: value) + } else { + url = nil + } + guard let url else { return false } + NSWorkspace.shared.open(url) + return true + } + } +} + +/// NSTextView that measures height for a given width without fighting SwiftUI layout. +final class MeasuringLinkTextView: NSTextView { + func height(forWidth width: CGFloat) -> CGFloat { + guard let container = textContainer, let layoutManager else { + return ceil(font?.boundingRectForFont.height ?? 16) + } + let clamped = max(width, 1) + container.containerSize = NSSize(width: clamped, height: .greatestFiniteMagnitude) + layoutManager.ensureLayout(for: container) + let used = layoutManager.usedRect(for: container) + return max(ceil(used.height), ceil(font?.boundingRectForFont.height ?? 16)) + } + + func idealWidth() -> CGFloat { + guard let container = textContainer, let layoutManager else { return 1 } + container.containerSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: .greatestFiniteMagnitude + ) + layoutManager.ensureLayout(for: container) + let used = layoutManager.usedRect(for: container) + return max(ceil(used.width), 1) + } +} + +extension AttributedString { + var containsLinks: Bool { + runs.contains { $0.link != nil } + } +} diff --git a/ui/ui/Jobs/JobResultPresenter.swift b/ui/ui/Jobs/JobResultPresenter.swift index 6441687b..54e854df 100644 --- a/ui/ui/Jobs/JobResultPresenter.swift +++ b/ui/ui/Jobs/JobResultPresenter.swift @@ -270,6 +270,10 @@ private struct JobResultStandaloneCard: View { let shortID: String let onDismiss: () -> Void + private var kind: InAppNotificationKind { + result.failed ? .failure : .success + } + var body: some View { VStack(alignment: .leading, spacing: 0) { JobResultModalHeader(shortID: shortID, failed: result.failed) @@ -279,14 +283,26 @@ private struct JobResultStandaloneCard: View { .frame(width: 576) .fixedSize(horizontal: false, vertical: true) .background( - RoundedRectangle(cornerRadius: ModalPopupDefaults.cornerRadius, style: .continuous) - .fill(Color(nsColor: .windowBackgroundColor)) + RoundedRectangle( + cornerRadius: InAppNotificationBannerChrome.cornerRadius, + style: .continuous + ) + .fill(InAppNotificationBannerChrome.fill) + ) + .clipShape( + RoundedRectangle( + cornerRadius: InAppNotificationBannerChrome.cornerRadius, + style: .continuous + ) ) - .clipShape(RoundedRectangle(cornerRadius: ModalPopupDefaults.cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: ModalPopupDefaults.cornerRadius, style: .continuous) - .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + RoundedRectangle( + cornerRadius: InAppNotificationBannerChrome.cornerRadius, + style: .continuous + ) + .strokeBorder(kind.accent.opacity(0.4), lineWidth: 1.5) ) + .shadow(color: .black.opacity(0.1), radius: 14, y: 4) .padding(16) .preferredColorScheme(.light) } @@ -296,16 +312,16 @@ struct JobResultModalHeader: View { var shortID: String? var failed: Bool = false + private var kind: InAppNotificationKind { + failed ? .failure : .success + } + var body: some View { HStack(spacing: 10) { - Image(systemName: failed ? "exclamationmark.circle" : "checkmark.circle") + Image(systemName: kind.symbolName) .font(ModalChrome.symbolFont) .symbolRenderingMode(.hierarchical) - .foregroundStyle( - failed - ? Color(red: 0.72, green: 0.22, blue: 0.18) - : Color(red: 0.176, green: 0.286, blue: 0.576) - ) + .foregroundStyle(kind.accent) VStack(alignment: .leading, spacing: 2) { Text(failed ? "Derrick · Job failed" : "Derrick · Job finished") .font(.headline) diff --git a/ui/ui/Messaging/AppWorkspace.swift b/ui/ui/Messaging/AppWorkspace.swift index 3c68d07a..ebbedaec 100644 --- a/ui/ui/Messaging/AppWorkspace.swift +++ b/ui/ui/Messaging/AppWorkspace.swift @@ -2,15 +2,30 @@ import Foundation enum AppWorkspace: Equatable { case chats - case plugins + /// New or focused plugin-creator Q&A. + case pluginsCreate + /// Installed Agent Plugin package browser. + case pluginsList case debugLogs + + var isPluginsSection: Bool { + switch self { + case .pluginsCreate, .pluginsList: + return true + case .chats, .debugLogs: + return false + } + } } enum ChatShellNotification { static let startPluginCreation = Notification.Name("derrick.startPluginCreation") static let startPluginEdit = Notification.Name("derrick.startPluginEdit") static let openPluginInChat = Notification.Name("derrick.openPluginInChat") + static let openPluginList = Notification.Name("derrick.openPluginList") static let pluginFactorySucceeded = Notification.Name("derrick.pluginFactorySucceeded") + /// Posted when a plugin (all versions) is removed so Chat can drop its tabs. + static let pluginDeleted = Notification.Name("derrick.pluginDeleted") static let pluginIDUserInfoKey = "pluginID" static let pluginVersionUserInfoKey = "pluginVersion" static let editPromptUserInfoKey = "editPrompt" diff --git a/ui/ui/Messaging/MessagingConnectorCredentials.swift b/ui/ui/Messaging/MessagingConnectorCredentials.swift index 7432fe97..95518844 100644 --- a/ui/ui/Messaging/MessagingConnectorCredentials.swift +++ b/ui/ui/Messaging/MessagingConnectorCredentials.swift @@ -17,6 +17,7 @@ enum MessagingConnectorCredentials { repository: repository ) migrateLegacyCredentialsIfNeeded(pluginID: pluginID, secrets: secrets) + PluginSecretKeychain.migrateCallCredentialAliases(pluginID: pluginID, fields: secrets) PluginSecretKeychain.promoteToSharedGroup(pluginID: pluginID, fields: secrets) PluginSecretHostMirror.syncDevelopmentSecretsToKeychain( pluginID: pluginID, diff --git a/ui/ui/Messaging/MessagingConversationView.swift b/ui/ui/Messaging/MessagingConversationView.swift index d25eec1c..bec53d2e 100644 --- a/ui/ui/Messaging/MessagingConversationView.swift +++ b/ui/ui/Messaging/MessagingConversationView.swift @@ -49,7 +49,49 @@ struct MessagingConversationView: View { } .onAppear { syncPickerSelection(with: store.threads) + focusPrimaryComposer() } + .onChange(of: store.conversationLanding) { _, _ in + focusPrimaryComposer() + } + .onChange(of: store.selectedThread?.id) { _, _ in + focusPrimaryComposer() + } + .onChange(of: store.selectedPluginID) { _, _ in + focusPrimaryComposer() + } + } + + private func focusPrimaryComposer() { + DispatchQueue.main.async { + switch store.conversationLanding { + case .catalogRoot: + break + case .vendorConnector: + if store.isViewingReplyThread { + threadComposerFocused = true + return + } + if presentsInbox { + if store.isConnectorSyncing, store.tabs.isEmpty { return } + composerFocused = true + return + } + if store.selectedThread != nil { + composerFocused = true + return + } + if store.canPickThread { return } + if store.needsThreadDiscovery || store.isConnectorSyncing { return } + if store.canComposeManualChannel { + if channelID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + channelFocused = true + } else { + composerFocused = true + } + } + } + } } private func syncPickerSelection(with threads: [MessagingThreadDTO]) { @@ -293,20 +335,9 @@ struct MessagingConversationView: View { } } if let banner = store.inboundBanner, !banner.isEmpty { - Button { + InAppNotificationToast(text: banner, kind: .message) { onInboundBannerTap?() - } label: { - Text(banner) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(2) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(maxWidth: 520) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) - .shadow(color: .black.opacity(0.12), radius: 8, y: 2) } - .buttonStyle(.plain) .padding(.top, 10) .transition(.move(edge: .top).combined(with: .opacity)) } @@ -665,6 +696,7 @@ private struct MessagingBubble: View { ) } .buttonStyle(.plain) + .pointerStyle(.link) .foregroundStyle(message.replyCount > 0 ? Color.accentColor : .primary.opacity(0.75)) .help(message.replyCount > 0 ? "Open thread" : "Reply in thread") .accessibilityLabel(replyActionTitle) @@ -678,26 +710,21 @@ private struct MessagingBubble: View { } private var messageBody: some View { - ViewThatFits(in: .horizontal) { - bubbleLabel - .fixedSize() - .modifier(MessagingBubbleChrome(direction: message.direction)) - bubbleLabel - .fixedSize(horizontal: false, vertical: true) - .modifier(MessagingBubbleChrome(direction: message.direction)) - } - .frame( - maxWidth: .infinity, - alignment: message.direction == .outbound ? .trailing : .leading - ) + bubbleLabel + .frame(maxWidth: 420, alignment: message.direction == .outbound ? .trailing : .leading) + .fixedSize(horizontal: false, vertical: true) + .modifier(MessagingBubbleChrome(direction: message.direction)) + .frame( + maxWidth: .infinity, + alignment: message.direction == .outbound ? .trailing : .leading + ) } private var bubbleLabel: some View { MessagingMarkdownText( text: message.body, - font: .system(size: 13) + fontSize: 13 ) - .multilineTextAlignment(.leading) } private var replyActionTitle: String { @@ -714,15 +741,25 @@ private struct MessagingBubble: View { private struct MessagingBubbleChrome: ViewModifier { let direction: MessagingMessageDirection + private var kind: InAppNotificationKind { + direction == .outbound ? .message : .info + } + func body(content: Content) -> some View { content - .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.horizontal, 14) + .padding(.vertical, 10) .background( - RoundedRectangle(cornerRadius: 12) - .fill(direction == .outbound - ? Color.black.opacity(0.08) - : Color.white) + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + direction == .outbound + ? InAppNotificationBannerChrome.fill + : Color.white + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(kind.accent.opacity(direction == .outbound ? 0.28 : 0.14), lineWidth: 1) ) } } diff --git a/ui/ui/Plugins/PluginCreationController.swift b/ui/ui/Plugins/PluginCreationController.swift index 1045cd31..56bf9a07 100644 --- a/ui/ui/Plugins/PluginCreationController.swift +++ b/ui/ui/Plugins/PluginCreationController.swift @@ -36,6 +36,17 @@ final class PluginCreationController: ObservableObject { var status: Status } + /// Host create order: credentials → Agent Plugin spec → docs → SKILL.md → build. + static let factoryProgressStepOrder: [(id: String, title: String)] = [ + ("credentials", "Save credentials"), + ("spec", "Read Agent Plugin spec"), + ("docs", "Read API docs"), + ("skill", "Write SKILL.md"), + ("factory", "Build guest program"), + ("review", "Safety review"), + ("trial", "Trial run"), + ] + @Published private(set) var phase: Phase = .idle @Published private(set) var statusMessage = "" @Published private(set) var progressSteps: [ProgressStepState] = [] @@ -483,14 +494,10 @@ final class PluginCreationController: ObservableObject { } private func resetProgressSteps() { - progressSteps = [ - ProgressStepState(id: "skill", title: "Write SKILL.md", status: .completed), - ProgressStepState(id: "docs", title: "Read API docs", status: .pending), - ProgressStepState(id: "credentials", title: "Save credentials", status: .pending), - ProgressStepState(id: "factory", title: "Build guest program", status: .pending), - ProgressStepState(id: "review", title: "Safety review", status: .pending), - ProgressStepState(id: "trial", title: "Trial run", status: .pending), - ] + // Order matches the host workflow: credentials → Agent Plugin spec → docs → SKILL.md → build. + progressSteps = Self.factoryProgressStepOrder.map { + ProgressStepState(id: $0.id, title: $0.title, status: .pending) + } if skillDraft.plannedKind == .customCapability { setProgressStep("docs", status: .completed) setProgressStep("credentials", status: .completed) @@ -512,18 +519,38 @@ final class PluginCreationController: ObservableObject { private func markProgressFailed(fromStage stage: String?) { switch stage?.lowercased() { - case "crawl", "docs": - setProgressStep("docs", status: .failed) case "credentials", "auth": - markProgressCompleted("docs") setProgressStep("credentials", status: .failed) - case "factory", "build": + case "spec", "validate": + markProgressCompleted("credentials") + setProgressStep("spec", status: .failed) + case "crawl", "docs": + markProgressCompleted("credentials") + markProgressCompleted("spec") + setProgressStep("docs", status: .failed) + case "skill": + markProgressCompleted("credentials") + markProgressCompleted("spec") markProgressCompleted("docs") + setProgressStep("skill", status: .failed) + case "factory", "build", "builder", "package": markProgressCompleted("credentials") + markProgressCompleted("spec") + markProgressCompleted("docs") + markProgressCompleted("skill") setProgressStep("factory", status: .failed) - case "review": + case "trial": + markProgressCompleted("credentials") + markProgressCompleted("spec") markProgressCompleted("docs") + markProgressCompleted("skill") + markProgressCompleted("factory") + setProgressStep("trial", status: .failed) + case "review": markProgressCompleted("credentials") + markProgressCompleted("spec") + markProgressCompleted("docs") + markProgressCompleted("skill") markProgressCompleted("factory") setProgressStep("review", status: .failed) default: @@ -535,15 +562,43 @@ final class PluginCreationController: ObservableObject { switch event.kind { case "progress": switch event.stage { + case "validate", "spec": + markProgressCompleted("credentials") + markProgressActive("spec") case "docs": + markProgressCompleted("credentials") + markProgressCompleted("spec") markProgressActive("docs") - case "factory": + case "skill": + markProgressCompleted("credentials") + markProgressCompleted("spec") markProgressCompleted("docs") + markProgressActive("skill") + case "factory", "builder", "package": markProgressCompleted("credentials") + markProgressCompleted("spec") + markProgressCompleted("docs") + markProgressCompleted("skill") markProgressActive("factory") - case "complete": + case "trial": + markProgressCompleted("credentials") + markProgressCompleted("spec") + markProgressCompleted("docs") + markProgressCompleted("skill") + markProgressCompleted("factory") + markProgressActive("trial") + case "review": + markProgressCompleted("credentials") + markProgressCompleted("spec") markProgressCompleted("docs") + markProgressCompleted("skill") + markProgressCompleted("factory") + markProgressActive("review") + case "promote", "complete": markProgressCompleted("credentials") + markProgressCompleted("spec") + markProgressCompleted("docs") + markProgressCompleted("skill") markProgressCompleted("factory") markProgressCompleted("review") markProgressCompleted("trial") @@ -553,8 +608,10 @@ final class PluginCreationController: ObservableObject { case "log": let message = event.message if message.contains("draft_started") || message.contains("direct_test") { - markProgressCompleted("docs") markProgressCompleted("credentials") + markProgressCompleted("spec") + markProgressCompleted("docs") + markProgressCompleted("skill") markProgressActive("factory") } if message.contains("review decision=approved") { @@ -590,13 +647,15 @@ final class PluginCreationController: ObservableObject { } switch result.status { case .completed: + markProgressCompleted("credentials") + markProgressCompleted("spec") markProgressCompleted("docs") + markProgressCompleted("skill") markProgressCompleted("factory") markProgressCompleted("review") markProgressCompleted("trial") await PluginFactoryListStore.shared.reload() if let saved = parseSuccessResult(result.resultJSON) { - markProgressCompleted("credentials") phase = .succeeded(pluginID: saved.pluginID, outcome: .plugin) NotificationCenter.default.post( name: ChatShellNotification.pluginFactorySucceeded, @@ -607,7 +666,6 @@ final class PluginCreationController: ObservableObject { ] ) } else if let saved = PluginFactoryListStore.shared.releases.first { - markProgressCompleted("credentials") phase = .succeeded(pluginID: saved.pluginID, outcome: .plugin) NotificationCenter.default.post( name: ChatShellNotification.pluginFactorySucceeded, diff --git a/ui/ui/Plugins/PluginPackageBrowserView.swift b/ui/ui/Plugins/PluginPackageBrowserView.swift index cfdba895..ab40040d 100644 --- a/ui/ui/Plugins/PluginPackageBrowserView.swift +++ b/ui/ui/Plugins/PluginPackageBrowserView.swift @@ -3,6 +3,7 @@ import SwiftUI struct PluginPackageBrowserView: View { @ObservedObject var controller: PluginPackageBrowserController + @FocusState private var editPromptFocused: Bool private let chromeFill = Color(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 246.0 / 255.0) private let sidebarWidth: CGFloat = 220 @@ -35,6 +36,19 @@ struct PluginPackageBrowserView: View { .task { await controller.reloadList() } + .onAppear { + focusEditPromptIfPossible() + } + .onChange(of: controller.selectedPluginID) { _, _ in + focusEditPromptIfPossible() + } + } + + private func focusEditPromptIfPossible() { + guard controller.selectedPluginID != nil else { return } + DispatchQueue.main.async { + editPromptFocused = true + } } private var pluginSidebar: some View { @@ -221,6 +235,7 @@ struct PluginPackageBrowserView: View { ) .textFieldStyle(.roundedBorder) .lineLimit(2...5) + .focused($editPromptFocused) .disabled(controller.selectedPluginID == nil) .accessibilityIdentifier("plugin-package-edit-prompt") diff --git a/ui/ui/Plugins/PluginsWorkspaceShellView.swift b/ui/ui/Plugins/PluginsWorkspaceShellView.swift deleted file mode 100644 index 42872c23..00000000 --- a/ui/ui/Plugins/PluginsWorkspaceShellView.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI - -enum PluginsWorkspaceSubtab: String, CaseIterable, Identifiable, Hashable { - case create = "Create plugin" - case plugins = "Plugins" - - var id: String { rawValue } -} - -/// Plugins tab chrome: Create plugin Q&A and Plugins package browser as pill sub-tabs. -struct PluginsWorkspaceShellView: View { - @ViewBuilder var createContent: () -> CreateContent - @State private var subtab: PluginsWorkspaceSubtab = .create - @StateObject private var browser = PluginPackageBrowserController() - - var body: some View { - VStack(spacing: 0) { - PillSubtabBar( - tabs: Array(PluginsWorkspaceSubtab.allCases), - selection: $subtab, - title: { $0.rawValue }, - accessibilityIdentifier: "plugins-workspace-subtabs" - ) - - Group { - switch subtab { - case .create: - createContent() - case .plugins: - PluginPackageBrowserView(controller: browser) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .onChange(of: subtab) { _, newValue in - if newValue == .plugins { - Task { await browser.reloadList() } - } - } - .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.pluginFactorySucceeded)) { notification in - guard let pluginID = notification.userInfo?[ChatShellNotification.pluginIDUserInfoKey] as? String, - !pluginID.isEmpty - else { return } - let version = notification.userInfo?[ChatShellNotification.pluginVersionUserInfoKey] as? String - subtab = .plugins - Task { await browser.handleFactorySucceeded(pluginID: pluginID, version: version) } - } - } -} diff --git a/ui/ui/Session/ChatSessionStore.swift b/ui/ui/Session/ChatSessionStore.swift index 83de97a9..e9924daf 100644 --- a/ui/ui/Session/ChatSessionStore.swift +++ b/ui/ui/Session/ChatSessionStore.swift @@ -81,6 +81,16 @@ struct ChatTab: Identifiable, Hashable { return (rest, nil) } + /// True when the creator tab has user progress beyond the seeded opening turn. + var isOngoingPluginCreator: Bool { + isPluginCreator && turns.count > 1 + } + + /// Unused Create tab — in memory only until the user starts prompting. + var isUnusedPluginCreator: Bool { + isPluginCreator && !isOngoingPluginCreator + } + /// Recents can restore this tab with no turns; Plugins must still show the creator. static func pluginCreator(existing: ChatTab? = nil) -> ChatTab { var tab = existing ?? ChatTab( @@ -216,6 +226,7 @@ final class ChatSessionStore: ObservableObject { if tabs.isEmpty { if let latest = recentSessions.first(where: { !JobSessionID.isJobSession($0.sessionID) + && !Self.isUnstartedPluginCreatorSession($0) }) { selectSession(id: latest.sessionID) } else { @@ -229,11 +240,23 @@ final class ChatSessionStore: ObservableObject { func refreshRecents() async { guard let repository else { return } let rows = (try? await repository.listRecentChatSessions( + applicationName: applicationName, + limit: 20 + )) ?? [] + // Drop legacy Create screens that were saved before the user prompted. + for session in rows where Self.isUnstartedPluginCreatorSession(session) { + try? await repository.deleteChatSession( + applicationName: applicationName, + sessionID: session.sessionID + ) + } + let cleaned = (try? await repository.listRecentChatSessions( applicationName: applicationName, limit: 5 )) ?? [] - recentSessions = rows.filter { + recentSessions = cleaned.filter { !JobSessionID.isJobSession($0.sessionID) + && !Self.isUnstartedPluginCreatorSession($0) } } @@ -245,12 +268,16 @@ final class ChatSessionStore: ObservableObject { persistSessionShell(sessionID: id, title: tab.title, tab: tab) } + /// Opens Create plugin. Reuses an unused in-memory Create tab; does not persist until the user prompts. @discardableResult func openOrFocusPluginCreator() -> String { + if let existing = tabs.last(where: \.isUnusedPluginCreator) { + selectedSessionID = existing.id + return existing.id + } let tab = ChatTab.pluginCreator() tabs.append(tab) selectedSessionID = tab.id - persistSessionShell(sessionID: tab.id, title: tab.title, tab: tab) return tab.id } @@ -487,6 +514,11 @@ final class ChatSessionStore: ObservableObject { return } if PluginSpecProcession.isCreatorTabID(id) || tab.isPluginCreator { + // Never restore an unused Create into Chat — those stay in-memory only. + if session.map(Self.isUnstartedPluginCreatorSession) ?? true { + openNewChat() + return + } tab = ChatTab.pluginCreator(existing: tab) } tabs.append(tab) @@ -583,6 +615,48 @@ final class ChatSessionStore: ObservableObject { } } + /// Removes Create-plugin tabs (and their saved sessions) after a successful build opens the plugin. + func retirePluginCreatorTabs() { + let creators = tabs.filter(\.isPluginCreator) + guard !creators.isEmpty else { return } + let ids = creators.map(\.id) + for id in ids { + activeTasks[id]?.cancel() + activeTasks[id] = nil + accessDocsTasks[id]?.cancel() + accessDocsTasks[id] = nil + accessDocsGeneration[id] = nil + } + tabs.removeAll { ids.contains($0.id) } + if let selected = selectedSessionID, ids.contains(selected) { + selectedSessionID = tabs.last?.id + } + guard let repository else { return } + Task { + for id in ids { + try? await repository.deleteChatSession( + applicationName: applicationName, + sessionID: id + ) + } + await refreshRecents() + } + } + + /// Drops Chat tabs for a deleted plugin (root + thread tabs). + func closeTabs(forPluginID pluginID: String) { + let trimmed = pluginID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let matching = tabs.filter { tab in + tab.pluginID == trimmed + || ChatTab.pluginTabIdentity(tab.id)?.pluginID == trimmed + } + for tab in matching { + closeTab(id: tab.id) + } + Task { await refreshRecents() } + } + func sendPrompt( _ prompt: String, apiKey: String, @@ -760,10 +834,15 @@ final class ChatSessionStore: ObservableObject { private func persistSessionShell(sessionID: String, title: String, tab: ChatTab) { guard let repository else { return } + // Unused Create screens stay in-memory only until the user starts prompting. + if tab.isUnusedPluginCreator { + return + } let now = Date.now var metadata: [String: String] = ["surface": tab.surface.rawValue] if tab.isPluginCreator { metadata["pluginCreator"] = "true" + metadata["pluginCreatorStarted"] = "true" } if let pluginID = tab.pluginID { metadata["pluginID"] = pluginID @@ -785,6 +864,14 @@ final class ChatSessionStore: ObservableObject { } } + /// Legacy or incomplete Create rows that never received a user prompt. + private static func isUnstartedPluginCreatorSession(_ session: ChatSessionDTO) -> Bool { + let isCreator = session.metadata["pluginCreator"] == "true" + || PluginSpecProcession.isCreatorTabID(session.sessionID) + guard isCreator else { return false } + return session.metadata["pluginCreatorStarted"] != "true" + } + private func tab(from session: ChatSessionDTO?, id: String, title: String) -> ChatTab { let metadata = session?.metadata ?? [:] let surface = ChatTabSurface(rawValue: metadata["surface"] ?? "") ?? .conversation diff --git a/ui/ui/Session/PluginFactoryListStore.swift b/ui/ui/Session/PluginFactoryListStore.swift index f0ec48a3..438a70b7 100644 --- a/ui/ui/Session/PluginFactoryListStore.swift +++ b/ui/ui/Session/PluginFactoryListStore.swift @@ -32,7 +32,7 @@ final class PluginFactoryListStore: ObservableObject { func configure(repository: DBRepository) async { self.repository = repository - await purgeLegacySlackConnectors() + await purgeOrphanedPluginData() await reload() } @@ -59,35 +59,55 @@ final class PluginFactoryListStore: ObservableObject { func delete(_ release: PluginFactoryReleaseSummary) async { guard let repository else { return } do { - try await repository.deletePluginFactoryRelease( + var secretFields: [String] = [] + if let full = try await repository.pluginFactoryRelease( + pluginID: release.pluginID, + version: release.version + ) { + secretFields = PluginSecretField.resolvedDescriptors( + pluginID: release.pluginID, + fromManifestJSON: full.manifestJSON + ).map(\.id) + } + let result = try await repository.purgePlugin( pluginID: release.pluginID, version: release.version ) + if result.purgedAssociatedData { + PluginSecretKeychain.deleteAllStoredSecrets( + pluginID: release.pluginID, + fieldIDs: secretFields + ) + NotificationCenter.default.post( + name: ChatShellNotification.pluginDeleted, + object: nil, + userInfo: [ChatShellNotification.pluginIDUserInfoKey: release.pluginID] + ) + } + _ = try await repository.purgeOrphanedPluginAssociatedData() + let installed = (try? await repository.listInstalledPluginIDs()) ?? [] + PluginSecretKeychain.deleteOrphanedSharedSecrets(keepingPluginIDs: installed) await reload() } catch { lastError = error.localizedDescription } } - /// Removes legacy Slack reference connectors installed outside the LLM create path. - func purgeLegacySlackConnectors() async { + /// Removes leftover messaging / chat / secret data for plugins that no longer have a release. + func purgeOrphanedPluginData() async { guard let repository else { return } do { - let summaries = try await repository.listPluginFactoryReleaseSummaries() - var pluginIDs = Set() - for summary in summaries where PluginFactoryLegacyPurge.isLegacySlackPluginID(summary.pluginID) { - pluginIDs.insert(summary.pluginID) - } - for pluginID in pluginIDs { - try await repository.deletePluginFactoryRelease(pluginID: pluginID) + let orphanResults = try await repository.purgeOrphanedPluginAssociatedData() + for result in orphanResults where result.purgedAssociatedData { + PluginSecretKeychain.deleteAllStoredSecrets(pluginID: result.pluginID) + NotificationCenter.default.post( + name: ChatShellNotification.pluginDeleted, + object: nil, + userInfo: [ChatShellNotification.pluginIDUserInfoKey: result.pluginID] + ) } - let connectors = try await repository.listMessagingConnectors() - let keep = Set( - connectors - .map(\.pluginID) - .filter { !PluginFactoryLegacyPurge.isLegacySlackPluginID($0) } - ) - try await repository.pruneMessagingConnectors(keeping: keep) + let installed = (try? await repository.listInstalledPluginIDs()) ?? [] + PluginSecretKeychain.deleteOrphanedSharedSecrets(keepingPluginIDs: installed) } catch { lastError = error.localizedDescription } diff --git a/ui/ui/Views/ChatTabBarView.swift b/ui/ui/Views/ChatTabBarView.swift index 0aeefe5b..442fa054 100644 --- a/ui/ui/Views/ChatTabBarView.swift +++ b/ui/ui/Views/ChatTabBarView.swift @@ -2,10 +2,10 @@ import SwiftUI struct ChatTabBarView: View { enum TabFilter: Equatable { - /// Regular chats and plugin surfaces — never plugin-creator tabs. + /// Regular chats plus in-progress plugin create sessions (not fresh empty creates). case chats - /// Only the Plugins creator tab(s). - case plugins + /// Plugin creator tabs only. + case pluginsCreate } @ObservedObject var store: ChatSessionStore @@ -18,8 +18,10 @@ struct ChatTabBarView: View { private var visibleTabs: [ChatTab] { switch filter { case .chats: - return store.tabs.filter { !$0.isPluginCreator } - case .plugins: + return store.tabs.filter { tab in + !tab.isPluginCreator || tab.isOngoingPluginCreator + } + case .pluginsCreate: return store.tabs.filter(\.isPluginCreator) } } @@ -41,7 +43,9 @@ struct ChatTabBarView: View { .fill(Color.primary.opacity(0.08)) .frame(height: 1) } - .accessibilityIdentifier(filter == .plugins ? "chat-tab-bar-plugins" : "chat-tab-bar-chats") + .accessibilityIdentifier( + filter == .pluginsCreate ? "chat-tab-bar-plugins-create" : "chat-tab-bar-chats" + ) } private func browserTab(_ tab: ChatTab) -> some View { @@ -86,7 +90,6 @@ struct ChatTabBarView: View { BrowserTabShape(cornerRadius: tabCorner) .fill(isSelected ? selectedFill : Color.primary.opacity(0.03)) } - // Sit on top of the strip hairline so the selected tab merges into the pane. .padding(.bottom, isSelected ? -1 : 0) .zIndex(isSelected ? 1 : 0) } diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index f870d6cd..63673cca 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -280,6 +280,7 @@ struct ContentView: View { @ObservedObject private var pluginFactoryList = PluginFactoryListStore.shared @ObservedObject private var agentProfiles = AgentProfileStore.shared @StateObject private var pluginCreationController = PluginCreationController() + @StateObject private var pluginPackageBrowser = PluginPackageBrowserController() @State private var pluginAutocompleteHighlight = 0 @State private var pluginAutocompleteDismissed = false @@ -414,32 +415,17 @@ struct ContentView: View { } VStack(spacing: 0) { - if workspace != .debugLogs { - ChatTabBarView( - store: chatSessions, - filter: workspace == .plugins ? .plugins : .chats - ) - } switch workspace { case .debugLogs: DebugLogsView(repository: repository) - case .chats, .plugins: - if chatSessions.selectedTab?.surface == .thread { - MessagingConversationView( - store: messaging, - onInboundBannerTap: { - Task { @MainActor in - await openInboundBannerConversation() - } - }, - presentsInbox: chatSessions.selectedTab?.threadID == nil - ) - } else if let surface = chatSessions.selectedTab?.surface, - surface == .generatedView || surface == .file || surface == .image { - PluginPresentTabBody(surface: surface) - } else { - mainPanel - } + case .pluginsList: + PluginPackageBrowserView(controller: pluginPackageBrowser) + case .chats: + ChatTabBarView(store: chatSessions, filter: .chats) + chatsMainContent + case .pluginsCreate: + ChatTabBarView(store: chatSessions, filter: .pluginsCreate) + mainPanel } } .overlay { @@ -475,9 +461,10 @@ struct ContentView: View { switch newValue { case .chats: ensureChatMenuSelection() - case .plugins: - ensurePluginsMenuSelection() - case .debugLogs: + requestPromptFocusIfNeeded() + case .pluginsCreate: + requestPromptFocusIfNeeded() + case .pluginsList, .debugLogs: break } } @@ -485,10 +472,15 @@ struct ContentView: View { Task { @MainActor in await syncSelectedChatTabWithMessaging() } + requestPromptFocusIfNeeded() } .onAppear { messaging.setWorkspaceActive(chatSessions.selectedTab?.surface == .thread) refreshProviderCredentialUI() + if workspace == .chats { + ensureChatMenuSelection() + requestPromptFocusIfNeeded() + } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in refreshProviderCredentialUI() @@ -535,7 +527,11 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.startPluginCreation)) { _ in bindPluginCreatorCompletion() chatSessions.openOrFocusPluginCreator() - workspace = .plugins + workspace = .pluginsCreate + } + .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.openPluginList)) { _ in + workspace = .pluginsList + Task { await pluginPackageBrowser.reloadList() } } .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.startPluginEdit)) { notification in guard let pluginID = notification.userInfo?[ChatShellNotification.pluginIDUserInfoKey] as? String, @@ -547,7 +543,7 @@ struct ContentView: View { else { return } - workspace = .plugins + workspace = .pluginsList pluginCreationController.beginEdit( pluginID: pluginID, version: version, @@ -557,6 +553,19 @@ struct ContentView: View { helperReviewerModelJSON: currentHelperReviewerModelJSON ) } + .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.pluginFactorySucceeded)) { notification in + // Drop the Create tab that finished; Recents should not keep a leftover "Plugins" session. + chatSessions.retirePluginCreatorTabs() + workspace = .pluginsList + guard let pluginID = notification.userInfo?[ChatShellNotification.pluginIDUserInfoKey] as? String, + !pluginID.isEmpty + else { + Task { await pluginPackageBrowser.reloadList() } + return + } + let version = notification.userInfo?[ChatShellNotification.pluginVersionUserInfoKey] as? String + Task { await pluginPackageBrowser.handleFactorySucceeded(pluginID: pluginID, version: version) } + } .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.openPluginInChat)) { notification in guard let pluginID = notification.userInfo?[ChatShellNotification.pluginIDUserInfoKey] as? String, !pluginID.isEmpty @@ -567,6 +576,15 @@ struct ContentView: View { await routeToPluginTab(pluginID) } } + .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.pluginDeleted)) { notification in + guard let pluginID = notification.userInfo?[ChatShellNotification.pluginIDUserInfoKey] as? String, + !pluginID.isEmpty + else { + return + } + chatSessions.closeTabs(forPluginID: pluginID) + Task { await messaging.syncConnectorsFromFactory() } + } .sheet(isPresented: $isPresentingAPIKeyPrompt) { apiKeyPrompt() } @@ -986,9 +1004,29 @@ struct ContentView: View { } } + @ViewBuilder + private var chatsMainContent: some View { + if chatSessions.selectedTab?.surface == .thread { + MessagingConversationView( + store: messaging, + onInboundBannerTap: { + Task { @MainActor in + await openInboundBannerConversation() + } + }, + presentsInbox: chatSessions.selectedTab?.threadID == nil + ) + } else if let surface = chatSessions.selectedTab?.surface, + surface == .generatedView || surface == .file || surface == .image { + PluginPresentTabBody(surface: surface) + } else { + mainPanel + } + } + @ViewBuilder var mainPanel: some View { - let panel = Color(red: 248.0/255.0, green: 248.0/255.0, blue: 246.0/255.0) + Color(red: 248.0/255.0, green: 248.0/255.0, blue: 246.0/255.0) .ignoresSafeArea() .overlay { GeometryReader { proxy in @@ -998,14 +1036,6 @@ struct ContentView: View { panelContent(inputHeight: inputHeight, panelWidth: panelWidth) } } - // Plugin tab + pill sub-tabs only while the Plugins menu is active. - if workspace == .plugins, chatSessions.selectedTab?.isPluginCreator == true { - PluginsWorkspaceShellView { - panel - } - } else { - panel - } } func panelContent(inputHeight: CGFloat, panelWidth: CGFloat) -> some View { @@ -1400,6 +1430,18 @@ struct ContentView: View { promptFocusToken += 1 } + private func requestPromptFocusIfNeeded() { + switch workspace { + case .chats: + guard chatSessions.selectedTab?.surface != .thread else { return } + promptFocusToken += 1 + case .pluginsCreate: + promptFocusToken += 1 + case .pluginsList, .debugLogs: + break + } + } + private func promptInputHeight(for availableHeight: CGFloat) -> CGFloat { min(max(availableHeight * 0.10, 100), 300) } @@ -1501,25 +1543,20 @@ struct ContentView: View { } } - /// Chat menu: hide Plugin tab by leaving any creator selection. + /// Chat menu: keep ongoing create sessions; leave fresh empty create screens. private func ensureChatMenuSelection() { - guard chatSessions.selectedTab?.isPluginCreator == true else { return } - if let chat = chatSessions.tabs.last(where: { !$0.isPluginCreator }) { - chatSessions.selectSession(id: chat.id) - } else { - chatSessions.openNewChat() - } - } - - /// Plugins menu: show/focus a Plugin tab (creator). - private func ensurePluginsMenuSelection() { - if chatSessions.selectedTab?.isPluginCreator == true { return } - if let creator = chatSessions.tabs.last(where: \.isPluginCreator) { - chatSessions.selectSession(id: creator.id) + guard let selected = chatSessions.selectedTab else { return } + if selected.isPluginCreator, !selected.isOngoingPluginCreator { + if let ongoing = chatSessions.tabs.last(where: \.isOngoingPluginCreator) { + chatSessions.selectSession(id: ongoing.id) + } else if let chat = chatSessions.tabs.last(where: { !$0.isPluginCreator }) { + chatSessions.selectSession(id: chat.id) + } else { + chatSessions.openNewChat() + } return } - bindPluginCreatorCompletion() - chatSessions.openOrFocusPluginCreator() + // Ongoing creator or normal chat is fine under Chat. } private func bindPluginCreatorCompletion() { @@ -1538,6 +1575,8 @@ struct ContentView: View { @discardableResult private func routeToPluginTab(_ pluginID: String, present: PluginPresent? = nil) async -> Bool { workspace = .chats + // Finished Create sessions must not linger next to the new plugin tab. + chatSessions.retirePluginCreatorTabs() let connector = await isMessagingConnector(pluginID) let binding: ChatTabSurfacePolicy.Binding if let present { diff --git a/ui/ui/Views/MarkdownView.swift b/ui/ui/Views/MarkdownView.swift index c5b9f787..16ab9292 100644 --- a/ui/ui/Views/MarkdownView.swift +++ b/ui/ui/Views/MarkdownView.swift @@ -310,27 +310,24 @@ struct MarkdownResponseView: View { private func blockView(for block: MarkdownBlock) -> some View { switch block { case .heading(let level, let text): - Text((try? AttributedString(markdown: text)) ?? AttributedString(text)) + markdownText(text) .font(headingFont(level: level)) .fontWeight(.semibold) .padding(.horizontal, 2) .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) case .paragraph(let text): - Text((try? AttributedString(markdown: text)) ?? AttributedString(text)) + markdownText(text) .lineSpacing(2) .padding(.horizontal, 2) .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) case .bullet(let text): HStack(alignment: .top, spacing: 6) { Text("•") .font(.system(size: 15, weight: .bold)) .foregroundStyle(.secondary) - Text((try? AttributedString(markdown: text)) ?? AttributedString(text)) + markdownText(text) .lineSpacing(2) .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) } .padding(.leading, 12) case .numbered(let number, let text): @@ -338,10 +335,9 @@ struct MarkdownResponseView: View { Text("\(number).") .font(.system(size: 14, weight: .medium)) .foregroundStyle(.secondary) - Text((try? AttributedString(markdown: text)) ?? AttributedString(text)) + markdownText(text) .lineSpacing(2) .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) } .padding(.leading, 12) case .blockquote(let text): @@ -349,11 +345,10 @@ struct MarkdownResponseView: View { Rectangle() .fill(Color.gray.opacity(0.3)) .frame(width: 4) - Text((try? AttributedString(markdown: text)) ?? AttributedString(text)) + markdownText(text) .lineSpacing(2) .font(.system(.body).italic()) .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) } .padding(.vertical, 4) .padding(.horizontal, 8) @@ -378,6 +373,16 @@ struct MarkdownResponseView: View { } } + private func markdownText(_ text: String) -> some View { + let attributed = (try? AttributedString(markdown: text)) ?? AttributedString(text) + return SelectableLinkTextView( + attributedString: attributed, + fontSize: 15, + textColor: .labelColor, + maxIdealWidth: 720 + ) + } + private func headingFont(level: Int) -> Font { switch level { case 1: return .title diff --git a/ui/ui/Views/PromptView.swift b/ui/ui/Views/PromptView.swift index 883ca4e3..1588a696 100644 --- a/ui/ui/Views/PromptView.swift +++ b/ui/ui/Views/PromptView.swift @@ -132,7 +132,7 @@ struct PromptInputView: NSViewRepresentable { final class Coordinator: NSObject, NSTextViewDelegate { @Binding var text: String - var lastFocusedToken: Int = 0 + var lastFocusedToken: Int = -1 init(text: Binding) { _text = text diff --git a/ui/ui/Views/SidebarView.swift b/ui/ui/Views/SidebarView.swift index 3405d68e..9212bed4 100644 --- a/ui/ui/Views/SidebarView.swift +++ b/ui/ui/Views/SidebarView.swift @@ -43,19 +43,9 @@ struct SidebarView: View { workspace = .chats chatSessions.openNewChat() } - SidebarActionRow( - row: SidebarRow( - id: SidebarPrimaryActions.newPlugin.id, - icon: SidebarPrimaryActions.newPlugin.icon, - title: SidebarPrimaryActions.newPlugin.title, - isProminent: workspace == .plugins - ) - ) { - NotificationCenter.default.post( - name: ChatShellNotification.startPluginCreation, - object: nil - ) - } + + pluginsSection + SidebarActionRow( row: SidebarRow( id: "chats", @@ -139,6 +129,48 @@ struct SidebarView: View { } } + private var pluginsSection: some View { + VStack(alignment: .leading, spacing: 4) { + // Parent is not navigational — Create and List do the work. + SidebarActionRow( + row: SidebarRow( + id: SidebarPrimaryActions.plugins.id, + icon: SidebarPrimaryActions.plugins.icon, + title: SidebarPrimaryActions.plugins.title, + isProminent: workspace.isPluginsSection + ) + ) + SidebarActionRow( + row: SidebarRow( + id: SidebarPrimaryActions.pluginsCreate.id, + icon: SidebarPrimaryActions.pluginsCreate.icon, + title: SidebarPrimaryActions.pluginsCreate.title, + isProminent: workspace == .pluginsCreate + ), + indented: true + ) { + NotificationCenter.default.post( + name: ChatShellNotification.startPluginCreation, + object: nil + ) + } + SidebarActionRow( + row: SidebarRow( + id: SidebarPrimaryActions.pluginsList.id, + icon: SidebarPrimaryActions.pluginsList.icon, + title: SidebarPrimaryActions.pluginsList.title, + isProminent: workspace == .pluginsList + ), + indented: true + ) { + NotificationCenter.default.post( + name: ChatShellNotification.openPluginList, + object: nil + ) + } + } + } + private var debugLogsHint: some View { VStack(alignment: .leading, spacing: 8) { Text("Diagnostics") @@ -174,10 +206,7 @@ struct SidebarView: View { } else { ForEach(chatSessions.recentSessions) { session in Button { - let isCreator = session.metadata["pluginCreator"] == "true" - || PluginSpecProcession.isCreatorTabID(session.sessionID) - workspace = isCreator ? .plugins : .chats - chatSessions.selectSession(id: session.sessionID) + openRecent(session) } label: { Text(session.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? session.title! @@ -199,6 +228,26 @@ struct SidebarView: View { } } } + + private func openRecent(_ session: ChatSessionDTO) { + let isCreator = session.metadata["pluginCreator"] == "true" + || PluginSpecProcession.isCreatorTabID(session.sessionID) + let started = session.metadata["pluginCreatorStarted"] == "true" + if isCreator, !started { + // Unstarted Create screens are not sessions — open a fresh in-memory Create instead. + NotificationCenter.default.post( + name: ChatShellNotification.startPluginCreation, + object: nil + ) + return + } + if isCreator { + workspace = .pluginsCreate + } else { + workspace = .chats + } + chatSessions.selectSession(id: session.sessionID) + } } #Preview { @@ -238,15 +287,21 @@ struct SidebarRow: Identifiable, Hashable, Sendable { enum SidebarPrimaryActions { static let newChat = SidebarRow(id: "new-chat", icon: "plus.circle.fill", title: "New chat") - static let newPlugin = SidebarRow(id: "new-plugin", icon: "puzzlepiece.extension.fill", title: "Plugins") + static let plugins = SidebarRow(id: "plugins", icon: "puzzlepiece.extension.fill", title: "Plugins") + static let pluginsCreate = SidebarRow(id: "plugins-create", icon: "plus.square", title: "Create") + static let pluginsList = SidebarRow(id: "plugins-list", icon: "list.bullet", title: "List") + /// Kept for older tests / settings that still refer to the former single Plugins row. + static let newPlugin = plugins } struct SidebarActionRow: View { let row: SidebarRow + var indented: Bool = false var action: (() -> Void)? - init(row: SidebarRow, action: (() -> Void)? = nil) { + init(row: SidebarRow, indented: Bool = false, action: (() -> Void)? = nil) { self.row = row + self.indented = indented self.action = action } @@ -268,7 +323,8 @@ struct SidebarActionRow: View { } .font(.callout) .padding(.vertical, 6) - .padding(.horizontal, 10) + .padding(.leading, indented ? 28 : 10) + .padding(.trailing, 10) .background(row.isProminent ? Color.black.opacity(0.06) : Color.clear, in: RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) diff --git a/ui/uiTests/ChatTabRoutingTests.swift b/ui/uiTests/ChatTabRoutingTests.swift index cc1220e4..cbe7e22b 100644 --- a/ui/uiTests/ChatTabRoutingTests.swift +++ b/ui/uiTests/ChatTabRoutingTests.swift @@ -89,21 +89,32 @@ import CoreGraphics @Test func newPluginSitsBelowNewChatInTheSidebar() { #expect(SidebarPrimaryActions.newChat.title == "New chat") - #expect(SidebarPrimaryActions.newPlugin.title == "Plugins") - #expect(SidebarPrimaryActions.newPlugin.id == "new-plugin") + #expect(SidebarPrimaryActions.plugins.title == "Plugins") + #expect(SidebarPrimaryActions.pluginsCreate.title == "Create") + #expect(SidebarPrimaryActions.pluginsList.title == "List") + #expect(SidebarPrimaryActions.plugins.id == "plugins") + #expect(SidebarPrimaryActions.pluginsCreate.id == "plugins-create") + #expect(SidebarPrimaryActions.pluginsList.id == "plugins-list") } - @Test func pluginsWorkspaceSubtabsAreCreateAndPlugins() { - #expect(PluginsWorkspaceSubtab.allCases.map(\.rawValue) == ["Create plugin", "Plugins"]) - } - - @Test func chatTabBarHidesPluginTabsInChatFilter() { - #expect(ChatTabBarView.TabFilter.chats != .plugins) + @Test func chatTabBarIncludesOngoingCreatesOnly() { + #expect(ChatTabBarView.TabFilter.chats != .pluginsCreate) + let fresh = ChatTab.pluginCreator() + #expect(fresh.isOngoingPluginCreator == false) + var ongoing = ChatTab.pluginCreator() + ongoing.turns.append( + ChatTurn(prompt: "Slack bot", response: "Next question", status: .complete) + ) + #expect(ongoing.isOngoingPluginCreator) } - @Test func pluginsMenuIsSeparateFromChatMenu() { - #expect(AppWorkspace.plugins != .chats) - #expect(SidebarPrimaryActions.newPlugin.title == "Plugins") + @Test func pluginsCreateAndListAreSeparateFromChat() { + #expect(AppWorkspace.pluginsCreate != .chats) + #expect(AppWorkspace.pluginsList != .chats) + #expect(AppWorkspace.pluginsCreate != .pluginsList) + #expect(AppWorkspace.pluginsCreate.isPluginsSection) + #expect(AppWorkspace.pluginsList.isPluginsSection) + #expect(!AppWorkspace.chats.isPluginsSection) } @Test func pluginCreatorIntroExplainsWhatAPluginIs() { @@ -132,18 +143,50 @@ import CoreGraphics } @MainActor - @Test func newPluginOpensAFreshCreatorTabEachTime() { + @Test func createPluginReusesUnusedCreatorTabAndDoesNotPersistYet() { let store = ChatSessionStore() let first = store.openOrFocusPluginCreator() let second = store.openOrFocusPluginCreator() - #expect(first != second) + #expect(first == second) #expect(PluginSpecProcession.isCreatorTabID(first)) - #expect(PluginSpecProcession.isCreatorTabID(second)) - #expect(store.tabs.filter(\.isPluginCreator).count == 2) - #expect(store.selectedSessionID == second) + #expect(store.tabs.filter(\.isPluginCreator).count == 1) + #expect(store.selectedSessionID == first) + #expect(store.selectedTab?.isUnusedPluginCreator == true) #expect(store.selectedTab?.title == PluginSpecProcession.pluginsTabTitle) } + @MainActor + @Test func unusedCreatorIsHiddenFromChatTabFilter() { + let unused = ChatTab.pluginCreator() + #expect(unused.isUnusedPluginCreator) + var started = ChatTab.pluginCreator() + started.turns.append( + ChatTurn(prompt: "Slack bot", response: "Next", status: .complete) + ) + #expect(started.isOngoingPluginCreator) + #expect(!started.isUnusedPluginCreator) + } + + @MainActor + @Test func retirePluginCreatorTabsRemovesCreateTabsBeforePluginOpens() { + let store = ChatSessionStore() + let creatorID = store.openOrFocusPluginCreator() + _ = store.openOrFocusPlugin(pluginID: "slack-connector-1", surface: .thread, title: "/slack-connector-1") + #expect(store.tabs.contains { $0.id == creatorID }) + store.retirePluginCreatorTabs() + #expect(store.tabs.contains { $0.id == creatorID } == false) + #expect(store.tabs.contains { $0.pluginID == "slack-connector-1" }) + #expect(store.tabs.contains(where: \.isPluginCreator) == false) + } + + @MainActor + @Test func factoryProgressListsSkillAfterAgentPluginSpec() { + let ids = PluginCreationController.factoryProgressStepOrder.map(\.id) + #expect(ids == ["credentials", "spec", "docs", "skill", "factory", "review", "trial"]) + #expect(ids.firstIndex(of: "spec")! < ids.firstIndex(of: "skill")!) + #expect(ids.firstIndex(of: "credentials")! < ids.firstIndex(of: "spec")!) + } + @MainActor @Test func recentsChannelTabOpensThePluginInboxInstead() { let store = ChatSessionStore() diff --git a/ui/uiTests/InAppNotificationBannerChromeTests.swift b/ui/uiTests/InAppNotificationBannerChromeTests.swift new file mode 100644 index 00000000..2349b986 --- /dev/null +++ b/ui/uiTests/InAppNotificationBannerChromeTests.swift @@ -0,0 +1,13 @@ +import Testing +@testable import ui + +@Suite struct InAppNotificationBannerChromeTests { + @Test func kindsExposeDistinctSymbolsAndAccents() { + let kinds: [InAppNotificationKind] = [.message, .success, .warning, .failure, .info] + let symbols = Set(kinds.map(\.symbolName)) + #expect(symbols.count == kinds.count) + #expect(InAppNotificationKind.message.symbolName.contains("bubble")) + #expect(InAppNotificationKind.failure.symbolName.contains("octagon")) + #expect(InAppNotificationBannerChrome.cornerRadius >= 16) + } +} diff --git a/ui/uiTests/MessagingMarkdownTextTests.swift b/ui/uiTests/MessagingMarkdownTextTests.swift index 8e11c197..551b559b 100644 --- a/ui/uiTests/MessagingMarkdownTextTests.swift +++ b/ui/uiTests/MessagingMarkdownTextTests.swift @@ -17,4 +17,12 @@ import Testing let attributed = MessagingMarkdownText.attributed("hello") #expect(String(attributed.characters) == "hello") } + + @Test func parsesMarkdownLinks() { + let attributed = MessagingMarkdownText.attributed( + "See [AccuWeather](https://www.accuweather.com) for details." + ) + #expect(attributed.containsLinks) + #expect(String(attributed.characters).contains("AccuWeather")) + } } From 909ecf8bd05203049a67871993a28d16acf97f4e Mon Sep 17 00:00:00 2001 From: David Choi Date: Fri, 18 Sep 2026 20:43:14 -0400 Subject: [PATCH 4/4] Fix PluginFactoryTests for required skills and empty runtimeJSON. Factory releases now require skills//SKILL.md and ship empty runtimeJSON; update fixtures and expectations so CI unit tests pass. Co-authored-by: Cursor --- .../PluginTests/PluginFactoryTests.swift | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift b/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift index 0ba58a74..0473681a 100644 --- a/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift +++ b/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift @@ -107,8 +107,8 @@ import Testing #expect(release.pluginID == "weather-tool") #expect(release.version == "1.2.3") - #expect(release.runtimeJSON.contains("\"language\":\"go\"")) - #expect(release.runtimeJSON.contains("plugin.go")) + #expect(release.runtimeJSON.isEmpty) + #expect(release.skillFiles.keys.contains("skills/weather/SKILL.md")) #expect(!release.contentHash.rawValue.isEmpty) #expect(release.verifyIntegrity()) var tampered = release.packageFiles() @@ -321,11 +321,13 @@ import Testing version: "1.0.0", description: "Weather summaries.", guestSource: guestGoSource(), + skillFiles: sampleSkillFileEntries(name: "weather") ) let draft = try response.draft() let manifest = try AgentPluginManifest.decode(Data(draft.manifestJSON.utf8)) #expect(manifest.schema == PluginContract.agentPluginSchema) #expect(manifest.derrick?.entrypoint == "./app.derrick/plugin.go") + #expect(draft.skillFiles.keys.contains("skills/weather/SKILL.md")) } @Test func builderNormalizesUnderscorePluginIDAndWritesSecretLabels() throws { @@ -334,6 +336,7 @@ import Testing version: "1.0.0", description: "Slack send and receive.", guestSource: guestGoSource(), + skillFiles: sampleSkillFileEntries(name: "slack"), secrets: [ try PluginSecretField(id: "username", label: "Slack username", kind: .username), try PluginSecretField(id: "password", label: "Slack password", kind: .password), @@ -353,6 +356,7 @@ import Testing version: "1.0.0", description: "Slack send and receive.", guestSource: guestGoSource(), + skillFiles: sampleSkillFileEntries(name: "slack"), role: .connector, messagingOps: ["send_message"] ) @@ -372,6 +376,7 @@ import Testing version: "1.0.0", description: "Slack full sync.", guestSource: guestGoSource(), + skillFiles: sampleSkillFileEntries(name: "slack"), role: .connector ) let draft = try response.draft() @@ -616,12 +621,17 @@ import Testing @Test func builderDraftFromModelTextAcceptsNestedTestInputJSON() throws { let goJSON = String(data: try JSONEncoder().encode(guestGoSource()), encoding: .utf8)! + let skillJSON = String( + data: try JSONEncoder().encode(sampleSkillFileEntries(name: "weather")), + encoding: .utf8 + )! let text = """ - {"description":"Weather","go_source":\(goJSON),"test_input_json":{"kind":"manual"}} + {"description":"Weather","go_source":\(goJSON),"test_input_json":{"kind":"manual"},"skill_files":\(skillJSON)} """ let draft = try PluginFactoryBuilderResponse.draft(fromModelText: text) #expect(String(decoding: draft.testInput, as: UTF8.self).contains("manual")) #expect(draft.guestSource.contains("package main")) + #expect(draft.skillFiles.keys.contains("skills/weather/SKILL.md")) } @Test func builderDraftFromModelTextNamesMissingGoSource() { @@ -660,10 +670,27 @@ import Testing PluginFactoryDraft( manifestJSON: manifestJSON(), guestSource: guestGoSource(), - testInput: Data(#"{"kind":"manual"}"#.utf8) + testInput: Data(#"{"kind":"manual"}"#.utf8), + skillFiles: sampleSkillFiles(name: "weather") ) } + private func sampleSkillFiles(name: String) -> [String: String] { + [ + "skills/\(name)/SKILL.md": + "---\nname: \(name)\ndescription: Test skill\n---\n" + ] + } + + private func sampleSkillFileEntries(name: String) -> [PluginFactorySkillFile] { + [ + PluginFactorySkillFile( + path: "skills/\(name)/SKILL.md", + body: "---\nname: \(name)\ndescription: Test skill\n---\n" + ) + ] + } + private func manifestJSON() -> String { """ {"$schema":"\(PluginContract.agentPluginSchema)","name":"weather-tool","version":"1.2.3","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go"}}} @@ -704,7 +731,10 @@ private func connectorDraft(testInput: Data) -> PluginFactoryDraft { return PluginFactoryDraft( manifestJSON: manifestJSON, guestSource: connectorGuestGoSource(), - testInput: testInput + testInput: testInput, + skillFiles: [ + "skills/slack/SKILL.md": "---\nname: slack\ndescription: Test skill\n---\n" + ] ) }