Add thread/shellCommand to app server API surface (#14988)

This PR adds a new `thread/shellCommand` app server API so clients can
implement `!` shell commands. These commands are executed within the
sandbox, and the command text and output are visible to the model.

The internal implementation mirrors the current TUI `!` behavior.
- persist shell command execution as `CommandExecution` thread items,
including source and formatted output metadata
- bridge live and replayed app-server command execution events back into
the existing `tui_app_server` exec rendering path

This PR also wires `tui_app_server` to submit `!` commands through the
new API.
This commit is contained in:
Eric Traut 2026-03-18 23:42:40 -06:00 committed by GitHub
parent 10eb3ec7fc
commit 01df50cf42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2580 additions and 86 deletions

View file

@ -2881,6 +2881,22 @@
],
"type": "object"
},
"ThreadShellCommandParams": {
"properties": {
"command": {
"description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.",
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"command",
"threadId"
],
"type": "object"
},
"ThreadSortKey": {
"enum": [
"created_at",
@ -3586,6 +3602,30 @@
"title": "Thread/compact/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/shellCommand"
],
"title": "Thread/shellCommandRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadShellCommandParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {

View file

@ -745,6 +745,15 @@
],
"type": "object"
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -2390,6 +2399,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -499,6 +499,30 @@
"title": "Thread/compact/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/shellCommand"
],
"title": "Thread/shellCommandRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadShellCommandParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
@ -6121,6 +6145,15 @@
"title": "CommandExecutionOutputDeltaNotification",
"type": "object"
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -12063,6 +12096,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/v2/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/v2/CommandExecutionStatus"
},
@ -13102,6 +13143,29 @@
"title": "ThreadSetNameResponse",
"type": "object"
},
"ThreadShellCommandParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"command": {
"description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.",
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"command",
"threadId"
],
"title": "ThreadShellCommandParams",
"type": "object"
},
"ThreadShellCommandResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadShellCommandResponse",
"type": "object"
},
"ThreadSortKey": {
"enum": [
"created_at",

View file

@ -1026,6 +1026,30 @@
"title": "Thread/compact/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/shellCommand"
],
"title": "Thread/shellCommandRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadShellCommandParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
@ -2754,6 +2778,15 @@
"title": "CommandExecutionOutputDeltaNotification",
"type": "object"
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -9823,6 +9856,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},
@ -10862,6 +10903,29 @@
"title": "ThreadSetNameResponse",
"type": "object"
},
"ThreadShellCommandParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"command": {
"description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.",
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"command",
"threadId"
],
"title": "ThreadShellCommandParams",
"type": "object"
},
"ThreadShellCommandResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadShellCommandResponse",
"type": "object"
},
"ThreadSortKey": {
"enum": [
"created_at",

View file

@ -177,6 +177,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -642,6 +651,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -177,6 +177,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -642,6 +651,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -756,6 +765,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -353,6 +353,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -1236,6 +1245,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -994,6 +1003,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -994,6 +1003,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -994,6 +1003,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -353,6 +353,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -1236,6 +1245,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -994,6 +1003,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"command": {
"description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.",
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"command",
"threadId"
],
"title": "ThreadShellCommandParams",
"type": "object"
}

View file

@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadShellCommandResponse",
"type": "object"
}

View file

@ -353,6 +353,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -1236,6 +1245,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -994,6 +1003,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -994,6 +1003,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -756,6 +765,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -756,6 +765,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -291,6 +291,15 @@
}
]
},
"CommandExecutionSource": {
"enum": [
"agent",
"userShell",
"unifiedExecStartup",
"unifiedExecInteraction"
],
"type": "string"
},
"CommandExecutionStatus": {
"enum": [
"inProgress",
@ -756,6 +765,14 @@
"null"
]
},
"source": {
"allOf": [
{
"$ref": "#/definitions/CommandExecutionSource"
}
],
"default": "agent"
},
"status": {
"$ref": "#/definitions/CommandExecutionStatus"
},

View file

