@keyou007/agent-bot 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +16 -10
  3. package/README.zh.md +16 -10
  4. package/assets/agent-bot-logo.png +0 -0
  5. package/dist/acp/AcpSessionManager.js +78 -46
  6. package/dist/acp/AcpSessionManager.js.map +1 -1
  7. package/dist/codex/AppServerConnection.js +15 -1
  8. package/dist/codex/AppServerConnection.js.map +1 -1
  9. package/dist/codex/CodexLocalActivityDetector.js +4 -1
  10. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  11. package/dist/codex/CodexProcessManager.js +1 -0
  12. package/dist/codex/CodexProcessManager.js.map +1 -1
  13. package/dist/codex/CodexRuntime.js +77 -2
  14. package/dist/codex/CodexRuntime.js.map +1 -1
  15. package/dist/commands/CommandRouter.js +64 -49
  16. package/dist/commands/CommandRouter.js.map +1 -1
  17. package/dist/console/ConsoleTurnPresenter.js +1 -1
  18. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  19. package/dist/feishu/CardRenderer.js +345 -11
  20. package/dist/feishu/CardRenderer.js.map +1 -1
  21. package/dist/feishu/FeishuMessageClient.js +51 -5
  22. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  23. package/dist/feishu/FeishuTurnPresenter.js +8 -3
  24. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  25. package/dist/index.js +18 -17
  26. package/dist/index.js.map +1 -1
  27. package/dist/proxy/ProxySessionController.js +589 -205
  28. package/dist/proxy/ProxySessionController.js.map +1 -1
  29. package/dist/runtime/AcpRuntimeAdapter.js +1 -0
  30. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  31. package/dist/runtime/AgentRuntimeRegistry.js +7 -4
  32. package/dist/runtime/AgentRuntimeRegistry.js.map +1 -1
  33. package/dist/runtime/createAgentRuntimeRegistry.js +22 -0
  34. package/dist/runtime/createAgentRuntimeRegistry.js.map +1 -0
  35. package/dist/startup/SessionMetadataHydrator.js +2 -2
  36. package/dist/startup/SessionMetadataHydrator.js.map +1 -1
  37. package/dist/startup/StartupNotifier.js +1 -0
  38. package/dist/startup/StartupNotifier.js.map +1 -1
  39. package/dist/state/StateStore.js +21 -15
  40. package/dist/state/StateStore.js.map +1 -1
  41. package/dist/supervision/SafeRestartScheduler.js +5 -0
  42. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  43. package/docs/technical-reference.md +15 -5
  44. package/docs/technical-reference.zh.md +15 -5
  45. package/npm-shrinkwrap.json +2 -2
  46. package/package.json +2 -1
  47. package/skills/agent-bot/SKILL.md +13 -3
  48. package/src/acp/AcpSessionManager.ts +73 -54
  49. package/src/codex/AppServerConnection.ts +21 -1
  50. package/src/codex/CodexLocalActivityDetector.ts +8 -1
  51. package/src/codex/CodexProcessManager.ts +1 -0
  52. package/src/codex/CodexRuntime.ts +91 -2
  53. package/src/commands/CommandRouter.ts +71 -49
  54. package/src/commands/commandTypes.ts +6 -11
  55. package/src/console/ConsoleTurnPresenter.ts +1 -1
  56. package/src/feishu/CardRenderer.ts +418 -11
  57. package/src/feishu/FeishuMessageClient.ts +66 -3
  58. package/src/feishu/FeishuTurnPresenter.ts +7 -2
  59. package/src/index.ts +21 -22
  60. package/src/proxy/ProxySessionController.ts +706 -208
  61. package/src/runtime/AcpRuntimeAdapter.ts +1 -0
  62. package/src/runtime/AgentRuntimeRegistry.ts +7 -6
  63. package/src/runtime/createAgentRuntimeRegistry.ts +25 -0
  64. package/src/runtime/types.ts +18 -0
  65. package/src/startup/SessionMetadataHydrator.ts +2 -2
  66. package/src/startup/StartupNotifier.ts +1 -0
  67. package/src/state/StateStore.ts +29 -15
  68. package/src/supervision/SafeRestartScheduler.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "description": "Using Codex in Lark/Feishu feels just like using native Codex.",
