diff --git a/README.md b/README.md index 73961d4951..f84666edb3 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ harnesses, models and tools into one agent that finishes real work in your projects and apps. Ready from day one, yours to shape over time. Cindy runs locally on your own machine, using your real files and logged-in -apps. The first supported harnesses are **Claude Code** and **Codex** — more are -being added, and a native harness is in the works. Models and harnesses mix +apps. The first supported harnesses are **Claude Code**, **Codex**, and **Pi**. **Grok Build** is an optional local harness when the `grok` CLI is on PATH. Models and harnesses mix freely and can switch mid-task while your workspace, memory, skills and tools stay continuous; one task can even be planned, executed in parallel, and reviewed by agents on different harness × model combos. She can drive your diff --git a/README.zh-CN.md b/README.zh-CN.md index 4f1caabe7f..88a50e4565 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,8 +26,7 @@ Cindy 是一个开源、开箱即用的 AI Agent。她把多套 Harness、模型 持续成长的伙伴,在真实工程和软件里把任务做完。一开始就好用,任你打扮,任你培养。 Cindy 运行在你自己的电脑上,使用你本地的文件和已登录的应用。首批兼容 -**Claude Code** 与 **Codex** 两套 Agent Harness——更多 Harness 正在接入,自研 -Harness 也在酝酿。模型与 Harness 自由组合、同一任务中随时切换,工作现场、记忆、 +**Claude Code**、**Codex** 与 **Pi** 三套 Agent Harness;本机 PATH 上有 `grok` 时还可选 **Grok Build**。模型与 Harness 自由组合、同一任务中随时切换,工作现场、记忆、 Skill 和工具始终连续;一个任务还可以由不同 Harness × 模型组合的多个 agent 规划、并行执行、独立 review。她能操作浏览器、电脑和手机,并支持从 IM 和 定时任务派活。 diff --git a/apps/desktop/src/main/im/defaultSessionSettings.ts b/apps/desktop/src/main/im/defaultSessionSettings.ts index 758badc031..090dd3bdb4 100644 --- a/apps/desktop/src/main/im/defaultSessionSettings.ts +++ b/apps/desktop/src/main/im/defaultSessionSettings.ts @@ -25,6 +25,8 @@ import { import { IM_DEFAULT_EFFORT_OVERRIDES, IM_DEFAULT_SETTINGS, + isImDefaultAgentKind, + type ImDefaultAgentKind, type ImDefaultAgentSettings, type ImDefaultSettingsChannel, } from '../../shared/imDefaultSettings.js'; @@ -72,7 +74,10 @@ export async function resolveImSessionDefaults( const requestedSettings = raw.agents[requestedAgent]; const model = pickModel(requestedAgent, requestedSettings, config, providers); const agentKind = model.agentKind; - const agentSettings = raw.agents[agentKind] ?? requestedSettings; + // IM 默认设置只为可选的三个 agent 存 per-agent 拷贝;渠道配置把会话落到目录之外的 + // agent(grok-build)时,沿用请求 agent 的那份,来源/档位随后仍按落地模型 reconcile。 + const agentSettings = + (isImDefaultAgentKind(agentKind) ? raw.agents[agentKind] : undefined) ?? requestedSettings; // 先定来源再定 effort:effort 支持是 per-(来源, 模型) 的,保存的来源被停用改道后, // 必须按**最终落地来源**的拷贝 reconcile —— 按第一份 connected 拷贝(可能正是那份 // 停用拷贝)算出的档位,启用替代来源未必支持,直建会话会被上游拒 @@ -122,7 +127,8 @@ export async function resolveDefaultProviderIdForModel( } function pickModel( - requestedAgent: AgentKind, + // 请求 agent 恒来自 IM 默认设置(三选一);兜底才可能落到渠道配置的其它 agent。 + requestedAgent: ImDefaultAgentKind, settings: ImDefaultAgentSettings, config: ImOrchestratorConfig, providers: ProviderView[] | null, diff --git a/apps/desktop/src/main/localDb/chatHistoryReader.ts b/apps/desktop/src/main/localDb/chatHistoryReader.ts index 39eb8d49d7..90c11ead71 100644 --- a/apps/desktop/src/main/localDb/chatHistoryReader.ts +++ b/apps/desktop/src/main/localDb/chatHistoryReader.ts @@ -29,7 +29,8 @@ const messageRowid = sql`"messages"."rowid"`; // ── Types ─────────────────────────────────────────────────────────────────── export type HistoryOrder = 'asc' | 'desc'; -export type HistoryAgentKind = 'cc' | 'codex' | 'pi'; +/** sessions.agent_kind 的历史筛选取值;'cc' 是 Claude Code 的历史存储形态。 */ +export type HistoryAgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; export interface HistoryCursor { createdAt: number; // unix ms diff --git a/apps/desktop/src/main/localDb/chatHistorySearch.ts b/apps/desktop/src/main/localDb/chatHistorySearch.ts index 33f7d2139b..7d623ba297 100644 --- a/apps/desktop/src/main/localDb/chatHistorySearch.ts +++ b/apps/desktop/src/main/localDb/chatHistorySearch.ts @@ -37,6 +37,7 @@ import type { HistoryRole, } from '@cindy/mcps'; +import type { HistoryAgentKind } from './chatHistoryReader'; import { getDbClient } from './client/current'; import { messages as messagesTable, sessions as sessionsTable } from './schema'; import { messageToCamel } from './mapper'; @@ -86,7 +87,12 @@ interface HitMeta { type SearchSessionStatus = 'active' | 'archived' | 'deleted'; -interface SearchChatHistoryEngineArgs extends SearchChatHistoryArgs { +interface SearchChatHistoryEngineArgs extends Omit { + /** + * 桌面端会话搜索可按 grok-build 过滤;MCP wire 契约的 agentKind 只有三种,是这里的 + * 真子集,工具层照旧直接传。过滤值就是 sessions.agent_kind 的存储形态。 + */ + agentKind: HistoryAgentKind | null; /** * Optional host-side filters for product entry points that should only expose * desktop-visible conversations. MCP callers omit these and keep the original diff --git a/apps/desktop/src/main/localDb/ipc/history.ts b/apps/desktop/src/main/localDb/ipc/history.ts index e22a700159..7edeabaa6b 100644 --- a/apps/desktop/src/main/localDb/ipc/history.ts +++ b/apps/desktop/src/main/localDb/ipc/history.ts @@ -21,7 +21,7 @@ import { sessions } from '../schema'; import { readLatestSessionTerminal, type SessionTerminalHint } from '../sessionTerminal'; import { requireObject, requireString, throwIpcError } from '../../utils/ipcValidate'; -const VALID_AGENT_KINDS: readonly HistoryAgentKind[] = ['cc', 'codex', 'pi']; +const VALID_AGENT_KINDS: readonly HistoryAgentKind[] = ['cc', 'codex', 'pi', 'grok-build']; const VALID_ORDERS: readonly HistoryOrder[] = ['asc', 'desc']; const VALID_ROLES: readonly HistoryRole[] = [ 'user', diff --git a/apps/desktop/src/main/localDb/ipc/messages.ts b/apps/desktop/src/main/localDb/ipc/messages.ts index d8e1033336..9e507dd3e1 100644 --- a/apps/desktop/src/main/localDb/ipc/messages.ts +++ b/apps/desktop/src/main/localDb/ipc/messages.ts @@ -21,6 +21,7 @@ import { extractMessagePreview, } from '../mapper'; import { throwIpcError, requireString } from '../../utils/ipcValidate'; +import type { DbAgentKind } from '../../../shared/agentKindConversion'; import * as broadcastTap from '../../device-link/broadcast-tap'; import { createLogger } from '../../logger'; import { collectCindyMediaHashes, commitMessageMediaRefs } from '../../cindy-media/chatAttachments'; @@ -1001,7 +1002,7 @@ export async function commitContextRebuild( meta: { reason: 'context-overflow' | 'pi-prompt-timeout'; sourceUserClientId: string | null; - sourceAgentKind?: 'cc' | 'codex' | 'pi'; + sourceAgentKind?: DbAgentKind; sourceModel?: string | null; sourceProviderId?: string | null; expectedClearedAt?: number | null; @@ -1418,7 +1419,7 @@ export async function createMessage( * agentMeta 需要它;main 侧 SDK 事件落库路径必传,renderer pending echo 等 * 无 SDK 元信息的行留空(null 回落 session.agentKind)。 */ - agentKind?: 'cc' | 'codex' | 'pi' | null; + agentKind?: DbAgentKind | null; createdAt?: number; }, opts?: { @@ -2511,7 +2512,7 @@ export interface ParkedEngineSession { */ export async function findParkedEngineSession( sessionId: string, - targetDbKind: 'cc' | 'codex' | 'pi', + targetDbKind: DbAgentKind, ): Promise { const db = getDbClient().drizzle; const [sessRow] = await db diff --git a/apps/desktop/src/main/localDb/ipc/sessions.ts b/apps/desktop/src/main/localDb/ipc/sessions.ts index 3188a7ab6e..b1e3b02ea9 100644 --- a/apps/desktop/src/main/localDb/ipc/sessions.ts +++ b/apps/desktop/src/main/localDb/ipc/sessions.ts @@ -33,7 +33,12 @@ import { buildSessionListFlightKey, runSessionListSingleFlight } from './session import { throwIpcError, requireString, requireObject } from '../../utils/ipcValidate'; import { bindDeletedPiSubagentCleanupCancel } from './piSubagentDeletion'; import { resolveBusinessSessionId } from '../../sessionIds'; -import { normalizeDbAgentKind } from '../../../shared/agentKindConversion'; +import { + dbToMakerAgentKind, + normalizeDbAgentKind, + type DbAgentKind, + type MakerAgentKindWire, +} from '../../../shared/agentKindConversion'; import { sessionToCamel, sessionCreateToRow, @@ -440,7 +445,7 @@ const REMOTE_PERSIST_FIELDS = new Set([ export async function applyAgentSwitchToSessionRow( sessionId: string, patch: { - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: DbAgentKind; model: string; providerId: string | null | undefined; sdkSessionId?: string | null; @@ -863,7 +868,7 @@ export interface OverwritableAutoTitleTarget { * `reconcileCreateOptsAgainstDb` 处理的正是同一类漂移),用错 agent 会让标题 * 走错供应商 —— 纯 Codex / 纯 Claude 用户会因此只拿到 fallback 标题。 */ - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: MakerAgentKindWire; /** * 是否仍停在建会话时的裸默认标题。合成占位(纯附件消息)只允许覆写这一种 —— * fork 占位与上一条附件写下的合成占位都要保留到用户真正打字为止。 @@ -878,8 +883,9 @@ export async function getOverwritableAutoTitle( const db = getDbClient().drizzle; const row = await selectSessionWithCount(db, id); if (!row) return null; - const agentKind = - row.agentKind === 'codex' || row.agentKind === 'pi' ? row.agentKind : 'claude-code'; + // 走映射正本:就地 ternary 会把 'cc' 之外的新引擎(grok-build)误判成 claude-code, + // 起名就跑去了错的供应商(见上方 agentKind 注释)。 + const agentKind = dbToMakerAgentKind(row.agentKind); const overwritable = row.title === DEFAULT_DRAFT_SESSION_TITLE || (!!row.parentSessionId && row.title.startsWith(FORK_PLACEHOLDER_TITLE_PREFIX)) || diff --git a/apps/desktop/src/main/localDb/mapper.ts b/apps/desktop/src/main/localDb/mapper.ts index 3a7bfd6c64..d66ea87e81 100644 --- a/apps/desktop/src/main/localDb/mapper.ts +++ b/apps/desktop/src/main/localDb/mapper.ts @@ -41,6 +41,7 @@ import type { ScriptCapability, PreRunHookRunResult, } from '@cindy/maker-scheduler'; +import type { DbAgentKind } from '../../shared/agentKindConversion.js'; import { normalizeSessionSource } from '../../shared/sessionSource.js'; import { normalizeWorkingDirForStorage } from '../../shared/workingDir.js'; import { isSyntheticTriggerText } from '../../shared/interruptedTurn.js'; @@ -435,7 +436,7 @@ export function messageCreateToRow( content: unknown; toolUseId?: string; agentMeta?: AgentMeta | null; - agentKind?: 'cc' | 'codex' | 'pi' | null; + agentKind?: DbAgentKind | null; createdAt?: number; }, now: number, diff --git a/apps/desktop/src/main/localDb/schema.ts b/apps/desktop/src/main/localDb/schema.ts index cc7f6c7076..bc0dde4c5f 100644 --- a/apps/desktop/src/main/localDb/schema.ts +++ b/apps/desktop/src/main/localDb/schema.ts @@ -446,7 +446,7 @@ export const subagentRuns = sqliteTable( sessionId: text('session_id') .notNull() .references((): AnySQLiteColumn => sessions.id, { onDelete: 'cascade' }), - provider: text('provider', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + provider: text('provider', { enum: ['claude-code', 'codex', 'pi', 'grok-build'] }).notNull(), logicalAgentId: text('logical_agent_id').notNull(), parentToolUseId: text('parent_tool_use_id'), /** JSON string[] containing task/tool aliases observed for this logical child. */ @@ -516,7 +516,7 @@ export const subagentRunAliases = sqliteTable( sessionId: text('session_id') .notNull() .references((): AnySQLiteColumn => sessions.id, { onDelete: 'cascade' }), - provider: text('provider', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + provider: text('provider', { enum: ['claude-code', 'codex', 'pi', 'grok-build'] }).notNull(), alias: text('alias').notNull(), runId: text('run_id') .notNull() @@ -866,7 +866,7 @@ export const schedules = sqliteTable( * 引擎 fireOne 优先用 intervalMs 算 nextFireAt;旧 cron 数据 0015 migration 自动回填。 */ intervalMs: integer('interval_ms'), - agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi', 'grok-build'] }).notNull(), model: text('model'), /** * 显式选定的供应商(来源)id。NULL = 回落该 agent 原生默认来源(no-break, @@ -985,7 +985,7 @@ export const sessionGoals = sqliteTable( /** usageLimited 时记录的限额重置时刻(unix ms);到点自动续跑。其它状态为 null。 */ usageResetAt: integer('usage_reset_at'), lastReason: text('last_reason'), - agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi', 'grok-build'] }).notNull(), startedAt: integer('started_at').notNull(), updatedAt: integer('updated_at').notNull(), }, diff --git a/apps/desktop/src/main/maker-host/active-catalog.ts b/apps/desktop/src/main/maker-host/active-catalog.ts index bae127d842..0d52bf6105 100644 --- a/apps/desktop/src/main/maker-host/active-catalog.ts +++ b/apps/desktop/src/main/maker-host/active-catalog.ts @@ -154,8 +154,13 @@ export interface XdGatewayModelInfo { /** AIGateway 缓存 token 单价(per token);参与「免费」判定与价格展示。 */ cacheReadInputTokenCost?: number; cacheCreationInputTokenCost?: number; - /** 进哪些 runtime tab;v3 由服务端完整下发。 */ - agents?: AgentKind[]; + /** + * 进哪些 runtime tab;v3 由服务端完整下发。这里跟 shared/modelAccess 的 + * `ModelAccessGatewayModel.agents` 逐字对齐(同一份服务端协议):网关目录只服务 + * cc / codex / pi 三个 tab,grok-build 是本机 CLI、自带唯一内置模型,不进网关目录, + * 所以**不能**写成 `AgentKind[]` —— 那样两边协议类型会漂移。 + */ + agents?: ('claude-code' | 'codex' | 'pi')[]; name?: string; group?: string; description?: string; @@ -545,8 +550,9 @@ function modelRegistryMetaFields( modelId: string, ): RegistryMetaFields | undefined { // 模型 registry 的路由与 perAgent 覆盖只按 claude-code / codex 建键;Pi 是动态 BYOM, - // 无 registry per-agent 覆盖,按 agent 无关处理(取条目基线元数据)。 - const registryAgent = agent === 'pi' ? undefined : agent; + // 无 registry per-agent 覆盖,按 agent 无关处理(取条目基线元数据)。Grok Build + // 只有一个内置模型、不进模型平面,同样按 agent 无关处理。 + const registryAgent = agent === 'pi' || agent === 'grok-build' ? undefined : agent; const catalog = base ?? BUNDLED_CATALOG; const matched = findModelRegistryRoute(catalog.modelRegistry, providerId, modelId, registryAgent); if (!matched) return undefined; diff --git a/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts b/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts index 949b96ac02..71b2ec3179 100644 --- a/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts +++ b/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts @@ -53,6 +53,17 @@ interface SeenModelProjection { includesUserProvider: boolean; } +/** ModelDescriptor.newSessionDefault 的元素类型(maker-core 只为三个 wire agent 记种子)。 */ +type NewSessionDefaultAgent = NonNullable[number]; + +/** + * grok-build 不进新对话默认种子:它只有一个内置模型、不由目录供货,目录里出现该标记 + * 只能是脏数据。这里丢弃而不是投影,避免下游按不存在的目录默认改路由。 + */ +function isNewSessionDefaultAgent(agent: AgentKind): agent is NewSessionDefaultAgent { + return agent !== 'grok-build'; +} + /** CatalogModel → ModelDescriptor。仅透传 ModelDescriptor 需要的字段;可选字段缺省时不写键。 */ function toDescriptor( m: CatalogModel, @@ -92,7 +103,9 @@ function toDescriptor( if (m.defaultEnabled !== undefined) d.defaultEnabled = m.defaultEnabled; // 新对话默认种子标记要透传:渲染层 getDefaultModelForVendor 据它优先选中被标记的模型。 // v3 可携带 Pi 自己的标记;消费端按 Agent 严格解释,不跨 Agent 借用默认策略。 - if (m.newSessionDefault !== undefined) d.newSessionDefault = m.newSessionDefault; + if (m.newSessionDefault !== undefined) { + d.newSessionDefault = m.newSessionDefault.filter(isNewSessionDefaultAgent); + } if (m.cost !== undefined) d.cost = m.cost; if (m.maxOutput !== undefined) d.maxOutputTokens = m.maxOutput; const supportsImageInput = @@ -131,6 +144,7 @@ function mergeNewSessionDefaultMarker( next: ModelDescriptor, agent: AgentKind, ): ModelDescriptor { + if (!isNewSessionDefaultAgent(agent)) return first; const hasNewMarker = next.newSessionDefault?.includes(agent) === true && first.newSessionDefault?.includes(agent) !== true; diff --git a/apps/desktop/src/main/maker-host/grok-build-host.ts b/apps/desktop/src/main/maker-host/grok-build-host.ts new file mode 100644 index 0000000000..00d4ce7137 --- /dev/null +++ b/apps/desktop/src/main/maker-host/grok-build-host.ts @@ -0,0 +1,124 @@ +/** + * Grok Build desktop host — PATH detect, ACP auth probe, optional Maker registration. + * + * Unlike Pi, there is no CDN binary pin and no Cindy gateway endpoint. Missing + * `grok` returns null so Claude Code / Codex / Pi keep working. + * + * Auth is grok CLI login or XAI_API_KEY. This module never reads ~/.grok/auth.json + * and never reuses SuperGrok OAuth (`grok-oauth-login.ts`). + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { + GrokBuildAgent, + probeGrokBuildAcp, + resolveGrokBinaryFromPath, + type AuthAdapter, + type AuthState, + type Logger, +} from '@cindy/maker-core'; + +import hostSystemPrompt from './host-system-prompt.md?raw'; + +const execFileAsync = promisify(execFile); + +function createLogger(base: Logger): Logger { + return base.child('grok-build-host'); +} + +export function resolveGrokBuildBinaryPath(): string | null { + return resolveGrokBinaryFromPath(); +} + +class DesktopGrokBuildAuthAdapter implements AuthAdapter { + constructor( + private readonly binaryPath: string, + private readonly logger: Logger, + ) {} + + async getState(): Promise { + if (process.env.XAI_API_KEY && process.env.XAI_API_KEY.trim().length > 0) { + return { authenticated: true, identity: 'XAI_API_KEY', authSource: 'api-key' }; + } + const probe = await probeGrokBuildAcp({ + binaryPath: this.binaryPath, + logger: this.logger, + env: process.env, + }); + if (probe.status === 'ready') { + return { + authenticated: true, + identity: probe.identity ?? 'grok', + authSource: 'oauth', + }; + } + return { + authenticated: false, + errorReason: probe.errorReason ?? probe.status, + authSource: 'oauth', + }; + } + + async triggerLogin(): Promise { + this.logger.info('spawning grok login'); + try { + await execFileAsync(this.binaryPath, ['login'], { + timeout: 15 * 60_000, + env: process.env, + }); + } catch (err) { + this.logger.warn('grok login failed', { + error: err instanceof Error ? err.message : String(err), + }); + } + return this.getState(); + } + + async logout(): Promise { + this.logger.info('spawning grok logout'); + try { + await execFileAsync(this.binaryPath, ['logout'], { + timeout: 30_000, + env: process.env, + }); + } catch (err) { + this.logger.warn('grok logout failed', { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + async getAuthEnv(): Promise> { + const key = process.env.XAI_API_KEY?.trim(); + return key ? { XAI_API_KEY: key } : {}; + } +} + +export function buildGrokBuildAgent(opts: { + logger: Logger; + reviewAutoPermissionAction?: ConstructorParameters[0]['reviewAutoPermissionAction']; + registerLocalAgentProcess?: ConstructorParameters[0]['registerLocalAgentProcess']; +}): GrokBuildAgent | null { + const log = createLogger(opts.logger); + const binaryPath = resolveGrokBuildBinaryPath(); + if (!binaryPath) { + log.info('grok binary not on PATH; grok-build agent disabled for this launch'); + return null; + } + log.info('grok-build agent enabled', { binaryPath }); + return new GrokBuildAgent({ + auth: new DesktopGrokBuildAuthAdapter(binaryPath, log), + runtimeConfig: { systemPrompt: hostSystemPrompt.trim() }, + binaryPath, + logger: log, + reviewAutoPermissionAction: opts.reviewAutoPermissionAction, + registerLocalAgentProcess: opts.registerLocalAgentProcess, + capabilityAdditions: { + availableModels: [ + { id: 'grok-build', displayName: 'Grok Build', contextWindow: 0, efforts: [], defaultEffort: null }, + ], + }, + }); +} diff --git a/apps/desktop/src/main/maker-host/index.ts b/apps/desktop/src/main/maker-host/index.ts index 000d50d07e..f88be35292 100644 --- a/apps/desktop/src/main/maker-host/index.ts +++ b/apps/desktop/src/main/maker-host/index.ts @@ -111,6 +111,7 @@ import { resolveVerifiedContextWindow, } from './catalog-to-descriptors.js'; import { buildPiAgent } from './pi-host.js'; +import { buildGrokBuildAgent } from './grok-build-host.js'; import { clearChatgptBridgeCredentialCache } from './anthropic-responses-bridge-host.js'; import { getDesktopSelectableCatalog, @@ -2057,11 +2058,18 @@ export function getMaker(): Maker { }, }); + const grokBuildAgent = buildGrokBuildAgent({ + logger: desktopMakerLogger, + reviewAutoPermissionAction, + registerLocalAgentProcess: ({ pid, kind, role }) => registerAgentProcess(pid, kind, role), + }); + _maker = new Maker({ agents: { 'claude-code': claudeAgent, codex: codexAgent, ...(piAgent ? { pi: piAgent } : {}), + ...(grokBuildAgent ? { 'grok-build': grokBuildAgent } : {}), }, storage: desktopSessionStorage, logger: desktopMakerLogger, diff --git a/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts b/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts index 3be4b9747e..9556cfb653 100644 --- a/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts +++ b/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts @@ -113,6 +113,9 @@ export function isRegistryTombstoneForConsumer( if (agent === 'pi') { return piRegistryMatch(registry, providerId, modelId)?.entry.status === 'retired'; } + // Grok Build is outside the model plane: it ships one built-in model and no provider + // routing, so no Registry entry can ever name it and nothing can be tombstoned for it. + if (agent === 'grok-build') return false; const registryAgent = policy.roots.includes(agent) || policy.membershipGatedBridges.includes(agent) ? agent : null; if (!registryAgent) return false; diff --git a/apps/desktop/src/main/maker-host/model-route-guard-live.ts b/apps/desktop/src/main/maker-host/model-route-guard-live.ts index a86c719c6f..8f0420cf64 100644 --- a/apps/desktop/src/main/maker-host/model-route-guard-live.ts +++ b/apps/desktop/src/main/maker-host/model-route-guard-live.ts @@ -319,6 +319,8 @@ const DEFAULT_ONESHOT_MODEL: Record = { codex: 'gpt-5.4-mini', // pi oneShot 未实现(BaseAgent 默认抛 NotSupported);占位与 claude 同款网关小模型。 pi: 'claude-haiku-4-5', + // grok-build oneShot 未实现;占位用内置模型 id。 + 'grok-build': 'grok-build', }; /** diff --git a/apps/desktop/src/main/maker-host/session-storage.ts b/apps/desktop/src/main/maker-host/session-storage.ts index 90640ef959..66efc25f72 100644 --- a/apps/desktop/src/main/maker-host/session-storage.ts +++ b/apps/desktop/src/main/maker-host/session-storage.ts @@ -12,7 +12,11 @@ import { and, eq, inArray } from 'drizzle-orm'; -import { dbToMakerAgentKind, makerToDbAgentKind } from '../../shared/agentKindConversion.js'; +import { + dbToMakerAgentKind, + makerToDbAgentKind, + type DbAgentKind, +} from '../../shared/agentKindConversion.js'; import type { AgentKind, @@ -27,8 +31,6 @@ import { normalizeRemoteHostId } from '../localDb/mapper.js'; import { DESKTOP_VISIBLE_SESSION_SOURCES } from '../../shared/sessionSource.js'; import { normalizeWorkingDirForStorage } from '../../shared/workingDir.js'; -type DbAgentKind = 'cc' | 'codex' | 'pi'; - // 形态映射走 shared/agentKindConversion 正本(支持 pi;此前 pi 被误落成 codex)。 function toDbKind(k: AgentKind): DbAgentKind { return makerToDbAgentKind(k); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/agentKindGate.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/agentKindGate.test.ts new file mode 100644 index 0000000000..10ec20ccdf --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/agentKindGate.test.ts @@ -0,0 +1,93 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + AGENT_KINDS, + DRAFT_AGENT_KINDS, + isAgentKind, + requireAgentKind, + requireDraftAgentKind, +} from '../agentKindGate'; + +const registerSource = readFileSync(resolve(__dirname, '..', 'register.ts'), 'utf8'); + +/** 取某个 wire channel 的 handler 源码片段(到下一个 ipcMain.handle 为止)。 */ +function handlerSource(channel: string): string { + const start = registerSource.indexOf(`MAKER_INVOKE.${channel},`); + expect(start, `${channel} handler not found in register.ts`).toBeGreaterThan(-1); + const next = registerSource.indexOf('ipcMain.handle(', start); + return registerSource.slice(start, next > start ? next : registerSource.length); +} + +/** 会话面 wire 入口:grok-build 会话要靠它们拿能力、命令、技能、@ 资源与定制。 */ +const SESSION_FACING_CHANNELS = [ + 'GET_CAPABILITIES', + 'LIST_AGENT_COMMANDS', + 'LIST_AGENT_SKILLS', + 'SCAN_AT_RESOURCES', + 'LIST_CUSTOMIZATIONS', +] as const; + +/** New Maker 草稿面 wire 入口:只有三个 vendor 有草稿槽。 */ +const DRAFT_FACING_CHANNELS = ['GET_NEW_MAKER_DEFAULTS', 'APPLY_NEW_MAKER_DRAFT_PREF'] as const; + +describe('agentKind IPC gate', () => { + it('accepts every AgentKind including Grok Build at the session-facing gate', () => { + expect([...AGENT_KINDS].sort()).toEqual(['claude-code', 'codex', 'grok-build', 'pi']); + for (const kind of AGENT_KINDS) { + expect(requireAgentKind(kind)).toBe(kind); + expect(isAgentKind(kind)).toBe(true); + } + }); + + it('rejects values that are not agent kinds', () => { + // 'grok' 是 xAI catalog provider 名,harness 的 UI vendor 是 'grok-build'。 + for (const bogus of ['grok', 'cc', 'Codex', '', undefined, null, 42, {}]) { + expect(() => requireAgentKind(bogus)).toThrow('[INVALID_PARAMS]'); + expect(isAgentKind(bogus)).toBe(false); + } + }); + + it('keeps the draft gate on the three vendors that own a New Maker draft slot', () => { + expect(DRAFT_AGENT_KINDS).toEqual(['claude-code', 'codex', 'pi']); + for (const kind of DRAFT_AGENT_KINDS) { + expect(requireDraftAgentKind(kind)).toBe(kind); + } + expect(() => requireDraftAgentKind('grok-build')).toThrow('[INVALID_PARAMS]'); + // 草稿 pref 的字段叫 agent,报错要指回调用方的参数名。 + expect(() => requireDraftAgentKind('grok-build', 'agent')).toThrow('invalid agent: grok-build'); + }); + + it('routes the session-facing register.ts channels through the full-union gate', () => { + expect(registerSource).toContain( + "import { requireAgentKind, requireDraftAgentKind } from './agentKindGate.js';", + ); + const authSource = readFileSync(resolve(__dirname, '..', 'authHandlers.ts'), 'utf8'); + expect(authSource).toContain("import { requireAgentKind } from './agentKindGate.js';"); + expect(authSource).not.toContain('const AGENT_KINDS'); + // 本地再定义一份就会遮蔽共享 helper,闸门会重新与 AgentKind 漂移。 + expect(registerSource).not.toContain('function requireAgentKind('); + for (const channel of SESSION_FACING_CHANNELS) { + const handler = handlerSource(channel); + expect(handler, `${channel} must use the full agentKind gate`).toContain('requireAgentKind('); + expect(handler, `${channel} must not use the draft-only gate`).not.toContain( + 'requireDraftAgentKind(', + ); + } + }); + + it('keeps the New Maker draft channels on the draft-only gate', () => { + for (const channel of DRAFT_FACING_CHANNELS) { + const handler = handlerSource(channel); + expect(handler, `${channel} must use the draft-only gate`).toContain( + 'requireDraftAgentKind(', + ); + expect( + handler.replace(/requireDraftAgentKind\(/g, ''), + `${channel} must not fall back to the full gate`, + ).not.toContain('requireAgentKind('); + } + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts index 473308efff..04fb40fc0e 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts @@ -36,6 +36,75 @@ describe('maker auth IPC handlers', () => { expect(getAgentAuthState).toHaveBeenCalledWith('pi'); }); + it('accepts Grok Build across the auth IPC boundary so the unauthenticated modal can run grok login', async () => { + const harness = new IpcHarness(); + const broadcast = vi.fn(); + const getAgentAuthState = vi.fn().mockResolvedValue({ authenticated: false }); + const triggerAgentLogin = vi.fn().mockResolvedValue({ authenticated: true }); + const logoutAgent = vi.fn().mockResolvedValue(undefined); + + registerMakerAuthHandlers( + harness, + createMakerStub({ getAgentAuthState, triggerAgentLogin, logoutAgent }), + broadcast, + () => null, + ); + + await expect(harness.invoke(MAKER_INVOKE.AUTH_GET_STATE, 'grok-build')).resolves.toEqual({ + authenticated: false, + }); + expect(getAgentAuthState).toHaveBeenCalledWith('grok-build'); + + await expect(harness.invoke(MAKER_INVOKE.AUTH_TRIGGER_LOGIN, 'grok-build')).resolves.toEqual({ + authenticated: true, + }); + expect(triggerAgentLogin).toHaveBeenCalledWith( + 'grok-build', + expect.objectContaining({ mode: 'browser' }), + ); + + await expect(harness.invoke(MAKER_INVOKE.AUTH_LOGOUT, 'grok-build')).resolves.toBeUndefined(); + expect(logoutAgent).toHaveBeenCalledWith('grok-build'); + expect(broadcast).toHaveBeenCalledWith(MAKER_PUSH.AUTH_STATE_CHANGED, { + agentKind: 'grok-build', + authenticated: false, + }); + }); + + it('keeps device-code login and owner tokens Codex-only for Grok Build', async () => { + const harness = new IpcHarness(); + const triggerAgentLogin = vi.fn(); + registerMakerAuthHandlers(harness, createMakerStub({ triggerAgentLogin }), vi.fn(), () => null); + + await expect( + harness.invoke(MAKER_INVOKE.AUTH_TRIGGER_LOGIN, 'grok-build', { mode: 'device-code' }), + ).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); + await expect( + harness.invokeFrom(202, MAKER_INVOKE.AUTH_TRIGGER_LOGIN, 'grok-build', { + ownerId: 'window-1', + }), + ).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); + await expect( + harness.invokeFrom(202, MAKER_INVOKE.AUTH_CANCEL_LOGIN, 'grok-build', { + releaseOwner: true, + ownerId: 'window-1', + }), + ).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); + expect(triggerAgentLogin).not.toHaveBeenCalled(); + }); + + it('still rejects a non-agent kind at the auth IPC boundary', async () => { + const harness = new IpcHarness(); + const getAgentAuthState = vi.fn(); + registerMakerAuthHandlers(harness, createMakerStub({ getAgentAuthState }), vi.fn(), () => null); + + // 'grok' 是 xAI catalog provider 名,不是 harness 的 UI vendor。 + await expect(harness.invoke(MAKER_INVOKE.AUTH_GET_STATE, 'grok')).rejects.toMatchObject({ + code: 'INVALID_PARAMS', + }); + expect(getAgentAuthState).not.toHaveBeenCalled(); + }); + it('normalizes login progress and broadcasts final auth state', async () => { const harness = new IpcHarness(); const broadcast = vi.fn(); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts index 0df4cef812..640884f964 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts @@ -22,7 +22,7 @@ const WORKER_SESSION_ID = '123e4567-e89b-42d3-a456-426614174000'; describe('buildNoProviderMessage (pi first-class)', () => { const snap = (name: string): OrcaWorkerProviderSnapshot => ({ name }) as OrcaWorkerProviderSnapshot; it('names Pi (not Claude Code) when pi has no connected provider', () => { - const msg = buildNoProviderMessage('pi', { 'claude-code': [], codex: [], pi: [] }); + const msg = buildNoProviderMessage('pi', { 'claude-code': [], codex: [], pi: [], 'grok-build': [] }); expect(msg).toContain('Pi 当前没有可用的模型供应商'); expect(msg).not.toContain('Claude Code 当前没有'); }); @@ -31,6 +31,7 @@ describe('buildNoProviderMessage (pi first-class)', () => { 'claude-code': [], codex: [], pi: [snap('Cindy AI')], + 'grok-build': [], }); expect(msg).toContain('Pi(已连接:Cindy AI)'); }); @@ -59,6 +60,7 @@ function providerRoutingContext( 'claude-code': partial['claude-code'] ?? [], codex: partial.codex ?? [], pi: partial.pi ?? [], + 'grok-build': partial['grok-build'] ?? [], }; return { availability, @@ -1786,6 +1788,7 @@ describe('OrcaWorkerCreationService', () => { { id: 'xd', name: 'XD Gateway', models: ['gpt-5.4'] }, ], pi: [], + 'grok-build': [], } satisfies Record; const { deps, service } = createDeps({ getWorkerDefaults: vi.fn(() => ({ model: 'gpt-5.5', providerId: 'custom-codex' })), @@ -2094,6 +2097,7 @@ describe('buildNoProviderMessage', () => { 'claude-code': [{ id: 'xd', name: 'XD Gateway', models: ['claude-sonnet-4-6'] }], pi: [], codex: [], + 'grok-build': [], }); expect(msg).toContain('Codex 当前没有可用的模型供应商'); expect(msg).toContain('改用'); @@ -2101,7 +2105,7 @@ describe('buildNoProviderMessage', () => { }); it('omits the agent suggestion when no agent has a connected provider', () => { - const msg = buildNoProviderMessage('claude-code', { 'claude-code': [], codex: [], pi: [] }); + const msg = buildNoProviderMessage('claude-code', { 'claude-code': [], codex: [], pi: [], 'grok-build': [] }); expect(msg).toContain('Claude Code 当前没有可用的模型供应商'); expect(msg).toContain('设置 → 模型供应商'); expect(msg).not.toContain('改用'); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/queuedMessageGate.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/queuedMessageGate.test.ts new file mode 100644 index 0000000000..22b021c489 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/queuedMessageGate.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import type { AgentInputQueuedMessage } from '../../../shared/agentInputQueue'; +import { requireQueuedMessageShape } from '../queuedMessageGate'; + +const registerSource = readFileSync(resolve(__dirname, '..', 'register.ts'), 'utf8'); + +function queuedMessage(agentKind: unknown): unknown { + return { + clientId: 'client-1', + text: 'hello', + persistedContent: 'hello', + model: 'grok-code', + effort: '', + permissionMode: 'ask', + workingDir: 'C:\\repo', + chatMessage: { clientId: 'client-1', role: 'user', content: 'hello' }, + createOpts: { agentKind, workingDir: 'C:\\repo', model: 'grok-code' }, + }; +} + +describe('queued message IPC gate', () => { + it('accepts every agent kind the queued createOpts contract declares', () => { + for (const kind of ['claude-code', 'codex', 'pi', 'grok-build'] as const) { + const item = queuedMessage(kind); + const parsed: AgentInputQueuedMessage = requireQueuedMessageShape(item); + expect(parsed).toBe(item); + expect(parsed.createOpts.agentKind).toBe(kind); + } + }); + + it('rejects a non-agent createOpts.agentKind', () => { + for (const bogus of ['grok', 'cc', '', undefined, null, 7]) { + expect(() => requireQueuedMessageShape(queuedMessage(bogus))).toThrow('[INVALID_PARAMS]'); + } + }); + + it('still enforces the rest of the queued message shape', () => { + expect(() => requireQueuedMessageShape(null)).toThrow('[INVALID_PARAMS]'); + expect(() => + requireQueuedMessageShape({ ...(queuedMessage('grok-build') as object), clientId: '' }), + ).toThrow('[INVALID_PARAMS]'); + expect(() => + requireQueuedMessageShape({ ...(queuedMessage('grok-build') as object), text: 42 }), + ).toThrow('[INVALID_PARAMS]'); + expect(() => + requireQueuedMessageShape({ ...(queuedMessage('grok-build') as object), chatMessage: null }), + ).toThrow('[INVALID_PARAMS]'); + expect(() => + requireQueuedMessageShape({ ...(queuedMessage('grok-build') as object), createOpts: 'x' }), + ).toThrow('[INVALID_PARAMS]'); + }); + + it('routes INPUT_ENQUEUE through the shared shape gate', () => { + expect(registerSource).toContain( + "import { requireQueuedMessageShape } from './queuedMessageGate.js';", + ); + + const validatorStart = registerSource.indexOf('const requireQueuedMessage = ('); + expect(validatorStart).toBeGreaterThan(-1); + const validator = registerSource.slice(validatorStart, validatorStart + 600); + expect(validator).toContain('const msg = requireQueuedMessageShape(value);'); + + const enqueueStart = registerSource.indexOf('MAKER_INVOKE.INPUT_ENQUEUE,'); + expect(enqueueStart).toBeGreaterThan(-1); + const enqueue = registerSource.slice( + enqueueStart, + registerSource.indexOf('MAKER_INVOKE.INPUT_STEER,', enqueueStart), + ); + expect(enqueue).toContain('requireQueuedMessage(item)'); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts index 649bcd0323..beb6b08d99 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts @@ -82,6 +82,8 @@ const providerRouting = ( 'pi-model': { efforts: ['low', 'high', 'max'], defaultEffort: 'high' }, }, }], + // grok-build 走本机 grok CLI,没有可路由的供应商。 + 'grok-build': [], }, resolveDefaultProviderIdForModel: (agent: AgentKind) => defaults[agent] ?? ( agent === 'claude-code' ? 'anthropic' : agent === 'codex' ? 'openai' : 'xd' @@ -311,6 +313,7 @@ describe('resolveSendToSessionExecutionConfig', () => { }, }], pi: [], + 'grok-build': [], }, resolveDefaultProviderIdForModel: () => 'xd', }, diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts index df6f353c09..8db06b0dd8 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts @@ -361,16 +361,7 @@ describe('performSessionAgentSwitch', () => { describe('deferred switch (turn running)', () => { function makeDepsWithPending(overrides: Partial = {}) { const base = makeDeps(overrides); - const store = new Map< - string, - { - targetAgentKind: 'claude-code' | 'codex' | 'pi'; - model: string; - providerId: string | null | undefined; - effort?: string; - fastMode?: boolean; - } - >(); + const store = new Map(); base.deps.pendingSwitches = { set: (id, intent) => void store.set(id, intent), get: (id) => store.get(id), diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sessionSendHandler.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sessionSendHandler.test.ts index 43cdd17012..a3e956d491 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sessionSendHandler.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sessionSendHandler.test.ts @@ -40,6 +40,24 @@ describe('maker session SEND IPC handler', () => { expect(sendToAgentAccepted).toHaveBeenCalledWith('session-1', message, createOpts, sendOpts); }); + it('forwards Grok Build create opts unchanged', async () => { + const harness = new IpcHarness(); + const result = { accepted: true }; + const sendToAgentAccepted = vi.fn().mockResolvedValue(result); + const createOpts = { + agentKind: 'grok-build', + workingDir: 'C:\\repo', + model: 'grok-code-fast-1', + }; + + registerMakerSessionSendHandler(harness, { sendToAgentAccepted }); + + await expect( + harness.invoke(MAKER_INVOKE.SEND, 'session-1', 'hello', createOpts, undefined), + ).resolves.toBe(result); + expect(sendToAgentAccepted).toHaveBeenCalledWith('session-1', 'hello', createOpts, undefined); + }); + it('runs the clear-boundary fence before a legacy direct send', async () => { const harness = new IpcHarness(); const sendToAgentAccepted = vi.fn().mockResolvedValue({ accepted: true }); diff --git a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts index 28d4abd4c1..bf55330a44 100644 --- a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts +++ b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts @@ -24,6 +24,7 @@ * 它只提交 intent payload;排序、投递模式、回滚和持久化由本模块决定。 */ +import type { AgentKind } from '@cindy/maker-core'; import { redactSensitiveText } from '@cindy/maker-shared/error-redaction'; import { isUnsupportedResponsesImageErrorPayload } from '@cindy/responses-chat-bridge'; import { isPiImageInputUnsupportedError } from '../../shared/inputError.js'; @@ -308,7 +309,8 @@ export interface AgentInputCoordinatorDeps { */ reconcileTurnIdle?: (sessionId: string) => boolean; hasPendingInteraction: (sessionId: string) => boolean; - getAgentKind: (sessionId: string) => AgentInputCreateOpts['agentKind'] | null; + /** 活跃 session 的 agent;读的是实时会话,可能是队列 createOpts 之外的 agent。 */ + getAgentKind: (sessionId: string) => AgentKind | null; getSdkSessionId: (sessionId: string) => Promise; /** Read a bounded, durable progress snapshot before a retry is re-enqueued. */ getRecoveryContextSnapshot?: ( @@ -5153,7 +5155,7 @@ export class AgentInputCoordinator { return; } - let agentKind: AgentInputCreateOpts['agentKind'] | null = null; + let agentKind: AgentKind | null = null; try { agentKind = this.deps.getAgentKind(sessionId); } catch (err) { diff --git a/apps/desktop/src/main/maker-ipc/agentHandoff.ts b/apps/desktop/src/main/maker-ipc/agentHandoff.ts index dd84eb76b2..ad3ac78c43 100644 --- a/apps/desktop/src/main/maker-ipc/agentHandoff.ts +++ b/apps/desktop/src/main/maker-ipc/agentHandoff.ts @@ -13,8 +13,21 @@ import { projectPersistedAgentFacingUserText } from '@cindy/maker-shared/agent-input-projection'; -/** DB 层引擎标识(sessions.agent_kind / messages.agent_kind 的值域)。 */ -export type DbAgentKind = 'cc' | 'codex' | 'pi'; +import type { DbAgentKind } from '../../shared/agentKindConversion.js'; + +/** DB 层引擎标识(sessions.agent_kind / messages.agent_kind 的值域),正本在 shared。 */ +export type { DbAgentKind }; + +/** + * 交接 framing 与边界卡展示用的引擎名。放在这里(而不是各调用点自己 ternary)是因为 + * 漏一个分支就会把别家引擎的会话写成 Claude Code —— 新增 agent 只改这一处。 + */ +export function agentEngineLabel(dbKind: DbAgentKind): string { + if (dbKind === 'codex') return 'Codex'; + if (dbKind === 'pi') return 'Pi'; + if (dbKind === 'grok-build') return 'Grok Build'; + return 'Claude Code'; +} /** 构造交接文本所需的最小消息投影(content 已 JSON.parse,即 camel Message.content)。 */ export interface HandoffSourceMessage { diff --git a/apps/desktop/src/main/maker-ipc/agentKindGate.ts b/apps/desktop/src/main/maker-ipc/agentKindGate.ts new file mode 100644 index 0000000000..ec78791c41 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/agentKindGate.ts @@ -0,0 +1,56 @@ +/** + * IPC 入口的 agentKind 闸门。 + * + * 两个口径必须分开: + * - **会话面**(capabilities / 命令 / 技能 / @ 资源 / 定制 / 排队输入)认全部 + * `AgentKind`,含本机可选 harness Grok Build —— 这些入口是会话能不能开口说话的 + * 前置,拒了 grok-build 会话就是死的。 + * - **New Maker 草稿面**只认三个有草稿 vendor 槽的 agent;grok-build 没有草稿槽, + * 在那里仍按非法参数拒绝。 + */ + +import type { AgentKind } from '@cindy/maker-core'; + +import { requireEnum } from '../utils/ipcValidate.js'; + +/** + * 运行时枚举不能靠 TypeScript 强转替代,但也不该再手抄一份联合体:用 + * `Record` 建表,`AgentKind` 新增成员时这里先编译不过,wire 闸门 + * 不会与类型声明漂移。 + */ +const AGENT_KIND_KEYS: Record = { + 'claude-code': true, + codex: true, + pi: true, + 'grok-build': true, +}; + +/** wire 上合法的全部 agent 种类。 */ +export const AGENT_KINDS = Object.keys(AGENT_KIND_KEYS) as readonly AgentKind[]; + +/** 有 New Maker 草稿 vendor 槽的 agent。 */ +export const DRAFT_AGENT_KINDS = [ + 'claude-code', + 'codex', + 'pi', +] as const satisfies readonly AgentKind[]; + +export type DraftAgentKind = (typeof DRAFT_AGENT_KINDS)[number]; + +/** 会话面 wire 入口的 agentKind 校验:认全部 AgentKind(含 Grok Build)。 */ +export function requireAgentKind(value: unknown): AgentKind { + return requireEnum(value, AGENT_KINDS, 'agentKind'); +} + +/** + * 草稿面 wire 入口的 agentKind 校验:只认能在控制端建草稿的三个 vendor。 + * `name` 供调用点保留自己的参数名(草稿 pref 的字段叫 `agent`)。 + */ +export function requireDraftAgentKind(value: unknown, name = 'agentKind'): DraftAgentKind { + return requireEnum(value, DRAFT_AGENT_KINDS, name); +} + +/** 纯判定:给已有自己错误文案的调用点(如排队消息)做类型收窄。 */ +export function isAgentKind(value: unknown): value is AgentKind { + return typeof value === 'string' && (AGENT_KINDS as readonly string[]).includes(value); +} diff --git a/apps/desktop/src/main/maker-ipc/authHandlers.ts b/apps/desktop/src/main/maker-ipc/authHandlers.ts index f7e4a2799c..c788e9b5a1 100644 --- a/apps/desktop/src/main/maker-ipc/authHandlers.ts +++ b/apps/desktop/src/main/maker-ipc/authHandlers.ts @@ -7,9 +7,10 @@ import type { AgentKind, AgentLoginMode, AuthState, Maker } from '@cindy/maker-core'; -import { optionalEnum, requireEnum, requireObject, throwIpcError } from '../utils/ipcValidate.js'; +import { optionalEnum, requireObject, throwIpcError } from '../utils/ipcValidate.js'; import { createLogger } from '../logger.js'; import { MAKER_INVOKE, MAKER_PUSH } from './channels.js'; +import { requireAgentKind } from './agentKindGate.js'; import type { IpcHandlerRegistry } from './ipcHandlerRegistry.js'; const log = createLogger('maker-ipc:authHandlers'); @@ -17,8 +18,6 @@ const log = createLogger('maker-ipc:authHandlers'); /** main → renderer 的 push 广播能力。 */ export type MakerIpcBroadcast = (channel: string, payload: unknown) => void; -/** IPC 允许的 agent 种类;运行时枚举校验不能靠 TypeScript 强转替代。 */ -const AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const satisfies readonly AgentKind[]; const AGENT_LOGIN_MODES = ['browser', 'device-code'] as const satisfies readonly AgentLoginMode[]; const MAX_LOGIN_PROGRESS_CHARS = 16_384; const LOGIN_OWNER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; @@ -550,10 +549,6 @@ function cancelledAuthState(): AuthState { return { authenticated: false, errorReason: 'login_cancelled' }; } -function requireAgentKind(value: unknown): AgentKind { - return requireEnum(value, AGENT_KINDS, 'agentKind'); -} - function requireLoginOptions( agentKind: AgentKind, value: unknown, diff --git a/apps/desktop/src/main/maker-ipc/help.ts b/apps/desktop/src/main/maker-ipc/help.ts index 0b49163a19..517d5322a2 100644 --- a/apps/desktop/src/main/maker-ipc/help.ts +++ b/apps/desktop/src/main/maker-ipc/help.ts @@ -248,6 +248,7 @@ async function getMostRecentSessionAgent(): Promise { if (row.agentKind === 'cc' || row.agentKind === 'claude-code') return 'claude-code'; if (row.agentKind === 'codex') return 'codex'; if (row.agentKind === 'pi') return 'pi'; + if (row.agentKind === 'grok-build') return 'grok-build'; return null; } catch (err) { log.debug('help recent-agent probe failed', { error: String(err) }); @@ -263,8 +264,8 @@ export async function pickHelpAgent( preferredAgent: AgentKind | null, ): Promise { const candidates: AgentKind[] = preferredAgent - ? [...new Set([preferredAgent, 'claude-code', 'codex', 'pi'])] - : ['claude-code', 'codex', 'pi']; + ? [...new Set([preferredAgent, 'claude-code', 'codex', 'pi', 'grok-build'])] + : ['claude-code', 'codex', 'pi', 'grok-build']; const ordered = candidates.filter((agentKind) => agentSupportsOneShot(agentKind)); const available = new Set(maker.listAvailableAgents()); for (const agentKind of ordered) { diff --git a/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts b/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts index fab30923dd..e889eeb59a 100644 --- a/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts +++ b/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts @@ -52,7 +52,9 @@ export async function readOrcaWorkerProviderRoutingContext(deps: { modelRegistry, provider.id, model.id, - agent === 'pi' ? undefined : agent, + // Registry routes only key claude-code/codex; Pi is client-projected and + // Grok Build has no provider routing, so both look up agent-agnostically. + agent === 'pi' || agent === 'grok-build' ? undefined : agent, ); return matched ? [[model.id, matched.entry.id]] : []; }), @@ -82,6 +84,7 @@ export async function readOrcaWorkerProviderRoutingContext(deps: { 'claude-code': availabilityFor('claude-code'), codex: availabilityFor('codex'), pi: availabilityFor('pi'), + 'grok-build': availabilityFor('grok-build'), }, resolveDefaultProviderIdForModel: (agent, model) => effectiveSourceIdForModel(views, null, model, agent), diff --git a/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts b/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts index a584106585..f0ca225328 100644 --- a/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts +++ b/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts @@ -524,7 +524,10 @@ export function budgetModelRequiresApiKeyMessage(model: string): string { /** agent 的人类可读名,用于 preflight 失败信息。 */ function agentDisplayName(agent: AgentKind): string { - return agent === 'codex' ? 'Codex' : agent === 'pi' ? 'Pi' : 'Claude Code'; + if (agent === 'codex') return 'Codex'; + if (agent === 'pi') return 'Pi'; + if (agent === 'grok-build') return 'Grok Build'; + return 'Claude Code'; } /** @@ -546,7 +549,7 @@ export function buildNoProviderMessage( availability: Record, ): string { const base = `${agentDisplayName(agent)} 当前没有可用的模型供应商(provider)。请在「设置 → 模型供应商」连接一个支持 ${agentDisplayName(agent)} 的供应商后重试`; - const others = (['claude-code', 'codex', 'pi'] as AgentKind[]).filter( + const others = (['claude-code', 'codex', 'pi', 'grok-build'] as AgentKind[]).filter( (a) => a !== agent && (availability[a]?.length ?? 0) > 0, ); if (others.length === 0) return `${base}。`; diff --git a/apps/desktop/src/main/maker-ipc/queuedMessageGate.ts b/apps/desktop/src/main/maker-ipc/queuedMessageGate.ts new file mode 100644 index 0000000000..8d0c560d4c --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/queuedMessageGate.ts @@ -0,0 +1,36 @@ +/** + * INPUT_ENQUEUE / INPUT_STEER 收到的排队消息形状闸门。 + * + * wire 只能保证是 JSON,这里把 renderer / device-link 控制端传来的排队项收敛成 + * `AgentInputQueuedMessage` 的最小合法形状。`createOpts.agentKind` 与 + * `AgentInputCreateOpts` 声明的联合体同源(含 Grok Build):类型放宽了、运行时闸门 + * 还停在三个 agent 的话,composer 发送会直接被 INVALID_PARAMS 打掉。 + * + * 只做形状校验;device-link 的会话引用可信度判定留在 register.ts 的调用点。 + */ + +import type { AgentInputQueuedMessage } from '../../shared/agentInputQueue.js'; +import { throwIpcError } from '../utils/ipcValidate.js'; +import { isAgentKind } from './agentKindGate.js'; + +export function requireQueuedMessageShape(value: unknown): AgentInputQueuedMessage { + if (!value || typeof value !== 'object') + throwIpcError('INVALID_PARAMS', 'queued message required'); + const msg = value as AgentInputQueuedMessage; + if (typeof msg.clientId !== 'string' || !msg.clientId) { + throwIpcError('INVALID_PARAMS', 'queued.clientId required'); + } + if (typeof msg.text !== 'string') throwIpcError('INVALID_PARAMS', 'queued.text required'); + if (typeof msg.persistedContent !== 'string') + throwIpcError('INVALID_PARAMS', 'queued.persistedContent required'); + if (!msg.chatMessage || typeof msg.chatMessage !== 'object') { + throwIpcError('INVALID_PARAMS', 'queued.chatMessage required'); + } + if (!msg.createOpts || typeof msg.createOpts !== 'object') { + throwIpcError('INVALID_PARAMS', 'queued.createOpts required'); + } + if (!isAgentKind(msg.createOpts.agentKind)) { + throwIpcError('INVALID_PARAMS', 'queued.createOpts.agentKind invalid'); + } + return msg; +} diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 1925552b8d..12e3894de6 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -580,12 +580,18 @@ import { recordTurnSpend, } from '../usageBroadcaster.js'; import { requireEnum, requireObject, throwIpcError } from '../utils/ipcValidate.js'; +import { requireAgentKind, requireDraftAgentKind } from './agentKindGate.js'; +import { requireQueuedMessageShape } from './queuedMessageGate.js'; import { isIpcError } from '../../shared/ipc-errors.js'; import { runPiPackageListIpcBoundary, runPiPackageMutationIpcBoundary, } from './piPackageMutationIpc.js'; -import { dbToMakerAgentKind, makerToDbAgentKind } from '../../shared/agentKindConversion.js'; +import { + dbToMakerAgentKind, + makerToDbAgentKind, + normalizeDbAgentKind, +} from '../../shared/agentKindConversion.js'; import { readWorkflowProgressForSession } from '../workflow-progress/reader.js'; import { AgentInputCoordinator } from './agent-input-coordinator.js'; import { @@ -2073,11 +2079,6 @@ export function stopOrcaIdleWatcher(): void { idleReleaseWatcher = null; } -function requireAgentKind(value: unknown): AgentKind { - if (value === 'claude-code' || value === 'codex' || value === 'pi') return value; - throwIpcError('INVALID_PARAMS', 'agentKind required'); -} - type IpcUserMessage = string | { type: 'user'; content: string | Array<{ type: string; [k: string]: unknown }> }; @@ -6440,7 +6441,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // (model/effort/fast/permission/source/是否显式选过模型)。控制端经隧道调用 → seed 远程项目草稿。 // 缓存未就绪 / 该 vendor 无草稿 model → 返回 {},控制端按 capabilities 默认兜底。 ipcMain.handle(MAKER_INVOKE.GET_NEW_MAKER_DEFAULTS, (_e, agentKind: unknown) => { - return getRemoteNewMakerDefaults(requireAgentKind(agentKind)); + return getRemoteNewMakerDefaults(requireDraftAgentKind(agentKind)); }); // device-link 草稿「模型 effort/fast」写穿:控制端经隧道调用 → 跑在**被控端**。被控端不直接改 @@ -6459,9 +6460,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) active?: unknown; markModelChoice?: unknown; }; - if (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi') { - throwIpcError('INVALID_PARAMS', 'agent must be claude-code|codex|pi'); - } + const draftAgent = requireDraftAgentKind(p.agent, 'agent'); if (p.providerId !== undefined && typeof p.providerId !== 'string') { throwIpcError('INVALID_PARAMS', 'providerId must be string'); } @@ -6484,7 +6483,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) throwIpcError('INVALID_PARAMS', 'markModelChoice must be boolean'); } broadcastToAllWindows(MAKER_PUSH.DRAFT_PREF_APPLY, { - agent: p.agent, + agent: draftAgent, providerId: p.providerId ?? '', modelId: p.modelId, active: p.active === true, @@ -6917,10 +6916,14 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) : undefined; const sessionMeta = sessionId ? await maker.getSessionMeta(sessionId) : null; const builtins = maker.listAgentCommands(kind); + // Pi 包体系只存在于 Pi 会话;其它 agent(含 grok-build)按「不是本机普通 Pi + // 任务」传 null,与 shouldListPiPackageCommands 内部的 fail-closed 判定同义。 + const piSessionMeta = + sessionMeta?.agentKind === 'pi' ? { ...sessionMeta, agentKind: 'pi' as const } : null; const mayListPackageCommands = shouldListPiPackageCommands( kind, sessionId !== undefined, - sessionMeta, + piSessionMeta, params.allowManagedPiPackagePreview !== false, ); let packageCommands: Array<{ name: string; description: string }> = []; @@ -12089,12 +12092,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) .from(sessions) .where(eq(sessions.id, sessionId)) .limit(1); - const cardAgentKind = - sessionKindRow?.agentKind === 'codex' - ? 'codex' - : sessionKindRow?.agentKind === 'pi' - ? 'pi' - : 'cc'; + // 走映射正本:就地 ternary 会把 'cc' / 'codex' / 'pi' 之外的引擎写成 'cc', + // 重建卡片就挂到了错的引擎名下。 + const cardAgentKind = normalizeDbAgentKind(sessionKindRow?.agentKind); broadcastSessionPatched(sessionId, { sdkSessionId: null, updatedAt: new Date(updatedAt).toISOString(), @@ -13653,28 +13653,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) value: unknown, opts?: { allowMissingTrustedContexts?: boolean }, ): AgentInputQueuedMessage => { - if (!value || typeof value !== 'object') - throwIpcError('INVALID_PARAMS', 'queued message required'); - const msg = value as AgentInputQueuedMessage; - if (typeof msg.clientId !== 'string' || !msg.clientId) { - throwIpcError('INVALID_PARAMS', 'queued.clientId required'); - } - if (typeof msg.text !== 'string') throwIpcError('INVALID_PARAMS', 'queued.text required'); - if (typeof msg.persistedContent !== 'string') - throwIpcError('INVALID_PARAMS', 'queued.persistedContent required'); - if (!msg.chatMessage || typeof msg.chatMessage !== 'object') { - throwIpcError('INVALID_PARAMS', 'queued.chatMessage required'); - } - if (!msg.createOpts || typeof msg.createOpts !== 'object') { - throwIpcError('INVALID_PARAMS', 'queued.createOpts required'); - } - if ( - msg.createOpts.agentKind !== 'claude-code' && - msg.createOpts.agentKind !== 'codex' && - msg.createOpts.agentKind !== 'pi' - ) { - throwIpcError('INVALID_PARAMS', 'queued.createOpts.agentKind invalid'); - } + const msg = requireQueuedMessageShape(value); const normalized: AgentInputQueuedMessage = { ...msg }; const refs = requireSessionRefs(normalized.sessionRefs); if (!isDeviceLinkInvoke()) { @@ -16736,7 +16715,9 @@ async function checkWorkDirExists( // 或者 agent 真跑起来时由远端 codex 自己报 ENOENT)。这里直接放行。 if (remoteHostId) return true; if (!workingDir?.trim()) return true; - const source: AgentKind = agentKind === 'codex' || agentKind === 'pi' ? agentKind : 'claude-code'; + // 已知 agent 原样透传(否则 grok-build 会被写成 claude-code);只有老 session 的 + // 未知来源才按注释里的 'claude-code' 兜底。 + const source: AgentKind = agentKind ?? 'claude-code'; // suppressMissingBroadcast: 调用方(SEND 事务)手里还有 DB 权威值可兜底时, // 首检失败只记日志不广播错误横幅——兜底成功的话用户不该看到假错误。 const suppress = opts?.suppressMissingBroadcast === true; diff --git a/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts b/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts index 5c3d333f2c..376322aef2 100644 --- a/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts +++ b/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts @@ -31,6 +31,7 @@ import type { AgentKind } from '@cindy/maker-core'; import { MAKER_INVOKE } from './channels.js'; import type { IpcHandlerRegistry } from './ipcHandlerRegistry.js'; import { + agentEngineLabel, buildHandoffText, type DbAgentKind, type HandoffSourceMessage, @@ -61,12 +62,8 @@ export function toMakerAgentKind(dbKind: string): AgentKind { return dbToMakerAgentKind(dbKind); } -/** 交接 framing 与边界卡展示用的引擎名。 */ -export function agentEngineLabel(dbKind: DbAgentKind): string { - if (dbKind === 'codex') return 'Codex'; - if (dbKind === 'pi') return 'Pi'; - return 'Claude Code'; -} +/** 交接 framing 与边界卡展示用的引擎名(正本在 agentHandoff.ts)。 */ +export { agentEngineLabel }; /** role='agent_switch' 边界行的 content 结构(与 renderer AgentSwitchContent 对齐)。 */ export interface AgentSwitchBoundaryContent { diff --git a/apps/desktop/src/main/maker-ipc/sessionRequest.ts b/apps/desktop/src/main/maker-ipc/sessionRequest.ts index c81e5de33f..275f3d00ab 100644 --- a/apps/desktop/src/main/maker-ipc/sessionRequest.ts +++ b/apps/desktop/src/main/maker-ipc/sessionRequest.ts @@ -66,7 +66,7 @@ export interface ReadCreateSessionOptsDeps { } function readAgentKind(value: unknown): AgentKind { - if (value === 'claude-code' || value === 'codex' || value === 'pi') return value; + if (value === 'claude-code' || value === 'codex' || value === 'pi' || value === 'grok-build') return value; throwIpcError('INVALID_PARAMS', 'agentKind required'); } diff --git a/apps/desktop/src/main/maker-ipc/title.ts b/apps/desktop/src/main/maker-ipc/title.ts index dfb4827f98..57e7b22ac2 100644 --- a/apps/desktop/src/main/maker-ipc/title.ts +++ b/apps/desktop/src/main/maker-ipc/title.ts @@ -258,7 +258,12 @@ const AUTO_TITLE_TEXT_MAX = 2000; /** sessionId 长度上限(UUID / cuid 都远小于此)。 */ const SESSION_ID_MAX = 128; -const TITLE_AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const satisfies readonly AgentKind[]; +const TITLE_AGENT_KINDS = [ + 'claude-code', + 'codex', + 'pi', + 'grok-build', +] as const satisfies readonly AgentKind[]; interface GenerateTitleRequest { message: string; diff --git a/apps/desktop/src/main/maker-orchestration/fork.ts b/apps/desktop/src/main/maker-orchestration/fork.ts index 9545ea03de..3e45fa2963 100644 --- a/apps/desktop/src/main/maker-orchestration/fork.ts +++ b/apps/desktop/src/main/maker-orchestration/fork.ts @@ -19,9 +19,17 @@ import { sessionToCamel } from '../localDb/mapper'; import { commitContextRebuild, createMessage } from '../localDb/ipc/messages.js'; import { getMaker } from '../maker-host/index.js'; import { createBusinessSessionId } from '../sessionIds.js'; -import { dbToMakerAgentKind, normalizeDbAgentKind } from '../../shared/agentKindConversion.js'; +import { + dbToMakerAgentKind, + normalizeDbAgentKind, + type DbAgentKind, +} from '../../shared/agentKindConversion.js'; import type { AgentMeta, Session } from '../../renderer/lib/ccAgent.types'; -import { buildHandoffText, type HandoffSourceMessage } from '../maker-ipc/agentHandoff.js'; +import { + agentEngineLabel, + buildHandoffText, + type HandoffSourceMessage, +} from '../maker-ipc/agentHandoff.js'; import { type ClaudeTranscriptAnchorIndex, loadClaudeTranscriptAnchorIndex, @@ -62,8 +70,6 @@ function normalizePositiveInt(value: unknown): number { const messageRowid = sql`rowid`; -type DbAgentKind = 'cc' | 'codex' | 'pi'; - interface MessagePosition { createdAt: number; rowid: number | null; @@ -129,8 +135,8 @@ async function seedForkHandoffAfterSameEngineRebuild(opts: { toolUseId: row.toolUseId, })); const lastUser = [...opts.rows].reverse().find((row) => row.role === 'user'); - const label = - opts.agentKind === 'codex' ? 'Codex' : opts.agentKind === 'pi' ? 'Pi' : 'Claude Code'; + // 同引擎重建的交接 framing 用真实引擎名 —— 落到默认分支等于把会话写成 Claude Code。 + const label = agentEngineLabel(opts.agentKind); const handoff = buildHandoffText(handoffMessages, { fromLabel: label, toLabel: label, @@ -583,6 +589,12 @@ export async function forkSessionAtMessage( if (!source) { throw forkError('SOURCE_NOT_FOUND', `Source session ${sourceSessionId} 不存在`); } + // 与 rewind 同一条边界:grok-build 声明 fork: supported: false,不拦的话下面 + // 「非 codex / pi 即 Claude」的分叉会拿 message-uuid 锚点去 fork 一个没有 + // Claude transcript 的会话。 + if (source.agentKind === 'grok-build') { + throw forkError('UNSUPPORTED_HISTORY', 'Grok Build 会话不支持 fork'); + } // 轮 26 发现 5 防御深度:远端会话 fork 由 SDK 层拒绝(pi forkSdkSession 抛 // NotSupportedError remoteFork;cc/codex 由 daemon 远端执行)。host 层显式 diff --git a/apps/desktop/src/main/maker-orchestration/rewind.ts b/apps/desktop/src/main/maker-orchestration/rewind.ts index c4753303b8..4ef5993a12 100644 --- a/apps/desktop/src/main/maker-orchestration/rewind.ts +++ b/apps/desktop/src/main/maker-orchestration/rewind.ts @@ -184,6 +184,12 @@ async function loadRewindContext( if (makerSession.isTurnRunning()) { throw rewindError('SESSION_RUNNING', '会话进行中,无法回滚'); } + // grok-build 的 capabilities 里 rewind / fork 都是 supported: false(ACP 没有 + // checkpoint / rollback 原语)。这里 fail-closed 拦下,否则下面的三元会把它当 + // 'claude-code',走 Claude checkpoint 分支回滚一个根本没有 checkpoint 的会话。 + if (makerSession.agentKind === 'grok-build') { + throw rewindError('REWIND_UNSUPPORTED_HISTORY', 'Grok Build 会话不支持回滚'); + } const agentKind = makerSession.agentKind === 'codex' ? 'codex' : makerSession.agentKind === 'pi' diff --git a/apps/desktop/src/main/messagePersistBroadcaster.ts b/apps/desktop/src/main/messagePersistBroadcaster.ts index 96163d2096..666ea3afa6 100644 --- a/apps/desktop/src/main/messagePersistBroadcaster.ts +++ b/apps/desktop/src/main/messagePersistBroadcaster.ts @@ -55,6 +55,7 @@ import { createLogger } from './logger.js'; import * as broadcastTap from './device-link/broadcast-tap.js'; import { commitMessageMediaRefs } from './cindy-media/chatAttachments.js'; import { takeMediaToolResult } from './mcp-integrations/mediaToolResultFallback.js'; +import type { DbAgentKind } from '../shared/agentKindConversion.js'; import { capToolResultTextForPersist } from '../shared/toolResultPersistCap.js'; import { redactSensitiveText } from '@cindy/maker-shared/error-redaction'; import { @@ -154,13 +155,13 @@ type OwnerScope = ReturnType * session.agent_kind 只代表"当前引擎",历史行的 agent_meta 必须按写入时引擎解析。 * clearSessionPersistState 时清理。 */ -const dbAgentKindBySession = new Map(); +const dbAgentKindBySession = new Map(); -export function noteSessionAgentKind(sessionId: string, dbAgentKind: 'cc' | 'codex' | 'pi'): void { +export function noteSessionAgentKind(sessionId: string, dbAgentKind: DbAgentKind): void { dbAgentKindBySession.set(sessionId, dbAgentKind); } -export function getSessionDbAgentKind(sessionId: string): 'cc' | 'codex' | 'pi' | null { +export function getSessionDbAgentKind(sessionId: string): DbAgentKind | null { return dbAgentKindBySession.get(sessionId) ?? null; } diff --git a/apps/desktop/src/main/process-monitor/agent-scan.ts b/apps/desktop/src/main/process-monitor/agent-scan.ts index a9aa07af33..6d49ff4332 100644 --- a/apps/desktop/src/main/process-monitor/agent-scan.ts +++ b/apps/desktop/src/main/process-monitor/agent-scan.ts @@ -27,7 +27,7 @@ import { runWindowsProcessScanWorker } from './windowsProcessScanWorkerClient.js const execFileAsync = promisify(execFile); -export type MonitoredAgentKind = 'claude' | 'codex' | 'pi'; +export type MonitoredAgentKind = 'claude' | 'codex' | 'pi' | 'grok-build'; export interface OsProcessRow { pid: number; diff --git a/apps/desktop/src/main/process-monitor/sampler.ts b/apps/desktop/src/main/process-monitor/sampler.ts index f3dbd8393e..961d9a0aac 100644 --- a/apps/desktop/src/main/process-monitor/sampler.ts +++ b/apps/desktop/src/main/process-monitor/sampler.ts @@ -69,6 +69,7 @@ const AGENT_KIND_TO_USAGE_KIND: Record = { claude: 'agent-claude', codex: 'agent-codex', pi: 'agent-pi', + 'grok-build': 'agent-grok-build', }; export interface ProcessMonitorSampler { diff --git a/apps/desktop/src/main/sessionTaskSummary.ts b/apps/desktop/src/main/sessionTaskSummary.ts index f090ef647c..3a84c7f16f 100644 --- a/apps/desktop/src/main/sessionTaskSummary.ts +++ b/apps/desktop/src/main/sessionTaskSummary.ts @@ -25,6 +25,7 @@ import { BrowserWindow } from 'electron'; import { and, count, desc, eq, gt, isNotNull, isNull, lt, or, sql } from 'drizzle-orm'; +import { dbToMakerAgentKind } from '../shared/agentKindConversion.js'; import { getMaker } from './maker-host/index.js'; import { isAgentOneShotRouteDisabled } from './maker-host/model-route-guard-live.js'; import { agentSupportsOneShot, requestUtilityText } from './utility-model/oneShotCandidates.js'; @@ -316,10 +317,9 @@ async function generateSummaryOnce(sessionId: string): Promise { const inactiveMs = Date.now() - (session.userSendAt ?? session.updatedAt); const tier = pickTier({ inactiveMs, messageCount, isScheduled }); - const agentKind = - session.agentKind === 'codex' || session.agentKind === 'pi' - ? session.agentKind - : 'claude-code'; + // 走映射正本:就地 ternary 会把新引擎(grok-build)当成 claude-code,摘要就跑去了 + // 错的 oneShot 兜底(agentSupportsOneShot 的判定也随之失真)。 + const agentKind = dbToMakerAgentKind(session.agentKind); const prompt = SUMMARY_PROMPT(session.title, userMsg, assistantMsg, tier); // 模型走系统统一配置:优先用"轻量任务模型链"(utility-model,与起标题同源, // 由 getUtilityModelChainProfiles 决定),配置缺失/不可用时再回退到 agent 自带的 diff --git a/apps/desktop/src/main/turn-change-set/store.ts b/apps/desktop/src/main/turn-change-set/store.ts index 2cd492d568..f76f97fca2 100644 --- a/apps/desktop/src/main/turn-change-set/store.ts +++ b/apps/desktop/src/main/turn-change-set/store.ts @@ -81,7 +81,7 @@ interface TurnChangeActionStateV1 { states: Record; } -const PROVIDERS = new Set(['codex', 'claude-code', 'pi']); +const PROVIDERS = new Set(['codex', 'claude-code', 'pi', 'grok-build']); const STATES = new Set(['complete', 'partial']); const WORKSPACE_STATES = new Set(['applied', 'undone']); const INCOMPLETE_REASONS = new Set([ diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 3efeb22615..6920912108 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -360,7 +360,7 @@ type VoiceInputModelSelectionPatchWire = { type DiscordBotSessionAuthCheckWire = { ok: boolean; missing: 'gateway-key' | 'agent-oauth' | 'provider-key' | 'provider-disconnected' | null; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; providerLabel: string | null; @@ -2286,12 +2286,12 @@ contextBridge.exposeInMainWorld('electronAPI', { syncNewMakerDraft: (snapshot: { lastByVendor: Partial< Record< - 'cc' | 'codex' | 'pi', + 'cc' | 'codex' | 'pi' | 'grok-build', { model?: string; effort?: string; permissionMode?: string; providerId?: string | null } > >; /** 每个 vendor 是否由用户在 New Maker 中明确选过模型;device-link 默认校准据此保护显式选择。 */ - modelChosenByVendor: Partial>; + modelChosenByVendor: Partial>; fastModeByModel: Record; effortByModel: Record; /** 「新建会话默认启用 worktree」勾选记忆(vendor 无关根字段,远程草稿播种用)。 */ @@ -5254,9 +5254,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // ─── Maker Core IPC ───────────────────────────────────────────────────── // renderer 通过统一 maker API 按 agentKind 调用 Claude Code / Codex / Pi。 maker: { - listAvailableAgents: (): Promise> => + listAvailableAgents: (): Promise> => ipcRenderer.invoke('maker:list-available-agents'), - getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:get-capabilities', agentKind), listTurnChangeSets: ( sessionId: string, @@ -5303,11 +5303,11 @@ contextBridge.exposeInMainWorld('electronAPI', { // 自定义供应商配置 CRUD(配置与 runtime 密钥均由 main 原子排队)。 createCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, ): Promise<{ ok: true }> => ipcRenderer.invoke('maker:provider:custom:create', config, keys), updateCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, ): Promise<{ ok: true }> => ipcRenderer.invoke('maker:provider:custom:update', config, keys), deleteCustomProvider: (providerId: string): Promise<{ ok: true }> => ipcRenderer.invoke('maker:provider:custom:delete', providerId), @@ -5321,11 +5321,11 @@ contextBridge.exposeInMainWorld('electronAPI', { */ testProviderConnection: ( input: - | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' } + | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' | 'grok-build' } | { kind: 'adhoc'; spec: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; baseUrl: string; modelId: string; authMethod: 'apiKey' | 'oauth' | 'none'; @@ -5347,7 +5347,7 @@ contextBridge.exposeInMainWorld('electronAPI', { * 结构化结果:ok=true 带 models;失败 code 走 providerError.* i18n。 */ fetchProviderModels: (input: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; baseUrl: string; authMethod: 'apiKey' | 'oauth' | 'none'; wireProtocol?: import('@cindy/model-providers').ProviderWireProtocol; @@ -5624,7 +5624,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }): Promise<{ ok: true; runId: string; reviewerSessionId: string }> => ipcRenderer.invoke('maker:review:start', input), listAgentCommands: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { sessionId?: string; allowManagedPiPackagePreview?: boolean } = {}, ): Promise<{ success: boolean; @@ -5634,7 +5634,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }> => ipcRenderer.invoke('maker:list-agent-commands', agentKind, params), listAgentSkills: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { workingDir?: string; forceReload?: boolean; sessionId?: string }, ): Promise<{ success: boolean; @@ -5747,7 +5747,7 @@ contextBridge.exposeInMainWorld('electronAPI', { onGoalStatusChanged: fanOutGoalStatusChanged, scanAtResources: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { workingDir: string; cap?: number; query?: string }, ): Promise<{ success: boolean; @@ -5780,7 +5780,7 @@ contextBridge.exposeInMainWorld('electronAPI', { createSession: (opts: { /** 可选: 复用外部 sessionId(本端 chat 用 local-db:sessions:create 拿到的 id) */ id?: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; title?: string; @@ -5890,7 +5890,7 @@ contextBridge.exposeInMainWorld('electronAPI', { message: string | { type: 'user'; content: string | Array<{ type: string; [k: string]: unknown }> }, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; orcaRole?: 'lead' | 'worker' | null; @@ -5935,7 +5935,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getContextUsage: ( sessionId: string, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; orcaRole?: 'lead' | 'worker' | null; @@ -5973,7 +5973,7 @@ contextBridge.exposeInMainWorld('electronAPI', { listActive: (): Promise< Array<{ sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workDir: string; capabilities: unknown; isTurnRunning: boolean; @@ -6036,14 +6036,14 @@ contextBridge.exposeInMainWorld('electronAPI', { // switched=false 且无 deferred = 同引擎 no-op(用户选回当前引擎,意图已清)。 switchSessionAgent: ( sessionId: string, - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', model: string, providerId?: string | null, effort?: string, fastMode?: boolean, ): Promise<{ switched: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; engineReady: boolean; deferred?: boolean; @@ -6064,7 +6064,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionAgentSwitchIntent: ( sessionId: string, ): Promise<{ - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort?: string; @@ -6110,14 +6110,14 @@ contextBridge.exposeInMainWorld('electronAPI', { // Memory 控制 (Personalization → Memory section)。 // 由 BaseAgent 子类落地; UI 层负责 Reset 前 confirm dialog。 memoryGet: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): Promise<{ enabled: boolean; source: 'agent-default' | 'host-runtime' | 'user-config'; stats?: { entryCount?: number; sizeBytes?: number; storagePath?: string }; }> => ipcRenderer.invoke('maker:memory:get', agentKind), memorySet: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', enabled: boolean, ): Promise<{ effective: 'immediate' | 'next-session'; @@ -6126,7 +6126,7 @@ contextBridge.exposeInMainWorld('electronAPI', { defaults: { maker: boolean; claudeCode: boolean; codex: boolean; pi: boolean }; }> => ipcRenderer.invoke('maker:memory:set', agentKind, enabled), memoryReset: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): Promise<{ removedEntries?: number; removedBytes?: number; @@ -6606,7 +6606,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // Stage 2 C1: chat utility (前身 cc-agent:generate-title / cc-agent:plan-file-write) generateTitle: ( message: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', sessionId?: string, ): Promise<{ title: string | null }> => ipcRenderer.invoke('maker:generate-title', { message, agentKind, sessionId }), @@ -6617,14 +6617,14 @@ contextBridge.exposeInMainWorld('electronAPI', { autoTitle: (request: { sessionId: string; text: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; isUserText?: boolean; }): Promise<{ applied: boolean; done: boolean }> => ipcRenderer.invoke('maker:auto-title', request), /** 输入框推荐提示词:turn 结束后预测用户下一步输入(turn 完成 → 调 IPC → 返回预测文本)。 */ predictNextPrompt: (request: { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; messages: Array<{ role: string; content: string }>; workingDir?: string; turnGen: number; @@ -6687,17 +6687,17 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Agent 鉴权 (取代老 electronAPI.codex.auth.*) ──────────────────────── auth: { - getState: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getState: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:auth:get-state', agentKind), triggerLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', options?: { mode?: 'browser' | 'device-code'; ownerId?: string }, ): Promise => ipcRenderer.invoke('maker:auth:trigger-login', agentKind, options), cancelLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', options?: { releaseOwner?: boolean; ownerId?: string }, ): Promise => ipcRenderer.invoke('maker:auth:cancel-login', agentKind, options), - logout: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + logout: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:auth:logout', agentKind), onStateChanged: fanOutMakerAuthStateChanged, onLoginProgress: fanOutMakerAuthLoginProgress, @@ -6705,13 +6705,13 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Agent 联合状态 (取代老 electronAPI.codex.binary.getStatus) ────────── agent: { - getStatus: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getStatus: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:agent:status', agentKind), /** spawn 当前应用使用的 binary `--version`, 进程内缓存。About 面板用。 */ getBinaryVersion: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): Promise<{ - kind: 'claude-code' | 'codex' | 'pi'; + kind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; binaryPath: string | null; version: string | null; error?: string; @@ -6720,9 +6720,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Agent 今日累计 (取代老 electronAPI.codex.usage.* + electronAPI.onUsageTodaySpendChanged) ─ usage: { - getToday: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getToday: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:usage:today', agentKind), - getAccount: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getAccount: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:usage:account', agentKind), /** Codex app-server authoritative windows and banked reset-credit metadata. */ getCodexRateLimits: (): Promise => diff --git a/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx b/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx index cfdab4faff..b87ea45da0 100644 --- a/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx +++ b/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx @@ -674,7 +674,7 @@ describe('AddProviderWizard — preset 直达', () => { // 同一 model id 在两端窗口可以不同(如 cc=1M / codex=272K):共享一个发现值 // 会让其中一端显示与压缩阈值双错,必须按 agent 分槽各取各的端点上报值。 vi.mocked(window.electronAPI.maker.fetchProviderModels).mockImplementation( - async ({ agent }: { agent: 'claude-code' | 'codex' | 'pi' }) => ({ + async ({ agent }: { agent: 'claude-code' | 'codex' | 'pi' | 'grok-build' }) => ({ ok: true, models: [ { diff --git a/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts b/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts index 1c10dfe0a1..1817c538fc 100644 --- a/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts +++ b/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts @@ -119,6 +119,7 @@ describe('useAgentCapabilities deviceId-aware cache', () => { await expect(mod.loadLocalCapabilitiesSnapshot()).resolves.toEqual([ ['claude-code', caps('local:claude-code')], ['codex', caps('local:codex')], + ['grok-build', caps('local:grok-build')], ]); expect(getCapabilities).toHaveBeenCalledWith('pi'); }); @@ -443,8 +444,8 @@ describe('useAgentCapabilities deviceId-aware cache', () => { mod.prefetchDeviceCapabilities('dev-1'), mod.prefetchDeviceCapabilities('dev-1'), ]); - // cc + codex + pi 各一次 = 3 次,而非 6 次 - expect(invoke).toHaveBeenCalledTimes(3); + // cc + codex + pi + grok-build 各一次 = 4 次,而非 8 次 + expect(invoke).toHaveBeenCalledTimes(4); }); it('驱逐:evict 只清该设备,本地与其它设备保留', async () => { @@ -542,15 +543,17 @@ describe('useAgentCapabilities deviceId-aware cache', () => { const stale = mod.prefetchDeviceCapabilities('dev-1'); mod.evictDeviceCapabilities('dev-1'); const fresh = mod.prefetchDeviceCapabilities('dev-1'); - // 每轮按 ALL_AGENT_KINDS 顺序 push 三个 resolver(cc/codex/pi): - // 第一轮(stale)= [0][1][2],第二轮(fresh)= [3][4][5]。 - resolvers[3](caps('fresh:claude')); - resolvers[4](caps('fresh:codex')); - resolvers[5](caps('fresh:pi')); + // 每轮按 ALL_AGENT_KINDS 顺序 push resolver(cc/codex/pi/grok-build): + // 第一轮(stale)= [0][1][2][3],第二轮(fresh)= [4][5][6][7]。 + resolvers[4](caps('fresh:claude')); + resolvers[5](caps('fresh:codex')); + resolvers[6](caps('fresh:pi')); + resolvers[7](caps('fresh:grok-build')); await fresh; resolvers[0](caps('stale:claude')); resolvers[1](caps('stale:codex')); resolvers[2](caps('stale:pi')); + resolvers[3](caps('stale:grok-build')); await stale; expect(claudeListener).toHaveBeenNthCalledWith(1, { status: 'loading' }); diff --git a/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx b/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx index 760a48afe5..0f3028e4a6 100644 --- a/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx +++ b/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx @@ -2984,7 +2984,7 @@ describe('统一面板 · 行内折扣徽标', () => { isFavoriteRow: false, justFavorited: false, interactionDisabled: false, - effortLabelOf: (_agent: 'claude-code' | 'codex' | 'pi', effort: string) => effort, + effortLabelOf: (_agent: 'claude-code' | 'codex' | 'pi' | 'grok-build', effort: string) => effort, providers: [], onReveal: vi.fn(), onRevealForKeyboard: vi.fn(), @@ -3226,7 +3226,7 @@ describe('列表样式试用开关(badge · v7 引擎徽标行)', () => { isFavoriteRow: false, justFavorited: false, interactionDisabled: false, - effortLabelOf: (_agent: 'claude-code' | 'codex' | 'pi', effort: string) => effort, + effortLabelOf: (_agent: 'claude-code' | 'codex' | 'pi' | 'grok-build', effort: string) => effort, providers: [], onReveal: vi.fn(), onRevealForKeyboard: vi.fn(), diff --git a/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts b/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts index 1a0e053159..987451dc8b 100644 --- a/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts +++ b/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts @@ -501,8 +501,8 @@ describe('会话内形态(同引擎过滤 / pinnedEngine)', () => { describe('同引擎视图:生效引擎是排序优先级,不是隐藏条件', () => { /** 注入侧的真实形态:调用方给的是 resolveUnifiedRowConfig / resolveFavoriteRowConfig 的 engine。 */ const engineOfRow = ( - overrides: Record = {}, - pinnedEngine: 'cc' | 'codex' | 'pi' = 'cc', + overrides: Record = {}, + pinnedEngine: 'cc' | 'codex' | 'pi' | 'grok-build' = 'cc', ) => (entry: UnifiedModelEntry, favorite?: ModelFavoriteItem) => favorite ? resolveFavoriteRowConfig({ entry, item: favorite }).engine diff --git a/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts b/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts index b4ae699138..7b2bcb199c 100644 --- a/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts +++ b/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts @@ -166,6 +166,8 @@ describe('pickVoiceInputDialogCopy(语音输入缺认证文案)', () => { 'codex-voice-unauth': { title: 'codex', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, 'codex-binary-missing': { title: 'binary', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, 'pi-binary-missing': { title: 'pi-binary', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, + 'grok-build-binary-missing': { title: 'grok-build-binary', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, + 'grok-build-unauth': { title: 'grok-build-unauth', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, }; it('api-key + providers 使用 XD Gateway 文案', () => { diff --git a/apps/desktop/src/renderer/cindy-brain/GhostErrandPrefs.tsx b/apps/desktop/src/renderer/cindy-brain/GhostErrandPrefs.tsx index 1928ab62e4..9ed7a616b6 100644 --- a/apps/desktop/src/renderer/cindy-brain/GhostErrandPrefs.tsx +++ b/apps/desktop/src/renderer/cindy-brain/GhostErrandPrefs.tsx @@ -41,7 +41,7 @@ const PERMISSION_ALLOWED = new Set(['plan', 'acceptEdits', 'auto']); const ERRAND_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']); interface ErrandConfig { - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; model?: string; effort?: string; fastMode?: boolean; @@ -90,9 +90,12 @@ export function GhostErrandPrefs({ // 保证非空,种子默认兜底)。不能用 getPersistedVendorModel:那是调度专用的严格口径, // 仅当用户在新建对话里显式选过该 vendor 模型才返回,否则返回 '',会让 trigger 落到 // 「选择模型」占位(2026-07-31 Lizi 反馈:应像草稿一样直接显示当前模型)。 - const followVendor: 'cc' | 'codex' | 'pi' = - draft.vendor === 'pi' ? 'pi' : draft.vendor === 'codex' ? 'codex' : 'cc'; - const vendor: 'cc' | 'codex' | 'pi' = config.agentKind ?? followVendor; + const followVendor: 'cc' | 'codex' | 'pi' | 'grok-build' = + draft.vendor === 'pi' ? 'pi' + : draft.vendor === 'codex' ? 'codex' + : draft.vendor === 'grok-build' ? 'grok-build' + : 'cc'; + const vendor: 'cc' | 'codex' | 'pi' | 'grok-build' = config.agentKind ?? followVendor; const shownModel = config.model ?? draft.lastByVendor[vendor].model; const shownEffort = (config.effort ?? getEffortForModel(shownModel) ?? @@ -160,7 +163,7 @@ export function GhostErrandPrefs({ // 值钉进本插件配置(未选过时才实时跟随草稿)。 void save({ ...config, - agentKind: next === 'pi' ? 'pi' : next === 'codex' ? 'codex' : 'cc', + agentKind: next === 'pi' ? 'pi' : next === 'codex' ? 'codex' : next === 'grok-build' ? 'grok-build' : 'cc', model: undefined, effort: undefined, fastMode: undefined, diff --git a/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx b/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx index 27fac87a8c..ec92d262f0 100644 --- a/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx @@ -62,7 +62,7 @@ interface AgentTaskCardProps { sessionId?: string; /** Current owning harness. Pi's durable-detail sidebar must never surface * after the session has switched to Claude Code or Codex. */ - sessionAgentKind?: 'cc' | 'codex' | 'pi'; + sessionAgentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; } function readInputString(input: unknown, keys: string[]): string | undefined { diff --git a/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx b/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx index a40f89b760..436fb73a62 100644 --- a/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx @@ -72,7 +72,7 @@ interface ErrorBannerProps { usageLimitRecovery?: UsageLimitRecoveryHint | null; /** 当前 session 的 agent kind。codex 的 401 / Missing bearer 必须 hide Retry, * 否则 retry 撞同一个 in-memory auth retry-loop 产生重复失败 turn。 */ - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; /** 当前 session 的远端 host id;非空 + agentKind='codex' 时显「同步登录态」按钮。 * 本地 codex 401 仍 hide Retry, 但只能提示用户去自己 fix login (没有 sync 入口)。 */ remoteHostId?: string; diff --git a/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx b/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx index d17649b6e6..cf351e388f 100644 --- a/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx @@ -178,7 +178,7 @@ export function ErrorTailErrorBanner({ errorText: string; onContinue: () => Promise | void; onDismiss: () => void; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; remoteHostId?: string; deviceLinkDeviceId?: string | null; modelId?: string; diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index 3a18791e81..fc5cdee3ff 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -343,7 +343,7 @@ interface MessageStreamProps { sessionTitle?: string | null; /** Owning agent kind — propagated to UserMessage so capability gates * (fork/rewind icon visibility) can read the right agent's capabilities. */ - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; /** Owning session's remote SSH host id (null for local sessions). Forwarded * so message-level controls can gate features unsupported on remote * (e.g. rewind on cc-remote daemon sessions). */ @@ -2871,7 +2871,7 @@ function renderWorkGroupChild( workingDir: string; sessionId?: string; sessionTitle?: string | null; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; remoteHostId?: string | null; isSessionStreaming: boolean; firstUserMessageClientId: string | null; @@ -6295,7 +6295,7 @@ const MessageItem = memo(function MessageItem({ remoteHostId?: string | null; /** Forwarded to User/AssistantMessage so they can read this agent's * capabilities (gates Fork/Rewind icon visibility). */ - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; /** Whether this session currently has an in-flight SDK turn. Rewind uses it * to require an idle live query; fork can still target stable history. */ sessionRunning?: boolean; diff --git a/apps/desktop/src/renderer/components/icons/GrokBuildMark.tsx b/apps/desktop/src/renderer/components/icons/GrokBuildMark.tsx new file mode 100644 index 0000000000..bcb67e4d7e --- /dev/null +++ b/apps/desktop/src/renderer/components/icons/GrokBuildMark.tsx @@ -0,0 +1,36 @@ +/** + * GrokBuildMark — Grok Build (xAI terminal coding agent) identity mark. + * + * Geometric "G" / chevron mark at 13-14px, visual weight aligned with PiMark / + * ClaudeMark / CodexMark. Not SuperGrok OAuth branding. + */ + +interface GrokBuildMarkProps { + size?: number; + className?: string; + variant?: 'mono' | 'brand'; +} + +export function GrokBuildMark({ size = 14, className }: GrokBuildMarkProps) { + return ( + + + + + + + ); +} diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index a412291c89..8d672f4f88 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -67,6 +67,7 @@ import { SlashCommandDecoration, } from './SlashCommandDecoration'; +import type { SelectableVendor } from '@/lib/agentVendors'; import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { toast } from '@/lib/toast'; @@ -656,7 +657,7 @@ interface ChatInputProps { * M35: Vendor lock — when provided, ModelSelector only shows models * belonging to this vendor ('cc' for Claude, 'codex' for OpenAI Codex). */ - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: SelectableVendor; /** * Optional override for the composerDraftStore key used to persist editor * content (and via attachmentState, attachments) across mount/unmount. @@ -766,7 +767,7 @@ interface ChatInputProps { * `lastByVendor.model` 并原样进 createSession,写错就是首条请求路由到一个不存在的模型。 */ onUnifiedDraftSelect?: (selection: { - vendor: 'cc' | 'codex' | 'pi'; + vendor: SelectableVendor; providerId: string; /** 选中引擎的 **wire model id**。 */ modelId: string; @@ -787,14 +788,18 @@ interface ChatInputProps { const UNIFIED_AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; /** AgentKind → NewMaker vendor(useAvailableAgents 用 vendor 口径)。 */ -function agentKindToVendor(kind: AgentKind): 'cc' | 'codex' | 'pi' { - return kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : 'cc'; +function agentKindToVendor(kind: AgentKind): SelectableVendor { + if (kind === 'codex') return 'codex'; + if (kind === 'pi') return 'pi'; + if (kind === 'grok-build') return 'grok-build'; + return 'cc'; } -function vendorKeyToAgentKind(v?: 'cc' | 'codex' | 'pi'): AgentKind | null { +function vendorKeyToAgentKind(v?: SelectableVendor): AgentKind | null { if (v === 'cc') return 'claude-code'; if (v === 'codex') return 'codex'; if (v === 'pi') return 'pi'; + if (v === 'grok-build') return 'grok-build'; return null; } @@ -6105,7 +6110,7 @@ export function ChatInput({ ) => void | boolean | Promise; }>({ byProvider: () => {}, byModel: () => {} }); const confirmAgentBrowseSwitch = useCallback( - (targetAgent: 'claude-code' | 'codex' | 'pi' | null) => + (targetAgent: AgentKind | null) => confirmAgentSwitchRisk({ // 只有「目标就是会话正在跑的真实引擎」才不必再问(回原引擎 = same-engine no-op, // 不重建上下文)。挂着的切换意图不算已经确认过(Chris 2026-08-20):Claude 任务里 @@ -6130,7 +6135,7 @@ export function ChatInput({ ); const performAgentSwitch = useCallback( async ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: AgentKind, newModelId: string, providerId: string | null = null, // 意图期内的档位/Fast 改动经此显式覆盖(用户手选优先于记忆/默认解析)。 @@ -6618,7 +6623,7 @@ export function ChatInput({ /** 选中引擎的 **wire model id** —— 唯一可发送、可当记忆键的那个 id。 */ modelId: string; effort?: Effort; - engine: 'cc' | 'codex' | 'pi'; + engine: SelectableVendor; fast: boolean; favoriteUid: string | null; /** 行的归一化 id(面板行身份)。草稿层不消费,更不作为发送 id。 */ @@ -8344,7 +8349,7 @@ export function ChatInput({ currentVendor: vendorKey, // 两步分段的目标是 vendor 口径,确认门按 AgentKind 判(与意图 // 记录同形),在边界上转一次 —— 见 confirmAgentBrowseSwitch。 - confirmBrowseSwitch: (targetVendor: 'cc' | 'codex' | 'pi') => + confirmBrowseSwitch: (targetVendor: SelectableVendor) => confirmAgentBrowseSwitch(vendorKeyToAgentKind(targetVendor)), onSwitch: performAgentSwitch, } diff --git a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx index 5806a447f9..54b555007d 100644 --- a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx @@ -550,7 +550,7 @@ function RemoteModelLoadNotice({ } export interface ModelSelectorAgentIdentity { - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: SelectableVendor; /** * current = 已由会话/runtime 元数据确认的当前 Agent; * pending = 已登记、将在下一条消息应用的切换目标。 @@ -562,8 +562,8 @@ export function resolveModelSelectorAgentIdentity( runtimeAgentKind: AgentKind | null | undefined, pendingTarget: AgentKind | null | undefined, ): ModelSelectorAgentIdentity | undefined { - const toVendorKey = (kind: AgentKind): 'cc' | 'codex' | 'pi' => - kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : 'cc'; + const toVendorKey = (kind: AgentKind): SelectableVendor => + kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : kind === 'grok-build' ? 'grok-build' : 'cc'; if (pendingTarget) { return { vendorKey: toVendorKey(pendingTarget), @@ -626,7 +626,7 @@ interface ModelSelectorProps { /** 非选中模型行的 effort/fast 全局预设读写器(按本机 / 被控设备隔离)。 */ modelMemory?: ModelMemoryAccessors; /** When provided, only models with this vendorKey are shown in the dropdown. */ - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: SelectableVendor; /** * 已创建会话的 trigger 同时展示 Agent 与模型,避免 Claude Code 使用 OpenAI 模型时 * 只看来源图标而误判成 Codex。必须由权威 session/runtime 身份或明确切换 intent 提供, @@ -739,7 +739,7 @@ interface ModelSelectorProps { * device-link / SSH 远程不传(v1 不支持切换)。 */ agentSwitch?: { - currentVendor: 'cc' | 'codex' | 'pi'; + currentVendor: SelectableVendor; /** * 进入非当前 Agent 浏览态前确认;false 时保持原分段,什么都不改。 * @@ -747,14 +747,14 @@ interface ModelSelectorProps { * 判据是「会话上已有**指向该目标**的切换意图」。不传目标,它只能判「有没有意图」, * 于是先切 Codex 再选 Pi 时确认框永久静默(见 agentSwitchConfirmation.hasSwitchIntent)。 */ - confirmBrowseSwitch?: (targetVendor: 'cc' | 'codex' | 'pi') => Promise; + confirmBrowseSwitch?: (targetVendor: SelectableVendor) => Promise; /** * 返回值(若有)= 切换事务**真的登记成功了没有**;本两步分段路径不消费它, * 声明成宽联合只是为了让同一个 `performAgentSwitch` 能同时喂给这里与统一面板的 * `onCrossEngineSelect`(后者按真实结果决定要不要做清理动作)。 */ onSwitch: ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: AgentKind, modelId: string, providerId: string | null, ) => void | boolean | Promise; @@ -777,7 +777,7 @@ interface ModelSelectorContentProps { thinkingEnabled?: boolean; onThinkingChange?: (enabled: boolean) => void | Promise; modelMemory?: ModelMemoryAccessors; - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: SelectableVendor; /** device-link 远程会话所属被控端 id(列被控端模型)。 */ deviceId?: string; /** SSH 远程会话隐藏订阅直连模型(语义同 ModelSelectorProps 同名字段)。 */ @@ -885,7 +885,7 @@ interface ModelSelectorContentProps { anchor: { uid: string; wireModelId: string; - engine: 'cc' | 'codex' | 'pi'; + engine: SelectableVendor; /** 选中时的显式来源。来源也是锚点身份的一部分:同 wire id 同引擎、仅来源不同的 * 配置是两份配置,少了它,别的窗口把会话来源从 A 切到 B 后,面板仍在 A 的收藏上 * 打勾(2026-08-17 review)。 */ @@ -910,7 +910,7 @@ interface ModelSelectorContentProps { modelId: string; /** 该行生效档位;该 (模型, 引擎) 不可调档时为 undefined。 */ effort?: Effort; - engine: 'cc' | 'codex' | 'pi'; + engine: SelectableVendor; fast: boolean; favoriteUid: string | null; /** 配置浮层「恢复推荐」的应用动作;调用方应删除 override,不得重新记忆推荐值。 */ @@ -931,16 +931,16 @@ interface ModelSelectorContentProps { fluidWidth?: boolean; /** 语义同 ModelSelectorProps.agentSwitch(显式两步引擎切换)。 */ agentSwitch?: { - currentVendor: 'cc' | 'codex' | 'pi'; + currentVendor: SelectableVendor; /** 语义同 ModelSelectorProps.agentSwitch.confirmBrowseSwitch(带本次目标引擎)。 */ - confirmBrowseSwitch?: (targetVendor: 'cc' | 'codex' | 'pi') => Promise; + confirmBrowseSwitch?: (targetVendor: SelectableVendor) => Promise; /** * 返回值(若有)= 切换事务**真的登记成功了没有**;本两步分段路径不消费它, * 声明成宽联合只是为了让同一个 `performAgentSwitch` 能同时喂给这里与统一面板的 * `onCrossEngineSelect`(后者按真实结果决定要不要做清理动作)。 */ onSwitch: ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: AgentKind, modelId: string, providerId: string | null, ) => void | boolean | Promise; @@ -961,10 +961,11 @@ interface ModelSelectorContentProps { interactionDisabled?: boolean; } -function vendorKeyToAgentKind(v?: 'cc' | 'codex' | 'pi'): AgentKind | null { +function vendorKeyToAgentKind(v?: SelectableVendor): AgentKind | null { if (v === 'cc') return 'claude-code'; if (v === 'codex') return 'codex'; if (v === 'pi') return 'pi'; + if (v === 'grok-build') return 'grok-build'; return null; } @@ -1104,11 +1105,11 @@ function ModelSelectorContentView({ const modelTagDensity = modelTagDensityForWidth(paneWidth ?? (fluidWidth ? null : 320)); // session-agent-switch:两步式引擎切换的浏览态。browseVendor 初始 = 会话当前引擎; // 切到另一家 tab 只是「浏览目标引擎的模型」,选中模型行才真正触发切换事务。 - const [browseVendor, setBrowseVendor] = useState<'cc' | 'codex' | 'pi'>( + const [browseVendor, setBrowseVendor] = useState( agentSwitch?.currentVendor ?? vendorKey ?? 'cc', ); const browseSwitchPendingRef = useRef(false); - const handleBrowseVendorChange = async (next: 'cc' | 'codex' | 'pi') => { + const handleBrowseVendorChange = async (next: SelectableVendor) => { if (interactionDisabled || next === browseVendor || browseSwitchPendingRef.current) return; // 返回当前引擎(含已有意图时浏览原引擎准备撤销)不需要确认;只有从 // currentVendor 进入另一 Agent 浏览态才调用上层风险确认。确认前绝不翻分段。 @@ -1130,10 +1131,9 @@ function ModelSelectorContentView({ const agentKind = agentSwitch ? vendorKeyToAgentKind(browseVendor) : vendorKeyToAgentKind(vendorKey); - const browseTargetLabel = - browseVendor === 'codex' ? 'Codex' : browseVendor === 'pi' ? 'Pi' : 'Claude Code'; + const browseTargetLabel = agentOptionOf(browseVendor).label; const enqueueAgentSwitch = ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: AgentKind, targetModelId: string, targetProviderId: string | null, ) => { @@ -1816,7 +1816,7 @@ function ModelSelectorContentView({ // trigger 来源 icon / 路由立即正确(null = flat 退化行,交给默认路由)。 if (browsing && agentSwitch) { enqueueAgentSwitch( - browseVendor === 'codex' ? 'codex' : browseVendor === 'pi' ? 'pi' : 'claude-code', + vendorKeyToAgentKind(browseVendor) ?? 'claude-code', id, providerId, ); @@ -3285,7 +3285,7 @@ export function ModelSelector({ if (!confirmBrowseSwitch) return agentSwitch; return { ...agentSwitch, - confirmBrowseSwitch: async (targetVendor: 'cc' | 'codex' | 'pi') => { + confirmBrowseSwitch: async (targetVendor: SelectableVendor) => { setKeepOpenForAgentConfirmation(true); try { return await confirmBrowseSwitch(targetVendor); diff --git a/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx b/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx index dd31095e75..272171f274 100644 --- a/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx @@ -24,7 +24,7 @@ import type { PermissionMode } from '@/lib/userPreferences.types'; interface PermissionSelectorProps { permissionMode: PermissionMode; onPermissionModeChange: (mode: PermissionMode) => void; - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: 'cc' | 'codex' | 'pi' | 'grok-build'; /** device-link 远程会话所属被控端 id;非空 = 权限档从被控端读(本地会话 undefined,行为不变)。 */ deviceId?: string; /** 禁用 trigger。用于断线远程会话等只读 composer 状态。 */ @@ -66,9 +66,10 @@ const PERMISSION_ICONS: Record = { bypassPermissions: TriangleAlert, }; -function vendorKeyToAgentKind(v: 'cc' | 'codex' | 'pi'): AgentKind { +function vendorKeyToAgentKind(v: 'cc' | 'codex' | 'pi' | 'grok-build'): AgentKind { if (v === 'codex') return 'codex'; if (v === 'pi') return 'pi'; + if (v === 'grok-build') return 'grok-build'; return 'claude-code'; } diff --git a/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx b/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx index b39e04900b..5fe757adf1 100644 --- a/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx +++ b/apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx @@ -40,6 +40,7 @@ const ENGINE_BADGE_TINT: Record = { cc: 'var(--engine-badge-cc)', codex: 'var(--engine-badge-codex)', pi: 'var(--engine-badge-pi)', + 'grok-build': 'var(--engine-badge-grok-build)', }; /** diff --git a/apps/desktop/src/renderer/components/new-chat/agentOptions.ts b/apps/desktop/src/renderer/components/new-chat/agentOptions.ts index db99948211..58e45dda4e 100644 --- a/apps/desktop/src/renderer/components/new-chat/agentOptions.ts +++ b/apps/desktop/src/renderer/components/new-chat/agentOptions.ts @@ -17,6 +17,7 @@ import type { ComponentType } from 'react'; import { ClaudeMark } from '@/components/icons/ClaudeMark'; import { CodexMark } from '@/components/icons/CodexMark'; import { PiMark } from '@/components/icons/PiMark'; +import { GrokBuildMark } from '@/components/icons/GrokBuildMark'; import { SELECTABLE_VENDORS, type SelectableVendor } from '@/lib/agentVendors'; export interface AgentOption { @@ -30,6 +31,7 @@ const VENDOR_PRESENTATION: Record> cc: { label: 'Claude', Mark: ClaudeMark }, codex: { label: 'Codex', Mark: CodexMark }, pi: { label: 'Pi', Mark: PiMark }, + 'grok-build': { label: 'Grok Build', Mark: GrokBuildMark }, }; export const AGENT_OPTIONS: readonly AgentOption[] = SELECTABLE_VENDORS.map((vendor) => ({ diff --git a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts index 2bba5934c5..377a9bb7b7 100644 --- a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts +++ b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts @@ -30,12 +30,18 @@ export type UnifiedEngine = SelectableVendor; /** vendor → AgentKind(查目录 / 能力 / 记忆时用)。 */ export function agentKindOfEngine(engine: UnifiedEngine): AgentKind { - return engine === 'cc' ? 'claude-code' : engine === 'codex' ? 'codex' : 'pi'; + if (engine === 'cc') return 'claude-code'; + if (engine === 'codex') return 'codex'; + if (engine === 'grok-build') return 'grok-build'; + return 'pi'; } /** AgentKind → vendor(落 store / draft 时用)。未知值回落 cc,与既有 sanitize 方向一致。 */ export function engineOfAgentKind(agent: AgentKind): UnifiedEngine { - return agent === 'codex' ? 'codex' : agent === 'pi' ? 'pi' : 'cc'; + if (agent === 'codex') return 'codex'; + if (agent === 'pi') return 'pi'; + if (agent === 'grok-build') return 'grok-build'; + return 'cc'; } /** diff --git a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx index d98dc09ea6..5c18a6b61a 100644 --- a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx +++ b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx @@ -90,6 +90,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; function presetRuntimeBaseUrl( diff --git a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx index 8cb519b741..e67d7aecd0 100644 --- a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx @@ -113,6 +113,11 @@ type DialogAgentKind = Extract; const AGENTS: DialogAgentKind[] = ['claude-code', 'codex', 'pi']; +/** grok-build 不在本面板:它是本机 CLI,没有自定义 provider / baseUrl 可配。 */ +function isDialogAgentKind(value: string): value is DialogAgentKind { + return (AGENTS as string[]).includes(value); +} + const VISIBLE_AGENTS: DialogAgentKind[] = AGENTS; const DIALOG_FOCUSABLE_SELECTOR = [ @@ -784,7 +789,7 @@ export function CustomProviderDialog({ // 的 runtime 上,handleSave 的守卫拦不住"用户已经看不到"的这条草稿,表单 // 卡死报错却找不到对应输入框(review P1)。 setWindowDrafts({}); - const first = configuredPresetAgents(p)[0]; + const first = configuredPresetAgents(p).find(isDialogAgentKind); if (first) setActiveTab(first); }, [i18n.language, setRtSynced], @@ -1484,8 +1489,8 @@ export function CustomProviderDialog({ for (const [draftKey, draftText] of Object.entries(windowDrafts)) { if (isCommittableWindowText(draftText)) continue; const sep = draftKey.lastIndexOf(':'); - const draftAgent = draftKey.slice(0, sep) as AgentKind; - if (!VISIBLE_AGENTS.includes(draftAgent)) continue; + const draftAgent = draftKey.slice(0, sep); + if (!isDialogAgentKind(draftAgent) || !VISIBLE_AGENTS.includes(draftAgent)) continue; // 该 runtime 未配置 baseUrl、或该行 id/name 为空:两者都会在下面序列化时 // 被丢弃,不会写进最终配置,草稿再非法也不该挡住一个原本有效的保存 // (review P1)。 diff --git a/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx b/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx index 8e5fbfd2ea..832a513c34 100644 --- a/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx +++ b/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx @@ -493,6 +493,8 @@ function toVendorKey(agentKind: string | null): 'cc' | 'codex' | 'pi' { return agentKind === 'codex' || agentKind === 'pi' ? agentKind : 'cc'; } +const HOOK_HIDDEN_VENDORS: MakerVendor[] = ['grok-build']; + /** * 选择器的 vendor key → hook prefs 的 agentKind。 * MakerVendor 还含 'orca' 等本编辑器不支持的值 —— 分段只有 Claude/Codex 两项,该分支 @@ -585,6 +587,7 @@ export function WorkspacePrefsEditor({ // 默认 agent),重选它 = 钉成显式偏好 —— 与模型字段的 reselectEmitsChange 同语义; // 显式同值由下方 nextAgent === prefs.agentKind 去重,不产生空写。 reselectEmitsChange + hiddenVendors={HOOK_HIDDEN_VENDORS} onChange={(next) => { const nextAgent = toAgentKind(next); if (nextAgent === prefs.agentKind) return; diff --git a/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx b/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx index f4ec646fc4..cd1d9253b5 100644 --- a/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx @@ -18,6 +18,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; interface Props { diff --git a/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx b/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx index 6432386167..a270f96c54 100644 --- a/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx +++ b/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx @@ -63,6 +63,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; /** diff --git a/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx b/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx index f56dc8b4cc..501c341af7 100644 --- a/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx +++ b/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx @@ -28,6 +28,7 @@ const AGENT_RANK: Record = { 'claude-code': 0, codex: 1, pi: 2, + 'grok-build': 3, }; const TH_CLASS = diff --git a/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx b/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx index 2660bd7b47..0bade96470 100644 --- a/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx +++ b/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx @@ -16,8 +16,9 @@ import { cn } from '@/lib/utils'; import { ClaudeMark } from '@/components/icons/ClaudeMark'; import { CodexMark } from '@/components/icons/CodexMark'; +import { GrokBuildMark } from '@/components/icons/GrokBuildMark'; -export type VendorIconKind = 'cc' | 'codex' | 'pi'; +export type VendorIconKind = 'cc' | 'codex' | 'pi' | 'grok-build'; /** * agentKind → VendorIcon vendor 的唯一映射。所有渲染 agent 身份图标的调用点 @@ -25,7 +26,10 @@ export type VendorIconKind = 'cc' | 'codex' | 'pi'; * 吞成 Claude 脸,2026-07-30 实测 bug)。兼容 'claude-code' 别名与 null。 */ export function agentKindToVendor(kind: string | null | undefined): VendorIconKind { - return kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : 'cc'; + if (kind === 'codex') return 'codex'; + if (kind === 'pi') return 'pi'; + if (kind === 'grok-build') return 'grok-build'; + return 'cc'; } interface VendorIconProps { @@ -59,6 +63,8 @@ export function VendorIcon({ {vendor === 'codex' ? ( + ) : vendor === 'grok-build' ? ( + ) : vendor === 'pi' ? ( m.id === model); @@ -5527,7 +5527,7 @@ function ContextCapacityRing({ }: { contextTokens: number; model: string; - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'; /** SDK-reported context window; 0 = not yet known → use hardcoded fallback. */ sdkContextWindow: number; /** device-link 远程会话所属被控端 id;按被控端能力查 contextWindow(本机会话 undefined,行为不变)。 */ diff --git a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx index 70b4f1ac9b..09bfb139e2 100644 --- a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx @@ -71,6 +71,7 @@ import { remoteProjectsStore } from '@/features/device-link/remoteProjectsStore' import { dbToMakerAgentKind, normalizeDbAgentKind, + type DbAgentKind, type MakerAgentKindWire, } from '../../../shared/agentKindConversion'; import { getBranchName } from '../../../shared/managedWorktreeBranches'; @@ -771,8 +772,8 @@ export function NewMakerDraftRoute() { * 并清掉 —— 持久化之后那等于一切引擎只能记住最后一次选择。 */ const draftFavoriteAnchor = useDraftFavoriteAnchor(normalizeDbAgentKind(draft.vendor)); - const persistedAgentKind: 'cc' | 'codex' | 'pi' = normalizeDbAgentKind(draft.vendor); - const authVendor: 'cc' | 'codex' | 'pi' = persistedAgentKind; + const persistedAgentKind: DbAgentKind = normalizeDbAgentKind(draft.vendor); + const authVendor: DbAgentKind = persistedAgentKind; const capabilityAgentKind = dbToMakerAgentKind(persistedAgentKind); // 品牌区跟随当前主题;icon / logo 的固定布局统一由 ThemeBrandLockup 负责。 @@ -924,7 +925,7 @@ export function NewMakerDraftRoute() { ); const hiddenSwitcherVendors = useMemo(() => { if (!availableAgentsLoaded) return []; - return (['cc', 'codex', 'pi'] as const).filter((vendor) => !availableVendors.has(vendor)); + return (['cc', 'codex', 'pi', 'grok-build'] as const).filter((vendor) => !availableVendors.has(vendor)); }, [availableAgentsLoaded, availableVendors]); /** * 「这份草稿要建到对端设备上」—— 只看 deviceId,**不再要求 workingDir**(#807)。 @@ -2144,7 +2145,7 @@ export function NewMakerDraftRoute() { const carryDraftFavoriteAnchorToSession = useCallback( ( newSessionId: string, - engine: 'cc' | 'codex' | 'pi', + engine: 'cc' | 'codex' | 'pi' | 'grok-build', model: string, providerId: string | null, ): void => { @@ -2366,7 +2367,7 @@ export function NewMakerDraftRoute() { const handleRemoteProjectAdded = useCallback( async (target: RemoteProjectTarget) => { // vendor 由外层 VendorSegmentedSwitcher (draft.vendor) 单一决策 —— dialog 不再让用户选。 - const draftVendor: 'cc' | 'codex' | 'pi' = normalizeDbAgentKind(draft.vendor); + const draftVendor: DbAgentKind = normalizeDbAgentKind(draft.vendor); if (target.kind === 'device-link') { // device-link:**不**像 SSH 立即建会话(会在被控端留空会话)。改为把当前草稿指向该被控 @@ -3847,7 +3848,10 @@ export function NewMakerDraftRoute() { // agent 启动时看到的工作区已是迁移后的状态。fail-soft:检测错误只 warn,不阻塞 send。 try { const wd = effectiveWorkingDir; - if (wd && !isRemoteProjectDraft && persistedAgentKind !== 'pi') { + // 迁移只覆盖 CLAUDE.md ↔ AGENTS.md 两家;pi 与 grok-build 没有对应的约定文件。 + const crossAgentMigratable = + persistedAgentKind === 'cc' || persistedAgentKind === 'codex'; + if (wd && !isRemoteProjectDraft && crossAgentMigratable) { const r = await crossAgentConvertService.detect( wd, persistedAgentKind === 'cc' ? 'claude-code' : persistedAgentKind, diff --git a/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts b/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts index 755b6cbd87..887bd79e15 100644 --- a/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts +++ b/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts @@ -23,8 +23,8 @@ import type { AgentKind } from '@/hooks/useAgentCapabilities'; import type { Effort, PermissionMode } from '@/lib/userPreferences.types'; export interface DeviceLinkCreateParams { - /** 草稿 vendor 形态:'cc' | 'codex' | 'pi'(persistedAgentKind)。 */ - agentKind: 'cc' | 'codex' | 'pi'; + /** 草稿 vendor 形态:'cc' | 'codex' | 'pi' | 'grok-build'(persistedAgentKind)。 */ + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; /** * 被控端上的项目目录。缺省 / 空白 = 在该设备上建**不绑项目的 standalone dialogue**, * workspaceKind 随之派生为 'dialogue',运行目录由被控端分配。 @@ -56,7 +56,7 @@ export interface DeviceLinkCreateParams { } export interface DeviceLinkCreateArgs { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** 仅远程 worktree 流程出现(与 worktree:create 登记的绑定同 id)。 */ id?: string; /** 仅项目会话出现;dialogue 不带此字段(被控端自行分配运行目录)。 */ @@ -111,7 +111,7 @@ export interface DeviceLinkSubmissionCandidate { } export interface DeviceLinkSubmissionParams { - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; workingDir?: string; id?: string; extraDirs?: string[]; @@ -217,7 +217,7 @@ export function buildProvisionalRemoteSession(p: ProvisionalRemoteSessionParams) // 侧边栏按这条时间轴排序,新会话该立刻浮到顶部。 userSendAt: p.nowIso, status: 'active', - // Session.agentKind 是本机形态('cc' | 'codex' | 'pi'),args 里是 maker-core 形态,这里转回来。 + // Session.agentKind 是本机形态('cc' | 'codex' | 'pi' | 'grok-build'),args 里是 maker-core 形态,这里转回来。 agentKind: p.args.agentKind === 'claude-code' ? 'cc' : p.args.agentKind, extraDirs: p.args.extraDirs ?? [], createdAt: p.nowIso, diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts b/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts index 43c77eb495..8cc5e54e48 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts @@ -34,7 +34,7 @@ export interface SessionTaskItem { /** 标题链取不到任何来源时为 ''(UI 层负责 i18n 兜底)。 */ title: string; status: AgentTaskStatus; - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'grok-build'; update?: AgentTaskUpdate; toolCallClientId?: string; toolUseId?: string; diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx index 04ba8f25f3..c7c1afc157 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx @@ -40,12 +40,14 @@ const KIND_ICON: Record = { utility: Cog, 'agent-claude': Bot, 'agent-codex': Bot, + 'agent-grok-build': Bot, 'agent-pi': Bot, }; const AGENT_NAME: Record = { 'agent-claude': 'Claude Code', 'agent-codex': 'Codex', + 'agent-grok-build': 'Grok Build', 'agent-pi': 'Pi', }; diff --git a/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx b/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx index 6720f1e9d3..3334255d6c 100644 --- a/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx +++ b/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx @@ -51,7 +51,7 @@ import type { SessionReference } from '../../../../shared/sessionReference'; import { isReviewSessionSource } from '../../../../shared/sessionSource'; export type Destination = 'local' | 'worktree' | 'thread'; -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; interface ChipButtonProps { icon?: React.ReactNode; diff --git a/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts b/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts index e536cb146c..6a3681301b 100644 --- a/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts +++ b/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts @@ -85,6 +85,7 @@ function defaultScheduleFormPrefs(): ScheduleFormPrefs { 'claude-code': EMPTY_AGENT_PREFS, codex: EMPTY_AGENT_PREFS, pi: EMPTY_AGENT_PREFS, + 'grok-build': EMPTY_AGENT_PREFS, }, }; } @@ -95,7 +96,7 @@ function loadScheduleFormPrefs(): ScheduleFormPrefs { const raw = window.localStorage.getItem(SCHEDULE_FORM_PREFS_KEY); if (!raw) return defaultScheduleFormPrefs(); const parsed = JSON.parse(raw) as Partial; - const agentKind = parsed.agentKind === 'codex' ? 'codex' : parsed.agentKind === 'pi' ? 'pi' : 'claude-code'; + const agentKind = parsed.agentKind === 'codex' ? 'codex' : parsed.agentKind === 'pi' ? 'pi' : parsed.agentKind === 'grok-build' ? 'grok-build' : 'claude-code'; const workingDir = typeof parsed.workingDir === 'string' ? parsed.workingDir : ''; const workspaceKind = normalizePrefsWorkspaceKind(parsed.workspaceKind, workingDir); return { @@ -107,6 +108,7 @@ function loadScheduleFormPrefs(): ScheduleFormPrefs { 'claude-code': sanitizeAgentPrefs(parsed.lastByAgent?.['claude-code']), codex: sanitizeAgentPrefs(parsed.lastByAgent?.codex), pi: sanitizeAgentPrefs(parsed.lastByAgent?.pi), + 'grok-build': sanitizeAgentPrefs(parsed.lastByAgent?.['grok-build']), }, }; } catch { diff --git a/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts b/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts index 22249c265b..c19e93b1e7 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts @@ -14,7 +14,7 @@ export interface ProjectScheduleConfig { recurring?: boolean; manual?: boolean; intervalMs?: number; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model?: string; /** 显式来源(供应商)id;省略 = 使用该 Agent 的原生默认来源。 */ providerId?: string; diff --git a/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts b/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts index 188febd361..e82eda0c4a 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts @@ -119,7 +119,7 @@ export interface ScheduleFormState { recurring: boolean; /** 手动模式:true → 创建后永不自动 fire,只能 Run now。UI 上需要 recurring=false 才能勾。 */ manual: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; /** * 显式选定的来源(供应商)id。'' = 跟随该 agent 原生默认来源(no-break,与未升级 @@ -350,10 +350,11 @@ export function applyRunMode( /** renderer Session.agentKind('cc'|'codex')→ schedule agentKind 映射。 */ export function sessionAgentKindToScheduleAgentKind( - kind: 'cc' | 'codex' | 'pi', + kind: 'cc' | 'codex' | 'pi' | 'grok-build', ): ScheduleFormState['agentKind'] { if (kind === 'codex') return 'codex'; if (kind === 'pi') return 'pi'; + if (kind === 'grok-build') return 'grok-build'; return 'claude-code'; } diff --git a/apps/desktop/src/renderer/features/scheduler/lib/usageLimitScheduleCreateIntent.ts b/apps/desktop/src/renderer/features/scheduler/lib/usageLimitScheduleCreateIntent.ts index d586d888ec..8dec76fbe0 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/usageLimitScheduleCreateIntent.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/usageLimitScheduleCreateIntent.ts @@ -4,7 +4,7 @@ export interface UsageLimitScheduleCreateIntent { kind: 'usage-limit-recovery'; requestId: string; sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; resetAtMs: number | null; } diff --git a/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts b/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts index 7386df62f4..8fd017e141 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts @@ -1,6 +1,7 @@ interface LocalSkillRouteEntry { id: string; - engine: 'claude-code' | 'codex' | 'pi'; + // Track the scanner's engine union so a new agent runtime cannot break the constraint. + engine: SkillhubSkill['engine']; kind: SkillhubKind; scope: SkillhubScope; name: string; diff --git a/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts b/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts index 1020b75aa2..18038849d1 100644 --- a/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts +++ b/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts @@ -17,12 +17,12 @@ import type { Effort, PermissionMode } from '@/lib/userPreferences.types'; const log = createLogger('useAgentCapabilities'); -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; // capability 生命周期(预取 / 驱逐通知 / 本地快照刷新 / 启动预载)必须覆盖全部 agent, // 少一个就会让该 agent 的远程会话在断链或 provider revision 后收不到 loading 事件、 // 也不再被重新预取,界面永久停在旧模型/能力快照(codex review)。新增 agent 只改这里。 -const ALL_AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const; +const ALL_AGENT_KINDS = ['claude-code', 'codex', 'pi', 'grok-build'] as const; // renderer 视角: id 全部是不透明 string, 渲染只读 displayName。 // effort 的合法 id 集合 = capabilities.effortLevels 上每个项的 id。 @@ -55,7 +55,7 @@ export interface ModelDescriptor { * 解耦;生产环境 XD 网关由服务端按区域下发)。消费点见 modelDefinitions.newSessionDefaultModelId * 与 draftModelCalibration:被标记且可用的模型优先作新对话默认。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'grok-build')[]; } export interface EffortDescriptor { diff --git a/apps/desktop/src/renderer/hooks/useAvailableAgents.ts b/apps/desktop/src/renderer/hooks/useAvailableAgents.ts index 27fa928ab1..65a3ae20b9 100644 --- a/apps/desktop/src/renderer/hooks/useAvailableAgents.ts +++ b/apps/desktop/src/renderer/hooks/useAvailableAgents.ts @@ -20,7 +20,7 @@ import { createLogger } from '@/lib/logger'; const log = createLogger('useAvailableAgents'); -type RuntimeAgentKind = 'claude-code' | 'codex' | 'pi'; +type RuntimeAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** runtime agent id → NewMaker vendor(其余保持同名)。 */ function toVendor(agent: RuntimeAgentKind): MakerVendor { @@ -47,7 +47,7 @@ async function fetchAvailableAgents(deviceId?: string | null): Promise - v === 'claude-code' || v === 'codex' || v === 'pi') as RuntimeAgentKind[]) : []; + v === 'claude-code' || v === 'codex' || v === 'pi' || v === 'grok-build') as RuntimeAgentKind[]) : []; } const api = getMakerApi(); if (!api) throw new Error('maker IPC not available'); diff --git a/apps/desktop/src/renderer/hooks/useMakerSession.ts b/apps/desktop/src/renderer/hooks/useMakerSession.ts index 2e66605b97..a1b257041c 100644 --- a/apps/desktop/src/renderer/hooks/useMakerSession.ts +++ b/apps/desktop/src/renderer/hooks/useMakerSession.ts @@ -15,13 +15,13 @@ interface MakerEvent { interface SessionInfo { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workDir: string; capabilities: unknown; } interface CreateSessionParams { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; /** 与 maker-core/types/common.ts 的 Effort union 一致 */ diff --git a/apps/desktop/src/renderer/hooks/useUsageHistory.ts b/apps/desktop/src/renderer/hooks/useUsageHistory.ts index de6bfbcefc..7796aba993 100644 --- a/apps/desktop/src/renderer/hooks/useUsageHistory.ts +++ b/apps/desktop/src/renderer/hooks/useUsageHistory.ts @@ -31,7 +31,7 @@ import { } from '../../shared/regionalMoney'; export interface UsageHistoryModel { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; money: RegionalMoney; estimatedMoney: RegionalMoney | null; @@ -44,7 +44,7 @@ export interface UsageHistoryModel { /** 每日 × 模型明细 — 右栏堆叠柱状图分段。 */ export interface UsageHistoryModelDay { day: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; money: RegionalMoney; apiMoney: RegionalMoney; diff --git a/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts b/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts index 174f31eaea..10120c1777 100644 --- a/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts +++ b/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts @@ -54,7 +54,9 @@ type CopyKey = | 'voice-direct-api-key-unauth' | 'codex-voice-unauth' | 'codex-binary-missing' - | 'pi-binary-missing'; + | 'pi-binary-missing' + | 'grok-build-binary-missing' + | 'grok-build-unauth'; function buildCopy(t: (key: string) => string): Record { return { @@ -103,6 +105,20 @@ function buildCopy(t: (key: string) => string): Record { cancelText: t('logic.confirm.cancel'), settingsTab: 'providers', }, + 'grok-build-binary-missing': { + title: t('logic.confirm.grokBuildBinaryMissingTitle'), + description: t('logic.confirm.grokBuildBinaryMissingDescription'), + confirmText: t('logic.confirm.gotIt'), + cancelText: t('logic.confirm.cancel'), + settingsTab: 'providers', + }, + 'grok-build-unauth': { + title: t('logic.confirm.grokBuildUnauthenticatedTitle'), + description: t('logic.confirm.grokBuildUnauthenticatedDescription'), + confirmText: t('logic.confirm.grokBuildLogin'), + cancelText: t('logic.confirm.cancel'), + settingsTab: 'providers', + }, }; } @@ -122,6 +138,8 @@ function pickCopy( ): DialogCopy | null { if (readiness === 'binary-missing' && vendor === 'codex') return copy['codex-binary-missing']; if (readiness === 'binary-missing' && vendor === 'pi') return copy['pi-binary-missing']; + if (readiness === 'binary-missing' && vendor === 'grok-build') return copy['grok-build-binary-missing']; + if (readiness === 'unauthenticated' && vendor === 'grok-build') return copy['grok-build-unauth']; if (readiness !== 'unauthenticated') return null; // 无可用来源:cc / codex 走同一条「连接来源」文案(send 门禁,与 agent 类型无关)。 return copy['no-source']; @@ -155,6 +173,10 @@ export function deriveRemoteReadiness( if (vendor !== 'cc' && input.binaryReady === false) { return 'binary-missing'; } + if (vendor === 'grok-build') { + if (input.authReady !== null) return input.authReady ? 'ready' : 'unauthenticated'; + return 'ready'; + } if (input.sourceReady !== null) return input.sourceReady ? 'ready' : 'unauthenticated'; if (input.authReady !== null) return input.authReady ? 'ready' : 'unauthenticated'; return 'ready'; @@ -237,6 +259,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { const cc = useVendorReadiness('cc'); const codex = useVendorReadiness('codex'); const pi = useVendorReadiness('pi'); + const grokBuild = useVendorReadiness('grok-build'); const checkAndConfirm = useCallback( async ( @@ -271,7 +294,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { const deviceId = options?.deviceId; if (deviceId) { const providerAgent: ProviderAgentKind = - vendor === 'codex' ? 'codex' : vendor === 'pi' ? 'pi' : 'claude-code'; + vendor === 'codex' ? 'codex' : vendor === 'pi' ? 'pi' : vendor === 'grok-build' ? 'grok-build' : 'claude-code'; const [statusRes, providersRes] = await Promise.allSettled([ window.electronAPI.deviceLink.invoke(deviceId, 'maker:agent:status', [providerAgent]), window.electronAPI.deviceLink.invoke(deviceId, 'maker:provider:list', []), @@ -339,7 +362,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { } // 触发一次最新检查——避免 stale state 误放行。 - const target = vendor === 'codex' ? codex : vendor === 'pi' ? pi : cc; + const target = vendor === 'codex' ? codex : vendor === 'pi' ? pi : vendor === 'grok-build' ? grokBuild : cc; // 已建会话的发送门禁计入 suspended 来源(见 useVendorReadiness 注释);草稿不传。 const readiness = await target.revalidate({ includeSuspended: options?.existingSessionRoute === true, @@ -357,11 +380,22 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { autoFocusConfirm: true, }); if (ok) { + if (vendor === 'grok-build' && readiness === 'unauthenticated') { + try { + await window.electronAPI.maker.auth.triggerLogin('grok-build'); + } catch { + // login spawn failed; user can retry + } + return { proceed: false }; + } + if (vendor === 'grok-build' && readiness === 'binary-missing') { + return { proceed: false }; + } navigate(`/settings?tab=${dialogCopy.settingsTab}`); } return { proceed: false }; }, - [cc, codex, pi, confirm, copy, navigate, t], + [cc, codex, pi, grokBuild, confirm, copy, navigate, t], ); return { checkAndConfirm }; diff --git a/apps/desktop/src/renderer/hooks/useVendorReadiness.ts b/apps/desktop/src/renderer/hooks/useVendorReadiness.ts index 6daac49112..67521008a9 100644 --- a/apps/desktop/src/renderer/hooks/useVendorReadiness.ts +++ b/apps/desktop/src/renderer/hooks/useVendorReadiness.ts @@ -24,20 +24,20 @@ export type Readiness = 'ready' | 'unauthenticated' | 'binary-missing' | 'loadin * 两种不同的恢复路径,不能把 Pi 的缺包状态伪装成未授权。 */ export function readinessFromBinaryStatus( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', binaryReady: boolean, ): Readiness | null { return vendorKey !== 'cc' && !binaryReady ? 'binary-missing' : null; } -export function useVendorReadiness(vendorKey: 'cc' | 'codex' | 'pi'): { +export function useVendorReadiness(vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): { readiness: Readiness; revalidate: (opts?: { includeSuspended?: boolean }) => Promise; } { const [readiness, setReadiness] = useState('loading'); const revalidate = useCallback(async (opts?: { includeSuspended?: boolean }): Promise => { - const agent: AgentKind = vendorKey === 'cc' ? 'claude-code' : vendorKey === 'pi' ? 'pi' : 'codex'; + const agent: AgentKind = vendorKey === 'cc' ? 'claude-code' : vendorKey === 'pi' ? 'pi' : vendorKey === 'grok-build' ? 'grok-build' : 'codex'; // 轴 2(codex / pi,正交于来源):本地二进制是运行时前提,缺了连发都发不了 → 优先返回 // binary-missing。binary 状态走 maker:agent:status(其 authReady 是 codex OAuth 专属,已被 @@ -61,6 +61,22 @@ export function useVendorReadiness(vendorKey: 'cc' | 'codex' | 'pi'): { } } + // Grok Build auth is grok CLI / XAI_API_KEY, not Cindy catalog providers. + if (vendorKey === 'grok-build') { + try { + const status = (await window.electronAPI.maker.agent.getStatus(agent)) as { + binaryReady: boolean; + authReady: boolean; + }; + const next: Readiness = status.authReady ? 'ready' : 'unauthenticated'; + setReadiness(next); + return next; + } catch { + setReadiness('unauthenticated'); + return 'unauthenticated'; + } + } + // 轴 1(与 agent 类型无关,唯一真相):该 agent 有没有「已连接的可选来源」。连接态走本地 IPC // listProviders(极快),send 门禁时刻现拉避免 stale;失败按空列表处理(判未就绪,引导去连接)。 let providers: ProviderView[] = []; diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 6acbfb0561..2b0c2dd9fa 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -2579,6 +2579,10 @@ "pi": { "label": "Pi", "description": "Saves long-session compression summaries to Cindy memory for future Pi sessions." + }, + "grok-build": { + "label": "Grok Build", + "description": "Grok Build does not expose a Cindy-managed auto-memory channel in this version." } }, "agent": { @@ -3638,7 +3642,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "Permission mode for new group tasks", "groupPermissionDescription": "Permission mode used for every task created in a group chat — @bot opening a topic, /new, and /ctr all use it; the permission mode above applies to direct messages only. Defaults to Auto approval. Group context can contain member-controlled content — choosing Full access executes actions without per-step confirmation, so only pick it for groups you trust. Changes apply to tasks created afterwards; switch an existing task with /permission in the group." @@ -6800,6 +6805,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "Next: {{agent}}" } }, @@ -6917,6 +6923,20 @@ "label": "Full access", "description": "Routine tools run without asking. Installing, updating, or removing Pi extensions still requires confirmation. Highest risk; use only for trusted tasks." } + }, + "grok-build": { + "ask": { + "label": "Default permissions", + "description": "Grok Build tools that write files, run commands, or leave the workspace ask each time via ACP prompts." + }, + "auto": { + "label": "Auto-review", + "description": "In-workspace writes and safe commands run automatically; out-of-workspace writes and risky commands still ask. Cindy Auto-review intercepts ACP permission requests." + }, + "bypassPermissions": { + "label": "Full access", + "description": "Grok Build runs with always-approve. Highest risk; use only for trusted tasks." + } } } }, @@ -8374,7 +8394,8 @@ "all": "All", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "Group by project", @@ -10441,6 +10462,11 @@ "codexBinaryMissingDescription": "The Codex component is not ready yet, so this feature cannot be used. Try again later or check the Codex connection in Settings → Providers.", "piBinaryMissingTitle": "Pi component not ready", "piBinaryMissingDescription": "The Pi component is not ready yet, so this feature cannot be used. Try again later or check the Pi connection in Settings → Providers.", + "grokBuildBinaryMissingTitle": "Grok Build is not installed", + "grokBuildBinaryMissingDescription": "Cindy did not find a grok CLI on PATH. Install Grok Build and try again. Claude Code, Codex, and Pi are unaffected.", + "grokBuildUnauthenticatedTitle": "Grok Build is not signed in", + "grokBuildUnauthenticatedDescription": "Sign in with grok login, or set XAI_API_KEY. Cindy never reads ~/.grok/auth.json.", + "grokBuildLogin": "Sign in", "goToSettings": "Open Settings", "remoteCodexBinaryMissingTitle": "Codex component not ready on the remote device", "remotePiBinaryMissingTitle": "Pi component not ready on the remote device", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 3bab03e4ad..840186850d 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -2578,6 +2578,10 @@ "pi": { "label": "Pi", "description": "長いセッションの圧縮要約を Cindy メモリに保存し、今後の Pi セッションで利用します" + }, + "grok-build": { + "label": "Grok Build", + "description": "このバージョンの Grok Build は Cindy 管理の自動メモリを提供しません。" } }, "agent": { @@ -3637,7 +3641,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "グループで新規タスクを作成するときの権限モード", "groupPermissionDescription": "グループで新規タスクを作成するときに共通で使う権限モード(@bot でトピックを開く場合、/new、/ctr のいずれも対象)。初期値は「自動承認」で、上の権限モードは個人チャット専用です。グループの文脈にはメンバーが操作できる内容が含まれるため、「フルアクセス」を選ぶと確認なしで直接実行されます。信頼できるグループでのみ選択してください。変更は以後に作成するタスクにのみ適用され、既存のタスクはグループ内で /permission を使って個別に切り替えます。" @@ -6798,6 +6803,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "次回:{{agent}}" } }, @@ -6915,6 +6921,20 @@ "label": "フルアクセス", "description": "通常のツールは確認なしで実行しますが、Pi 拡張のインストール、更新、アンインストールには引き続き確認が必要です。最もリスクが高いため、信頼できるタスクでのみ使用してください。" } + }, + "grok-build": { + "ask": { + "label": "デフォルト権限", + "description": "ファイル書き込み、コマンド実行、ワークスペース外の操作は ACP 経由で毎回確認します。" + }, + "auto": { + "label": "自動レビュー", + "description": "ワークスペース内の書き込みと安全なコマンドは自動実行し、境界外と危険な操作は確認します。Cindy の自動レビューが ACP 権限リクエストを処理します。" + }, + "bypassPermissions": { + "label": "フルアクセス", + "description": "Grok Build は always-approve で実行し、確認しません。最もリスクが高いため、信頼できるタスクでのみ使用してください。" + } } } }, @@ -8359,7 +8379,8 @@ "all": "すべて", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "プロジェクトでグループ化", @@ -10425,6 +10446,11 @@ "codexBinaryMissingDescription": "Codex コンポーネントの準備がまだできていないため、この機能は使用できません。しばらくしてから再試行するか、「設定 → プロバイダー」で Codex の接続状態を確認してください。", "piBinaryMissingTitle": "Pi コンポーネントの準備ができていません", "piBinaryMissingDescription": "Pi コンポーネントの準備がまだできていないため、この機能は使用できません。しばらくしてから再試行するか、「設定 → プロバイダー」で Pi の接続状態を確認してください。", + "grokBuildBinaryMissingTitle": "Grok Build がインストールされていません", + "grokBuildBinaryMissingDescription": "PATH 上に grok CLI が見つかりません。Grok Build をインストールして再試行してください。Claude Code / Codex / Pi には影響しません。", + "grokBuildUnauthenticatedTitle": "Grok Build に未ログインです", + "grokBuildUnauthenticatedDescription": "grok login でサインインするか、XAI_API_KEY を設定してください。Cindy は ~/.grok/auth.json を読みません。", + "grokBuildLogin": "サインイン", "goToSettings": "設定を開く", "remoteCodexBinaryMissingTitle": "リモートデバイスの Codex コンポーネントが未準備です", "remotePiBinaryMissingTitle": "リモートデバイスの Pi コンポーネントが未準備です", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index a3a913ff72..4b7a7be744 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -2578,6 +2578,10 @@ "pi": { "label": "Pi", "description": "긴 세션의 압축 요약을 Cindy 메모리에 저장해 이후 Pi 세션에서 사용합니다" + }, + "grok-build": { + "label": "Grok Build", + "description": "이 버전의 Grok Build는 Cindy가 관리하는 자동 메모리를 제공하지 않습니다." } }, "agent": { @@ -3637,7 +3641,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "그룹에서 새 작업을 만들 때의 권한 모드", "groupPermissionDescription": "그룹 채팅에서 새 작업을 만들 때 공통으로 사용하는 권한 모드입니다(@bot으로 스레드 열기, /new, /ctr 모두 해당). 기본값은 자동 승인이며, 위의 권한 모드는 개인 채팅에만 적용됩니다. 그룹 컨텍스트에는 멤버가 조작할 수 있는 내용이 포함되므로 전체 액세스를 선택하면 확인 없이 바로 실행됩니다. 신뢰할 수 있는 그룹에서만 선택하세요. 변경은 이후에 만드는 작업에만 적용되며, 기존 작업은 그룹에서 /permission으로 개별 전환합니다." @@ -6798,6 +6803,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "다음: {{agent}}" } }, @@ -6915,6 +6921,20 @@ "label": "전체 접근", "description": "일반 도구는 확인 없이 실행하지만 Pi 확장 설치, 업데이트 또는 제거에는 계속 확인이 필요합니다. 가장 위험하므로 신뢰할 수 있는 작업에서만 사용하세요." } + }, + "grok-build": { + "ask": { + "label": "기본 권한", + "description": "파일을 쓰거나 명령을 실행하거나 작업 영역을 벗어나는 Grok Build 도구는 ACP 프롬프트로 매번 묻습니다." + }, + "auto": { + "label": "자동 검토", + "description": "작업 영역 안의 쓰기와 안전한 명령은 자동 실행되고, 영역 밖 쓰기와 위험한 명령은 계속 묻습니다. Cindy 자동 검토가 ACP 권한 요청을 가로챕니다." + }, + "bypassPermissions": { + "label": "전체 액세스", + "description": "Grok Build가 always-approve로 실행되어 다시 묻지 않습니다. 위험이 가장 높으니 신뢰할 수 있는 작업에만 사용하세요." + } } } }, @@ -8359,7 +8379,8 @@ "all": "전체", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "프로젝트별 그룹", @@ -10425,6 +10446,11 @@ "codexBinaryMissingDescription": "Codex 구성 요소가 아직 준비되지 않아 이 기능을 사용할 수 없습니다. 잠시 후 다시 시도하거나 설정 → 제공자에서 Codex 연결 상태를 확인하세요.", "piBinaryMissingTitle": "Pi 구성 요소가 준비되지 않음", "piBinaryMissingDescription": "Pi 구성 요소가 아직 준비되지 않아 이 기능을 사용할 수 없습니다. 잠시 후 다시 시도하거나 설정 → 제공자에서 Pi 연결 상태를 확인하세요.", + "grokBuildBinaryMissingTitle": "Grok Build가 설치되지 않았습니다", + "grokBuildBinaryMissingDescription": "PATH에서 grok CLI를 찾지 못했습니다. Grok Build를 설치한 뒤 다시 시도하세요. Claude Code / Codex / Pi는 영향을 받지 않습니다.", + "grokBuildUnauthenticatedTitle": "Grok Build에 로그인되어 있지 않습니다", + "grokBuildUnauthenticatedDescription": "grok login으로 로그인하거나 XAI_API_KEY를 설정하세요. Cindy는 ~/.grok/auth.json을 읽지 않습니다.", + "grokBuildLogin": "로그인", "goToSettings": "설정 열기", "remoteCodexBinaryMissingTitle": "원격 기기의 Codex 구성 요소가 준비되지 않음", "remotePiBinaryMissingTitle": "원격 기기의 Pi 구성 요소가 준비되지 않음", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 749fadbabc..efec7d7722 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -2570,6 +2570,10 @@ "pi": { "label": "Pi", "description": "将长任务的压缩摘要保存到 Cindy 记忆,供后续 Pi 任务继续使用" + }, + "grok-build": { + "label": "Grok Build", + "description": "当前版本 Grok Build 不提供 Cindy 托管的自动记忆通道。" } }, "agent": { @@ -3629,7 +3633,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "群聊新建任务权限档", "groupPermissionDescription": "群里新建任务时统一使用的权限档(@bot 开话题、/new、/ctr 都算),默认「自动审批」;上面那个权限档只管私聊。群上下文里有成员可控的内容,设成「完全访问」后将直接执行、不再逐条确认,请确认群里的人都可信。改动只影响之后新建的任务,已有任务在群里用 /permission 单独切换。" @@ -6790,6 +6795,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "下条:{{agent}}" } }, @@ -6907,6 +6913,20 @@ "label": "完全访问", "description": "常规工具无需询问直接执行;安装、更新或卸载 Pi 扩展仍需确认。风险最高,只适合可信任务。" } + }, + "grok-build": { + "ask": { + "label": "默认权限", + "description": "Grok Build 写入文件、执行命令或离开工作区的工具每次都通过 ACP 询问。" + }, + "auto": { + "label": "自动审批", + "description": "工作区内写入和安全命令自动执行;区外写入和高风险命令仍会询问。Cindy 自动审批拦截 ACP 权限请求。" + }, + "bypassPermissions": { + "label": "完全访问", + "description": "Grok Build 以 always-approve 运行,不再询问。风险最高,只适合可信任务。" + } } } }, @@ -8351,7 +8371,8 @@ "all": "全部", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "按项目分组", @@ -10417,6 +10438,11 @@ "codexBinaryMissingDescription": "Codex 组件尚未准备好,暂时无法使用这个功能。请稍后重试,或前往「模型供应商」检查 Codex 连接状态。", "piBinaryMissingTitle": "Pi 组件未就绪", "piBinaryMissingDescription": "Pi 组件尚未准备好,暂时无法使用这个功能。请稍后重试,或前往「模型供应商」检查 Pi 连接状态。", + "grokBuildBinaryMissingTitle": "未安装 Grok Build", + "grokBuildBinaryMissingDescription": "Cindy 在 PATH 上没有找到 grok 命令。安装 Grok Build 后再试。Claude Code / Codex / Pi 不受影响。", + "grokBuildUnauthenticatedTitle": "Grok Build 尚未登录", + "grokBuildUnauthenticatedDescription": "请运行 grok login 登录,或设置 XAI_API_KEY。Cindy 不会读取 ~/.grok/auth.json。", + "grokBuildLogin": "去登录", "goToSettings": "前往设置", "remoteCodexBinaryMissingTitle": "被控端 Codex 组件未就绪", "remotePiBinaryMissingTitle": "被控端 Pi 组件未就绪", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 8c995512a6..286283a4d6 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -2570,6 +2570,10 @@ "pi": { "label": "Pi", "description": "將長任務的壓縮摘要儲存到 Cindy 記憶,供後續 Pi 任務繼續使用" + }, + "grok-build": { + "label": "Grok Build", + "description": "目前版本 Grok Build 不提供 Cindy 託管的自動記憶通道。" } }, "agent": { @@ -3629,7 +3633,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "群聊新建任務權限檔", "groupPermissionDescription": "群組裡新建任務時統一使用的權限檔(@bot 開話題、/new、/ctr 都算),預設「自動審批」;上面那個權限檔只管私聊。群組上下文裡有成員可控制的內容,設成「完全存取」後將直接執行、不再逐項確認,請確認群組成員都可信。變更只影響之後新建的任務,已有任務在群組裡用 /permission 單獨切換。" @@ -6789,6 +6794,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "下條:{{agent}}" } }, @@ -6907,6 +6913,20 @@ "label": "完全訪問", "description": "常規工具無需詢問直接執行;安裝、更新或解除安裝 Pi 擴展仍需確認。風險最高,只適合可信任務。" } + }, + "grok-build": { + "ask": { + "label": "預設權限", + "description": "Grok Build 寫入檔案、執行命令或離開工作區的工具每次都透過 ACP 詢問。" + }, + "auto": { + "label": "自動審批", + "description": "工作區內寫入和安全命令自動執行;區外寫入和高風險命令仍會詢問。Cindy 自動審批攔截 ACP 權限請求。" + }, + "bypassPermissions": { + "label": "完全訪問", + "description": "Grok Build 以 always-approve 執行,不再詢問。風險最高,只適合可信任務。" + } } } }, @@ -8351,7 +8371,8 @@ "all": "全部", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "按專案分組", @@ -10417,6 +10438,11 @@ "codexBinaryMissingDescription": "Codex 元件尚未準備好,暫時無法使用這個功能。請稍後重試,或前往「模型供應商」檢查 Codex 連線狀態。", "piBinaryMissingTitle": "Pi 元件未就緒", "piBinaryMissingDescription": "Pi 元件尚未準備好,暫時無法使用這個功能。請稍後重試,或前往「模型供應商」檢查 Pi 連線狀態。", + "grokBuildBinaryMissingTitle": "未安裝 Grok Build", + "grokBuildBinaryMissingDescription": "Cindy 在 PATH 上沒有找到 grok 命令。安裝 Grok Build 後再試。Claude Code / Codex / Pi 不受影響。", + "grokBuildUnauthenticatedTitle": "Grok Build 尚未登入", + "grokBuildUnauthenticatedDescription": "請執行 grok login 登入,或設定 XAI_API_KEY。Cindy 不會讀取 ~/.grok/auth.json。", + "grokBuildLogin": "去登入", "goToSettings": "前往設定", "remoteCodexBinaryMissingTitle": "被控端 Codex 元件未就緒", "remotePiBinaryMissingTitle": "被控端 Pi 元件未就緒", diff --git a/apps/desktop/src/renderer/lib/agentVendors.ts b/apps/desktop/src/renderer/lib/agentVendors.ts index 519d1fc0f5..6bc053f251 100644 --- a/apps/desktop/src/renderer/lib/agentVendors.ts +++ b/apps/desktop/src/renderer/lib/agentVendors.ts @@ -14,7 +14,7 @@ import type { MakerVendor } from './ccAgent.types'; -export const SELECTABLE_VENDORS = ['cc', 'codex', 'pi'] as const satisfies readonly MakerVendor[]; +export const SELECTABLE_VENDORS = ['cc', 'codex', 'pi', 'grok-build'] as const satisfies readonly MakerVendor[]; export type SelectableVendor = (typeof SELECTABLE_VENDORS)[number]; diff --git a/apps/desktop/src/renderer/lib/atResourceService.ts b/apps/desktop/src/renderer/lib/atResourceService.ts index a61c541a1c..6b26cef560 100644 --- a/apps/desktop/src/renderer/lib/atResourceService.ts +++ b/apps/desktop/src/renderer/lib/atResourceService.ts @@ -82,7 +82,7 @@ const EMPTY_QUERY_SECTIONS: ReadonlyArray> = [ new Set(['plugin-command']), ]; -export type PaletteAgentKind = 'claude-code' | 'codex' | 'pi'; +export type PaletteAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export interface AtResourceScanContext { /** Current local task. Its built-in browser tabs are the only tabs exposed. */ diff --git a/apps/desktop/src/renderer/lib/ccAgent.types.ts b/apps/desktop/src/renderer/lib/ccAgent.types.ts index e713bad5a0..60b8501a54 100644 --- a/apps/desktop/src/renderer/lib/ccAgent.types.ts +++ b/apps/desktop/src/renderer/lib/ccAgent.types.ts @@ -15,7 +15,7 @@ export type DeviceLinkConnectionStatus = 'connected' | 'disconnected'; * 暂时只有 'cc'(Claude Code)。未来扩展 'codex' 等时新增枚举值即可, * schema 不动;老 session DEFAULT 'cc' 兜底。 */ -export type AgentKind = 'cc' | 'codex' | 'pi'; +export type AgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; export type MakerVendor = AgentKind | 'orca'; export type OrcaRole = 'lead' | 'worker'; @@ -314,7 +314,7 @@ export interface Session { } export interface SessionRuntimeProfileProjection { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort: Effort | null; @@ -340,8 +340,8 @@ export type MessageRole = 'user' | 'assistant' | 'tool_use' | 'tool_result' | 'a * 不作为对话正文渲染,也绝不回发给 agent(注入走 main 的 wire 前缀通道)。 */ export interface AgentSwitchContent { - fromAgentKind: 'cc' | 'codex' | 'pi'; - toAgentKind: 'cc' | 'codex' | 'pi'; + fromAgentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; + toAgentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; fromModel: string | null; toModel: string | null; handoff: string; @@ -371,6 +371,6 @@ export interface Message { * session-agent-switch 后 session.agentKind 只代表当前活跃引擎,历史行按本字段解析; * null = 切换功能上线前的老消息(回落 session.agentKind)。 */ - agentKind?: 'cc' | 'codex' | 'pi' | null; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build' | null; createdAt: string; // ISO 8601 } diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index bd1b5559e0..8315c3016d 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -2296,7 +2296,7 @@ export type MessageDeliveryMode = 'queue' | 'steer'; /** 仅影响 selector/chip 的乐观展示;agentKind 始终保留真实 reducer 路由。 */ export interface AgentSwitchIntentRecord { - target: 'claude-code' | 'codex' | 'pi'; + target: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort?: string; @@ -2312,7 +2312,7 @@ export interface SessionChatState { * Codex reducer。ensureInitialMessages 从 DB sessions.agent_kind 读出来灌进。 * 默认 'claude-code' 兼容老路径(老 session row 没有此字段时按 Claude 处理)。 */ - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** 下一条消息发送时才由 main 应用的跨引擎切换意图。 */ agentSwitchIntent: AgentSwitchIntentRecord | null; /** @@ -8933,7 +8933,7 @@ setRemoteTerminalErrorProbe(hasSessionTerminalError); interface ActiveSessionSnapshot { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; isTurnRunning: boolean; } @@ -8942,7 +8942,8 @@ function isActiveSessionSnapshot(value: unknown): value is ActiveSessionSnapshot const item = value as Record; return ( typeof item.sessionId === 'string' && - (item.agentKind === 'claude-code' || item.agentKind === 'codex' || item.agentKind === 'pi') && + (item.agentKind === 'claude-code' || item.agentKind === 'codex' || item.agentKind === 'pi' + || item.agentKind === 'grok-build') && typeof item.isTurnRunning === 'boolean' ); } @@ -9962,18 +9963,19 @@ function retryInvalidatedInitialHistoryFetchIfNeeded( } /** - * DB sessions.agent_kind('cc' / 'codex' / 'pi')→ maker-core AgentKind 的唯一映射点。 + * DB sessions.agent_kind('cc' / 'codex' / 'pi' / 'grok-build')→ maker-core AgentKind 的唯一映射点。 * 缺失 / 异常值走 fallback(默认 'claude-code',老 row 兼容)。所有从 session * row 派生 agentKind 的地方必须走这里,不要在调用点手写三元(历史上多处各写 * 一份,遗漏 fallback 语义差异被 review 逐个揪出)。 */ function dbAgentKindToMakerKind( dbKind: string | null | undefined, - fallback: 'claude-code' | 'codex' | 'pi' = 'claude-code', -): 'claude-code' | 'codex' | 'pi' { + fallback: 'claude-code' | 'codex' | 'pi' | 'grok-build' = 'claude-code', +): 'claude-code' | 'codex' | 'pi' | 'grok-build' { if (dbKind === 'codex') return 'codex'; if (dbKind === 'cc') return 'claude-code'; if (dbKind === 'pi') return 'pi'; + if (dbKind === 'grok-build') return 'grok-build'; return fallback; } @@ -12177,7 +12179,7 @@ function autoTitleFallbackLabels(): AutoTitleFallbackLabels { function scheduleAutoName( sessionId: string, text: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', isUserText = true, ): void { // 与 main 共用 normalizeAutoTitle,两端算出的占位串逐字一致,回流时不跳变。 @@ -12293,7 +12295,7 @@ function clearAutoTitlePreviewSafely(sessionId: string): void { function maybeAutoNameUnnamedSession( sessionId: string, seed: AutoTitleSeed | null, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): void { if (!seed?.isUserText) return; scheduleAutoName(sessionId, seed.text, agentKind, true); @@ -12627,7 +12629,7 @@ async function sendMessageCore( // 用会话真实 agentKind 起名 — 之前写死 'claude-code',导致 Codex 会话也 // 用 Claude haiku 起标题:纯 Codex 用户(无 Claude 鉴权)会 oneShot 失败 → // fallback 原话,表现为"Codex 会话标题没有智能总结"。current.agentKind 已是 - // maker 格式('claude-code' | 'codex' | 'pi'),直接透传。起名走立即占位 + 后台覆盖。 + // maker 格式('claude-code' | 'codex' | 'pi' | 'grok-build'),直接透传。起名走立即占位 + 后台覆盖。 if (autoTitleSeed) { scheduleAutoName( sessionId, @@ -14749,7 +14751,10 @@ function sendUiTrigger(sessionId: string, prompt: string): Promise { * sdkSessionId——否则 buildCreateOpts 会把旧引擎的原生会话 id 当 resume 目标 * (main 侧 reconcileCreateOptsWithDb 是兜底,这里是第一现场收敛)。 */ -function noteAgentSwitched(sessionId: string, agentKind: 'claude-code' | 'codex' | 'pi'): void { +function noteAgentSwitched( + sessionId: string, + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', +): void { if (!sessionId) return; setState(sessionId, (s) => { const nextProviderId = s.agentSwitchIntent ? s.agentSwitchIntent.providerId : s.sessionProviderId; @@ -14781,7 +14786,7 @@ function noteAgentSwitched(sessionId: string, agentKind: 'claude-code' | 'codex' */ function noteAgentSwitchIntent( sessionId: string, - target: 'claude-code' | 'codex' | 'pi', + target: 'claude-code' | 'codex' | 'pi' | 'grok-build', opts: { model: string; providerId: string | null; effort?: string; fastMode?: boolean }, ): void { if (!sessionId) return; @@ -14891,7 +14896,7 @@ function mirrorAgentSwitchIntent(sessionId: string, value: unknown): void { function setSessionRuntime( sessionId: string, opts: { - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; fastMode?: boolean; planModeEnabled?: boolean; /** Seed before SessionView hydrates the DB row; sendMessage reads this for SSH routing. */ diff --git a/apps/desktop/src/renderer/lib/makerTransport.ts b/apps/desktop/src/renderer/lib/makerTransport.ts index 884fbea4fe..0f709f93de 100644 --- a/apps/desktop/src/renderer/lib/makerTransport.ts +++ b/apps/desktop/src/renderer/lib/makerTransport.ts @@ -215,7 +215,7 @@ export function makerApiForDevice(deviceId: string): RoutableMaker { /** Mutation 前按明确 deviceId 重新读取被控端能力,避免复用可能过期的 renderer cache。 */ export function agentCapabilitiesForDevice( deviceId: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): Promise<{ supportsOrcaWorkerPermissionMode?: boolean; supportsDeferredOrcaUiAssignment?: boolean; diff --git a/apps/desktop/src/renderer/lib/modelDefinitions.ts b/apps/desktop/src/renderer/lib/modelDefinitions.ts index c2ee6ae8f1..51785fdc32 100644 --- a/apps/desktop/src/renderer/lib/modelDefinitions.ts +++ b/apps/desktop/src/renderer/lib/modelDefinitions.ts @@ -17,7 +17,7 @@ export interface ModelDefinition { description: string; efforts: readonly Effort[]; defaultEffort: Effort | null; - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'; contextWindow?: number; supportsFastMode?: boolean; /** 目录展示排序;缺省排末尾(见 getDefaultModelForVendor)。 */ @@ -29,10 +29,10 @@ export interface ModelDefinition { * 解耦)。缺省 = 不作为默认。getDefaultModelForVendor / newSessionDefaultModelId 据它选默认; * Pi 只接受自己的 v3 标记,不借用其它 Agent 的默认策略。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'grok-build')[]; } -function toLegacy(m: ModelDescriptor, vendorKey: 'cc' | 'codex' | 'pi'): ModelDefinition { +function toLegacy(m: ModelDescriptor, vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): ModelDefinition { return { id: m.id, label: m.displayName, @@ -104,12 +104,12 @@ export function getModelById(modelId: string, deviceId?: string): ModelDefinitio } /** vendor → capabilities 缓存的 agent 键(pi 有自己的能力清单)。 */ -function agentKindForVendor(vendorKey: 'cc' | 'codex' | 'pi'): 'claude-code' | 'codex' | 'pi' { - return vendorKey === 'codex' ? 'codex' : vendorKey === 'pi' ? 'pi' : 'claude-code'; +function agentKindForVendor(vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): 'claude-code' | 'codex' | 'pi' | 'grok-build' { + return vendorKey === 'codex' ? 'codex' : vendorKey === 'pi' ? 'pi' : vendorKey === 'grok-build' ? 'grok-build' : 'claude-code'; } export function getModelsForVendor( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', deviceId?: string, ): readonly ModelDefinition[] { // 直接读该 vendor 对应 agent 的能力缓存 —— 不经 allCachedModels(后者只聚合 cc/codex 供 @@ -138,8 +138,8 @@ function firstByCatalogOrder(models: readonly ModelDefinition[]): ModelDefinitio /** vendor → 新对话默认所依据的目录 Agent 标记。 */ function defaultMarkerAgentForVendor( - vendorKey: 'cc' | 'codex' | 'pi', -): 'claude-code' | 'codex' | 'pi' { + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', +): 'claude-code' | 'codex' | 'pi' | 'grok-build' { if (vendorKey === 'cc') return 'claude-code'; return vendorKey; } @@ -155,7 +155,7 @@ function defaultMarkerAgentForVendor( * 本函数返回 null、默认行为不变。 */ export function newSessionDefaultModelId( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', deviceId?: string, ): string | null { const agent = defaultMarkerAgentForVendor(vendorKey); @@ -177,7 +177,7 @@ export function newSessionDefaultModelId( * useScheduleForm.ts getScheduleDefaultModel,不要把这里的默认接到 scheduler 上。 */ export function getDefaultModelForVendor( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', deviceId?: string, ): ModelDefinition { const list = getModelsForVendor(vendorKey, deviceId); @@ -210,15 +210,17 @@ const COLD_START_CODEX_MODEL_ID = 'gpt-5.6-sol'; const COLD_START_PI_MODEL_ID = 'claude-sonnet-5'; /** 冷启动占位 id 的只读导出(newMakerDraft 的种子默认复用,避免另一处写死)。 */ -export function coldStartModelIdForVendor(vendorKey: 'cc' | 'codex' | 'pi'): string { - return vendorKey === 'codex' - ? COLD_START_CODEX_MODEL_ID - : vendorKey === 'pi' - ? COLD_START_PI_MODEL_ID - : COLD_START_CC_MODEL_ID; +export function coldStartModelIdForVendor(vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): string { + if (vendorKey === 'codex') return COLD_START_CODEX_MODEL_ID; + if (vendorKey === 'pi') return COLD_START_PI_MODEL_ID; + if (vendorKey === 'grok-build') return 'grok-build'; + return COLD_START_CC_MODEL_ID; } /** 冷启动占位的展示名(与占位 id 同源,仅首帧短暂可见)。 */ -function coldStartLabelForVendor(vendorKey: 'cc' | 'codex' | 'pi'): string { - return vendorKey === 'codex' ? 'GPT-5.6-Sol' : vendorKey === 'pi' ? 'Sonnet 5' : 'Opus 5'; +function coldStartLabelForVendor(vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): string { + if (vendorKey === 'codex') return 'GPT-5.6-Sol'; + if (vendorKey === 'pi') return 'Sonnet 5'; + if (vendorKey === 'grok-build') return 'Grok Build'; + return 'Opus 5'; } diff --git a/apps/desktop/src/renderer/lib/newMakerDefaultTuple.ts b/apps/desktop/src/renderer/lib/newMakerDefaultTuple.ts index 5e48540621..de6a14a1da 100644 --- a/apps/desktop/src/renderer/lib/newMakerDefaultTuple.ts +++ b/apps/desktop/src/renderer/lib/newMakerDefaultTuple.ts @@ -7,6 +7,12 @@ import { import type { MakerVendor } from '@/lib/ccAgent.types'; +/** + * 产品默认 tuple 只覆盖走 provider 路由的三个 harness。grok-build 自带唯一内置 + * 模型、不参与来源/模型默认下放,所以不进这张种子表。 + */ +type NewMakerDefaultAgent = Exclude; + export interface NewMakerDefaultTuple { vendor: Extract; providerId: string; @@ -17,7 +23,7 @@ export interface NewMakerDefaultTuple { interface ProviderDefaultPolicy { providerId: 'openai' | 'anthropic' | 'xai' | 'xd'; accessKind: 'subscription' | 'managed'; - agents: readonly AgentKind[]; + agents: readonly NewMakerDefaultAgent[]; modelIds: readonly string[]; requireNewSessionDefault?: boolean; requireImageInput?: boolean; @@ -59,7 +65,7 @@ const DEFAULT_POLICIES: readonly ProviderDefaultPolicy[] = [ }, ]; -function vendorForAgent(agent: AgentKind): NewMakerDefaultTuple['vendor'] { +function vendorForAgent(agent: NewMakerDefaultAgent): NewMakerDefaultTuple['vendor'] { return agent === 'claude-code' ? 'cc' : agent; } diff --git a/apps/desktop/src/renderer/lib/providerSubtitle.ts b/apps/desktop/src/renderer/lib/providerSubtitle.ts index fe183a4215..245bea71de 100644 --- a/apps/desktop/src/renderer/lib/providerSubtitle.ts +++ b/apps/desktop/src/renderer/lib/providerSubtitle.ts @@ -4,6 +4,7 @@ const AGENT_DISPLAY_LABELS: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; export function providerAgentSupportLabel(provider?: Pick | null): string { diff --git a/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts b/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts index d72c1102fa..7c8a12add8 100644 --- a/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts +++ b/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts @@ -16,7 +16,7 @@ import { toast } from './toast'; import type { ProviderErrorCode } from '../../shared/providerErrors'; interface ProviderUpstreamErrorPayload { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; providerName?: string; code: ProviderErrorCode; diff --git a/apps/desktop/src/renderer/lib/sessionService.ts b/apps/desktop/src/renderer/lib/sessionService.ts index e3772c67f7..2b766b6f90 100644 --- a/apps/desktop/src/renderer/lib/sessionService.ts +++ b/apps/desktop/src/renderer/lib/sessionService.ts @@ -72,7 +72,7 @@ export async function create(body?: { fastMode?: boolean; /** 计划模式一级开关(与 permissionMode 正交); 草稿开着计划模式时随建会话落库。 */ planModeEnabled?: boolean; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; orcaRole?: OrcaRole | null; /** 附加只读引用目录列表 (绝对路径); main 端 mapper 会 JSON.stringify 后写库。 */ extraDirs?: string[]; diff --git a/apps/desktop/src/renderer/state/newMakerDraft.ts b/apps/desktop/src/renderer/state/newMakerDraft.ts index 4b04732693..4c37999976 100644 --- a/apps/desktop/src/renderer/state/newMakerDraft.ts +++ b/apps/desktop/src/renderer/state/newMakerDraft.ts @@ -173,6 +173,15 @@ export interface NewMakerDraft { * 在目录里都是默认隐藏的模型 —— 种子默认模型压根不在用户看到的清单里。 */ function defaultVendorPrefs(vendor: MakerVendor): VendorPrefs { + if (vendor === 'grok-build') { + return { + model: 'grok-build', + effort: 'high', + permissionMode: 'auto', + planMode: false, + providerId: null, + }; + } if (vendor === 'pi') { return { // pi 走 XD 网关(anthropic-messages 可达面),默认给网关中档模型; @@ -229,6 +238,7 @@ function makeDefault(): NewMakerDraft { pi: defaultVendorPrefs('pi'), orca: defaultVendorPrefs('orca'), codex: defaultVendorPrefs('codex'), + 'grok-build': defaultVendorPrefs('grok-build'), }, modelChosenByVendor: {}, defaultTupleCustomized: false, @@ -453,6 +463,7 @@ function sanitize(raw: unknown): NewMakerDraft { pi: sanitizeVendorPrefs(lastByVendorRaw.pi, 'pi'), orca: sanitizeVendorPrefs(lastByVendorRaw.orca, 'orca'), codex: sanitizeVendorPrefs(lastByVendorRaw.codex, 'codex'), + 'grok-build': sanitizeVendorPrefs(lastByVendorRaw['grok-build'], 'grok-build'), }, modelChosenByVendor, defaultTupleCustomized, diff --git a/apps/desktop/src/renderer/themes/__tests__/tokenRegistry.test.ts b/apps/desktop/src/renderer/themes/__tests__/tokenRegistry.test.ts index b8ce60aa4d..0f15888adb 100644 --- a/apps/desktop/src/renderer/themes/__tests__/tokenRegistry.test.ts +++ b/apps/desktop/src/renderer/themes/__tests__/tokenRegistry.test.ts @@ -102,6 +102,7 @@ describe('主题注册表 · 引擎徽标标识色', () => { 'engine-badge-cc': '#d97757', 'engine-badge-codex': '#7a9dff', 'engine-badge-pi': '#a78bfa', + 'engine-badge-grok-build': '#6b7280', } as const; it.each(Object.entries(ENGINE_BADGE_TOKENS))( diff --git a/apps/desktop/src/renderer/themes/colors.ts b/apps/desktop/src/renderer/themes/colors.ts index ed80d50c73..b634dc7f79 100644 --- a/apps/desktop/src/renderer/themes/colors.ts +++ b/apps/desktop/src/renderer/themes/colors.ts @@ -1084,7 +1084,8 @@ registerColor('fast-accent', { // 各自来源: // · cc = Anthropic 陶土橙,与 ClaudeMark 的 brand variant 同一支色; // · codex = Codex 官方渐变的中段蓝(CodexMark brand 的 0.5 stop); -// · pi = 上游无官方品牌色,取一支与前两者可区分的紫(统一选择器设计稿 v7)。 +// · pi = 上游无官方品牌色,取一支与前两者可区分的紫(统一选择器设计稿 v7); +// · grok-build = 上游品牌是黑白单色,取中性石墨灰,避免与前三支撞色。 // 徽标底色(14%)与描边(30%)由组件用 color-mix 从**同一个 var** 派生,PiMark 的 // currentColor 也接同一个 var —— TS 侧不再持有这三个 hex,不会出现「组件拿常量、 // 主题拿 token」两条路各画各的。 @@ -1100,6 +1101,10 @@ registerColor('engine-badge-pi', { light: '#a78bfa', dark: '#a78bfa', }, 'Pi 引擎徽标色 — 自选紫,上游无官方品牌色(light/dark 同值)'); +registerColor('engine-badge-grok-build', { + light: '#6b7280', + dark: '#6b7280', +}, 'Grok Build 引擎徽标色 — 上游品牌为黑白单色,取一支与前三支可区分的中性石墨灰(light/dark 同值)'); // Permission selector registerColor('perm-item-selected-bg', { light: '#f8f8f6', diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index f1af1f70fb..3c2adbabe8 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -159,7 +159,7 @@ interface DeviceLinkPresenceSnapshot { /** .cshare 导入向导的预览数据(main 侧 SharePreview 的镜像)。 */ interface SessionSharePreview { title: string; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; workspaceKind: 'project' | 'dialogue'; originalWorkingDir: string | null; exportedAt: string; @@ -525,7 +525,7 @@ interface WechatChannelSettingsState { type DiscordBotSessionAuthCheckResult = { ok: boolean; missing: 'gateway-key' | 'agent-oauth' | 'provider-key' | 'provider-disconnected' | null; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; providerLabel: string | null; @@ -624,7 +624,7 @@ interface OrcaWorkerRecord { session: { id: string; title: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; effort: string; @@ -700,7 +700,7 @@ interface CCAgentStreamEvent { | 'thinking' | 'compact_boundary'; data: unknown; - source?: 'claude-code' | 'codex' | 'pi' | 'vision-bridge'; + source?: 'claude-code' | 'codex' | 'pi' | 'grok-build' | 'vision-bridge'; /** * agent-meta: SDK 元信息(按 session.agentKind 解析)。当事件来自一条 SDK * message(assistant / tool_use / thinking final / done 等)时由 main 透传过来。 @@ -2307,12 +2307,12 @@ interface ElectronAPI { syncNewMakerDraft: (snapshot: { lastByVendor: Partial< Record< - 'cc' | 'codex' | 'pi', + 'cc' | 'codex' | 'pi' | 'grok-build', { model?: string; effort?: string; permissionMode?: string; providerId?: string | null } > >; /** 每个 vendor 是否由用户在 New Maker 中明确选过模型;device-link 默认校准据此保护显式选择。 */ - modelChosenByVendor: Partial>; + modelChosenByVendor: Partial>; fastModeByModel: Record; effortByModel: Record; /** 「新建会话默认启用 worktree」勾选记忆(vendor 无关根字段,远程草稿播种用)。 */ @@ -2335,7 +2335,7 @@ interface ElectronAPI { /** 被控端 renderer → 自身 main:会话「非选中模型」effort/fast 变化镜像(转发给控制端)。 */ syncSessionModelPref: (pref: { sessionId: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; model: string; effort?: string; @@ -2345,7 +2345,7 @@ interface ElectronAPI { /** 被控端本地 main → 自身 renderer:控制端写穿的草稿「模型 effort/fast」pref(调本地 setter)。 */ onMakerDraftPrefApply: ( cb: (payload: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; modelId: string; active: boolean; @@ -2384,7 +2384,7 @@ interface ElectronAPI { onMakerSessionPrefApply: ( cb: (payload: { sessionId: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; model: string; effort?: string; @@ -3031,7 +3031,7 @@ interface ElectronAPI { workingDir: string; cap?: number; query?: string; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; }) => Promise<{ success: boolean; error?: string; @@ -4290,7 +4290,7 @@ interface ElectronAPI { permissionMode?: string; fastMode?: boolean; planModeEnabled?: boolean; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; orcaRole?: import('@/lib/ccAgent.types').OrcaRole | null; /** 附加只读引用目录列表 (绝对路径); main 端 mapper 会 JSON.stringify 后写库。 */ extraDirs?: string[]; @@ -4762,8 +4762,8 @@ interface ElectronAPI { * apps/desktop/src/main/maker-ipc/ 的 handlers + apps/desktop/src/main/maker-host/。 */ maker: { - listAvailableAgents: () => Promise>; - getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + listAvailableAgents: () => Promise>; + getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise; /** workflow 逐 agent 进度树(只读);读不到 / 解析失败返回 null → 回退 workflow 级卡片。 */ getWorkflowProgress: ( sessionId: string, @@ -4789,11 +4789,11 @@ interface ElectronAPI { // 自定义供应商配置 CRUD(配置与 runtime 密钥均由 main 原子排队)。 createCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, ) => Promise<{ ok: true }>; updateCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, ) => Promise<{ ok: true }>; deleteCustomProvider: (providerId: string) => Promise<{ ok: true }>; localModelStatus: () => Promise; @@ -4847,11 +4847,11 @@ interface ElectronAPI { /** 供应商「测试连接」—— 与真实会话同路由口径的最小探测请求(结构化结果,code 走 providerError.* i18n)。 */ testProviderConnection: ( input: - | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' } + | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' | 'grok-build' } | { kind: 'adhoc'; spec: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; baseUrl: string; modelId: string; authMethod: 'apiKey' | 'oauth' | 'none'; @@ -4870,7 +4870,7 @@ interface ElectronAPI { }>; /** 供应商「获取模型列表」—— 表单值透传,结构化结果(code 走 providerError.* i18n)。 */ fetchProviderModels: (input: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; baseUrl: string; authMethod: 'apiKey' | 'oauth' | 'none'; wireProtocol?: import('@cindy/model-providers').ProviderWireProtocol; @@ -4942,7 +4942,7 @@ interface ElectronAPI { /** 自定义供应商上游错误订阅(返回 off);code 走 providerError.* i18n。 */ onProviderUpstreamError: ( cb: (event: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; providerName?: string; code: import('../shared/providerErrors').ProviderErrorCode; @@ -5066,7 +5066,7 @@ interface ElectronAPI { attachments?: import('./lib/fileTypes').SerializedAttachedFile[]; }) => Promise<{ ok: true; runId: string; reviewerSessionId: string }>; listAgentCommands: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params?: { sessionId?: string; allowManagedPiPackagePreview?: boolean }, ) => Promise<{ success: boolean; @@ -5076,7 +5076,7 @@ interface ElectronAPI { }>; listAgentSkills: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { workingDir?: string; forceReload?: boolean; sessionId?: string }, ) => Promise<{ success: boolean; @@ -5156,7 +5156,7 @@ interface ElectronAPI { ) => () => void; scanAtResources: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { workingDir: string; cap?: number; query?: string }, ) => Promise<{ success: boolean; @@ -5188,7 +5188,7 @@ interface ElectronAPI { createSession: (opts: { /** 可选: 复用外部 sessionId(本端 chat 用 local-db:sessions:create 拿到的 id) */ id?: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; title?: string; @@ -5232,7 +5232,7 @@ interface ElectronAPI { enableOrca: ( leadSessionId: string, opts: { - workerAgent: 'claude-code' | 'codex' | 'pi'; + workerAgent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; delegateTask?: string; role?: string; label?: string; @@ -5280,7 +5280,7 @@ interface ElectronAPI { message: string | { type: 'user'; content: string | Array<{ type: string; [k: string]: unknown }> }, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; orcaRole?: import('@/lib/ccAgent.types').OrcaRole | null; @@ -5326,7 +5326,7 @@ interface ElectronAPI { getContextUsage: ( sessionId: string, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; orcaRole?: import('@/lib/ccAgent.types').OrcaRole | null; @@ -5355,7 +5355,7 @@ interface ElectronAPI { listActive: () => Promise< Array<{ sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workDir: string; capabilities: unknown; isTurnRunning: boolean; @@ -5495,14 +5495,14 @@ interface ElectronAPI { */ switchSessionAgent: ( sessionId: string, - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', model: string, providerId?: string | null, effort?: string, fastMode?: boolean, ) => Promise<{ switched: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; engineReady: boolean; deferred?: boolean; @@ -5514,7 +5514,7 @@ interface ElectronAPI { * 重开视图 / device-link 远程会话重连后恢复乐观显示用。 */ getSessionAgentSwitchIntent: (sessionId: string) => Promise<{ - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort?: string; @@ -5554,13 +5554,13 @@ interface ElectronAPI { setExtraDirs: (sessionId: string, dirs: string[]) => Promise; // Memory 控制 (Settings → Personalization → Memory section) - memoryGet: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + memoryGet: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ enabled: boolean; source: 'agent-default' | 'host-runtime' | 'user-config'; stats?: { entryCount?: number; sizeBytes?: number; storagePath?: string }; }>; memorySet: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', enabled: boolean, ) => Promise<{ effective: 'immediate' | 'next-session'; @@ -5568,7 +5568,7 @@ interface ElectronAPI { customizedKeys: string[]; defaults: { maker: boolean; claudeCode: boolean; codex: boolean; pi: boolean }; }>; - memoryReset: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + memoryReset: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ removedEntries?: number; removedBytes?: number; }>; @@ -5863,7 +5863,7 @@ interface ElectronAPI { // Stage 2 C1: chat utility (前身 cc-agent:generate-title / cc-agent:plan-file-write) generateTitle: ( message: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', sessionId?: string, ) => Promise<{ title: string | null }>; /** 重命名输入框 Magic 按钮:按会话最新对话内容重新生成标题(失败返 title: null)。 */ @@ -5876,13 +5876,13 @@ interface ElectronAPI { autoTitle: (request: { sessionId: string; text: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; isUserText?: boolean; }) => Promise<{ applied: boolean; done: boolean }>; /** 输入框推荐提示词:turn 结束后预测用户下一步输入。 */ predictNextPrompt: (request: { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; messages: Array<{ role: string; content: string }>; workingDir?: string; turnGen: number; @@ -5953,22 +5953,22 @@ interface ElectronAPI { /* ── Agent 鉴权 (取代老 codex.auth.*) ── */ auth: { - getState: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + getState: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise; triggerLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', options?: { mode?: 'browser' | 'device-code'; ownerId?: string }, ) => Promise; cancelLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', options?: { releaseOwner?: boolean; ownerId?: string }, ) => Promise; - logout: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + logout: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise; onStateChanged: ( - cb: (s: { agentKind: 'claude-code' | 'codex' | 'pi' } & CodexAuthState) => void, + cb: (s: { agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build' } & CodexAuthState) => void, ) => () => void; onLoginProgress: ( cb: (p: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; phase: string; mode?: 'browser' | 'device-code'; detail?: string; @@ -5980,15 +5980,15 @@ interface ElectronAPI { /* ── Agent 联合状态 (binary + auth, 取代老 codex.binary.getStatus) ── */ agent: { - getStatus: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + getStatus: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ binaryReady: boolean; binaryPath: string; authReady: boolean; identity?: string; }>; /** spawn 当前应用使用的 binary `--version`, 进程内缓存。About 面板用。 */ - getBinaryVersion: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ - kind: 'claude-code' | 'codex' | 'pi'; + getBinaryVersion: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ + kind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; binaryPath: string | null; version: string | null; error?: string; @@ -5997,7 +5997,7 @@ interface ElectronAPI { /* ── Agent 今日累计 (取代老 codex.usage.* + onUsageTodaySpendChanged) ── */ usage: { - getToday: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + getToday: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ day: string; money?: import('../shared/regionalMoney').RegionalMoney; costUsd?: number; @@ -6007,7 +6007,7 @@ interface ElectronAPI { reasoningTokens?: number; cachedTokens?: number; }>; - getAccount: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + getAccount: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise; /** Codex app-server authoritative windows and banked reset-credit metadata. */ getCodexRateLimits: () => Promise< import('@cindy/maker-shared/device-link-contract').MobileCodexRateLimitsResult @@ -6094,7 +6094,7 @@ interface ElectronAPI { crossAgent: { detect: ( workingDir: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ) => Promise<{ items: CrossAgentMigrationItem[] }>; convert: (items: CrossAgentMigrationItem[]) => Promise<{ total: number; @@ -6161,7 +6161,7 @@ interface ElectronAPI { scheduleName?: string; workingDir?: string; providerId?: string; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model?: string; /** 绑定会话任务:workingDir 空时 main 按会话 meta.workDir 解析落盘/自测目录。 */ targetSessionId?: string; @@ -6354,10 +6354,10 @@ interface SkillhubSkill { /** 同一 URL 基键存在多个来源时,详情路由必须携带 sourceKey。 */ requiresSourceKey?: boolean; /** 来自哪个 agent 引擎。 */ - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** 发现该 skill 的所有引擎专属路径(去重后)。 */ linkedEngines: Array<{ - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'grok-build'; label: string; runtimeStatus?: 'discovered' | 'approved' | 'loaded' | 'failed' | 'unknown'; }>; @@ -6482,7 +6482,7 @@ interface SkillUsageEvidenceIndex { rawLineNo: number; sessionId: string; sdkSessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; skillName: string; skillPath: string | null; skillDocumentHash: string | null; diff --git a/apps/desktop/src/shared/agentInputQueue.ts b/apps/desktop/src/shared/agentInputQueue.ts index b10ebbd6f2..096ed3a97f 100644 --- a/apps/desktop/src/shared/agentInputQueue.ts +++ b/apps/desktop/src/shared/agentInputQueue.ts @@ -118,7 +118,7 @@ export interface AgentInputChatMessage { } export interface AgentInputCreateOpts { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; providerId?: string | null; diff --git a/apps/desktop/src/shared/agentKindConversion.ts b/apps/desktop/src/shared/agentKindConversion.ts index e66b72f0e1..d5b66473d0 100644 --- a/apps/desktop/src/shared/agentKindConversion.ts +++ b/apps/desktop/src/shared/agentKindConversion.ts @@ -1,6 +1,6 @@ /** - * agentKindConversion —— DB/renderer 形态('cc' | 'codex' | 'pi')与 maker-core - * 形态('claude-code' | 'codex' | 'pi')的唯一双向映射。 + * agentKindConversion —— DB/renderer 形态('cc' | 'codex' | 'pi' | 'grok-build')与 + * maker-core 形态('claude-code' | 'codex' | 'pi' | 'grok-build')的唯一双向映射。 * * 背景:sessions.agent_kind 历史上存 renderer 形态('cc' 起家,default 'cc'), * maker-core 用 'claude-code'。三值化前全仓散落 `x === 'cc' ? 'claude-code' : @@ -9,23 +9,25 @@ */ /** DB(sessions.agent_kind)与 renderer 侧的 agent 形态。 */ -export type DbAgentKind = 'cc' | 'codex' | 'pi'; +export type DbAgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; /** maker-core / IPC 契约侧的 agent 形态。 */ -export type MakerAgentKindWire = 'claude-code' | 'codex' | 'pi'; +export type MakerAgentKindWire = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export function dbToMakerAgentKind(db: string | null | undefined): MakerAgentKindWire { if (db === 'codex') return 'codex'; if (db === 'pi') return 'pi'; + if (db === 'grok-build') return 'grok-build'; return 'claude-code'; // 'cc' 与历史缺省 } export function makerToDbAgentKind(maker: string | null | undefined): DbAgentKind { if (maker === 'codex') return 'codex'; if (maker === 'pi') return 'pi'; + if (maker === 'grok-build') return 'grok-build'; return 'cc'; // 'claude-code' 与历史缺省 } /** 宽输入归一成 DbAgentKind;非法值回落 'cc'(与 sessions 表 default 同语义)。 */ export function normalizeDbAgentKind(value: string | null | undefined): DbAgentKind { - return value === 'codex' || value === 'pi' ? value : 'cc'; + return value === 'codex' || value === 'pi' || value === 'grok-build' ? value : 'cc'; } diff --git a/apps/desktop/src/shared/conversationSearch.ts b/apps/desktop/src/shared/conversationSearch.ts index c540cd2c51..7858687abd 100644 --- a/apps/desktop/src/shared/conversationSearch.ts +++ b/apps/desktop/src/shared/conversationSearch.ts @@ -2,7 +2,7 @@ import { projectDraftSessionTitle } from '@cindy/maker-shared/session-title'; import type { SessionSource } from './sessionSource'; -export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi'; +export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; export type ConversationSearchWorkspaceKind = 'project' | 'dialogue'; export type ConversationSearchSessionStatus = 'active' | 'archived' | 'deleted'; export type ConversationSearchOrcaRole = 'lead' | 'worker'; diff --git a/apps/desktop/src/shared/modelPriceQuote.ts b/apps/desktop/src/shared/modelPriceQuote.ts index 85ab7a6d40..4f7d33b263 100644 --- a/apps/desktop/src/shared/modelPriceQuote.ts +++ b/apps/desktop/src/shared/modelPriceQuote.ts @@ -259,10 +259,11 @@ export function providerReferencePriceQuote( } = {}, ): ModelPriceQuote | undefined { // 参考价 registry 的 agent 维度只有 claude-code / codex;Pi(动态 BYOM,按 provider/模型 - // 路由)在此按 agent 无关的参考价解析 —— pi 一律降级为 undefined 传给协议函数。 + // 路由)与 Grok Build(本机 CLI 自带单一模型条目,不进参考价表)在此按 agent 无关的 + // 参考价解析 —— 这两个 kind 一律降级为 undefined 传给协议函数。 const resolved = resolveModelReferencePrice(registry, providerId, modelId, { ...options, - agent: options.agent === 'pi' ? undefined : options.agent, + agent: options.agent === 'pi' || options.agent === 'grok-build' ? undefined : options.agent, }); if (!resolved) return undefined; const day = referencePriceCalendarDate(options.at); diff --git a/apps/desktop/src/shared/piPackages.ts b/apps/desktop/src/shared/piPackages.ts index a2ee81ae93..c5d6e03739 100644 --- a/apps/desktop/src/shared/piPackages.ts +++ b/apps/desktop/src/shared/piPackages.ts @@ -1,3 +1,5 @@ +import type { MakerAgentKindWire } from './agentKindConversion.js'; + export type PiPackageResourceKind = 'extension' | 'skill' | 'prompt' | 'theme'; export type PiPackageCompatibility = 'supported' | 'partial' | 'unsupported' | 'unknown'; @@ -160,7 +162,7 @@ export type PiPackageCommandRuntimeStatus = /** Runtime-confirmed Pi package commands belong only to the Pi command palette. */ export function mergePiPackageCommands( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: MakerAgentKindWire, builtins: PiPackageSlashCommand[], packageCommands: Array<{ name: string; description: string }>, ): PiPackageSlashCommand[] { @@ -178,7 +180,7 @@ export function mergePiPackageCommands( } export function shouldListPiPackageCommands( - requestedAgentKind: 'claude-code' | 'codex' | 'pi', + requestedAgentKind: MakerAgentKindWire, sessionIdProvided: boolean, session: { agentKind: 'claude-code' | 'codex' | 'pi'; diff --git a/apps/desktop/src/shared/processMonitor.ts b/apps/desktop/src/shared/processMonitor.ts index 8a01f13312..55527082e8 100644 --- a/apps/desktop/src/shared/processMonitor.ts +++ b/apps/desktop/src/shared/processMonitor.ts @@ -23,6 +23,7 @@ export type ProcessUsageKind = | 'utility' | 'agent-claude' | 'agent-codex' + | 'agent-grok-build' | 'agent-pi'; /** Codex 本地 app-server 的产品职责;仅传枚举,不暴露 host key / 凭据 / 命令行。 */ @@ -72,5 +73,5 @@ export interface TerminateAgentProcessRequest { /** terminate 成功返回(失败一律走 IPC 错误协议 throwIpcError)。 */ export interface TerminateAgentProcessResult { pid: number; - kind: 'claude' | 'codex' | 'pi'; + kind: 'claude' | 'codex' | 'pi' | 'grok-build'; } diff --git a/apps/desktop/src/shared/sessionReference.ts b/apps/desktop/src/shared/sessionReference.ts index f700cd51d8..0fd0d9243a 100644 --- a/apps/desktop/src/shared/sessionReference.ts +++ b/apps/desktop/src/shared/sessionReference.ts @@ -1,3 +1,5 @@ +import type { DbAgentKind } from './agentKindConversion'; + /** * A scheduler-facing snapshot of a session reference. * @@ -10,5 +12,5 @@ export interface SessionReference { state: 'available' | 'deleted' | 'missing'; status?: 'active' | 'archived' | 'deleted'; title?: string; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: DbAgentKind; } diff --git a/apps/desktop/src/shared/turnChangeSet.ts b/apps/desktop/src/shared/turnChangeSet.ts index 1f9f7f96f1..e3eb57e1b3 100644 --- a/apps/desktop/src/shared/turnChangeSet.ts +++ b/apps/desktop/src/shared/turnChangeSet.ts @@ -2,7 +2,7 @@ import type { DiffChangeKind, FileDiff } from './gitReviewWire'; export const TURN_CHANGE_SET_MAX_DIFF_BYTES = 12 * 1024 * 1024; -export type TurnChangeProvider = 'codex' | 'claude-code' | 'pi'; +export type TurnChangeProvider = 'codex' | 'claude-code' | 'pi' | 'grok-build'; export type TurnChangeSetState = 'complete' | 'partial'; export type TurnChangeWorkspaceState = 'applied' | 'undone'; export type TurnChangeAction = 'undo' | 'reapply'; diff --git a/apps/mobile/src/__tests__/homeDesktopFirst.test.ts b/apps/mobile/src/__tests__/homeDesktopFirst.test.ts index 6736a51f61..b9638d128c 100644 --- a/apps/mobile/src/__tests__/homeDesktopFirst.test.ts +++ b/apps/mobile/src/__tests__/homeDesktopFirst.test.ts @@ -271,7 +271,9 @@ describe('mobile home desktop-first surface', () => { expect(providerMarkSource).not.toContain('CLAUDE_AGENT_PATH'); expect(providerMarkSource).not.toContain('CODEX_AGENT_FLOWER_PATH'); expect(vendorIconSource).toContain("import { MobileAgentMark } from './MobileAgentMark';"); - expect(vendorIconSource).toContain("agentKind={vendor === 'codex' || vendor === 'pi' ? vendor : 'claude-code'}"); + expect(vendorIconSource).toContain( + "agentKind={vendor === 'codex' || vendor === 'pi' || vendor === 'grok-build' ? vendor : 'claude-code'}", + ); expect(vendorIconSource).not.toContain('viewBox="136 137 282 158"'); expect(vendorIconSource).not.toContain('transform="translate('); expect(vendorIconSource).toContain('Easing.inOut(Easing.ease)'); diff --git a/apps/mobile/src/__tests__/newSession.test.ts b/apps/mobile/src/__tests__/newSession.test.ts index 9dc5d3c5db..47be592bb0 100644 --- a/apps/mobile/src/__tests__/newSession.test.ts +++ b/apps/mobile/src/__tests__/newSession.test.ts @@ -1124,7 +1124,7 @@ describe('new session model', () => { it('exposes Pi as a first-class agent and preserves Fast for Pi sessions', () => { expect(NEW_SESSION_AGENT_OPTIONS.map((option) => option.kind)).toEqual([ - 'claude-code', 'codex', 'pi', + 'claude-code', 'codex', 'pi', 'grok-build', ]); const pi = withAgentDefaults({ ...DEFAULT_NEW_SESSION_DRAFT, fastMode: true }, 'pi'); expect(pi).toMatchObject({ agentKind: 'pi', model: 'gpt-5.4', fastMode: true }); @@ -1136,10 +1136,18 @@ describe('new session model', () => { }); it('filters the new-session agent options by the controlled device runtime-registered set', () => { - // null(未拉到)→ fail-open,全部保留。 + // null(未拉到)→ fail-open 保留随桌面端分发的 runtime;grok-build 需本机装 grok, + // 未确认注册前不露出,避免建出 requireAgent 报 not-registered 的会话。 expect(availableNewSessionAgentOptions(null).map((o) => o.kind)).toEqual([ 'claude-code', 'codex', 'pi', ]); + // 被控端确认注册了 grok-build → 才出现在入口里。 + expect( + availableNewSessionAgentOptions(new Set(['claude-code', 'codex', 'pi', 'grok-build'])) + .map((o) => o.kind), + ).toEqual(['claude-code', 'codex', 'pi', 'grok-build']); + expect(availableNewSessionAgentOptions(new Set(['grok-build'])).map((o) => o.kind)) + .toEqual(['grok-build']); // 被控端无 Pi(二进制缺失)→ 隐藏 Pi,避免建出 requireAgent 报 not-registered 的会话。 expect( availableNewSessionAgentOptions(new Set(['claude-code', 'codex'])).map((o) => o.kind), diff --git a/apps/mobile/src/components/MobileAgentMark.tsx b/apps/mobile/src/components/MobileAgentMark.tsx index 565592aba9..64d00fc06b 100644 --- a/apps/mobile/src/components/MobileAgentMark.tsx +++ b/apps/mobile/src/components/MobileAgentMark.tsx @@ -14,7 +14,7 @@ import { } from './vendorIconPaths'; export interface MobileAgentMarkProps { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; color: string; size?: number; } @@ -26,6 +26,8 @@ export function MobileAgentMark({ agentKind, color, size = iconSize.sm }: Mobile {agentKind === 'pi' ? ( π + ) : agentKind === 'grok-build' ? ( + G ) : agentKind === 'codex' ? ( diff --git a/apps/mobile/src/device-link/mobileMakerTransport.ts b/apps/mobile/src/device-link/mobileMakerTransport.ts index 9478f95f70..873d232c7b 100644 --- a/apps/mobile/src/device-link/mobileMakerTransport.ts +++ b/apps/mobile/src/device-link/mobileMakerTransport.ts @@ -62,7 +62,7 @@ export interface SendOptions { } export interface CreateSessionOptions { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** * 控制端预生成的 sessionId(新建会话乐观管线用):被控端 readCreateSessionOpts * 自手机远控首版(2026-06-21)起透传 body.id,maker-core createSession 对 @@ -94,7 +94,7 @@ export interface CreateSessionResult { usedProjectContext?: boolean; } -export type MobileAgentKind = 'claude-code' | 'codex' | 'pi'; +export type MobileAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type MobileSlashCommand = | { kind: 'agent-builtin'; name: string; description: string } diff --git a/apps/mobile/src/session/MessageRenderer.tsx b/apps/mobile/src/session/MessageRenderer.tsx index 5d1f1d7053..00d2236f4e 100644 --- a/apps/mobile/src/session/MessageRenderer.tsx +++ b/apps/mobile/src/session/MessageRenderer.tsx @@ -3041,6 +3041,7 @@ const AGENT_TASK_PROVIDER_LABEL: Record 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; function AgentTaskStatusIcon({ status, size = iconSize.md }: { status: AgentTaskStatus; size?: number }) { diff --git a/apps/mobile/src/session/agentAuthGate.ts b/apps/mobile/src/session/agentAuthGate.ts index 1426e2dc36..dfb00b551a 100644 --- a/apps/mobile/src/session/agentAuthGate.ts +++ b/apps/mobile/src/session/agentAuthGate.ts @@ -24,7 +24,7 @@ export interface AgentAuthGateInput { loading: boolean; /** 目录拉取失败(典型:旧被控端不识别通道)。 */ error: string | null; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** * true = 已建会话的发送门禁:计入 suspended 来源(停用是准入轴,不打断运行中 * 会话,门禁只回答「凭证还连着吗」)。缺省 false = 新建草稿:suspended 不算可 @@ -45,7 +45,7 @@ export function agentAuthGateVerdict(input: AgentAuthGateInput): AgentAuthGateVe } /** 未鉴权时的提示文案(与 describeAgentAuthError 的引导口径一致)。 */ -export function agentAuthGateHint(agentKind: 'claude-code' | 'codex' | 'pi'): string { +export function agentAuthGateHint(agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): string { const label = agentKind === 'claude-code' ? 'Claude' : agentKind === 'pi' ? 'Pi' : 'Codex'; return i18n.t('session.row.authGateHint', { agent: label }); } diff --git a/apps/mobile/src/session/composerPalette.ts b/apps/mobile/src/session/composerPalette.ts index f45f62d595..2fe9255805 100644 --- a/apps/mobile/src/session/composerPalette.ts +++ b/apps/mobile/src/session/composerPalette.ts @@ -2,6 +2,8 @@ import type { RemoteSession } from './types'; export * from '@cindy/maker-shared/composer-palette'; -export function agentKindForSession(session: Pick): 'claude-code' | 'codex' | 'pi' { - return session.agentKind === 'codex' || session.agentKind === 'pi' ? session.agentKind : 'claude-code'; +export function agentKindForSession(session: Pick): 'claude-code' | 'codex' | 'pi' | 'grok-build' { + return session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'grok-build' + ? session.agentKind + : 'claude-code'; } diff --git a/apps/mobile/src/session/newSession.ts b/apps/mobile/src/session/newSession.ts index d76d73f8a0..2a070f57e5 100644 --- a/apps/mobile/src/session/newSession.ts +++ b/apps/mobile/src/session/newSession.ts @@ -12,25 +12,37 @@ import { effectiveSourceIdForModel } from '@cindy/model-providers/registry'; import { reconcileEffortForModel, type ProviderModelRow } from './providerModelSections'; import type { RemoteSession } from './types'; -export type NewSessionAgentKind = 'claude-code' | 'codex' | 'pi'; +export type NewSessionAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type NewSessionWorkspaceKind = 'project' | 'dialogue'; export const NEW_SESSION_AGENT_OPTIONS: readonly { kind: NewSessionAgentKind; label: string }[] = [ { kind: 'claude-code', label: 'Claude' }, { kind: 'codex', label: 'Codex' }, { kind: 'pi', label: 'Pi' }, + { kind: 'grok-build', label: 'Grok Build' }, ]; +/** + * 需要用户自行在被控端装好本机 CLI 才会注册的 runtime。Claude Code / Codex / Pi 的 + * 二进制随桌面端分发,几乎总是注册;grok-build 只有 PATH 上有 grok 才注册,因此不进 + * fail-open 名单——否则拉取注册集合的那段时间里,多数用户会看到一个建了就报 + * not-registered 的入口。 + */ +const OPT_IN_NEW_SESSION_AGENT_KINDS: ReadonlySet = new Set(['grok-build']); + /** * 按被控端 runtime 已注册的 agent 集合过滤新建入口(maker:list-available-agents)。 - * `available === null` = 尚未拉到 → fail-open 返回全部(避免异步期间误隐藏合法 agent); + * `available === null` = 尚未拉到 → fail-open 返回随桌面端分发的那几个(避免异步期间 + * 误隐藏合法 agent,同时不提前露出需自行安装的 runtime); * 拉到后只保留已注册的 kind —— Pi 二进制缺失时被控端无 pi,过滤掉可防用户建出最终 * requireAgent 报 not-registered 的会话(codex review P2)。 */ export function availableNewSessionAgentOptions( available: ReadonlySet | null, ): readonly { kind: NewSessionAgentKind; label: string }[] { - if (!available) return NEW_SESSION_AGENT_OPTIONS; + if (!available) { + return NEW_SESSION_AGENT_OPTIONS.filter((option) => !OPT_IN_NEW_SESSION_AGENT_KINDS.has(option.kind)); + } const filtered = NEW_SESSION_AGENT_OPTIONS.filter((option) => available.has(option.kind)); // 防御:被控端异常返回空集时不至于把入口清空到无法创建(至少保留 Claude)。 return filtered.length > 0 ? filtered : NEW_SESSION_AGENT_OPTIONS.filter((o) => o.kind === 'claude-code'); @@ -154,6 +166,8 @@ const DEFAULT_MODELS: Record = { 'claude-code': 'claude-sonnet-4-6', codex: 'gpt-5.4', pi: 'gpt-5.4', + // grok-build 只有内置的单一模型条目(GrokBuildAgent.capabilities.availableModels)。 + 'grok-build': 'grok-build', }; /** 新建交互式会话的权限种子默认;三个 agent 都保留 Auto-review。 */ @@ -325,7 +339,7 @@ type NewSessionDefaultModel = { id: string; efforts: readonly string[]; defaultEffort: string | null; - newSessionDefault?: readonly ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: readonly ('claude-code' | 'codex' | 'pi' | 'grok-build')[]; }; function isNewSessionDefaultForAgent( diff --git a/apps/mobile/src/session/sessionAgentSwitch.ts b/apps/mobile/src/session/sessionAgentSwitch.ts index 6dc7c08a85..8870b54873 100644 --- a/apps/mobile/src/session/sessionAgentSwitch.ts +++ b/apps/mobile/src/session/sessionAgentSwitch.ts @@ -9,7 +9,7 @@ import type { import type { MobileAgentCapabilities } from './agentCapabilities'; import type { RemoteSession } from './types'; -export type MobileSessionAgentKind = 'claude-code' | 'codex' | 'pi'; +export type MobileSessionAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** 将不可信 device-link payload 收窄为公开 intent;非法值按“无意图”处理。 */ export function normalizeSessionAgentSwitchIntent( @@ -21,6 +21,7 @@ export function normalizeSessionAgentSwitchIntent( item.targetAgentKind !== 'claude-code' && item.targetAgentKind !== 'codex' && item.targetAgentKind !== 'pi' + && item.targetAgentKind !== 'grok-build' ) return null; if (typeof item.model !== 'string' || item.model.length === 0) return null; // providerId 缺失(undefined)按 null 处理,与桌面 projectPendingAgentSwitchIntent 的 @@ -42,7 +43,7 @@ export function normalizeSessionAgentSwitchIntent( /** DB 会话行的 cc/codex 映射到 maker agent kind。 */ export function sessionAgentKind(session: Pick): MobileSessionAgentKind { - return session.agentKind === 'codex' || session.agentKind === 'pi' + return session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'grok-build' ? session.agentKind : 'claude-code'; } @@ -58,13 +59,16 @@ export function supportsMobileSessionAgentSwitch( } export function mobileAgentLabel(agentKind: MobileSessionAgentKind): string { - return agentKind === 'codex' ? 'Codex' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; + return mobileAgentLabelFromUnknown(agentKind); } export function mobileAgentLabelFromUnknown(agentKind: unknown): string { - return agentKind === 'codex' ? 'Codex' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; + if (agentKind === 'codex') return 'Codex'; + if (agentKind === 'pi') return 'Pi'; + if (agentKind === 'grok-build') return 'Grok Build'; + return 'Claude Code'; } -export function mobileAgentVendor(agentKind: MobileSessionAgentKind): 'cc' | 'codex' | 'pi' { +export function mobileAgentVendor(agentKind: MobileSessionAgentKind): 'cc' | 'codex' | 'pi' | 'grok-build' { return agentKind === 'claude-code' ? 'cc' : agentKind; } diff --git a/apps/mobile/src/session/types.ts b/apps/mobile/src/session/types.ts index feff62dd42..3941b3238c 100644 --- a/apps/mobile/src/session/types.ts +++ b/apps/mobile/src/session/types.ts @@ -59,7 +59,7 @@ export interface RemoteSession { activeTurnStartedAt?: number | null; lastTurnEndedAt?: number | null; status: RemoteSessionStatus; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; /** main 进程内的下一条消息跨 Agent 切换意图;null = 已确认没有。 */ agentSwitchIntent?: MobileSessionAgentSwitchIntent | null; source?: string; @@ -89,7 +89,7 @@ export interface RemoteSession { } export interface RemoteSessionRuntimeProfile { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort: string | null; @@ -193,7 +193,7 @@ export interface QueuedRemoteMessage { sessionReferencesRequireTrustedSnapshot?: boolean; userName?: string; createOpts: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; effort?: string; diff --git a/docs/dev-rules/grok-build-harness.md b/docs/dev-rules/grok-build-harness.md new file mode 100644 index 0000000000..e4bcf5f1e9 --- /dev/null +++ b/docs/dev-rules/grok-build-harness.md @@ -0,0 +1,43 @@ +# Grok Build harness 集成规则 + +> 修改 `packages/maker-core/src/agents/grok-build/**`、`apps/desktop/src/main/maker-host/grok-build-host.ts`, +> 或任何 Grok Build 会话行为、权限、探测之前必读本文件。 + +## 1. 架构总览 + +Grok Build 是 xAI 的终端 coding agent(`grok` CLI)。Cindy 把它作为第四个**可选 runtime** +(`AgentKind = 'grok-build'`),与 Claude Code / Codex / Pi 并列。协议是 ACP +(`grok agent stdio`,JSON-RPC 2.0 NDJSON),**不是** Pi RPC,也不是把 Grok 当模型 +(`xai/grok-*` 仍走 CC/Codex/Pi)。 + +关键装配点: + +- **探测**:只走 PATH 上的 `grok`。`buildGrokBuildAgent()` 在二进制缺失时返回 `null`, + Maker agents map 不注册,选择器隐藏。**禁止**读 `~/.grok/auth.json`,禁止复用 + SuperGrok OAuth,禁止 CDN 二进制 CONFIG。 +- **Spawn**:ask/auto → `grok agent stdio`;bypassPermissions → + `grok agent --always-approve stdio` 且 `session/new._meta.yoloMode = true`。 + auto 档**不得**设 grok 的 `autoMode` —— Cindy Auto-review 拦截 ACP + `session/request_permission`。 +- **Auth**:ACP `initialize.authMethods` 为空 = 已登录;非空 = logged-out。 + `XAI_API_KEY` 也算已登录。`triggerLogin` spawn `grok login`;`logout` spawn + `grok logout`。 + +## 2. 维护不变量 + +1. **权限档从严到宽**:`capabilities.permissionModes` 必须 + `[ask, auto, bypassPermissions]`,`[0]` 是最严档。由 + `grok-build-capabilities.test.ts` 守。 +2. **缺失 grok 不得影响其它 harness**:CC / Codex / Pi 的注册、启动、选择器保持原样。 +3. **UI vendor 是 `'grok-build'`**,不要用 `'grok'`(与 xAI catalog provider 撞名)。 +4. **不要**把 grok-build 加进 `VALID_AGENTS` / `CUSTOM_PROVIDER_RUNTIME_AGENTS` + (那些是把 Cindy 目录模型路由进 CC/Codex/Pi;grok-build 用自己的模型)。 +5. **就绪态**:二进制 + grok 登录 / `XAI_API_KEY`。不要用 + `connectedProvidersForAgent(..., 'grok-build')`(目录不会列出 grok-build)。 + +## 3. 非目标(本阶段不做) + +- CDN / cindy-binary-release 钉死 grok 版本 +- 移动端完整会话(只加类型与 `listAvailableAgents` 过滤) +- 把 SuperGrok OAuth 当本 harness 的登录 +- 嵌入 Grok TUI;one-shot `grok -p` diff --git a/packages/lizi-mcps/src/scheduler/setPreRunHook.ts b/packages/lizi-mcps/src/scheduler/setPreRunHook.ts index 6392317198..ffa22b50a5 100644 --- a/packages/lizi-mcps/src/scheduler/setPreRunHook.ts +++ b/packages/lizi-mcps/src/scheduler/setPreRunHook.ts @@ -19,6 +19,8 @@ import { z } from 'zod'; +import type { AgentKind } from '@cindy/maker-core'; + import { withScheduler } from './_shared.js'; import type { SchedulerMcpDeps } from '../types.js'; import type { SchedulerToolRegistry } from '../cindy_schedulerToolRegistry.js'; @@ -88,7 +90,9 @@ export function registerScheduleSetPreRunHookTool( let currentCommand: string | undefined; let currentTimeoutMs: number | undefined; let providerId: string | undefined; - let agentKind: 'codex' | 'claude-code' | 'pi' | undefined; + // schedule.agentKind 由宿主写入,这里只透传;类型跟 maker-core 的 AgentKind 走, + // 不再自己抄一份三档字面量(抄漏一档就是本行原来的编译错误)。 + let agentKind: AgentKind | undefined; let model: string | undefined; if (scheduleId) { const schedule = await scheduler.get(scheduleId); diff --git a/packages/lizi-mcps/src/types.ts b/packages/lizi-mcps/src/types.ts index 22abfe6243..1da36d22e9 100644 --- a/packages/lizi-mcps/src/types.ts +++ b/packages/lizi-mcps/src/types.ts @@ -503,7 +503,7 @@ export type ControlResult = * adding a new vendor (e.g. 'gemini') without updating this union will cause * LLM tool calls to fail zod enum validation. */ -export type ControlWorkerAgent = 'claude-code' | 'codex' | 'pi'; +export type ControlWorkerAgent = 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** Browser automation MCP host deps. Core browser execution is injected by host. */ export interface BrowserMcpDeps { diff --git a/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts b/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts index ee54a65e1e..f5fa140727 100644 --- a/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts +++ b/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts @@ -8,7 +8,7 @@ import { BRAND_NAME } from '@cindy/maker-shared/branding'; import { z } from 'zod'; import type { XdtHelperToolRegistry } from '../lizi_xdtHelperToolRegistry.js'; -import type { ControlResult } from '../lizi_xdtHelperMcpServer.js'; +import type { ControlResult, ControlWorkerAgent } from '../lizi_xdtHelperMcpServer.js'; import { errorPayload, okPayload } from './_payload.js'; export interface SendToWorkerDeps { @@ -22,7 +22,7 @@ export interface SendToWorkerDeps { }) => Promise< ControlResult< { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: ControlWorkerAgent; wakeKind: 'resumed' | 'already-active' | 'queued'; targetTitle: string | null; targetLastUserSendAt: string | null; @@ -38,7 +38,7 @@ export interface SendToWorkerDeps { }) => Promise< ControlResult< { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: ControlWorkerAgent; queuedMessageId: string; stopOutcome: | 'requested' diff --git a/packages/maker-core/src/agents/base-agent.ts b/packages/maker-core/src/agents/base-agent.ts index 7191b3a7cc..71444ebaef 100644 --- a/packages/maker-core/src/agents/base-agent.ts +++ b/packages/maker-core/src/agents/base-agent.ts @@ -381,7 +381,7 @@ export interface CodexAppServerProcessRegistration { export interface LocalAgentProcessRegistration { pid: number; - kind: 'claude' | 'pi'; + kind: 'claude' | 'pi' | 'grok-build'; role: 'task-host' | 'control-plane-service'; } diff --git a/packages/maker-core/src/agents/grok-build/__tests__/fake-grok-acp.mjs b/packages/maker-core/src/agents/grok-build/__tests__/fake-grok-acp.mjs new file mode 100755 index 0000000000..ae548ea76f --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/fake-grok-acp.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import readline from 'node:readline'; + +const failInit = process.env.FAKE_GROK_FAIL_INIT === '1'; +const failPrompt = process.env.FAKE_GROK_FAIL_PROMPT === '1'; +const updateBeforeError = process.env.FAKE_GROK_UPDATE_BEFORE_ERROR === '1'; +const promptDelayMs = Number(process.env.FAKE_GROK_PROMPT_DELAY_MS ?? '0'); + +function reply(obj) { + process.stdout.write(`${JSON.stringify(obj)}\n`); +} + +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + if (!line.trim()) return; + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + if (msg.method === 'initialize') { + if (failInit) { + reply({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'initialize failed' } }); + return; + } + reply({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, authMethods: [] } }); + return; + } + if (msg.method === 'session/new') { + reply({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'sess-1' } }); + return; + } + if (msg.method === 'session/prompt') { + const sessionId = msg.params?.sessionId ?? 'sess-1'; + if (failPrompt && !updateBeforeError) { + reply({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'prompt rejected' } }); + return; + } + reply({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }, + }, + }); + if (failPrompt && updateBeforeError) { + reply({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'late failure' } }); + return; + } + const finish = () => { + reply({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } }); + }; + if (promptDelayMs > 0) setTimeout(finish, promptDelayMs); + else finish(); + } +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/fake-transport.ts b/packages/maker-core/src/agents/grok-build/__tests__/fake-transport.ts new file mode 100644 index 0000000000..e2b62e7c4a --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/fake-transport.ts @@ -0,0 +1,48 @@ +import type { AcpTransport, AcpCloseHandler, AcpLineHandler, AcpStderrHandler } from '../stdio-transport.js'; + +/** In-memory ACP transport for tests. */ +export class FakeAcpTransport implements AcpTransport { + readonly written: string[] = []; + private lineHandlers = new Set(); + private stderrHandlers = new Set(); + private closeHandlers = new Set(); + private closed = false; + readonly pid = 4242; + + async writeLine(line: string): Promise { + if (this.closed) throw new Error('closed'); + this.written.push(line); + } + + onLine(handler: AcpLineHandler): () => void { + this.lineHandlers.add(handler); + return () => { this.lineHandlers.delete(handler); }; + } + + onStderr(handler: AcpStderrHandler): () => void { + this.stderrHandlers.add(handler); + return () => { this.stderrHandlers.delete(handler); }; + } + + onClose(handler: AcpCloseHandler): () => void { + this.closeHandlers.add(handler); + return () => { this.closeHandlers.delete(handler); }; + } + + async close(reason = 'test close'): Promise { + if (this.closed) return; + this.closed = true; + for (const handler of this.closeHandlers) handler({ reason }); + } + + pushLine(obj: unknown): void { + const line = typeof obj === 'string' ? obj : JSON.stringify(obj); + for (const handler of this.lineHandlers) handler(line); + } + + lastRequest(): { id: number; method: string; params?: unknown; jsonrpc: string } { + const raw = this.written.at(-1); + if (!raw) throw new Error('no request written'); + return JSON.parse(raw) as { id: number; method: string; params?: unknown; jsonrpc: string }; + } +} diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-acp-client.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-acp-client.test.ts new file mode 100644 index 0000000000..52e7b4682e --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-acp-client.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { AcpClient, AcpRequestTimeoutError } from '../acp-client.js'; +import { ACP_JSONRPC_VERSION } from '../types.js'; +import { FakeAcpTransport } from './fake-transport.js'; + +describe('AcpClient JSON-RPC 2.0', () => { + it('sends initialize with jsonrpc 2.0 and resolves the result', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport }); + client.start(); + const pending = client.initialize({ + protocolVersion: 1, + clientInfo: { name: 'cindy', version: '0' }, + }); + await Promise.resolve(); + const req = transport.lastRequest(); + expect(req.jsonrpc).toBe(ACP_JSONRPC_VERSION); + expect(req.method).toBe('initialize'); + transport.pushLine({ + jsonrpc: '2.0', + id: req.id, + result: { protocolVersion: 1, authMethods: [] }, + }); + await expect(pending).resolves.toMatchObject({ protocolVersion: 1, authMethods: [] }); + await client.close(); + }); + + it('routes session/update notifications', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport }); + const updates: unknown[] = []; + client.onNotification((method, params) => { + updates.push({ method, params }); + }); + client.start(); + transport.pushLine({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 's1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }, + }, + }); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ method: 'session/update' }); + await client.close(); + }); + + it('answers session/request_permission from the request handler', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport }); + client.setRequestHandler(async (method, params) => { + expect(method).toBe('session/request_permission'); + expect(params).toMatchObject({ toolCall: { toolCallId: 'tc-1' } }); + return { outcome: { outcome: 'selected', optionId: 'allow-once' } }; + }); + client.start(); + transport.pushLine({ + jsonrpc: '2.0', + id: 99, + method: 'session/request_permission', + params: { + sessionId: 's1', + toolCall: { toolCallId: 'tc-1', kind: 'execute', title: 'bash' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }], + }, + }); + await Promise.resolve(); + await Promise.resolve(); + const response = JSON.parse(transport.written.at(-1)!) as { + jsonrpc: string; + id: number; + result: { outcome: { outcome: string; optionId: string } }; + }; + expect(response.jsonrpc).toBe('2.0'); + expect(response.id).toBe(99); + expect(response.result.outcome).toEqual({ outcome: 'selected', optionId: 'allow-once' }); + await client.close(); + }); + + it('times out initialize when the agent never replies', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport, defaultTimeoutMs: 20 }); + client.start(); + await expect(client.initialize({ protocolVersion: 1 }, 20)).rejects.toBeInstanceOf(AcpRequestTimeoutError); + await client.close(); + }); + + it('sends session/cancel as a notification (no id)', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport }); + client.start(); + await client.sessionCancel('s-9'); + const payload = JSON.parse(transport.written.at(-1)!) as Record; + expect(payload).toEqual({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId: 's-9' } }); + expect(payload).not.toHaveProperty('id'); + await client.close(); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-auto-review-policy.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-auto-review-policy.test.ts new file mode 100644 index 0000000000..e2e8083ada --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-auto-review-policy.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { grokBuildToolToReviewableAction, pickPermissionOptionId } from '../auto-review-policy.js'; + +describe('grok-build auto-review policy', () => { + it('maps ACP tool kinds onto ReviewableAction', () => { + expect(grokBuildToolToReviewableAction({ + toolCallId: '1', kind: 'execute', rawInput: { command: 'ls' }, + })).toEqual({ kind: 'exec', command: 'ls', cwd: undefined, cwdUnknown: false }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '2', kind: 'edit', locations: [{ path: '/repo/a.ts' }], + })).toEqual({ kind: 'file-write', path: '/repo/a.ts' }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '3', kind: 'read', rawInput: { path: '/repo/a.ts' }, + })).toMatchObject({ kind: 'read', path: '/repo/a.ts' }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '4', kind: 'fetch', rawInput: { url: 'https://example.com' }, + })).toMatchObject({ kind: 'network', target: 'https://example.com' }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '5', kind: 'think', + })).toEqual({ kind: 'session-state' }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '6', kind: 'other', title: 'mcp', + })).toMatchObject({ kind: 'other', description: 'mcp' }); + }); + + it('picks ACP permission option ids for allow/deny', () => { + const options = [ + { optionId: 'a1', kind: 'allow_once' }, + { optionId: 'a2', kind: 'allow_always' }, + { optionId: 'r1', kind: 'reject_once' }, + ]; + expect(pickPermissionOptionId(options, 'allow')).toBe('a1'); + expect(pickPermissionOptionId(options, 'allow', true)).toBe('a2'); + expect(pickPermissionOptionId(options, 'deny')).toBe('r1'); + }); + + it('fail-closes deny when only allow_* options exist', () => { + const options = [{ optionId: 'a1', kind: 'allow_once' }]; + expect(pickPermissionOptionId(options, 'deny')).toBeNull(); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-capabilities.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-capabilities.test.ts new file mode 100644 index 0000000000..dcc711d5b2 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-capabilities.test.ts @@ -0,0 +1,63 @@ +/** + * GrokBuildAgent capabilities contract — permissionModes must be strict→wide, + * `[0]` is the strictest mode (hook-control/defaults.ts falls back to it). + */ +import { describe, expect, it } from 'vitest'; + +import { GrokBuildAgent } from '../index.js'; +import type { AgentDeps } from '../../base-agent.js'; +import type { Logger } from '../../../interfaces/logger.js'; + +const noopLogger: Logger = { + trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, + child: () => noopLogger, +}; + +function buildAgent(): GrokBuildAgent { + const deps: AgentDeps = { + auth: { + getState: async () => ({ authenticated: true, identity: 't', authSource: 'api-key' as const }), + triggerLogin: async () => ({ authenticated: true }), + logout: async () => {}, + getAuthEnv: async () => ({}), + }, + runtimeConfig: {}, + binaryPath: '/nonexistent/grok', + logger: noopLogger, + }; + return new GrokBuildAgent(deps); +} + +describe('GrokBuildAgent capabilities contract', () => { + it('declares permission modes strict→wide with ask first (unattended clamp safety)', () => { + const ids = buildAgent().capabilities.permissionModes.map((m) => m.id); + expect(ids).toEqual(['ask', 'auto', 'bypassPermissions']); + expect(ids[0]).toBe('ask'); + expect(ids[ids.length - 1]).toBe('bypassPermissions'); + }); + + it('every permission mode ships an English fallback label + description', () => { + for (const m of buildAgent().capabilities.permissionModes) { + expect(m.displayName && m.displayName.length > 0).toBe(true); + expect(m.description && m.description.length > 0).toBe(true); + expect(/[一-鿿]/.test(`${m.displayName}${m.description}`)).toBe(false); + } + }); + + it('does not expose Fast mode', () => { + expect(buildAgent().capabilities.hasFastMode).toBe(false); + }); + + it('supports host turn policies in ask/auto but rejects Full Access', () => { + expect(buildAgent().capabilities.turnPermissionPolicy).toEqual({ + supported: { supported: true }, + unsupportedPermissionModes: ['bypassPermissions'], + }); + }); + + it('exposes a built-in Grok Build model and abort', () => { + const capabilities = buildAgent().capabilities; + expect(capabilities.availableModels.some((model) => model.id === 'grok-build')).toBe(true); + expect(capabilities.abort).toEqual({ supported: true }); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-detect.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-detect.test.ts new file mode 100644 index 0000000000..bebe4cc7fb --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-detect.test.ts @@ -0,0 +1,138 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; + +import { detectGrokBuildOnPath, probeGrokBuildAcp, resolveGrokBinaryFromPath } from '../detect.js'; +import type { GrokSpawnFn } from '../stdio-transport.js'; + +function fakeSpawn(handler: (stdin: PassThrough, stdout: PassThrough) => void): GrokSpawnFn { + return () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as ReturnType; + Object.assign(child, { + stdin, + stdout, + stderr, + pid: 99, + killed: false, + kill: () => { + // killed 在 ChildProcess 类型上是只读的,伪造对象用赋值语法会过不了 tsc。 + Object.assign(child, { killed: true }); + child.emit('exit', 0, null); + return true; + }, + }); + stdin.on('data', (buf: Buffer) => { + handler(stdin, stdout); + void buf; + }); + // Also handle line-oriented writes: listen after each write by wrapping. + const originalWrite = stdin.write.bind(stdin); + stdin.write = ((chunk: unknown, encoding?: unknown, cb?: unknown) => { + const result = originalWrite(chunk as never, encoding as never, cb as never); + queueMicrotask(() => handler(stdin, stdout)); + return result; + }) as typeof stdin.write; + return child; + }; +} + +describe('grok-build detection', () => { + it('reports uninstalled when grok is not on PATH', () => { + const result = detectGrokBuildOnPath({ + pathEnv: '/tmp/empty-bin', + existsSyncImpl: () => false, + platform: 'linux', + }); + expect(result).toEqual({ + status: 'uninstalled', + binaryPath: null, + errorReason: 'uninstalled', + }); + expect(resolveGrokBinaryFromPath({ pathEnv: '', existsSyncImpl: () => false })).toBeNull(); + }); + + it('resolves grok on PATH without reading auth.json', () => { + const found = resolveGrokBinaryFromPath({ + pathEnv: '/opt/xai/bin:/usr/bin', + platform: 'linux', + existsSyncImpl: (candidate) => candidate === '/opt/xai/bin/grok', + }); + expect(found).toBe('/opt/xai/bin/grok'); + }); + + // Windows 上 grok 由 npm 装成 .cmd shim;分隔符是 ;、扩展名要逐个试。 + // 注入 platform 的用例在任何宿主上都要给出同一答案,所以拼接也得跟着 platform 走。 + it('resolves the Windows .cmd shim from a win32 PATH', () => { + const found = resolveGrokBinaryFromPath({ + pathEnv: 'C:\\Users\\u\\AppData\\Roaming\\npm;C:\\Windows\\system32', + platform: 'win32', + existsSyncImpl: (candidate) => candidate === 'C:\\Users\\u\\AppData\\Roaming\\npm\\grok.cmd', + }); + expect(found).toBe('C:\\Users\\u\\AppData\\Roaming\\npm\\grok.cmd'); + }); + + it('treats initialize authMethods as logged-out', async () => { + const spawnImpl = fakeSpawn((_stdin, stdout) => { + stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: 1, + authMethods: [{ id: 'oauth', name: 'Sign in' }], + }, + })}\n`); + }); + const result = await probeGrokBuildAcp({ + binaryPath: '/opt/xai/bin/grok', + spawnImpl, + timeoutMs: 1_000, + }); + expect(result.status).toBe('logged-out'); + expect(result.binaryPath).toBe('/opt/xai/bin/grok'); + }); + + it('treats empty authMethods as ready', async () => { + const spawnImpl = fakeSpawn((_stdin, stdout) => { + stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { protocolVersion: 1, authMethods: [] }, + })}\n`); + }); + const result = await probeGrokBuildAcp({ + binaryPath: '/opt/xai/bin/grok', + spawnImpl, + timeoutMs: 1_000, + }); + expect(result.status).toBe('ready'); + }); + + it('reports acp-fail when initialize times out', async () => { + const spawnImpl = fakeSpawn(() => { + // never replies + }); + const result = await probeGrokBuildAcp({ + binaryPath: '/opt/xai/bin/grok', + spawnImpl, + timeoutMs: 30, + }); + expect(result.status).toBe('acp-fail'); + expect(result.errorReason).toMatch(/timed out/i); + }); +}); + +describe('optional grok-build registration', () => { + it('omits grok-build from the Maker agents map when detection returns null', () => { + const grokBuildAgent = null; + const agents = { + 'claude-code': { kind: 'claude-code' }, + codex: { kind: 'codex' }, + pi: { kind: 'pi' }, + ...(grokBuildAgent ? { 'grok-build': grokBuildAgent } : {}), + }; + expect(Object.keys(agents)).toEqual(['claude-code', 'codex', 'pi']); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-session.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-session.test.ts new file mode 100644 index 0000000000..690760a1cd --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-session.test.ts @@ -0,0 +1,139 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { GrokBuildAgent } from '../index.js'; +import type { AgentDeps } from '../../base-agent.js'; +import type { AgentEvent } from '../../../types/events.js'; +import type { Logger } from '../../../interfaces/logger.js'; + +const fakeGrokScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fake-grok-acp.mjs'); + +/** + * 被控端把 grok 当普通可执行文件 spawn(不过 shell),所以这里不能直接把 .mjs 当 + * binaryPath —— CI runner 上没有 bun / node 未必在 PATH。跟 Pi 侧同款做法:写一个 + * sh wrapper 显式 exec 当前 node 去跑假 grok。Windows 上 spawn 不过 shell 起不了 + * .cmd(Node ≥18.20 直接 EINVAL),整组按仓库既有约定跳过。 + */ +let fakeGrok = ''; +let wrapperDir = ''; + +beforeAll(async () => { + wrapperDir = await mkdtemp(path.join(tmpdir(), 'grok-build-fake-')); + fakeGrok = path.join(wrapperDir, 'grok'); + await writeFile(fakeGrok, `#!/bin/sh\nexec "${process.execPath}" "${fakeGrokScript}" "$@"\n`); + await chmod(fakeGrok, 0o700); +}); + +afterAll(async () => { + if (wrapperDir) await rm(wrapperDir, { force: true, recursive: true }); +}); + +const noopLogger: Logger = { + trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, + child: () => noopLogger, +}; + +function buildAgent(env: Record = {}) { + const registered: Array<{ pid: number; kind: string; role: string }> = []; + const deps: AgentDeps = { + auth: { + getState: async () => ({ authenticated: true, identity: 't', authSource: 'api-key' as const }), + triggerLogin: async () => ({ authenticated: true }), + logout: async () => {}, + getAuthEnv: async () => env, + }, + runtimeConfig: {}, + binaryPath: fakeGrok, + logger: noopLogger, + registerLocalAgentProcess: (info) => { + registered.push(info); + }, + }; + return { agent: new GrokBuildAgent(deps), registered }; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** AgentEvent 的 data 是按 type 分叉的联合;测试只关心 error 上的 message。 */ +function eventMessage(event: AgentEvent): string { + const data: unknown = (event as { data?: unknown }).data; + if (!data || typeof data !== 'object') return ''; + const message = (data as { message?: unknown }).message; + return typeof message === 'string' ? message : ''; +} + +async function collectUntil( + events: AsyncIterable, + predicate: (seen: AgentEvent[]) => boolean, + timeoutMs = 3_000, +): Promise { + const seen: AgentEvent[] = []; + const iter = events[Symbol.asyncIterator](); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const remaining = deadline - Date.now(); + const next = await Promise.race([ + iter.next(), + delay(remaining).then(() => ({ done: true as const, value: undefined })), + ]); + if (next.done || next.value == null) break; + seen.push(next.value); + if (predicate(seen)) return seen; + } + return seen; +} + +describe.skipIf(process.platform === 'win32')('GrokBuildAgent startSession / send lifetime', () => { + it('registers the child via onProcessSpawned once and closes ACP if initialize fails', async () => { + const { agent, registered } = buildAgent({ FAKE_GROK_FAIL_INIT: '1' }); + await expect(agent.startSession({ workingDir: process.cwd(), model: 'grok-build' })) + .rejects.toThrow(/initialize failed/); + expect(registered).toHaveLength(1); + expect(registered[0]).toMatchObject({ kind: 'grok-build', role: 'task-host' }); + expect(registered[0]?.pid).toBeGreaterThan(0); + await delay(50); + expect(() => process.kill(registered[0]!.pid, 0)).toThrow(); + }); + + it('returns send() once the first session/update arrives, keeping prompt in-flight for abort', async () => { + const { agent, registered } = buildAgent({ FAKE_GROK_PROMPT_DELAY_MS: '2000' }); + const handle = await agent.startSession({ workingDir: process.cwd(), model: 'grok-build' }); + expect(registered).toHaveLength(1); + + const sending = handle.send({ type: 'user', content: 'hello' }); + const seen = await collectUntil(handle.events(), (events) => events.some((e) => e.type === 'text')); + expect(seen.some((e) => e.type === 'text')).toBe(true); + await expect(sending).resolves.toBeUndefined(); + + await handle.abort(); + await handle.close(); + }); + + it('throws from send() when session/prompt errors before any update', async () => { + const { agent } = buildAgent({ FAKE_GROK_FAIL_PROMPT: '1' }); + const handle = await agent.startSession({ workingDir: process.cwd(), model: 'grok-build' }); + await expect(handle.send({ type: 'user', content: 'hello' })).rejects.toThrow(/prompt rejected/); + await handle.close(); + }); + + it('does not throw from send() when session/prompt errors after a turn update', async () => { + const { agent } = buildAgent({ + FAKE_GROK_FAIL_PROMPT: '1', + FAKE_GROK_UPDATE_BEFORE_ERROR: '1', + }); + const handle = await agent.startSession({ workingDir: process.cwd(), model: 'grok-build' }); + const sending = handle.send({ type: 'user', content: 'hello' }); + await expect(sending).resolves.toBeUndefined(); + const seen = await collectUntil( + handle.events(), + (events) => events.some((e) => e.type === 'error' && eventMessage(e).includes('late failure')), + ); + expect(seen.some((e) => e.type === 'error' && eventMessage(e).includes('late failure'))).toBe(true); + await handle.close(); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-stdio-transport.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-stdio-transport.test.ts new file mode 100644 index 0000000000..eef65f6f09 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-stdio-transport.test.ts @@ -0,0 +1,83 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; + +import { createGrokStdioTransport, type GrokSpawnFn } from '../stdio-transport.js'; + +type FakeChild = EventEmitter & { + pid: number; + killed: boolean; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + stdout: PassThrough; + stderr: PassThrough; + stdin: PassThrough; + kill: (signal?: NodeJS.Signals) => boolean; + signals: NodeJS.Signals[]; +}; + +function makeChild(opts?: { ignoreTerm?: boolean }): FakeChild { + const child = new EventEmitter() as FakeChild; + child.pid = 4242; + child.killed = false; + child.exitCode = null; + child.signalCode = null; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new PassThrough(); + child.signals = []; + child.kill = (signal: NodeJS.Signals = 'SIGTERM') => { + child.signals.push(signal); + // Node sets killed=true after SIGTERM even when the process is still alive. + child.killed = true; + if (signal === 'SIGTERM' && opts?.ignoreTerm) { + return true; + } + child.exitCode = signal === 'SIGKILL' ? null : 0; + child.signalCode = signal === 'SIGKILL' ? 'SIGKILL' : null; + child.emit('exit', child.exitCode, child.signalCode); + return true; + }; + return child; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('createGrokStdioTransport close()', () => { + it('sends SIGKILL if the child ignores SIGTERM (does not use child.killed)', async () => { + const child = makeChild({ ignoreTerm: true }); + const transport = createGrokStdioTransport({ + binaryPath: '/grok', + args: ['agent', 'stdio'], + spawnImpl: (() => child) as unknown as GrokSpawnFn, + }); + + const closing = transport.close('test'); + expect(child.signals).toEqual(['SIGTERM']); + expect(child.killed).toBe(true); + expect(child.exitCode).toBeNull(); + + await delay(1_500); + expect(child.signals).toEqual(['SIGTERM']); + + await delay(800); + expect(child.signals).toEqual(['SIGTERM', 'SIGKILL']); + await closing; + }); + + it('does not send SIGKILL when the child exits after SIGTERM', async () => { + const child = makeChild({ ignoreTerm: false }); + const transport = createGrokStdioTransport({ + binaryPath: '/grok', + args: ['agent', 'stdio'], + spawnImpl: (() => child) as unknown as GrokSpawnFn, + }); + + await transport.close('test'); + expect(child.signals).toEqual(['SIGTERM']); + await delay(2_100); + expect(child.signals).toEqual(['SIGTERM']); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-translator.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-translator.test.ts new file mode 100644 index 0000000000..abeef53f10 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-translator.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + translatePromptResult, + translateSessionUpdate, +} from '../translator.js'; +import type { AcpSessionUpdate } from '../types.js'; + +describe('grok-build ACP translator', () => { + it('maps agent_message_chunk to streaming text', () => { + const update: AcpSessionUpdate = { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + }; + expect(translateSessionUpdate(update, {})).toEqual([ + { type: 'text', data: { text: 'hello', isFinal: false }, source: 'grok-build' }, + ]); + }); + + it('maps agent_thought_chunk to thinking', () => { + const update: AcpSessionUpdate = { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'hmm' }, + }; + expect(translateSessionUpdate(update, { thoughtBlockId: 't1' })).toEqual([ + { + type: 'thinking', + data: { stage: 'delta', blockId: 't1', text: 'hmm' }, + source: 'grok-build', + }, + ]); + }); + + it('maps tool_call then completed tool_call_update', () => { + const start: AcpSessionUpdate = { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + title: 'bash', + kind: 'execute', + rawInput: { command: 'ls' }, + }; + expect(translateSessionUpdate(start, {})).toEqual([ + { + type: 'tool_use', + data: { toolUseId: 'tc-1', toolName: 'bash', input: { command: 'ls' } }, + source: 'grok-build', + }, + ]); + const done: AcpSessionUpdate = { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + title: 'bash', + status: 'completed', + rawOutput: 'ok', + }; + const events = translateSessionUpdate(done, {}); + expect(events.map((e) => e.type)).toEqual(['tool_result_full', 'tool_result']); + expect(events[0]?.data).toMatchObject({ toolUseId: 'tc-1', content: 'ok', isError: false }); + }); + + it('maps usage_update to status', () => { + const update: AcpSessionUpdate = { + sessionUpdate: 'usage_update', + used: 12, + size: 128000, + inputTokens: 8, + outputTokens: 4, + }; + const events = translateSessionUpdate(update, {}); + expect(events[0]?.type).toBe('status'); + expect(events[0]?.data).toMatchObject({ + status: 'running', + tokenUsage: 12, + contextWindow: 128000, + outputTokens: 4, + }); + }); + + it('maps session/prompt result to done', () => { + expect(translatePromptResult({ stopReason: 'end_turn' })).toEqual({ + type: 'done', + data: { stopReason: 'end_turn' }, + source: 'grok-build', + }); + expect(translatePromptResult({ stopReason: 'cancelled' }).data).toEqual({ + stopReason: 'cancelled', + }); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/acp-client.ts b/packages/maker-core/src/agents/grok-build/acp-client.ts new file mode 100644 index 0000000000..cf8438159e --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/acp-client.ts @@ -0,0 +1,279 @@ +/** + * ACP JSON-RPC 2.0 NDJSON client for Grok Build. + * + * Modeled on Codex app-server/client.ts but **includes** `jsonrpc: "2.0"`. + * Incoming: + * - id + method → agent→client request (`session/request_permission`) + * - method only → notification (`session/update`) + * - id + result/error → response to our request + */ + +import type { Logger } from '../../interfaces/logger.js'; +import type { AcpTransport } from './stdio-transport.js'; +import { + ACP_JSONRPC_VERSION, + ACP_PROTOCOL_VERSION, + parseIncomingMessage, + type AcpInitializeParams, + type AcpInitializeResult, + type AcpJsonRpcId, + type AcpPermissionRequest, + type AcpPermissionResponse, + type AcpSessionNewParams, + type AcpSessionNewResult, + type AcpSessionPromptParams, + type AcpSessionPromptResult, + type AcpSessionUpdateNotification, +} from './types.js'; + +const DEFAULT_MAX_LINE_BYTES = 16 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; +const INITIALIZE_TIMEOUT_MS = 15_000; + +export class AcpRequestTimeoutError extends Error { + constructor( + public readonly method: string, + public readonly timeoutMs: number, + ) { + super(`grok ACP ${method} timed out after ${timeoutMs}ms`); + this.name = 'AcpRequestTimeoutError'; + } +} + +export class AcpRpcError extends Error { + constructor( + public readonly method: string, + public readonly code: number, + message: string, + ) { + super(`grok ACP ${method} failed (${code}): ${message}`); + this.name = 'AcpRpcError'; + } +} + +type Pending = { + method: string; + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timeoutId: ReturnType | null; +}; + +export type AcpRequestHandler = ( + method: string, + params: unknown, + id: AcpJsonRpcId, +) => Promise; + +export type AcpNotificationHandler = (method: string, params: unknown) => void; + +export interface AcpClientOptions { + transport: AcpTransport; + logger?: Logger; + maxLineBytes?: number; + defaultTimeoutMs?: number; +} + +export class AcpClient { + private nextId = 1; + private readonly pending = new Map(); + private requestHandler: AcpRequestHandler | undefined; + private notificationHandler: AcpNotificationHandler | undefined; + private started = false; + private closed = false; + private readonly maxLineBytes: number; + private readonly defaultTimeoutMs: number; + private readonly logger?: Logger; + private readonly transport: AcpTransport; + + constructor(opts: AcpClientOptions) { + this.transport = opts.transport; + this.logger = opts.logger; + this.maxLineBytes = opts.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES; + this.defaultTimeoutMs = opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + start(): void { + if (this.started) throw new Error('AcpClient: already started'); + if (this.closed) throw new Error('AcpClient: cannot start after close()'); + this.started = true; + this.transport.onLine((line) => this.handleLine(line)); + this.transport.onStderr((line) => { + this.logger?.debug('grok ACP stderr', { line: line.slice(0, 2_000) }); + }); + this.transport.onClose((info) => { + void this.failAll(new Error(`grok ACP transport closed: ${info.reason}`)); + }); + } + + setRequestHandler(handler: AcpRequestHandler): void { + this.requestHandler = handler; + } + + onNotification(handler: AcpNotificationHandler): void { + this.notificationHandler = handler; + } + + async initialize( + params: AcpInitializeParams = { + protocolVersion: ACP_PROTOCOL_VERSION, + clientInfo: { name: 'cindy', version: '0.0.0' }, + }, + timeoutMs = INITIALIZE_TIMEOUT_MS, + ): Promise { + return this.request('initialize', params, timeoutMs) as Promise; + } + + async sessionNew(params: AcpSessionNewParams, timeoutMs?: number): Promise { + return this.request('session/new', params, timeoutMs) as Promise; + } + + async sessionPrompt(params: AcpSessionPromptParams, timeoutMs?: number): Promise { + return this.request('session/prompt', params, timeoutMs ?? 10 * 60_000) as Promise; + } + + async sessionCancel(sessionId: string): Promise { + await this.notify('session/cancel', { sessionId }); + } + + async request(method: string, params?: unknown, timeoutMs?: number): Promise { + if (this.closed) throw new Error(`AcpClient closed; cannot ${method}`); + const id = this.nextId++; + const wait = timeoutMs ?? this.defaultTimeoutMs; + const payload = { + jsonrpc: ACP_JSONRPC_VERSION, + id, + method, + ...(params === undefined ? {} : { params }), + }; + const result = new Promise((resolve, reject) => { + const timeoutId = wait > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new AcpRequestTimeoutError(method, wait)); + }, wait) + : null; + this.pending.set(id, { method, resolve, reject, timeoutId }); + }); + try { + await this.transport.writeLine(JSON.stringify(payload)); + } catch (err) { + this.takePending(id); + throw err; + } + return result; + } + + async notify(method: string, params?: unknown): Promise { + if (this.closed) return; + const payload = { + jsonrpc: ACP_JSONRPC_VERSION, + method, + ...(params === undefined ? {} : { params }), + }; + await this.transport.writeLine(JSON.stringify(payload)); + } + + async respond(id: AcpJsonRpcId, result: unknown): Promise { + if (this.closed) return; + await this.transport.writeLine(JSON.stringify({ + jsonrpc: ACP_JSONRPC_VERSION, + id, + result, + })); + } + + async respondError(id: AcpJsonRpcId, code: number, message: string): Promise { + if (this.closed) return; + await this.transport.writeLine(JSON.stringify({ + jsonrpc: ACP_JSONRPC_VERSION, + id, + error: { code, message }, + })); + } + + async close(reason = 'client close'): Promise { + if (this.closed) return; + this.closed = true; + this.failAll(new Error(`grok ACP closed: ${reason}`)); + await this.transport.close(reason); + } + + private failAll(err: Error): void { + for (const [, pending] of this.pending) { + if (pending.timeoutId) clearTimeout(pending.timeoutId); + pending.reject(err); + } + this.pending.clear(); + } + + private handleLine(line: string): void { + if (this.closed) return; + if (line.length > this.maxLineBytes) { + this.logger?.error('grok ACP line exceeded max size; closing', { bytes: line.length }); + void this.close('max line size exceeded'); + return; + } + const trimmed = line.trim(); + if (!trimmed) return; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (err) { + this.logger?.warn('grok ACP ignored non-JSON line', { + line: trimmed.slice(0, 200), + error: err instanceof Error ? err.message : String(err), + }); + return; + } + const message = parseIncomingMessage(parsed); + if (!message) { + this.logger?.warn('grok ACP ignored malformed message', { line: trimmed.slice(0, 200) }); + return; + } + if ('method' in message && 'id' in message) { + void this.dispatchRequest(message.method, message.params, message.id); + return; + } + if ('method' in message) { + this.notificationHandler?.(message.method, message.params); + return; + } + if ('error' in message) { + const pending = this.takePending(message.id); + if (!pending) return; + pending.reject(new AcpRpcError(pending.method, message.error.code, message.error.message)); + return; + } + const pending = this.takePending(message.id); + pending?.resolve(message.result); + } + + private takePending(id: AcpJsonRpcId): Pending | undefined { + const pending = this.pending.get(id); + if (!pending) return undefined; + this.pending.delete(id); + if (pending.timeoutId) clearTimeout(pending.timeoutId); + return pending; + } + + private async dispatchRequest(method: string, params: unknown, id: AcpJsonRpcId): Promise { + const handler = this.requestHandler; + if (!handler) { + await this.respondError(id, -32601, `method not found: ${method}`); + return; + } + try { + const result = await handler(method, params, id); + await this.respond(id, result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.respondError(id, -32000, message); + } + } +} + +export type { + AcpPermissionRequest, + AcpPermissionResponse, + AcpSessionUpdateNotification, +}; diff --git a/packages/maker-core/src/agents/grok-build/auto-review-policy.ts b/packages/maker-core/src/agents/grok-build/auto-review-policy.ts new file mode 100644 index 0000000000..8a78905138 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/auto-review-policy.ts @@ -0,0 +1,95 @@ +/** + * Grok Build Auto-review adapter — ACP tool_call.kind → ReviewableAction. + * + * Mapping (ACP kind → Cindy review kind): + * execute → exec + * edit / delete / move → file-write + * read / search → read + * fetch → network + * think → session-state + * other / unknown → other + */ + +import type { ReviewableAction } from '../shared/auto-review.js'; +import type { AcpToolCall, AcpToolKind } from './types.js'; +import { isRecord } from './types.js'; + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function firstPath(toolCall: AcpToolCall, input: Record): string | undefined { + const loc = toolCall.locations?.[0]?.path; + if (typeof loc === 'string' && loc.length > 0) return loc; + return ( + readString(input.path) + ?? readString(input.file) + ?? readString(input.file_path) + ?? readString(input.filename) + ?? readString(input.target) + ?? readString(input.dest) + ?? readString(input.destination) + ); +} + +function firstCommand(input: Record): string { + return ( + readString(input.command) + ?? readString(input.cmd) + ?? readString(input.shell) + ?? JSON.stringify(input) + ); +} + +export function grokBuildToolToReviewableAction(toolCall: AcpToolCall): ReviewableAction { + const input = isRecord(toolCall.rawInput) ? toolCall.rawInput : {}; + const kind: AcpToolKind | undefined = toolCall.kind; + switch (kind) { + case 'execute': + return { + kind: 'exec', + command: firstCommand(input), + cwd: readString(input.cwd), + cwdUnknown: 'cwd' in input && !readString(input.cwd), + }; + case 'edit': + case 'delete': + case 'move': + return { kind: 'file-write', path: firstPath(toolCall, input) }; + case 'read': + case 'search': + return { + kind: 'read', + path: firstPath(toolCall, input), + scope: kind === 'search' ? 'tree' : 'file', + }; + case 'fetch': + return { + kind: 'network', + target: readString(input.url) ?? readString(input.uri) ?? firstPath(toolCall, input), + operation: readString(input.method) ?? toolCall.title, + }; + case 'think': + return { kind: 'session-state' }; + default: + return { + kind: 'other', + description: toolCall.title ?? kind ?? 'tool', + }; + } +} + +export function pickPermissionOptionId( + options: ReadonlyArray<{ optionId: string; kind: string }>, + behavior: 'allow' | 'deny', + always = false, +): string | null { + const preferred = behavior === 'allow' + ? (always ? ['allow_always', 'allow_once'] : ['allow_once', 'allow_always']) + : (always ? ['reject_always', 'reject_once'] : ['reject_once', 'reject_always']); + for (const kind of preferred) { + const match = options.find((option) => option.kind === kind); + if (match) return match.optionId; + } + return behavior === 'allow' ? options[0]?.optionId ?? null : null; +} diff --git a/packages/maker-core/src/agents/grok-build/detect.ts b/packages/maker-core/src/agents/grok-build/detect.ts new file mode 100644 index 0000000000..b3ddef92c3 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/detect.ts @@ -0,0 +1,136 @@ +/** + * Grok Build binary + ACP probe. + * + * PATH walk only — never reads ~/.grok/auth.json. Auth is inferred from ACP + * `initialize.authMethods` (empty = logged in) or from `XAI_API_KEY` in env. + * + * `buildGrokBuildAgent` must stay PATH-only so a missing/slow grok cannot delay + * Cindy startup. ACP initialize lives in AuthAdapter.getState. + */ + +import path from 'node:path'; +import { existsSync } from 'node:fs'; + +import type { Logger } from '../../interfaces/logger.js'; +import { ACP_PROTOCOL_VERSION } from './types.js'; +import { AcpClient, AcpRequestTimeoutError } from './acp-client.js'; +import { createGrokStdioTransport, type GrokSpawnFn } from './stdio-transport.js'; + +export type GrokBuildDetectStatus = + | 'uninstalled' + | 'logged-out' + | 'unsupported-version' + | 'acp-fail' + | 'ready'; + +export interface GrokBuildProbeResult { + status: GrokBuildDetectStatus; + binaryPath: string | null; + identity?: string; + agentVersion?: string; + errorReason?: string; +} + +export interface ResolveGrokBinaryOptions { + pathEnv?: string; + platform?: NodeJS.Platform; + existsSyncImpl?: (candidate: string) => boolean; + pathSep?: string; +} + +export function resolveGrokBinaryFromPath(options: ResolveGrokBinaryOptions = {}): string | null { + const pathEnv = options.pathEnv ?? process.env.PATH ?? ''; + const platform = options.platform ?? process.platform; + const exists = options.existsSyncImpl ?? existsSync; + // 分隔符与拼接必须同属一个 platform:注入 platform 做跨平台用例时,若这里还用宿主的 + // path.join,在 Windows 上跑 posix 用例会拼出 `\opt\xai\bin\grok`,反之亦然。 + const pathApi = platform === 'win32' ? path.win32 : path.posix; + const delim = options.pathSep ?? pathApi.delimiter; + const exts = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; + for (const dir of pathEnv.split(delim)) { + if (!dir) continue; + for (const ext of exts) { + const candidate = pathApi.join(dir, `grok${ext}`); + if (exists(candidate)) return candidate; + } + } + return null; +} + +export interface ProbeGrokBuildOptions { + binaryPath: string; + timeoutMs?: number; + env?: NodeJS.ProcessEnv; + spawnImpl?: GrokSpawnFn; + logger?: Logger; +} + +const DEFAULT_PROBE_TIMEOUT_MS = 5_000; + +export async function probeGrokBuildAcp(options: ProbeGrokBuildOptions): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const logger = options.logger; + let transport: ReturnType | undefined; + let client: AcpClient | undefined; + try { + transport = createGrokStdioTransport({ + binaryPath: options.binaryPath, + args: ['agent', 'stdio'], + env: options.env, + spawnImpl: options.spawnImpl, + }); + client = new AcpClient({ + transport, + logger, + defaultTimeoutMs: timeoutMs, + }); + client.start(); + const init = await client.initialize({ + protocolVersion: ACP_PROTOCOL_VERSION, + clientInfo: { name: 'cindy', version: '0.0.0' }, + }, timeoutMs); + if (typeof init.protocolVersion === 'number' && init.protocolVersion > ACP_PROTOCOL_VERSION) { + return { + status: 'unsupported-version', + binaryPath: options.binaryPath, + agentVersion: init.agentInfo?.version, + errorReason: `unsupported ACP protocolVersion ${init.protocolVersion}`, + }; + } + const methods = init.authMethods ?? []; + if (methods.length > 0) { + return { + status: 'logged-out', + binaryPath: options.binaryPath, + agentVersion: init.agentInfo?.version, + errorReason: 'logged-out', + }; + } + return { + status: 'ready', + binaryPath: options.binaryPath, + identity: init.agentInfo?.name ?? 'grok', + agentVersion: init.agentInfo?.version, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status: GrokBuildDetectStatus = + err instanceof AcpRequestTimeoutError ? 'acp-fail' : 'acp-fail'; + logger?.warn('grok-build ACP probe failed', { message }); + return { + status, + binaryPath: options.binaryPath, + errorReason: message, + }; + } finally { + await client?.close('probe complete').catch(() => undefined); + } +} + +export function detectGrokBuildOnPath(options: ResolveGrokBinaryOptions = {}): GrokBuildProbeResult { + const binaryPath = resolveGrokBinaryFromPath(options); + if (!binaryPath) { + return { status: 'uninstalled', binaryPath: null, errorReason: 'uninstalled' }; + } + return { status: 'ready', binaryPath }; +} diff --git a/packages/maker-core/src/agents/grok-build/index.ts b/packages/maker-core/src/agents/grok-build/index.ts new file mode 100644 index 0000000000..11d15b6a98 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/index.ts @@ -0,0 +1,424 @@ +/** + * GrokBuildAgent — xAI Grok Build (`grok` CLI) as a Cindy harness. + * + * Protocol: ACP over `grok agent [ --always-approve ] stdio` (JSON-RPC 2.0 NDJSON). + * Optional like Pi: host `buildGrokBuildAgent()` returns null when `grok` is not + * on PATH. Missing grok must not affect Claude Code / Codex / Pi. + * + * Permission modes (strict → wide, [0] is strictest): + * ask — ACP session/request_permission → InteractionResolver + * auto — ACP permission requests → shared Auto-review core (NOT grok autoMode) + * bypassPermissions — `grok agent --always-approve stdio` and `_meta.yoloMode` + */ + +import { randomUUID } from 'node:crypto'; + +import type { AgentKind, PermissionMode, UserMessage } from '../../types/common.js'; +import type { Capabilities } from '../../types/capabilities.js'; +import type { + AgentEvent, + InteractionResolver, + UsageSnapshot, +} from '../../types/events.js'; +import { + annotatePermissionRequestForUnavailableReview, + createAutoReviewUnavailableNotice, + resolveAutoReviewDecision, +} from '../shared/auto-review-decision.js'; +import { createAsyncQueue } from '../shared/async-queue.js'; +import { pickTurnStartStatus } from '../shared/turn-start-phrases.js'; +import { + AgentNotAuthenticatedError, + BaseAgent, + type AgentDeps, + type AgentSessionHandle, + type SendOptions, + type StartSessionOptions, +} from '../base-agent.js'; +import { ACP_PROTOCOL_VERSION, isRecord, type AcpContentBlock, type AcpPermissionOption, type AcpToolCall } from './types.js'; +import { AcpClient } from './acp-client.js'; +import { createGrokStdioTransport } from './stdio-transport.js'; +import { + GROK_BUILD_SOURCE, + translateError, + translatePromptResult, + translateSessionUpdate, + usageFromUpdate, +} from './translator.js'; +import { grokBuildToolToReviewableAction, pickPermissionOptionId } from './auto-review-policy.js'; + +const NOT_IMPLEMENTED = { supported: false, reason: 'not-implemented' as const }; + +function emptyUsage(): UsageSnapshot { + return { tokenUsage: 0, contextTokens: 0, contextWindow: 0, costUsd: 0 }; +} + +function userMessageToPrompt(message: UserMessage): AcpContentBlock[] { + if (typeof message.content === 'string') { + return [{ type: 'text', text: message.content }]; + } + const blocks: AcpContentBlock[] = []; + for (const block of message.content) { + if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text }); + } else if (block.type === 'image') { + blocks.push({ type: 'text', text: `[image: ${block.path}]` }); + } else if (block.type === 'file') { + blocks.push({ type: 'text', text: `[file: ${block.path}]` }); + } else if (block.type === 'mention') { + blocks.push({ type: 'text', text: `@${block.name} (${block.path})` }); + } + } + return blocks.length > 0 ? blocks : [{ type: 'text', text: '' }]; +} + +function lastUserText(message: UserMessage): string { + if (typeof message.content === 'string') return message.content; + for (let i = message.content.length - 1; i >= 0; i -= 1) { + const block = message.content[i]; + if (block.type === 'text' && block.text.trim()) return block.text; + } + return ''; +} + +export class GrokBuildAgent extends BaseAgent { + readonly kind: AgentKind = 'grok-build'; + readonly capabilities: Capabilities; + + constructor(deps: AgentDeps) { + super(deps); + this.capabilities = this.buildCapabilities(GrokBuildAgent.baseCapabilities()); + } + + private static baseCapabilities(): Capabilities { + return { + switchModel: { supported: true }, + availableModels: [ + { id: 'grok-build', displayName: 'Grok Build', contextWindow: 0, efforts: [], defaultEffort: null }, + ], + hasFastMode: false, + effort: { supported: false, reason: 'not-implemented' }, + effortLevels: [], + reasoningDisplay: ['off', 'full'], + permissionModes: [ + { + id: 'ask', + displayName: 'Default permissions', + description: 'Grok Build tools that write files, run commands, or leave the workspace ask each time via ACP prompts.', + }, + { + id: 'auto', + displayName: 'Auto-review', + description: 'In-workspace writes and safe commands run automatically; out-of-workspace writes and risky commands still ask. Cindy Auto-review intercepts ACP permission requests.', + }, + { + id: 'bypassPermissions', + displayName: 'Full access', + description: 'Grok Build runs with always-approve (ACP yoloMode). Highest risk; use only for trusted tasks.', + }, + ], + setPermissionModeMidSession: { supported: false, reason: 'not-implemented', message: 'Grok Build permission mode is set when the session starts.' }, + turnPermissionPolicy: { + supported: { supported: true }, + unsupportedPermissionModes: ['bypassPermissions'], + }, + planMode: NOT_IMPLEMENTED, + multimodal: { + text: { supported: true }, + image: { supported: false, reason: 'not-implemented' }, + file: { supported: false, reason: 'not-implemented' }, + }, + fork: NOT_IMPLEMENTED, + rewind: NOT_IMPLEMENTED, + sessionTree: NOT_IMPLEMENTED, + abort: { supported: true }, + sameTurnSteer: NOT_IMPLEMENTED, + memory: { + supported: NOT_IMPLEMENTED, + }, + extraDirs: NOT_IMPLEMENTED, + sessionHtmlExport: NOT_IMPLEMENTED, + manualCompact: NOT_IMPLEMENTED, + }; + } + + async startSession(opts: StartSessionOptions): Promise { + const auth = await this.deps.auth.getState(); + if (!auth.authenticated) { + throw new AgentNotAuthenticatedError(this.kind, auth.errorReason); + } + + const permissionMode: PermissionMode = opts.permissionMode ?? 'ask'; + const model = opts.model || 'grok-build'; + const bypass = permissionMode === 'bypassPermissions'; + const args = ['agent']; + if (bypass) args.push('--always-approve'); + if (model && model !== 'grok-build') { + args.push('-m', model); + } + args.push('stdio'); + + const authEnv = await this.deps.auth.getAuthEnv(); + const env: NodeJS.ProcessEnv = { ...process.env, ...authEnv }; + const events = createAsyncQueue(); + let usage = emptyUsage(); + let resolver: InteractionResolver | undefined; + let acpSessionId = ''; + let closed = false; + let promptInFlight = false; + const lastUserIntent = { text: '' }; + const thought = { thoughtBlockId: randomUUID() }; + let onTurnAccepted: (() => void) | undefined; + const autoReviewNotice = createAutoReviewUnavailableNotice((message) => { + events.push({ type: 'error', data: { message, isTerminal: false }, source: GROK_BUILD_SOURCE }); + }); + + const transport = createGrokStdioTransport({ + binaryPath: this.deps.binaryPath, + args, + cwd: opts.workingDir, + env, + onProcessSpawned: (pid) => this.deps.registerLocalAgentProcess?.({ + pid, + kind: 'grok-build', + role: 'task-host', + }), + }); + const client = new AcpClient({ + transport, + logger: this.deps.logger.child('grok-build'), + }); + + client.onNotification((method, params) => { + if (method !== 'session/update' || !isRecord(params) || !isRecord(params.update)) return; + onTurnAccepted?.(); + const sessionUpdate = params.update as { sessionUpdate: string; [key: string]: unknown }; + if (sessionUpdate.sessionUpdate === 'usage_update') { + usage = { + ...usage, + ...usageFromUpdate(sessionUpdate), + }; + } + for (const event of translateSessionUpdate(sessionUpdate as never, thought)) { + events.push(event); + } + }); + + client.setRequestHandler(async (method, params) => { + if (method !== 'session/request_permission') { + throw new Error(`unsupported ACP client method: ${method}`); + } + if (!isRecord(params) || !isRecord(params.toolCall)) { + return { outcome: { outcome: 'cancelled' } }; + } + const toolCall = params.toolCall as unknown as AcpToolCall; + const options = Array.isArray(params.options) ? params.options as AcpPermissionOption[] : []; + return this.resolvePermission({ + permissionMode, + toolCall, + options, + resolver, + workingDir: opts.workingDir, + extraDirs: opts.extraDirs ?? [], + model, + providerId: opts.providerId, + sessionId: opts.sessionId, + lastUserIntent: lastUserIntent.text, + autoReviewNotice, + }); + }); + + client.start(); + try { + const init = await client.initialize({ + protocolVersion: ACP_PROTOCOL_VERSION, + clientInfo: { name: 'cindy', version: '0.0.0' }, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }); + if (init.agentCapabilities?.promptCapabilities?.image) { + // capabilities is already published; image support is negotiated per session. + this.deps.logger.debug('grok-build ACP reports image prompt capability'); + } + + const hostPrompt = this.deps.runtimeConfig.systemPrompt?.trim() ?? ''; + const userPrompt = opts.userPrompt?.trim() ?? ''; + const systemPromptOverride = [hostPrompt, userPrompt].filter(Boolean).join('\n\n') || undefined; + const created = await client.sessionNew({ + cwd: opts.workingDir, + mcpServers: [], + _meta: { + ...(bypass ? { yoloMode: true } : {}), + ...(systemPromptOverride ? { systemPromptOverride } : {}), + }, + }); + acpSessionId = created.sessionId; + } catch (err) { + closed = true; + await client.close('startSession failed'); + events.end(); + throw err; + } + events.push({ + type: 'session_id', + data: { sessionId: acpSessionId }, + source: GROK_BUILD_SOURCE, + }); + + const handle: AgentSessionHandle = { + id: acpSessionId, + agentKind: this.kind, + model, + async send(message: UserMessage, sendOpts?: SendOptions) { + if (closed) throw new Error('grok-build session is closed'); + lastUserIntent.text = lastUserText(message); + const greeting = pickTurnStartStatus(undefined); + events.push({ + type: 'status', + data: { status: 'running', text: greeting.text, ...usage }, + source: GROK_BUILD_SOURCE, + }); + promptInFlight = true; + let sendReturned = false; + // markAccepted / prompt catch 分处两个闭包,用对象持有状态,避免 TS 把读取处 + // 收窄成写入前的字面量类型。 + const accept: { state: 'pending' | 'accepted' | 'rejected' } = { state: 'pending' }; + let acceptResolve = () => {}; + const accepted = new Promise((resolve) => { + acceptResolve = resolve; + }); + const markAccepted = () => { + if (accept.state !== 'pending') return; + accept.state = 'accepted'; + acceptResolve(); + }; + const publishAfterSend = (event: AgentEvent) => { + const fire = () => { events.push(event); }; + if (sendReturned) fire(); + else setImmediate(fire); + }; + onTurnAccepted = markAccepted; + const promptPromise = (async () => { + try { + const result = await client.sessionPrompt({ + sessionId: acpSessionId, + prompt: userMessageToPrompt(message), + }); + markAccepted(); + publishAfterSend(translatePromptResult(result)); + } catch (err) { + if (accept.state === 'accepted' || sendReturned) { + const messageText = err instanceof Error ? err.message : String(err); + publishAfterSend(translateError(messageText, true)); + return; + } + accept.state = 'rejected'; + throw err; + } finally { + promptInFlight = false; + if (onTurnAccepted === markAccepted) onTurnAccepted = undefined; + } + })(); + try { + await Promise.race([accepted, promptPromise]); + sendReturned = true; + } finally { + void sendOpts; + } + }, + async steer() { + throw new Error('grok-build does not support same-turn steer'); + }, + async abort() { + if (!promptInFlight) return; + await client.sessionCancel(acpSessionId); + }, + async close() { + if (closed) return; + closed = true; + await client.close('session close'); + events.end(); + }, + events() { + return events; + }, + getUsageSnapshot() { + return usage; + }, + setInteractionResolver(next: InteractionResolver) { + resolver = next; + }, + }; + return handle; + } + + private async resolvePermission(args: { + permissionMode: PermissionMode; + toolCall: AcpToolCall; + options: AcpPermissionOption[]; + resolver: InteractionResolver | undefined; + workingDir: string; + extraDirs: string[]; + model: string; + providerId?: string | null; + sessionId?: string; + lastUserIntent: string; + autoReviewNotice: { notify(): void; reset(): void }; + }): Promise<{ outcome: { outcome: 'selected'; optionId: string } | { outcome: 'cancelled' } }> { + const { permissionMode, toolCall, options, resolver } = args; + const select = (behavior: 'allow' | 'deny', always = false) => { + const optionId = pickPermissionOptionId(options, behavior, always); + if (!optionId) return { outcome: { outcome: 'cancelled' as const } }; + return { outcome: { outcome: 'selected' as const, optionId } }; + }; + + if (permissionMode === 'bypassPermissions') { + return select('allow', true); + } + + const request = { + requestId: toolCall.toolCallId || randomUUID(), + toolUseId: toolCall.toolCallId, + kind: 'permission' as const, + toolName: toolCall.title || toolCall.kind || 'tool', + input: isRecord(toolCall.rawInput) ? toolCall.rawInput : {}, + title: toolCall.title, + }; + + if (permissionMode === 'auto') { + const decision = await resolveAutoReviewDecision( + { + sessionId: args.sessionId, + agentKind: this.kind, + providerId: args.providerId, + model: args.model, + userIntent: args.lastUserIntent, + action: grokBuildToolToReviewableAction(toolCall), + workspaceRoots: [args.workingDir, ...args.extraDirs], + platform: process.platform, + }, + this.deps.reviewAutoPermissionAction, + ); + if (decision.verdict === 'allow') return select('allow'); + if (decision.verdict === 'block') return select('deny'); + if (decision.unavailable) args.autoReviewNotice.notify(); + if (!resolver) return select('deny'); + const prompt = decision.unavailable + ? annotatePermissionRequestForUnavailableReview(request) + : request; + const user = await resolver(prompt); + if (user.kind !== 'permission') return { outcome: { outcome: 'cancelled' } }; + return select(user.behavior, Boolean(user.permissionUpdates?.length)); + } + + if (!resolver) return { outcome: { outcome: 'cancelled' } }; + const user = await resolver(request); + if (user.kind !== 'permission') return { outcome: { outcome: 'cancelled' } }; + return select(user.behavior, Boolean(user.permissionUpdates?.length)); + } +} + +export { resolveGrokBinaryFromPath, probeGrokBuildAcp, detectGrokBuildOnPath } from './detect.js'; +export type { GrokBuildDetectStatus, GrokBuildProbeResult } from './detect.js'; diff --git a/packages/maker-core/src/agents/grok-build/stdio-transport.ts b/packages/maker-core/src/agents/grok-build/stdio-transport.ts new file mode 100644 index 0000000000..668f687861 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/stdio-transport.ts @@ -0,0 +1,168 @@ +/** + * Stdio transport for Grok Build ACP (`grok agent [flags] stdio`). + * + * Byte-stream only: NDJSON framing is handled by AcpClient. Spawn is injectable + * so detection tests can fake a child without a real grok binary. + */ + +import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptions } from 'node:child_process'; +import { createInterface, type Interface } from 'node:readline'; + +export type AcpLineHandler = (line: string) => void; +export type AcpStderrHandler = (line: string) => void; +export type AcpCloseHandler = (info: { reason: string }) => void; + +export interface AcpTransport { + writeLine(line: string): Promise; + onLine(handler: AcpLineHandler): () => void; + onStderr(handler: AcpStderrHandler): () => void; + onClose(handler: AcpCloseHandler): () => void; + close(reason?: string): Promise; + readonly pid: number | undefined; +} + +export type GrokSpawnFn = ( + command: string, + args: readonly string[], + options: SpawnOptions, +) => ChildProcessWithoutNullStreams; + +export interface GrokStdioTransportOptions { + binaryPath: string; + args: readonly string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + spawnImpl?: GrokSpawnFn; + onProcessSpawned?: (pid: number) => void | (() => void); +} + +export function createGrokStdioTransport(opts: GrokStdioTransportOptions): AcpTransport { + if (!opts.binaryPath) { + throw new Error('createGrokStdioTransport: binaryPath is required'); + } + + const spawnImpl = opts.spawnImpl ?? (spawn as GrokSpawnFn); + const lineHandlers = new Set(); + const stderrHandlers = new Set(); + const closeHandlers = new Set(); + const pendingLines: string[] = []; + let closed = false; + let exited = false; + let closeReason = 'transport closed'; + let stdoutRl: Interface | undefined; + let stderrRl: Interface | undefined; + let disposeProcess: (() => void) | undefined; + + const child = spawnImpl(opts.binaryPath, [...opts.args], { + cwd: opts.cwd, + env: opts.env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + + if (typeof child.pid === 'number' && opts.onProcessSpawned) { + const disposer = opts.onProcessSpawned(child.pid); + if (typeof disposer === 'function') disposeProcess = disposer; + } + + stdoutRl = createInterface({ input: child.stdout }); + stderrRl = createInterface({ input: child.stderr }); + + stdoutRl.on('line', (line: string) => { + if (closed) return; + if (lineHandlers.size === 0) { + pendingLines.push(line); + return; + } + for (const handler of lineHandlers) handler(line); + }); + + stderrRl.on('line', (line: string) => { + if (closed) return; + for (const handler of stderrHandlers) handler(line); + }); + + const finish = (reason: string) => { + if (closed) return; + closed = true; + closeReason = reason; + disposeProcess?.(); + stdoutRl?.close(); + stderrRl?.close(); + for (const handler of closeHandlers) handler({ reason }); + }; + + child.on('error', (err) => { + finish(`grok spawn error: ${err.message}`); + }); + child.on('exit', (code, signal) => { + exited = true; + finish(signal ? `grok exited signal ${signal}` : `grok exited code ${code ?? 'unknown'}`); + }); + + return { + get pid() { + return child.pid; + }, + async writeLine(line: string): Promise { + if (closed || !child.stdin.writable) { + throw new Error(`grok ACP transport closed (${closeReason})`); + } + await new Promise((resolve, reject) => { + child.stdin.write(`${line}\n`, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }, + onLine(handler: AcpLineHandler): () => void { + lineHandlers.add(handler); + if (pendingLines.length > 0) { + const queued = pendingLines.splice(0); + for (const line of queued) handler(line); + } + return () => { + lineHandlers.delete(handler); + }; + }, + onStderr(handler: AcpStderrHandler): () => void { + stderrHandlers.add(handler); + return () => { + stderrHandlers.delete(handler); + }; + }, + onClose(handler: AcpCloseHandler): () => void { + closeHandlers.add(handler); + if (closed) handler({ reason: closeReason }); + return () => { + closeHandlers.delete(handler); + }; + }, + async close(reason = 'client close'): Promise { + if (closed) return; + finish(reason); + if (exited || child.exitCode != null) return; + await new Promise((resolve) => { + if (exited) { + resolve(); + return; + } + const timer = setTimeout(() => { + if (!exited) { + try { child.kill('SIGKILL'); } catch { /* already gone */ } + } + resolve(); + }, 2_000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + try { child.kill('SIGTERM'); } catch { /* already gone */ } + if (exited) { + clearTimeout(timer); + resolve(); + } + }); + }, + }; +} diff --git a/packages/maker-core/src/agents/grok-build/translator.ts b/packages/maker-core/src/agents/grok-build/translator.ts new file mode 100644 index 0000000000..7d1210daec --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/translator.ts @@ -0,0 +1,169 @@ +/** + * Grok Build ACP `session/update` → Cindy AgentEvent. + * + * agent_message_chunk → text { text, isFinal } + * agent_thought_chunk → thinking { stage, blockId, text, ... } + * tool_call → tool_use { toolUseId, toolName, input } + * tool_call_update completed/failed → tool_result_full + tool_result + * usage_update → status with UsageSnapshot + * session/prompt result → done + */ + +import type { AgentEvent, UsageSnapshot } from '../../types/events.js'; +import type { AcpContentBlock, AcpSessionPromptResult, AcpSessionUpdate, AcpToolCall } from './types.js'; +import { isRecord } from './types.js'; + +export const GROK_BUILD_SOURCE = 'grok-build' as const; + +function textOf(content: AcpContentBlock | undefined): string { + if (!content) return ''; + if (content.type === 'text' && typeof content.text === 'string') return content.text; + return ''; +} + +/** + * AcpSessionUpdate 末尾有 `{ sessionUpdate: string; [key: string]: unknown }` 兜底成员, + * 判别式是 string,所以 switch 收窄后 content 仍是 unknown。内容来自外部进程,这里按 + * 结构校验再收窄,而不是硬转。 + */ +function contentOf(content: unknown): AcpContentBlock | undefined { + if (!isRecord(content) || typeof content.type !== 'string') return undefined; + return content as AcpContentBlock; +} + +function toolNameOf(call: Partial): string { + return call.title || call.kind || 'tool'; +} + +function stringifyOutput(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function translateSessionUpdate( + update: AcpSessionUpdate, + ctx: { thoughtBlockId?: string }, +): AgentEvent[] { + const events: AgentEvent[] = []; + switch (update.sessionUpdate) { + case 'agent_message_chunk': { + const text = textOf(contentOf(update.content)); + if (!text) break; + events.push({ + type: 'text', + data: { text, isFinal: false }, + source: GROK_BUILD_SOURCE, + }); + break; + } + case 'agent_thought_chunk': { + const text = textOf(contentOf(update.content)); + if (!text) break; + events.push({ + type: 'thinking', + data: { + stage: 'delta', + blockId: ctx.thoughtBlockId ?? 'grok-thought', + text, + }, + source: GROK_BUILD_SOURCE, + }); + break; + } + case 'tool_call': { + const call = update as AcpToolCall & { sessionUpdate: 'tool_call' }; + events.push({ + type: 'tool_use', + data: { + toolUseId: call.toolCallId, + toolName: toolNameOf(call), + input: isRecord(call.rawInput) ? call.rawInput : {}, + }, + source: GROK_BUILD_SOURCE, + }); + break; + } + case 'tool_call_update': { + const call = update as Partial & { + sessionUpdate: 'tool_call_update'; + toolCallId: string; + }; + if (call.status !== 'completed' && call.status !== 'failed') break; + const content = stringifyOutput(call.rawOutput ?? call.content); + const isError = call.status === 'failed'; + events.push({ + type: 'tool_result_full', + data: { + toolUseId: call.toolCallId, + toolName: toolNameOf(call), + content, + isError, + }, + source: GROK_BUILD_SOURCE, + }); + events.push({ + type: 'tool_result', + data: { + toolUseId: call.toolCallId, + toolName: toolNameOf(call), + content, + isError, + }, + source: GROK_BUILD_SOURCE, + }); + break; + } + case 'usage_update': { + const snapshot = usageFromUpdate(update); + events.push({ + type: 'status', + data: { + status: 'running', + ...snapshot, + }, + source: GROK_BUILD_SOURCE, + }); + break; + } + default: + break; + } + return events; +} + +export function usageFromUpdate(update: Extract | Record): UsageSnapshot { + const rec = update as Record; + const input = typeof rec.inputTokens === 'number' ? rec.inputTokens : 0; + const output = typeof rec.outputTokens === 'number' ? rec.outputTokens : 0; + const used = typeof rec.used === 'number' ? rec.used : input + output; + const size = typeof rec.size === 'number' ? rec.size : 0; + const cost = isRecord(rec.cost) && typeof rec.cost.amount === 'number' ? rec.cost.amount : 0; + return { + tokenUsage: used, + contextTokens: used, + contextWindow: size, + costUsd: cost, + outputTokens: output || undefined, + }; +} + +export function translatePromptResult(result: AcpSessionPromptResult): AgentEvent { + return { + type: 'done', + data: { stopReason: result.stopReason }, + source: GROK_BUILD_SOURCE, + }; +} + +export function translateError(message: string, isTerminal = true): AgentEvent { + return { + type: 'error', + data: { message, isTerminal }, + source: GROK_BUILD_SOURCE, + }; +} diff --git a/packages/maker-core/src/agents/grok-build/types.ts b/packages/maker-core/src/agents/grok-build/types.ts new file mode 100644 index 0000000000..a0effa1264 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/types.ts @@ -0,0 +1,240 @@ +/** + * Agent Client Protocol (ACP) types used by Grok Build (`grok agent stdio`). + * + * JSON-RPC 2.0 **with** `jsonrpc: "2.0"` (unlike Codex app-server, which omits it). + * Spec: https://agentclientprotocol.com — grok-build session `_meta` is a vendor + * extension (`yoloMode` / `autoMode` / `rules` / `systemPromptOverride`). + */ + +export const ACP_PROTOCOL_VERSION = 1; +export const ACP_JSONRPC_VERSION = '2.0' as const; + +export type AcpJsonRpcId = number | string; + +export interface AcpJsonRpcRequest { + jsonrpc: typeof ACP_JSONRPC_VERSION; + id: AcpJsonRpcId; + method: string; + params?: unknown; +} + +export interface AcpJsonRpcNotification { + jsonrpc: typeof ACP_JSONRPC_VERSION; + method: string; + params?: unknown; +} + +export interface AcpJsonRpcSuccess { + jsonrpc: typeof ACP_JSONRPC_VERSION; + id: AcpJsonRpcId; + result: unknown; +} + +export interface AcpJsonRpcErrorObject { + code: number; + message: string; + data?: unknown; +} + +export interface AcpJsonRpcFailure { + jsonrpc: typeof ACP_JSONRPC_VERSION; + id: AcpJsonRpcId; + error: AcpJsonRpcErrorObject; +} + +export type AcpIncomingMessage = + | AcpJsonRpcRequest + | AcpJsonRpcNotification + | AcpJsonRpcSuccess + | AcpJsonRpcFailure; + +export interface AcpClientInfo { + name: string; + version: string; +} + +export interface AcpInitializeParams { + protocolVersion: number; + clientInfo?: AcpClientInfo; + clientCapabilities?: { + fs?: { readTextFile?: boolean; writeTextFile?: boolean }; + terminal?: boolean; + }; +} + +export interface AcpAuthMethod { + id: string; + name: string; + description?: string; +} + +export interface AcpInitializeResult { + protocolVersion: number; + agentInfo?: { name?: string; version?: string; title?: string }; + agentCapabilities?: { + loadSession?: boolean; + promptCapabilities?: { + image?: boolean; + audio?: boolean; + embeddedContext?: boolean; + }; + }; + authMethods?: AcpAuthMethod[]; +} + +export interface AcpSessionNewMeta { + yoloMode?: boolean; + autoMode?: boolean; + rules?: string; + systemPromptOverride?: string; + agentProfile?: string | Record; +} + +export interface AcpSessionNewParams { + cwd: string; + mcpServers: unknown[]; + _meta?: AcpSessionNewMeta; +} + +export interface AcpSessionNewResult { + sessionId: string; +} + +export type AcpContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'audio'; data: string; mimeType: string } + | { type: 'resource'; resource: unknown } + | { type: 'resource_link'; uri: string; name?: string }; + +export interface AcpSessionPromptParams { + sessionId: string; + prompt: AcpContentBlock[]; +} + +export type AcpStopReason = + | 'end_turn' + | 'max_tokens' + | 'max_turn_requests' + | 'refusal' + | 'cancelled'; + +export interface AcpSessionPromptResult { + stopReason: AcpStopReason; +} + +export type AcpToolKind = + | 'read' + | 'edit' + | 'delete' + | 'move' + | 'search' + | 'execute' + | 'think' + | 'fetch' + | 'other'; + +export type AcpToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +export interface AcpToolCall { + toolCallId: string; + title?: string; + kind?: AcpToolKind; + status?: AcpToolCallStatus; + locations?: Array<{ path: string }>; + content?: unknown[]; + rawInput?: Record; + rawOutput?: unknown; +} + +export type AcpSessionUpdate = + | { + sessionUpdate: 'agent_message_chunk'; + content: AcpContentBlock; + } + | { + sessionUpdate: 'agent_thought_chunk'; + content: AcpContentBlock; + } + | { + sessionUpdate: 'user_message_chunk'; + content: AcpContentBlock; + } + | ({ + sessionUpdate: 'tool_call'; + } & AcpToolCall) + | ({ + sessionUpdate: 'tool_call_update'; + } & Partial & { toolCallId: string }) + | { + sessionUpdate: 'plan'; + entries?: unknown[]; + } + | { + sessionUpdate: 'usage_update'; + used?: number; + size?: number; + cost?: { amount?: number; currency?: string }; + inputTokens?: number; + outputTokens?: number; + thoughtTokens?: number; + cachedTokens?: number; + } + | { + sessionUpdate: string; + [key: string]: unknown; + }; + +export interface AcpSessionUpdateNotification { + sessionId: string; + update: AcpSessionUpdate; +} + +export type AcpPermissionOptionKind = + | 'allow_once' + | 'allow_always' + | 'reject_once' + | 'reject_always'; + +export interface AcpPermissionOption { + optionId: string; + name: string; + kind: AcpPermissionOptionKind; +} + +export interface AcpPermissionRequest { + sessionId: string; + toolCall: AcpToolCall; + options: AcpPermissionOption[]; +} + +export type AcpPermissionOutcome = + | { outcome: 'selected'; optionId: string } + | { outcome: 'cancelled' }; + +export interface AcpPermissionResponse { + outcome: AcpPermissionOutcome; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseIncomingMessage(value: unknown): AcpIncomingMessage | null { + if (!isRecord(value) || value.jsonrpc !== ACP_JSONRPC_VERSION) return null; + const hasId = 'id' in value; + const hasMethod = typeof value.method === 'string'; + if (hasMethod && hasId) { + return value as unknown as AcpJsonRpcRequest; + } + if (hasMethod && !hasId) { + return value as unknown as AcpJsonRpcNotification; + } + if (hasId && 'result' in value) { + return value as unknown as AcpJsonRpcSuccess; + } + if (hasId && isRecord(value.error)) { + return value as unknown as AcpJsonRpcFailure; + } + return null; +} diff --git a/packages/maker-core/src/agents/index.ts b/packages/maker-core/src/agents/index.ts index 275472b675..be075070a8 100644 --- a/packages/maker-core/src/agents/index.ts +++ b/packages/maker-core/src/agents/index.ts @@ -18,6 +18,7 @@ export { // finalizeCodexCitationText = 剥截断残尾 + 归一化(与流式 completed 完全同口径)。 export { finalizeCodexCitationText, normalizeCodexFileCitations } from './codex/translator.js'; export { PiAgent } from './pi/index.js'; +export { GrokBuildAgent, resolveGrokBinaryFromPath, probeGrokBuildAcp, detectGrokBuildOnPath } from './grok-build/index.js'; export { canReuseCodexHostForCredentialMode, canReuseHostForCredentialMode, diff --git a/packages/maker-core/src/types/common.ts b/packages/maker-core/src/types/common.ts index dfde77069a..84b10e0ec3 100644 --- a/packages/maker-core/src/types/common.ts +++ b/packages/maker-core/src/types/common.ts @@ -5,7 +5,7 @@ * 故意保持兼容以便 desktop adapter 层零代码翻译。 */ -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type WorkspaceKind = 'project' | 'dialogue'; /** diff --git a/packages/maker-core/src/types/events.ts b/packages/maker-core/src/types/events.ts index 3d10bafb07..1201c7d2a9 100644 --- a/packages/maker-core/src/types/events.ts +++ b/packages/maker-core/src/types/events.ts @@ -72,7 +72,7 @@ export interface AgentTaskUsage { } export interface AgentTaskUpdateEventData { - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** Provider task id when available; falls back to the parent tool call id. */ taskId: string; /** The tool_use id that launched or controls this subagent task. */ @@ -144,7 +144,7 @@ export interface AgentEvent { type: AgentEventType; data: unknown; /** 事件来源标识,便于调试 */ - source?: 'claude-code' | 'codex' | 'pi'; + source?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** * Events that finish work owned by a completed turn can still arrive after a * later turn has started (for example, a V1 collab child). These are still diff --git a/packages/maker-scheduler/src/types.ts b/packages/maker-scheduler/src/types.ts index 6402d46d9b..afe02836b8 100644 --- a/packages/maker-scheduler/src/types.ts +++ b/packages/maker-scheduler/src/types.ts @@ -1,5 +1,5 @@ export type ScheduleKind = 'cron'; -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type ScheduleStatus = 'active' | 'paused' | 'expired'; export type ScheduleWorkspaceKind = 'project' | 'dialogue'; export type ScheduleExecutionMode = 'agent' | 'script'; diff --git a/packages/maker-shared/src/agentCapabilities.ts b/packages/maker-shared/src/agentCapabilities.ts index 9ea9e8b374..f3f9e43680 100644 --- a/packages/maker-shared/src/agentCapabilities.ts +++ b/packages/maker-shared/src/agentCapabilities.ts @@ -7,7 +7,7 @@ export interface MobileModelOption { defaultEffort: string | null; supportsFastMode: boolean; /** 区域门控后的新任务默认标记。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'grok-build')[]; } export interface MobileChoiceOption { @@ -306,8 +306,8 @@ function normalizeModelOption(value: unknown): MobileModelOption | null { : {}; const newSessionDefault = Array.isArray(value.newSessionDefault) ? [...new Set(value.newSessionDefault.filter( - (item): item is 'claude-code' | 'codex' | 'pi' => - item === 'claude-code' || item === 'codex' || item === 'pi', + (item): item is 'claude-code' | 'codex' | 'pi' | 'grok-build' => + item === 'claude-code' || item === 'codex' || item === 'pi' || item === 'grok-build', ))] : []; return { diff --git a/packages/maker-shared/src/agentTask.ts b/packages/maker-shared/src/agentTask.ts index 48035708f0..baa4779ebb 100644 --- a/packages/maker-shared/src/agentTask.ts +++ b/packages/maker-shared/src/agentTask.ts @@ -138,7 +138,7 @@ export function normalizeWorkflowProgressEntries( } export interface AgentTaskUpdate { - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'grok-build'; taskId: string; parentToolUseId?: string; status: AgentTaskStatus; @@ -210,7 +210,7 @@ export const PI_SUBAGENT_TOOL_NAME = 'subagent'; */ export function normalizeAgentTaskUpdate( data: unknown, - source?: 'claude-code' | 'codex' | 'pi', + source?: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): AgentTaskUpdate | null { if (!data || typeof data !== 'object') return null; const raw = data as Record; @@ -225,9 +225,9 @@ export function normalizeAgentTaskUpdate( rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'stopped' ? rawStatus : 'running'; - const provider = raw.provider === 'codex' || raw.provider === 'claude-code' || raw.provider === 'pi' + const provider = raw.provider === 'codex' || raw.provider === 'claude-code' || raw.provider === 'pi' || raw.provider === 'grok-build' ? raw.provider - : source === 'codex' || source === 'pi' + : source === 'codex' || source === 'pi' || source === 'grok-build' ? source : 'claude-code'; const usageRaw = raw.usage && typeof raw.usage === 'object' ? raw.usage as Record : null; @@ -307,7 +307,7 @@ export function isSameAgentTaskAlias(left: AgentTaskUpdate, right: AgentTaskUpda export function applyAgentTaskUpdateEvent( prevMap: ReadonlyMap | undefined, data: unknown, - source: 'claude-code' | 'codex' | 'pi' | undefined, + source: 'claude-code' | 'codex' | 'pi' | 'grok-build' | undefined, nowIso: string, ): Map | null { const update = normalizeAgentTaskUpdate(data, source); @@ -362,7 +362,7 @@ export function findAgentTaskUpdate( */ export interface AgentTaskCardModel { status: AgentTaskStatus; - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** Best title, or null when nothing usable was found (caller supplies its own fallback). */ title: string | null; description?: string; @@ -447,7 +447,7 @@ export function buildAgentTaskCardModel(input: { subagentSpawnReceiptName(toolName, toolInput, result) !== undefined || subagentSpawnResultIndicatesRunning(toolName, result), }); - const provider: 'claude-code' | 'codex' | 'pi' = + const provider: 'claude-code' | 'codex' | 'pi' | 'grok-build' = update?.provider ?? (toolName?.startsWith('collab:') ? 'codex' diff --git a/packages/maker-shared/src/conversationSearch.ts b/packages/maker-shared/src/conversationSearch.ts index c1125dfcf0..37b74ddf43 100644 --- a/packages/maker-shared/src/conversationSearch.ts +++ b/packages/maker-shared/src/conversationSearch.ts @@ -11,7 +11,7 @@ import { collapseWorktreeDirForGrouping } from './worktreePaths.js'; * 不包含桌面本机 SQLite、hybrid / 向量搜索、机器切换栏 origin 解析。 */ -export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi'; +export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; export type ConversationSearchWorkspaceKind = 'project' | 'dialogue'; export type ConversationSearchSessionStatus = 'active' | 'archived' | 'deleted'; export type ConversationSearchOrcaRole = 'lead' | 'worker'; diff --git a/packages/maker-shared/src/deviceLinkContract.ts b/packages/maker-shared/src/deviceLinkContract.ts index be4b9e7272..b179f0bd60 100644 --- a/packages/maker-shared/src/deviceLinkContract.ts +++ b/packages/maker-shared/src/deviceLinkContract.ts @@ -243,7 +243,7 @@ export interface MobileCodexRateLimitResetResult { /** 下一条消息发送时才会应用的跨 Agent 切换意图。 */ export interface MobileSessionAgentSwitchIntent { - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort?: string; @@ -253,7 +253,7 @@ export interface MobileSessionAgentSwitchIntent { /** desktop 登记 / 取消跨 Agent 意图后的稳定结果。 */ export interface MobileSessionAgentSwitchResult { switched: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; engineReady: boolean; deferred?: boolean; diff --git a/packages/maker-shared/src/fixtures.ts b/packages/maker-shared/src/fixtures.ts index 32c7b8e8a8..02c5279147 100644 --- a/packages/maker-shared/src/fixtures.ts +++ b/packages/maker-shared/src/fixtures.ts @@ -18,7 +18,7 @@ export interface RemoteControlSessionFixture { workingDir: string | null; workspaceKind: 'project' | 'dialogue'; status: 'active' | 'archived' | 'deleted'; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; model: string; effort: string; permissionMode: string; diff --git a/packages/maker-shared/src/scheduleForm.ts b/packages/maker-shared/src/scheduleForm.ts index 0232b5ebff..4fc7bca780 100644 --- a/packages/maker-shared/src/scheduleForm.ts +++ b/packages/maker-shared/src/scheduleForm.ts @@ -617,7 +617,7 @@ function defaultModelFor(agentKind: RemoteScheduleAgentKind): string { if (agentKind === 'codex') return DEFAULT_CODEX_MODEL; // Pi 模型来自动态 BYOM 供应商目录,没有固定默认 id;留空 → 序列化时省略 → host 解析 // 该 Pi agent 的当前默认模型(用户仍可在自由文本模型框里显式指定)。 - if (agentKind === 'pi') return ''; + if (agentKind === 'pi' || agentKind === 'grok-build') return ''; return DEFAULT_CLAUDE_MODEL; } diff --git a/packages/maker-shared/src/scheduleModel.ts b/packages/maker-shared/src/scheduleModel.ts index ecd6f152e0..3026a3ccc9 100644 --- a/packages/maker-shared/src/scheduleModel.ts +++ b/packages/maker-shared/src/scheduleModel.ts @@ -466,6 +466,7 @@ function formatRunDuration(ms: number, localizer?: PresentationLocalizer): strin function humanizeAgentKind(agentKind: RemoteSchedule['agentKind']): string { if (agentKind === 'codex') return 'Codex'; if (agentKind === 'pi') return 'Pi'; + if (agentKind === 'grok-build') return 'Grok Build'; return 'Claude'; } diff --git a/packages/maker-shared/src/scheduleTypes.ts b/packages/maker-shared/src/scheduleTypes.ts index 3d33a92739..fb46c246d7 100644 --- a/packages/maker-shared/src/scheduleTypes.ts +++ b/packages/maker-shared/src/scheduleTypes.ts @@ -1,5 +1,5 @@ export type RemoteScheduleStatus = 'active' | 'paused' | 'expired'; -export type RemoteScheduleAgentKind = 'claude-code' | 'codex' | 'pi'; +export type RemoteScheduleAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type RemoteScheduleWorkspaceKind = 'project' | 'dialogue'; export type RemoteScheduleRunStatus = 'running' | 'success' | 'failed' | 'aborted' | 'interrupted' | 'skipped'; export type RemoteScheduleExecutionMode = 'agent' | 'script'; diff --git a/packages/maker-shared/src/subagentWorkspace.ts b/packages/maker-shared/src/subagentWorkspace.ts index 3fd144f701..64f9439477 100644 --- a/packages/maker-shared/src/subagentWorkspace.ts +++ b/packages/maker-shared/src/subagentWorkspace.ts @@ -8,7 +8,7 @@ * may add opaque `providerRunIds` without changing the product model. */ -export type SubagentProvider = 'claude-code' | 'codex' | 'pi'; +export type SubagentProvider = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type SubagentRunStatus = 'running' | 'completed' | 'failed' | 'stopped'; diff --git a/packages/model-providers/src/catalog.ts b/packages/model-providers/src/catalog.ts index cb587e0175..0b5bd90d9f 100644 --- a/packages/model-providers/src/catalog.ts +++ b/packages/model-providers/src/catalog.ts @@ -29,7 +29,7 @@ import { isProviderRequestPath } from './provider-url.js'; export { BUNDLED_CATALOG, BUILTIN_PROVIDERS } from './builtin.js'; -const AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; +const AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi', 'grok-build']; const EFFORTS: readonly Effort[] = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; const WIRE_PROTOCOLS = ['anthropic-messages', 'openai-responses', 'openai-chat'] as const; diff --git a/packages/model-providers/src/types.ts b/packages/model-providers/src/types.ts index 5367c97603..5a4a88919f 100644 --- a/packages/model-providers/src/types.ts +++ b/packages/model-providers/src/types.ts @@ -20,7 +20,7 @@ import type { ModelRegistry } from './modelAccessBean.js'; /** 承载模型的 agent runtime —— 与 maker-core AgentKind 对齐。 */ -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** 推理强度档位 —— 与 maker-core Effort 对齐。 */ export type Effort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; diff --git a/packages/model-providers/src/user-provider.ts b/packages/model-providers/src/user-provider.ts index 4c2a5be8c0..062d893588 100644 --- a/packages/model-providers/src/user-provider.ts +++ b/packages/model-providers/src/user-provider.ts @@ -121,7 +121,9 @@ function registryEffortMetadata( modelId: string, agent: AgentKind, ): RegistryEffortMetadata | undefined { - if (agent === "pi" || !registry) return undefined; + // pi 与 grok-build 不在参考价 registry 的 agent 维度里(前者动态 BYOM,后者本机 + // CLI 自带单一模型条目),直接判无 effort 元数据。 + if (agent === "pi" || agent === "grok-build" || !registry) return undefined; // Stage 1 — exact lookup: only the original modelId. const exactMatches = registry.models.filter((entry) =>