@keyou007/agent-bot 0.1.20 → 0.1.21

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 (59) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +15 -5
  3. package/README.zh.md +15 -5
  4. package/dist/codex/CodexEventMapper.js +21 -1
  5. package/dist/codex/CodexEventMapper.js.map +1 -1
  6. package/dist/codex/CodexProcessManager.js +3 -0
  7. package/dist/codex/CodexProcessManager.js.map +1 -1
  8. package/dist/codex/CodexRuntime.js +136 -4
  9. package/dist/codex/CodexRuntime.js.map +1 -1
  10. package/dist/codex/ProviderModels.js +159 -0
  11. package/dist/codex/ProviderModels.js.map +1 -0
  12. package/dist/commands/CommandRouter.js +8 -3
  13. package/dist/commands/CommandRouter.js.map +1 -1
  14. package/dist/console/ConsoleTurnPresenter.js +2 -1
  15. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  16. package/dist/feishu/CardRenderer.js +46 -16
  17. package/dist/feishu/CardRenderer.js.map +1 -1
  18. package/dist/feishu/FeishuTurnPresenter.js +34 -8
  19. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/local-files/LocalFileViewerServer.js +151 -16
  23. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  24. package/dist/local-files/TurnPreviewPage.js +725 -0
  25. package/dist/local-files/TurnPreviewPage.js.map +1 -0
  26. package/dist/presentation/OutboundRouter.js +6 -1
  27. package/dist/presentation/OutboundRouter.js.map +1 -1
  28. package/dist/presentation/TurnStateReducer.js +142 -3
  29. package/dist/presentation/TurnStateReducer.js.map +1 -1
  30. package/dist/proxy/ProxySessionController.js +186 -80
  31. package/dist/proxy/ProxySessionController.js.map +1 -1
  32. package/dist/state/StateStore.js +7 -0
  33. package/dist/state/StateStore.js.map +1 -1
  34. package/dist/utils/formatStorageSize.js +15 -0
  35. package/dist/utils/formatStorageSize.js.map +1 -0
  36. package/docs/technical-reference.md +7 -1
  37. package/docs/technical-reference.zh.md +7 -1
  38. package/npm-shrinkwrap.json +2 -2
  39. package/package.json +1 -1
  40. package/skills/agent-bot/SKILL.md +5 -1
  41. package/src/codex/CodexEventMapper.ts +31 -1
  42. package/src/codex/CodexProcessManager.ts +4 -0
  43. package/src/codex/CodexRuntime.ts +153 -9
  44. package/src/codex/ProviderModels.ts +185 -0
  45. package/src/commands/CommandRouter.ts +7 -2
  46. package/src/commands/commandTypes.ts +1 -1
  47. package/src/console/ConsoleTurnPresenter.ts +3 -0
  48. package/src/feishu/CardRenderer.ts +47 -16
  49. package/src/feishu/FeishuTurnPresenter.ts +46 -4
  50. package/src/index.ts +2 -0
  51. package/src/local-files/LocalFileViewerServer.ts +166 -17
  52. package/src/local-files/TurnPreviewPage.ts +792 -0
  53. package/src/presentation/OutboundRouter.ts +8 -0
  54. package/src/presentation/TurnStateReducer.ts +175 -2
  55. package/src/presentation/turnViewTypes.ts +13 -0
  56. package/src/proxy/ProxySessionController.ts +186 -67
  57. package/src/runtime/types.ts +18 -1
  58. package/src/state/StateStore.ts +9 -0
  59. package/src/utils/formatStorageSize.ts +13 -0
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import type { Logger } from "pino";
2
3
  import type {
3
4
  AgentRuntime,
@@ -9,6 +10,7 @@ import type {
9
10
  ModelProviderOption,
10
11
  PermissionMode,
11
12
  RemoteSessionActivity,
13
+ RemoteSessionMetrics,
12
14
  RemoteSessionPage,
13
15
  RemoteSessionSummary,
14
16
  RemoteTurnPage,
@@ -30,6 +32,7 @@ import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
30
32
  import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
31
33
  import { threadWriterLockPath } from "./ThreadWriterProcess.js";
32
34
  import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, providerSwitchFailure } from "./ProviderSwitch.js";
35
+ import { fetchProviderModels, type ProviderModelConfig } from "./ProviderModels.js";
33
36
 
34
37
  const WINDOWS_SCREENSHOT_DEVELOPER_INSTRUCTIONS = [
35
38
  "When capturing any screenshot on Windows, use one fresh process and make it Per-Monitor DPI Aware V2 before loading System.Windows.Forms, System.Drawing, or UI Automation, and before calling any screen, window, or bounds API.",
@@ -66,6 +69,7 @@ export interface AppServerClientProvider {
66
69
  getAgentFamily?(): "codex" | "traex" | undefined;
67
70
  getProcessInfo?(): AgentProcessInfo;
68
71
  getCodexHome?(): string;
72
+ getEnvironmentVariable?(name: string): string | undefined;
69
73
  onDisconnect?(listener: (error: Error) => void): () => void;
70
74
  release?(): Promise<void>;
71
75
  close(): void;
@@ -80,6 +84,9 @@ interface CodexSession extends RuntimeSession {
80
84
  needsResume: boolean;
81
85
  canReplaceEmptyThread: boolean;
82
86
  settingsRecoveryError?: string;
87
+ rolloutPath?: string;
88
+ turnCount?: number;
89
+ knownTurnIds: Set<string>;
83
90
  }
84
91
 
85
92
  interface PendingApproval {
@@ -291,6 +298,8 @@ export class CodexRuntime implements AgentRuntime {
291
298
  limit: input.limit ?? 20,
292
299
  sortKey: this.provider.getAgentFamily?.() === "traex" ? "updated_at" : "recency_at",
293
300
  sortDirection: "desc",
301
+ // Omitting this filter only lists the App Server's current Provider.
302
+ modelProviders: [],
294
303
  sourceKinds: USER_RESUMABLE_THREAD_SOURCE_KINDS,
295
304
  archived: false,
296
305
  searchTerm: input.searchTerm,
@@ -312,6 +321,53 @@ export class CodexRuntime implements AgentRuntime {
312
321
  return this.decorateRemoteSession(remoteSessionId, remoteSessionSummary({ ...response.thread, turns }));
313
322
  }
314
323
 
324
+ async readRemoteSessionMetrics(remoteSessionId: string): Promise<RemoteSessionMetrics> {
325
+ const client = await this.client();
326
+ const metadata = await client.request<ThreadReadResponse>(
327
+ "thread/read", { threadId: remoteSessionId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS,
328
+ );
329
+ const { turnCount, turnIds } = await this.countRemoteTurns(client, remoteSessionId);
330
+ const rolloutPath = stringValue(metadata.thread.path)?.trim() || undefined;
331
+ const loaded = [...this.sessions.values()].filter((session) => session.remoteSessionId === remoteSessionId);
332
+ for (const session of loaded) {
333
+ session.rolloutPath = rolloutPath ?? session.rolloutPath;
334
+ const knownTurnIds = new Set(turnIds);
335
+ if (session.activeTurnId && !knownTurnIds.has(session.activeTurnId)) knownTurnIds.add(session.activeTurnId);
336
+ session.knownTurnIds = knownTurnIds;
337
+ session.turnCount = knownTurnIds.size;
338
+ }
339
+ const effectivePath = rolloutPath ?? loaded[0]?.rolloutPath;
340
+ return {
341
+ turnCount: loaded[0]?.turnCount ?? turnCount,
342
+ storageBytes: rolloutStorageBytes(effectivePath),
343
+ };
344
+ }
345
+
346
+ private async countRemoteTurns(
347
+ client: AppServerClient,
348
+ remoteSessionId: string,
349
+ ): Promise<{ turnCount: number; turnIds: Set<string> }> {
350
+ const turnIds = new Set<string>();
351
+ const seenCursors = new Set<string>();
352
+ let cursor: string | undefined;
353
+ do {
354
+ const page = await this.readTurnPage(client, remoteSessionId, {
355
+ cursor,
356
+ limit: 100,
357
+ itemsView: "summary",
358
+ }, CONTROL_REQUEST_TIMEOUT_MS);
359
+ for (const turn of page.data) turnIds.add(turn.id);
360
+ const nextCursor = page.nextCursor ?? undefined;
361
+ if (!nextCursor) break;
362
+ if (nextCursor === cursor || seenCursors.has(nextCursor)) {
363
+ throw new Error("App Server repeated a Turn history cursor while counting task Turns.");
364
+ }
365
+ seenCursors.add(nextCursor);
366
+ cursor = nextCursor;
367
+ } while (cursor);
368
+ return { turnCount: turnIds.size, turnIds };
369
+ }
370
+
315
371
  async listRemoteTurnSummaries(
316
372
  remoteSessionId: string,
317
373
  input: { cursor?: string; limit: number },
@@ -698,7 +754,43 @@ export class CodexRuntime implements AgentRuntime {
698
754
  this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
699
755
  }
700
756
 
701
- async listModels(): Promise<ModelOption[]> {
757
+ async listModels(modelProvider?: string, fallbackModel?: string): Promise<ModelOption[]> {
758
+ const requestedProvider = modelProvider?.trim();
759
+ if (!requestedProvider
760
+ || (requestedProvider === BUILT_IN_CODEX_PROVIDER_ID && this.provider.getAgentFamily?.() !== "traex")) {
761
+ return this.listCatalogModels();
762
+ }
763
+ const config = await this.readConfig();
764
+ const configuredProviders = isRecord(config.model_providers) ? config.model_providers : {};
765
+ const providerConfig = configuredProviders[requestedProvider];
766
+ let catalog: ModelOption[] = [];
767
+ try {
768
+ catalog = await this.listCatalogModels();
769
+ } catch (error) {
770
+ this.logger.warn({ error, modelProvider: requestedProvider },
771
+ "Failed to read the App Server model catalog while discovering Provider models.");
772
+ }
773
+ const configuredDefaultModel = stringValue(config.model_provider)?.trim() === requestedProvider
774
+ ? stringValue(config.model)?.trim()
775
+ : undefined;
776
+ try {
777
+ return await fetchProviderModels({
778
+ providerId: requestedProvider,
779
+ config: (isRecord(providerConfig) ? providerConfig : {}) as ProviderModelConfig,
780
+ catalog,
781
+ configuredDefaultModel,
782
+ environmentValue: (name) => this.provider.getEnvironmentVariable?.(name) ?? process.env[name],
783
+ });
784
+ } catch (error) {
785
+ const fallback = fallbackProviderModel(catalog, fallbackModel, configuredDefaultModel);
786
+ if (!fallback) throw error;
787
+ this.logger.warn({ error, modelProvider: requestedProvider, fallbackModel: fallback.id },
788
+ "Provider model discovery failed; using the current or configured model as a fallback.");
789
+ return [fallback];
790
+ }
791
+ }
792
+
793
+ private async listCatalogModels(): Promise<ModelOption[]> {
702
794
  const response = await (await this.client()).request<{
703
795
  data: Array<{
704
796
  id: string;
@@ -724,12 +816,7 @@ export class CodexRuntime implements AgentRuntime {
724
816
  }
725
817
 
726
818
  async listModelProviders(): Promise<ModelProviderOption[]> {
727
- const response = await (await this.client()).request<{ config?: Record<string, unknown> }>(
728
- "config/read",
729
- {},
730
- CONTROL_REQUEST_TIMEOUT_MS,
731
- );
732
- const config = response.config ?? {};
819
+ const config = await this.readConfig();
733
820
  const defaultProvider = stringValue(config.model_provider)?.trim();
734
821
  const configuredProviders = isRecord(config.model_providers) ? config.model_providers : {};
735
822
  const providers = new Map<string, ModelProviderOption>();
@@ -757,6 +844,15 @@ export class CodexRuntime implements AgentRuntime {
757
844
  );
758
845
  }
759
846
 
847
+ private async readConfig(): Promise<Record<string, unknown>> {
848
+ const response = await (await this.client()).request<{ config?: Record<string, unknown> }>(
849
+ "config/read",
850
+ {},
851
+ CONTROL_REQUEST_TIMEOUT_MS,
852
+ );
853
+ return response.config ?? {};
854
+ }
855
+
760
856
  onEvent(listener: (event: RuntimeEvent) => void): () => void {
761
857
  this.listeners.add(listener);
762
858
  return () => this.listeners.delete(listener);
@@ -890,6 +986,18 @@ export class CodexRuntime implements AgentRuntime {
890
986
  turnId: mapped.turnId,
891
987
  lastTokens: mapped.lastTokens,
892
988
  cumulativeTokens: mapped.cumulativeTokens,
989
+ contextTokens: mapped.contextTokens,
990
+ });
991
+ } else if (mapped.kind === "context_compaction") {
992
+ this.emit({
993
+ type: "context_compaction",
994
+ sessionId,
995
+ turnId: mapped.turnId,
996
+ phase: mapped.phase,
997
+ compactionId: mapped.compactionId,
998
+ timestampMs: mapped.timestampMs,
999
+ turnCount: session.turnCount,
1000
+ storageBytes: rolloutStorageBytes(session.rolloutPath),
893
1001
  });
894
1002
  } else if (mapped.kind === "agent_message_phase") {
895
1003
  session.messagePhases.set(mapped.itemId, mapped.phase);
@@ -1005,7 +1113,7 @@ export class CodexRuntime implements AgentRuntime {
1005
1113
  if (input.reasoningEffort) return input.reasoningEffort;
1006
1114
  if (response.reasoningEffort) return response.reasoningEffort;
1007
1115
  const model = input.model ?? response.model;
1008
- const models = await this.listModels();
1116
+ const models = await this.listModels(input.modelProvider ?? response.modelProvider, model);
1009
1117
  return models.find((item) => item.id === model)?.defaultReasoningEffort
1010
1118
  ?? models.find((item) => item.isDefault)?.defaultReasoningEffort;
1011
1119
  }
@@ -1091,6 +1199,10 @@ export class CodexRuntime implements AgentRuntime {
1091
1199
 
1092
1200
  private adoptTurn(session: CodexSession, turnId: string, startedAt: number): void {
1093
1201
  session.canReplaceEmptyThread = false;
1202
+ if (!session.knownTurnIds.has(turnId)) {
1203
+ session.knownTurnIds.add(turnId);
1204
+ if (session.turnCount !== undefined) session.turnCount += 1;
1205
+ }
1094
1206
  session.activeTurnId = turnId;
1095
1207
  session.activeTurnStartedAt = startedAt;
1096
1208
  session.finalText = "";
@@ -1183,6 +1295,9 @@ export class CodexRuntime implements AgentRuntime {
1183
1295
  messagePhases: new Map(),
1184
1296
  needsResume: false,
1185
1297
  canReplaceEmptyThread: !("remoteSessionId" in input),
1298
+ rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1299
+ turnCount: "remoteSessionId" in input ? undefined : 0,
1300
+ knownTurnIds: new Set(),
1186
1301
  };
1187
1302
  }
1188
1303
 
@@ -1241,7 +1356,7 @@ export class CodexRuntime implements AgentRuntime {
1241
1356
  }
1242
1357
 
1243
1358
  interface ThreadResponse {
1244
- thread: { id: string; name?: string | null; preview?: string };
1359
+ thread: { id: string; name?: string | null; preview?: string; path?: string | null };
1245
1360
  modelProvider?: string;
1246
1361
  model?: string;
1247
1362
  reasoningEffort?: string | null;
@@ -1273,6 +1388,7 @@ interface CodexThreadSnapshot {
1273
1388
  recencyAt?: number | null;
1274
1389
  status?: { type?: string };
1275
1390
  forkedFromId?: string | null;
1391
+ path?: string | null;
1276
1392
  turns?: CodexTurnSnapshot[];
1277
1393
  }
1278
1394
 
@@ -1341,6 +1457,34 @@ function stringValue(value: unknown): string | undefined {
1341
1457
  return typeof value === "string" ? value : undefined;
1342
1458
  }
1343
1459
 
1460
+ function rolloutStorageBytes(filePath: string | undefined): number | undefined {
1461
+ if (!filePath) return undefined;
1462
+ try {
1463
+ const stats = statSync(filePath);
1464
+ return stats.isFile() ? stats.size : undefined;
1465
+ } catch {
1466
+ return undefined;
1467
+ }
1468
+ }
1469
+
1470
+ function fallbackProviderModel(
1471
+ catalog: ModelOption[],
1472
+ requestedModel?: string,
1473
+ configuredDefaultModel?: string,
1474
+ ): ModelOption | undefined {
1475
+ const id = requestedModel?.trim()
1476
+ || configuredDefaultModel?.trim()
1477
+ || catalog.find((model) => model.isDefault)?.id
1478
+ || catalog[0]?.id;
1479
+ if (!id) return undefined;
1480
+ const catalogModel = catalog.find((model) => model.id === id);
1481
+ return {
1482
+ ...(catalogModel ?? { id, supportedReasoningEfforts: [] }),
1483
+ id,
1484
+ isDefault: true,
1485
+ };
1486
+ }
1487
+
1344
1488
  function turnStartedAt(turn: CodexTurnSnapshot): number {
1345
1489
  return typeof turn.startedAt === "number" ? turn.startedAt * 1_000 : Date.now();
1346
1490
  }
@@ -0,0 +1,185 @@
1
+ import type { ModelOption, ReasoningEffortOption } from "../runtime/types.js";
2
+
3
+ const PROVIDER_MODEL_LIST_TIMEOUT_MS = 10_000;
4
+
5
+ export interface ProviderModelConfig {
6
+ base_url?: unknown;
7
+ env_key?: unknown;
8
+ experimental_bearer_token?: unknown;
9
+ query_params?: unknown;
10
+ http_headers?: unknown;
11
+ env_http_headers?: unknown;
12
+ default_model?: unknown;
13
+ model?: unknown;
14
+ }
15
+
16
+ export async function fetchProviderModels(input: {
17
+ providerId: string;
18
+ config: ProviderModelConfig;
19
+ catalog: ModelOption[];
20
+ configuredDefaultModel?: string;
21
+ environmentValue: (name: string) => string | undefined;
22
+ }): Promise<ModelOption[]> {
23
+ const baseUrl = stringValue(input.config.base_url)?.trim();
24
+ if (!baseUrl) {
25
+ throw new Error(`Provider ${input.providerId} 没有配置 base_url,无法读取模型列表。`);
26
+ }
27
+
28
+ const url = providerModelsUrl(baseUrl, input.config.query_params, input.providerId);
29
+ const headers = providerModelHeaders(input.config, input.environmentValue);
30
+ let response: Response;
31
+ try {
32
+ response = await fetch(url, {
33
+ method: "GET",
34
+ headers,
35
+ signal: AbortSignal.timeout(PROVIDER_MODEL_LIST_TIMEOUT_MS),
36
+ });
37
+ } catch {
38
+ throw new Error(`无法连接 Provider ${input.providerId} 的模型列表,请检查 Provider 服务和网络。`);
39
+ }
40
+ if (!response.ok) {
41
+ throw new Error(`Provider ${input.providerId} 的模型列表请求失败(HTTP ${response.status})。`);
42
+ }
43
+
44
+ let payload: unknown;
45
+ try {
46
+ payload = await response.json();
47
+ } catch {
48
+ throw new Error(`Provider ${input.providerId} 返回了无效的模型列表。`);
49
+ }
50
+ const models = normalizeProviderModels(payload, input.catalog, {
51
+ configuredDefaultModel: input.configuredDefaultModel,
52
+ providerDefaultModel: stringValue(input.config.default_model)?.trim()
53
+ ?? stringValue(input.config.model)?.trim(),
54
+ });
55
+ if (models.length === 0) {
56
+ throw new Error(`Provider ${input.providerId} 没有返回可用模型。`);
57
+ }
58
+ return models;
59
+ }
60
+
61
+ function providerModelsUrl(baseUrl: string, queryParams: unknown, providerId: string): URL {
62
+ let url: URL;
63
+ try {
64
+ url = new URL(baseUrl);
65
+ } catch {
66
+ throw new Error(`Provider ${providerId} 的 base_url 无效,无法读取模型列表。`);
67
+ }
68
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
69
+ throw new Error(`Provider ${providerId} 的 base_url 不支持模型发现。`);
70
+ }
71
+ const path = url.pathname.replace(/\/+$/u, "");
72
+ if (!/\/models$/iu.test(path)) url.pathname = `${path}/models`;
73
+ if (isRecord(queryParams)) {
74
+ for (const [key, value] of Object.entries(queryParams)) {
75
+ if (!key || value === null || value === undefined || typeof value === "object") continue;
76
+ url.searchParams.set(key, String(value));
77
+ }
78
+ }
79
+ return url;
80
+ }
81
+
82
+ function providerModelHeaders(
83
+ config: ProviderModelConfig,
84
+ environmentValue: (name: string) => string | undefined,
85
+ ): Headers {
86
+ const headers = new Headers({ Accept: "application/json" });
87
+ appendHeaderValues(headers, config.http_headers, (value) => value);
88
+ appendHeaderValues(headers, config.env_http_headers, environmentValue);
89
+ if (!headers.has("authorization")) {
90
+ const envKey = stringValue(config.env_key)?.trim();
91
+ const token = (envKey ? environmentValue(envKey) : undefined)
92
+ ?? stringValue(config.experimental_bearer_token)?.trim();
93
+ if (token) headers.set("Authorization", `Bearer ${token}`);
94
+ }
95
+ return headers;
96
+ }
97
+
98
+ function appendHeaderValues(
99
+ headers: Headers,
100
+ values: unknown,
101
+ resolve: (value: string) => string | undefined,
102
+ ): void {
103
+ if (!isRecord(values)) return;
104
+ for (const [name, rawValue] of Object.entries(values)) {
105
+ const value = stringValue(rawValue)?.trim();
106
+ const resolved = value ? resolve(value)?.trim() : undefined;
107
+ if (name.trim() && resolved) headers.set(name, resolved);
108
+ }
109
+ }
110
+
111
+ function normalizeProviderModels(
112
+ payload: unknown,
113
+ catalog: ModelOption[],
114
+ defaults: { configuredDefaultModel?: string; providerDefaultModel?: string },
115
+ ): ModelOption[] {
116
+ const root = isRecord(payload) ? payload : undefined;
117
+ const entries = Array.isArray(root?.data)
118
+ ? root.data
119
+ : Array.isArray(root?.models)
120
+ ? root.models
121
+ : Array.isArray(payload)
122
+ ? payload
123
+ : [];
124
+ const catalogById = new Map(catalog.map((model) => [model.id, model]));
125
+ const normalized = new Map<string, ModelOption>();
126
+ let itemDefaultModel: string | undefined;
127
+
128
+ for (const entry of entries) {
129
+ const record = isRecord(entry) ? entry : undefined;
130
+ const id = (typeof entry === "string"
131
+ ? entry
132
+ : stringValue(record?.id) ?? stringValue(record?.model) ?? stringValue(record?.name))?.trim();
133
+ if (!id || normalized.has(id)) continue;
134
+ const catalogModel = catalogById.get(id);
135
+ const efforts = reasoningEfforts(record) ?? catalogModel?.supportedReasoningEfforts ?? [];
136
+ const defaultEffort = stringValue(record?.defaultReasoningEffort)?.trim()
137
+ ?? stringValue(record?.default_reasoning_effort)?.trim()
138
+ ?? catalogModel?.defaultReasoningEffort;
139
+ const displayName = stringValue(record?.displayName)?.trim()
140
+ ?? stringValue(record?.display_name)?.trim()
141
+ ?? (stringValue(record?.name)?.trim() !== id ? stringValue(record?.name)?.trim() : undefined)
142
+ ?? catalogModel?.displayName;
143
+ if (!itemDefaultModel && (record?.isDefault === true || record?.is_default === true || record?.default === true)) {
144
+ itemDefaultModel = id;
145
+ }
146
+ normalized.set(id, {
147
+ id,
148
+ ...(displayName ? { displayName } : {}),
149
+ supportedReasoningEfforts: efforts,
150
+ ...(defaultEffort ? { defaultReasoningEffort: defaultEffort } : {}),
151
+ });
152
+ }
153
+
154
+ const rootDefaultModel = stringValue(root?.defaultModel)?.trim()
155
+ ?? stringValue(root?.default_model)?.trim();
156
+ const defaultModel = [rootDefaultModel, itemDefaultModel, defaults.providerDefaultModel, defaults.configuredDefaultModel]
157
+ .find((candidate) => candidate && normalized.has(candidate));
158
+ if (defaultModel) normalized.set(defaultModel, { ...normalized.get(defaultModel)!, isDefault: true });
159
+ return [...normalized.values()];
160
+ }
161
+
162
+ function reasoningEfforts(record: Record<string, unknown> | undefined): ReasoningEffortOption[] | undefined {
163
+ if (!record) return undefined;
164
+ const raw = record.supportedReasoningEfforts ?? record.supported_reasoning_efforts;
165
+ if (!Array.isArray(raw)) return undefined;
166
+ const efforts = raw.flatMap((entry): ReasoningEffortOption[] => {
167
+ if (typeof entry === "string" && entry.trim()) return [{ value: entry.trim() }];
168
+ if (!isRecord(entry)) return [];
169
+ const value = stringValue(entry.value)?.trim()
170
+ ?? stringValue(entry.reasoningEffort)?.trim()
171
+ ?? stringValue(entry.reasoning_effort)?.trim();
172
+ if (!value) return [];
173
+ const description = stringValue(entry.description)?.trim();
174
+ return [{ value, ...(description ? { description } : {}) }];
175
+ });
176
+ return efforts.length > 0 ? efforts : undefined;
177
+ }
178
+
179
+ function isRecord(value: unknown): value is Record<string, unknown> {
180
+ return typeof value === "object" && value !== null && !Array.isArray(value);
181
+ }
182
+
183
+ function stringValue(value: unknown): string | undefined {
184
+ return typeof value === "string" ? value : undefined;
185
+ }
@@ -128,8 +128,13 @@ export class CommandRouter {
128
128
  throw new Error("/mute 只接受 on 或 off;不传参数等同于 /mute on。");
129
129
  }
130
130
  case "turns":
131
- if (args.length > 0) throw new Error("/turns 不接受参数,请在历史轮次卡片中选择 turn。");
132
- return { type: "turns" };
131
+ rejectOptions("/turn", args);
132
+ if (args.length > 1) throw new Error("/turn 只接受一个可选的 Turn ID 或序号。");
133
+ if (args[0] && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(args[0])
134
+ && (!/^\d+$/.test(args[0]) || !Number.isSafeInteger(Number(args[0])) || Number(args[0]) < 1)) {
135
+ throw new Error("Turn 序号必须是从 1 开始的正整数。");
136
+ }
137
+ return args[0] ? { type: "turns", turnReference: args[0] } : { type: "turns" };
133
138
  case "model":
134
139
  return settingsCommand("/model", args, { type: "model" });
135
140
  case "provider":
@@ -21,7 +21,7 @@ export type Command =
21
21
  | { type: "restart"; force?: boolean }
22
22
  | { type: "release" }
23
23
  | { type: "mute"; enabled: boolean }
24
- | { type: "turns" }
24
+ | { type: "turns"; turnReference?: string }
25
25
  | { type: "model" }
26
26
  | { type: "provider" }
27
27
  | { type: "thinking" }
@@ -15,11 +15,14 @@ export class ConsoleTurnPresenter implements TurnPresenter {
15
15
  _taskTitle?: string,
16
16
  _projectCwd?: string,
17
17
  agentLabel?: string,
18
+ _model?: string,
18
19
  ): void {
19
20
  this.contexts.set(sessionId, contextKey);
20
21
  if (agentLabel) this.agentLabels.set(sessionId, agentLabel);
21
22
  }
22
23
 
24
+ updateSessionModel(): void {}
25
+
23
26
  updateSessionTitle(): void {}
24
27
 
25
28
  unregisterSession(sessionId: string): void {
@@ -123,6 +123,14 @@ export interface PermissionSelectorCardView {
123
123
 
124
124
  export type ExecutionSettingsTab = "agent" | "provider" | "model" | "thinking" | "permission";
125
125
 
126
+ const EXECUTION_SETTING_EFFECTS: Record<ExecutionSettingsTab, string> = {
127
+ agent: "只影响新建任务,不切换当前任务。",
128
+ provider: "只影响当前任务,需空闲时切换。",
129
+ model: "只影响当前任务,下一轮生效。",
130
+ thinking: "只影响当前任务,下一轮生效。",
131
+ permission: "只影响当前任务,下一轮生效。",
132
+ };
133
+
126
134
  export interface ExecutionSettingsAgentOption {
127
135
  name: string;
128
136
  title: string;
@@ -158,6 +166,7 @@ export interface TaskListCardAction {
158
166
  text: string;
159
167
  type?: "default" | "primary" | "danger";
160
168
  value: Record<string, string>;
169
+ url?: string;
161
170
  }
162
171
 
163
172
  export interface HelpCardCommand {
@@ -542,7 +551,7 @@ export class CardRenderer {
542
551
 
543
552
  if (view.activeTab === "agent") {
544
553
  elements.push(...view.agents.map((agent) => settingsOptionRow({
545
- label: `${inlineCode(agent.name)} · ${escapeCardHtml(agent.title)}`,
554
+ label: inlineCode(agent.name),
546
555
  current: agent.name === view.currentAgent,
547
556
  action: {
548
557
  text: "Switch",
@@ -562,9 +571,6 @@ export class CardRenderer {
562
571
  elements.push(...view.providers.map((provider) => settingsOptionRow({
563
572
  label: [
564
573
  inlineCode(provider.id),
565
- provider.displayName && provider.displayName !== provider.id
566
- ? ` · ${escapeCardHtml(provider.displayName)}`
567
- : "",
568
574
  provider.isDefault ? " · 默认" : "",
569
575
  ].join(""),
570
576
  current: provider.id === view.currentProvider,
@@ -631,6 +637,7 @@ export class CardRenderer {
631
637
  })));
632
638
  }
633
639
 
640
+ elements.push(markdown(`> ${EXECUTION_SETTING_EFFECTS[view.activeTab]}`));
634
641
  return sectionCard("运行设置", elements, view.notice ? "green" : "blue");
635
642
  }
636
643
 
@@ -1138,7 +1145,7 @@ export class CardRenderer {
1138
1145
  };
1139
1146
  }
1140
1147
 
1141
- renderTurn(state: TurnViewState): Record<string, unknown> {
1148
+ renderTurn(state: TurnViewState, previewUrl?: string): Record<string, unknown> {
1142
1149
  const elements = this.thinkingCardLayout === "timeline"
1143
1150
  ? renderTurnElements(state, "hidden")
1144
1151
  : renderGroupedTurnElements(state, "hidden");
@@ -1149,23 +1156,31 @@ export class CardRenderer {
1149
1156
  type: "danger",
1150
1157
  value: { action: "turn_cancel", sessionId: state.sessionId, turnId: state.turnId },
1151
1158
  });
1159
+ if (previewUrl) footerActions.push(turnPreviewAction(previewUrl));
1152
1160
  elements.push(
1153
1161
  { tag: "hr" },
1154
- taskActionRow(footerActions, renderTurnDuration(state)),
1162
+ taskActionRow(footerActions, renderTurnDuration(state), true),
1155
1163
  );
1156
1164
  } else if (state.status === "completed") {
1157
1165
  footerActions.push({
1158
1166
  text: "Reset",
1159
1167
  value: { action: "turn_reset", sessionId: state.sessionId, turnId: state.turnId },
1160
1168
  });
1169
+ if (previewUrl) footerActions.push(turnPreviewAction(previewUrl));
1161
1170
  elements.push(
1162
1171
  { tag: "hr" },
1163
- taskActionRow(footerActions),
1172
+ taskActionRow(footerActions, undefined, true),
1164
1173
  );
1165
1174
  } else if (state.status === "starting") {
1175
+ if (previewUrl) footerActions.push(turnPreviewAction(previewUrl));
1176
+ elements.push(
1177
+ { tag: "hr" },
1178
+ taskActionRow(footerActions, renderTurnDuration(state), true),
1179
+ );
1180
+ } else if (previewUrl) {
1166
1181
  elements.push(
1167
1182
  { tag: "hr" },
1168
- taskActionRow([], renderTurnDuration(state)),
1183
+ taskActionRow([turnPreviewAction(previewUrl)], renderTurnDuration(state)),
1169
1184
  );
1170
1185
  }
1171
1186
  if (state.status === "completed") {
@@ -1184,11 +1199,15 @@ export class CardRenderer {
1184
1199
  );
1185
1200
  }
1186
1201
 
1187
- renderTurnDetails(state: TurnViewState): Record<string, unknown> {
1202
+ renderTurnDetails(state: TurnViewState, previewUrl?: string): Record<string, unknown> {
1188
1203
  const title = state.taskTitle
1189
1204
  ? `任务执行详情:${truncateText(state.taskTitle.replace(/\s+/g, " ").trim(), 60)}`
1190
1205
  : "任务执行详情";
1191
- return turnCard(title, "blue", renderTurnElements(state, "always"), renderTurnSubtitle(state));
1206
+ const elements = [markdown(`Turn: \`${state.turnId}\``)];
1207
+ if (state.prompt) elements.push(markdown(`**Prompt**\n${truncateText(state.prompt, 1_000)}`));
1208
+ elements.push(...renderTurnElements(state, "always"));
1209
+ if (previewUrl) elements.push(taskActionRow([turnPreviewAction(previewUrl)]));
1210
+ return turnCard(title, "blue", elements, renderTurnSubtitle(state));
1192
1211
  }
1193
1212
 
1194
1213
  renderActivityHistory(state: TurnViewState, requestedPage: number): Record<string, unknown> {
@@ -2274,7 +2293,7 @@ function renderToolDetails(tool: ToolState, projectCwd?: string): string {
2274
2293
  return codeBlock([`$ ${commandText}`, resultText].filter((part): part is string => part !== undefined).join("\n"), 2_003);
2275
2294
  }
2276
2295
 
2277
- function formatShellCommandForDisplay(command: string): string {
2296
+ export function formatShellCommandForDisplay(command: string): string {
2278
2297
  const normalized = command.replace(/\r\n/g, "\n");
2279
2298
  let result = "";
2280
2299
  let quote: "'" | "\"" | undefined;
@@ -2379,7 +2398,7 @@ function toolPanelTitle(tool: ToolState): string {
2379
2398
  const command = stripAnsi(tool.command ?? tool.title).trim();
2380
2399
  const meaningfulCommand = tool.kind === "web_search"
2381
2400
  ? tool.title
2382
- : unwrapShellCommand(command) ?? tool.title;
2401
+ : displayToolCommand(command) ?? tool.title;
2383
2402
  const duration = toolDuration(tool);
2384
2403
  const prefix = `${icon} `;
2385
2404
  const suffix = duration ? ` · ${duration}` : "";
@@ -2451,6 +2470,11 @@ function shellCommandStatus(view: ShellCommandCardView): {
2451
2470
  return { title: "命令执行失败", label: `退出码 ${view.exitCode ?? "未知"}`, template: "red" };
2452
2471
  }
2453
2472
 
2473
+ export function displayToolCommand(command: string): string | undefined {
2474
+ const clean = stripAnsi(command).trim();
2475
+ return unwrapShellCommand(clean);
2476
+ }
2477
+
2454
2478
  function unwrapShellCommand(command: string): string | undefined {
2455
2479
  return unwrapPowerShellCommand(command) ?? unwrapPosixShellCommand(command);
2456
2480
  }
@@ -3007,10 +3031,17 @@ function taskActionElement(action: TaskListCardAction): Record<string, unknown>
3007
3031
  elements: [markdown(
3008
3032
  `<font color='${action.type === "danger" ? "red" : "blue"}'>${escapeCardActionText(action.text)}</font>`,
3009
3033
  )],
3010
- behaviors: [{
3011
- type: "callback",
3012
- value: action.value,
3013
- }],
3034
+ behaviors: action.url
3035
+ ? [{ type: "open_url", default_url: action.url }]
3036
+ : [{ type: "callback", value: action.value }],
3037
+ };
3038
+ }
3039
+
3040
+ function turnPreviewAction(previewUrl: string): TaskListCardAction {
3041
+ return {
3042
+ text: "Preview",
3043
+ value: {},
3044
+ url: previewUrl,
3014
3045
  };
3015
3046
  }
3016
3047