Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ All notable changes to this project will be documented in this file. This change

## [Unreleased]

### Added (v0.2.1 sync)
### Added (upstream PR #917)
- **Session filesystem provider** — new `:session-fs` option in `create-client` enables applications to virtualize per-session file I/O (event log, large output, etc.) through custom callbacks instead of the server's default local filesystem. Provide a `SessionFsConfig` map with `:initial-cwd`, `:session-state-path`, and `:conventions` (`"windows"` or `"posix"`). On connect, the client sends a `sessionFs.setProvider` RPC to register itself as the provider.
- New `:create-session-fs-handler` option in `create-session` / `resume-session` config — required when `:session-fs` is set on the client. A factory `(fn [session] -> handler-map)` called once per session, returning a map implementing the file operations: `:read-file`, `:write-file`, `:append-file`, `:exists`, `:stat`, `:mkdir`, `:readdir`, `:readdir-with-types`, `:rm`, `:rename`. Handler functions receive normalized (kebab-case) params maps and may return values directly or via core.async channels.
- New specs: `::session-fs-config`, `::session-fs`, `::create-session-fs-handler`.

### Changed (upstream PR #917)
- **`session.task_complete` event data** now includes optional `:aborted?` boolean field — `true` when the preceding agentic loop was cancelled via abort signal. New `::aborted` spec added; `::session.task_complete-data` updated to include it.
- **MCP server connection status** enum extended with `"needs-auth"` value for `mcp.server_status_changed` and `mcp.server_connected` events (in addition to existing `"connected"`, `"failed"`, `"pending"`, `"disabled"`, `"not_configured"`).