@ -49,6 +49,7 @@ import type { ThreadReadParams } from "./v2/ThreadReadParams";
import type { ThreadResumeParams } from "./v2/ThreadResumeParams";
import type { ThreadRollbackParams } from "./v2/ThreadRollbackParams";
import type { ThreadSetNameParams } from "./v2/ThreadSetNameParams";
import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams";
import type { ThreadStartParams } from "./v2/ThreadStartParams";
import type { ThreadUnarchiveParams } from "./v2/ThreadUnarchiveParams";
import type { ThreadUnsubscribeParams } from "./v2/ThreadUnsubscribeParams";
@ -60,4 +61,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/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": "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, };

View file

@ -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 CommandExecutionSource = "agent" | "userShell" | "unifiedExecStartup" | "unifiedExecInteraction";

View file

@ -8,6 +8,7 @@ import type { CollabAgentState } from "./CollabAgentState";
import type { CollabAgentTool } from "./CollabAgentTool";
import type { CollabAgentToolCallStatus } from "./CollabAgentToolCallStatus";
import type { CommandAction } from "./CommandAction";
import type { CommandExecutionSource } from "./CommandExecutionSource";
import type { CommandExecutionStatus } from "./CommandExecutionStatus";
import type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem";
import type { DynamicToolCallStatus } from "./DynamicToolCallStatus";
@ -32,7 +33,7 @@ cwd: string,
/**
* Identifier for the underlying PTY process (when available).
*/
processId: string | null, status: CommandExecutionStatus,
processId: string | null, source: CommandExecutionSource, status: CommandExecutionStatus,
/**
* A best-effort parsing of the command to understand the action(s) it will perform.
* This returns a list of CommandAction objects because a single shell command may

View file

@ -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.
export type ThreadShellCommandParams = { threadId: string,
/**
* Shell command string evaluated by the thread's configured shell.
* Unlike `command/exec`, this intentionally preserves shell syntax
* such as pipes, redirects, and quoting. This runs unsandboxed with full
* access rather than inheriting the thread sandbox policy.
*/
command: string, };

View file

@ -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 ThreadShellCommandResponse = Record<string, never>;

View file

@ -55,6 +55,7 @@ export type { CommandExecutionOutputDeltaNotification } from "./CommandExecution
export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams";
export type { CommandExecutionRequestApprovalResponse } from "./CommandExecutionRequestApprovalResponse";
export type { CommandExecutionRequestApprovalSkillMetadata } from "./CommandExecutionRequestApprovalSkillMetadata";
export type { CommandExecutionSource } from "./CommandExecutionSource";
export type { CommandExecutionStatus } from "./CommandExecutionStatus";
export type { Config } from "./Config";
export type { ConfigBatchWriteParams } from "./ConfigBatchWriteParams";
@ -283,6 +284,8 @@ export type { ThreadRollbackParams } from "./ThreadRollbackParams";
export type { ThreadRollbackResponse } from "./ThreadRollbackResponse";
export type { ThreadSetNameParams } from "./ThreadSetNameParams";
export type { ThreadSetNameResponse } from "./ThreadSetNameResponse";
export type { ThreadShellCommandParams } from "./ThreadShellCommandParams";
export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse";
export type { ThreadSortKey } from "./ThreadSortKey";
export type { ThreadSourceKind } from "./ThreadSourceKind";
export type { ThreadStartParams } from "./ThreadStartParams";

View file

@ -267,6 +267,10 @@ client_request_definitions! {
params: v2::ThreadCompactStartParams,
response: v2::ThreadCompactStartResponse,
},
ThreadShellCommand => "thread/shellCommand" {
params: v2::ThreadShellCommandParams,
response: v2::ThreadShellCommandResponse,
},
#[experimental("thread/backgroundTerminals/clean")]
ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" {
params: v2::ThreadBackgroundTerminalsCleanParams,

View file

