diff --git a/README.md b/README.md index 38a03ac..01887e2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # SwiftASB -SwiftASB helps Swift apps work with the local Codex app-server without making app builders deal with Codex's raw app-server messages directly. +*Faster-Than-Light Framework for Custom Codex Apps and Integrations in Swift* + +Listen to the SwiftASB Codex apps promo clip: + + + +[Download the promo clip](docs/media/swiftasb-codex-apps-promo.mp3) ## Table of Contents @@ -17,25 +23,42 @@ SwiftASB helps Swift apps work with the local Codex app-server without making ap ### Status -SwiftASB has a supported v1 public API for the core local Codex app-server lifecycle. `v1.2.1` is the current released baseline. +SwiftASB is actively maintained and supported by Gale. Our current API is v1, and `v1.3.0` is the current and latest release. ### What This Project Is -TBD +SwiftASB is a native Swift client/runtime layer for AI coding agent app-servers and streaming orchestration systems. ### Motivation -TBD +I built SwiftASB because I saw so many others building and forking existing Apps for agentic coding on the desktop. I wanted to build my own, of course, but I also wanted to make it easier for anyone to build a custom UI tailored to the way they like to work. SwiftASB handles the complexity and rough edges, providing a rock-solid foundation for everyone from vibecoders to staff engineers. Just grab the `swiftasb-skills` plugin from [Socket Marketplace](https://www.github.com/gaelic-ghost/socket) and you're off to the races. ## Quick Start -Add SwiftASB from the GitHub package URL: +Add SwiftASB to your `Package.swift` dependencies: -https://github.com/gaelic-ghost/SwiftASB +```swift +.package(url: "https://github.com/gaelic-ghost/SwiftASB", from: "1.3.0"), +``` + +Then add the library product to your target dependencies: -Use release `v1.2.1` or newer unless your project intentionally pins an older version. +```swift +.product(name: "SwiftASB", package: "SwiftASB"), +``` -You also need a local Codex CLI installation with app-server support. SwiftASB currently reviews against the `0.130.x` Codex CLI app-server schema window, looks for `codex` in the usual command-line locations, and apps can provide an exact executable path when they need stricter control. +Check your Codex version: + +```bash +codex --version +``` +*Note: SwiftASB supports the latest version of Codex CLI, as well as the prior two minor versions. This policy will be revised once Codex CLI reaches a v1.x.x release.* + +Add the Socket Marketplace to Codex and enable the SwiftASB Skills Plugin: + +```bash +codex plugin marketplace add socket +``` For copy-pasteable startup code, open the DocC getting-started guide: @@ -45,9 +68,9 @@ For copy-pasteable startup code, open the DocC getting-started guide: Use SwiftASB when an app needs to show what Codex is doing right now, keep recent command and file activity visible, answer interactive requests, or build SwiftUI state around a running Codex turn. -For app-wide sidebars and launchers, `CodexAppServer.makeLibrary()` provides observable stored-thread lists, cwd or repository grouping, stable worktree groups, repository/worktree thread filters, refresh actions, library-local selection state, app-server-owned worktree snapshots, and app-wide model, MCP, and hook diagnostics snapshots. Thread handles can also name, archive, unarchive, compact, and roll back stored threads through thread-scoped methods. +For app-wide sidebars and launchers, `CodexAppServer.makeLibrary()` provides observable stored-thread lists, cwd or repository grouping, stable worktree groups, repository/worktree thread filters, refresh actions, library-local selection state, app-server-owned worktree snapshots, selected-worktree Git status, and app-wide model, MCP, and hook diagnostics snapshots. Thread handles can also name, archive, unarchive, compact, and roll back stored threads through thread-scoped methods. -Use `CodexAppServer.fs` when a sandboxed client needs filesystem metadata, directory listings, file bytes, file discovery, fuzzy file lookup, or file-change watches through the Codex app-server instead of reading local disk directly. File-discovery hits include match kind, matched character ranges, and ranking reasons for picker highlighting and result explanations. `CodexWorkspace` carries app-server-owned worktree, Git, workspace permission selection, active permission-profile provenance, and runtime filesystem/network permission facts for started threads and turns. Use `CodexAppServer.config` for effective config reads, and `CodexAppServer.extensions` for app, skill, plugin, and collaboration-mode inventory. +Use `CodexAppServer.fs` when a sandboxed client needs filesystem metadata, directory listings, file bytes, file discovery, fuzzy file lookup, or file-change watches through the Codex app-server instead of reading local disk directly. File-discovery hits include match kind, matched character ranges, and ranking reasons for picker highlighting and result explanations. `CodexWorkspace` carries app-server-owned worktree, Git, workspace permission selection, active permission-profile provenance, and runtime filesystem/network permission facts for started threads and turns. Use `CodexAppServer.config` for effective config reads, and `CodexAppServer.extensions` for app, skill, plugin, collaboration-mode inventory, and configured plugin-marketplace upgrades. Use `CodexAppServer.ThreadListQD`, `CodexFS.FileDiscoveryQD`, `CodexThread.HistoryWindowQD`, `CodexThread.RecentFilesQD`, and `CodexThread.RecentCommandsQD` when a client needs to preserve repeatable list, file-discovery, history-window, or recent-activity intent without depending on Core Data, SwiftData, direct filesystem reads, or raw app-server paging details. diff --git a/ROADMAP.md b/ROADMAP.md index 1df697e..a8a1a3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,7 +59,8 @@ | Thread management actions | `Partially shipped` | `CodexThread.setName(...)` wraps `thread/name/set`, `CodexThread.archive()` wraps `thread/archive`, `CodexThread.unarchive()` wraps `thread/unarchive`, `CodexThread.updateMetadata(...)` wraps `thread/metadata/update`, and `CodexThread.rollbackLastTurns(...)` wraps `thread/rollback`. Metadata patches use an explicit replace/clear/unchanged field model so callers can express upstream null-vs-omitted semantics. Rollback reconciles visible local history to the app-server response, records a rollback marker, and now has opt-in live coverage against a disposable non-ephemeral thread, but it does not preserve full removed turn payloads as forensic archive data yet. | | App-server filesystem reads and watches | `Partially shipped` | `CodexAppServer.fs` now exposes the `CodexFS` namespace for app-server-routed metadata, directory listing, file-byte reads, bounded file discovery, SwiftASB-owned fuzzy ranking over app-server-returned entries, UI-ready discovery match metadata, and filesystem watch notifications. This gives sandboxed clients a Codex-owned path for basic filesystem facts and picker/search views instead of requiring direct local disk reads. File mutations and repository-root discovery remain separate schema families for later promotion decisions. | | App-server config reads | `Partially shipped` | `CodexAppServer.config` now exposes `CodexConfig` for effective config and requirements reads through the app-server. Effective config stays JSON-shaped for now so SwiftASB does not turn unstable config keys into long-lived public Swift fields too early. | -| App-server extension inventory | `Partially shipped` | `CodexAppServer.extensions` now exposes `CodexAppServer.CodexExtensions` for app, skill, plugin, and collaboration-mode inventory. Plugin install/uninstall/upgrade and skills config writes remain unpromoted until their permission and review model is clearer. | +| App-server extension inventory and maintenance | `Partially shipped` | `CodexAppServer.extensions` now exposes `CodexAppServer.CodexExtensions` for app, skill, plugin, and collaboration-mode inventory, plus `upgradeMarketplace(_:)` for upgrading already-configured plugin marketplaces through app-server `command/exec` under the `extensionMaintenance` feature category. Plugin installs, removals, sharing changes, and skills config writes remain unpromoted until their permission and review model is clearer. | +| SwiftASB feature permission policy | `Fifth slice shipped` | `SwiftASBFeaturePolicy`, `SwiftASBFeatureCategory`, and `SwiftASBHostAccess` now describe feature-category defaults and host access declarations, and `CodexAppServer.Configuration` accepts the app-wide feature policy. SwiftASB also has an internal `command/exec` protocol/executor path for future typed Git/GitHub helper intents, `CodexAppServer.Library` selected-worktree Git status refresh through the default-enabled `gitObservability` category, `CodexAppServer.featureOperationEvents()` for human-readable SwiftASB-owned mutation records, and a typed marketplace-upgrade maintenance intent. Maintainer planning targets quiet read-only Git/config/extension inventory by default, one-time mutation-category enablement, and human-readable mutation events instead of repeated prompts. See [`docs/maintainers/feature-permission-policy-plan.md`](docs/maintainers/feature-permission-policy-plan.md). | | Thread goals | `Partially shipped` | `CodexThread.readGoal()`, `setGoal(...)`, and `clearGoal()` wrap `thread/goal/get`, `thread/goal/set`, and `thread/goal/clear`, and thread event streams now surface goal updated and cleared notifications. | | Paged turn-history flow | `Shipped` | `listThreadTurns(...)` wraps `thread/turns/list`, returns typed paged turn values, and can now seed the local history cache even before that thread has been loaded locally. | | Typed async thread event stream | `Partially shipped` | `CodexThread.events` now streams `thread/started`, `thread/status/changed`, `thread/archived`, `thread/unarchived`, `thread/name/updated`, `thread/tokenUsage/updated`, `thread/goal/updated`, `thread/goal/cleared`, and `thread/closed`, but broader thread lifecycle coverage is still pending. | @@ -76,7 +77,7 @@ | Non-UI local history-reading helpers | `Partially shipped` | `CodexThread` now exposes a lightweight `HistoryWindow` page shape for recent local history, older or newer local windows around a known boundary turn id, centered `windowAroundTurn(...)` reads, centered `windowAroundItem(...)` reads, direct `ClosedTurn` reads for one turn, and convenience array helpers over those same windows. This gives non-UI callers an intentional path into the local history store without binding a UI-oriented observable, while still deferring a broader public cursor model, transcript search surface, and richer history-query helpers. | | Public API curation | `Shipped / ongoing` | The source-organization pass has split app-wide model, MCP, thread-management, history, and observable companion values into focused public files while preserving `CodexAppServer`, `CodexThread`, and `CodexTurnHandle` as the three real owners. The connected public-surface review closed the v1 ownership model; post-v1 curation now includes app-server-owned project identity and thread source facts for launcher UI without exposing generated wire models. Future curation should stay tied to concrete public API additions. | | DocC documentation | `Shipped / ongoing` | `Sources/SwiftASB/SwiftASB.docc/` contains a package landing page, public-handle extension pages, conceptual articles for app-wide capabilities, interactive lifecycle, thread management, history/observable companions, generated-wire boundary notes, and copy-pasteable walkthroughs for startup, progress/approval handling, diagnostics/history, and SwiftUI observable companions. The catalog is validated through Xcode `docbuild`; future work is ordinary stale-link, prose, and symbol-comment refinement as the public API grows. | -| Swift Package Index readiness | `Shipped` | `.spi.yml` declares `SwiftASB` as the documentation target, and Swift Package Index lists `gaelic-ghost/SwiftASB` with a documentation link, compatibility/build results, Package ID `9B5839D9-9551-473F-A939-841534A3FC55`, and a 2026-05-06 update timestamp for the latest confirmed indexed release. Recheck SPI after the `v1.2.1` tag is published. | +| Swift Package Index readiness | `Shipped` | `.spi.yml` declares `SwiftASB` as the documentation target, and Swift Package Index lists `gaelic-ghost/SwiftASB` with a documentation link, compatibility/build results, Package ID `9B5839D9-9551-473F-A939-841534A3FC55`, and a 2026-05-06 update timestamp for the latest confirmed indexed release. Recheck SPI after the `v1.3.0` tag is published. | | Contributor documentation split | `Shipped` | `README.md` is now focused on Swift and SwiftUI package users, while `CONTRIBUTING.md` owns contributor setup, validation, DocC, live-test flags, generated-wire refresh, and PR expectations. | | `CodexTurnHandle` live observable companion | `Partially shipped` | `CodexTurnHandle` owns a live `Minimap` companion that is attached when the handle is created and maintains current-state call snapshots for command, file-edit, dynamic-tool, collab-tool, and MCP item activity. It also now mirrors whether thread context compaction is active for the turn and supports explicit `complete()` handoff into a caller-owned sealed turn snapshot. | | Additional turn event mapping | `Partially shipped` | The public event layer covers the current interactive lifecycle plus the item-start and item-complete events needed for observable call-state mirrors. Raw command-output and file-change-output deltas now stay internal as transport detail but drive the shipped `RecentCommands` and `RecentFiles` companions, and streamed or patch-updated payloads are preserved when later completed snapshots are thinner. Richer MCP-progress detail still remains internal, while warning, guardian-warning, config-warning, deprecation, MCP-server-status, remote-control-status, model-reroute, and model-verification notifications now surface through hand-owned diagnostic events. | @@ -104,7 +105,7 @@ The next meaningful package step is no longer proving the v1 interactive lifecycle, SPI visibility, basic history hydration, first-pass reconciliation, or command-approval completion. Those slices now exist and shipped in the -`v1.2.1` baseline. +`v1.3.0` baseline. The next meaningful work is to widen the reviewed app-server schema and protocol coverage before adding more public query descriptors. Descriptors should compile @@ -137,24 +138,32 @@ The package can now: That means the current priority order is: -1. Review the currently bundled app-server schema families that are not yet +1. Implement the feature permission policy described in + [`docs/maintainers/feature-permission-policy-plan.md`](docs/maintainers/feature-permission-policy-plan.md): + read-only and inventory features stay enabled by default, mutation + categories are enabled once by the consuming app, and every write/mutation + emits human-readable observable operation events instead of recurring + prompts. +2. Review the currently bundled app-server schema families that are not yet promoted through SwiftASB's hand-shaped protocol/public surfaces, with special attention to workspace, filesystem, Git/repository, and app-server action families that let sandboxed clients ask Codex for facts instead of reading disk directly. -2. Continue promoting app-server-owned workspace and Git facts beyond the +3. Continue promoting app-server-owned workspace and Git facts beyond the current cwd, origin metadata, runtime permission-profile provenance, and `CodexWorkspace.WorktreeSnapshot`: Git worktree root if upstream exposes it, branch/SHA observables, and any workspace listing/search/status actions that - upstream already owns. -3. Add a deliberate `codex mcp-server` support plan as a separate integration + upstream already owns. Use sandboxed `command/exec` fallback only for typed + Git fact intents that upstream does not expose yet; do not use unsandboxed + `process/spawn` for permission-sensitive helpers. +4. Add a deliberate `codex mcp-server` support plan as a separate integration lane from `codex app-server`. The current MCP mode should be treated as an external-agent bridge with a smaller stdio tool surface, not as a replacement for the app-server lifecycle SwiftASB already wraps. Verify the live `initialize`, `tools/list`, `resources/list`, and `prompts/list` surface before deciding whether SwiftASB should expose client helpers, examples, or a dedicated package module for it. -4. Evaluate a Worktrunk-based worktree system only after the workspace and Git +5. Evaluate a Worktrunk-based worktree system only after the workspace and Git fact boundary is clearer. The useful shape is a SwiftASB-supported way for clients to ask Codex-owned services for workspace/worktree identity, branch/status facts, and safe handoff points, without committing @@ -164,32 +173,32 @@ That means the current priority order is: report user-granted directory access or pass a security-scoped bookmark, following Apple's sandbox model instead of treating local disk access as an implicit SwiftASB capability. -5. Plan command-execution-backed Git and GitHub actions for consuming apps that +6. Plan command-execution-backed Git and GitHub actions for consuming apps that want Codex-like repository operations through SwiftASB. The first useful shape should route explicit user-reviewed actions through installed `git` and optional `gh` binaries when available, keep command output and approval decisions observable, and reuse the app-access/perms model instead of silently expanding filesystem authority. -6. Explore a custom approval auto-reviewer after the answerable +7. Explore a custom approval auto-reviewer after the answerable server-request model is stable enough to distinguish advisory review from action approval. The first useful slice should classify approval requests and produce review recommendations; automatically answering requests should wait for an explicit policy model and tests that prove dangerous actions stay user-controlled. -7. Finish the next descriptor increment beyond the current list, history, and +8. Finish the next descriptor increment beyond the current list, history, and recent-activity descriptors: broader public cursor semantics, any selection-centered reads that become necessary, and later search-hit hydration. -8. Finish the next `CodexAppServer.Library` slice around app-wide +9. Finish the next `CodexAppServer.Library` slice around app-wide settings/actions, using promoted app-server facts and descriptor values where they make list and selection behavior explicit. -9. Keep tuning `RecentTurns`, `RecentFiles`, and `RecentCommands` after v1 as +10. Keep tuning `RecentTurns`, `RecentFiles`, and `RecentCommands` after v1 as real UI usage teaches better calibration. The v1 review keeps the separate turn/file/command companions, current cache-policy names and defaults, selection/visibility protection, slimming behavior, and rehydration model as stable enough; remaining work is calibration and richer previews, not proving the model exists. -10. Keep future Codex CLI schema additions classified before public promotion: +11. Keep future Codex CLI schema additions classified before public promotion: `excludeTurns` remains public on resume/fork request models because it directly supports the existing paged history model; permission-profile families stay internal until SwiftASB owns a deliberate public permission @@ -198,16 +207,16 @@ That means the current priority order is: sessions, marketplace/account-management families, and guardian denied-action approval remain post-v1 until their consumer workflows are clearer. -11. Flesh out archive-aware retention and eviction beyond the current list-driven +12. Flesh out archive-aware retention and eviction beyond the current list-driven archive-state drift correction. -12. Add any sharper binary-discovery diagnostics we want alongside the +13. Add any sharper binary-discovery diagnostics we want alongside the current-reviewed compatibility window before a broader compatibility release. -13. Revisit whether a convenience `run(...)` API is earned only after the +14. Revisit whether a convenience `run(...)` API is earned only after the lower-level lifecycle has more production mileage. ## V1 Readiness Checklist -This checklist records the work that made `SwiftASB` ready for the `v1.2.1` +This checklist records the work that made `SwiftASB` ready for the `v1.3.0` tag. The goal was not to make every possible app-server feature public before v1. The goal was to make the supported lifecycle honest, durable, well documented, and intentionally shaped. @@ -250,7 +259,7 @@ workflow earns them in a later feature release. diagnostics/capability API so Swift clients can show what hooks are active before a turn runs. Hook enable/disable mutation remains post-v1+ until the configuration-writing UX is clearer. -- [ ] Marketplace upgrade surfaces. +- [x] Marketplace upgrade surfaces for already-configured plugin marketplaces. - [ ] Account-management variants, including provider-specific account families such as Amazon Bedrock. - [ ] Richer MCP progress detail beyond the current dashboard/minimap summaries. @@ -259,6 +268,23 @@ workflow earns them in a later feature release. - [ ] Structured patch rendering for `RecentFiles`. - [ ] Mixed `RecentActivity` timeline. Keep `RecentTurns`, `RecentFiles`, and `RecentCommands` separate for v1. +- [x] Add SwiftASB feature-policy descriptors, read-only defaults, and + host-access declarations. +- [x] Add an app-wide stream and public event value for human-readable + SwiftASB-owned mutation records. +- [ ] Wire mutation-category enablement checks into concrete SwiftASB-owned + write actions and emit operation events from those actions. +- [x] Wire `extensionMaintenance` checks and operation events into configured + plugin-marketplace upgrades. +- [x] Promote sandboxed `command/exec` as the internal execution primitive for + typed Git/GitHub helper intents, while keeping unsandboxed `process/spawn` + out of permission-sensitive public helpers. +- [x] Add proactive Git observability refresh to `CodexAppServer.Library` so + selected threads/worktrees hydrate branch, SHA, repository, remote, and status + facts when `gitObservability` is enabled. +- [ ] Add the trusted Swift repo guidance sync category, starting with + Apple/Swift repo guidance, Git preflight, idempotent writes, observable + mutation events, and one-action rollback when possible. - [ ] Review and promote more app-server schema families before widening query descriptors, prioritizing workspace, filesystem, Git/repository, and app-server action surfaces that let sandboxed clients ask Codex for facts @@ -390,8 +416,8 @@ workflow earns them in a later feature release. ### Documentation And Examples -- [x] Update stale release references after the `v1.2.1` release. - Decision: README now names `v1.2.1` as the current released baseline and no +- [x] Update stale release references after the `v1.3.0` release. + Decision: README now names `v1.3.0` as the current released baseline and no longer describes the package as early development. - [x] Finish DocC symbol comments for the supported lifecycle, not just the conceptual articles. @@ -576,10 +602,10 @@ workflow earns them in a later feature release. the `release/v1.0.0` branch on 2026-05-02 and on the `release/v1.0.1-prep` branch on 2026-05-02. - [x] Decide whether another targeted `v0.9.x` patch release is needed before - `v1.2.1`, or whether the remaining work should go straight into the v1 + `v1.3.0`, or whether the remaining work should go straight into the v1 release branch. Decision: no additional `v0.9.x` patch is needed. The remaining work should go - straight into the `v1.2.1` release branch. + straight into the `v1.3.0` release branch. - [x] Prepare v1 release notes with explicit sections for public surface, intentionally internal surfaces, compatibility window, migration notes, validation performed, and known post-v1 work. @@ -633,7 +659,7 @@ workflow earns them in a later feature release. #### Migration Notes - Existing `v0.9.x` consumers should update the SwiftPM dependency to - `from: "1.2.1"` once the tag is published. + `from: "1.3.0"` once the tag is published. - The v1 API surface has removed stale pre-v1 compatibility shims and phantom fields that no longer exist in the reviewed `v0.128.0` schema. - Same-thread overlapping turns are rejected client-side with @@ -658,7 +684,7 @@ workflow earns them in a later feature release. - Keep an eye on future Swift Package Index builds after compatibility-window or DocC changes; the `v1.1.1` listing and documentation link are live, and - `v1.2.1` should be rechecked after the patch tag is indexed. + `v1.3.0` should be rechecked after the patch tag is indexed. - Add broader live server-request coverage for permissions and MCP elicitation if those become stronger public runtime guarantees. - Continue tuning recent companion cache calibration, richer file previews, @@ -1233,7 +1259,7 @@ Completed - [x] Add version-compatibility policy notes for the local Codex binary. - [x] Refresh the compatibility window and promoted generated snapshot against the current `v0.124.0` schema dump once the added endpoint, notification, and field families have been classified. - [x] Curate the public API before v1 by splitting large source files along existing responsibility boundaries where still helpful, tightening public names/defaults, and finishing targeted source-level symbol documentation for the supported lifecycle. - Decision: completed for the `v1.2.1` boundary through the public API audit, + Decision: completed for the `v1.3.0` boundary through the public API audit, symbol inventory, source-comment pass, and focused public file organization. - [x] Add the first DocC documentation catalog before v1, including a package landing page, public-handle topic groups, and conceptual articles for the interactive lifecycle, history companions, and generated-wire boundary. - [x] Validate the DocC catalog through Xcode `docbuild` and document the maintainer command. @@ -1290,7 +1316,9 @@ Completed - [ ] Add a broader public history cursor or transcript search surface after the local history contract is clearer. - [ ] Add richer MCP progress detail either as public event cases or as deeper observable companion state. - [ ] Add guardian denied-action approval once SwiftASB owns a stable request and response model for that control flow. -- [ ] Add marketplace upgrade and account-management surfaces after SwiftASB has a concrete app-wide management workflow. +- [ ] Add marketplace upgrade and account-management surfaces after SwiftASB has + a concrete app-wide management workflow and feature-category policy for + extension inventory, maintenance, and mutation. - [ ] Add external-agent config import surfaces after external-agent configuration becomes a public app-server management workflow. - [ ] Add structured patch rendering for `RecentFiles`. - [x] Add richer `CodexFS.FileDiscoveryHit` search metadata soon, including @@ -1338,3 +1366,4 @@ Completed - 2026-05-08: Added stable `CodexAppServer.Library.worktreeGroups`, selected worktree/repository context, and repository/worktree thread filters for app-wide sidebars without changing the caller-selected visible grouping mode. - 2026-05-08: Added a future optional macOS app-access layer for user-granted directory access and security-scoped bookmark handoff, keeping richer local repository and file-write enrichment separate from app-server-reported workspace facts. - 2026-05-08: Added future command-execution-backed Git and GitHub actions through installed `git` and optional `gh`, scoped by explicit user-reviewed command intents and the app-access permission model. +- 2026-05-09: Added the feature permission policy implementation plan, shifting the next app-wide action work toward quiet read-only defaults, one-time mutation-category enablement, proactive Git observability, and human-readable mutation events. diff --git a/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift b/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift index 681c3cb..371eee3 100644 --- a/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift +++ b/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift @@ -36,6 +36,7 @@ enum CodexAppServerProtocolEvent: Equatable, Sendable { case turnCompleted(CodexWireTurnCompletedNotification) case itemStarted(CodexWireItemStartedNotification) case itemCompleted(CodexWireItemCompletedNotification) + case commandExecOutputDelta(CodexWireCommandExecOutputDeltaNotification) case commandExecutionOutputDelta(CodexWireCommandExecutionOutputDeltaNotification) case fileChangeOutputDelta(CodexWireFileChangeOutputDeltaNotification) case fileChangePatchUpdated(CodexWireFileChangePatchUpdatedNotification) @@ -66,6 +67,42 @@ struct CodexProtocolModelProviderCapabilitiesReadResponse: Decodable, Equatable, let webSearch: Bool } +struct CodexProtocolCommandExecParams: Encodable, Equatable, Sendable { + let command: [String] + let cwd: String? + let disableOutputCap: Bool? + let disableTimeout: Bool? + let env: [String: String?]? + let outputBytesCap: Int? + let permissionProfile: CodexWirePermissionProfile? + let processID: String? + let sandboxPolicy: CodexWireSandboxPolicy? + let size: TerminalSize? + let streamStdin: Bool? + let streamStdoutStderr: Bool? + let timeoutMS: Int? + let tty: Bool? + + enum CodingKeys: String, CodingKey { + case command, cwd, disableOutputCap, disableTimeout, env, outputBytesCap, permissionProfile + case processID = "processId" + case sandboxPolicy, size, streamStdin, streamStdoutStderr + case timeoutMS = "timeoutMs" + case tty + } + + struct TerminalSize: Encodable, Equatable, Sendable { + let cols: Int + let rows: Int + } +} + +struct CodexProtocolCommandExecResponse: Decodable, Equatable, Sendable { + let exitCode: Int + let stderr: String + let stdout: String +} + struct CodexProtocolCollaborationModeListParams: Encodable, Equatable, Sendable {} struct CodexProtocolThreadMetadataUpdateParams: Encodable, Equatable, Sendable { diff --git a/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift b/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift index b8f2fa6..9fa868b 100644 --- a/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift +++ b/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift @@ -33,6 +33,7 @@ struct CodexAppServerProtocol { case collaborationModeList = "collaborationMode/list" case configRead = "config/read" case configRequirementsRead = "configRequirements/read" + case commandExec = "command/exec" case hooksList = "hooks/list" case modelList = "model/list" case modelProviderCapabilitiesRead = "modelProvider/capabilities/read" @@ -288,6 +289,16 @@ struct CodexAppServerProtocol { ) } + func makeCommandExecRequest( + id: CodexRPCRequestID, + params: CodexProtocolCommandExecParams + ) throws -> Data { + try encodeRequest( + JSONRPCRequestEnvelope(id: id, method: .commandExec, params: params), + method: .commandExec + ) + } + func makeAppListRequest( id: CodexRPCRequestID, params: CodexWireAppsListParams @@ -740,6 +751,18 @@ struct CodexAppServerProtocol { ) } + func decodeCommandExecResponse( + _ responsePayload: Data, + expectedID: CodexRPCRequestID + ) throws -> CodexProtocolCommandExecResponse { + try decodeResponse( + responsePayload, + expectedID: expectedID, + method: .commandExec, + resultType: CodexProtocolCommandExecResponse.self + ) + } + func decodeAppListResponse( _ responsePayload: Data, expectedID: CodexRPCRequestID @@ -1195,6 +1218,14 @@ struct CodexAppServerProtocol { resultType: CodexWireCommandExecutionOutputDeltaNotification.self ) ) + case "command/exec/outputDelta": + return .commandExecOutputDelta( + try decodeNotification( + payload, + method: method, + resultType: CodexWireCommandExecOutputDeltaNotification.self + ) + ) case "item/fileChange/outputDelta": return .fileChangeOutputDelta( try decodeNotification( diff --git a/Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift b/Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift index ece41b8..379a934 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift @@ -31,6 +31,7 @@ extension CodexAppServer { public var arguments: [String] public var currentDirectoryURL: URL? public var environment: [String: String]? + public var featurePolicy: SwiftASBFeaturePolicy /// Creates launch configuration for the app-server subprocess. /// @@ -38,17 +39,20 @@ extension CodexAppServer { /// supported local install locations. Omitting `arguments` starts the /// standard stdio app-server command. Omitting `currentDirectoryURL` or /// `environment` lets the launched process inherit the caller's current - /// process defaults. + /// process defaults. Omitting `featurePolicy` uses SwiftASB's built-in + /// feature-category defaults. public init( codexExecutableURL: URL? = nil, arguments: [String] = ["app-server", "--listen", "stdio://"], currentDirectoryURL: URL? = nil, - environment: [String: String]? = nil + environment: [String: String]? = nil, + featurePolicy: SwiftASBFeaturePolicy = .defaults ) { self.codexExecutableURL = codexExecutableURL self.arguments = arguments self.currentDirectoryURL = currentDirectoryURL self.environment = environment + self.featurePolicy = featurePolicy } } diff --git a/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift b/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift index 7f15c84..bd98b47 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift @@ -1,3 +1,5 @@ +import Foundation + public extension CodexAppServer { /// App-server-owned extension inventory for apps, skills, plugins, and collaboration modes. struct CodexExtensions: Sendable { @@ -219,6 +221,32 @@ public extension CodexAppServer { } } + public struct MarketplaceUpgradeRequest: Sendable, Equatable { + public var currentDirectoryPaths: [String]? + public var marketplaceName: String + public var timeoutMilliseconds: Int + + public init( + marketplaceName: String, + currentDirectoryPaths: [String]? = nil, + timeoutMilliseconds: Int = 120_000 + ) { + self.marketplaceName = marketplaceName + self.currentDirectoryPaths = currentDirectoryPaths + self.timeoutMilliseconds = max(1_000, timeoutMilliseconds) + } + } + + public struct MarketplaceUpgradeResult: Sendable, Equatable { + public let command: [String] + public let exitCode: Int + public let marketplaceName: String + public let operationID: String + public let status: SwiftASBFeatureOperationEvent.Status + public let stderr: String + public let stdout: String + } + public struct PluginDetail: Sendable, Equatable { public let apps: [AppSummary] public let description: String? @@ -290,6 +318,19 @@ public extension CodexAppServer { try await appServer.readExtensionPlugin(request) } + /// Upgrades an already-configured plugin marketplace through Codex. + /// + /// SwiftASB preflights the marketplace through `plugin/list`, runs the + /// installed Codex CLI's `plugin marketplace upgrade` command through + /// app-server `command/exec`, and emits a feature-operation event. New + /// marketplace installs and marketplace removals remain separate, + /// stricter mutation categories. + public func upgradeMarketplace( + _ request: MarketplaceUpgradeRequest + ) async throws -> MarketplaceUpgradeResult { + try await appServer.upgradeExtensionMarketplace(request) + } + public func listCollaborationModes() async throws -> CollaborationModeList { try await appServer.listExtensionCollaborationModes() } @@ -301,6 +342,105 @@ public extension CodexAppServer { } } +extension CodexAppServer { + func upgradeExtensionMarketplace( + _ request: CodexExtensions.MarketplaceUpgradeRequest + ) async throws -> CodexExtensions.MarketplaceUpgradeResult { + try requireFeatureEnabled(.extensionMaintenance, for: "plugin marketplace upgrade") + + let marketplaceName = request.marketplaceName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !marketplaceName.isEmpty else { + throw CodexAppServerError.invalidState( + reason: "SwiftASB cannot upgrade a plugin marketplace without a marketplace name." + ) + } + + let pluginSnapshot = try await listExtensionPlugins( + .init(currentDirectoryPaths: request.currentDirectoryPaths) + ) + guard let marketplace = pluginSnapshot.marketplaces.first(where: { $0.name == marketplaceName }) else { + throw CodexAppServerError.invalidState( + reason: """ + SwiftASB cannot upgrade plugin marketplace \(marketplaceName) because plugin/list did not report an existing marketplace with that name. \ + Refresh extension inventory and choose a configured marketplace before requesting maintenance. + """ + ) + } + + let startedAt = Date() + let operationID = "extension-maintenance:marketplace-upgrade:\(marketplaceName):\(UUID().uuidString)" + let command = [ + await codexCommandExecutablePath(), + "plugin", + "marketplace", + "upgrade", + marketplaceName, + ] + let result = try await executeCommand( + .init( + command: command, + outputBytesCap: 32_768, + timeoutMilliseconds: request.timeoutMilliseconds + ) + ) + let completedAt = Date() + let status: SwiftASBFeatureOperationEvent.Status = + result.exitCode == 0 ? .succeeded : .failed + let affectedPaths = marketplace.path.map { [$0] } ?? [] + let summary: String + if result.exitCode == 0 { + summary = "Upgraded plugin marketplace \(marketplaceName)." + } else { + summary = "Plugin marketplace \(marketplaceName) upgrade exited with code \(result.exitCode)." + } + + publishFeatureOperationEvent( + .init( + categoryID: .extensionMaintenance, + operationID: operationID, + title: "Upgrade plugin marketplace", + summary: summary, + reason: "Extension maintenance is enabled for already-configured plugin marketplaces.", + startedAt: startedAt, + completedAt: completedAt, + affectedPaths: affectedPaths, + commands: [ + .init(argv: command) + ], + appServerMethod: "command/exec", + intentKind: "extensionMarketplaceUpgrade", + status: status, + rollback: .unavailable, + diagnosticText: result.exitCode == 0 ? nil : Self.commandDiagnosticText(result) + ) + ) + + return .init( + command: command, + exitCode: result.exitCode, + marketplaceName: marketplaceName, + operationID: operationID, + status: status, + stderr: result.stderr, + stdout: result.stdout + ) + } + + private static func commandDiagnosticText(_ result: CommandExecResult) -> String { + let stderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + if !stderr.isEmpty { + return stderr + } + + let stdout = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if !stdout.isEmpty { + return stdout + } + + return "The command exited with code \(result.exitCode) and did not report output." + } +} + extension CodexAppServer.CodexExtensions.AppListPage { init(wireValue: CodexWireAppsListResponse) { self.init( diff --git a/Sources/SwiftASB/Public/CodexAppServer+CommandExecution.swift b/Sources/SwiftASB/Public/CodexAppServer+CommandExecution.swift new file mode 100644 index 0000000..4e71326 --- /dev/null +++ b/Sources/SwiftASB/Public/CodexAppServer+CommandExecution.swift @@ -0,0 +1,32 @@ +import Foundation + +extension CodexAppServer { + struct CommandExecRequest: Sendable, Equatable { + var command: [String] + var currentDirectoryPath: String? + var environment: [String: String?] + var outputBytesCap: Int? + var timeoutMilliseconds: Int? + + init( + command: [String], + currentDirectoryPath: String? = nil, + environment: [String: String?] = [:], + outputBytesCap: Int? = nil, + timeoutMilliseconds: Int? = nil + ) { + self.command = command + self.currentDirectoryPath = currentDirectoryPath + self.environment = environment + self.outputBytesCap = outputBytesCap + self.timeoutMilliseconds = timeoutMilliseconds + } + } + + struct CommandExecResult: Sendable, Equatable { + var exitCode: Int + var stdout: String + var stderr: String + } + +} diff --git a/Sources/SwiftASB/Public/CodexAppServer+GitObservability.swift b/Sources/SwiftASB/Public/CodexAppServer+GitObservability.swift new file mode 100644 index 0000000..18d1134 --- /dev/null +++ b/Sources/SwiftASB/Public/CodexAppServer+GitObservability.swift @@ -0,0 +1,160 @@ +import Foundation + +extension CodexAppServer { + internal func refreshGitStatus( + for worktree: CodexWorkspace.WorktreeSnapshot + ) async throws -> CodexWorkspace.GitStatusSnapshot? { + let cwd = worktree.currentDirectoryPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cwd.isEmpty else { return nil } + + let commandFacts = try await ( + root: gitOutput(["rev-parse", "--show-toplevel"], cwd: cwd), + sha: gitOutput(["rev-parse", "HEAD"], cwd: cwd), + remotes: gitOutput(["remote", "-v"], cwd: cwd), + status: gitOutput(["status", "--porcelain=v1", "--branch"], cwd: cwd) + ) + + let remotes = commandFacts.remotes.map(Self.parseGitRemotes) ?? [] + let status = commandFacts.status.map(Self.parseGitStatusSummary) ?? .init() + let originURL = worktree.repository?.originURL + ?? remotes.first { $0.name == "origin" && $0.purpose == .fetch }?.url + ?? remotes.first { $0.name == "origin" }?.url + let repository = CodexWorkspace.RepositoryInfo( + originURL: originURL, + branch: worktree.repository?.branch ?? status.branch, + sha: worktree.repository?.sha ?? commandFacts.sha + ) + let commandReturnedFacts = commandFacts.root != nil + || commandFacts.sha != nil + || commandFacts.remotes != nil + || commandFacts.status != nil + let source = Self.gitFactSource( + hasAppServerFacts: worktree.hasRepositoryFacts, + hasCommandExecFacts: commandReturnedFacts + ) + + return .init( + worktreeID: worktree.id, + currentDirectoryPath: cwd, + repositoryRootPath: commandFacts.root, + repository: repository, + remotes: remotes, + status: status, + source: source + ) + } + + private func gitOutput(_ arguments: [String], cwd: String) async throws -> String? { + let result = try await executeCommand( + .init( + command: ["git", "-C", cwd] + arguments, + outputBytesCap: 65_536, + timeoutMilliseconds: 5_000 + ) + ) + guard result.exitCode == 0 else { + return nil + } + return CodexWorkspace.RepositoryInfo.normalizedFact(result.stdout) + } + + private static func gitFactSource( + hasAppServerFacts: Bool, + hasCommandExecFacts: Bool + ) -> CodexWorkspace.GitFactSource { + switch (hasAppServerFacts, hasCommandExecFacts) { + case (true, true): + return .appServerAndCommandExec + case (true, false): + return .appServer + case (false, _): + return .commandExec + } + } + + private static func parseGitRemotes(_ output: String) -> [CodexWorkspace.GitRemoteInfo] { + var remotes: [CodexWorkspace.GitRemoteInfo] = [] + var seen: Set = [] + + for line in output.split(whereSeparator: \.isNewline) { + let columns = line.split(whereSeparator: \.isWhitespace) + guard columns.count >= 2 else { continue } + + let name = String(columns[0]) + let url = String(columns[1]) + let purpose = columns.dropFirst(2).first.map(String.init).map(Self.remotePurpose) ?? .unknown + let key = "\(name)||\(url)||\(purpose.rawValue)" + guard seen.insert(key).inserted else { continue } + remotes.append(.init(name: name, url: url, purpose: purpose)) + } + + return remotes + } + + private static func remotePurpose(_ marker: String) -> CodexWorkspace.GitRemoteInfo.Purpose { + switch marker { + case "(fetch)": + return .fetch + case "(push)": + return .push + default: + return .unknown + } + } + + private static func parseGitStatusSummary(_ output: String) -> CodexWorkspace.GitStatusSummary { + let lines = output.split(whereSeparator: \.isNewline).map(String.init) + let branchLine = lines.first { $0.hasPrefix("## ") } + let changedLines = lines.filter { !$0.hasPrefix("## ") && !$0.isEmpty } + let untrackedCount = changedLines.filter { $0.hasPrefix("?? ") }.count + let branchStatus = branchLine.map(parseGitBranchStatus) + + return .init( + branch: branchStatus?.branch, + upstream: branchStatus?.upstream, + aheadCount: branchStatus?.aheadCount, + behindCount: branchStatus?.behindCount, + changedFileCount: changedLines.count, + untrackedFileCount: untrackedCount + ) + } + + private static func parseGitBranchStatus( + _ line: String + ) -> ( + branch: String?, + upstream: String?, + aheadCount: Int?, + behindCount: Int? + ) { + let trimmed = String(line.dropFirst(3)) + let branchAndTracking = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init) ?? trimmed + let split = splitBranchAndUpstream(branchAndTracking) + let branch = CodexWorkspace.RepositoryInfo.normalizedFact(split.branch) + let upstream = CodexWorkspace.RepositoryInfo.normalizedFact(split.upstream) + + return ( + branch: branch == "HEAD" ? nil : branch, + upstream: upstream, + aheadCount: trackingCount(named: "ahead", in: trimmed), + behindCount: trackingCount(named: "behind", in: trimmed) + ) + } + + private static func trackingCount(named name: String, in text: String) -> Int? { + guard let range = text.range(of: "\(name) ") else { return nil } + let suffix = text[range.upperBound...] + let digits = suffix.prefix { $0.isNumber } + return digits.isEmpty ? nil : Int(digits) + } + + private static func splitBranchAndUpstream(_ text: String) -> (branch: String?, upstream: String?) { + guard let range = text.range(of: "...") else { + return (text, nil) + } + return ( + String(text[..? + @ObservationIgnored + private var gitStatusTask: Task? + internal init( appServer: CodexAppServer, configuration: Configuration, @@ -470,11 +487,15 @@ public extension CodexAppServer { self.allThreads = initialThreads self.archivedThreads = [] self.configuredHookListCurrentDirectoryPaths = configuration.hookListCurrentDirectoryPaths + self.featurePolicy = configuration.featurePolicy + self.gitStatusByWorktreeID = [:] self.groups = [] self.groupedBy = configuration.groupedBy self.hookListSnapshot = nil self.lastReconciledAt = nil + self.lastGitStatusReadAt = nil self.lastSnapshotsReadAt = nil + self.latestGitStatusErrorDescription = nil self.latestSnapshotErrorDescription = nil self.latestErrorDescription = nil self.maxPagesPerArchiveState = configuration.maxPagesPerArchiveState @@ -505,6 +526,7 @@ public extension CodexAppServer { eventTask?.cancel() refreshTask?.cancel() snapshotTask?.cancel() + gitStatusTask?.cancel() } public func refresh() async { @@ -633,6 +655,33 @@ public extension CodexAppServer { selectThread(nil) } + public func refreshSelectedGitStatus() async { + guard featurePolicy.mode(for: .gitObservability) != .disabled else { + gitStatusByWorktreeID.removeAll() + latestGitStatusErrorDescription = nil + return + } + guard let worktree = selectedWorktree else { + latestGitStatusErrorDescription = nil + return + } + + do { + let snapshot = try await appServer.refreshGitStatus(for: worktree) + if let snapshot { + gitStatusByWorktreeID[worktree.id] = snapshot + lastGitStatusReadAt = Date() + } else { + gitStatusByWorktreeID.removeValue(forKey: worktree.id) + } + latestGitStatusErrorDescription = nil + } catch is CancellationError { + return + } catch { + latestGitStatusErrorDescription = error.localizedDescription + } + } + public func threads( in worktree: CodexWorkspace.WorktreeSnapshot, includeArchived: Bool = false @@ -718,6 +767,7 @@ public extension CodexAppServer { allThreads = try await appServer.libraryThreadSnapshots(query: query) clearSelectionIfThreadDisappeared() applyVisibleState() + scheduleSelectedGitStatusRefresh() } catch { latestErrorDescription = error.localizedDescription } @@ -741,6 +791,19 @@ public extension CodexAppServer { ) } + private func scheduleSelectedGitStatusRefresh() { + gitStatusTask?.cancel() + + guard selectedThreadID != nil, + featurePolicy.mode(for: .gitObservability) != .disabled else { + return + } + + gitStatusTask = Task { [weak self] in + await self?.refreshSelectedGitStatus() + } + } + private func recordSelection(threadID: String) { selectionSequence += 1 selectionOrderByThreadID[threadID] = selectionSequence diff --git a/Sources/SwiftASB/Public/CodexAppServer.swift b/Sources/SwiftASB/Public/CodexAppServer.swift index bf8662c..3b6b0c4 100644 --- a/Sources/SwiftASB/Public/CodexAppServer.swift +++ b/Sources/SwiftASB/Public/CodexAppServer.swift @@ -88,6 +88,7 @@ public actor CodexAppServer { private let transport: any CodexAppServerTransporting private let protocolLayer: CodexAppServerProtocol + private let featurePolicy: SwiftASBFeaturePolicy private static let logger = Logger( subsystem: "com.gaelic-ghost.SwiftASB", category: "CodexAppServer" @@ -99,12 +100,14 @@ public actor CodexAppServer { private var threadEventContinuations: [String: [UUID: AsyncThrowingStream.Continuation]] = [:] private var diagnosticEventContinuations: [UUID: AsyncThrowingStream.Continuation] = [:] private var libraryEventContinuations: [UUID: AsyncStream.Continuation] = [:] + private var featureOperationEventContinuations: [UUID: AsyncStream.Continuation] = [:] private var fsChangeContinuations: [String: [UUID: AsyncStream.Continuation]] = [:] private var threadObservableActivityContinuations: [String: [UUID: AsyncStream.Continuation]] = [:] private var threadCommandDeltaContinuations: [String: [UUID: AsyncStream.Continuation]] = [:] private var threadFileDeltaContinuations: [String: [UUID: AsyncStream.Continuation]] = [:] private var bufferedThreadEvents: [String: [CodexThreadEvent]] = [:] private var bufferedDiagnosticEvents: [CodexDiagnosticEvent] = [] + private var bufferedFeatureOperationEvents: [SwiftASBFeatureOperationEvent] = [] private var bufferedTerminalThreadEvents: [String: CodexThreadEvent] = [:] private var threadTurnEventContinuations: [String: [UUID: AsyncThrowingStream.Continuation]] = [:] private var turnEventContinuations: [String: [UUID: AsyncThrowingStream.Continuation]] = [:] @@ -123,6 +126,7 @@ public actor CodexAppServer { /// Omitting `configuration` uses SwiftASB's standard app-server launch /// command and local Codex executable discovery. public init(configuration: Configuration = .init()) { + self.featurePolicy = configuration.featurePolicy self.transport = CodexAppServerTransport( configuration: CodexAppServerTransport.Configuration( codexExecutableURL: configuration.codexExecutableURL, @@ -144,10 +148,12 @@ public actor CodexAppServer { internal init( transport: any CodexAppServerTransporting, protocolLayer: CodexAppServerProtocol = CodexAppServerProtocol(), - historyStore: ThreadHistoryStore? = nil + historyStore: ThreadHistoryStore? = nil, + featurePolicy: SwiftASBFeaturePolicy = .defaults ) { self.transport = transport self.protocolLayer = protocolLayer + self.featurePolicy = featurePolicy if let historyStore { self.historyStore = historyStore self.historyStoreInitializationError = nil @@ -191,6 +197,7 @@ public actor CodexAppServer { finishAllThreadEventStreams(throwing: nil) finishAllDiagnosticEventStreams(throwing: nil) finishAllLibraryEventStreams() + finishAllFeatureOperationEventStreams() finishAllFSChangeStreams() finishAllThreadObservableActivityStreams() finishAllThreadCommandDeltaStreams() @@ -205,6 +212,7 @@ public actor CodexAppServer { turnThreadIDs.removeAll() bufferedThreadEvents.removeAll() bufferedDiagnosticEvents.removeAll() + bufferedFeatureOperationEvents.removeAll() bufferedTurnEvents.removeAll() bufferedTerminalThreadEvents.removeAll() bufferedTerminalTurnEvents.removeAll() @@ -243,6 +251,17 @@ public actor CodexAppServer { makeDiagnosticEventStream() } + /// Subscribes to SwiftASB-owned feature-operation events. + /// + /// Feature-operation events are app-wide, human-readable records for + /// SwiftASB convenience operations such as future repo-guidance sync, + /// extension maintenance, and typed Git actions. Routine read-only + /// refreshes do not emit events. The stream finishes normally when the + /// app-server is stopped through SwiftASB. + public func featureOperationEvents() -> AsyncStream { + makeFeatureOperationEventStream() + } + /// Performs the app-server initialize handshake. /// /// Call this once after `start()`. SwiftASB sends the app-server's required @@ -273,6 +292,77 @@ public actor CodexAppServer { } } + /// Runs one argv command through app-server `command/exec`. + /// + /// This intentionally omits permission-profile and sandbox overrides so + /// Codex applies the user's configured command permissions by default. + internal func executeCommand(_ request: CommandExecRequest) async throws -> CommandExecResult { + try requireInitialized(for: "command/exec") + + guard !request.command.isEmpty else { + throw CodexAppServerError.invalidState( + reason: "SwiftASB cannot run command/exec with an empty argv vector." + ) + } + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeCommandExecRequest( + id: requestID, + params: CodexProtocolCommandExecParams( + command: request.command, + cwd: request.currentDirectoryPath, + disableOutputCap: nil, + disableTimeout: nil, + env: request.environment.isEmpty ? nil : request.environment, + outputBytesCap: request.outputBytesCap, + permissionProfile: nil, + processID: nil, + sandboxPolicy: nil, + size: nil, + streamStdin: nil, + streamStdoutStderr: nil, + timeoutMS: request.timeoutMilliseconds, + tty: nil + ) + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeCommandExecResponse( + responsePayload, + expectedID: requestID + ) + + return .init( + exitCode: response.exitCode, + stdout: response.stdout, + stderr: response.stderr + ) + } catch { + throw CodexAppServerError.wrap(error, operation: "command/exec") + } + } + + internal func codexCommandExecutablePath() async -> String { + await transport.executableResolution()?.resolvedExecutableURL?.path ?? "codex" + } + + internal func requireFeatureEnabled( + _ categoryID: SwiftASBFeatureCategory.ID, + for operation: String + ) throws { + guard featurePolicy.mode(for: categoryID) == .enabled else { + let categoryName = SwiftASBFeatureCategory.builtInCategory(id: categoryID)?.displayName + ?? categoryID.rawValue + throw CodexAppServerError.invalidState( + reason: """ + SwiftASB cannot run \(operation) because the \(categoryName) feature category is not enabled. \ + Enable \(categoryID.rawValue) in SwiftASBFeaturePolicy before requesting this SwiftASB-owned mutation. + """ + ) + } + } + /// Reads the app-server's current model catalog. /// /// Omitting `request` sends an empty list request, leaving pagination and @@ -1558,6 +1648,21 @@ public actor CodexAppServer { } } + internal func publishFeatureOperationEvent(_ event: SwiftASBFeatureOperationEvent) { + bufferedFeatureOperationEvents.append(event) + if bufferedFeatureOperationEvents.count > 100 { + bufferedFeatureOperationEvents.removeFirst(bufferedFeatureOperationEvents.count - 100) + } + + guard !featureOperationEventContinuations.isEmpty else { + return + } + + for continuation in featureOperationEventContinuations.values { + continuation.yield(event) + } + } + internal func fsChangeStream(watchID: String) -> AsyncStream { let streamID = UUID() @@ -2434,6 +2539,7 @@ public actor CodexAppServer { throwing: CodexAppServerError.wrap(error, operation: "server events") ) await self.finishAllLibraryEventStreams() + await self.finishAllFeatureOperationEventStreams() await self.finishAllFSChangeStreams() await self.finishAllTurnEventStreams( throwing: CodexAppServerError.wrap(error, operation: "server events") @@ -2616,6 +2722,8 @@ public actor CodexAppServer { itemID: notification.itemID, delta: notification.delta ) + case .commandExecOutputDelta: + break case let .hookStarted(notification): updateThreadObservableActivityForHookRun( notification.run, @@ -2779,6 +2887,7 @@ public actor CodexAppServer { finishAllThreadEventStreams(throwing: nil) finishAllDiagnosticEventStreams(throwing: nil) finishAllLibraryEventStreams() + finishAllFeatureOperationEventStreams() finishAllFSChangeStreams() finishAllThreadObservableActivityStreams() finishAllThreadCommandDeltaStreams() @@ -2800,6 +2909,7 @@ public actor CodexAppServer { ) ) finishAllLibraryEventStreams() + finishAllFeatureOperationEventStreams() finishAllFSChangeStreams() finishAllThreadObservableActivityStreams() finishAllThreadCommandDeltaStreams() @@ -2860,6 +2970,24 @@ public actor CodexAppServer { } } + private func makeFeatureOperationEventStream() -> AsyncStream { + let streamID = UUID() + + return AsyncStream { continuation in + featureOperationEventContinuations[streamID] = continuation + + for event in bufferedFeatureOperationEvents { + continuation.yield(event) + } + + continuation.onTermination = { _ in + Task { + await self.removeFeatureOperationEventContinuation(streamID: streamID) + } + } + } + } + private func makeTurnEventStream( turnID: String ) -> AsyncThrowingStream { @@ -2957,6 +3085,10 @@ public actor CodexAppServer { libraryEventContinuations.removeValue(forKey: streamID) } + private func removeFeatureOperationEventContinuation(streamID: UUID) { + featureOperationEventContinuations.removeValue(forKey: streamID) + } + private func removeFSChangeContinuation(streamID: UUID, watchID: String) { guard var continuations = fsChangeContinuations[watchID] else { return } continuations.removeValue(forKey: streamID) @@ -3223,6 +3355,15 @@ public actor CodexAppServer { } } + private func finishAllFeatureOperationEventStreams() { + let activeContinuations = featureOperationEventContinuations.values + featureOperationEventContinuations.removeAll() + + for continuation in activeContinuations { + continuation.finish() + } + } + private func finishAllFSChangeStreams() { let activeContinuations = fsChangeContinuations.values.flatMap(\.values) fsChangeContinuations.removeAll() diff --git a/Sources/SwiftASB/Public/CodexWorkspace.swift b/Sources/SwiftASB/Public/CodexWorkspace.swift index 0a95ec7..7752ebe 100644 --- a/Sources/SwiftASB/Public/CodexWorkspace.swift +++ b/Sources/SwiftASB/Public/CodexWorkspace.swift @@ -248,7 +248,7 @@ public enum CodexWorkspace { isEmpty ? nil : self } - private static func normalizedFact(_ value: String?) -> String? { + internal static func normalizedFact(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil @@ -257,6 +257,104 @@ public enum CodexWorkspace { } } + /// Source that produced a Git observability snapshot. + public enum GitFactSource: String, Sendable, Equatable { + /// The snapshot only uses Git facts Codex already attached to thread or worktree metadata. + case appServer + /// The snapshot only uses facts refreshed through sandboxed app-server `command/exec`. + case commandExec + /// The snapshot combines Codex-owned metadata with sandboxed app-server `command/exec` facts. + case appServerAndCommandExec + } + + /// One named Git remote reported by `git remote -v`. + public struct GitRemoteInfo: Sendable, Equatable { + public enum Purpose: String, Sendable, Equatable { + case fetch + case push + case unknown + } + + public let name: String + public let purpose: Purpose + public let url: String + + public init( + name: String, + url: String, + purpose: Purpose = .unknown + ) { + self.name = name + self.url = url + self.purpose = purpose + } + } + + /// Parsed summary from `git status --porcelain=v1 --branch`. + public struct GitStatusSummary: Sendable, Equatable { + public let aheadCount: Int? + public let behindCount: Int? + public let branch: String? + public let changedFileCount: Int + public let untrackedFileCount: Int + public let upstream: String? + + public init( + branch: String? = nil, + upstream: String? = nil, + aheadCount: Int? = nil, + behindCount: Int? = nil, + changedFileCount: Int = 0, + untrackedFileCount: Int = 0 + ) { + self.branch = RepositoryInfo.normalizedFact(branch) + self.upstream = RepositoryInfo.normalizedFact(upstream) + self.aheadCount = aheadCount + self.behindCount = behindCount + self.changedFileCount = max(0, changedFileCount) + self.untrackedFileCount = max(0, untrackedFileCount) + } + + public var isDirty: Bool { + changedFileCount > 0 || untrackedFileCount > 0 + } + } + + /// Live Git facts for a selected worktree. + public struct GitStatusSnapshot: Sendable, Equatable, Identifiable { + public let currentDirectoryPath: String + public let id: String + public let remotes: [GitRemoteInfo] + public let repository: RepositoryInfo? + public let repositoryRootPath: String? + public let source: GitFactSource + public let status: GitStatusSummary + public let worktreeID: String + + public init( + worktreeID: String, + currentDirectoryPath: String, + repositoryRootPath: String? = nil, + repository: RepositoryInfo? = nil, + remotes: [GitRemoteInfo] = [], + status: GitStatusSummary = .init(), + source: GitFactSource + ) { + self.worktreeID = worktreeID + self.currentDirectoryPath = currentDirectoryPath + self.repositoryRootPath = RepositoryInfo.normalizedFact(repositoryRootPath) + self.repository = repository?.normalized + self.remotes = remotes + self.status = status + self.source = source + self.id = worktreeID + } + + public var isDirty: Bool { + status.isDirty + } + } + /// Thread-session workspace snapshot built from app-server-owned facts. public struct SessionSnapshot: Sendable, Equatable { public let activePermissionProfile: ActivePermissionProfile? diff --git a/Sources/SwiftASB/Public/SwiftASBFeatureOperationEvent.swift b/Sources/SwiftASB/Public/SwiftASBFeatureOperationEvent.swift new file mode 100644 index 0000000..f4d5a45 --- /dev/null +++ b/Sources/SwiftASB/Public/SwiftASBFeatureOperationEvent.swift @@ -0,0 +1,98 @@ +import Foundation + +/// A human-readable record of a SwiftASB-owned feature operation. +/// +/// SwiftASB emits these events for feature-category operations that can mutate +/// local state. Routine read-only refreshes stay quiet. +public struct SwiftASBFeatureOperationEvent: Sendable, Equatable, Identifiable { + public let id: String + public let categoryID: SwiftASBFeatureCategory.ID + public let operationID: String + public let title: String + public let summary: String + public let reason: String + public let startedAt: Date + public let completedAt: Date? + public let affectedPaths: [String] + public let commands: [Command] + public let appServerMethod: String? + public let intentKind: String? + public let status: Status + public let rollback: Rollback + public let diagnosticText: String? + + init( + categoryID: SwiftASBFeatureCategory.ID, + operationID: String, + title: String, + summary: String, + reason: String, + startedAt: Date, + completedAt: Date? = nil, + affectedPaths: [String] = [], + commands: [Command] = [], + appServerMethod: String? = nil, + intentKind: String? = nil, + status: Status, + rollback: Rollback = .unavailable, + diagnosticText: String? = nil + ) { + self.id = operationID + self.categoryID = categoryID + self.operationID = operationID + self.title = title + self.summary = summary + self.reason = reason + self.startedAt = startedAt + self.completedAt = completedAt + self.affectedPaths = affectedPaths + self.commands = commands + self.appServerMethod = appServerMethod + self.intentKind = intentKind + self.status = status + self.rollback = rollback + self.diagnosticText = diagnosticText + } +} + +extension SwiftASBFeatureOperationEvent { + /// The current result state for a feature operation. + public enum Status: String, Sendable, Equatable { + case started, succeeded, failed, cancelled, skipped + } + + /// One command SwiftASB ran as part of a feature operation. + public struct Command: Sendable, Equatable { + public let argv: [String] + public let currentDirectoryPath: String? + + init( + argv: [String], + currentDirectoryPath: String? = nil + ) { + self.argv = argv + self.currentDirectoryPath = currentDirectoryPath + } + } + + /// Rollback metadata for a feature operation. + public struct Rollback: Sendable, Equatable { + public let isAvailable: Bool + public let handle: String? + public let summary: String? + + init( + isAvailable: Bool, + handle: String? = nil, + summary: String? = nil + ) { + self.isAvailable = isAvailable + self.handle = handle + self.summary = summary + } + + static var unavailable: Self { + .init(isAvailable: false) + } + } +} diff --git a/Sources/SwiftASB/Public/SwiftASBFeaturePolicy.swift b/Sources/SwiftASB/Public/SwiftASBFeaturePolicy.swift new file mode 100644 index 0000000..38b3af3 --- /dev/null +++ b/Sources/SwiftASB/Public/SwiftASBFeaturePolicy.swift @@ -0,0 +1,248 @@ +import Foundation + +/// App-wide SwiftASB feature-category policy. +/// +/// `SwiftASBFeaturePolicy` gates SwiftASB-owned convenience features such as +/// Git observability, extension maintenance, and trusted repo-guidance sync. It +/// does not replace Codex app-server sandboxing or interactive approval +/// requests. +public struct SwiftASBFeaturePolicy: Sendable, Equatable { + public var categoryModes: [SwiftASBFeatureCategory.ID: SwiftASBFeatureMode] + public var hostAccess: SwiftASBHostAccess + + /// Creates a feature policy. + /// + /// Any category omitted from `categoryModes` falls back to its descriptor's + /// default mode. + public init( + categoryModes: [SwiftASBFeatureCategory.ID: SwiftASBFeatureMode] = [:], + hostAccess: SwiftASBHostAccess = .unknown + ) { + self.categoryModes = categoryModes + self.hostAccess = hostAccess + } + + /// Built-in defaults for SwiftASB feature categories. + public static var defaults: Self { + var modes: [SwiftASBFeatureCategory.ID: SwiftASBFeatureMode] = [:] + for category in SwiftASBFeatureCategory.builtIn { + modes[category.id] = category.defaultMode + } + return .init(categoryModes: modes) + } + + /// Returns the current mode for a category. + public func mode(for categoryID: SwiftASBFeatureCategory.ID) -> SwiftASBFeatureMode { + categoryModes[categoryID] + ?? SwiftASBFeatureCategory.builtInCategory(id: categoryID)?.defaultMode + ?? .disabled + } + + /// Updates the mode for one feature category. + public mutating func setMode( + _ mode: SwiftASBFeatureMode, + for categoryID: SwiftASBFeatureCategory.ID + ) { + categoryModes[categoryID] = mode + } +} + +/// One SwiftASB feature category a consuming app can describe and configure. +public struct SwiftASBFeatureCategory: Sendable, Equatable, Identifiable { + public struct ID: RawRepresentable, Sendable, Equatable, Hashable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public static let gitObservability: Self = "gitObservability" + public static let extensionInventory: Self = "extensionInventory" + public static let extensionMaintenance: Self = "extensionMaintenance" + public static let swiftRepoGuidanceSync: Self = "swiftRepoGuidanceSync" + public static let gitActions: Self = "gitActions" + public static let configMutation: Self = "configMutation" + public static let extensionMutation: Self = "extensionMutation" + public static let worktreeAutomation: Self = "worktreeAutomation" + } + + public let id: ID + public let displayName: String + public let description: String + public let permissionReason: String + public let defaultMode: SwiftASBFeatureMode + public let sensitivity: SwiftASBFeatureSensitivity + public let eventPolicy: SwiftASBFeatureEventPolicy + + /// Creates a feature category descriptor. + public init( + id: ID, + displayName: String, + description: String, + permissionReason: String, + defaultMode: SwiftASBFeatureMode, + sensitivity: SwiftASBFeatureSensitivity, + eventPolicy: SwiftASBFeatureEventPolicy + ) { + self.id = id + self.displayName = displayName + self.description = description + self.permissionReason = permissionReason + self.defaultMode = defaultMode + self.sensitivity = sensitivity + self.eventPolicy = eventPolicy + } + + /// Built-in SwiftASB feature categories. + public static let builtIn: [Self] = [ + .init( + id: .gitObservability, + displayName: "Git Observability", + description: "Read branch, SHA, remotes, status summaries, repository identity, and Git availability.", + permissionReason: "SwiftASB refreshes Git facts so developer tools can show current repository state without each UI running its own probes.", + defaultMode: .enabled, + sensitivity: .readOnly, + eventPolicy: .quietReads + ), + .init( + id: .extensionInventory, + displayName: "Extension Inventory", + description: "List installed apps, skills, plugins, marketplaces, collaboration modes, and update availability.", + permissionReason: "SwiftASB reads installed extension state so consuming apps can show available capabilities and updates.", + defaultMode: .enabled, + sensitivity: .readOnly, + eventPolicy: .quietReads + ), + .init( + id: .extensionMaintenance, + displayName: "Extension Maintenance", + description: "Upgrade already-installed extensions, plugins, skills, or marketplace entries.", + permissionReason: "SwiftASB can keep existing trusted extension installs current while reporting any maintenance write it performs.", + defaultMode: .enabled, + sensitivity: .maintenance, + eventPolicy: .notifyOnMutation + ), + .init( + id: .swiftRepoGuidanceSync, + displayName: "Swift Repo Guidance Sync", + description: "Apply trusted, idempotent Apple and Swift repository guidance updates inside detected Git repositories.", + permissionReason: "SwiftASB writes repo guidance only after this category is enabled, and reports touched files plus rollback details.", + defaultMode: .disabled, + sensitivity: .mutation, + eventPolicy: .notifyOnMutation + ), + .init( + id: .gitActions, + displayName: "Git Actions", + description: "Run bounded typed Git intents such as branch creation, staging, commit preparation, or local rollback helpers.", + permissionReason: "SwiftASB uses typed Git intents instead of arbitrary command strings and reports mutation results.", + defaultMode: .disabled, + sensitivity: .mutation, + eventPolicy: .notifyOnMutation + ), + .init( + id: .configMutation, + displayName: "Config Mutation", + description: "Write Codex or SwiftASB configuration values through stable app-server configuration surfaces.", + permissionReason: "SwiftASB changes configuration only after this category is enabled and reports the setting it changed.", + defaultMode: .disabled, + sensitivity: .mutation, + eventPolicy: .notifyOnMutation + ), + .init( + id: .extensionMutation, + displayName: "Extension Mutation", + description: "Install new extensions, uninstall extensions, change extension config, or mutate extension sharing settings.", + permissionReason: "SwiftASB treats new extension installs, removals, and sharing changes as explicit extension mutations.", + defaultMode: .disabled, + sensitivity: .highImpact, + eventPolicy: .notifyOnMutation + ), + .init( + id: .worktreeAutomation, + displayName: "Worktree Automation", + description: "Create, update, or clean worktrees after workspace and Git facts are explicit.", + permissionReason: "SwiftASB reports worktree changes because they alter repository checkout state on disk.", + defaultMode: .disabled, + sensitivity: .mutation, + eventPolicy: .notifyOnMutation + ), + ] + + /// Returns a built-in category by id. + public static func builtInCategory(id: ID) -> Self? { + builtIn.first { $0.id == id } + } +} + +/// Feature-category mode selected by the consuming app or SwiftASB defaults. +public enum SwiftASBFeatureMode: String, Sendable, Equatable { + case disabled, enabled, readOnly +} + +/// Feature sensitivity used by consuming apps when presenting category toggles. +public enum SwiftASBFeatureSensitivity: String, Sendable, Equatable { + case readOnly, maintenance, mutation, highImpact +} + +/// Event behavior SwiftASB should use for work in a feature category. +public enum SwiftASBFeatureEventPolicy: String, Sendable, Equatable { + case quietReads, notifyOnMutation, requireExplicitAction +} + +/// Host filesystem access declared by a consuming app. +public struct SwiftASBHostAccess: Sendable, Equatable { + public var homeDirectoryReadWriteGranted: Bool + public var homeDirectoryURL: URL? + public var accessSource: AccessSource + + /// Creates a host access declaration. + public init( + homeDirectoryReadWriteGranted: Bool = false, + homeDirectoryURL: URL? = nil, + accessSource: AccessSource = .unknown + ) { + self.homeDirectoryReadWriteGranted = homeDirectoryReadWriteGranted + self.homeDirectoryURL = homeDirectoryURL + self.accessSource = accessSource + } + + public static var unknown: Self { + .init() + } + + /// Declares an unsandboxed host application. + public static func unsandboxed(homeDirectoryURL: URL? = nil) -> Self { + .init( + homeDirectoryReadWriteGranted: true, + homeDirectoryURL: homeDirectoryURL, + accessSource: .unsandboxed + ) + } + + /// Declares broad user-granted home-directory access. + public static func homeDirectoryReadWrite( + url: URL, + source: AccessSource + ) -> Self { + .init( + homeDirectoryReadWriteGranted: true, + homeDirectoryURL: url, + accessSource: source + ) + } + + /// Where the consuming app says its broad host access came from. + public enum AccessSource: String, Sendable, Equatable { + case declaredByHostApp + case fullDiskAccess + case securityScopedBookmark + case unknown + case unsandboxed + case userSelectedDirectory + } +} diff --git a/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md b/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md index 06a2893..5a8406f 100644 --- a/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md +++ b/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md @@ -10,6 +10,8 @@ Use ``CodexAppServer/listModels(_:)`` to read the currently visible model catalo Use ``CodexAppServer/makeLibrary(configuration:)`` when these same model, MCP, and hook snapshots should live beside observable stored-thread lists. ``CodexAppServer/Library/refreshAppSnapshots()`` reads the current app-wide snapshots and publishes them as Library state; hook diagnostics use Library thread `cwd` values unless configuration passes explicit hook current-directory paths. +Use ``CodexAppServer/extensions`` for app, skill, plugin, and collaboration-mode inventory. ``CodexAppServer/CodexExtensions/upgradeMarketplace(_:)`` is the narrow maintenance mutation in this app-wide family: it upgrades an already-configured plugin marketplace through app-server `command/exec` and reports the operation through ``CodexAppServer/featureOperationEvents()``. + ```swift let models = try await appServer.listModels( .init(limit: 50, includeHidden: false) @@ -109,3 +111,11 @@ These types are public because a consumer can use them directly today. Other gen - ``CodexAppServer/HookMetadata`` - ``CodexAppServer/HookError`` - ``CodexAppServer/HookDiagnostic`` + +### Extensions + +- ``CodexAppServer/extensions`` +- ``CodexAppServer/CodexExtensions`` +- ``CodexAppServer/CodexExtensions/upgradeMarketplace(_:)`` +- ``CodexAppServer/CodexExtensions/MarketplaceUpgradeRequest`` +- ``CodexAppServer/CodexExtensions/MarketplaceUpgradeResult`` diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md b/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md index 86d7a5b..69d2fd6 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md @@ -35,6 +35,8 @@ Use ``cliExecutableDiagnostics()`` when a UI or command-line tool needs to expla Use ``diagnosticEvents()`` to observe passive runtime diagnostics that are not control requests. These events let clients show or log warnings, guardian warnings, config warnings, deprecation notices, MCP-server status changes, remote-control status changes, model reroutes, and model verification results without exposing generated wire payloads. +Use ``featureOperationEvents()`` to observe human-readable SwiftASB feature-operation records for enabled mutation categories. Routine read-only refreshes stay quiet; writes and maintenance actions report what changed, why SwiftASB changed it, where it changed, and whether rollback is available. + ## App-Wide Capabilities Use ``listModels(_:)``, ``listMcpServerStatuses(_:)``, ``readMcpResource(_:)``, and ``listHooks(_:)`` for connection-wide snapshots. They do not belong to a single thread because they describe the app-server's current model catalog, MCP server surface, MCP resource contents, and configured hook diagnostics. @@ -47,6 +49,8 @@ Use ``config`` to read effective app-server configuration and requirements polic Use ``extensions`` to read app, skill, plugin, and collaboration-mode inventory through the app-server instead of inspecting installed plugin or skill directories directly. +Use ``CodexExtensions/upgradeMarketplace(_:)`` for the narrow extension-maintenance mutation SwiftASB owns today: upgrading an already-configured plugin marketplace through app-server `command/exec`. The method preflights `plugin/list`, respects ``SwiftASBFeaturePolicy``'s `extensionMaintenance` category, and emits a ``SwiftASBFeatureOperationEvent``. + Use ``makeLibrary(configuration:)`` when a GUI or CLI client needs an app-wide observable over stored threads. The library loads local Core Data-backed snapshots first, then reconciles unarchived app-server pages before archived pages. It publishes SwiftASB value snapshots, not Core Data objects. `Library` also reloads local snapshots after app-wide thread and turn events, so archive, unarchive, name, status, and completed-turn changes can update sidebars without each consumer wiring per-thread event streams. @@ -76,6 +80,8 @@ Set ``ThreadResumeRequest/excludeTurns`` or ``ThreadForkRequest/excludeTurns`` w - ``CLIExecutableDiagnostics`` - ``diagnosticEvents()`` - ``CodexDiagnosticEvent`` +- ``featureOperationEvents()`` +- ``SwiftASBFeatureOperationEvent`` ### Startup @@ -98,6 +104,9 @@ Set ``ThreadResumeRequest/excludeTurns`` or ``ThreadForkRequest/excludeTurns`` w - ``CodexConfig`` - ``extensions`` - ``CodexExtensions`` +- ``CodexExtensions/upgradeMarketplace(_:)`` +- ``CodexExtensions/MarketplaceUpgradeRequest`` +- ``CodexExtensions/MarketplaceUpgradeResult`` - ``listModels(_:)`` - ``ModelListRequest`` - ``ModelListPage`` diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md b/Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md index c1a802d..81ebc9f 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexWorkspace.md @@ -23,6 +23,8 @@ let thread = try await appServer.startThread( Use ``SessionSnapshot`` or the workspace values on ``CodexThread`` when a UI needs to show what Codex actually activated for the session: current directory, Git metadata, instruction sources, legacy sandbox policy, active profile id, and exact filesystem/network permissions. +Use ``GitStatusSnapshot`` through ``CodexAppServer/Library/selectedGitStatus`` when a library UI wants live selected-worktree Git facts. SwiftASB starts from Codex-reported repository metadata, then uses sandboxed app-server `command/exec` for repository root, remotes, and porcelain status details that are not attached to stored thread metadata yet. + ## Topics ### Request Selection @@ -42,3 +44,13 @@ Use ``SessionSnapshot`` or the workspace values on ``CodexThread`` when a UI nee - ``FileSystemPath`` - ``FileSystemSpecialPath`` - ``NetworkPermissions`` + +### Git Observability + +- ``ProjectInfo`` +- ``RepositoryInfo`` +- ``WorktreeSnapshot`` +- ``GitStatusSnapshot`` +- ``GitStatusSummary`` +- ``GitRemoteInfo`` +- ``GitFactSource`` diff --git a/Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md b/Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md new file mode 100644 index 0000000..9fc27ea --- /dev/null +++ b/Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md @@ -0,0 +1,57 @@ +# Feature Permission Policy + +Describe and configure SwiftASB-owned convenience features without forcing repeated prompts for safe, expected work. + +## Overview + +``SwiftASBFeaturePolicy`` is separate from Codex app-server approval requests. +Codex approvals answer server-originated requests during a thread or turn. +Feature policy says which SwiftASB convenience categories a consuming app has +enabled. + +Read-only and inventory categories are available by default. Most mutation +categories are disabled until the consuming app enables them, and enabled +mutations should emit human-readable operation events as those surfaces land. +The deliberate default-enabled exception is extension maintenance for +already-configured plugin marketplaces. + +Use ``CodexAppServer/featureOperationEvents()`` to observe those mutation +records. Each ``SwiftASBFeatureOperationEvent`` carries the category id, stable +operation id, title, summary, reason, timing, affected paths, commands, +app-server method or SwiftASB intent kind, result status, rollback metadata, and +diagnostic text when a feature operation fails. Routine read-only refreshes such +as selected-worktree Git status hydration stay quiet. + +Pass ``SwiftASBFeaturePolicy`` through ``CodexAppServer/Configuration`` to +control app-server-owned convenience mutations. The default policy enables +``SwiftASBFeatureCategory/ID/extensionMaintenance``, which permits +``CodexAppServer/CodexExtensions/upgradeMarketplace(_:)`` for already-configured +plugin marketplaces while leaving new installs, removals, sharing changes, and +configuration writes out of scope. + +The initial built-in categories are: + +- ``SwiftASBFeatureCategory/ID/gitObservability`` +- ``SwiftASBFeatureCategory/ID/extensionInventory`` +- ``SwiftASBFeatureCategory/ID/extensionMaintenance`` +- ``SwiftASBFeatureCategory/ID/swiftRepoGuidanceSync`` +- ``SwiftASBFeatureCategory/ID/gitActions`` +- ``SwiftASBFeatureCategory/ID/configMutation`` +- ``SwiftASBFeatureCategory/ID/extensionMutation`` +- ``SwiftASBFeatureCategory/ID/worktreeAutomation`` + +Use ``SwiftASBHostAccess`` to describe broad filesystem access the host app has +already arranged, such as unsandboxed access or sandboxed home-directory access +through a user-selected directory or security-scoped bookmark. + +## Topics + +### Policy + +- ``SwiftASBFeaturePolicy`` +- ``SwiftASBFeatureCategory`` +- ``SwiftASBFeatureMode`` +- ``SwiftASBFeatureSensitivity`` +- ``SwiftASBFeatureEventPolicy`` +- ``SwiftASBHostAccess`` +- ``SwiftASBFeatureOperationEvent`` diff --git a/Sources/SwiftASB/SwiftASB.docc/SwiftASB.md b/Sources/SwiftASB/SwiftASB.docc/SwiftASB.md index fcc8df0..fc513a9 100644 --- a/Sources/SwiftASB/SwiftASB.docc/SwiftASB.md +++ b/Sources/SwiftASB/SwiftASB.docc/SwiftASB.md @@ -14,6 +14,8 @@ The public surface has three main handles: - ``CodexWorkspace`` owns app-server-routed workspace permission selections and runtime permission facts. - ``CodexConfig`` owns app-server-routed configuration reads for sandboxed clients. - ``CodexAppServer/CodexExtensions`` owns app, skill, plugin, and collaboration-mode inventory. +- ``SwiftASBFeaturePolicy`` owns SwiftASB convenience-feature categories, defaults, and host-access declarations. +- ``SwiftASBFeatureOperationEvent`` reports SwiftASB-owned mutation operations in human-readable form. - ``CodexThread`` owns a single conversation thread, including new turns, thread-management actions, thread event streams, local history windows, and thread-scoped observable companions. - ``CodexTurnHandle`` owns one active turn, including turn events, steering, interruption, server-request responses, and an observable current-state minimap. @@ -29,6 +31,7 @@ Generated Codex wire types remain internal scaffolding. Public callers should us - - - +- - - - @@ -40,6 +43,9 @@ Generated Codex wire types remain internal scaffolding. Public callers should us - ``CodexWorkspace`` - ``CodexConfig`` - ``CodexAppServer/CodexExtensions`` +- ``SwiftASBFeaturePolicy`` +- ``SwiftASBFeatureCategory`` +- ``SwiftASBFeatureOperationEvent`` - ``CodexThread`` - ``CodexTurnHandle`` diff --git a/Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md b/Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md index 1995632..f43c1e8 100644 --- a/Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md +++ b/Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md @@ -93,6 +93,8 @@ Use ``CodexAppServer/Library/selectedThreadID`` and ``CodexAppServer/Library/sel Use ``CodexAppServer/Library/worktreeGroups`` when a sidebar needs repository/workspace sections independent of the current visible grouping mode. Use ``CodexAppServer/Library/threads(inWorktreeID:includeArchived:)`` or ``CodexAppServer/Library/threads(inRepositoryOriginURL:includeArchived:)`` when a project browser needs the sorted threads for one Codex-reported worktree or Git origin without reading local disk. +When `gitObservability` is enabled in ``SwiftASBFeaturePolicy``, selecting a library thread refreshes ``CodexAppServer/Library/selectedGitStatus`` for that worktree. The status snapshot combines Codex-reported branch, SHA, and origin metadata with sandboxed app-server `command/exec` facts for repository root, remotes, ahead/behind, and dirty/untracked counts. + Use ``CodexAppServer/Library/refreshAppSnapshots()`` when the same app-wide UI needs model capabilities, MCP server status, and hook diagnostics. Library derives hook `cwd` requests from its stored thread snapshots unless configuration provides explicit hook current-directory paths. Recent companions keep caller-owned UI inputs mutable. For example, views can update selected file or command identifiers and visible item identifiers. SwiftASB uses that information to protect visible or selected payloads while slimming older low-value entries when the resident cache exceeds its budget. diff --git a/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift b/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift index 1909a9d..bc095e8 100644 --- a/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift +++ b/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift @@ -50,6 +50,66 @@ struct CodexAppServerProtocolTests { #expect(object["id"] == nil) } + @Test("encodes command/exec without permission or sandbox overrides by default") + func encodesCommandExecWithConfiguredPermissionDefaults() throws { + let payload = try protocolLayer.makeCommandExecRequest( + id: .string("command-exec-1"), + params: .init( + command: ["git", "status", "--short"], + cwd: "/tmp/project", + disableOutputCap: nil, + disableTimeout: nil, + env: nil, + outputBytesCap: 16_384, + permissionProfile: nil, + processID: nil, + sandboxPolicy: nil, + size: nil, + streamStdin: nil, + streamStdoutStderr: nil, + timeoutMS: 5_000, + tty: nil + ) + ) + + let object = try #require(try JSONSerialization.jsonObject(with: payload) as? [String: Any]) + #expect(object["jsonrpc"] == nil) + #expect(object["method"] as? String == "command/exec") + #expect(object["id"] as? String == "command-exec-1") + + let params = try #require(object["params"] as? [String: Any]) + #expect(params["command"] as? [String] == ["git", "status", "--short"]) + #expect(params["cwd"] as? String == "/tmp/project") + #expect(params["outputBytesCap"] as? Int == 16_384) + #expect(params["timeoutMs"] as? Int == 5_000) + #expect(params["permissionProfile"] == nil) + #expect(params["sandboxPolicy"] == nil) + #expect(params["processId"] == nil) + #expect(params["streamStdoutStderr"] == nil) + } + + @Test("decodes command/exec output as connection-scoped command output") + func decodesCommandExecOutputAsConnectionScopedOutput() throws { + let payload = try #require( + #"{"capReached":false,"deltaBase64":"aGVsbG8K","processId":"swiftasb-command-1","stream":"stdout"}"# + .data(using: .utf8) + ) + + let event = try protocolLayer.decodeServerEvent( + .notification(method: "command/exec/outputDelta", payload: payload) + ) + + guard case let .commandExecOutputDelta(notification) = event else { + Issue.record("Expected command/exec output to decode separately from thread command-execution output.") + return + } + + #expect(notification.processID == "swiftasb-command-1") + #expect(notification.deltaBase64 == "aGVsbG8K") + #expect(notification.stream == .stdout) + #expect(notification.capReached == false) + } + @Test("encodes thread/start requests with the expected method and params payload") func encodesThreadStartRequest() throws { let payload = try protocolLayer.makeThreadStartRequest( diff --git a/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift index 9a6e6cf..64f5550 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift @@ -372,6 +372,139 @@ extension CodexAppServerTests { await client.stop() } + @Test("CodexExtensions upgrades configured marketplaces through command exec") + func codexExtensionsUpgradesConfiguredMarketplacesThroughCommandExec() async throws { + let transport = FakeCodexAppServerTransport( + commandExecResult: [ + "exitCode": 0, + "stderr": "", + "stdout": "Marketplace openai-curated upgraded.\n", + ] + ) + let client = CodexAppServer(transport: transport) + let operationStream = await client.featureOperationEvents() + let operationTask = Task { + var iterator = operationStream.makeAsyncIterator() + return await iterator.next() + } + + try await client.start() + _ = try await client.initialize( + .init( + clientInfo: .init( + name: "SwiftASBTests", + title: "SwiftASB Tests", + version: "0.1.0" + ) + ) + ) + + let result = try await client.extensions.upgradeMarketplace( + .init( + marketplaceName: "openai-curated", + currentDirectoryPaths: ["/tmp/project"], + timeoutMilliseconds: 30_000 + ) + ) + + #expect(result.marketplaceName == "openai-curated") + #expect(result.exitCode == 0) + #expect(result.status == .succeeded) + #expect(result.stdout == "Marketplace openai-curated upgraded.\n") + #expect(result.command == ["codex", "plugin", "marketplace", "upgrade", "openai-curated"]) + + let methods = await transport.recordedMethods + #expect(methods.contains("plugin/list")) + #expect(methods.contains("command/exec")) + #expect(!methods.contains("thread/start")) + + let pluginsRequest = try #require(await transport.recordedRequestPayload(for: "plugin/list")) + #expect(value(at: ["params", "cwds"], in: try decodedJSONObject(from: pluginsRequest)) as? [String] == ["/tmp/project"]) + + let commandRequest = try #require(await transport.recordedRequestPayload(for: "command/exec")) + let commandJSON = try decodedJSONObject(from: commandRequest) + #expect(value(at: ["params", "command"], in: commandJSON) as? [String] == result.command) + #expect(value(at: ["params", "timeoutMs"], in: commandJSON) as? Int == 30_000) + #expect(value(at: ["params", "permissionProfile"], in: commandJSON) == nil) + #expect(value(at: ["params", "sandboxPolicy"], in: commandJSON) == nil) + + let operation = try #require(await operationTask.value) + #expect(operation.categoryID == .extensionMaintenance) + #expect(operation.operationID == result.operationID) + #expect(operation.title == "Upgrade plugin marketplace") + #expect(operation.status == .succeeded) + #expect(operation.commands.first?.argv == result.command) + #expect(operation.appServerMethod == "command/exec") + #expect(operation.intentKind == "extensionMarketplaceUpgrade") + #expect(operation.rollback.isAvailable == false) + + await client.stop() + } + + @Test("CodexExtensions refuses marketplace upgrades when maintenance is disabled") + func codexExtensionsRefusesMarketplaceUpgradesWhenMaintenanceIsDisabled() async throws { + var featurePolicy = SwiftASBFeaturePolicy.defaults + featurePolicy.setMode(.disabled, for: .extensionMaintenance) + + let transport = FakeCodexAppServerTransport() + let client = CodexAppServer(transport: transport, featurePolicy: featurePolicy) + + try await client.start() + _ = try await client.initialize( + .init( + clientInfo: .init( + name: "SwiftASBTests", + title: "SwiftASB Tests", + version: "0.1.0" + ) + ) + ) + + await #expect(throws: CodexAppServerError.self) { + try await client.extensions.upgradeMarketplace( + .init(marketplaceName: "openai-curated") + ) + } + + let methods = await transport.recordedMethods + #expect(!methods.contains("plugin/list")) + #expect(!methods.contains("command/exec")) + + await client.stop() + } + + @Test("CodexExtensions refuses marketplace upgrades when maintenance is read-only") + func codexExtensionsRefusesMarketplaceUpgradesWhenMaintenanceIsReadOnly() async throws { + var featurePolicy = SwiftASBFeaturePolicy.defaults + featurePolicy.setMode(.readOnly, for: .extensionMaintenance) + + let transport = FakeCodexAppServerTransport() + let client = CodexAppServer(transport: transport, featurePolicy: featurePolicy) + + try await client.start() + _ = try await client.initialize( + .init( + clientInfo: .init( + name: "SwiftASBTests", + title: "SwiftASB Tests", + version: "0.1.0" + ) + ) + ) + + await #expect(throws: CodexAppServerError.self) { + try await client.extensions.upgradeMarketplace( + .init(marketplaceName: "openai-curated") + ) + } + + let methods = await transport.recordedMethods + #expect(!methods.contains("plugin/list")) + #expect(!methods.contains("command/exec")) + + await client.stop() + } + @Test("CodexExtensions rejects removed per-cwd extra skill roots option") func codexExtensionsRejectsRemovedPerCwdExtraSkillRootsOption() async throws { let transport = FakeCodexAppServerTransport() diff --git a/Tests/SwiftASBTests/Public/CodexAppServerLibraryTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerLibraryTests.swift index b472617..68bb8b3 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerLibraryTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerLibraryTests.swift @@ -645,6 +645,93 @@ extension CodexAppServerTests { await client.stop() } + @MainActor + @Test("library refreshes selected worktree Git status through command exec") + func libraryRefreshesSelectedWorktreeGitStatusThroughCommandExec() async throws { + let transport = FakeCodexAppServerTransport( + commandExecResultQueue: [ + commandExecResult(stdout: "/tmp/project\n"), + commandExecResult(stdout: "abcdef1234567890\n"), + commandExecResult( + stdout: """ + origin\thttps://github.com/gaelic-ghost/SwiftASB.git (fetch) + origin\thttps://github.com/gaelic-ghost/SwiftASB.git (push) + upstream\thttps://github.com/openai/codex.git (fetch) + + """ + ), + commandExecResult( + stdout: """ + ## docs/feature-permission-plan...origin/docs/feature-permission-plan [ahead 1, behind 2] + M Sources/SwiftASB/Public/CodexWorkspace.swift + ?? docs/media/swiftasb-codex-apps-promo.mp3 + + """ + ), + ] + ) + let client = CodexAppServer(transport: transport) + + try await client.start() + _ = try await client.initialize( + .init( + clientInfo: .init( + name: "SwiftASBTests", + title: "SwiftASB Tests", + version: "0.1.0" + ) + ) + ) + _ = try await client.startThread( + .init( + currentDirectoryPath: "/tmp/project", + ephemeral: false + ) + ) + + let library = try await client.makeLibrary( + configuration: .init( + pageSize: 20, + reconcilesOnCreation: false, + loadsAppSnapshotsOnCreation: false + ) + ) + library.selectThread("thread-123") + try await waitForCondition { + await MainActor.run { + library.selectedGitStatus != nil + } + } + + let status = try #require(library.selectedGitStatus) + #expect(status.currentDirectoryPath == "/tmp/project") + #expect(status.repositoryRootPath == "/tmp/project") + #expect(status.repository?.originURL == "https://github.com/gaelic-ghost/SwiftASB.git") + #expect(status.repository?.branch == "docs/feature-permission-plan") + #expect(status.repository?.shortSHA == "abcdef123456") + #expect(status.remotes.count == 3) + #expect(status.status.upstream == "origin/docs/feature-permission-plan") + #expect(status.status.aheadCount == 1) + #expect(status.status.behindCount == 2) + #expect(status.status.changedFileCount == 2) + #expect(status.status.untrackedFileCount == 1) + #expect(status.source == .commandExec) + #expect(library.latestGitStatusErrorDescription == nil) + + let commandPayloads = await transport.requestPayloads(for: "command/exec") + #expect(commandPayloads.count == 4) + let firstCommand = try decodedJSONObject(from: commandPayloads[0]) + #expect(value(at: ["params", "command"], in: firstCommand) as? [String] == [ + "git", + "-C", + "/tmp/project", + "rev-parse", + "--show-toplevel", + ]) + + await client.stop() + } + @Test("thread list query descriptors provide common list shapes") func threadListQueryDescriptorsProvideCommonListShapes() { let projectQuery = CodexAppServer.ThreadListQD @@ -764,6 +851,18 @@ private func decodedJSONObject(from data: Data) throws -> [String: Any] { try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) } +private func commandExecResult( + exitCode: Int = 0, + stdout: String, + stderr: String = "" +) -> [String: Any] { + [ + "exitCode": exitCode, + "stderr": stderr, + "stdout": stdout, + ] +} + private func value( at path: [String], in object: [String: Any] diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift b/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift index ce076ff..d9fa105 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift @@ -47,6 +47,8 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { private var threadTurnsListResult: [String: Any]? private var threadTurnsListResultQueue: [[String: Any]] private var threadTurnsItemsListResult: [String: Any]? + private var commandExecResult: [String: Any] + private var commandExecResultQueue: [[String: Any]] private var appSnapshotResponseDelayNanoseconds: UInt64 = 0 private let resolvedExecutable: CodexCLIExecutableResolver.Resolution? private var started = false @@ -65,7 +67,13 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { threadTurnsListErrorMessage: String? = nil, threadTurnsListResult: [String: Any]? = nil, threadTurnsListResultQueue: [[String: Any]] = [], - threadTurnsItemsListResult: [String: Any]? = nil + threadTurnsItemsListResult: [String: Any]? = nil, + commandExecResult: [String: Any] = [ + "exitCode": 0, + "stderr": "", + "stdout": "", + ], + commandExecResultQueue: [[String: Any]] = [] ) { self.resolvedExecutable = executableResolution self.threadListResult = threadListResult @@ -79,6 +87,8 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { self.threadTurnsListResult = threadTurnsListResult self.threadTurnsListResultQueue = threadTurnsListResultQueue self.threadTurnsItemsListResult = threadTurnsItemsListResult + self.commandExecResult = commandExecResult + self.commandExecResultQueue = commandExecResultQueue } func setThreadListResult(_ result: [String: Any]?) { @@ -1134,6 +1144,17 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { "nextCursor": "cursor-older-items", ] ) + case "command/exec": + if !commandExecResultQueue.isEmpty { + return responsePayload( + id: id, + result: commandExecResultQueue.removeFirst() + ) + } + return responsePayload( + id: id, + result: commandExecResult + ) case "turn/start": let turnID = turnStartIDQueue.isEmpty ? "turn-123" : turnStartIDQueue.removeFirst() return responsePayload( diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerTests.swift index 8edde4d..c7c5776 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTests.swift @@ -100,6 +100,140 @@ struct CodexAppServerTests { await client.stop() } + @Test("streams SwiftASB feature operation events") + func streamsSwiftASBFeatureOperationEvents() async throws { + let client = CodexAppServer(transport: FakeCodexAppServerTransport()) + let stream = await client.featureOperationEvents() + let nextEventTask = Task { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + + let event = SwiftASBFeatureOperationEvent( + categoryID: .extensionMaintenance, + operationID: "plugin-upgrade-1", + title: "Upgrade installed plugin", + summary: "Upgraded an already-installed plugin.", + reason: "Extension maintenance is enabled by the host app.", + startedAt: Date(timeIntervalSince1970: 1_700_000_000), + completedAt: Date(timeIntervalSince1970: 1_700_000_001), + affectedPaths: ["/Users/example/.codex/plugins/cache/socket/example"], + commands: [ + .init( + argv: ["codex", "plugin", "upgrade", "example"], + currentDirectoryPath: "/Users/example" + ), + ], + appServerMethod: "extension/plugin/upgrade", + intentKind: "extensionMaintenance", + status: .succeeded + ) + + await client.publishFeatureOperationEvent(event) + + let receivedEvent = try await withThrowingTaskGroup( + of: SwiftASBFeatureOperationEvent?.self + ) { group in + group.addTask { + await nextEventTask.value + } + group.addTask { + try await Task.sleep(nanoseconds: 1_000_000_000) + throw TimeoutError() + } + + let result = try await group.next() + group.cancelAll() + return try #require(result) + } + + #expect(receivedEvent == event) + + await client.stop() + } + + @Test("replays buffered feature operation events to later subscribers") + func replaysBufferedFeatureOperationEventsToLaterSubscribers() async throws { + let client = CodexAppServer(transport: FakeCodexAppServerTransport()) + let event = SwiftASBFeatureOperationEvent( + categoryID: .gitActions, + operationID: "branch-create-1", + title: "Create Git branch", + summary: "Created a feature branch.", + reason: "Git actions are enabled by the host app.", + startedAt: Date(timeIntervalSince1970: 1_700_000_000), + completedAt: Date(timeIntervalSince1970: 1_700_000_001), + commands: [ + .init(argv: ["git", "switch", "-c", "docs/example"]) + ], + appServerMethod: "command/exec", + intentKind: "gitBranchCreate", + status: .succeeded + ) + + await client.publishFeatureOperationEvent(event) + + let replayStream = await client.featureOperationEvents() + var iterator = replayStream.makeAsyncIterator() + let receivedEvent = await iterator.next() + + #expect(receivedEvent == event) + + await client.stop() + } + + @Test("runs internal commands through command/exec without thread transcript methods") + func runsInternalCommandsThroughCommandExecWithoutThreadTranscriptMethods() async throws { + let transport = FakeCodexAppServerTransport( + commandExecResult: [ + "exitCode": 0, + "stderr": "", + "stdout": "## docs/feature-permission-plan\n", + ] + ) + let client = CodexAppServer(transport: transport) + + try await client.start() + _ = try await client.initialize( + .init( + clientInfo: .init( + name: "SwiftASBTests", + title: "SwiftASB Tests", + version: "0.1.0" + ) + ) + ) + + let result = try await client.executeCommand( + .init( + command: ["git", "status", "--short", "--branch"], + currentDirectoryPath: "/tmp/project", + outputBytesCap: 4096, + timeoutMilliseconds: 5_000 + ) + ) + + #expect(result.exitCode == 0) + #expect(result.stdout == "## docs/feature-permission-plan\n") + #expect(result.stderr == "") + + let methods = await transport.recordedMethods + #expect(methods.contains("command/exec")) + #expect(!methods.contains("thread/start")) + #expect(!methods.contains("turn/start")) + #expect(!methods.contains("thread/turns/items/list")) + + let requestPayload = try #require(await transport.recordedRequestPayload(for: "command/exec")) + let request = try #require(try JSONSerialization.jsonObject(with: requestPayload) as? [String: Any]) + let params = try #require(request["params"] as? [String: Any]) + #expect(params["command"] as? [String] == ["git", "status", "--short", "--branch"]) + #expect(params["cwd"] as? String == "/tmp/project") + #expect(params["permissionProfile"] == nil) + #expect(params["sandboxPolicy"] == nil) + + await client.stop() + } + @Test("lists app-wide models through the public client") func listsAppWideModels() async throws { let transport = FakeCodexAppServerTransport() diff --git a/Tests/SwiftASBTests/Public/CodexWorkspaceTests.swift b/Tests/SwiftASBTests/Public/CodexWorkspaceTests.swift index 7c9dfd3..7a83897 100644 --- a/Tests/SwiftASBTests/Public/CodexWorkspaceTests.swift +++ b/Tests/SwiftASBTests/Public/CodexWorkspaceTests.swift @@ -54,4 +54,55 @@ struct CodexWorkspaceTests { #expect(snapshot.repository == nil) #expect(!snapshot.hasRepositoryFacts) } + + @Test("Git status snapshot preserves root remotes and dirty summary") + func gitStatusSnapshotPreservesRootRemotesAndDirtySummary() { + let snapshot = CodexWorkspace.GitStatusSnapshot( + worktreeID: "https://github.com/gaelic-ghost/SwiftASB.git", + currentDirectoryPath: "/tmp/SwiftASB", + repositoryRootPath: "/tmp/SwiftASB", + repository: .init( + originURL: "https://github.com/gaelic-ghost/SwiftASB.git", + branch: "docs/feature-permission-plan", + sha: "abcdef1234567890" + ), + remotes: [ + .init( + name: "origin", + url: "https://github.com/gaelic-ghost/SwiftASB.git", + purpose: .fetch + ), + ], + status: .init( + branch: "docs/feature-permission-plan", + upstream: "origin/docs/feature-permission-plan", + aheadCount: 1, + changedFileCount: 2, + untrackedFileCount: 1 + ), + source: .appServerAndCommandExec + ) + + #expect(snapshot.id == "https://github.com/gaelic-ghost/SwiftASB.git") + #expect(snapshot.repositoryRootPath == "/tmp/SwiftASB") + #expect(snapshot.repository?.shortSHA == "abcdef123456") + #expect(snapshot.remotes.map(\.name) == ["origin"]) + #expect(snapshot.status.upstream == "origin/docs/feature-permission-plan") + #expect(snapshot.status.aheadCount == 1) + #expect(snapshot.status.changedFileCount == 2) + #expect(snapshot.status.untrackedFileCount == 1) + #expect(snapshot.isDirty) + #expect(snapshot.source == .appServerAndCommandExec) + } + + @Test("Git status summary treats untracked-only worktrees as dirty") + func gitStatusSummaryTreatsUntrackedOnlyWorktreesAsDirty() { + let status = CodexWorkspace.GitStatusSummary( + branch: "main", + changedFileCount: 0, + untrackedFileCount: 2 + ) + + #expect(status.isDirty) + } } diff --git a/Tests/SwiftASBTests/Public/SwiftASBFeatureOperationEventTests.swift b/Tests/SwiftASBTests/Public/SwiftASBFeatureOperationEventTests.swift new file mode 100644 index 0000000..e53de38 --- /dev/null +++ b/Tests/SwiftASBTests/Public/SwiftASBFeatureOperationEventTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import SwiftASB + +@Suite("SwiftASB feature operation events") +struct SwiftASBFeatureOperationEventTests { + @Test("feature operation event carries mutation metadata") + func featureOperationEventCarriesMutationMetadata() { + let startedAt = Date(timeIntervalSince1970: 1_700_000_000) + let completedAt = Date(timeIntervalSince1970: 1_700_000_003) + let event = SwiftASBFeatureOperationEvent( + categoryID: .swiftRepoGuidanceSync, + operationID: "guidance-sync-123", + title: "Sync Swift guidance", + summary: "Updated repo guidance files for a Swift package.", + reason: "The host enabled trusted Swift repo guidance sync.", + startedAt: startedAt, + completedAt: completedAt, + affectedPaths: ["AGENTS.md", "CONTRIBUTING.md"], + commands: [ + .init( + argv: ["git", "status", "--short"], + currentDirectoryPath: "/tmp/project" + ), + ], + appServerMethod: "command/exec", + intentKind: "swiftRepoGuidanceSync", + status: .succeeded, + rollback: .init( + isAvailable: true, + handle: "swift-guidance-sync:guidance-sync-123", + summary: "Restore the touched guidance files from the pre-sync snapshot." + ), + diagnosticText: nil + ) + + #expect(event.id == "guidance-sync-123") + #expect(event.categoryID == .swiftRepoGuidanceSync) + #expect(event.operationID == "guidance-sync-123") + #expect(event.title == "Sync Swift guidance") + #expect(event.summary == "Updated repo guidance files for a Swift package.") + #expect(event.reason == "The host enabled trusted Swift repo guidance sync.") + #expect(event.startedAt == startedAt) + #expect(event.completedAt == completedAt) + #expect(event.affectedPaths == ["AGENTS.md", "CONTRIBUTING.md"]) + #expect(event.commands[0].argv == ["git", "status", "--short"]) + #expect(event.commands[0].currentDirectoryPath == "/tmp/project") + #expect(event.appServerMethod == "command/exec") + #expect(event.intentKind == "swiftRepoGuidanceSync") + #expect(event.status == .succeeded) + #expect(event.rollback.isAvailable) + #expect(event.rollback.handle == "swift-guidance-sync:guidance-sync-123") + #expect(event.diagnosticText == nil) + } +} diff --git a/Tests/SwiftASBTests/Public/SwiftASBFeaturePolicyTests.swift b/Tests/SwiftASBTests/Public/SwiftASBFeaturePolicyTests.swift new file mode 100644 index 0000000..3251a2d --- /dev/null +++ b/Tests/SwiftASBTests/Public/SwiftASBFeaturePolicyTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import SwiftASB + +@Suite("SwiftASB feature policy") +struct SwiftASBFeaturePolicyTests { + @Test("built-in feature categories have stable ids and defaults") + func builtInFeatureCategoriesHaveStableIDsAndDefaults() { + let categories = SwiftASBFeatureCategory.builtIn + let ids = categories.map(\.id) + + #expect(ids == [ + .gitObservability, + .extensionInventory, + .extensionMaintenance, + .swiftRepoGuidanceSync, + .gitActions, + .configMutation, + .extensionMutation, + .worktreeAutomation, + ]) + + #expect(SwiftASBFeatureCategory.builtInCategory(id: .gitObservability)?.defaultMode == .enabled) + #expect(SwiftASBFeatureCategory.builtInCategory(id: .extensionInventory)?.defaultMode == .enabled) + #expect(SwiftASBFeatureCategory.builtInCategory(id: .extensionMaintenance)?.defaultMode == .enabled) + #expect(SwiftASBFeatureCategory.builtInCategory(id: .swiftRepoGuidanceSync)?.defaultMode == .disabled) + #expect(SwiftASBFeatureCategory.builtInCategory(id: .gitActions)?.defaultMode == .disabled) + #expect(SwiftASBFeatureCategory.builtInCategory(id: .configMutation)?.defaultMode == .disabled) + #expect(SwiftASBFeatureCategory.builtInCategory(id: .extensionMutation)?.defaultMode == .disabled) + #expect(SwiftASBFeatureCategory.builtInCategory(id: .worktreeAutomation)?.defaultMode == .disabled) + } + + @Test("policy defaults allow quiet reads and disable repo mutations") + func policyDefaultsAllowQuietReadsAndDisableRepoMutations() { + let policy = SwiftASBFeaturePolicy.defaults + + #expect(policy.mode(for: .gitObservability) == .enabled) + #expect(policy.mode(for: .extensionInventory) == .enabled) + #expect(policy.mode(for: .extensionMaintenance) == .enabled) + #expect(policy.mode(for: .swiftRepoGuidanceSync) == .disabled) + #expect(policy.mode(for: .gitActions) == .disabled) + #expect(policy.mode(for: .configMutation) == .disabled) + #expect(policy.mode(for: .extensionMutation) == .disabled) + #expect(policy.mode(for: .worktreeAutomation) == .disabled) + #expect(policy.hostAccess == .unknown) + } + + @Test("policy can override one category without losing built-in fallback") + func policyCanOverrideOneCategoryWithoutLosingBuiltInFallback() { + var policy = SwiftASBFeaturePolicy() + + #expect(policy.mode(for: .gitObservability) == .enabled) + #expect(policy.mode(for: .swiftRepoGuidanceSync) == .disabled) + + policy.setMode(.enabled, for: .swiftRepoGuidanceSync) + + #expect(policy.mode(for: .gitObservability) == .enabled) + #expect(policy.mode(for: .swiftRepoGuidanceSync) == .enabled) + #expect(policy.mode(for: "futureCustomCategory") == .disabled) + } + + @Test("host access can declare sandbox-friendly home directory access") + func hostAccessCanDeclareSandboxFriendlyHomeDirectoryAccess() { + let home = URL(fileURLWithPath: "/Users/example") + let access = SwiftASBHostAccess.homeDirectoryReadWrite( + url: home, + source: .securityScopedBookmark + ) + + #expect(access.homeDirectoryReadWriteGranted) + #expect(access.homeDirectoryURL == home) + #expect(access.accessSource == .securityScopedBookmark) + } +} diff --git a/docs/maintainers/feature-permission-policy-plan.md b/docs/maintainers/feature-permission-policy-plan.md new file mode 100644 index 0000000..f14814d --- /dev/null +++ b/docs/maintainers/feature-permission-policy-plan.md @@ -0,0 +1,345 @@ +# Feature Permission Policy Plan + +## Purpose + +This note records the intended `SwiftASB` design for feature-level permission +policy before the package promotes convenience APIs that run Git commands, +refresh repo guidance, mutate config, or maintain installed extensions. + +The goal is to make common, safe, idempotent developer workflows feel +trustworthy and quiet instead of forcing repeated review prompts. SwiftASB +should ask once at the feature-category boundary, keep read-only facts available +by default, and emit clear observable mutation events when an enabled feature +changes local state. + +## Design Posture + +SwiftASB should not build a second filesystem sandbox on top of Codex. The +Codex app-server already owns command sandboxing for `command/exec`, and +`command/exec` defaults to the user's configured permissions when no explicit +permission profile or legacy sandbox policy is supplied. + +SwiftASB's job is narrower: + +- expose typed feature categories instead of arbitrary shell access +- keep low-surprise read-only and inventory features enabled by default +- let consuming apps enable trusted mutation categories once +- make every mutation visible through human-readable events +- keep rollback and Git-based recovery paths close to any repo-writing feature + +In plain language: permissions should protect users from surprising authority, +not punish them for using tools they intentionally enabled. + +## Command Execution Boundary + +Use `command/exec` for SwiftASB-owned Git and GitHub helpers that need an +external executable. + +`command/exec` is the correct primitive because it: + +- runs an argv vector in the Codex app-server sandbox +- does not create a thread or turn +- does not create user-message, command-execution, or transcript items +- can stream stdout and stderr through connection-scoped notifications +- defaults to the user's configured permissions when request overrides are + omitted + +Do not use `process/spawn` for permission-sensitive SwiftASB helpers. +`process/spawn` is explicitly unsandboxed on the host where the app-server is +running, and should remain an internal or advanced-process-control concern until +SwiftASB has a consumer workflow that justifies it. + +Do not use thread shell-command flows for library-owned Git facts. Thread shell +commands are user-facing conversation activity and should remain part of the +thread/turn transcript model. + +## Policy Ownership + +Add a SwiftASB-owned app-wide policy model, separate from Codex's interactive +approval request types. + +Avoid names like `SwiftASBApprovals` for the top-level owner because +`CodexApprovalRequest` already means server-originated approval requests that a +turn or thread must answer. Prefer language such as: + +- `SwiftASBFeaturePolicy` +- `SwiftASBFeatureGate` +- `SwiftASBFeatureCategory` +- `SwiftASBHostAccess` + +This policy describes what SwiftASB convenience features are allowed to do. It +does not describe every low-level Codex permission profile or sandbox rule. + +## Feature Categories + +Each category should have a stable id, user-facing name, description, +permission reason, default mode, current mode, sensitivity, and event policy. + +Candidate modes: + +- `enabled`: the feature can run +- `disabled`: the feature cannot run +- `readOnly`: the feature can read or inventory state but cannot mutate it + +Candidate event policies: + +- `quietReads`: read-only refreshes should not announce every poll +- `notifyOnMutation`: writes, upgrades, or repo changes must emit observable + events +- `requireExplicitAction`: high-impact operations require a direct caller action + even when the category is enabled + +Initial categories: + +| Category | Default | Event Policy | Scope | +| --- | --- | --- | --- | +| `gitObservability` | `enabled` | `quietReads` | Read branch, SHA, remotes, status summaries, repository identity, and Git availability through Codex-owned facts or sandboxed `command/exec`. | +| `extensionInventory` | `enabled` | `quietReads` | List installed apps, skills, plugins, marketplaces, collaboration modes, and update availability. | +| `extensionMaintenance` | `enabled` | `notifyOnMutation` | Upgrade already-installed extensions, plugins, skills, or marketplace entries. Installing new extensions and uninstalling existing ones should remain separate actions or stricter categories. | +| `swiftRepoGuidanceSync` | `disabled` | `notifyOnMutation` | Apply trusted, idempotent Apple/Swift repo guidance updates inside detected Git repositories with rollback support. | +| `gitActions` | `disabled` | `notifyOnMutation` | Run bounded typed Git intents such as branch creation, staging, commit preparation, or local rollback helpers. Push, force-push, and history rewriting should be stricter subcategories or explicit actions. | +| `configMutation` | `disabled` | `notifyOnMutation` | Write Codex or SwiftASB configuration values through app-server config-write surfaces once those surfaces have a stable public model. | +| `extensionMutation` | `disabled` | `notifyOnMutation` | Install new extensions, uninstall extensions, change extension config, or mutate extension sharing settings. | +| `worktreeAutomation` | `disabled` | `notifyOnMutation` | Create, update, or clean worktrees after the workspace/Git fact model and rollback story are explicit. | + +Read-only categories being enabled by default is intentional. SwiftASB consumers +should not need to ask users repeatedly before showing basic developer-tool +facts that the app-server and Codex config already permit. + +## Host Access Model + +SwiftASB should support unsandboxed apps and sandboxed macOS apps with explicit +user-granted access to the home directory or another broad workspace root. + +Model this as host access capability rather than as a feature category. + +Candidate shape: + +```swift +public struct SwiftASBHostAccess: Sendable, Equatable { + public var homeDirectoryReadWriteGranted: Bool + public var homeDirectoryURL: URL? + public var accessSource: AccessSource +} + +public enum AccessSource: Sendable, Equatable { + case unsandboxed + case securityScopedBookmark + case userSelectedDirectory + case fullDiskAccess + case declaredByHostApp + case unknown +} +``` + +The target practical assumption is: sandboxed, but the consuming application has +read-write access to the user's home directory or another broad workspace root. +That is close to the unsandboxed case while still supporting the realistic +sandboxed case for developer tools. + +SwiftASB should still handle denial gracefully. On macOS, user-granted access, +security-scoped bookmarks, Full Disk Access, POSIX permissions, and mandatory +system protections can disagree in practice. A declared capability should make a +feature eligible to try work; it should not be treated as proof that every path +will succeed. + +Apple's sandbox rules matter here: + +- sandboxed apps can persist access to user-selected resources with + security-scoped bookmarks +- resolved security-scoped URLs require balanced access calls while the resource + is in use +- Full Disk Access is granted by the person using the app in System Settings, + not acquired automatically by entitlement or code + +## Observable Mutation Events + +Every enabled mutation category must produce human-readable observable events. +These events are the low-friction alternative to repeated prompts. + +Mutation events should include: + +- category id +- stable operation id +- short title +- human-readable summary +- reason text +- start time and completion time +- affected paths when known +- commands run when applicable +- app-server method or SwiftASB intent kind +- result status +- rollback availability and rollback handle when available +- diagnostic text for failures + +The event copy should answer: what changed, why SwiftASB changed it, where it +changed, and how to undo or inspect it. + +Do not emit noisy events for routine read-only refreshes such as branch/SHA +hydration, installed-extension inventory, or update availability checks. + +## Proactive Observable Refresh + +`CodexAppServer.Library` should become proactive for safe expected facts. + +When a thread or worktree is selected and `gitObservability` is enabled, the +library should refresh Git facts automatically: + +- repository root when Codex or sandboxed command execution can provide it +- current branch +- current SHA +- origin/remotes +- dirty/clean status summary +- ahead/behind facts when cheap and safe + +These values should hydrate existing app-wide observable snapshots instead of +forcing each consuming UI to run its own Git probes. + +The first implementation should prefer Codex app-server-owned facts when they +exist, then use sandboxed `command/exec` as the fallback for Git facts that +upstream does not expose yet. + +## Swift Repo Guidance Sync + +`swiftRepoGuidanceSync` should be a trusted, idempotent repo-maintenance +category rather than a per-file approval prompt loop. + +Rules for the first implementation: + +- run only inside a detected Git repository +- require a clean or explicitly recoverable working tree unless the caller opts + into working with existing changes +- update only the repo guidance surfaces owned by the selected workflow +- preserve intentional document structure +- emit one mutation event with the touched file list and summary +- provide a one-action rollback path when possible +- leave a clear diagnostic when rollback is unavailable + +The initial workflow can be based on the open-source Apple Dev Skills sync +guidance because that source is trusted, maintained by Gale, and aligned with +SwiftASB's Swift/Apple package conventions. + +## Typed Intents + +Do not expose arbitrary command strings as the main public API. + +Expose typed intents that SwiftASB can validate, describe, run, observe, and +eventually roll back. Examples: + +- `refreshGitStatus(worktree:)` +- `refreshGitRemotes(worktree:)` +- `upgradeInstalledExtensions(_:)` +- `syncSwiftRepoGuidance(repository:)` +- `prepareGitCommit(repository:message:)` +- `rollbackGuidanceSync(handle:)` + +Each intent should declare the feature category it requires and whether it is +read-only, idempotent maintenance, or mutation. + +## Future `run(...)` And `liftoff(...)` + +When SwiftASB grows a one-shot `run(...)` or larger `liftoff(...)` convenience +surface, feature policy should be part of its configuration. + +The convenience API should accept an explicit policy value and should also have +safe defaults: + +- read-only and inventory features enabled +- mutation features disabled except existing-extension maintenance if the + consuming app chooses to keep that default +- host access unset unless the app declares or supplies it +- observable mutation events enabled + +The convenience API should not hide mutations. It can make common work easy, but +it must still surface what changed. + +## Implementation Slices + +### Slice 1: Policy Types And Descriptors + +Status: shipped on `docs/feature-permission-plan`. + +- Add public feature-policy value types. +- Add built-in category descriptors with names, descriptions, reasons, defaults, + sensitivity, and event policy. +- Add tests for defaults, stable ids, and category lookup. +- Document the categories in DocC. + +### Slice 2: Command Execution Protocol Surface + +Status: shipped on `docs/feature-permission-plan`. + +- Promote app-server `command/exec` request/response types through an internal + protocol layer. +- Keep raw process control internal. +- Add a small internal executor that runs argv commands through `command/exec` + using default Codex permissions unless an implementation test intentionally + supplies a sandbox override. +- Add live or fake-transport tests proving command output does not become thread + transcript activity. + +### Slice 3: Git Observability + +Status: shipped on `docs/feature-permission-plan`. + +- Add typed Git fact intents backed by app-server facts first and sandboxed + `command/exec` fallback second. +- Hydrate `CodexWorkspace.WorktreeSnapshot` or a sibling Git-status snapshot + with branch, SHA, root, remotes, and status summary when available. +- Wire safe refresh into `CodexAppServer.Library` selection/worktree refresh. +- Keep Git observability on by default. + +### Slice 4: Mutation Event Stream + +- Status: shipped. +- Added `SwiftASBFeatureOperationEvent` as the public, human-readable operation + record for SwiftASB-owned feature mutations. +- Added `CodexAppServer.featureOperationEvents()` as the app-wide observable + stream. +- Kept read-only refreshes quiet unless a future feature promotes a + user-visible failure signal. + +### Slice 5: Existing Extension Maintenance + +- Status: shipped. +- Verified the promoted app-server schema has read-only `plugin/list` and + `plugin/read` but no plugin-marketplace upgrade method. +- Verified the installed Codex CLI exposes + `codex plugin marketplace upgrade [MARKETPLACE_NAME]`. +- Added `CodexAppServer.CodexExtensions.upgradeMarketplace(_:)` as a typed + `extensionMaintenance` intent backed by app-server `command/exec`. +- Kept listing and update checks on by default. +- Treated new installs, uninstalls, config mutation, and sharing mutation as + separate stricter categories. + +### Slice 6: Swift Repo Guidance Sync + +- Add the repo-detection, Git preflight, idempotent write, observable event, and + rollback handle model. +- Start with the Apple/Swift guidance sync workflow. +- Keep the category disabled by default until the consuming app enables it. + +## Definition Of Done + +This plan is complete when SwiftASB can: + +- describe built-in feature categories to a consuming UI +- keep read-only Git and extension inventory flows available by default +- allow a consuming app to enable mutation categories once +- run Git fact refreshes without creating thread transcript items +- emit clear mutation events when enabled categories change local state +- support sandboxed host apps that declare broad home/workspace access +- gracefully report denied filesystem or command access without confusing it + with feature-policy denial + +## Open Questions + +- Should existing-extension upgrades be enabled by default, or should they start + as `readOnly` with a recommended one-time enable prompt? +- Should Git pushes be part of `gitActions`, or a separate `gitRemoteActions` + category? +- Should the host access model store security-scoped bookmark data itself, or + should consuming apps own bookmark persistence and pass active access into + SwiftASB? +- Should mutation events live on `CodexAppServer`, `CodexAppServer.Library`, or + a dedicated app-wide operation center owned by `CodexAppServer`? diff --git a/docs/maintainers/v1-public-api-audit.md b/docs/maintainers/v1-public-api-audit.md index 7d53b32..5b4686c 100644 --- a/docs/maintainers/v1-public-api-audit.md +++ b/docs/maintainers/v1-public-api-audit.md @@ -2,7 +2,7 @@ This document is the working checklist for the `SwiftASB` v1 public API curation pass. The goal is to freeze a compact, Swift-native surface for the -supported app-server lifecycle before `v1.2.1`, not to expose every generated +supported app-server lifecycle before `v1.3.0`, not to expose every generated wire family. ## Current Public Source Inventory @@ -429,7 +429,7 @@ Use these decisions for every public symbol: - [x] Add symbol comments for every stable v1 public type and method that is not self-explanatory from its declaration. - Decision: complete for the `v1.2.1` release boundary. Default-bearing public + Decision: complete for the `v1.3.0` release boundary. Default-bearing public initializers and methods now document whether omission delegates to Codex, chooses a SwiftASB local-history/UI default, or applies an explicit safety default such as `.turn` or `.unchanged`. The source-level pass also covers the @@ -508,7 +508,7 @@ Use these decisions for every public symbol: Decision: covered by the startup, progress/approval, diagnostics/history, and SwiftUI observable companion walkthroughs in `Sources/SwiftASB/SwiftASB.docc/`. - [x] Update stale README release references before the next release. - Decision: README now names `v1.2.1` as the current released baseline. + Decision: README now names `v1.3.0` as the current released baseline. - [x] Confirm README, DocC, and this audit use the same v1 release boundary. Decision: README, DocC, and this audit now describe the same narrow v1 promise: app-server lifecycle, app-wide capability reads, stored-thread diff --git a/docs/maintainers/v1-public-api-symbol-inventory.md b/docs/maintainers/v1-public-api-symbol-inventory.md index 01d180e..5217c8d 100644 --- a/docs/maintainers/v1-public-api-symbol-inventory.md +++ b/docs/maintainers/v1-public-api-symbol-inventory.md @@ -762,23 +762,26 @@ The 2026-05-06 app-server schema promotion added several hand-owned public names - `CodexFS` now includes bounded file discovery and SwiftASB-owned fuzzy ranking over app-server-returned directory entries: `FileDiscoveryQD`, `FileDiscoveryResult`, `FileDiscoveryHit`, `FileDiscoveryHit.Kind`, and `discoverFiles(_:)`. - `CodexConfig` owns effective config reads: `ReadRequest`, `Snapshot`, `Layer`, `LayerMetadata`, `LayerSource`, `LayerSource.Kind`, `RequirementsSnapshot`, `read(_:)`, and `readRequirements()`. - `CodexAppServer.CodexExtensions` owns app-server extension inventory: `AppListRequest`, `AppListPage`, `AppInfo`, `SkillListRequest`, `SkillListSnapshot`, `PluginListRequest`, `PluginListSnapshot`, `PluginReadRequest`, `PluginDetail`, `PluginHookSummary`, `CollaborationModeList`, `listApps(_:)`, `listSkills(_:)`, `listPlugins(_:)`, `readPlugin(_:)`, and `listCollaborationModes()`. +- `CodexAppServer.CodexExtensions` also owns the first extension-maintenance mutation: `MarketplaceUpgradeRequest`, `MarketplaceUpgradeResult`, and `upgradeMarketplace(_:)` for already-configured plugin marketplaces. - `CodexThread` now exposes thread goals: `Goal`, `Goal.Status`, `GoalSetRequest`, `readGoal()`, `setGoal(_:)`, and `clearGoal()`. - `CodexThreadEvent` now includes `.goalUpdated(_:)` and `.goalCleared(_:)` for app-server goal notifications. - `CodexThread.RecentFilesQD` and `CodexThread.RecentCommandsQD` describe repeatable recent-activity companion startup intent. - `CodexAppServer.Library.GroupedBy.repository` groups app-wide library snapshots by `CodexWorkspace.ProjectInfo` identity: app-server Git origin metadata with cwd fallback. - `CodexAppServer.Library` exposes stable worktree groups, selected worktree/repository context, and sorted repository/worktree thread filters for sidebar and project-browser UIs independent of the caller-selected visible grouping mode. -- `CodexWorkspace` owns app-server-owned permission selections, runtime workspace permission facts, project identity, and worktree snapshots: `PermissionSelection`, `PermissionSelectionModification`, `ActivePermissionProfile`, `ActivePermissionModification`, `PermissionProfile`, `FileSystemPermissions`, `FileSystemSandboxEntry`, `FileSystemAccessMode`, `FileSystemPath`, `FileSystemSpecialPath`, `NetworkPermissions`, `ProjectInfo`, `RepositoryInfo`, `SessionSnapshot`, and `WorktreeSnapshot`. +- `CodexWorkspace` owns app-server-owned permission selections, runtime workspace permission facts, project identity, worktree snapshots, and selected-worktree Git observability values: `PermissionSelection`, `PermissionSelectionModification`, `ActivePermissionProfile`, `ActivePermissionModification`, `PermissionProfile`, `FileSystemPermissions`, `FileSystemSandboxEntry`, `FileSystemAccessMode`, `FileSystemPath`, `FileSystemSpecialPath`, `NetworkPermissions`, `ProjectInfo`, `RepositoryInfo`, `SessionSnapshot`, `WorktreeSnapshot`, `GitStatusSnapshot`, `GitStatusSummary`, `GitRemoteInfo`, and `GitFactSource`. - `CodexAppServer.ThreadStartRequest`, `ThreadResumeRequest`, `ThreadForkRequest`, `TurnStartRequest`, `CodexThread.TurnStartRequest`, and `CodexThread.startTextTurn(...)` now accept optional `CodexWorkspace.PermissionSelection` values. - `CodexAppServer.ThreadSession` and `CodexThread` now expose active permission-profile provenance, runtime permission facts, app-server-owned project identity, app-server-owned worktree snapshots, and a `CodexWorkspace.SessionSnapshot`. - `CodexAppServer.ThreadInfo` and `CodexAppServer.Library.ThreadSnapshot` now expose `CodexAppServer.ThreadSource` so launcher UIs can badge CLI, app-server, editor, custom, and sub-agent threads without reading generated wire values. +- `SwiftASBFeaturePolicy`, `SwiftASBFeatureCategory`, `SwiftASBFeatureMode`, `SwiftASBFeatureSensitivity`, `SwiftASBFeatureEventPolicy`, and `SwiftASBHostAccess` now describe SwiftASB-owned convenience-feature policy, built-in category defaults, and host access declarations without replacing Codex app-server sandboxing. +- `SwiftASBFeatureOperationEvent`, `SwiftASBFeatureOperationEvent.Status`, `SwiftASBFeatureOperationEvent.Command`, and `SwiftASBFeatureOperationEvent.Rollback` now describe human-readable SwiftASB-owned mutation records for the app-wide feature-operation event stream. ## Public Property Counts By Source File -- `Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift`: 19 public properties -- `Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift`: 113 public properties +- `Sources/SwiftASB/Public/CodexAppServer+Bootstrap.swift`: 20 public properties +- `Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift`: 126 public properties - `Sources/SwiftASB/Public/CodexAppServer+Compatibility.swift`: 10 public properties - `Sources/SwiftASB/Public/CodexAppServer+Hooks.swift`: 32 public properties -- `Sources/SwiftASB/Public/CodexAppServer+Library.swift`: 61 public properties +- `Sources/SwiftASB/Public/CodexAppServer+Library.swift`: 66 public properties - `Sources/SwiftASB/Public/CodexAppServer+LoadedThreads.swift`: 4 public properties - `Sources/SwiftASB/Public/CodexAppServer+MCP.swift`: 43 public properties - `Sources/SwiftASB/Public/CodexAppServer+Models.swift`: 23 public properties @@ -796,4 +799,6 @@ The 2026-05-06 app-server schema promotion added several hand-owned public names - `Sources/SwiftASB/Public/CodexThread+RecentTurns.swift`: 54 public properties - `Sources/SwiftASB/Public/CodexThread.swift`: 71 public properties - `Sources/SwiftASB/Public/CodexTurnHandle.swift`: 108 public properties -- `Sources/SwiftASB/Public/CodexWorkspace.swift`: 44 public properties +- `Sources/SwiftASB/Public/CodexWorkspace.swift`: 63 public properties +- `Sources/SwiftASB/Public/SwiftASBFeatureOperationEvent.swift`: 20 public properties +- `Sources/SwiftASB/Public/SwiftASBFeaturePolicy.swift`: 13 public properties diff --git a/docs/media/swiftasb-codex-apps-promo.mp3 b/docs/media/swiftasb-codex-apps-promo.mp3 new file mode 100644 index 0000000..3a718e0 Binary files /dev/null and b/docs/media/swiftasb-codex-apps-promo.mp3 differ diff --git a/scripts/repo-maintenance/version-bump.sh b/scripts/repo-maintenance/version-bump.sh index f1ecde3..4438390 100755 --- a/scripts/repo-maintenance/version-bump.sh +++ b/scripts/repo-maintenance/version-bump.sh @@ -36,7 +36,7 @@ API_AUDIT_PATH="$REPO_ROOT/docs/maintainers/v1-public-api-audit.md" current_version=$( { sed -n 's/.*from: "\([0-9][0-9.]*[-A-Za-z0-9.]*\)".*/\1/p' "$README_PATH" - sed -n 's/.*`v\([0-9][0-9.]*[-A-Za-z0-9.]*\)`.*/\1/p' "$README_PATH" + sed -n 's/.*`v\([0-9][0-9.]*[-A-Za-z0-9.]*\)`.*current and latest release.*/\1/p' "$README_PATH" } | head -n 1 ) @@ -49,6 +49,21 @@ tmp_file="${TMPDIR:-/tmp}/swiftasb-readme-version.XXXXXX" tmp_file=$(mktemp "$tmp_file") trap 'rm -f "$tmp_file"' EXIT INT TERM +count_readme_release_references() { + awk \ + -v version="$1" ' + index($0, "current and latest release") && index($0, "`v" version "`") { + count += 1 + } + index($0, "from: \"" version "\"") { + count += 1 + } + END { + print count + 0 + } + ' "$README_PATH" +} + rewrite_release_references() { input_path="$1" output_path="$2" @@ -64,10 +79,25 @@ rewrite_release_references() { ' "$input_path" >"$output_path" } +readme_reference_count="$(count_readme_release_references "$current_version")" +[ "$readme_reference_count" -ge 2 ] || { + printf 'ERROR: SwiftASB version bump expected at least two README release references for %s, but found %s.\n' "$current_version" "$readme_reference_count" >&2 + printf 'Expected the README status sentence and SwiftPM dependency snippet to both carry the release version.\n' >&2 + exit 1 +} + rewrite_release_references "$README_PATH" "$tmp_file" mv "$tmp_file" "$README_PATH" +if [ "$current_version" != "$release_version" ]; then + stale_readme_reference_count="$(count_readme_release_references "$current_version")" + [ "$stale_readme_reference_count" = "0" ] || { + printf 'ERROR: SwiftASB version bump left %s stale README release reference(s) for %s.\n' "$stale_readme_reference_count" "$current_version" >&2 + exit 1 + } +fi + for doc_path in "$ROADMAP_PATH" "$API_AUDIT_PATH"; do tmp_file="${TMPDIR:-/tmp}/swiftasb-release-doc-version.XXXXXX" tmp_file=$(mktemp "$tmp_file")