6
6
  "license": "GPL-3.0-only",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "files": [
27
27
  ".env.example",
28
+ "assets/",
28
29
  "CHANGELOG.md",
29
30
  "config.example.yaml",
30
31
  "dist/",
@@ -99,6 +99,8 @@ agent-bot server stop
99
99
 
100
100
  Treat `server restart` as the normal restart path. It schedules a safe restart and waits for active tasks, pending final-message delivery, and a quiet inbound-message window. The first safe-restart status card is delayed by three seconds so the active task's final response can usually be delivered first; this presentation delay does not postpone scheduler polling. While the restart is pending, its status card has a bottom `Cancel` button that conditionally cancels that exact schedule and updates the card in place.
101
101
 
102
+ In Feishu, `/restart` schedules the same safe restart. Use `/restart --force` only when the user explicitly accepts interruption; the command accepts no other arguments.
103
+
102
104
  Use `agent-bot server restart --immediate` only when the user explicitly requests an immediate restart or accepts interruption. Never use `taskkill`, `Stop-Process`, or equivalent commands for routine Agent Bot restart management.
103
105
 
104
106
  `agent-bot console` opens the console UI only when the service is stopped. Do not use `--force` while the service is live unless the user explicitly accepts concurrent state access.
@@ -135,7 +137,11 @@ Use `/queue <prompt>` in Feishu to persist a separate follow-up Prompt instead o
135
137
 
136
138
  For a long-running objective in the active Feishu task, use `/goal <objective>`. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control automatic continuation, `/goal edit <objective>` to revise it, and `/goal clear` to remove it. Stopping a Goal turn through Agent Bot also pauses the Goal before sending Interrupt so it does not immediately continue.
137
139
 
138
- Use `/newgroup [title]` in Feishu to create a private group named `[agent] [project directory] <title>`, invite the command sender, and immediately create and bind a new task in that group. The new task inherits the source chat's current task project directory, model, reasoning effort, and permission mode without interrupting or switching the source task. When the source chat has no current task, use the selected agent and its runtime defaults. An explicit title becomes both the group suffix and the task title. When the title is omitted, use task title `新任务` and group name `[agent] [project directory] 新任务 (mm-dd)`; an explicit title never gets a date suffix. Every new group gets a deterministic scheme-C Identicon avatar: Latin project names display their first word, Chinese names display up to their first four characters, and the normalized full project path hashes into the palette and symmetric node pattern. Projectless groups use the title as the stable seed. Replace the user's home-directory prefix with `~`. The bracketed project-directory value is limited to 15 characters. Longer paths keep the final two directories when they fit, otherwise keep the final directory, truncating the tail only when the final directory itself is overlong. Use `\` on Windows and `/` on macOS/Linux. Do not automatically send a Sessions or Status card to the new group. A later `/new --dir <cwd>` or `/new --nodir` in the group may explicitly replace the inherited project shape.
140
+ Use `/agent` in Feishu to open the unified execution-settings card on its Agent tab and `/agent <name>` to directly select the default agent. The Agent tab is present only when multiple agents are configured and remains usable before the chat has a current task. When only one Agent is configured, `/agent` reports that current Agent directly instead of sending a card. Agent selection affects future tasks and does not replace the Agent of an existing task; use `/new` after selecting when a new task is needed.
141
+
142
+ Use `/newgroup [title] [--dir <cwd> | --nodir]` in Feishu to create a private group named `[agent] [project directory] <title>`, invite the command sender, and immediately create and bind a new task in that group. `/new` and `/newgroup` share the same project options: an explicit `--dir` overrides the inherited project directory, while `--nodir` forces a Projectless Codex task; reject using both together. Resolve `~`, `~/...`, and `~\...` from the current user's home directory for both commands. Projectless groups omit the project segment and use `[agent] <title>`. The new task inherits the source chat's current task project directory when neither option is present, plus its model, reasoning effort, and permission mode without interrupting or switching the source task. When the source chat has no current task, use the selected agent and its runtime defaults. An explicit title becomes both the group suffix and the task title. When the title is omitted, use task title `新任务` and group name `[agent] [project directory] 新任务 (mm-dd)`, or `[agent] 新任务 (mm-dd)` for Projectless; an explicit title never gets a date suffix. Every new group gets a deterministic scheme-C Identicon avatar: Latin project names display their first word, Chinese names display up to their first four characters, and the normalized full project path hashes into the palette and symmetric node pattern. Projectless groups use the title as the stable seed. Replace the user's home-directory prefix with `~`. The bracketed project-directory value is limited to 15 characters. Longer paths keep the final two directories when they fit, otherwise keep the final directory, truncating the tail only when the final directory itself is overlong. Use `\` on Windows and `/` on macOS/Linux. Do not automatically send a Sessions or Status card to the new group. A later `/new --dir <cwd>` or `/new --nodir` in the group may explicitly replace the inherited project shape.
143
+
144
+ Provider is part of the same inherited execution-settings group as model, reasoning effort, and permission mode. Preserve all four across `/new`, `/newgroup`, `/fork`, `/forkgroup`, and the matching `/sessions` actions. New tasks with no inherited Provider must leave it unset so Codex App Server uses the default from Codex configuration. New-group welcome messages report all four settings.
139
145
 
140
146
  Group names created by `/newgroup` and `/forkgroup` are capped at 60 displayed characters. When the generated name would exceed that limit, truncate only the title portion in the Feishu group name; preserve the underlying Agent Bot task title.
141
147
 
@@ -145,11 +151,15 @@ Use `/new [title] --nodir` to force a new Codex Projectless task even when the c
145
151
 
146
152
  Every task in the Feishu `/sessions` card has `New`, `NewGroup`, `Fork`, and `ForkGroup` actions. `New` inherits the source task's project directory, model, reasoning effort, and permission mode, creates a new task, and switches the current chat to it without stopping or forking the source task. `NewGroup` applies the same inheritance in a newly created private group. `Fork` switches the current chat to a branch, while `ForkGroup` binds the branch to a newly created private group; both fork actions use the selected task's latest available completed turn without interrupting an active turn.
147
153
 
148
- Use `/fork [number-or-task-id]` in Feishu to branch from the current or specified Codex task and switch to the new branch. If the source task is running, Agent Bot forks from its latest completed turn and leaves the active turn running; it rejects only when the task has no completed turn yet.
154
+ Use `/fork [number-or-task-id]` in Feishu to branch from the current or specified Codex task and switch to the new branch. If the source task is running, Agent Bot forks from its latest completed turn and leaves the active turn running; it rejects only when the task has no completed turn yet. Every fork request sends `excludeTurns: true` to Codex App Server so the response omits populated turns without removing turns from the forked history. Older App Servers that explicitly reject this experimental field are retried once without it; ambiguous failures such as timeouts or disconnects are not retried.
149
155
 
150
156
  Status cards include a `Refresh` action that reads the latest task state and updates the same card in place without switching tasks or sending another card.
151
157
 
152
- Use `/model` in Feishu to show an interactive Card 2.0 model selector for the current task. Each non-current model has a blue link-style `Switch` action. After selecting a model, the same card advances to that model's reasoning-mode selector; the selector lists only mode names, has a `Back` action, and applies a selected mode from the next request. `/thinking` opens the reasoning selector directly. `/model <name>` and `/thinking <level>` remain available for direct text-based selection.
158
+ Use `/agent`, `/provider`, `/model`, `/thinking`, or `/permissions` in Feishu to open the same interactive Card 2.0 execution-settings card with the corresponding tab active. The card exposes Provider, Model, Thinking, and Permission tabs whenever a current task exists, plus an Agent tab when multiple agents are configured. Each non-current option has an English `Switch` action. Selecting an option refreshes the same card in place. Agent changes affect future tasks; the other settings apply from the next request. Model changes automatically choose a compatible reasoning effort when necessary.
159
+
160
+ If the current runtime has no alternative Provider, `/provider` reports the current Provider directly instead of opening the settings card. Apply the same rule to `/agent` when only one Agent is configured.
161
+
162
+ These four settings commands do not accept arguments. Never instruct a user to send a Provider, model, reasoning effort, or permission value after the command; direct them to the card callbacks instead.
153
163
 
154
164
  Keep action labels placed to the right of card content or at the bottom of a card in English. Buttons above the main card content, such as activity-history navigation, may remain in Chinese.
155
165
 
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import type { Logger } from "pino";
3
- import type { AppConfig } from "../config/schema.js";
3
+ import type { AgentConfig } from "../config/schema.js";
4
4
  import type {
5
5
  AcpInitializeResult,
6
6
  AcpPermissionRequestParams,
@@ -13,7 +13,6 @@ import { AcpProcessManager, type ManagedAcpProcess } from "./AcpProcessManager.j
13
13
 
14
14
  export interface RuntimeSession {
15
15
  localSessionId: string;
16
- processKey: string;
17
16
  agentName: string;
18
17
  cwd: string;
19
18
  acpSessionId: string;
@@ -22,6 +21,8 @@ export interface RuntimeSession {
22
21
  modes?: JsonValue;
23
22
  configOptions?: JsonValue;
24
23
  availableCommands?: JsonValue;
24
+ onUpdate: (session: RuntimeSession, update: Record<string, JsonValue>) => void;
25
+ onPermissionRequest: (session: RuntimeSession, params: AcpPermissionRequestParams) => Promise<JsonValue>;
25
26
  }
26
27
 
27
28
  export interface CreateRuntimeSessionInput {
@@ -38,9 +39,11 @@ export interface CreateRuntimeSessionInput {
38
39
  export class AcpSessionManager {
39
40
  private readonly sessions = new Map<string, RuntimeSession>();
40
41
  private readonly sessionsByAcpId = new Map<string, RuntimeSession>();
42
+ private processStart?: Promise<ManagedAcpProcess>;
41
43
 
42
44
  constructor(
43
- private readonly config: AppConfig,
45
+ private readonly agentName: string,
46
+ private readonly agent: AgentConfig,
44
47
  private readonly processManager: AcpProcessManager,
45
48
  private readonly logger: Logger,
46
49
  ) {}
@@ -50,66 +53,19 @@ export class AcpSessionManager {
50
53
  }
51
54
 
52
55
  async create(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
53
- const agent = this.config.agents[input.agentName];
54
- if (!agent) {
55
- throw new Error(`Unknown agent: ${input.agentName}`);
56
- }
56
+ if (input.agentName !== this.agentName) throw new Error(`Unexpected agent: ${input.agentName}`);
57
57
 
58
58
  const cwd = path.resolve(input.cwd);
59
- const processKey = input.localSessionId;
60
- const managed = this.processManager.start(processKey, input.agentName, agent);
59
+ const managed = await this.process();
61
60
  const connection = managed.connection;
62
61
 
63
- let runtime: RuntimeSession | undefined;
64
-
65
- connection.registerHandler("session/request_permission", async (params) => {
66
- if (!runtime) {
67
- throw new Error("Received permission request before session was ready.");
68
- }
69
-
70
- return input.onPermissionRequest(runtime, params as unknown as AcpPermissionRequestParams);
71
- });
72
-
73
- connection.on("notification", (_method, params) => {
74
- if (_method !== "session/update") {
75
- return;
76
- }
77
-
78
- const updateParams = params as unknown as AcpSessionUpdateParams;
79
- const target = this.sessionsByAcpId.get(updateParams.sessionId);
80
- if (!target) {
81
- this.logger.debug({ updateParams }, "Ignoring update for unknown ACP session.");
82
- return;
83
- }
84
-
85
- this.applySessionUpdate(target, updateParams.update);
86
- input.onUpdate(target, updateParams.update);
87
- });
88
-
89
- const initializeResult = await connection.request<AcpInitializeResult>("initialize", {
90
- protocolVersion: 1,
91
- clientCapabilities: {},
92
- clientInfo: {
93
- name: "agent-bot",
94
- title: "Agent Bot",
95
- version: "0.1.0",
96
- },
97
- });
98
-
99
- if (initializeResult.authMethods?.length) {
100
- const method = initializeResult.authMethods[0];
101
- this.logger.info({ methodId: method.id }, "Authenticating ACP agent with first advertised method.");
102
- await connection.request("authenticate", { methodId: method.id });
103
- }
104
-
105
62
  const newSessionResult = await connection.request<AcpSessionNewResult>("session/new", {
106
63
  cwd,
107
64
  mcpServers: [],
108
65
  });
109
66
 
110
- runtime = {
67
+ const runtime: RuntimeSession = {
111
68
  localSessionId: input.localSessionId,
112
- processKey,
113
69
  agentName: input.agentName,
114
70
  cwd,
115
71
  acpSessionId: newSessionResult.sessionId,
@@ -117,6 +73,8 @@ export class AcpSessionManager {
117
73
  running: false,
118
74
  modes: newSessionResult.modes,
119
75
  configOptions: newSessionResult.configOptions,
76
+ onUpdate: input.onUpdate,
77
+ onPermissionRequest: input.onPermissionRequest,
120
78
  };
121
79
 
122
80
  this.sessions.set(input.localSessionId, runtime);
@@ -169,7 +127,13 @@ export class AcpSessionManager {
169
127
 
170
128
  this.sessions.delete(localSessionId);
171
129
  this.sessionsByAcpId.delete(session.acpSessionId);
172
- this.processManager.stop(session.processKey);
130
+ }
131
+
132
+ shutdown(): void {
133
+ this.sessions.clear();
134
+ this.sessionsByAcpId.clear();
135
+ this.processStart = undefined;
136
+ this.processManager.stopAll();
173
137
  }
174
138
 
175
139
  async setMode(localSessionId: string, modeId: string): Promise<void> {
@@ -203,6 +167,61 @@ export class AcpSessionManager {
203
167
  return session;
204
168
  }
205
169
 
170
+ private async process(): Promise<ManagedAcpProcess> {
171
+ const existing = this.processManager.get(this.agentName);
172
+ if (existing) return existing;
173
+ if (this.processStart) return this.processStart;
174
+ const start = this.startProcess();
175
+ this.processStart = start;
176
+ try {
177
+ return await start;
178
+ } finally {
179
+ if (this.processStart === start) this.processStart = undefined;
180
+ }
181
+ }
182
+
183
+ private async startProcess(): Promise<ManagedAcpProcess> {
184
+ const managed = this.processManager.start(this.agentName, this.agentName, this.agent);
185
+ try {
186
+ const connection = managed.connection;
187
+ connection.registerHandler("session/request_permission", async (params) => {
188
+ const permission = params as unknown as AcpPermissionRequestParams;
189
+ const target = this.sessionsByAcpId.get(permission.sessionId);
190
+ if (!target) throw new Error(`Unknown ACP session requesting permission: ${permission.sessionId}`);
191
+ return target.onPermissionRequest(target, permission);
192
+ });
193
+ connection.on("notification", (method, params) => {
194
+ if (method !== "session/update") return;
195
+ const updateParams = params as unknown as AcpSessionUpdateParams;
196
+ const target = this.sessionsByAcpId.get(updateParams.sessionId);
197
+ if (!target) {
198
+ this.logger.debug({ updateParams }, "Ignoring update for unknown ACP session.");
199
+ return;
200
+ }
201
+ this.applySessionUpdate(target, updateParams.update);
202
+ target.onUpdate(target, updateParams.update);
203
+ });
204
+ const initializeResult = await connection.request<AcpInitializeResult>("initialize", {
205
+ protocolVersion: 1,
206
+ clientCapabilities: {},
207
+ clientInfo: {
208
+ name: "agent-bot",
209
+ title: "Agent Bot",
210
+ version: "0.1.0",
211
+ },
212
+ });
213
+ if (initializeResult.authMethods?.length) {
214
+ const method = initializeResult.authMethods[0];
215
+ this.logger.info({ methodId: method.id }, "Authenticating ACP agent with first advertised method.");
216
+ await connection.request("authenticate", { methodId: method.id });
217
+ }
218
+ return managed;
219
+ } catch (error) {
220
+ this.processManager.stop(this.agentName);
221
+ throw error;
222
+ }
223
+ }
224
+
206
225
  private applySessionUpdate(session: RuntimeSession, update: Record<string, JsonValue>): void {
207
226
  const updateType = update.sessionUpdate;
208
227
  if (updateType === "config_option_update" && "configOptions" in update) {
@@ -26,6 +26,21 @@ interface PendingRequest {
26
26
 
27
27
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
28
28
 
29
+ export class AppServerRequestError extends Error {
30
+ readonly data: unknown;
31
+
32
+ constructor(
33
+ readonly method: string,
34
+ readonly code: number,
35
+ readonly serverMessage: string,
36
+ data?: unknown,
37
+ ) {
38
+ super(`${method} failed: ${serverMessage}`);
39
+ this.name = "AppServerRequestError";
40
+ this.data = data;
41
+ }
42
+ }
43
+
29
44
  export class AppServerConnection {
30
45
  private nextId = 1;
31
46
  private closed = false;
@@ -131,7 +146,12 @@ export class AppServerConnection {
131
146
  this.pending.delete(message.id);
132
147
  if (pending.timeout) clearTimeout(pending.timeout);
133
148
  if ("error" in message) {
134
- pending.reject(new Error(`${pending.method} failed: ${message.error.message}`));
149
+ pending.reject(new AppServerRequestError(
150
+ pending.method,
151
+ message.error.code,
152
+ message.error.message,
153
+ message.error.data,
154
+ ));
135
155
  } else {
136
156
  pending.resolve(message.result);
137
157
  }
@@ -16,12 +16,14 @@ interface ThreadPathRow {
16
16
 
17
17
  interface ThreadSettingsRow {
18
18
  id: string;
19
+ model_provider?: string | null;
19
20
  model: string | null;
20
21
  reasoning_effort: string | null;
21
22
  approval_mode: string | null;
22
23
  }
23
24
 
24
25
  export interface CodexLocalThreadSettings {
26
+ modelProvider?: string;
25
27
  model?: string;
26
28
  reasoningEffort?: string;
27
29
  permissionMode?: PermissionMode;
@@ -79,10 +81,15 @@ export class CodexLocalActivityDetector {
79
81
  fileMustExist: true,
80
82
  });
81
83
  const placeholders = ids.map(() => "?").join(", ");
84
+ const columns = new Set(
85
+ (database.pragma("table_info(threads)") as Array<{ name: string }>).map((column) => column.name),
86
+ );
87
+ const providerColumn = columns.has("model_provider") ? ", model_provider" : "";
82
88
  const rows = database.prepare(
83
- `SELECT id, model, reasoning_effort, approval_mode FROM threads WHERE id IN (${placeholders})`,
89
+ `SELECT id${providerColumn}, model, reasoning_effort, approval_mode FROM threads WHERE id IN (${placeholders})`,
84
90
  ).all(...ids) as ThreadSettingsRow[];
85
91
  return new Map(rows.map((row) => [row.id, {
92
+ ...(row.model_provider?.trim() ? { modelProvider: row.model_provider.trim() } : {}),
86
93
  ...(row.model?.trim() ? { model: row.model.trim() } : {}),
87
94
  ...(row.reasoning_effort?.trim() ? { reasoningEffort: row.reasoning_effort.trim() } : {}),
88
95
  ...(row.approval_mode?.trim()
@@ -40,6 +40,7 @@ export class CodexProcessManager implements AppServerClientProvider {
40
40
  });
41
41
  await client.request("initialize", {
42
42
  clientInfo: { name: "agent-bot", title: "Agent Bot", version: "0.1.0" },
43
+ capabilities: { experimentalApi: true },
43
44
  });
44
45
  client.notify("initialized", {});
45
46
  return client;
@@ -5,6 +5,7 @@ import type {
5
5
  CreateRuntimeSessionInput,
6
6
  ForkRuntimeSessionInput,
7
7
  ModelOption,
8
+ ModelProviderOption,
8
9
  PermissionMode,
9
10
  RemoteSessionActivity,
10
11
  RemoteSessionPage,
@@ -13,12 +14,14 @@ import type {
13
14
  RuntimeGoal,
14
15
  RuntimeGoalUpdate,
15
16
  RuntimeEvent,
17
+ RuntimeExecutionSettings,
16
18
  RuntimePrompt,
17
19
  RuntimeSession,
18
20
  RuntimeSessionMetadata,
19
21
  } from "../runtime/types.js";
20
22
  import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
21
23
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
24
+ import { AppServerRequestError } from "./AppServerConnection.js";
22
25
  import { mapCodexNotification } from "./CodexEventMapper.js";
23
26
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
24
27
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
@@ -100,6 +103,7 @@ export class CodexRuntime implements AgentRuntime {
100
103
  const response = await client.request<ThreadResponse>("thread/start", {
101
104
  cwd: input.cwd,
102
105
  model: input.model,
106
+ ...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
103
107
  threadSource: "user",
104
108
  ...threadLifecycleParams(input.cwd),
105
109
  ...permissionParams(input.permissionMode),
@@ -122,8 +126,10 @@ export class CodexRuntime implements AgentRuntime {
122
126
  const client = await this.client();
123
127
  const response = await client.request<ThreadResponse>("thread/resume", {
124
128
  threadId: input.remoteSessionId,
129
+ excludeTurns: true,
125
130
  cwd: input.cwd,
126
131
  model: input.model,
132
+ ...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
127
133
  ...threadLifecycleParams(input.cwd),
128
134
  ...permissionParams(input.permissionMode),
129
135
  }, SESSION_REQUEST_TIMEOUT_MS);
@@ -135,15 +141,30 @@ export class CodexRuntime implements AgentRuntime {
135
141
 
136
142
  async forkSession(input: ForkRuntimeSessionInput): Promise<RuntimeSession> {
137
143
  const client = await this.client();
138
- const response = await client.request<ThreadResponse>("thread/fork", {
144
+ const forkParams = {
139
145
  threadId: input.remoteSessionId,
140
146
  lastTurnId: input.lastTurnId,
141
147
  cwd: input.cwd,
142
148
  model: input.model,
149
+ ...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
143
150
  threadSource: "user",
144
151
  ...threadLifecycleParams(input.cwd),
145
152
  ...permissionParams(input.permissionMode),
146
- }, FORK_REQUEST_TIMEOUT_MS);
153
+ };
154
+ let response: ThreadResponse;
155
+ try {
156
+ response = await client.request<ThreadResponse>("thread/fork", {
157
+ ...forkParams,
158
+ excludeTurns: true,
159
+ }, FORK_REQUEST_TIMEOUT_MS);
160
+ } catch (error) {
161
+ if (!isUnsupportedExcludeTurnsError(error)) throw error;
162
+ this.logger.warn(
163
+ { error },
164
+ "Codex App Server does not support thread/fork excludeTurns; retrying without it.",
165
+ );
166
+ response = await client.request<ThreadResponse>("thread/fork", forkParams, FORK_REQUEST_TIMEOUT_MS);
167
+ }
147
168
  const requestedTitle = normalizeTaskTitle(input.title);
148
169
  if (requestedTitle) {
149
170
  await client.request("thread/name/set", {
@@ -375,6 +396,28 @@ export class CodexRuntime implements AgentRuntime {
375
396
  this.requireSession(sessionId).permissionMode = mode;
376
397
  }
377
398
 
399
+ async setExecutionSettings(
400
+ sessionId: string,
401
+ settings: RuntimeExecutionSettings,
402
+ ): Promise<RuntimeSession> {
403
+ const session = this.requireSession(sessionId);
404
+ const response = await (await this.client()).request<ThreadResponse>("thread/resume", {
405
+ threadId: session.remoteSessionId,
406
+ excludeTurns: true,
407
+ cwd: session.cwd,
408
+ modelProvider: settings.modelProvider,
409
+ model: settings.model,
410
+ ...threadLifecycleParams(session.cwd),
411
+ ...permissionParams(settings.permissionMode),
412
+ }, SESSION_REQUEST_TIMEOUT_MS);
413
+ session.modelProvider = response.modelProvider ?? settings.modelProvider;
414
+ session.model = response.model ?? settings.model;
415
+ session.reasoningEffort = settings.reasoningEffort;
416
+ session.permissionMode = settings.permissionMode;
417
+ session.needsResume = false;
418
+ return session;
419
+ }
420
+
378
421
  async respondToApproval(
379
422
  sessionId: string,
380
423
  requestId: string,
@@ -412,6 +455,33 @@ export class CodexRuntime implements AgentRuntime {
412
455
  }));
413
456
  }
414
457
 
458
+ async listModelProviders(): Promise<ModelProviderOption[]> {
459
+ const response = await (await this.client()).request<{ config?: Record<string, unknown> }>(
460
+ "config/read",
461
+ {},
462
+ CONTROL_REQUEST_TIMEOUT_MS,
463
+ );
464
+ const config = response.config ?? {};
465
+ const defaultProvider = stringValue(config.model_provider)?.trim();
466
+ const configuredProviders = isRecord(config.model_providers) ? config.model_providers : {};
467
+ const providers = new Map<string, ModelProviderOption>();
468
+ for (const [id, value] of Object.entries(configuredProviders)) {
469
+ if (!id.trim()) continue;
470
+ const displayName = isRecord(value) ? stringValue(value.name)?.trim() : undefined;
471
+ providers.set(id, {
472
+ id,
473
+ ...(displayName ? { displayName } : {}),
474
+ ...(id === defaultProvider ? { isDefault: true } : {}),
475
+ });
476
+ }
477
+ if (defaultProvider && !providers.has(defaultProvider)) {
478
+ providers.set(defaultProvider, { id: defaultProvider, isDefault: true });
479
+ }
480
+ return [...providers.values()].sort((left, right) =>
481
+ Number(Boolean(right.isDefault)) - Number(Boolean(left.isDefault)) || left.id.localeCompare(right.id),
482
+ );
483
+ }
484
+
415
485
  onEvent(listener: (event: RuntimeEvent) => void): () => void {
416
486
  this.listeners.add(listener);
417
487
  return () => this.listeners.delete(listener);
@@ -642,8 +712,10 @@ export class CodexRuntime implements AgentRuntime {
642
712
  if (!session.needsResume) return;
643
713
  await client.request("thread/resume", {
644
714
  threadId: session.remoteSessionId,
715
+ excludeTurns: true,
645
716
  cwd: session.cwd,
646
717
  model: session.model,
718
+ ...(session.modelProvider ? { modelProvider: session.modelProvider } : {}),
647
719
  ...threadLifecycleParams(session.cwd),
648
720
  ...permissionParams(session.permissionMode),
649
721
  }, SESSION_REQUEST_TIMEOUT_MS);
@@ -772,6 +844,7 @@ export class CodexRuntime implements AgentRuntime {
772
844
  title: normalizeTaskTitle(response.thread.name)
773
845
  ?? normalizeTaskTitle(response.thread.preview)
774
846
  ?? input.title,
847
+ modelProvider: input.modelProvider ?? response.modelProvider,
775
848
  model: input.model ?? response.model,
776
849
  reasoningEffort,
777
850
  permissionMode: input.permissionMode,
@@ -835,6 +908,7 @@ export class CodexRuntime implements AgentRuntime {
835
908
 
836
909
  interface ThreadResponse {
837
910
  thread: { id: string; name?: string | null; preview?: string };
911
+ modelProvider?: string;
838
912
  model?: string;
839
913
  reasoningEffort?: string | null;
840
914
  }
@@ -853,6 +927,7 @@ interface CodexThreadSnapshot {
853
927
  name?: string | null;
854
928
  preview?: string;
855
929
  cwd?: string;
930
+ modelProvider?: string;
856
931
  source?: unknown;
857
932
  createdAt?: number;
858
933
  updatedAt?: number;
@@ -933,6 +1008,19 @@ function uniqueStrings(values: string[]): string[] {
933
1008
  return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
934
1009
  }
935
1010
 
1011
+ function isUnsupportedExcludeTurnsError(error: unknown): boolean {
1012
+ if (!(error instanceof AppServerRequestError) || error.method !== "thread/fork") return false;
1013
+ const data = typeof error.data === "string"
1014
+ ? error.data
1015
+ : error.data === undefined
1016
+ ? ""
1017
+ : JSON.stringify(error.data);
1018
+ const details = `${error.serverMessage} ${data}`;
1019
+ if (!details.toLowerCase().replaceAll(/[^a-z]/g, "").includes("excludeturns")) return false;
1020
+ return error.code === -32602
1021
+ || /unknown|unrecognized|unexpected|unsupported|not supported|experimental/i.test(details);
1022
+ }
1023
+
936
1024
  function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary {
937
1025
  const lastTurn = thread.turns?.at(-1);
938
1026
  const lastCompletedTurn = [...(thread.turns ?? [])]
@@ -956,6 +1044,7 @@ function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary
956
1044
  cwd: thread.cwd ?? "",
957
1045
  source: codexSourceLabel(thread.source),
958
1046
  status,
1047
+ modelProvider: thread.modelProvider,
959
1048
  createdAt: thread.createdAt,
960
1049
  updatedAt: thread.updatedAt,
961
1050
  recencyAt: thread.recencyAt ?? undefined,