app-server: add filesystem watch support (#14533)

### Summary
Add the v2 app-server filesystem watch RPCs and notifications, wire them
through the message processor, and implement connection-scoped watches
with notify-backed change delivery. This also updates the schema
fixtures, app-server documentation, and the v2 integration coverage for
watch and unwatch behavior.

This allows clients to efficiently watch for filesystem updates, e.g. to
react on branch changes.

### Testing
- exercise watch lifecycles for directory changes, atomic file
replacement, missing-file targets, and unwatch cleanup
This commit is contained in:
Ruslan Nigmatullin
2026-03-24 15:52:13 -07:00
committed by GitHub
Unverified
parent 062fa7a2bb
commit 301b17c2a1
29 changed files with 1396 additions and 5 deletions
@@ -781,6 +781,36 @@
],
"type": "object"
},
"FsUnwatchParams": {
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
"properties": {
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"watchId"
],
"type": "object"
},
"FsWatchParams": {
"description": "Start filesystem watch notifications for an absolute path.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Absolute file or directory path to watch."
}
},
"required": [
"path"
],
"type": "object"
},
"FsWriteFileParams": {
"description": "Write a file on the host filesystem.",
"properties": {
@@ -4000,6 +4030,54 @@
"title": "Fs/copyRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/watch"
],
"title": "Fs/watchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsWatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/watchRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/unwatch"
],
"title": "Fs/unwatchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsUnwatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/unwatchRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AccountLoginCompletedNotification": {
"properties": {
"error": {
@@ -998,6 +1002,27 @@
],
"type": "object"
},
"FsChangedNotification": {
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
"properties": {
"changedPaths": {
"description": "File or directory paths associated with this event.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"changedPaths",
"watchId"
],
"type": "object"
},
"FuzzyFileSearchMatchType": {
"enum": [
"file",
@@ -4394,6 +4419,26 @@
"title": "App/list/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fs/changed"
],
"title": "Fs/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Fs/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -883,6 +883,54 @@
"title": "Fs/copyRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"fs/watch"
],
"title": "Fs/watchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/FsWatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/watchRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"fs/unwatch"
],
"title": "Fs/unwatchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/FsUnwatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/unwatchRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4053,6 +4101,26 @@
"title": "App/list/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fs/changed"
],
"title": "Fs/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/FsChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Fs/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -7444,6 +7512,29 @@
],
"type": "string"
},
"FsChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
"properties": {
"changedPaths": {
"description": "File or directory paths associated with this event.",
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"changedPaths",
"watchId"
],
"title": "FsChangedNotification",
"type": "object"
},
"FsCopyParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Copy a file or directory tree on the host filesystem.",
@@ -7698,6 +7789,70 @@
"title": "FsRemoveResponse",
"type": "object"
},
"FsUnwatchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
"properties": {
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"watchId"
],
"title": "FsUnwatchParams",
"type": "object"
},
"FsUnwatchResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Successful response for `fs/unwatch`.",
"title": "FsUnwatchResponse",
"type": "object"
},
"FsWatchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Start filesystem watch notifications for an absolute path.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
}
],
"description": "Absolute file or directory path to watch."
}
},
"required": [
"path"
],
"title": "FsWatchParams",
"type": "object"
},
"FsWatchResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Created watch handle returned by `fs/watch`.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
}
],
"description": "Canonicalized path associated with the watch."
},
"watchId": {
"description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.",
"type": "string"
}
},
"required": [
"path",
"watchId"
],
"title": "FsWatchResponse",
"type": "object"
},
"FsWriteFileParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write a file on the host filesystem.",
@@ -1417,6 +1417,54 @@
"title": "Fs/copyRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/watch"
],
"title": "Fs/watchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsWatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/watchRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/unwatch"
],
"title": "Fs/unwatchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsUnwatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/unwatchRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4037,6 +4085,29 @@
],
"type": "string"
},
"FsChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
"properties": {
"changedPaths": {
"description": "File or directory paths associated with this event.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"changedPaths",
"watchId"
],
"title": "FsChangedNotification",
"type": "object"
},
"FsCopyParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Copy a file or directory tree on the host filesystem.",
@@ -4291,6 +4362,70 @@
"title": "FsRemoveResponse",
"type": "object"
},
"FsUnwatchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
"properties": {
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"watchId"
],
"title": "FsUnwatchParams",
"type": "object"
},
"FsUnwatchResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Successful response for `fs/unwatch`.",
"title": "FsUnwatchResponse",
"type": "object"
},
"FsWatchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Start filesystem watch notifications for an absolute path.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Absolute file or directory path to watch."
}
},
"required": [
"path"
],
"title": "FsWatchParams",
"type": "object"
},
"FsWatchResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Created watch handle returned by `fs/watch`.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Canonicalized path associated with the watch."
},
"watchId": {
"description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.",
"type": "string"
}
},
"required": [
"path",
"watchId"
],
"title": "FsWatchResponse",
"type": "object"
},
"FsWriteFileParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write a file on the host filesystem.",
@@ -8540,6 +8675,26 @@
"title": "App/list/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fs/changed"
],
"title": "Fs/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Fs/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -0,0 +1,29 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
"properties": {
"changedPaths": {
"description": "File or directory paths associated with this event.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"changedPaths",
"watchId"
],
"title": "FsChangedNotification",
"type": "object"
}
@@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
"properties": {
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"watchId"
],
"title": "FsUnwatchParams",
"type": "object"
}
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Successful response for `fs/unwatch`.",
"title": "FsUnwatchResponse",
"type": "object"
}
@@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"description": "Start filesystem watch notifications for an absolute path.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Absolute file or directory path to watch."
}
},
"required": [
"path"
],
"title": "FsWatchParams",
"type": "object"
}
@@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"description": "Created watch handle returned by `fs/watch`.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Canonicalized path associated with the watch."
},
"watchId": {
"description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.",
"type": "string"
}
},
"required": [
"path",
"watchId"
],
"title": "FsWatchResponse",
"type": "object"
}
@@ -26,6 +26,8 @@ import type { FsGetMetadataParams } from "./v2/FsGetMetadataParams";
import type { FsReadDirectoryParams } from "./v2/FsReadDirectoryParams";
import type { FsReadFileParams } from "./v2/FsReadFileParams";
import type { FsRemoveParams } from "./v2/FsRemoveParams";
import type { FsUnwatchParams } from "./v2/FsUnwatchParams";
import type { FsWatchParams } from "./v2/FsWatchParams";
import type { FsWriteFileParams } from "./v2/FsWriteFileParams";
import type { GetAccountParams } from "./v2/GetAccountParams";
import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams";
@@ -61,4 +63,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta
/**
* Request from the client to the server.
*/
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
@@ -15,6 +15,7 @@ import type { ContextCompactedNotification } from "./v2/ContextCompactedNotifica
import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification";
import type { ErrorNotification } from "./v2/ErrorNotification";
import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification";
import type { FsChangedNotification } from "./v2/FsChangedNotification";
import type { HookCompletedNotification } from "./v2/HookCompletedNotification";
import type { HookStartedNotification } from "./v2/HookStartedNotification";
import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification";
@@ -56,4 +57,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
@@ -0,0 +1,17 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
/**
* Filesystem watch notification emitted for `fs/watch` subscribers.
*/
export type FsChangedNotification = {
/**
* Watch identifier returned by `fs/watch`.
*/
watchId: string,
/**
* File or directory paths associated with this event.
*/
changedPaths: Array<AbsolutePathBuf>, };
@@ -0,0 +1,12 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Stop filesystem watch notifications for a prior `fs/watch`.
*/
export type FsUnwatchParams = {
/**
* Watch identifier returned by `fs/watch`.
*/
watchId: string, };
@@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Successful response for `fs/unwatch`.
*/
export type FsUnwatchResponse = Record<string, never>;
@@ -0,0 +1,13 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
/**
* Start filesystem watch notifications for an absolute path.
*/
export type FsWatchParams = {
/**
* Absolute file or directory path to watch.
*/
path: AbsolutePathBuf, };
@@ -0,0 +1,17 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
/**
* Created watch handle returned by `fs/watch`.
*/
export type FsWatchResponse = {
/**
* Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.
*/
watchId: string,
/**
* Canonicalized path associated with the watch.
*/
path: AbsolutePathBuf, };
@@ -97,6 +97,7 @@ export type { FileChangeOutputDeltaNotification } from "./FileChangeOutputDeltaN
export type { FileChangeRequestApprovalParams } from "./FileChangeRequestApprovalParams";
export type { FileChangeRequestApprovalResponse } from "./FileChangeRequestApprovalResponse";
export type { FileUpdateChange } from "./FileUpdateChange";
export type { FsChangedNotification } from "./FsChangedNotification";
export type { FsCopyParams } from "./FsCopyParams";
export type { FsCopyResponse } from "./FsCopyResponse";
export type { FsCreateDirectoryParams } from "./FsCreateDirectoryParams";
@@ -110,6 +111,10 @@ export type { FsReadFileParams } from "./FsReadFileParams";
export type { FsReadFileResponse } from "./FsReadFileResponse";
export type { FsRemoveParams } from "./FsRemoveParams";
export type { FsRemoveResponse } from "./FsRemoveResponse";
export type { FsUnwatchParams } from "./FsUnwatchParams";
export type { FsUnwatchResponse } from "./FsUnwatchResponse";
export type { FsWatchParams } from "./FsWatchParams";
export type { FsWatchResponse } from "./FsWatchResponse";
export type { FsWriteFileParams } from "./FsWriteFileParams";
export type { FsWriteFileResponse } from "./FsWriteFileResponse";
export type { GetAccountParams } from "./GetAccountParams";
@@ -326,6 +326,14 @@ client_request_definitions! {
params: v2::FsCopyParams,
response: v2::FsCopyResponse,
},
FsWatch => "fs/watch" {
params: v2::FsWatchParams,
response: v2::FsWatchResponse,
},
FsUnwatch => "fs/unwatch" {
params: v2::FsUnwatchParams,
response: v2::FsUnwatchResponse,
},
SkillsConfigWrite => "skills/config/write" {
params: v2::SkillsConfigWriteParams,
response: v2::SkillsConfigWriteResponse,
@@ -899,6 +907,7 @@ server_notification_definitions! {
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
FsChanged => "fs/changed" (v2::FsChangedNotification),
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification),
ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification),
@@ -1478,6 +1487,27 @@ mod tests {
Ok(())
}
#[test]
fn serialize_fs_watch() -> Result<()> {
let request = ClientRequest::FsWatch {
request_id: RequestId::Integer(10),
params: v2::FsWatchParams {
path: absolute_path("tmp/repo/.git"),
},
};
assert_eq!(
json!({
"method": "fs/watch",
"id": 10,
"params": {
"path": absolute_path_string("tmp/repo/.git")
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_list_experimental_features() -> Result<()> {
let request = ClientRequest::ExperimentalFeatureList {
@@ -2301,6 +2301,52 @@ pub struct FsCopyParams {
#[ts(export_to = "v2/")]
pub struct FsCopyResponse {}
/// Start filesystem watch notifications for an absolute path.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsWatchParams {
/// Absolute file or directory path to watch.
pub path: AbsolutePathBuf,
}
/// Created watch handle returned by `fs/watch`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsWatchResponse {
/// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.
pub watch_id: String,
/// Canonicalized path associated with the watch.
pub path: AbsolutePathBuf,
}
/// Stop filesystem watch notifications for a prior `fs/watch`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsUnwatchParams {
/// Watch identifier returned by `fs/watch`.
pub watch_id: String,
}
/// Successful response for `fs/unwatch`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsUnwatchResponse {}
/// Filesystem watch notification emitted for `fs/watch` subscribers.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsChangedNotification {
/// Watch identifier returned by `fs/watch`.
pub watch_id: String,
/// File or directory paths associated with this event.
pub changed_paths: Vec<AbsolutePathBuf>,
}
/// PTY size in character cells for `command/exec` PTY sessions.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -6497,6 +6543,33 @@ mod tests {
assert_eq!(decoded, response);
}
#[test]
fn fs_changed_notification_round_trips() {
let notification = FsChangedNotification {
watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(),
changed_paths: vec![
absolute_path("tmp/repo/.git/HEAD"),
absolute_path("tmp/repo/.git/FETCH_HEAD"),
],
};
let value = serde_json::to_value(&notification).expect("serialize fs/changed notification");
assert_eq!(
value,
json!({
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
"changedPaths": [
absolute_path_string("tmp/repo/.git/HEAD"),
absolute_path_string("tmp/repo/.git/FETCH_HEAD"),
],
})
);
let decoded = serde_json::from_value::<FsChangedNotification>(value)
.expect("deserialize fs/changed notification");
assert_eq!(decoded, notification);
}
#[test]
fn command_exec_params_default_optional_streaming_flags() {
let params = serde_json::from_value::<CommandExecParams>(json!({
+25
View File
@@ -159,6 +159,9 @@ Example with notification opt-out:
- `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path.
- `fs/remove` — remove an absolute file or directory tree; `recursive` and `force` default to `true`.
- `fs/copy` — copy between absolute paths; directory copies require `recursive: true`.
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path; returns a `watchId` and canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
@@ -795,6 +798,28 @@ All filesystem paths in this section must be absolute.
- `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`.
- `fs/copy` handles both file copies and directory-tree copies; it requires `recursive: true` when `sourcePath` is a directory. Recursive copies traverse regular files, directories, and symlinks; other entry types are skipped.
### Example: Filesystem watch
`fs/watch` accepts absolute file or directory paths. Watching a file emits `fs/changed` for that file path, including updates delivered via replace or rename operations.
```json
{ "method": "fs/watch", "id": 44, "params": {
"path": "/Users/me/project/.git/HEAD"
} }
{ "id": 44, "result": {
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
"path": "/Users/me/project/.git/HEAD"
} }
{ "method": "fs/changed", "params": {
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
"changedPaths": ["/Users/me/project/.git/HEAD"]
} }
{ "method": "fs/unwatch", "id": 45, "params": {
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1"
} }
{ "id": 45, "result": {} }
```
## Events
Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `thread/archived`, `thread/unarchived`, `thread/closed`, `turn/*`, and `item/*` notifications.
@@ -893,7 +893,9 @@ impl CodexMessageProcessor {
| ClientRequest::FsGetMetadata { .. }
| ClientRequest::FsReadDirectory { .. }
| ClientRequest::FsRemove { .. }
| ClientRequest::FsCopy { .. } => {
| ClientRequest::FsCopy { .. }
| ClientRequest::FsWatch { .. }
| ClientRequest::FsUnwatch { .. } => {
warn!("Filesystem request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::ConfigRequirementsRead { .. } => {
+2 -2
View File
@@ -159,7 +159,7 @@ impl FsApi {
}
}
fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
pub(crate) fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
@@ -167,7 +167,7 @@ fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
}
}
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string())
} else {
+379
View File
@@ -0,0 +1,379 @@
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingMessageSender;
use codex_app_server_protocol::FsChangedNotification;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsUnwatchResponse;
use codex_app_server_protocol::FsWatchParams;
use codex_app_server_protocol::FsWatchResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_core::file_watcher::FileWatcher;
use codex_core::file_watcher::FileWatcherEvent;
use codex_core::file_watcher::FileWatcherSubscriber;
use codex_core::file_watcher::Receiver;
use codex_core::file_watcher::WatchPath;
use codex_core::file_watcher::WatchRegistration;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::collections::HashSet;
use std::hash::Hash;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
#[cfg(test)]
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::Instant;
use tracing::warn;
use uuid::Uuid;
const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200);
struct DebouncedReceiver {
rx: Receiver,
interval: Duration,
changed_paths: HashSet<PathBuf>,
next_allowance: Option<Instant>,
}
impl DebouncedReceiver {
fn new(rx: Receiver, interval: Duration) -> Self {
Self {
rx,
interval,
changed_paths: HashSet::new(),
next_allowance: None,
}
}
async fn recv(&mut self) -> Option<FileWatcherEvent> {
while self.changed_paths.is_empty() {
self.changed_paths.extend(self.rx.recv().await?.paths);
}
let next_allowance = *self
.next_allowance
.get_or_insert_with(|| Instant::now() + self.interval);
loop {
tokio::select! {
event = self.rx.recv() => self.changed_paths.extend(event?.paths),
_ = tokio::time::sleep_until(next_allowance) => break,
}
}
Some(FileWatcherEvent {
paths: self.changed_paths.drain().collect(),
})
}
}
#[derive(Clone)]
pub(crate) struct FsWatchManager {
outgoing: Arc<OutgoingMessageSender>,
file_watcher: Arc<FileWatcher>,
state: Arc<AsyncMutex<FsWatchState>>,
}
#[derive(Default)]
struct FsWatchState {
entries: HashMap<WatchKey, WatchEntry>,
}
struct WatchEntry {
terminate_tx: oneshot::Sender<oneshot::Sender<()>>,
_subscriber: FileWatcherSubscriber,
_registration: WatchRegistration,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct WatchKey {
connection_id: ConnectionId,
watch_id: String,
}
impl FsWatchManager {
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>) -> Self {
let file_watcher = match FileWatcher::new() {
Ok(file_watcher) => Arc::new(file_watcher),
Err(err) => {
warn!("filesystem watch manager falling back to noop core watcher: {err}");
Arc::new(FileWatcher::noop())
}
};
Self::new_with_file_watcher(outgoing, file_watcher)
}
fn new_with_file_watcher(
outgoing: Arc<OutgoingMessageSender>,
file_watcher: Arc<FileWatcher>,
) -> Self {
Self {
outgoing,
file_watcher,
state: Arc::new(AsyncMutex::new(FsWatchState::default())),
}
}
pub(crate) async fn watch(
&self,
connection_id: ConnectionId,
params: FsWatchParams,
) -> Result<FsWatchResponse, JSONRPCErrorError> {
let watch_id = Uuid::now_v7().to_string();
let outgoing = self.outgoing.clone();
let (subscriber, rx) = self.file_watcher.add_subscriber();
let watch_root = params.path.to_path_buf().clone();
let registration = subscriber.register_paths(vec![WatchPath {
path: params.path.to_path_buf(),
recursive: false,
}]);
let (terminate_tx, terminate_rx) = oneshot::channel();
self.state.lock().await.entries.insert(
WatchKey {
connection_id,
watch_id: watch_id.clone(),
},
WatchEntry {
terminate_tx,
_subscriber: subscriber,
_registration: registration,
},
);
let task_watch_id = watch_id.clone();
tokio::spawn(async move {
let mut rx = DebouncedReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE);
tokio::pin!(terminate_rx);
loop {
let event = tokio::select! {
biased;
_ = &mut terminate_rx => break,
event = rx.recv() => match event {
Some(event) => event,
None => break,
},
};
let mut changed_paths = event
.paths
.into_iter()
.filter_map(|path| {
match AbsolutePathBuf::resolve_path_against_base(&path, &watch_root) {
Ok(path) => Some(path),
Err(err) => {
warn!(
"failed to normalize watch event path ({}) for {}: {err}",
path.display(),
watch_root.display()
);
None
}
}
})
.collect::<Vec<_>>();
changed_paths.sort_by(|left, right| left.as_path().cmp(right.as_path()));
if !changed_paths.is_empty() {
outgoing
.send_server_notification_to_connection_and_wait(
connection_id,
ServerNotification::FsChanged(FsChangedNotification {
watch_id: task_watch_id.clone(),
changed_paths,
}),
)
.await;
}
}
});
Ok(FsWatchResponse {
watch_id,
path: params.path,
})
}
pub(crate) async fn unwatch(
&self,
connection_id: ConnectionId,
params: FsUnwatchParams,
) -> Result<FsUnwatchResponse, JSONRPCErrorError> {
let watch_key = WatchKey {
connection_id,
watch_id: params.watch_id,
};
let entry = self.state.lock().await.entries.remove(&watch_key);
if let Some(entry) = entry {
// Wait for the oneshot to be destroyed by the task to ensure that no notifications
// are send after the unwatch response.
let (done_tx, done_rx) = oneshot::channel();
let _ = entry.terminate_tx.send(done_tx);
let _ = done_rx.await;
}
Ok(FsUnwatchResponse {})
}
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
let mut state = self.state.lock().await;
state
.entries
.extract_if(|key, _| key.connection_id == connection_id)
.count();
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use uuid::Version;
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
assert!(
path.is_absolute(),
"path must be absolute: {}",
path.display()
);
AbsolutePathBuf::try_from(path).expect("path should be absolute")
}
fn manager_with_noop_watcher() -> FsWatchManager {
const OUTGOING_BUFFER: usize = 1;
let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER);
FsWatchManager::new_with_file_watcher(
Arc::new(OutgoingMessageSender::new(tx)),
Arc::new(FileWatcher::noop()),
)
}
#[tokio::test]
async fn watch_returns_a_v7_id_and_tracks_the_owner_scoped_entry() {
let temp_dir = TempDir::new().expect("temp dir");
let head_path = temp_dir.path().join("HEAD");
std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD");
let manager = manager_with_noop_watcher();
let path = absolute_path(head_path);
let response = manager
.watch(ConnectionId(1), FsWatchParams { path: path.clone() })
.await
.expect("watch should succeed");
assert_eq!(response.path, path);
let watch_id = Uuid::parse_str(&response.watch_id).expect("watch id should be a UUID");
assert_eq!(watch_id.get_version(), Some(Version::SortRand));
let state = manager.state.lock().await;
assert_eq!(
state.entries.keys().cloned().collect::<HashSet<_>>(),
HashSet::from([WatchKey {
connection_id: ConnectionId(1),
watch_id: response.watch_id,
}])
);
}
#[tokio::test]
async fn unwatch_is_scoped_to_the_connection_that_created_the_watch() {
let temp_dir = TempDir::new().expect("temp dir");
let head_path = temp_dir.path().join("HEAD");
std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD");
let manager = manager_with_noop_watcher();
let response = manager
.watch(
ConnectionId(1),
FsWatchParams {
path: absolute_path(head_path),
},
)
.await
.expect("watch should succeed");
let watch_key = WatchKey {
connection_id: ConnectionId(1),
watch_id: response.watch_id.clone(),
};
manager
.unwatch(
ConnectionId(2),
FsUnwatchParams {
watch_id: response.watch_id.clone(),
},
)
.await
.expect("foreign unwatch should be a no-op");
assert!(manager.state.lock().await.entries.contains_key(&watch_key));
manager
.unwatch(
ConnectionId(1),
FsUnwatchParams {
watch_id: response.watch_id,
},
)
.await
.expect("owner unwatch should succeed");
assert!(!manager.state.lock().await.entries.contains_key(&watch_key));
}
#[tokio::test]
async fn connection_closed_removes_only_that_connections_watches() {
let temp_dir = TempDir::new().expect("temp dir");
let head_path = temp_dir.path().join("HEAD");
let fetch_head_path = temp_dir.path().join("FETCH_HEAD");
let packed_refs_path = temp_dir.path().join("packed-refs");
std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD");
std::fs::write(&fetch_head_path, "old-fetch\n").expect("write FETCH_HEAD");
std::fs::write(&packed_refs_path, "refs\n").expect("write packed-refs");
let manager = manager_with_noop_watcher();
let response_1 = manager
.watch(
ConnectionId(1),
FsWatchParams {
path: absolute_path(head_path),
},
)
.await
.expect("first watch should succeed");
let response_2 = manager
.watch(
ConnectionId(1),
FsWatchParams {
path: absolute_path(fetch_head_path),
},
)
.await
.expect("second watch should succeed");
let response_3 = manager
.watch(
ConnectionId(2),
FsWatchParams {
path: absolute_path(packed_refs_path),
},
)
.await
.expect("third watch should succeed");
manager.connection_closed(ConnectionId(1)).await;
assert_eq!(
manager
.state
.lock()
.await
.entries
.keys()
.cloned()
.collect::<HashSet<_>>(),
HashSet::from([WatchKey {
connection_id: ConnectionId(2),
watch_id: response_3.watch_id,
}])
);
assert_ne!(response_1.watch_id, response_2.watch_id);
}
}
+1
View File
@@ -67,6 +67,7 @@ mod error_code;
mod external_agent_config_api;
mod filters;
mod fs_api;
mod fs_watch;
mod fuzzy_file_search;
pub mod in_process;
mod message_processor;
@@ -11,6 +11,7 @@ use crate::config_api::ConfigApi;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::fs_api::FsApi;
use crate::fs_watch::FsWatchManager;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
@@ -36,6 +37,8 @@ use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsWatchParams;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
@@ -152,6 +155,7 @@ pub(crate) struct MessageProcessor {
external_agent_config_api: ExternalAgentConfigApi,
fs_api: FsApi,
auth_manager: Arc<AuthManager>,
fs_watch_manager: FsWatchManager,
config: Arc<Config>,
config_warnings: Arc<Vec<ConfigWarningNotification>>,
}
@@ -248,6 +252,7 @@ impl MessageProcessor {
);
let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone());
let fs_api = FsApi::default();
let fs_watch_manager = FsWatchManager::new(outgoing.clone());
Self {
outgoing,
@@ -256,6 +261,7 @@ impl MessageProcessor {
external_agent_config_api,
fs_api,
auth_manager,
fs_watch_manager,
config,
config_warnings: Arc::new(config_warnings),
}
@@ -467,6 +473,7 @@ impl MessageProcessor {
pub(crate) async fn connection_closed(&mut self, connection_id: ConnectionId) {
self.outgoing.connection_closed(connection_id).await;
self.fs_watch_manager.connection_closed(connection_id).await;
self.codex_message_processor
.connection_closed(connection_id)
.await;
@@ -759,6 +766,28 @@ impl MessageProcessor {
)
.await;
}
ClientRequest::FsWatch { request_id, params } => {
self.handle_fs_watch(
ConnectionRequestId {
connection_id,
request_id,
},
connection_id,
params,
)
.await;
}
ClientRequest::FsUnwatch { request_id, params } => {
self.handle_fs_unwatch(
ConnectionRequestId {
connection_id,
request_id,
},
connection_id,
params,
)
.await;
}
other => {
// Box the delegated future so this wrapper's async state machine does not
// inline the full `CodexMessageProcessor::process_request` future, which
@@ -910,6 +939,30 @@ impl MessageProcessor {
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_watch(
&self,
request_id: ConnectionRequestId,
connection_id: ConnectionId,
params: FsWatchParams,
) {
match self.fs_watch_manager.watch(connection_id, params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_unwatch(
&self,
request_id: ConnectionRequestId,
connection_id: ConnectionId,
params: FsUnwatchParams,
) {
match self.fs_watch_manager.unwatch(connection_id, params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
}
#[cfg(test)]
@@ -31,6 +31,8 @@ use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsWatchParams;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAuthStatusParams;
@@ -790,6 +792,19 @@ impl McpProcess {
self.send_request("fs/copy", params).await
}
pub async fn send_fs_watch_request(&mut self, params: FsWatchParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/watch", params).await
}
pub async fn send_fs_unwatch_request(
&mut self,
params: FsUnwatchParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/unwatch", params).await
}
/// Send an `account/logout` JSON-RPC request.
pub async fn send_logout_account_request(&mut self) -> anyhow::Result<i64> {
self.send_request("account/logout", /*params*/ None).await
+198
View File
@@ -4,11 +4,15 @@ use app_test_support::McpProcess;
use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::FsChangedNotification;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryEntry;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsWatchResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::RequestId;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -17,6 +21,8 @@ use std::path::PathBuf;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
use uuid::Version;
#[cfg(unix)]
use std::os::unix::fs::symlink;
@@ -611,3 +617,195 @@ async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications()
-> Result<()> {
let codex_home = TempDir::new()?;
let git_dir = codex_home.path().join("repo").join(".git");
let fetch_head = git_dir.join("FETCH_HEAD");
std::fs::create_dir_all(&git_dir)?;
std::fs::write(&fetch_head, "old\n")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let watch_request_id = mcp
.send_fs_watch_request(codex_app_server_protocol::FsWatchParams {
path: absolute_path(git_dir.clone()),
})
.await?;
let watch_response: FsWatchResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),
)
.await??,
)?;
assert_eq!(watch_response.path, absolute_path(git_dir.clone()));
let watch_id = Uuid::parse_str(&watch_response.watch_id)?;
assert_eq!(watch_id.get_version(), Some(Version::SortRand));
std::fs::write(&fetch_head, "updated\n")?;
// Kernel file watching is not reliable in every sandboxed test environment.
// Keep validating notification shape when the backend does emit, but do not
// fail the whole suite if no OS event arrives.
if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {
assert_eq!(changed.watch_id, watch_response.watch_id.clone());
assert_eq!(
changed.changed_paths,
vec![absolute_path(fetch_head.clone())]
);
}
while timeout(
Duration::from_millis(200),
mcp.read_stream_until_notification_message("fs/changed"),
)
.await
.is_ok()
{}
let unwatch_request_id = mcp
.send_fs_unwatch_request(FsUnwatchParams {
watch_id: watch_response.watch_id,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(unwatch_request_id)),
)
.await??;
std::fs::write(git_dir.join("packed-refs"), "refs\n")?;
let maybe_notification = timeout(
Duration::from_millis(1500),
mcp.read_stream_until_notification_message("fs/changed"),
)
.await;
assert!(
maybe_notification.is_err(),
"fs/unwatch should stop future change notifications"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> {
let codex_home = TempDir::new()?;
let git_dir = codex_home.path().join("repo").join(".git");
let head_path = git_dir.join("HEAD");
std::fs::create_dir_all(&git_dir)?;
std::fs::write(&head_path, "ref: refs/heads/main\n")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let watch_request_id = mcp
.send_fs_watch_request(codex_app_server_protocol::FsWatchParams {
path: absolute_path(head_path.clone()),
})
.await?;
let watch_response: FsWatchResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),
)
.await??,
)?;
assert_eq!(watch_response.path, absolute_path(head_path.clone()));
replace_file_atomically(&head_path, "ref: refs/heads/feature\n")?;
if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {
assert_eq!(
changed,
FsChangedNotification {
watch_id: watch_response.watch_id,
changed_paths: vec![absolute_path(head_path.clone())],
}
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_watch_allows_missing_file_targets() -> Result<()> {
let codex_home = TempDir::new()?;
let git_dir = codex_home.path().join("repo").join(".git");
let fetch_head = git_dir.join("FETCH_HEAD");
std::fs::create_dir_all(&git_dir)?;
let mut mcp = initialized_mcp(&codex_home).await?;
let watch_request_id = mcp
.send_fs_watch_request(codex_app_server_protocol::FsWatchParams {
path: absolute_path(fetch_head.clone()),
})
.await?;
let watch_response: FsWatchResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),
)
.await??,
)?;
assert_eq!(watch_response.path, absolute_path(fetch_head.clone()));
replace_file_atomically(&fetch_head, "origin/main\n")?;
if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {
assert_eq!(
changed,
FsChangedNotification {
watch_id: watch_response.watch_id,
changed_paths: vec![absolute_path(fetch_head.clone())],
}
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_watch_rejects_relative_paths() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = initialized_mcp(&codex_home).await?;
let watch_id = mcp
.send_raw_request("fs/watch", Some(json!({ "path": "relative-path" })))
.await?;
expect_error_message(
&mut mcp,
watch_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
Ok(())
}
fn fs_changed_notification(notification: JSONRPCNotification) -> Result<FsChangedNotification> {
let params = notification
.params
.context("fs/changed notification should include params")?;
Ok(serde_json::from_value::<FsChangedNotification>(params)?)
}
async fn maybe_fs_changed_notification(
mcp: &mut McpProcess,
) -> Result<Option<FsChangedNotification>> {
match timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("fs/changed"),
)
.await
{
Ok(notification) => Ok(Some(fs_changed_notification(notification?)?)),
Err(_) => Ok(None),
}
}
fn replace_file_atomically(path: &PathBuf, contents: &str) -> Result<()> {
let temp_path = path.with_extension("lock");
std::fs::write(&temp_path, contents)?;
std::fs::rename(temp_path, path)?;
Ok(())
}
@@ -388,6 +388,7 @@ fn server_notification_thread_target(
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::FsChanged(_)
| ServerNotification::WindowsWorldWritableWarning(_)
| ServerNotification::WindowsSandboxSetupCompleted(_)
| ServerNotification::AccountLoginCompleted(_) => None,
@@ -6169,6 +6169,7 @@ impl ChatWidget {
| ServerNotification::McpServerStatusUpdated(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AppListUpdated(_)
| ServerNotification::FsChanged(_)
| ServerNotification::ContextCompacted(_)
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
| ServerNotification::FuzzyFileSearchSessionCompleted(_)