diff --git a/README.md b/README.md index 759fcd0..dfe3ebb 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Recent observable startup is intentionally UI-friendly around known live app-ser The current public lifecycle contract is intentionally narrow and explicit: -- `CodexAppServer` starts and stops the subprocess, initializes the session, starts threads and turns, lists stored threads, reads/resumes/forks threads, pages stored turns, lists models, and lists MCP server statuses. +- `CodexAppServer` starts and stops the subprocess, initializes the session, starts threads and turns, lists stored threads, reads/resumes/forks threads, pages stored turns, lists models, lists MCP server statuses, and lists configured hook diagnostics. - `CodexThread` owns thread-scoped turn creation, thread events, thread-management actions, local-history reads, and thread-scoped observable companions. - `CodexTurnHandle` owns active-turn events and active-turn controls such as response handling, steering, interruption, minimap observation, and explicit completion snapshot handoff. - Approval and elicitation requests use hand-owned public models, including command approval, file-change approval, permissions approval, tool user input, and MCP server elicitation. diff --git a/ROADMAP.md b/ROADMAP.md index 0a5f09a..17144e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,12 +44,12 @@ | Codex CLI schema review | `In progress` | The local `codex-schemas/v0.128.0/` dump exists for the currently installed `codex-cli 0.128.0`, and `scripts/dump-codex-schemas.sh` now makes future versioned experimental dumps repeatable by default. The v0.128 experimental generated batch needs classification before promotion: permission profiles are still present behind the experimental schema surface, while new active-permission-profile, permissions-selection, hooks/model-provider/remote-control/thread-goal schema families need public/observable/internal decisions. | | Stdio subprocess transport | `Shipped internally` | The transport launches `codex app-server --listen stdio://`, frames newline-delimited JSON, correlates request IDs, and captures stderr for diagnostics. | | Raw server-event fanout | `Shipped internally` | Transport can stream raw JSON-RPC notifications and server requests to higher layers. | -| Typed protocol request encoding | `Shipped internally` | `initialize`, `initialized`, `thread/start`, `thread/list`, `thread/read`, `thread/resume`, `thread/fork`, `thread/compact/start`, `thread/rollback`, `thread/name/set`, `thread/metadata/update`, `thread/turns/list`, `model/list`, `mcpServerStatus/list`, and `turn/start` are encoded through the protocol layer. | -| Typed protocol response decoding | `Shipped internally` | `initialize`, `thread/start`, `thread/list`, `thread/read`, `thread/resume`, `thread/fork`, `thread/compact/start`, `thread/rollback`, `thread/name/set`, `thread/metadata/update`, `thread/turns/list`, `model/list`, `mcpServerStatus/list`, and `turn/start` responses are decoded and validated against request IDs. | +| Typed protocol request encoding | `Shipped internally` | `initialize`, `initialized`, `thread/start`, `thread/list`, `thread/read`, `thread/resume`, `thread/fork`, `thread/compact/start`, `thread/rollback`, `thread/name/set`, `thread/metadata/update`, `thread/turns/list`, `model/list`, `mcpServerStatus/list`, `hooks/list`, and `turn/start` are encoded through the protocol layer. | +| Typed protocol response decoding | `Shipped internally` | `initialize`, `thread/start`, `thread/list`, `thread/read`, `thread/resume`, `thread/fork`, `thread/compact/start`, `thread/rollback`, `thread/name/set`, `thread/metadata/update`, `thread/turns/list`, `model/list`, `mcpServerStatus/list`, `hooks/list`, and `turn/start` responses are decoded and validated against request IDs. | | Typed protocol notification decoding | `Partially shipped` | The protocol layer now maps a broader batch of thread, turn, item, reasoning, hook, and reroute notifications, plus the item lifecycle needed to drive the current observable tool, MCP, file-edit, hook, and compaction summaries. | | Public owning client actor | `Shipped` | `CodexAppServer` owns transport plus protocol and exposes startup, shutdown, initialize, thread start, and turn start. | | Public value-typed request and result models | `Shipped` | Public API uses hand-owned Swift value types rather than exposing `CodexWire...` directly. | -| App-wide capability surfaces | `Partially shipped` | `CodexAppServer.listModels(...)` and `CodexAppServer.listMcpServerStatuses(...)` now wrap `model/list` and `mcpServerStatus/list` with hand-owned Swift models. These are connection-wide capability snapshots rather than thread-owned lifecycle actions. Broader app-wide settings and actions still need deliberate public models before promotion. | +| App-wide capability surfaces | `Partially shipped` | `CodexAppServer.listModels(...)`, `CodexAppServer.listMcpServerStatuses(...)`, and `CodexAppServer.listHooks(...)` now wrap `model/list`, `mcpServerStatus/list`, and `hooks/list` with hand-owned Swift models. These are connection-wide capability and diagnostics snapshots rather than thread-owned lifecycle actions. Broader app-wide settings and actions still need deliberate public models before promotion. | | Initialize handshake | `Shipped` | `initialize(...)` automatically sends the follow-up `initialized` notification. | | Thread start flow | `Shipped` | `startThread(...)` returns `CodexThread`, which carries thread metadata plus a back-reference to the shared app-server owner. | | Stored thread list flow | `Shipped` | `listThreads(...)` wraps `thread/list`, returns typed stored-thread pages, and now reconciles local thread metadata plus explicit archived or unarchived list results back into the internal history store. | @@ -121,7 +121,7 @@ The package can now: `windowAroundTurn(...)` and `windowAroundItem(...)` - set thread names, patch stored Git metadata, and roll back trailing turns through `CodexThread` -- list app-wide model and MCP-server capability snapshots through +- list app-wide model, MCP-server, and hook diagnostics snapshots through `CodexAppServer` - document the supported lifecycle in the README without sending consumers into the tests @@ -146,7 +146,7 @@ That means the current priority order is: selection/visibility protection, slimming behavior, and rehydration model as stable enough; remaining work is calibration and richer previews, not proving the model exists. -5. Keep v0.128 schema additions classified before public promotion: `excludeTurns` remains public on resume/fork request models because it directly supports the existing paged history model; `permissionProfile`, `activePermissionProfile`, and request-side `permissions` stay internal until SwiftASB owns a deliberate public permission-profile model; `hooks/list` is a near-term post-v1 diagnostics/capability target; `ModelProviderCapabilitiesRead*` is a clean app-wide capability candidate; thread goals, realtime, fuzzy file search sessions, remote-control status, marketplace/account-management families, and guardian denied-action approval remain post-v1 until their consumer workflows are clearer. The v0.124 classifications still stand: `autoReview` is public as an approval reviewer option, `model/list` and `mcpServerStatus/list` are public app-wide capability snapshots on `CodexAppServer`, `thread/name/set`, `thread/metadata/update`, and `thread/rollback` are public on `CodexThread`, hook `permissionRequest` is available for dashboard/minimap naming, and warning/model-verification/guardian warning families are public diagnostics. +5. Keep v0.128 schema additions classified before public promotion: `excludeTurns` remains public on resume/fork request models because it directly supports the existing paged history model; `permissionProfile`, `activePermissionProfile`, and request-side `permissions` stay internal until SwiftASB owns a deliberate public permission-profile model; `hooks/list` is public as a read-only diagnostics/capability snapshot; `ModelProviderCapabilitiesRead*` is a clean app-wide capability candidate; thread goals, realtime, fuzzy file search sessions, remote-control status, marketplace/account-management families, and guardian denied-action approval remain post-v1 until their consumer workflows are clearer. The v0.124 classifications still stand: `autoReview` is public as an approval reviewer option, `model/list` and `mcpServerStatus/list` are public app-wide capability snapshots on `CodexAppServer`, `thread/name/set`, `thread/metadata/update`, and `thread/rollback` are public on `CodexThread`, hook `permissionRequest` is available for dashboard/minimap naming, and warning/model-verification/guardian warning families are public diagnostics. 6. Do not add `RecentActivity` for v1. The separate `RecentTurns`, `RecentFiles`, and `RecentCommands` types are the clearer consumer surface, and a mixed feed would add more confusion than value right now. 7. Flesh out archive-aware retention and eviction beyond the current list-driven archive-state drift correction. 8. Add any sharper binary-discovery diagnostics we want alongside the current-reviewed compatibility window before a first broader release. @@ -192,11 +192,11 @@ These are intentionally outside the v1 promise unless a concrete consumer workflow forces a release-boundary change before the v1 tag. - [ ] Guardian denied-action approval with a stable request and response model. -- [ ] Hooks list surface near-term after v1. Treat `hooks/list` as one of the - first post-v1 schema promotions: expose per-cwd hook metadata, warnings, and - load errors through a deliberate diagnostics/capability API so Swift clients - can show what hooks are active before a turn runs. Keep hook enable/disable - mutation post-v1+ until the configuration-writing UX is clearer. +- [x] Hooks list surface after v1. `CodexAppServer.listHooks(...)` exposes + per-cwd hook metadata, warnings, and load errors through a deliberate + 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. - [ ] Account-management variants, including provider-specific account families such as Amazon Bedrock. @@ -391,8 +391,8 @@ workflow forces a release-boundary change before the v1 tag. - [x] Confirm the promoted generated-wire snapshot matches the Codex CLI schema version included in the v1 compatibility window. - [x] Classify the Codex CLI `v0.128.0` schema diff before promotion. Decision: - generated permission-profile shapes remain internal, `hooks/list` is a - near-term post-v1 diagnostics/capability target, model-provider capabilities + generated permission-profile shapes remain internal, `hooks/list` is public + as a read-only diagnostics/capability snapshot, model-provider capabilities are a clean public candidate, and thread goals, realtime, fuzzy file search, remote-control status, marketplace/account-management families, and guardian denied-action approval stay post-v1. @@ -551,7 +551,6 @@ workflow forces a release-boundary change before the v1 tag. - Confirm Swift Package Index listing and DocC rendering after the public tag is indexed. -- Promote `hooks/list` as a near-term diagnostics/capability surface. - 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, @@ -766,8 +765,8 @@ runtime can be driven with a mock Responses provider. evidence, while app-connector MCP is the deterministic live elicitation coverage source. - [ ] Guardian denied-action approval after SwiftASB owns a stable public model. -- [ ] Future promoted surfaces such as `hooks/list` and model-provider - capabilities when they become public or observable contracts. +- [ ] Future promoted surfaces such as model-provider capabilities when they + become public or observable contracts. ### Harness And Script Shape @@ -826,7 +825,8 @@ not as the current maintainer priority. - A `v0.128.0` experimental schema compatibility pass has refreshed the staging generator, updated the Codex CLI compatibility window, kept generated permission-profile shapes internal, removed the older permission-profile - compatibility shim, and recorded `hooks/list` as a near-term post-v1 target. + compatibility shim, and promoted `hooks/list` as a post-v1 public + diagnostics/capability surface. - API curation and DocC docs good enough that a Swift consumer can understand the supported package surface without reading maintainer notes, including walkthroughs for the primary v1 lifecycle jobs. diff --git a/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift b/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift index eda36f0..561cfd3 100644 --- a/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift +++ b/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift @@ -263,6 +263,86 @@ struct CodexProtocolThreadTurnsListResponse: Decodable, Equatable, Sendable { let nextCursor: String? } +struct CodexProtocolHooksListParams: Encodable, Equatable, Sendable { + let cwds: [String]? +} + +struct CodexProtocolHooksListResponse: Decodable, Equatable, Sendable { + let data: [Entry] + + struct Entry: Decodable, Equatable, Sendable { + let cwd: String + let errors: [ErrorInfo] + let hooks: [HookMetadata] + let warnings: [String] + } + + struct ErrorInfo: Decodable, Equatable, Sendable { + let message: String + let path: String + } + + struct HookMetadata: Decodable, Equatable, Sendable { + let command: String? + let displayOrder: Int + let enabled: Bool + let eventName: EventName + let handlerType: HandlerType + let isManaged: Bool + let key: String + let matcher: String? + let pluginID: String? + let source: Source + let sourcePath: String + let statusMessage: String? + let timeoutSeconds: UInt64 + + enum CodingKeys: String, CodingKey { + case command + case displayOrder + case enabled + case eventName + case handlerType + case isManaged + case key + case matcher + case pluginID = "pluginId" + case source + case sourcePath + case statusMessage + case timeoutSeconds = "timeoutSec" + } + } + + enum EventName: String, Decodable, Equatable, Sendable { + case permissionRequest + case postToolUse + case preToolUse + case sessionStart + case stop + case userPromptSubmit + } + + enum HandlerType: String, Decodable, Equatable, Sendable { + case agent + case command + case prompt + } + + enum Source: String, Decodable, Equatable, Sendable { + case cloudRequirements + case legacyManagedConfigFile + case legacyManagedConfigMdm + case mdm + case plugin + case project + case sessionFlags + case system + case unknown + case user + } +} + struct CodexProtocolTurnSteerParams: Encodable, Equatable, Sendable { let expectedTurnID: String let input: [CodexWireUserInput] diff --git a/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift b/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift index aaeaad0..d38f008 100644 --- a/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift +++ b/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift @@ -17,6 +17,7 @@ struct CodexAppServerProtocol { case turnStart = "turn/start" case turnSteer = "turn/steer" case turnInterrupt = "turn/interrupt" + case hooksList = "hooks/list" case modelList = "model/list" case mcpServerStatusList = "mcpServerStatus/list" } @@ -179,6 +180,16 @@ struct CodexAppServerProtocol { ) } + func makeHooksListRequest( + id: CodexRPCRequestID, + params: CodexProtocolHooksListParams + ) throws -> Data { + try encodeRequest( + JSONRPCRequestEnvelope(id: id, method: .hooksList, params: params), + method: .hooksList + ) + } + func makeMcpServerStatusListRequest( id: CodexRPCRequestID, params: CodexWireListMCPServerStatusParams @@ -393,6 +404,18 @@ struct CodexAppServerProtocol { ) } + func decodeHooksListResponse( + _ responsePayload: Data, + expectedID: CodexRPCRequestID + ) throws -> CodexProtocolHooksListResponse { + try decodeResponse( + responsePayload, + expectedID: expectedID, + method: .hooksList, + resultType: CodexProtocolHooksListResponse.self + ) + } + func decodeMcpServerStatusListResponse( _ responsePayload: Data, expectedID: CodexRPCRequestID diff --git a/Sources/SwiftASB/Public/CodexAppServer+Hooks.swift b/Sources/SwiftASB/Public/CodexAppServer+Hooks.swift new file mode 100644 index 0000000..b976872 --- /dev/null +++ b/Sources/SwiftASB/Public/CodexAppServer+Hooks.swift @@ -0,0 +1,185 @@ +public extension CodexAppServer { + /// Request used to read configured hook diagnostics for one or more working directories. + struct HookListRequest: Sendable, Equatable { + public var currentDirectoryPaths: [String]? + + /// Creates a hook-list request. + /// + /// Nil `currentDirectoryPaths` omits `cwds`, which lets the app-server + /// use its current session working directory. Passing an empty array + /// sends an empty `cwds` list, which the app-server treats the same way. + public init(currentDirectoryPaths: [String]? = nil) { + self.currentDirectoryPaths = currentDirectoryPaths + } + } + + /// Current configured hook diagnostics grouped by working directory. + struct HookListSnapshot: Sendable, Equatable { + public let entries: [HookListEntry] + } + + /// Configured hook diagnostics for one working directory. + struct HookListEntry: Sendable, Equatable, Identifiable { + public var id: String { currentDirectoryPath } + + public let currentDirectoryPath: String + public let errors: [HookError] + public let hooks: [HookMetadata] + public let warnings: [String] + } + + /// Hook load or configuration error reported by the app-server. + struct HookError: Sendable, Equatable { + public let message: String + public let path: String + } + + /// Metadata for one configured hook. + struct HookMetadata: Sendable, Equatable, Identifiable { + /// Hook event that triggers this configured hook. + public enum EventName: String, Sendable, Equatable { + case permissionRequest + case postToolUse + case preToolUse + case sessionStart + case stop + case userPromptSubmit + } + + /// Handler shape used by this configured hook. + public enum HandlerType: String, Sendable, Equatable { + case agent + case command + case prompt + } + + /// Configuration source that provided this hook. + public enum Source: String, Sendable, Equatable { + case cloudRequirements + case legacyManagedConfigFile + case legacyManagedConfigMdm + case mdm + case plugin + case project + case sessionFlags + case system + case unknown + case user + } + + public var id: String { key } + + public let command: String? + public let displayOrder: Int + public let enabled: Bool + public let eventName: EventName + public let handlerType: HandlerType + public let isManaged: Bool + public let key: String + public let matcher: String? + public let pluginID: String? + public let source: Source + public let sourcePath: String + public let statusMessage: String? + public let timeoutSeconds: UInt64 + } +} + +extension CodexAppServer.HookListEntry { + init(protocolValue: CodexProtocolHooksListResponse.Entry) { + self.init( + currentDirectoryPath: protocolValue.cwd, + errors: protocolValue.errors.map(CodexAppServer.HookError.init), + hooks: protocolValue.hooks.map(CodexAppServer.HookMetadata.init), + warnings: protocolValue.warnings + ) + } +} + +extension CodexAppServer.HookError { + init(protocolValue: CodexProtocolHooksListResponse.ErrorInfo) { + self.init( + message: protocolValue.message, + path: protocolValue.path + ) + } +} + +extension CodexAppServer.HookMetadata { + init(protocolValue: CodexProtocolHooksListResponse.HookMetadata) { + self.init( + command: protocolValue.command, + displayOrder: protocolValue.displayOrder, + enabled: protocolValue.enabled, + eventName: .init(protocolValue: protocolValue.eventName), + handlerType: .init(protocolValue: protocolValue.handlerType), + isManaged: protocolValue.isManaged, + key: protocolValue.key, + matcher: protocolValue.matcher, + pluginID: protocolValue.pluginID, + source: .init(protocolValue: protocolValue.source), + sourcePath: protocolValue.sourcePath, + statusMessage: protocolValue.statusMessage, + timeoutSeconds: protocolValue.timeoutSeconds + ) + } +} + +extension CodexAppServer.HookMetadata.EventName { + init(protocolValue: CodexProtocolHooksListResponse.EventName) { + switch protocolValue { + case .permissionRequest: + self = .permissionRequest + case .postToolUse: + self = .postToolUse + case .preToolUse: + self = .preToolUse + case .sessionStart: + self = .sessionStart + case .stop: + self = .stop + case .userPromptSubmit: + self = .userPromptSubmit + } + } +} + +extension CodexAppServer.HookMetadata.HandlerType { + init(protocolValue: CodexProtocolHooksListResponse.HandlerType) { + switch protocolValue { + case .agent: + self = .agent + case .command: + self = .command + case .prompt: + self = .prompt + } + } +} + +extension CodexAppServer.HookMetadata.Source { + init(protocolValue: CodexProtocolHooksListResponse.Source) { + switch protocolValue { + case .cloudRequirements: + self = .cloudRequirements + case .legacyManagedConfigFile: + self = .legacyManagedConfigFile + case .legacyManagedConfigMdm: + self = .legacyManagedConfigMdm + case .mdm: + self = .mdm + case .plugin: + self = .plugin + case .project: + self = .project + case .sessionFlags: + self = .sessionFlags + case .system: + self = .system + case .unknown: + self = .unknown + case .user: + self = .user + } + } +} diff --git a/Sources/SwiftASB/Public/CodexAppServer.swift b/Sources/SwiftASB/Public/CodexAppServer.swift index e40ae25..ec311ee 100644 --- a/Sources/SwiftASB/Public/CodexAppServer.swift +++ b/Sources/SwiftASB/Public/CodexAppServer.swift @@ -300,6 +300,32 @@ public actor CodexAppServer { } } + /// Reads configured hooks and diagnostics for one or more working directories. + /// + /// Omitting `request` sends an empty list request, leaving the app-server to + /// use its current session working directory. + public func listHooks(_ request: HookListRequest = .init()) async throws -> HookListSnapshot { + try requireInitialized(for: "hooks/list") + + let requestID = CodexRPCRequestID.generated() + + do { + let requestPayload = try protocolLayer.makeHooksListRequest( + id: requestID, + params: .init(cwds: request.currentDirectoryPaths) + ) + let responsePayload = try await transport.send(requestPayload, id: requestID) + let response = try protocolLayer.decodeHooksListResponse( + responsePayload, + expectedID: requestID + ) + + return .init(entries: response.data.map(HookListEntry.init(protocolValue:))) + } catch { + throw CodexAppServerError.wrap(error, operation: "hooks/list") + } + } + /// Reads the app-server's current MCP server status snapshots. /// /// Omitting `request` sends an empty status-list request, leaving diff --git a/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md b/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md index b182058..13167bf 100644 --- a/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md +++ b/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md @@ -1,12 +1,12 @@ # App-Wide Capabilities -Discover model and MCP-server capability snapshots from the app-server owner. +Discover model, MCP-server, and hook diagnostics snapshots from the app-server owner. ## Overview -Some app-server operations describe the connection rather than one conversation thread. SwiftASB exposes those operations on ``CodexAppServer`` so consumers can populate settings screens, model pickers, MCP inspectors, and diagnostics without needing a thread handle. +Some app-server operations describe the connection rather than one conversation thread. SwiftASB exposes those operations on ``CodexAppServer`` so consumers can populate settings screens, model pickers, MCP inspectors, hook diagnostics, and other app-wide views without needing a thread handle. -Use ``CodexAppServer/listModels(_:)`` to read the currently visible model catalog. Use ``CodexAppServer/listMcpServerStatuses(_:)`` to inspect configured MCP servers, their auth status, and their resource, resource-template, and tool metadata. +Use ``CodexAppServer/listModels(_:)`` to read the currently visible model catalog. Use ``CodexAppServer/listMcpServerStatuses(_:)`` to inspect configured MCP servers, their auth status, and their resource, resource-template, and tool metadata. Use ``CodexAppServer/listHooks(_:)`` to inspect configured hooks, warnings, and load errors for one or more working directories before a turn runs. ```swift let models = try await appServer.listModels( @@ -16,13 +16,17 @@ let models = try await appServer.listModels( let statuses = try await appServer.listMcpServerStatuses( .init(detail: .toolsAndAuthOnly) ) + +let hooks = try await appServer.listHooks( + .init(currentDirectoryPaths: ["/absolute/path/to/workspace"]) +) ``` -Both requests are snapshots. If your UI needs refresh behavior, keep that refresh policy in the caller and ask the app-server for a new page when needed. +These requests are snapshots. If your UI needs refresh behavior, keep that refresh policy in the caller and ask the app-server for a new snapshot or page when needed. ## Pagination -Both capability APIs accept an optional cursor and return an optional next cursor. Keep requesting pages until `nextCursor` is `nil`. +Model and MCP capability APIs accept an optional cursor and return an optional next cursor. Keep requesting pages until `nextCursor` is `nil`. Hook diagnostics are returned as one snapshot grouped by working directory. ## Boundary @@ -48,3 +52,12 @@ These types are public because a consumer can use them directly today. Other gen - ``CodexAppServer/McpResource`` - ``CodexAppServer/McpResourceTemplate`` - ``CodexAppServer/McpTool`` + +### Hooks + +- ``CodexAppServer/listHooks(_:)`` +- ``CodexAppServer/HookListRequest`` +- ``CodexAppServer/HookListSnapshot`` +- ``CodexAppServer/HookListEntry`` +- ``CodexAppServer/HookMetadata`` +- ``CodexAppServer/HookError`` diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md b/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md index c85fe32..8638b3a 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md @@ -37,7 +37,7 @@ Use ``diagnosticEvents()`` to observe passive runtime diagnostics that are not c ## App-Wide Capabilities -Use ``listModels(_:)`` and ``listMcpServerStatuses(_:)`` for connection-wide snapshots. They do not belong to a single thread because they describe the app-server's current model catalog and MCP server surface. +Use ``listModels(_:)``, ``listMcpServerStatuses(_:)``, 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, and configured hook diagnostics. ## Stored Threads @@ -77,6 +77,10 @@ Set ``ThreadResumeRequest/excludeTurns`` or ``ThreadForkRequest/excludeTurns`` w - ``ModelListRequest`` - ``ModelListPage`` - ``Model`` +- ``listHooks(_:)`` +- ``HookListRequest`` +- ``HookListSnapshot`` +- ``HookListEntry`` - ``listMcpServerStatuses(_:)`` - ``McpServerStatusListRequest`` - ``McpServerStatusPage`` diff --git a/Sources/SwiftASB/SwiftASB.docc/GeneratedWireBoundary.md b/Sources/SwiftASB/SwiftASB.docc/GeneratedWireBoundary.md index b40bee3..0e679f9 100644 --- a/Sources/SwiftASB/SwiftASB.docc/GeneratedWireBoundary.md +++ b/Sources/SwiftASB/SwiftASB.docc/GeneratedWireBoundary.md @@ -25,6 +25,7 @@ Examples currently promoted through hand-owned public types include: - model catalog snapshots through ``CodexAppServer/listModels(_:)`` - MCP server status snapshots through ``CodexAppServer/listMcpServerStatuses(_:)`` +- hook diagnostics snapshots through ``CodexAppServer/listHooks(_:)`` - thread naming through ``CodexThread/setName(_:)`` - thread metadata patches through ``CodexThread/updateMetadata(gitInfo:)`` - thread rollback through ``CodexThread/rollbackLastTurns(_:)`` diff --git a/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift b/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift index 3b32810..b650aaf 100644 --- a/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift +++ b/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift @@ -377,6 +377,22 @@ struct CodexAppServerProtocolTests { #expect(params["limit"] as? Int == 10) } + @Test("encodes hooks/list requests with the expected method and params payload") + func encodesHooksListRequest() throws { + let payload = try protocolLayer.makeHooksListRequest( + id: .string("hooks-list-1"), + params: .init(cwds: ["/tmp/project", "/tmp/second-project"]) + ) + + let object = try #require(try JSONSerialization.jsonObject(with: payload) as? [String: Any]) + #expect(object["jsonrpc"] == nil) + #expect(object["method"] as? String == "hooks/list") + #expect(object["id"] as? String == "hooks-list-1") + + let params = try #require(object["params"] as? [String: Any]) + #expect(params["cwds"] as? [String] == ["/tmp/project", "/tmp/second-project"]) + } + @Test("encodes turn/start requests with the expected method and params payload") func encodesTurnStartRequest() throws { let payload = try protocolLayer.makeTurnStartRequest( diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift b/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift index eeb8d99..d05ff29 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift @@ -146,6 +146,43 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { "nextCursor": "cursor-models-next", ] ) + case "hooks/list": + return responsePayload( + id: id, + result: [ + "data": [ + [ + "cwd": "/tmp/project", + "errors": [ + [ + "message": "Hook script is not executable.", + "path": "/tmp/project/.codex/hooks/post-tool-use.sh", + ], + ], + "hooks": [ + [ + "command": "swift test", + "displayOrder": 2, + "enabled": true, + "eventName": "postToolUse", + "handlerType": "command", + "isManaged": false, + "key": "project-post-tool-use", + "matcher": "swift", + "pluginId": NSNull(), + "source": "project", + "sourcePath": "/tmp/project/.codex/hooks/post-tool-use.sh", + "statusMessage": "Ready.", + "timeoutSec": 30, + ], + ], + "warnings": [ + "Ignoring disabled user hook user-pre-tool-use.", + ], + ], + ], + ] + ) case "mcpServerStatus/list": return responsePayload( id: id, diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerTests.swift index 2fb7d4f..afe6b44 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTests.swift @@ -177,4 +177,47 @@ struct CodexAppServerTests { await client.stop() } + @Test("lists app-wide hook diagnostics through the public client") + func listsAppWideHookDiagnostics() async throws { + let transport = FakeCodexAppServerTransport() + 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 snapshot = try await client.listHooks( + .init(currentDirectoryPaths: ["/tmp/project", "/tmp/second-project"]) + ) + + #expect(snapshot.entries.count == 1) + #expect(snapshot.entries[0].currentDirectoryPath == "/tmp/project") + #expect(snapshot.entries[0].warnings == ["Ignoring disabled user hook user-pre-tool-use."]) + #expect(snapshot.entries[0].errors[0].message == "Hook script is not executable.") + #expect(snapshot.entries[0].errors[0].path == "/tmp/project/.codex/hooks/post-tool-use.sh") + #expect(snapshot.entries[0].hooks[0].key == "project-post-tool-use") + #expect(snapshot.entries[0].hooks[0].command == "swift test") + #expect(snapshot.entries[0].hooks[0].displayOrder == 2) + #expect(snapshot.entries[0].hooks[0].enabled) + #expect(snapshot.entries[0].hooks[0].eventName == .postToolUse) + #expect(snapshot.entries[0].hooks[0].handlerType == .command) + #expect(snapshot.entries[0].hooks[0].source == .project) + #expect(snapshot.entries[0].hooks[0].sourcePath == "/tmp/project/.codex/hooks/post-tool-use.sh") + #expect(snapshot.entries[0].hooks[0].timeoutSeconds == 30) + + let requestPayload = try #require(await transport.recordedRequestPayload(for: "hooks/list")) + let request = try #require(try JSONSerialization.jsonObject(with: requestPayload) as? [String: Any]) + let params = try #require(request["params"] as? [String: Any]) + #expect(params["cwds"] as? [String] == ["/tmp/project", "/tmp/second-project"]) + + await client.stop() + } + } diff --git a/docs/maintainers/interactive-lifecycle-release-boundary.md b/docs/maintainers/interactive-lifecycle-release-boundary.md index f05cc9d..43b39d8 100644 --- a/docs/maintainers/interactive-lifecycle-release-boundary.md +++ b/docs/maintainers/interactive-lifecycle-release-boundary.md @@ -117,6 +117,7 @@ belongs in the release boundary: | Thread metadata patching | `CodexThread.updateMetadata(...)` | `thread/metadata/update` is public with a hand-owned replace/clear/unchanged patch model so callers can express the upstream null-vs-omitted semantics without generated wire types. | | App-wide model listing | `CodexAppServer.listModels(...)` | `model/list` describes shared runtime capabilities rather than one conversation thread, so the public API belongs on the connection-owning app-server actor. | | App-wide MCP-server status listing | `CodexAppServer.listMcpServerStatuses(...)` | `mcpServerStatus/list` is a connection-wide server capability snapshot, so it is public on `CodexAppServer` rather than `CodexThread` or `CodexTurnHandle`. | +| App-wide hook diagnostics listing | `CodexAppServer.listHooks(...)` | `hooks/list` reports configured hooks, warnings, and load errors for working directories, so it is a read-only diagnostics/capability snapshot on the connection-owning app-server actor. | ### Observable-only for now @@ -250,9 +251,9 @@ notification families at all: - elicitation requests are public through typed server-request decoding - approval and elicitation responses are public through explicit methods on `CodexThread` and `CodexTurnHandle` -- app-wide model and MCP-server status listing is public through - `CodexAppServer` because those snapshots describe the shared app-server - connection, not one thread or one turn +- app-wide model, MCP-server status, and hook diagnostics listing is public + through `CodexAppServer` because those snapshots describe the shared + app-server connection, not one thread or one turn - turn interruption and steering are public control methods rather than event families - `thread/turns/list` is public through hand-owned history paging APIs even