From 68bcc7e0bbaf7b19135355f0f3f1451eb42a6900 Mon Sep 17 00:00:00 2001 From: Friel Date: Tue, 31 Mar 2026 21:34:09 +0000 Subject: [PATCH] Fix watchdog owner input activity --- .../schema/json/ClientRequest.json | 35 +++++++++++++++ .../codex_app_server_protocol.schemas.json | 42 ++++++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 42 ++++++++++++++++++ .../json/v2/ThreadInputActivityParams.json | 13 ++++++ .../json/v2/ThreadInputActivityResponse.json | 5 +++ .../schema/typescript/ClientRequest.ts | 3 +- .../v2/ThreadInputActivityParams.ts | 5 +++ .../v2/ThreadInputActivityResponse.ts | 5 +++ .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 4 ++ .../app-server-protocol/src/protocol/v2.rs | 44 +++++++++++++++++++ codex-rs/app-server/README.md | 13 ++++++ .../app-server/src/codex_message_processor.rs | 40 +++++++++++++++++ codex-rs/core/src/agent/control.rs | 6 ++- codex-rs/core/src/agent/control_tests.rs | 38 ++++++++++++++++ codex-rs/core/src/codex.rs | 7 +++ codex-rs/protocol/src/protocol.rs | 4 ++ codex-rs/tui/src/app.rs | 4 ++ codex-rs/tui/src/app_command.rs | 6 +++ codex-rs/tui/src/app_server_session.rs | 17 +++++++ codex-rs/tui/src/chatwidget.rs | 39 ++++++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 39 ++++++++++++++++ docs/config.md | 3 +- 23 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadInputActivityParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadInputActivityResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadInputActivityParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadInputActivityResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7c94419844..05fcac03e0 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2619,6 +2619,17 @@ ], "type": "object" }, + "ThreadInputActivityParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, "ThreadListParams": { "properties": { "archived": { @@ -3701,6 +3712,30 @@ "title": "Thread/shellCommandRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/inputActivity" + ], + "title": "Thread/inputActivityRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadInputActivityParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/inputActivityRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 084da013e4..49de32eb0a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -482,6 +482,30 @@ "title": "Thread/shellCommandRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/inputActivity" + ], + "title": "Thread/inputActivityRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadInputActivityParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/inputActivityRequest", + "type": "object" + }, { "properties": { "id": { @@ -12320,6 +12344,24 @@ "ThreadId": { "type": "string" }, + "ThreadInputActivityParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadInputActivityParams", + "type": "object" + }, + "ThreadInputActivityResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadInputActivityResponse", + "type": "object" + }, "ThreadItem": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 59dab32b19..84435c67bc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1057,6 +1057,30 @@ "title": "Thread/shellCommandRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/inputActivity" + ], + "title": "Thread/inputActivityRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadInputActivityParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/inputActivityRequest", + "type": "object" + }, { "properties": { "id": { @@ -10166,6 +10190,24 @@ "ThreadId": { "type": "string" }, + "ThreadInputActivityParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadInputActivityParams", + "type": "object" + }, + "ThreadInputActivityResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadInputActivityResponse", + "type": "object" + }, "ThreadItem": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadInputActivityParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadInputActivityParams.json new file mode 100644 index 0000000000..c103da64c5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadInputActivityParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadInputActivityParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadInputActivityResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadInputActivityResponse.json new file mode 100644 index 0000000000..8b8957a868 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadInputActivityResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadInputActivityResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e33a986359..3af7aecc81 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -45,6 +45,7 @@ import type { SkillsListParams } from "./v2/SkillsListParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; +import type { ThreadInputActivityParams } from "./v2/ThreadInputActivityParams"; import type { ThreadListParams } from "./v2/ThreadListParams"; import type { ThreadLoadedListParams } from "./v2/ThreadLoadedListParams"; import type { ThreadMetadataUpdateParams } from "./v2/ThreadMetadataUpdateParams"; @@ -64,4 +65,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": "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": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "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/inputActivity", id: RequestId, params: ThreadInputActivityParams, } | { "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": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadInputActivityParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadInputActivityParams.ts new file mode 100644 index 0000000000..cbb59eea1f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadInputActivityParams.ts @@ -0,0 +1,5 @@ +// 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. + +export type ThreadInputActivityParams = { threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadInputActivityResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadInputActivityResponse.ts new file mode 100644 index 0000000000..1e5f2908fd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadInputActivityResponse.ts @@ -0,0 +1,5 @@ +// 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. + +export type ThreadInputActivityResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d0687e5f1d..ab6319eb29 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -273,6 +273,8 @@ export type { ThreadCompactStartParams } from "./ThreadCompactStartParams"; export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; export type { ThreadForkParams } from "./ThreadForkParams"; export type { ThreadForkResponse } from "./ThreadForkResponse"; +export type { ThreadInputActivityParams } from "./ThreadInputActivityParams"; +export type { ThreadInputActivityResponse } from "./ThreadInputActivityResponse"; export type { ThreadItem } from "./ThreadItem"; export type { ThreadListParams } from "./ThreadListParams"; export type { ThreadListResponse } from "./ThreadListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 30061c716e..a99e25daf1 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -301,6 +301,10 @@ client_request_definitions! { params: v2::ThreadBackgroundTerminalsCleanParams, response: v2::ThreadBackgroundTerminalsCleanResponse, }, + ThreadInputActivity => "thread/inputActivity" { + params: v2::ThreadInputActivityParams, + response: v2::ThreadInputActivityResponse, + }, ThreadRollback => "thread/rollback" { params: v2::ThreadRollbackParams, response: v2::ThreadRollbackResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9373c852d8..68c42598fd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2992,6 +2992,18 @@ pub struct ThreadBackgroundTerminalsCleanParams { #[ts(export_to = "v2/")] pub struct ThreadBackgroundTerminalsCleanResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInputActivityParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInputActivityResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -6413,6 +6425,38 @@ mod tests { assert_eq!(decoded, response); } + #[test] + fn thread_input_activity_params_round_trip() { + let params = ThreadInputActivityParams { + thread_id: "thr_123".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize thread/inputActivity params"); + assert_eq!( + value, + json!({ + "threadId": "thr_123", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/inputActivity params"); + assert_eq!(decoded, params); + } + + #[test] + fn thread_input_activity_response_round_trip() { + let response = ThreadInputActivityResponse {}; + + let value = + serde_json::to_value(&response).expect("serialize thread/inputActivity response"); + assert_eq!(value, json!({})); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/inputActivity response"); + assert_eq!(decoded, response); + } + #[test] fn fs_changed_notification_round_trips() { let notification = FsChangedNotification { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4365cb8e29..8ba77d5a7a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -147,6 +147,7 @@ Example with notification opt-out: - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. +- `thread/inputActivity` — record owner-side draft activity for a thread without starting or steering a turn; returns `{}` and is primarily useful for watchdog idle timers while the user is still typing. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. @@ -604,6 +605,18 @@ not emit `turn/started` and does not accept turn context overrides. active turn, or the active turn kind does not accept same-turn steering (for example review or manual compaction), the request fails with an `invalid request` error. +### Example: Mark owner draft activity + +Use `thread/inputActivity` to keep watchdog idle timing honest while the user is still editing a +draft in the client. + +```json +{ "method": "thread/inputActivity", "id": 33, "params": { + "threadId": "thr_123" +} } +{ "id": 33, "result": {} } +``` + ### Example: Request a code review Use `review/start` to run Codex’s reviewer on the currently checked-out project. The request takes the thread id plus a `target` describing what should be reviewed: diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2952003c5a..70431a8867 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -124,6 +124,8 @@ use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadIncrementElicitationParams; use codex_app_server_protocol::ThreadIncrementElicitationResponse; +use codex_app_server_protocol::ThreadInputActivityParams; +use codex_app_server_protocol::ThreadInputActivityResponse; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; @@ -757,6 +759,10 @@ impl CodexMessageProcessor { ) .await; } + ClientRequest::ThreadInputActivity { request_id, params } => { + self.thread_input_activity(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ThreadRollback { request_id, params } => { self.thread_rollback(to_connection_request_id(request_id), params) .await; @@ -3235,6 +3241,40 @@ impl CodexMessageProcessor { } } + async fn thread_input_activity( + &self, + request_id: ConnectionRequestId, + params: ThreadInputActivityParams, + ) { + let ThreadInputActivityParams { thread_id } = params; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .submit_core_op(&request_id, thread.as_ref(), Op::NoteOwnerActivity) + .await + { + Ok(_) => { + self.outgoing + .send_response(request_id, ThreadInputActivityResponse {}) + .await; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to record thread input activity: {err}"), + ) + .await; + } + } + } + async fn thread_shell_command( &self, request_id: ConnectionRequestId, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 23b81ea154..e830ea00a7 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -591,13 +591,17 @@ impl AgentControl { ) .await; if result.is_ok() { - self.watchdogs.note_owner_input(agent_id).await; + self.note_owner_input(agent_id).await; self.state .update_last_task_message(agent_id, last_task_message); } result } + pub(crate) async fn note_owner_input(&self, agent_id: ThreadId) { + self.watchdogs.note_owner_input(agent_id).await; + } + /// Append a prebuilt message to an existing agent thread outside the normal user-input path. #[cfg(test)] pub(crate) async fn append_message( diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 3c423872e2..1d961ffb0f 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -459,6 +459,44 @@ async fn send_input_resets_watchdog_owner_idle_state() { ); } +#[tokio::test] +async fn note_owner_input_resets_watchdog_owner_idle_state() { + let harness = AgentControlHarness::new().await; + let (owner_thread_id, _thread) = harness.start_thread().await; + let target_thread_id = ThreadId::new(); + + harness + .control + .register_watchdog(WatchdogRegistration { + owner_thread_id, + target_thread_id, + child_depth: 0, + interval_s: 30, + prompt: String::new(), + config: harness.config.clone(), + }) + .await + .expect("watchdog registration should succeed"); + + assert_eq!( + harness + .control + .watchdog_owner_idle_since_is_none_for_tests(target_thread_id) + .await, + Some(false) + ); + + harness.control.note_owner_input(owner_thread_id).await; + + assert_eq!( + harness + .control + .watchdog_owner_idle_since_is_none_for_tests(target_thread_id) + .await, + Some(true) + ); +} + #[test] fn build_agent_inbox_items_emits_function_call_and_output() { let sender_thread_id = ThreadId::new(); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a80a47d5bb..9039294f54 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4585,6 +4585,13 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::interrupt(&sess).await; false } + Op::NoteOwnerActivity => { + sess.services + .agent_control + .note_owner_input(sess.conversation_id) + .await; + false + } Op::CleanBackgroundTerminals => { handlers::clean_background_terminals(&sess).await; false diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e2904a5399..8264490c28 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -235,6 +235,9 @@ pub enum Op { /// This server sends [`EventMsg::TurnAborted`] in response. Interrupt, + /// Mark owner-side input activity without starting or steering a turn. + NoteOwnerActivity, + /// Terminate all running background terminal processes for this thread. /// Use this when callers intentionally want to stop long-lived background shells. CleanBackgroundTerminals, @@ -595,6 +598,7 @@ impl Op { pub fn kind(&self) -> &'static str { match self { Self::Interrupt => "interrupt", + Self::NoteOwnerActivity => "note_owner_activity", Self::CleanBackgroundTerminals => "clean_background_terminals", Self::RealtimeConversationStart(_) => "realtime_conversation_start", Self::RealtimeConversationAudio(_) => "realtime_conversation_audio", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3357cf97e8..26df172630 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2907,6 +2907,10 @@ impl App { app_server.turn_interrupt(thread_id, turn_id).await?; Ok(true) } + AppCommandView::NoteOwnerActivity => { + app_server.thread_input_activity(thread_id).await?; + Ok(true) + } AppCommandView::UserTurn { items, cwd, diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index e01a250274..6573831699 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -30,6 +30,7 @@ pub(crate) struct AppCommand(Op); #[allow(dead_code)] pub(crate) enum AppCommandView<'a> { Interrupt, + NoteOwnerActivity, CleanBackgroundTerminals, RealtimeConversationStart(&'a ConversationStartParams), RealtimeConversationAudio(&'a ConversationAudioParams), @@ -113,6 +114,10 @@ impl AppCommand { Self(Op::Interrupt) } + pub(crate) fn note_owner_activity() -> Self { + Self(Op::NoteOwnerActivity) + } + pub(crate) fn clean_background_terminals() -> Self { Self(Op::CleanBackgroundTerminals) } @@ -289,6 +294,7 @@ impl AppCommand { pub(crate) fn view(&self) -> AppCommandView<'_> { match &self.0 { Op::Interrupt => AppCommandView::Interrupt, + Op::NoteOwnerActivity => AppCommandView::NoteOwnerActivity, Op::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals, Op::RealtimeConversationStart(params) => { AppCommandView::RealtimeConversationStart(params) diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index eb6c176076..70eeee0123 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -30,6 +30,8 @@ use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadInputActivityParams; +use codex_app_server_protocol::ThreadInputActivityResponse; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; @@ -454,6 +456,21 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn thread_input_activity(&mut self, thread_id: ThreadId) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadInputActivityResponse = self + .client + .request_typed(ClientRequest::ThreadInputActivity { + request_id, + params: ThreadInputActivityParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/inputActivity failed in TUI")?; + Ok(()) + } + pub(crate) async fn turn_steer( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 048ffe2580..447be837eb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -248,6 +248,7 @@ const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan"; const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; +const WATCHDOG_OWNER_ACTIVITY_SIGNAL_INTERVAL: Duration = Duration::from_secs(1); const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?"; const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable"; @@ -916,6 +917,8 @@ pub(crate) struct ChatWidget { /// We require the second press to match this key so `Ctrl+C` followed by /// `Ctrl+D` (or vice versa) doesn't quit accidentally. quit_shortcut_key: Option, + // Last time we sent a lightweight owner-activity signal for the running thread. + last_watchdog_owner_activity_signal_at: Option, // Simple review mode flag; used to adjust layout and banners. is_review_mode: bool, // Snapshot of token usage to restore after review mode exits. @@ -2322,6 +2325,7 @@ impl ChatWidget { fn on_task_started(&mut self) { self.agent_turn_running = true; + self.last_watchdog_owner_activity_signal_at = None; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ true); self.saw_plan_update_this_turn = false; @@ -2392,6 +2396,7 @@ impl ChatWidget { // Mark task stopped and request redraw now that all content is in history. self.pending_status_indicator_restore = false; self.agent_turn_running = false; + self.last_watchdog_owner_activity_signal_at = None; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ false); self.update_task_running_state(); @@ -4858,6 +4863,7 @@ impl ChatWidget { pending_notification: None, quit_shortcut_expires_at: None, quit_shortcut_key: None, + last_watchdog_owner_activity_signal_at: None, is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, @@ -4921,6 +4927,7 @@ impl ChatWidget { } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + let composer_before = self.bottom_pane.composer_text_with_pending(); match key_event { KeyEvent { code: KeyCode::Char(c), @@ -5090,6 +5097,7 @@ impl ChatWidget { InputResult::None => {} }, } + self.maybe_signal_watchdog_owner_activity_if_draft_changed(&composer_before); } /// Attach a local image to the composer when the active model supports image inputs. @@ -5114,7 +5122,9 @@ impl ChatWidget { } pub(crate) fn apply_external_edit(&mut self, text: String) { + let composer_before = self.bottom_pane.composer_text_with_pending(); self.bottom_pane.apply_external_edit(text); + self.maybe_signal_watchdog_owner_activity_if_draft_changed(&composer_before); self.request_redraw(); } @@ -5663,7 +5673,36 @@ impl ChatWidget { } pub(crate) fn handle_paste(&mut self, text: String) { + let composer_before = self.bottom_pane.composer_text_with_pending(); self.bottom_pane.handle_paste(text); + self.maybe_signal_watchdog_owner_activity_if_draft_changed(&composer_before); + } + + fn maybe_signal_watchdog_owner_activity_if_draft_changed(&mut self, composer_before: &str) { + if self.bottom_pane.composer_text_with_pending() == composer_before { + return; + } + self.maybe_signal_watchdog_owner_activity(); + } + + fn maybe_signal_watchdog_owner_activity(&mut self) { + if !self.agent_turn_running { + return; + } + let Some(thread_id) = self.thread_id else { + return; + }; + let now = Instant::now(); + if let Some(last_signal_at) = self.last_watchdog_owner_activity_signal_at + && now.duration_since(last_signal_at) < WATCHDOG_OWNER_ACTIVITY_SIGNAL_INTERVAL + { + return; + } + self.last_watchdog_owner_activity_signal_at = Some(now); + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id, + op: AppCommand::note_owner_activity().into(), + }); } // Returns true if caller should skip rendering this frame (a future frame is scheduled). diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a94325ff9b..00d356a6c1 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2273,6 +2273,7 @@ async fn make_chatwidget_manual( pending_notification: None, quit_shortcut_expires_at: None, quit_shortcut_key: None, + last_watchdog_owner_activity_signal_at: None, is_review_mode: false, pre_review_token_info: None, needs_final_message_separator: false, @@ -2333,6 +2334,21 @@ fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { } } +fn next_owner_activity_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { + loop { + match op_rx.try_recv() { + Ok(Op::NoteOwnerActivity) => return, + Ok(_) => continue, + Err(TryRecvError::Empty) => { + panic!("expected owner activity op but queue was empty") + } + Err(TryRecvError::Disconnected) => { + panic!("expected owner activity op but channel closed") + } + } + } +} + fn next_realtime_close_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { loop { match op_rx.try_recv() { @@ -2388,6 +2404,29 @@ async fn worked_elapsed_from_resets_when_timer_restarts() { assert_eq!(chat.worked_elapsed_from(/*current_elapsed*/ 7), 4); } +#[tokio::test] +async fn typing_during_running_turn_emits_owner_activity_op() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.agent_turn_running = true; + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + + next_owner_activity_op(&mut op_rx); +} + +#[tokio::test] +async fn non_mutating_keypress_during_running_turn_does_not_emit_owner_activity_op() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.agent_turn_running = true; + + chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + + assert_no_submit_op(&mut op_rx); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + pub(crate) async fn make_chatwidget_manual_with_sender() -> ( ChatWidget, AppEventSender, diff --git a/docs/config.md b/docs/config.md index 1b6a367fcf..37f5cfd30a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -58,9 +58,10 @@ Messages sent to subagents continue to arrive as normal user input. Watchdog agents use the top-level `watchdog_interval_s` setting to decide how long the owner thread must be idle before a check-in helper is spawned when the `agent_watchdog` feature is enabled. +Owner-side typing/input activity resets the idle timer. ```toml -watchdog_interval_s = 60 +watchdog_interval_s = 10 ``` ## JSON Schema