@ -341,6 +341,7 @@ impl ThreadHistoryBuilder {
command,
cwd: payload.cwd.clone(),
process_id: payload.process_id.clone(),
source: payload.source.into(),
status: CommandExecutionStatus::InProgress,
command_actions,
aggregated_output: None,
@ -371,6 +372,7 @@ impl ThreadHistoryBuilder {
command,
cwd: payload.cwd.clone(),
process_id: payload.process_id.clone(),
source: payload.source.into(),
status,
command_actions,
aggregated_output,
@ -1144,6 +1146,7 @@ impl From<&PendingTurn> for Turn {
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::v2::CommandExecutionSource;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::items::TurnItem as CoreTurnItem;
@ -1745,6 +1748,7 @@ mod tests {
command: "echo 'hello world'".into(),
cwd: PathBuf::from("/tmp"),
process_id: Some("pid-1".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
command_actions: vec![CommandAction::Unknown {
command: "echo hello world".into(),
@ -1893,6 +1897,7 @@ mod tests {
command: "ls".into(),
cwd: PathBuf::from("/tmp"),
process_id: Some("pid-2".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Declined,
command_actions: vec![CommandAction::Unknown {
command: "ls".into(),
@ -1987,6 +1992,7 @@ mod tests {
command: "echo done".into(),
cwd: PathBuf::from("/tmp"),
process_id: Some("pid-42".into()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
command_actions: vec![CommandAction::Unknown {
command: "echo done".into(),

View file

@ -52,6 +52,7 @@ use codex_protocol::protocol::AgentStatus as CoreAgentStatus;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource;
use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus;
use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig;
use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel;
@ -92,6 +93,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use serde_with::serde_as;
use thiserror::Error;
use ts_rs::TS;
@ -2871,6 +2873,23 @@ pub struct ThreadCompactStartParams {
#[ts(export_to = "v2/")]
pub struct ThreadCompactStartResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadShellCommandParams {
pub thread_id: String,
/// Shell command string evaluated by the thread's configured shell.
/// Unlike `command/exec`, this intentionally preserves shell syntax
/// such as pipes, redirects, and quoting. This runs unsandboxed with full
/// access rather than inheriting the thread sandbox policy.
pub command: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadShellCommandResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@ -4137,6 +4156,8 @@ pub enum ThreadItem {
cwd: PathBuf,
/// Identifier for the underlying PTY process (when available).
process_id: Option<String>,
#[serde(default)]
source: CommandExecutionSource,
status: CommandExecutionStatus,
/// A best-effort parsing of the command to understand the action(s) it will perform.
/// This returns a list of CommandAction objects because a single shell command may
@ -4417,6 +4438,17 @@ impl From<&CoreExecCommandStatus> for CommandExecutionStatus {
}
}
v2_enum_from_core! {
#[derive(Default)]
pub enum CommandExecutionSource from CoreExecCommandSource {
#[default]
Agent,
UserShell,
UnifiedExecStartup,
UnifiedExecInteraction,
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@ -4863,6 +4895,7 @@ pub struct TerminalInteractionNotification {
pub stdin: String,
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@ -6313,6 +6346,40 @@ mod tests {
assert_eq!(decoded, params);
}
#[test]
fn thread_shell_command_params_round_trip() {
let params = ThreadShellCommandParams {
thread_id: "thr_123".to_string(),
command: "printf 'hello world\\n'".to_string(),
};
let value = serde_json::to_value(&params).expect("serialize thread/shellCommand params");
assert_eq!(
value,
json!({
"threadId": "thr_123",
"command": "printf 'hello world\\n'",
})
);
let decoded = serde_json::from_value::<ThreadShellCommandParams>(value)
.expect("deserialize thread/shellCommand params");
assert_eq!(decoded, params);
}
#[test]
fn thread_shell_command_response_round_trip() {
let response = ThreadShellCommandResponse {};
let value =
serde_json::to_value(&response).expect("serialize thread/shellCommand response");
assert_eq!(value, json!({}));
let decoded = serde_json::from_value::<ThreadShellCommandResponse>(value)
.expect("deserialize thread/shellCommand response");
assert_eq!(decoded, response);
}
#[test]
fn command_exec_params_default_optional_streaming_flags() {
let params = serde_json::from_value::<CommandExecParams>(json!({
@ -6607,6 +6674,32 @@ mod tests {
assert_eq!(decoded, notification);
}
#[test]
fn command_execution_output_delta_round_trips() {
let notification = CommandExecutionOutputDeltaNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
delta: "\u{fffd}a\n".to_string(),
};
let value = serde_json::to_value(&notification)
.expect("serialize item/commandExecution/outputDelta notification");
assert_eq!(
value,
json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "item-1",
"delta": "\u{fffd}a\n",
})
);
let decoded = serde_json::from_value::<CommandExecutionOutputDeltaNotification>(value)
.expect("deserialize round-trip");
assert_eq!(decoded, notification);
}
#[test]
fn sandbox_policy_round_trips_external_sandbox_network_access() {
let v2_policy = SandboxPolicy::ExternalSandbox {

View file

@ -136,6 +136,7 @@ Example with notification opt-out:
- `thread/name/set` — set or update a threads user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`.
- `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/rollback` — drop the last N turns from the agents 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".
@ -415,6 +416,31 @@ While compaction is running, the thread is effectively in a turn so clients shou
{ "id": 25, "result": {} }
```
### Example: Run a thread shell command
Use `thread/shellCommand` for the TUI `!` workflow. The request returns immediately with `{}`.
This API runs unsandboxed with full access; it does not inherit the thread
sandbox policy.
If the thread already has an active turn, the command runs as an auxiliary action on that turn. In that case, progress is emitted as standard `item/*` notifications on the existing turn and the formatted output is injected into the turns message stream:
- `item/started` with `item: { "type": "commandExecution", "source": "userShell", ... }`
- zero or more `item/commandExecution/outputDelta`
- `item/completed` with the same `commandExecution` item id
If the thread does not already have an active turn, the server starts a standalone turn for the shell command. In that case clients should expect:
- `turn/started`
- `item/started` with `item: { "type": "commandExecution", "source": "userShell", ... }`
- zero or more `item/commandExecution/outputDelta`
- `item/completed` with the same `commandExecution` item id
- `turn/completed`
```json
{ "method": "thread/shellCommand", "id": 26, "params": { "threadId": "thr_b", "command": "git status --short" } }
{ "id": 26, "result": {} }
```
### Example: Start a turn (send user input)
Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions:

View file

@ -27,6 +27,7 @@ use codex_app_server_protocol::CommandExecutionOutputDeltaNotification;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionRequestApprovalSkillMetadata;
use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::ContextCompactedNotification;
use codex_app_server_protocol::DeprecationNoticeNotification;
@ -1563,6 +1564,7 @@ pub(crate) async fn apply_bespoke_event_handling(
command,
cwd,
process_id,
source: exec_command_begin_event.source.into(),
status: CommandExecutionStatus::InProgress,
command_actions,
aggregated_output: None,
@ -1580,7 +1582,6 @@ pub(crate) async fn apply_bespoke_event_handling(
}
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
let item_id = exec_command_output_delta_event.call_id.clone();
let delta = String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string();
// The underlying EventMsg::ExecCommandOutputDelta is used for shell, unified_exec,
// and apply_patch tool calls. We represent apply_patch with the FileChange item, and
// everything else with the CommandExecution item.
@ -1592,6 +1593,8 @@ pub(crate) async fn apply_bespoke_event_handling(
state.turn_summary.file_change_started.contains(&item_id)
};
if is_file_change {
let delta =
String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string();
let notification = FileChangeOutputDeltaNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
@ -1608,7 +1611,8 @@ pub(crate) async fn apply_bespoke_event_handling(
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
item_id,
delta,
delta: String::from_utf8_lossy(&exec_command_output_delta_event.chunk)
.to_string(),
};
outgoing
.send_server_notification(ServerNotification::CommandExecutionOutputDelta(
@ -1641,6 +1645,7 @@ pub(crate) async fn apply_bespoke_event_handling(
aggregated_output,
exit_code,
duration,
source,
status,
..
} = exec_command_end_event;
@ -1672,6 +1677,7 @@ pub(crate) async fn apply_bespoke_event_handling(
command: shlex_join(&command),
cwd,
process_id,
source: source.into(),
status,
command_actions,
aggregated_output,
@ -1935,6 +1941,7 @@ async fn complete_command_execution_item(
command: String,
cwd: PathBuf,
process_id: Option<String>,
source: CommandExecutionSource,
command_actions: Vec<V2ParsedCommand>,
status: CommandExecutionStatus,
outgoing: &ThreadScopedOutgoingMessageSender,
@ -1944,6 +1951,7 @@ async fn complete_command_execution_item(
command,
cwd,
process_id,
source,
status,
command_actions,
aggregated_output: None,
@ -2607,6 +2615,7 @@ async fn on_command_execution_request_approval_response(
completion_item.command,
completion_item.cwd,
/*process_id*/ None,
CommandExecutionSource::Agent,
completion_item.command_actions,
status,
&outgoing,

View file

@ -146,6 +146,8 @@ use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
@ -695,6 +697,10 @@ impl CodexMessageProcessor {
self.thread_read(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadShellCommand { request_id, params } => {
self.thread_shell_command(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::SkillsList { request_id, params } => {
self.skills_list(to_connection_request_id(request_id), params)
.await;
@ -2974,6 +2980,58 @@ impl CodexMessageProcessor {
}
}
async fn thread_shell_command(
&self,
request_id: ConnectionRequestId,
params: ThreadShellCommandParams,
) {
let ThreadShellCommandParams { thread_id, command } = params;
let command = command.trim().to_string();
if command.is_empty() {
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "command must not be empty".to_string(),
data: None,
},
)
.await;
return;
}
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::RunUserShellCommand { command },
)
.await
{
Ok(_) => {
self.outgoing
.send_response(request_id, ThreadShellCommandResponse {})
.await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to start shell command: {err}"),
)
.await;
}
}
}
async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) {
let ThreadListParams {
cursor,

View file

@ -68,6 +68,7 @@ use codex_app_server_protocol::ThreadRealtimeStopParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnsubscribeParams;
@ -386,6 +387,15 @@ impl McpProcess {
self.send_request("thread/compact/start", params).await
}
/// Send a `thread/shellCommand` JSON-RPC request.
pub async fn send_thread_shell_command_request(
&mut self,
params: ThreadShellCommandParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/shellCommand", params).await
}
/// Send a `thread/rollback` JSON-RPC request.
pub async fn send_thread_rollback_request(
&mut self,

View file

@ -38,6 +38,7 @@ mod thread_name_websocket;
mod thread_read;
mod thread_resume;
mod thread_rollback;
mod thread_shell_command;
mod thread_start;
mod thread_status;
mod thread_unarchive;

View file

@ -0,0 +1,439 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_shell_command_sse_response;
use app_test_support::format_with_current_shell_display;
use app_test_support::to_response;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionOutputDeltaNotification;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::features::FEATURES;
use codex_core::features::Feature;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> Result<()> {
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let server = create_mock_responses_server_sequence(vec![]).await;
create_config_toml(
codex_home.as_path(),
&server.uri(),
"never",
&BTreeMap::default(),
)?;
let mut mcp = McpProcess::new(codex_home.as_path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
persist_extended_history: true,
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let shell_id = mcp
.send_thread_shell_command_request(ThreadShellCommandParams {
thread_id: thread.id.clone(),
command: "printf 'hello from bang\\n'".to_string(),
})
.await?;
let shell_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(shell_id)),
)
.await??;
let _: ThreadShellCommandResponse = to_response::<ThreadShellCommandResponse>(shell_resp)?;
let started = wait_for_command_execution_started(&mut mcp, None).await?;
let ThreadItem::CommandExecution {
id, source, status, ..
} = &started.item
else {
unreachable!("helper returns command execution item");
};
let command_id = id.clone();
assert_eq!(source, &CommandExecutionSource::UserShell);
assert_eq!(status, &CommandExecutionStatus::InProgress);
let delta = wait_for_command_execution_output_delta(&mut mcp, &command_id).await?;
assert_eq!(delta.delta, "hello from bang\n");
let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?;
let ThreadItem::CommandExecution {
id,
source,
status,
aggregated_output,
exit_code,
..
} = &completed.item
else {
unreachable!("helper returns command execution item");
};
assert_eq!(id, &command_id);
assert_eq!(source, &CommandExecutionSource::UserShell);
assert_eq!(status, &CommandExecutionStatus::Completed);
assert_eq!(aggregated_output.as_deref(), Some("hello from bang\n"));
assert_eq!(*exit_code, Some(0));
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id,
include_turns: true,
})
.await?;
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
)
.await??;
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
assert_eq!(thread.turns.len(), 1);
let ThreadItem::CommandExecution {
source,
status,
aggregated_output,
..
} = thread.turns[0]
.items
.iter()
.find(|item| matches!(item, ThreadItem::CommandExecution { .. }))
.expect("expected persisted command execution item")
else {
unreachable!("matched command execution item");
};
assert_eq!(source, &CommandExecutionSource::UserShell);
assert_eq!(status, &CommandExecutionStatus::Completed);
assert_eq!(aggregated_output.as_deref(), Some("hello from bang\n"));
Ok(())
}
#[tokio::test]
async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let responses = vec![
create_shell_command_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
],
None,
Some(5000),
"call-approve",
)?,
create_final_assistant_message_sse_response("done")?,
];
let server = create_mock_responses_server_sequence(responses).await;
create_config_toml(
codex_home.as_path(),
&server.uri(),
"untrusted",
&BTreeMap::default(),
)?;
let mut mcp = McpProcess::new(codex_home.as_path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
persist_extended_history: true,
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "run python".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(workspace.clone()),
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
let agent_started = wait_for_command_execution_started(&mut mcp, Some("call-approve")).await?;
let ThreadItem::CommandExecution {
command, source, ..
} = &agent_started.item
else {
unreachable!("helper returns command execution item");
};
assert_eq!(source, &CommandExecutionSource::Agent);
assert_eq!(
command,
&format_with_current_shell_display("python3 -c 'print(42)'")
);
let server_req = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::CommandExecutionRequestApproval { request_id, .. } = server_req else {
panic!("expected approval request");
};
let shell_id = mcp
.send_thread_shell_command_request(ThreadShellCommandParams {
thread_id: thread.id.clone(),
command: "printf 'active turn bang\\n'".to_string(),
})
.await?;
let shell_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(shell_id)),
)
.await??;
let _: ThreadShellCommandResponse = to_response::<ThreadShellCommandResponse>(shell_resp)?;
let started =
wait_for_command_execution_started_by_source(&mut mcp, CommandExecutionSource::UserShell)
.await?;
assert_eq!(started.turn_id, turn.id);
let command_id = match &started.item {
ThreadItem::CommandExecution { id, .. } => id.clone(),
_ => unreachable!("helper returns command execution item"),
};
let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?;
assert_eq!(completed.turn_id, turn.id);
let ThreadItem::CommandExecution {
source,
aggregated_output,
..
} = &completed.item
else {
unreachable!("helper returns command execution item");
};
assert_eq!(source, &CommandExecutionSource::UserShell);
assert_eq!(aggregated_output.as_deref(), Some("active turn bang\n"));
mcp.send_response(
request_id,
serde_json::to_value(CommandExecutionRequestApprovalResponse {
decision: CommandExecutionApprovalDecision::Decline,
})?,
)
.await?;
let _: TurnCompletedNotification = serde_json::from_value(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??
.params
.expect("turn/completed params"),
)?;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id,
include_turns: true,
})
.await?;
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
)
.await??;
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
assert_eq!(thread.turns.len(), 1);
assert!(
thread.turns[0].items.iter().any(|item| {
matches!(
item,
ThreadItem::CommandExecution {
source: CommandExecutionSource::UserShell,
aggregated_output,
..
} if aggregated_output.as_deref() == Some("active turn bang\n")
)
}),
"expected active-turn shell command to be persisted on the existing turn"
);
Ok(())
}
async fn wait_for_command_execution_started(
mcp: &mut McpProcess,
expected_id: Option<&str>,
) -> Result<ItemStartedNotification> {
loop {
let notif = mcp
.read_stream_until_notification_message("item/started")
.await?;
let started: ItemStartedNotification = serde_json::from_value(
notif
.params
.ok_or_else(|| anyhow::anyhow!("missing item/started params"))?,
)?;
let ThreadItem::CommandExecution { id, .. } = &started.item else {
continue;
};
if expected_id.is_none() || expected_id == Some(id.as_str()) {
return Ok(started);
}
}
}
async fn wait_for_command_execution_started_by_source(
mcp: &mut McpProcess,
expected_source: CommandExecutionSource,
) -> Result<ItemStartedNotification> {
loop {
let started = wait_for_command_execution_started(mcp, None).await?;
let ThreadItem::CommandExecution { source, .. } = &started.item else {
continue;
};
if source == &expected_source {
return Ok(started);
}
}
}
async fn wait_for_command_execution_completed(
mcp: &mut McpProcess,
expected_id: Option<&str>,
) -> Result<ItemCompletedNotification> {
loop {
let notif = mcp
.read_stream_until_notification_message("item/completed")
.await?;
let completed: ItemCompletedNotification = serde_json::from_value(
notif
.params
.ok_or_else(|| anyhow::anyhow!("missing item/completed params"))?,
)?;
let ThreadItem::CommandExecution { id, .. } = &completed.item else {
continue;
};
if expected_id.is_none() || expected_id == Some(id.as_str()) {
return Ok(completed);
}
}
}
async fn wait_for_command_execution_output_delta(
mcp: &mut McpProcess,
item_id: &str,
) -> Result<CommandExecutionOutputDeltaNotification> {
loop {
let notif = mcp
.read_stream_until_notification_message("item/commandExecution/outputDelta")
.await?;
let delta: CommandExecutionOutputDeltaNotification = serde_json::from_value(
notif
.params
.ok_or_else(|| anyhow::anyhow!("missing output delta params"))?,
)?;
if delta.item_id == item_id {
return Ok(delta);
}
}
}
fn create_config_toml(
codex_home: &Path,
server_uri: &str,
approval_policy: &str,
feature_flags: &BTreeMap<Feature, bool>,
) -> std::io::Result<()> {
let feature_entries = feature_flags
.iter()
.map(|(feature, enabled)| {
let key = FEATURES
.iter()
.find(|spec| spec.id == *feature)
.map(|spec| spec.key)
.unwrap_or_else(|| panic!("missing feature key for {feature:?}"));
format!("{key} = {enabled}")
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
model = "mock-model"
approval_policy = "{approval_policy}"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[features]
{feature_entries}
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}

View file

@ -331,6 +331,9 @@ async fn persist_user_shell_output(
session
.record_conversation_items(turn_context, std::slice::from_ref(&output_item))
.await;
// Standalone shell turns can run before any regular user turn, so
// explicitly materialize rollout persistence after recording output.
session.ensure_rollout_materialized().await;
return;
}

View file

@ -2050,6 +2050,12 @@ impl App {
app_server.thread_realtime_stop(thread_id).await?;
Ok(true)
}
AppCommandView::RunUserShellCommand { command } => {
app_server
.thread_shell_command(thread_id, command.to_string())
.await?;
Ok(true)
}
AppCommandView::OverrideTurnContext { .. } => Ok(true),
_ => Ok(false),
}

File diff suppressed because it is too large Load diff

View file

@ -35,6 +35,9 @@ pub(crate) enum AppCommandView<'a> {
RealtimeConversationAudio(&'a ConversationAudioParams),
RealtimeConversationText(&'a ConversationTextParams),
RealtimeConversationClose,
RunUserShellCommand {
command: &'a str,
},
UserTurn {
items: &'a [UserInput],
cwd: &'a PathBuf,
@ -134,6 +137,10 @@ impl AppCommand {
Self(Op::RealtimeConversationClose)
}
pub(crate) fn run_user_shell_command(command: String) -> Self {
Self(Op::RunUserShellCommand { command })
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn user_turn(
items: Vec<UserInput>,
@ -291,6 +298,7 @@ impl AppCommand {
AppCommandView::RealtimeConversationText(params)
}
Op::RealtimeConversationClose => AppCommandView::RealtimeConversationClose,
Op::RunUserShellCommand { command } => AppCommandView::RunUserShellCommand { command },
Op::UserTurn {
items,
cwd,

View file

@ -42,6 +42,8 @@ use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
@ -492,6 +494,26 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_shell_command(
&mut self,
thread_id: ThreadId,
command: String,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadShellCommandResponse = self
.client
.request_typed(ClientRequest::ThreadShellCommand {
request_id,
params: ThreadShellCommandParams {
thread_id: thread_id.to_string(),
command,
},
})
.await
.wrap_err("thread/shellCommand failed in app-server TUI")?;
Ok(())
}
pub(crate) async fn thread_background_terminals_clean(
&mut self,
thread_id: ThreadId,

View file

@ -5134,13 +5134,7 @@ impl ChatWidget {
)));
return;
}
// TODO: Restore `!` support in app-server TUI once command execution can
// persist transcript-visible output into thread history with parity to the
// legacy TUI.
self.add_to_history(history_cell::new_error_event(
"`!` shell commands are unavailable in app-server TUI because command output is not yet persisted in thread history.".to_string(),
));
self.request_redraw();
self.submit_op(AppCommand::run_user_shell_command(cmd.to_string()));
return;
}
@ -5562,6 +5556,7 @@ impl ChatWidget {
command,
cwd,
process_id,
source,
status,
command_actions,
aggregated_output,
@ -5582,10 +5577,11 @@ impl ChatWidget {
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
.collect(),
source: ExecCommandSource::Agent,
source: source.to_core(),
interaction_input: None,
});
} else {
let aggregated_output = aggregated_output.unwrap_or_default();
self.on_exec_command_end(ExecCommandEndEvent {
call_id: id,
process_id,
@ -5596,16 +5592,16 @@ impl ChatWidget {
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
.collect(),
source: ExecCommandSource::Agent,
source: source.to_core(),
interaction_input: None,
stdout: String::new(),
stderr: String::new(),
aggregated_output: aggregated_output.unwrap_or_default(),
aggregated_output: aggregated_output.clone(),
exit_code: exit_code.unwrap_or_default(),
duration: Duration::from_millis(
duration_ms.unwrap_or_default().max(0) as u64
),
formatted_output: String::new(),
formatted_output: aggregated_output,
status: match status {
codex_app_server_protocol::CommandExecutionStatus::Completed => {
codex_protocol::protocol::ExecCommandStatus::Completed
@ -6144,6 +6140,7 @@ impl ChatWidget {
command,
cwd,
process_id,
source,
command_actions,
..
} => {
@ -6157,7 +6154,7 @@ impl ChatWidget {
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
.collect(),
source: ExecCommandSource::Agent,
source: source.to_core(),
interaction_input: None,
});
}

View file

@ -8840,7 +8840,7 @@ async fn user_shell_command_renders_output_not_exploring() {
}
#[tokio::test]
async fn bang_shell_command_is_disabled_in_app_server_tui() {
async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
@ -8873,22 +8873,11 @@ async fn bang_shell_command_is_disabled_in_app_server_tui() {
.set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
let mut rendered = None;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered = Some(lines_to_single_string(&cell.display_lines(80)));
break;
}
match op_rx.try_recv() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected RunUserShellCommand op, got {other:?}"),
}
let rendered = rendered.expect("expected disabled bang-shell error");
assert!(
rendered.contains(
"`!` shell commands are unavailable in app-server TUI because command output is not yet persisted in thread history."
),
"expected bang-shell disabled message, got: {rendered}"
);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]