- **`steerable` field on `session.start` events** — `session.start` event data now includes optional `:steerable?` boolean field indicating whether the session supports remote steering via Mission Control. New `::steerable?` spec added (upstream PR #927).
- **`get-session-metadata`** — new function on client for efficient O(1) session lookup by ID. Returns session metadata map if found, or `nil` if not found. Sends `session.getMetadata` JSON-RPC call. Shared `wire->session-metadata` helper extracted from `list-sessions` to eliminate duplication (upstream PR #899).

Expand Down
2 changes: 2 additions & 0 deletions doc/reference/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ Get information about the current shared client state. Returns `nil` if no share
| `:use-logged-in-user?` | boolean | `true` | Use logged-in user auth. Defaults to `false` when `:github-token` is provided. Cannot be used with `:cli-url` |
| `:on-list-models` | fn | nil | Zero-arg function returning model info maps. Bypasses `models.list` RPC; does not require `start!`. Results are cached the same way as RPC results |
| `:is-child-process?` | boolean | `false` | When `true`, connect via own stdio to a parent Copilot CLI process (no process spawning). Requires `:use-stdio?` `true`; mutually exclusive with `:cli-url` |
| `:session-fs` | map | nil | Custom session filesystem provider config (upstream PR #917). Map with `:initial-cwd` (string), `:session-state-path` (string), `:conventions` (`"windows"` or `"posix"`). When set, registers the client as the session filesystem provider on connect; each session config must include `:create-session-fs-handler` |

### Methods

Expand Down Expand Up @@ -254,6 +255,7 @@ Create a client and session together, ensuring both are cleaned up on exit.
| `:hooks` | map | Lifecycle hooks (see below) |
| `:agent` | string | Name of a custom agent to activate at session start. Must match a name in `:custom-agents`. Equivalent to calling `agent.select` after creation. |
| `:on-event` | fn | Event handler (1-arg fn receiving event maps). Registered before the RPC call, guaranteeing early events like `session.start` are not missed. |
| `:create-session-fs-handler` | fn | `(fn [session] -> handler-map)` Factory for the session filesystem handler. Required when `:session-fs` is configured in client options (upstream PR #917). The returned map implements file operations: `:read-file`, `:write-file`, `:append-file`, `:exists`, `:stat`, `:mkdir`, `:readdir`, `:readdir-with-types`, `:rm`, `:rename`. |

#### `resume-session`

Expand Down
160 changes: 108 additions & 52 deletions src/github/copilot_sdk/client.clj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
[clojure.java.io :as io]
[clojure.string :as str]
[clojure.data.json :as json]
[camel-snake-kebab.core :as csk]
[github.copilot-sdk.protocol :as proto]
[github.copilot-sdk.process :as proc]
[github.copilot-sdk.specs :as specs]
Expand Down Expand Up @@ -137,7 +138,11 @@
- :is-child-process? - When true, SDK is a child of an existing Copilot CLI process and uses stdio to communicate with it (no process spawning)
- :on-list-models - Zero-arg fn returning a seq of model info maps; bypasses the RPC call and does not require start!
- :telemetry - OpenTelemetry config map with optional keys :otlp-endpoint, :file-path, :exporter-type, :source-name, :capture-content?
- :on-get-trace-context - Zero-arg fn returning {:traceparent ... :tracestate ...} for distributed trace propagation"
- :on-get-trace-context - Zero-arg fn returning {:traceparent ... :tracestate ...} for distributed trace propagation
- :session-fs - Custom session filesystem provider config map (upstream PR #917):
{:initial-cwd \"...\" :session-state-path \"...\" :conventions \"windows\"|\"posix\"}
When provided, registers the client as the session filesystem provider on connect.
Each session config must include :create-session-fs-handler when this option is set."
([]
(client {}))
([opts]
Expand Down Expand Up @@ -531,62 +536,90 @@
(when (seq lines)
(str/join "\n" lines)))))

(defn- handle-session-fs-request!
"Handle a sessionFs.* RPC request from the server.
Dispatches to the appropriate function in the session's :session-fs-handler map.
The handler map keys are kebab-case keywords matching the camelCase operation name,
e.g. :read-file for sessionFs.readFile.
Handler functions receive the normalized (kebab-case) params map and may return a
value directly or a channel. Returns a channel delivering {:result ...} or {:error ...}."
[client method params]
(go
(let [{:keys [session-id]} params
handler (get-in @(:state client) [:sessions session-id :session-fs-handler])]
(if-not handler
{:error {:code -32001 :message (str "No sessionFs handler registered for session: " session-id)}}
(let [op (subs method (count "sessionFs."))
op-kw (csk/->kebab-case-keyword op)
f (get handler op-kw)]
(if-not f
{:error {:code -32601 :message (str "SessionFs handler does not implement: " op)}}
(try
(let [result-or-ch (f params)
result (if (instance? clojure.core.async.impl.channels.ManyToManyChannel result-or-ch)
(<! result-or-ch)
result-or-ch)]
{:result result})
(catch Exception e
{:error {:code -32603 :message (ex-message e)}}))))))))
(defn- setup-request-handler!
"Set up handler for incoming requests (tool calls, permission requests, hooks, user input)."
[client]
(let [{:keys [connection-io]} @(:state client)]
(proto/set-request-handler! connection-io
(fn [method params]
(go
(case method
"tool.call"
(let [{:keys [session-id tool-call-id tool-name arguments]} params]
(if-not (get-in @(:state client) [:sessions session-id])
{:error {:code -32001 :message (str "Unknown session: " session-id)}}
{:result (<! (session/handle-tool-call! client session-id tool-call-id tool-name arguments))}))

"permission.request"
(let [{:keys [session-id permission-request]} params]
(if-not (get-in @(:state client) [:sessions session-id])
{:result {:kind :denied-no-approval-rule-and-could-not-request-from-user}}
(let [perm-response (<! (session/handle-permission-request! client session-id permission-request))
result (:result perm-response)]
(if (= :no-result result)
;; no-result must propagate as an error on v2 protocol
;; so the CLI knows no answer was given (matches upstream -32603)
{:error {:code -32603
:message "Permission handler returned no-result on protocol v2"}}
(do
(log/debug "Permission response for session " session-id ": " result)
{:result perm-response})))))

;; User input request (PR #269)
"userInput.request"
(let [{:keys [session-id question choices allow-freeform]} params]
(log/debug "User input request for session " session-id ": " question)
(if-not (get-in @(:state client) [:sessions session-id])
{:error {:code -32001 :message (str "Unknown session: " session-id)}}
(<! (session/handle-user-input-request! client session-id
{:question question
:choices choices
:allow-freeform allow-freeform}))))

;; Hooks invocation (PR #269)
"hooks.invoke"
(let [{:keys [session-id hook-type input]} params]
(if-not (get-in @(:state client) [:sessions session-id])
{:result nil}
(<! (session/handle-hooks-invoke! client session-id hook-type input))))

;; System message transform (PR #816)
"systemMessage.transform"
(let [{:keys [session-id sections]} params]
(if-not (get-in @(:state client) [:sessions session-id])
{:error {:code -32001 :message (str "Unknown session: " session-id)}}
{:result (session/handle-system-message-transform
client session-id sections)}))

{:error {:code -32601 :message (str "Unknown method: " method)}}))))))
(if (str/starts-with? method "sessionFs.")
(<! (handle-session-fs-request! client method params))
(case method
"tool.call"
(let [{:keys [session-id tool-call-id tool-name arguments]} params]
(if-not (get-in @(:state client) [:sessions session-id])
{:error {:code -32001 :message (str "Unknown session: " session-id)}}
{:result (<! (session/handle-tool-call! client session-id tool-call-id tool-name arguments))}))

"permission.request"
(let [{:keys [session-id permission-request]} params]
(if-not (get-in @(:state client) [:sessions session-id])
{:result {:kind :denied-no-approval-rule-and-could-not-request-from-user}}
(let [perm-response (<! (session/handle-permission-request! client session-id permission-request))
result (:result perm-response)]
(if (= :no-result result)
;; no-result must propagate as an error on v2 protocol
;; so the CLI knows no answer was given (matches upstream -32603)
{:error {:code -32603
:message "Permission handler returned no-result on protocol v2"}}
(do
(log/debug "Permission response for session " session-id ": " result)
{:result perm-response})))))

;; User input request (PR #269)
"userInput.request"
(let [{:keys [session-id question choices allow-freeform]} params]
(log/debug "User input request for session " session-id ": " question)
(if-not (get-in @(:state client) [:sessions session-id])
{:error {:code -32001 :message (str "Unknown session: " session-id)}}
(<! (session/handle-user-input-request! client session-id
{:question question
:choices choices
:allow-freeform allow-freeform}))))

;; Hooks invocation (PR #269)
"hooks.invoke"
(let [{:keys [session-id hook-type input]} params]
(if-not (get-in @(:state client) [:sessions session-id])
{:result nil}
(<! (session/handle-hooks-invoke! client session-id hook-type input))))

;; System message transform (PR #816)
"systemMessage.transform"
(let [{:keys [session-id sections]} params]
(if-not (get-in @(:state client) [:sessions session-id])
{:error {:code -32001 :message (str "Unknown session: " session-id)}}
{:result (session/handle-system-message-transform
client session-id sections)}))

{:error {:code -32601 :message (str "Unknown method: " method)}}))))))))

(defn- connect-stdio!
"Connect via stdio to the CLI process."
Expand Down Expand Up @@ -791,6 +824,13 @@
;; Verify protocol version
(verify-protocol-version! client)

;; If a session filesystem provider is configured, register it now
(when-let [session-fs (get-in client [:options :session-fs])]
(log/debug "Registering sessionFs provider")
(let [{:keys [connection-io]} @(:state client)]
(proto/send-request! connection-io "sessionFs.setProvider"
(util/clj->wire session-fs))))

;; Set up notification routing and request handling
(start-notification-router! client)
(setup-request-handler! client)
Expand Down Expand Up @@ -1327,14 +1367,23 @@
This ensures early events (e.g. session.start) are not dropped.
Returns the CopilotSession handle."
[client session-id config]
(session/create-session client session-id
(let [session (session/create-session client session-id
{:tools (:tools config)
:commands (:commands config)
:on-permission-request (:on-permission-request config)
:on-user-input-request (:on-user-input-request config)
:hooks (:hooks config)
:on-event (:on-event config)
:config config}))
:config config})]
;; If sessionFs is configured in client options, set up per-session handler
(when (get-in client [:options :session-fs])
(if-let [factory (:create-session-fs-handler config)]
(session/set-session-fs-handler! client session-id (factory session))
(throw (ex-info
(str "A :create-session-fs-handler is required in session config "
"when :session-fs is configured in client options.")
{:config config}))))
session))

(defn create-session
"Create a new conversation session.
Expand Down Expand Up @@ -1370,6 +1419,11 @@
:on-session-start, :on-session-end, :on-error-occurred}
- :on-event - Event handler (1-arg fn) registered before the RPC call.
Guarantees early events like session.start are not missed.
- :create-session-fs-handler - (fn [session] -> handler-map) Factory for the sessionFs handler.
Required when `:session-fs` is configured in client options.
The handler map must implement keys for each file operation:
:read-file, :write-file, :append-file, :exists, :stat, :mkdir,
:readdir, :readdir-with-types, :rm, :rename (upstream PR #917).

Returns a CopilotSession."
[client config]
Expand Down Expand Up @@ -1419,6 +1473,8 @@
- :hooks - Lifecycle hooks map
- :on-event - Event handler (1-arg fn) registered before the RPC call.
Guarantees early events like session.start are not missed.
- :create-session-fs-handler - (fn [session] -> handler-map) Factory for the sessionFs handler.
Required when `:session-fs` is configured in client options (upstream PR #917).

Returns a CopilotSession."
[client session-id config]
Expand Down
6 changes: 6 additions & 0 deletions src/github/copilot_sdk/session.clj
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
:destroyed? false
:workspace-path workspace-path
:capabilities {}
:session-fs-handler nil
:config config})
(assoc-in [:session-io session-id]
{:event-chan event-chan
Expand Down Expand Up @@ -111,6 +112,11 @@
[client session-id capabilities]
(swap! (:state client) assoc-in [:sessions session-id :capabilities] (or capabilities {})))

(defn set-session-fs-handler!
"Store the sessionFs handler in session state. Called by client during session create/resume."
[client session-id handler]
(swap! (:state client) assoc-in [:sessions session-id :session-fs-handler] handler))

(defn register-transform-callbacks!
"Store system message transform callbacks on a session.
Callbacks is a map of wire section ID strings to 1-arity functions
Expand Down
Loading